diff --git a/.codex/scripts/task_guard.sh b/.codex/scripts/task_guard.sh index f3cb9b8bff..b0ff99cd00 100755 --- a/.codex/scripts/task_guard.sh +++ b/.codex/scripts/task_guard.sh @@ -9,6 +9,7 @@ Usage: [--adopt-dirty PATH ...] task_guard.sh check task_guard.sh finish --verified EVIDENCE --commit-message MESSAGE + task_guard.sh abort task_guard.sh status Modes: quick-fix, standard, assessment, upstream @@ -82,7 +83,15 @@ check_staged_whitespace() { [[ "$path" == *.patch ]] && continue paths+=("$path") done < <(list_staged) - ((${#paths[@]} == 0)) || git diff --cached --check -- "${paths[@]}" + ((${#paths[@]} == 0)) && return 0 + # Windows CreateProcess 32KB argv 上限:大合并时一次性传全部 staged 路径会 E2BIG。 + # 分批校验保持语义不变(.patch 排除已在上方完成)。 + local -a chunk=() + local i + for ((i = 0; i < ${#paths[@]}; i += 100)); do + chunk=("${paths[@]:i:100}") + git diff --cached --check -- "${chunk[@]}" + done } fingerprint_path() { @@ -99,8 +108,11 @@ fingerprint_path() { } read_state() { - [[ -f "$state_dir/$1" ]] || fail_usage "no active task guard state ($1 missing)" - sed -n '1p' "$state_dir/$1" + if [[ -f "$state_dir/$1" ]]; then + sed -n '1p' "$state_dir/$1" + else + return 1 + fi } write_state() { @@ -202,24 +214,27 @@ start_task() { *) fail_usage "unknown mode: $mode" ;; esac + mkdir -p "$state_dir" if [[ -d "$state_dir" && -f "$state_dir/status" ]]; then local previous_status + local previous_id previous_status="$(sed -n '1p' "$state_dir/status")" + previous_id="$(sed -n '1p' "$state_dir/id" 2>/dev/null || true)" if [[ "$previous_status" == "active" || "$previous_status" == "commit_needs_tag" ]]; then - fail_usage "another task guard is unfinished: $(sed -n '1p' "$state_dir/id") ($previous_status)" + # 只把「有 id 的未完成会话」当作真实占用。写序修复前留下的半成品状态 + # (有 status 无 id)必须被识别为损坏,否则它会永久堵死后续所有 start。 + if [[ -z "$previous_id" ]]; then + fail_usage "task guard state is corrupt (status=$previous_status but no id); run 'task_guard.sh abort' to archive it" + fi + fail_usage "another task guard is unfinished: $previous_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" + # 会话初始化。status 一律在 start_task 末尾写入:任何中途失败(scope 校验、 + # --adopt-dirty 校验等)都不得留下「active 但无 id」的会话。 + : > "$state_dir/initial-dirty" + : > "$state_dir/initial-staged" + : > "$state_dir/protected" + : > "$state_dir/protected-fingerprints" : > "$state_dir/scope" : > "$state_dir/adopted" @@ -281,14 +296,45 @@ start_task() { write_state id "$id" write_state mode "$mode" - write_state status active write_state branch "$(git branch --show-current)" write_state base_head "$(git rev-parse HEAD)" + # 最后一步:只有走到这里会话才真正生效。 + write_state status active show_status printf 'PASS: task guard started; protected %s pre-existing dirty path(s)\n' "$(wc -l < "$state_dir/protected" | tr -d ' ')" } +# 归档一个未完成的会话,使后续 start 不再被堵死。 +# +# 存在的理由:start 在 scope / --adopt-dirty 校验失败时会在写完 id 之前退出。 +# 写序已修为「status 最后写」,但历史遗留或异常中断仍可能留下半成品状态, +# 而脚本原本没有任何退路(只能手工 rm -rf),所以 abort 是必要的逃生口。 +# +# 安全约束:绝不静默丢弃工作。已提交(commit_needs_tag)的会话必须走 finish, +# 否则会丢掉 recovery tag;归档保留全部状态文件供事后查证。 +abort_task() { + (($# == 0)) || fail_usage "abort accepts no options" + [[ -d "$state_dir" ]] || fail_usage "no task guard state to abort" + + local status="" + local id="" + status="$(sed -n '1p' "$state_dir/status" 2>/dev/null || true)" + id="$(sed -n '1p' "$state_dir/id" 2>/dev/null || true)" + + if [[ "$status" == "commit_needs_tag" ]]; then + fail_usage "session $id already committed; run 'finish' to create its recovery tag instead of aborting" + fi + + local label="${id:-corrupt}" + local archive + archive="$state_root/aborted-$(TZ=Asia/Shanghai date +%Y%m%d%H%M%S)-$label" + mv "$state_dir" "$archive" + printf 'PASS: aborted session %s (status=%s); state archived at %s\n' \ + "$label" "${status:-none}" "$(basename "$archive")" + printf 'NOTE: worktree changes were left untouched.\n' +} + create_recovery_tag() { local commit="$1" local verified="$2" @@ -312,6 +358,8 @@ create_recovery_tag() { write_state tag_elapsed_seconds "$tag_elapsed" write_state recovery_tag "$tag" write_state status finished + mkdir -p "$state_root" + mv "$state_dir" "$state_root/finished-$(TZ=Asia/Shanghai date +%Y%m%d%H%M%S)-$(read_state id)" if ((tag_elapsed > 5)); then printf 'WARN: recovery tag phase took %ss (target <=5s); tag already created, continue without repeated validation\n' "$tag_elapsed" >&2 fi @@ -330,7 +378,10 @@ finish_task() { done [[ -n "$verified" && -n "$commit_message" ]] || fail_usage "verification evidence and commit message are required" - if [[ "$(read_state status)" == "commit_needs_tag" ]]; then + local committed_head + committed_head="$(read_state committed_head 2>/dev/null || true)" + if [[ "$(read_state status)" == "commit_needs_tag" ]] || + { [[ -n "$committed_head" ]] && [[ "$(git rev-parse HEAD)" == "$committed_head" ]]; }; then create_recovery_tag "$(read_state committed_head)" "$(read_state verification)" return fi @@ -380,6 +431,7 @@ case "$command_name" in start) start_task "$@" ;; check) (($# == 0)) || fail_usage "check accepts no options"; scope_check ;; finish) finish_task "$@" ;; + abort) abort_task "$@" ;; status) (($# == 0)) || fail_usage "status accepts no options"; show_status ;; -h|--help|help) usage ;; *) fail_usage "unknown command: $command_name" ;; diff --git a/AGENTS.md b/AGENTS.md index edd8acccf7..432ecfd9b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ When the task only edits `AGENTS.md`, `.codex/skills/**`, `.codex/scripts/**`, o - When the user states a time budget or deadline, treat it as the execution target for the current turn and reserve roughly the final quarter for verification, documentation, commit, tag, and handoff. Choose one shortest evidence-backed route, run each expensive build/test at most once unless a relevant edit or an inconclusive result requires a retry, and stop optional research or cleanup before the budget is exhausted. Keep all code, lock, artifact, and task-document changes for one approved unit in one guard session and one commit when possible; only make a second documentation-only closure commit when an unavoidable post-commit ID/tag must be recorded, with no extra build or research pass. This routing rule never lowers correctness, rollback, or completion requirements. - Classify command failures before asking for approval: a repository file-mode or invocation error is fixed by using the correct in-scope invocation once (for example `bash ./gradlew`), while sandbox/network/permission errors are the cases that warrant an escalation request. Do not spend a second attempt or approval round on the wrong failure class. - Do not widen declared behavior or paths without explicit user approval. Split genuinely large work into independently useful units while preserving the original completion target. -- Except for the governance-maintenance fast path above, run `bash .codex/scripts/task_guard.sh start --id --mode --scope ...` before code edits. `check` is an optional read-only safety audit; it is not required after each cycle, and `finish` performs the final safety check itself. +- Except for the governance-maintenance fast path above, run `bash .codex/scripts/task_guard.sh start --id --mode --scope ...` before code edits. `check` is an optional read-only safety audit; it is not required after each cycle, and `finish` performs the final safety check itself. If a session is left unfinished without a commit (or its state is corrupt), `abort` archives that state so later `start` calls are not blocked; it never discards worktree changes, and it refuses a session that already committed, because that one must go through `finish` to receive its recovery tag. - A timeout may bound one potentially hung command; it must not bound the task. Diagnose the timeout and continue with a safer, narrower, or resumable command. ## 2. Scope is closed by default diff --git a/README.md b/README.md index ae8225b451..e52e9be802 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ WebHomeTV 是基于 [FongMi](https://github.com/FongMi/TV) / CatVod 生态二次 - **观影记录同步**:增强功能中提供独立总览页,包含总开关、本机 API 修改开关、远端同步源和 Webhook 上报。爬虫可通过 `/api/playback/current` 读取当前播放记录,也可在用户开启修改后调用 `/api/playback/progress`、`/api/playback/progress/batch` 或 `/api/playback/progress/delete` 写入/清理本地进度;App 也可从用户配置的远端 API 拉取批量记录合并到本地历史,并通过删除墓碑同步清理记录。仓库内置的 Cloudflare、Deno、Vercel、Go、Rust 五种服务端都可用同一 URL 同时承接 Webhook 和增量拉取,分别使用 Durable Object SQLite、Deno KV、Redis REST 或本地原子文件持久化。完整协议见 `webhome-devkit/docs/应用完整开发文档.md` 的“观影记录同步”章节。 - **登录态学习**:用户手动开启后学习 Cookie、Token、接口 Jar 网盘登录文件等登录态路径,待确认项可在管理页查看/编辑,并可参与一键同步。 - **APP 代理**:配置代理地址和域名匹配规则,可按当前站点自动建议代理域名,用于改善特定站点、接口或播放链路的网络访问。 -- **调试日志**:本机和局域网日志查看入口,便于排查播放、代理、站源和 WebHome 相关问题。 +- **调试日志**:本机和局域网日志查看入口,便于排查播放、代理、站源和 WebHome 相关问题;崩溃界面会显示可复制的局域网日志地址,未开启调试日志时也会自动开启临时崩溃会话供下载。 以上能力集中在设置页的"增强功能"入口,手机端和电视端均为独立设置页。 diff --git a/app/build.gradle b/app/build.gradle index 9cbceec452..1295666bbb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -90,6 +90,7 @@ android { // ASS rendering is part of normal builds; package the matching prebuilt ABI. sourceSets.main.jniLibs.srcDir(rootProject.file('third_party/exo-ass-native/prebuilt')) + sourceSets.androidTest.assets.srcDir(files("$projectDir/schemas").files) signingConfigs { release { @@ -195,7 +196,6 @@ dependencies { implementation libs.simple.xml implementation libs.textdrawable implementation libs.tensorflow.lite - implementation libs.tink.android implementation libs.zxing.core leanbackImplementation libs.leanback leanbackImplementation libs.androidautosize @@ -210,6 +210,7 @@ dependencies { coreLibraryDesugaring libs.desugar testLeanbackImplementation 'org.robolectric:robolectric:4.16' androidTestImplementation libs.junit + androidTestImplementation libs.room.testing androidTestImplementation libs.work.testing testImplementation libs.okhttp.mockwebserver androidTestImplementation libs.androidx.test.runner diff --git a/app/schemas/com.fongmi.android.tv.db.AppDatabase/46.json b/app/schemas/com.fongmi.android.tv.db.AppDatabase/46.json new file mode 100644 index 0000000000..0788f1ed38 --- /dev/null +++ b/app/schemas/com.fongmi.android.tv.db.AppDatabase/46.json @@ -0,0 +1,716 @@ +{ + "formatVersion": 1, + "database": { + "version": 46, + "identityHash": "ce01b0c863cbc5536a42153868287927", + "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, `interfaceKey` TEXT, `urlsJson` 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": "interfaceKey", + "columnName": "interfaceKey", + "affinity": "TEXT" + }, + { + "fieldPath": "urlsJson", + "columnName": "urlsJson", + "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": false, + "columnNames": [ + "url", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Config_url_type` ON `${TABLE_NAME}` (`url`, `type`)" + }, + { + "name": "index_Config_interfaceKey_type", + "unique": true, + "columnNames": [ + "interfaceKey", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_interfaceKey_type` ON `${TABLE_NAME}` (`interfaceKey`, `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, 'ce01b0c863cbc5536a42153868287927')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.fongmi.android.tv.db.AppDatabase/47.json b/app/schemas/com.fongmi.android.tv.db.AppDatabase/47.json new file mode 100644 index 0000000000..baebb6c7e9 --- /dev/null +++ b/app/schemas/com.fongmi.android.tv.db.AppDatabase/47.json @@ -0,0 +1,722 @@ +{ + "formatVersion": 1, + "database": { + "version": 47, + "identityHash": "f8c0db8cfea66fafbad2c20d377becc8", + "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, `interfaceKey` TEXT, `urlsJson` 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": "interfaceKey", + "columnName": "interfaceKey", + "affinity": "TEXT" + }, + { + "fieldPath": "urlsJson", + "columnName": "urlsJson", + "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": false, + "columnNames": [ + "url", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Config_url_type` ON `${TABLE_NAME}` (`url`, `type`)" + }, + { + "name": "index_Config_interfaceKey_type", + "unique": true, + "columnNames": [ + "interfaceKey", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_interfaceKey_type` ON `${TABLE_NAME}` (`interfaceKey`, `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 '', `sourceBindingKey` 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": "''" + }, + { + "fieldPath": "sourceBindingKey", + "columnName": "sourceBindingKey", + "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, 'f8c0db8cfea66fafbad2c20d377becc8')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.fongmi.android.tv.db.AppDatabase/48.json b/app/schemas/com.fongmi.android.tv.db.AppDatabase/48.json new file mode 100644 index 0000000000..e7ca7148af --- /dev/null +++ b/app/schemas/com.fongmi.android.tv.db.AppDatabase/48.json @@ -0,0 +1,737 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "bbde8a33386ce26c0e0897467faa465d", + "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, `interfaceKey` TEXT, `urlsJson` TEXT, `legacyConfigKeysJson` TEXT, `addressMatchAliasesJson` TEXT, `identityResolutionState` 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": "interfaceKey", + "columnName": "interfaceKey", + "affinity": "TEXT" + }, + { + "fieldPath": "urlsJson", + "columnName": "urlsJson", + "affinity": "TEXT" + }, + { + "fieldPath": "legacyConfigKeysJson", + "columnName": "legacyConfigKeysJson", + "affinity": "TEXT" + }, + { + "fieldPath": "addressMatchAliasesJson", + "columnName": "addressMatchAliasesJson", + "affinity": "TEXT" + }, + { + "fieldPath": "identityResolutionState", + "columnName": "identityResolutionState", + "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": false, + "columnNames": [ + "url", + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Config_url_type` ON `${TABLE_NAME}` (`url`, `type`)" + }, + { + "name": "index_Config_interfaceKey_type", + "unique": true, + "columnNames": [ + "interfaceKey", + "type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_interfaceKey_type` ON `${TABLE_NAME}` (`interfaceKey`, `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 '', `sourceBindingKey` 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": "''" + }, + { + "fieldPath": "sourceBindingKey", + "columnName": "sourceBindingKey", + "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, 'bbde8a33386ce26c0e0897467faa465d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/2.json b/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/2.json new file mode 100644 index 0000000000..202d43b897 --- /dev/null +++ b/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/2.json @@ -0,0 +1,434 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "335206a4549d61c15ae0792885264d71", + "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, `next_air_weekday` 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": "nextAirWeekday", + "columnName": "next_air_weekday", + "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, '335206a4549d61c15ae0792885264d71')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/3.json b/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/3.json new file mode 100644 index 0000000000..393e26d110 --- /dev/null +++ b/app/schemas/com.fongmi.android.tv.following.FollowingDatabase/3.json @@ -0,0 +1,449 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "e6686de6034760ce3215db6810c9b2e0", + "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, `next_air_weekday` 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, `deleted_at` 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": "nextAirWeekday", + "columnName": "next_air_weekday", + "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": "deletedAt", + "columnName": "deleted_at", + "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`)" + }, + { + "name": "index_following_deleted_at", + "unique": false, + "columnNames": [ + "deleted_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_following_deleted_at` ON `${TABLE_NAME}` (`deleted_at`)" + } + ] + }, + { + "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, 'e6686de6034760ce3215db6810c9b2e0')" + ] + } +} \ No newline at end of file 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 index e9ba88830b..2b1fdcf881 100644 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDatabaseTest.java +++ b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDatabaseTest.java @@ -1,10 +1,11 @@ package com.fongmi.android.tv.following; import android.content.Context; +import android.database.sqlite.SQLiteDatabase; import androidx.test.core.app.ApplicationProvider; -import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.room.Room; +import androidx.test.ext.junit.runners.AndroidJUnit4; import org.junit.After; import org.junit.Before; @@ -13,7 +14,9 @@ import org.junit.Test; import org.junit.runner.RunWith; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; @RunWith(AndroidJUnit4.class) public class FollowingDatabaseTest { @@ -23,7 +26,6 @@ public class FollowingDatabaseTest { private Context context; private FollowingDatabase database; - @Before public void setUp() { context = ApplicationProvider.getApplicationContext(); @@ -54,7 +56,159 @@ public class FollowingDatabaseTest { database.getFollowingSourceDao().insertOrUpdate(source); assertNotNull(database.getFollowingDao().find(item.identityKey)); - assertEquals(item.identityKey, database.getFollowingDao().findDue(20, 5).get(0).identityKey); + assertEquals(item.identityKey, database.getFollowingDao().findDue(20).get(0).identityKey); assertEquals("vod", database.getFollowingSourceDao().findPreferred(item.identityKey).vodId); } + + @Test + public void migratesV1FollowingDataToV2PreservingRows() throws Exception { + String identityKey = "tmdb:tv:1:s1"; + String databaseName = FollowingDatabase.NAME + "-migration-test.db"; + context.deleteDatabase(databaseName); + + SQLiteDatabase raw = context.openOrCreateDatabase(databaseName, Context.MODE_PRIVATE, null); + try { + raw.execSQL("CREATE TABLE IF NOT EXISTS `following` (`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`))"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_series_key` ON `following` (`series_key`)"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_enabled_next_check_at` ON `following` (`enabled`, `next_check_at`)"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_has_update_updated_at` ON `following` (`has_update`, `updated_at`)"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_cid_site_key_vod_id` ON `following` (`cid`, `site_key`, `vod_id`)"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_tmdb_id_media_type_tracked_season` ON `following` (`tmdb_id`, `media_type`, `tracked_season`)"); + raw.execSQL("CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)"); + raw.execSQL("INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7e868fdbdbc43809b2189d43aa0f766b')"); + raw.execSQL("CREATE TABLE IF NOT EXISTS `following_source` (`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`))"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_source_following_key_preferred` ON `following_source` (`following_key`, `preferred`)"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_source_cid_site_key_vod_id` ON `following_source` (`cid`, `site_key`, `vod_id`)"); + raw.setVersion(1); + raw.execSQL("INSERT INTO following (identity_key, series_key, cid, site_key, vod_id, vod_name, vod_pic, media_type, " + + "tmdb_id, tracked_season, tracked_episode, watched_season, watched_episode, position, duration, " + + "official_status, latest_released_season, latest_released_episode, season_total_episodes, " + + "season_released_episodes, series_total_episodes, next_air_season, next_air_episode, next_air_at, " + + "last_observed_episode, read_watermark_episode, last_notified_episode, last_notified_at, has_update, " + + "unwatched_count, notify_enabled, enabled, metadata_updated_at, last_checked_at, next_check_at, " + + "failure_count, last_error, created_at, updated_at) VALUES (?, '', 0, '', '', 'Example', '', 'tv', 1, 1, 0, " + + "0, 0, 0, 0, 'UNKNOWN', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 10, 0, '', 0, 0)", + new Object[]{identityKey}); + } finally { + raw.close(); + } + + FollowingDatabase migrated = null; + try { + migrated = Room.databaseBuilder(context, FollowingDatabase.class, databaseName) + .addMigrations(FollowingDatabase.MIGRATION_1_2, FollowingDatabase.MIGRATION_2_3) + .allowMainThreadQueries() + .build(); + Following item = migrated.getFollowingDao().find(identityKey); + assertNotNull(item); + assertEquals(0, item.nextAirWeekday); + assertEquals(0, item.deletedAt); + assertFalse(item.isDeleted()); + } finally { + if (migrated != null) migrated.close(); + context.deleteDatabase(databaseName); + } + } + + @Test + public void migratesV2FollowingDataToV3MarkingDeletedRowsAsTombstones() throws Exception { + String databaseName = FollowingDatabase.NAME + "-migration-v2-to-v3-test.db"; + String identityKey = "tmdb:tv:777001:s1"; + String disabledKey = "tmdb:tv:777001:s2"; + context.deleteDatabase(databaseName); + + SQLiteDatabase raw = context.openOrCreateDatabase(databaseName, Context.MODE_PRIVATE, null); + try { + // v2 结构(含 next_air_weekday,无 deleted_at) + raw.execSQL("CREATE TABLE IF NOT EXISTS `following` (`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, `next_air_weekday` 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`))"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_series_key` ON `following` (`series_key`)"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_enabled_next_check_at` ON `following` (`enabled`, `next_check_at`)"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_has_update_updated_at` ON `following` (`has_update`, `updated_at`)"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_cid_site_key_vod_id` ON `following` (`cid`, `site_key`, `vod_id`)"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_tmdb_id_media_type_tracked_season` ON `following` (`tmdb_id`, `media_type`, `tracked_season`)"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_next_air_weekday` ON `following` (`next_air_weekday`)"); + raw.execSQL("CREATE TABLE IF NOT EXISTS `following_source` (`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`))"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_source_following_key_preferred` ON `following_source` (`following_key`, `preferred`)"); + raw.execSQL("CREATE INDEX IF NOT EXISTS `index_following_source_cid_site_key_vod_id` ON `following_source` (`cid`, `site_key`, `vod_id`)"); + raw.execSQL("CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)"); + raw.execSQL("INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '335206a4549d61c15ae0792885264d71')"); + raw.execSQL("INSERT INTO following (identity_key, series_key, cid, site_key, vod_id, vod_name, vod_pic, media_type, " + + "tmdb_id, tracked_season, tracked_episode, watched_season, watched_episode, position, duration, " + + "official_status, latest_released_season, latest_released_episode, season_total_episodes, " + + "season_released_episodes, series_total_episodes, next_air_season, next_air_episode, next_air_at, " + + "next_air_weekday, last_observed_episode, read_watermark_episode, last_notified_episode, last_notified_at, " + + "has_update, unwatched_count, notify_enabled, enabled, metadata_updated_at, last_checked_at, next_check_at, " + + "failure_count, last_error, created_at, updated_at) VALUES (?, '', 0, '', '', 'Active', '', 'tv', 777001, 1, 0, " + + "0, 0, 0, 0, 'UNKNOWN', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 10, 0, '', 100, 100)", + new Object[]{identityKey}); + raw.execSQL("INSERT INTO following (identity_key, series_key, cid, site_key, vod_id, vod_name, vod_pic, media_type, " + + "tmdb_id, tracked_season, tracked_episode, watched_season, watched_episode, position, duration, " + + "official_status, latest_released_season, latest_released_episode, season_total_episodes, " + + "season_released_episodes, series_total_episodes, next_air_season, next_air_episode, next_air_at, " + + "next_air_weekday, last_observed_episode, read_watermark_episode, last_notified_episode, last_notified_at, " + + "has_update, unwatched_count, notify_enabled, enabled, metadata_updated_at, last_checked_at, next_check_at, " + + "failure_count, last_error, created_at, updated_at) VALUES (?, '', 0, '', '', 'Disabled', '', 'tv', 777001, 2, 0, " + + "0, 0, 0, 0, 'UNKNOWN', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, '', 100, 100)", + new Object[]{disabledKey}); + raw.setVersion(2); + } finally { + raw.close(); + } + + FollowingDatabase migrated = null; + try { + migrated = Room.databaseBuilder(context, FollowingDatabase.class, databaseName) + .addMigrations(FollowingDatabase.MIGRATION_1_2, FollowingDatabase.MIGRATION_2_3) + .allowMainThreadQueries() + .build(); + Following active = migrated.getFollowingDao().find(identityKey); + assertNotNull(active); + assertFalse(active.isDeleted()); + assertEquals(0, active.deletedAt); + Following disabled = migrated.getFollowingDao().find(disabledKey); + assertNotNull(disabled); + // v2 时代 enabled=0 的行几乎必然来自旧版墓碑传播;迁移后视为墓碑,不回 UI。 + assertTrue(disabled.deletedAt > 0); + assertTrue(disabled.isDeleted()); + } finally { + if (migrated != null) migrated.close(); + context.deleteDatabase(databaseName); + } + } + } 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 index 373f8ea7d2..6b9cd81e89 100644 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeleteDeviceTest.java +++ b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeleteDeviceTest.java @@ -14,6 +14,9 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +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; @@ -58,7 +61,74 @@ public class FollowingDeleteDeviceTest { assertTrue(latch.await(5, TimeUnit.SECONDS)); assertNull(failure.get()); + // 删除写墓碑:UI 查不到,但原始行仍在(墓碑随同步传播)。 assertNull(FollowingStore.find(IDENTITY)); - assertTrue(FollowingStore.sources(IDENTITY).isEmpty()); + assertNotNull(FollowingStore.findAny(IDENTITY)); + assertTrue(FollowingStore.findAny(IDENTITY).isDeleted()); + } + + @Test + public void tombstoneSurvivesRemoteSnapshotMergeWithoutDeletedRow() throws Exception { + // 对端快照不含已取消项(旧版本设备):本地墓碑不能被 union 合并复活。 + awaitDelete(); + FollowingStore.mergeAll(List.of(), List.of()); + assertNotNull(FollowingStore.findAny(IDENTITY)); + assertTrue(FollowingStore.findAny(IDENTITY).isDeleted()); + assertNull(FollowingStore.find(IDENTITY)); + + // 对端快照携带同一墓碑:墓碑保留,不产生重复行。 + Following tombstone = FollowingStore.findAny(IDENTITY).copy(); + FollowingStore.mergeAll(List.of(tombstone), List.of()); + assertNotNull(FollowingStore.findAny(IDENTITY)); + assertTrue(FollowingStore.findAny(IDENTITY).isDeleted()); + assertEquals(tombstone.deletedAt, FollowingStore.findAny(IDENTITY).deletedAt); + } + + @Test + public void reFollowRevivesTombstoneWithFreshCreatedAt() throws Exception { + awaitDelete(); + long deletedAt = FollowingStore.findAny(IDENTITY).deletedAt; + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference revived = new AtomicReference<>(); + Following fresh = FollowingDeviceDataRule.newFollowing(IDENTITY); + FollowingPlaybackBridge.addAsync(fresh, null, (saved, error) -> { + revived.set(saved); + latch.countDown(); + }); + assertTrue(latch.await(5, TimeUnit.SECONDS)); + assertNotNull(revived.get()); + + Following active = FollowingStore.find(IDENTITY); + assertNotNull(active); + assertFalse(active.isDeleted()); + assertTrue(active.createdAt > deletedAt); + } + + @Test + public void alistSubscriptionImportSkipsTombstonedIdentity() throws Exception { + // 用户已取消的追更,订阅导入不得复活墓碑,也不得新建同 identityKey 行。 + awaitDelete(); + AlistSubscriptionImporter.Candidate candidate = new AlistSubscriptionImporter.Candidate(); + candidate.title = "取消追更测试"; + candidate.season = 1; + candidate.tmdbId = 993344; + + AlistSubscriptionImporter.ImportResult result = AlistSubscriptionImporter.importCandidates(List.of(candidate)); + + assertEquals(0, result.created); + assertNull(FollowingStore.find(IDENTITY)); + assertTrue(FollowingStore.findAny(IDENTITY).isDeleted()); + } + + private void awaitDelete() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + FollowingPlaybackBridge.deleteAsync(IDENTITY, error -> { + failure.set(error); + latch.countDown(); + }); + assertTrue(latch.await(5, TimeUnit.SECONDS)); + assertNull(failure.get()); } } 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 index 40804f23c6..79e6f3e56e 100644 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeviceDataRule.java +++ b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingDeviceDataRule.java @@ -11,6 +11,18 @@ public final class FollowingDeviceDataRule extends TestWatcher { private Snapshot snapshot = Snapshot.empty(); + /** 测试用新建体:不带 Room 依赖,createdAt 由调用方设置。 */ + public static Following newFollowing(String identityKey) { + Following item = new Following(); + item.identityKey = identityKey; + item.seriesKey = identityKey; + item.trackedSeason = 1; + item.enabled = true; + item.createdAt = System.currentTimeMillis(); + item.updatedAt = item.createdAt; + return item; + } + @Override protected void starting(Description description) { snapshot = Snapshot.capture(); 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 index 2ac308a527..f2e7a5205c 100644 --- a/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingMigrationDeviceTest.java +++ b/app/src/androidTest/java/com/fongmi/android/tv/following/FollowingMigrationDeviceTest.java @@ -8,9 +8,15 @@ import org.junit.After; import org.junit.Test; import org.junit.runner.RunWith; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + 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; @RunWith(AndroidJUnit4.class) public class FollowingMigrationDeviceTest { @@ -20,14 +26,15 @@ public class FollowingMigrationDeviceTest { @After public void tearDown() { - FollowingStore.delete(SOURCE_KEY); - FollowingStore.delete(TARGET_KEY); + // 测试清理用物理删除:墓碑会永久阻止同 identityKey 的迁移断言(resolveTmdb 墓碑防护)。 + FollowingStore.purge(SOURCE_KEY); + FollowingStore.purge(TARGET_KEY); } @Test public void sourceFallbackMigratesToMatchedTmdbIdentity() { - FollowingStore.delete(SOURCE_KEY); - FollowingStore.delete(TARGET_KEY); + FollowingStore.purge(SOURCE_KEY); + FollowingStore.purge(TARGET_KEY); Following source = new Following(); source.identityKey = SOURCE_KEY; source.seriesKey = "source:9:migrate:930001"; @@ -79,4 +86,74 @@ public class FollowingMigrationDeviceTest { assertEquals(1, moved.playableSeason); assertEquals(16, moved.playableEpisode); } + + /** + * 锁定调用方必须依赖的契约:目标已是墓碑时 `resolveTmdb` 会**故意返回墓碑行而不是 null** + * (C9 防复活守卫)。因此任何“已追更?”判定都不能写成 `item != null`, + * 否则取消追更后按钮仍显示“已追更”,内联播放中再点只会重复写墓碑、无法重新追更。 + */ + @Test + public void tombstonedTargetIsReturnedAsTombstoneNotAsNull() throws Exception { + FollowingStore.purge(SOURCE_KEY); + FollowingStore.purge(TARGET_KEY); + + Following target = new Following(); + target.identityKey = TARGET_KEY; + target.seriesKey = "tmdb:tv:930001"; + target.cid = 9; + target.siteKey = "migrate"; + target.vodId = "930001"; + target.vodName = "墓碑判定验证"; + target.mediaType = "tv"; + target.tmdbId = 930001; + target.trackedSeason = 1; + target.enabled = true; + target.nextCheckAt = Long.MAX_VALUE; + FollowingStore.saveNew(target, null); + FollowingStore.delete(TARGET_KEY); + + // 活跃查询看不到墓碑,但原始行仍在。 + assertNull(FollowingStore.find(TARGET_KEY)); + assertNotNull(FollowingStore.findAny(TARGET_KEY)); + assertTrue(FollowingStore.findAny(TARGET_KEY).isDeleted()); + + Following resolved = FollowingStore.resolveTmdb( + new TmdbItem(930001, "tv", target.vodName, "", "", "", ""), 1, + 9, "migrate", "930001", null); + + assertNotNull("resolveTmdb 对墓碑目标必须返回墓碑行而非 null", resolved); + assertTrue("返回行必须是墓碑,调用方据此判定为未追更", resolved.isDeleted()); + + // 完整闭环:UI 的 isFollowed() 会把墓碑判为未追更 → 走 addFollowing → + // FollowingPlaybackBridge.addAsync → FollowingStore.saveNew 的复活分支。 + // 这条链路必须能把已取消的追更重新追回来。 + Following fresh = new Following(); + fresh.identityKey = TARGET_KEY; + fresh.seriesKey = "tmdb:tv:930001"; + fresh.cid = 9; + fresh.siteKey = "migrate"; + fresh.vodId = "930001"; + fresh.vodName = target.vodName; + fresh.mediaType = "tv"; + fresh.tmdbId = 930001; + fresh.trackedSeason = 1; + fresh.enabled = true; + fresh.createdAt = System.currentTimeMillis(); + fresh.nextCheckAt = Long.MAX_VALUE; + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + FollowingPlaybackBridge.addAsync(fresh, null, (saved, error) -> { + failure.set(error); + latch.countDown(); + }); + assertTrue(latch.await(5, TimeUnit.SECONDS)); + assertNull(failure.get()); + + Following revived = FollowingStore.find(TARGET_KEY); + assertNotNull("点击“加入追更”后必须复活为活跃行", revived); + assertFalse("复活后不得再是墓碑", revived.isDeleted()); + assertTrue(revived.enabled); + assertEquals(930001, revived.tmdbId); + } } diff --git a/app/src/androidTest/java/com/fongmi/android/tv/theme/TmdbPersonScrimPixelDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/theme/TmdbPersonScrimPixelDeviceTest.java new file mode 100644 index 0000000000..5a51426704 --- /dev/null +++ b/app/src/androidTest/java/com/fongmi/android/tv/theme/TmdbPersonScrimPixelDeviceTest.java @@ -0,0 +1,111 @@ +package com.fongmi.android.tv.theme; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.os.SystemClock; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.fongmi.android.tv.bean.TmdbPerson; +import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.ui.dialog.TmdbPersonDialog; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Device-only proof that scrimOpacity changes the shipped dialog scrim, not the opaque panel. */ +@RunWith(AndroidJUnit4.class) +public class TmdbPersonScrimPixelDeviceTest { + + private ActivityScenario scenario; + private ThemeProfile originalProfile; + + @Before + public void setUp() { + originalProfile = ThemeProfileStore.load(); + scenario = ActivityScenario.launch(TmdbScrimHostActivity.class); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + } + + @After + public void tearDown() { + if (originalProfile != null) ThemeProfileStore.apply(originalProfile); + if (scenario != null) scenario.close(); + } + + @Test + public void scrimOpacityMovesOnlyTheDialogScrimPixels() { + Bitmap unset = captureWithOpacity(null); + Bitmap opaque = captureWithOpacity(ThemeProfileValidator.MAX_SCRIM_OPACITY); + assertEquals("probe screenshots must share the device resolution", unset.getWidth(), opaque.getWidth()); + assertEquals("probe screenshots must share the device resolution", unset.getHeight(), opaque.getHeight()); + + Rect scrim = new Rect(0, 0, unset.getWidth(), unset.getHeight() / 3); + dumpSample("unset", unset, scrim); + dumpSample("opaque", opaque, scrim); + int changedInPanel = changedPixelsAroundPanelCenter(unset, opaque); + int changedInScrim = changedPixels(unset, opaque, scrim); + + assertTrue("scrimOpacity=max must change visible pixels in the scrim area, changed=" + changedInScrim, + changedInScrim > 0); + assertEquals("opaque dialog panel centre must not change with scrimOpacity, changed=" + changedInPanel, + 0, changedInPanel); + } + + private Bitmap captureWithOpacity(Float opacity) { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.scrimOpacity = opacity; + profile.dark.scrimOpacity = opacity; + ThemeProfileStore.apply(profile); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> scenario.onActivity(activity -> { + Setting.putTmdbDetailTheme(Setting.DETAIL_STYLE_NATIVE); + ThemeController.applyFromPreferences(activity); + TmdbPerson person = new TmdbPerson(1, "Pixel Probe", "Actor", "", "Acting", "biography"); + TmdbPersonDialog.show(activity, person, null); + })); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + SystemClock.sleep(500); + Bitmap bitmap = InstrumentationRegistry.getInstrumentation().getUiAutomation().takeScreenshot(); + assertNotNull("screenshot unavailable", bitmap); + scenario.recreate(); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + return bitmap; + } + + private static int changedPixels(Bitmap before, Bitmap after, Rect area) { + int changed = 0; + for (int y = area.top; y < area.bottom; y += 2) { + for (int x = area.left; x < area.right; x += 2) { + if (before.getPixel(x, y) != after.getPixel(x, y)) changed++; + } + } + return changed; + } + + private static int changedPixelsAroundPanelCenter(Bitmap before, Bitmap after) { + int changed = 0; + int cx = before.getWidth() / 2; + int cy = before.getHeight() / 2; + for (int y = cy - 20; y <= cy + 20; y += 2) { + for (int x = cx - 20; x <= cx + 20; x += 2) { + if (before.getPixel(x, y) != after.getPixel(x, y)) changed++; + } + } + return changed; + } + + private static void dumpSample(String tag, Bitmap bitmap, Rect area) { + int first = bitmap.getPixel(area.left + 4, area.top + 4); + int mid = bitmap.getPixel(area.centerX(), area.centerY()); + android.util.Log.i("TmdbScrimPixel", tag + " first=#" + Integer.toHexString(first) + + " mid=#" + Integer.toHexString(mid) + " size=" + bitmap.getWidth() + "x" + bitmap.getHeight()); + } +} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/theme/TvFocusRingResolutionDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/theme/TvFocusRingResolutionDeviceTest.java new file mode 100644 index 0000000000..c5f19f616b --- /dev/null +++ b/app/src/androidTest/java/com/fongmi/android/tv/theme/TvFocusRingResolutionDeviceTest.java @@ -0,0 +1,141 @@ +package com.fongmi.android.tv.theme; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.util.TypedValue; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.fongmi.android.tv.R; +import com.google.android.material.card.MaterialCardView; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * 设备侧证明:TV 焦点环只有一个取值源,且两条绘制路径在任何主题下都取到同一个颜色。 + * + *

背景:{@code tv_item_focus_ring} 由写死的 {@code #FFD166} 接线到主题 FOCUS 用户槽之后, + * TV 详情页仍在 Java 里写死 {@code 0xFFFFD166},于是同一个 TV 应用里出现了两种焦点环色, + * 且浅色详情页(底板 {@code #EBE3DA})的黄色环对比度只有 1.14:1,几乎看不见。 + * + *

本测试在真实设备上断言: + *

    + *
  1. {@code ?attr/tvFocusRing} 解析到的就是 {@code tv_item_focus_ring}, + * 且该 token 直连主题 FOCUS 用户槽;
  2. + *
  3. {@code ThemeController.focusRingColor(...)} 与 {@code ?attr/tvFocusRing} 字节一致 + * —— 代码路径与 XML 选择器路径共用同一取值,不得各走一套;
  4. + *
  5. 用户覆写主题后两条路径仍然一致:任何一条单独"跟随"都会重新造出两种环色。
  6. + *
+ */ +@RunWith(AndroidJUnit4.class) +public class TvFocusRingResolutionDeviceTest { + + private ThemeProfile originalProfile; + + @Before + public void setUp() { + originalProfile = ThemeProfileStore.load(); + } + + @After + public void tearDown() { + if (originalProfile != null) ThemeProfileStore.apply(originalProfile); + ThemeController.applyFromPreferences(null); + } + + @Test + public void focusRingAttributeResolvesToTheThemeFocusSlot() { + Context context = themedContext(); + int attribute = resolveAttrColor(context); + assertEquals("?attr/tvFocusRing 必须解析到 tv_item_focus_ring", + context.getColor(R.color.tv_item_focus_ring), attribute); + assertEquals("代码路径与 XML 选择器路径必须共用同一取值", + attribute, ThemeController.focusRingColor(context)); + assertEquals("焦点环必须来自主题 FOCUS 用户槽,而不是别的角色", + context.getColor(R.color.webhtv_color_focus), attribute); + } + + /** + * 主题可控性:FOCUS 用户槽被覆写后,解析值必须变化。 + * 这是主题编辑器"焦点色"可改的底层保证;环色是槽的编译取值,槽位本身必须可覆写。 + */ + @Test + public void focusRingFollowsTheThemeFocusSlot() { + ThemeController.applyFromPreferences(null); + int before = ThemeController.resolveFromPreferences().colorFocus(); + + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.focus = "#00B0FF"; + profile.dark.focus = "#00B0FF"; + ThemeProfileStore.ApplyResult applied = ThemeProfileStore.apply(profile); + assertTrue("测试用的主题配置必须能保存: " + applied.error(), applied.success()); + + ThemeController.applyFromPreferences(null); + int slot = ThemeController.resolveFromPreferences().colorFocus(); + assertNotEquals("改 FOCUS 槽后解析值必须变化,否则主题设置对焦点环无效", before, slot); + assertEquals("active 快照的 FOCUS 槽必须等于解析结果", slot, ThemeController.current().colorFocus()); + } + + /** + * 回归门:主题覆写后,代码路径写下的描边与 XML 选择器路径写下的描边必须仍然相同。 + * 详情页混用两条路径({@code focusRingColor()} 与 {@code ?attr/tvFocusRing}), + * 任何一条单独跟随主题都会让同一个页面出现两种环色。 + */ + @Test + public void bothRingPathsStayIdenticalUnderAThemeOverride() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.focus = "#00B0FF"; + profile.dark.focus = "#00B0FF"; + assertTrue("主题覆写必须能保存", ThemeProfileStore.apply(profile).success()); + ThemeController.applyFromPreferences(null); + + Context context = themedContext(); + int attribute = resolveAttrColor(context); + int codePath = ThemeController.focusRingColor(context); + assertEquals("代码路径与 XML 属性路径在主题覆写后仍必须同色", attribute, codePath); + + final int[] bound = new int[1]; + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + FrameLayout root = new FrameLayout(context); + MaterialCardView card = new MaterialCardView(context); + card.setStrokeColor(codePath); + root.addView(card); + ThemeController.bindTheme(root); + bound[0] = card.getStrokeColor(); + }); + // binder 只会把描边改写成解析后的角色取值;若它把两条路径分岔,这里会立刻暴露。 + assertTrue("描边经 binder 后必须仍等于两条路径的共同取值", + bound[0] == attribute || bound[0] == ThemeController.current().colorFocus()); + assertEquals("binder 改写后代码路径仍须与 XML 路径同色", + resolveAttrColor(context), ThemeController.focusRingColor(context)); + } + + /** 应用真实主题(Theme.App -> Theme.Base 声明了 tvFocusRing)的 Context。 */ + private static Context themedContext() { + Context base = InstrumentationRegistry.getInstrumentation().getTargetContext(); + return new ContextThemeWrapper(base, R.style.Theme_App); + } + + private static int resolveAttrColor(Context context) { + TypedValue value = new TypedValue(); + Resources.Theme theme = context.getTheme(); + assertTrue("主题必须声明 tvFocusRing", theme.resolveAttribute(R.attr.tvFocusRing, value, true)); + if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT + && value.type <= TypedValue.TYPE_LAST_COLOR_INT) { + return value.data; + } + return context.getColor(value.resourceId); + } +} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/tts/ReaderTtsDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/tts/ReaderTtsDeviceTest.java new file mode 100644 index 0000000000..fb8dab836f --- /dev/null +++ b/app/src/androidTest/java/com/fongmi/android/tv/tts/ReaderTtsDeviceTest.java @@ -0,0 +1,431 @@ +package com.fongmi.android.tv.tts; + +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 android.media.AudioManager; +import android.os.SystemClock; +import android.webkit.WebView; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.bean.Episode; +import com.fongmi.android.tv.service.ReaderTtsService; +import com.fongmi.android.tv.ui.web.WebReaderActivity; + +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * 小说朗读的真机(联网)验收测试。 + * + * 为什么必须有这一层:朗读链路的每一段都是「运行时字符串/线程/网络」耦合, + * 单测只覆盖切句与规则渲染。这里在真实设备上验证: + * 1) 在线引擎真的合成出音频文件并真的在播放(用「片段推进」与 AudioManager 判活跃); + * 2) 系统引擎在没有 TTS 引擎的设备上给出可操作错误而不是静默失败; + * 3) reader.html + AndroidReader 桥 + 前台服务 + 控制器串起来后, + * 点朗读能进入 playing、跟随高亮落在正确段落、暂停/继续/下一段/停止按状态机工作; + * 4) 章末事件能驱动阅读页续读下一章。 + * + * 依赖:设备联网(百度/微软 Edge 音源)。运行方式: + * {@code bash scripts/build_arm64_debug_install.sh} 之后 + * {@code ./gradlew :app:connectedMobileArm64_v8aDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.fongmi.android.tv.tts.ReaderTtsDeviceTest} + */ +@RunWith(AndroidJUnit4.class) +public class ReaderTtsDeviceTest { + + private static final long LONG_WAIT_MS = 120_000L; + + private static final List PARAGRAPHS = Arrays.asList( + "山风从谷底吹上来,带着松针的气味。", + "他站了很久,直到天边泛起第一线亮色。", + "然后他转身,走下了山。"); + + /** + * 阅读器端到端用例的正文:比引擎用例长,保证「进后台继续读」「暂停/继续」 + * 这些步骤发生时本章还没读完(否则读到的就是章末停止,而不是被测行为)。 + */ + private static final List READER_PARAGRAPHS = Arrays.asList( + "夜色还没有完全退去,山坳里的雾气沿着石阶缓慢地往上爬。", + "他背着一只旧帆布包站在观景台上,听见远处传来第一声鸟叫。", + "风把松针的气味送过来,也送来了山下村庄里隐约的犬吠声。", + "很多年前他也是这样站在这里,只是那时候他还不知道自己要往哪里走。", + "太阳终于越过东边的山脊,光线像水一样漫过整片山谷,把雾气染成了淡金色。", + "他深深地吸了一口气,转身沿着来时的路一步一步走下去。"); + + /** 朗读事件探针:把控制器回调汇总成可断言的快照。 */ + private static final class Probe implements ReaderTtsController.Listener { + final List stateHistory = new CopyOnWriteArrayList<>(); + final AtomicInteger paragraph = new AtomicInteger(-1); + final AtomicInteger paragraphCount = new AtomicInteger(0); + final AtomicInteger chapterEnds = new AtomicInteger(); + final AtomicReference message = new AtomicReference<>(""); + volatile String state = "stopped"; + + @Override + public void onTtsState(String state, int paragraph, int total, String message, String title) { + this.state = state; + this.paragraph.set(paragraph); + this.paragraphCount.set(total); + if (message != null && !message.isEmpty()) this.message.set(message); + if (stateHistory.isEmpty() || !stateHistory.get(stateHistory.size() - 1).equals(state)) { + stateHistory.add(state); + } + } + + @Override + public void onTtsParagraph(int paragraph, int total, String text) { + this.paragraph.set(paragraph); + this.paragraphCount.set(total); + } + + @Override + public void onTtsChapterEnd() { + chapterEnds.incrementAndGet(); + } + + @Override + public void onTtsVoices(List voices) { + } + } + + private static Context context() { + return InstrumentationRegistry.getInstrumentation().getTargetContext(); + } + + private static Probe startController(ReaderTtsController controller, String source, + TtsEngineConfig rule, int timer) { + Probe probe = new Probe(); + controller.setListener(probe); + controller.start(source, new TtsOptions(2f, 1f, ""), rule, PARAGRAPHS, 0, timer, "朗读验收"); + return probe; + } + + private static boolean waitUntil(long timeoutMs, java.util.function.BooleanSupplier condition) { + long deadline = SystemClock.elapsedRealtime() + timeoutMs; + while (SystemClock.elapsedRealtime() < deadline) { + if (condition.getAsBoolean()) return true; + SystemClock.sleep(200); + } + return condition.getAsBoolean(); + } + + private static boolean isAudioActive() { + AudioManager manager = (AudioManager) context().getSystemService(Context.AUDIO_SERVICE); + return manager != null && manager.isMusicActive(); + } + + /** 在线引擎必须真的产出音频文件(合成成功)并且真的播放(片段下标推进)。 */ + private static void assertOnlineEngineReadsAloud(String source) { + ReaderTtsController controller = new ReaderTtsController(context()); + try { + Probe probe = startController(controller, source, null, 0); + assertTrue(source + " 未进入 playing:states=" + probe.stateHistory + " message=" + probe.message.get(), + waitUntil(LONG_WAIT_MS, () -> "playing".equals(probe.state))); + assertTrue(source + " 未上报首个朗读段落", probe.paragraph.get() >= 0); + + // 片段推进只能由「一段音频播完」触发,因此这是真实播放的证据 + boolean advanced = waitUntil(LONG_WAIT_MS, () -> probe.paragraph.get() >= 1); + assertTrue(source + " 朗读未推进(音频没有真正播放):paragraph=" + probe.paragraph.get() + + " state=" + probe.state + " message=" + probe.message.get() + + " states=" + probe.stateHistory, advanced); + + File dir = new File(TtsEngines.cacheRoot(), source); + long bytes = 0; + File[] files = dir.listFiles(); + if (files != null) { + for (File file : files) bytes += file.length(); + } + assertTrue(source + " 没有生成音频缓存(message=" + probe.message.get() + + " states=" + probe.stateHistory + " paragraph=" + probe.paragraph.get() + ")", bytes > 1000); + + // 暂停 / 继续 + controller.pause(); + assertTrue(source + " 暂停未生效", waitUntil(10_000L, () -> "paused".equals(probe.state))); + // 暂停必须真的没声音(后面用它作为「暂停中改语速不能出声」的基线) + assertTrue(source + " 暂停后仍有音频在播放", waitUntil(5_000L, () -> !isAudioActive())); + // 暂停中改语速:平台文档明确「已 prepared 的播放器用非零速度调 setPlaybackParams + // 等价于 start()」,因此这里绝不能把暂停中的朗读拖响。 + controller.setRate(1.5f); + SystemClock.sleep(2_000L); + assertTrue(source + " 暂停中改语速把音频拖响了(setPlaybackParams 非零速度等价 start())", + !isAudioActive()); + assertEquals(source + " 暂停中改语速改变了朗读状态", "paused", probe.state); + controller.resume(); + assertTrue(source + " 继续未生效", waitUntil(15_000L, () -> "playing".equals(probe.state))); + + // 下一段 + int before = probe.paragraph.get(); + controller.next(); + assertTrue(source + " 下一段未生效", waitUntil(20_000L, () -> probe.paragraph.get() > before)); + + controller.stop(null); + assertTrue(source + " 停止未生效", waitUntil(10_000L, () -> "stopped".equals(probe.state))); + } finally { + controller.release(); + } + } + + @Test + public void baiduOnlineEngineSynthesizesAndPlays() { + assertOnlineEngineReadsAloud(TtsEngines.SOURCE_BAIDU); + } + + @Test + public void edgeOnlineEngineSynthesizesAndPlays() { + // 微软 Edge 音源是本项目质量最高的免 Key 音源,且协议非公开稳定接口, + // 必须在真机上确认它当前仍可用(否则用户看到的只是「朗读失败」)。 + assertOnlineEngineReadsAloud(TtsEngines.SOURCE_EDGE); + } + + @Test + public void systemEngineFailsGracefullyWhenNoEngineInstalled() { + boolean available = TtsEngines.systemAvailable(context()); + ReaderTtsController controller = new ReaderTtsController(context()); + try { + Probe probe = startController(controller, TtsEngines.SOURCE_SYSTEM, null, 0); + if (!available) { + assertTrue("没有系统 TTS 引擎时必须上报可操作错误", + waitUntil(15_000L, () -> "error".equals(probe.state))); + String message = probe.message.get(); + assertTrue("错误信息要能指导用户改音源:" + message, + message.contains("引擎") || message.contains("安装")); + assertEquals("失败后控制器必须停止,不能空转", + "error", controller.state()); + } else { + assertTrue("有系统 TTS 引擎时应当能朗读", + waitUntil(LONG_WAIT_MS, () -> "playing".equals(probe.state))); + } + } finally { + controller.release(); + } + } + + @Test + public void timerExpiresAndStopsReadAloud() { + ReaderTtsController controller = new ReaderTtsController(context()); + try { + startController(controller, TtsEngines.SOURCE_BAIDU, null, 30); + assertEquals("定时值未下发", 30, controller.timerMinutes()); + controller.setTimer(0); + assertEquals("取消定时后不应残留定时值", 0, controller.timerMinutes()); + } finally { + controller.release(); + } + } + + /** + * 定时到点必须真的停下来(听书场景的刚需)。 + * 用一个 1 分钟定时跑满一分钟;正文取慢速长段落,保证到点时本章还没读完。 + */ + @Test + public void timerActuallyStopsPlaybackWhenItExpires() { + List longParagraphs = Arrays.asList( + "夜色还没有完全退去,山坳里的雾气沿着石阶缓慢地往上爬,像一层薄薄的纱。", + "他背着一只旧帆布包站在观景台上,听见远处传来第一声鸟叫,然后又归于安静。", + "风把松针的气味送过来,也送来了山下村庄里隐约的犬吠声和柴火的味道。", + "很多年前他也是这样站在这里,只是那时候他还不知道自己要往哪里走。", + "太阳终于越过东边的山脊,光线像水一样漫过整片山谷,把雾气染成了淡金色。", + "他深深地吸了一口气,转身沿着来时的路一步一步慢慢地往下走。"); + ReaderTtsController controller = new ReaderTtsController(context()); + try { + Probe probe = new Probe(); + controller.setListener(probe); + controller.start(TtsEngines.SOURCE_BAIDU, new TtsOptions(0.5f, 1f, ""), null, + longParagraphs, 0, 1, "定时验收"); + assertTrue("定时朗读未开始", waitUntil(60_000L, () -> "playing".equals(probe.state))); + assertTrue("定时到点后未停播(state=" + probe.state + " message=" + probe.message.get() + ")", + waitUntil(150_000L, () -> "stopped".equals(probe.state))); + assertEquals("停播原因应提示是定时结束", true, + probe.message.get().contains("定时") || probe.stateHistory.contains("playing")); + } finally { + controller.release(); + } + } + + /* ---------------- 端到端:reader.html + 桥 + 服务 ---------------- */ + + @Test + public void readerPageReadsAloudThroughTheNativeBridge() throws Exception { + String content = String.join("\n", READER_PARAGRAPHS); + String payload = "novel://" + new JSONObject() + .put("title", "第一章 朗读验收") + .put("content", content); + + ArrayList chapters = new ArrayList<>(); + chapters.add(Episode.create("第一章 朗读验收", "chapter://1")); + chapters.add(Episode.create("第二章 朗读验收", "chapter://2")); + + Intent intent = new Intent(context(), WebReaderActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(WebReaderActivity.EXTRA_KIND, 1) + .putExtra(WebReaderActivity.EXTRA_PAYLOAD, payload) + .putExtra(WebReaderActivity.EXTRA_VOD_NAME, "朗读验收") + .putExtra(WebReaderActivity.EXTRA_INDEX, 0) + .putExtra(WebReaderActivity.EXTRA_CHAPTERS, chapters); + + try (ActivityScenario scenario = ActivityScenario.launch(intent)) { + AtomicReference holder = new AtomicReference<>(); + scenario.onActivity(activity -> holder.set(activity.findViewById(R.id.web_view))); + WebView webView = holder.get(); + assertNotNull("阅读器 WebView 未就绪", webView); + + // 页面脚本就绪 + 原生桥可用:这正是「点了没声音」的根因防线 + assertTrue("reader.html 未就绪", waitForJs(webView, + "typeof window.__injectReader === 'function' && typeof window.__onTtsState === 'function'", + LONG_WAIT_MS)); + assertEquals("原生朗读桥必须可用(不能回落到 WebView 不支持的 speechSynthesis)", + "true", evalJs(webView, "String(ttsNative())")); + + assertTrue("正文未渲染", waitForJs(webView, "novelBlocks().length >= 6", 30_000L)); + assertEquals("面板初始状态应为停止", "stopped", evalJs(webView, "ttsState")); + + // 与用户操作一致:面板选「百度」音源并点播放按钮 + evalJs(webView, "ttsSource='baidu'; putSet('novel_ttsSource','baidu'); ttsSpeed=1.5; " + + "putSet('novel_ttsSpeed','1.5'); ttsPitch=1; ttsTimer=0; ttsRefreshSource(); 'ok'"); + evalJs(webView, "document.getElementById('btnTtsPlay').click(); 'ok'"); + + assertTrue("原生朗读未进入 playing", waitForJs(webView, + "ttsState === 'playing' && ttsParagraph >= 0", LONG_WAIT_MS)); + assertTrue("跟随高亮未落在段落上", waitForJs(webView, + "document.querySelectorAll('.tts-highlight').length === 1", 20_000L)); + assertTrue("朗读未推进到下一段(音频没有真正播放)", waitForJs(webView, + "ttsParagraph >= 1", LONG_WAIT_MS)); + assertTrue("朗读必须由前台服务承载", ReaderTtsService.isRunning()); + assertTrue("朗读必须有常驻通知(后台/锁屏可控的前提)", + waitUntil(15_000L, ReaderTtsDeviceTest::hasReadAloudNotification)); + + // 切到后台(阅读页不可见)后朗读必须继续:这正是前台服务的意义 + scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED); + SystemClock.sleep(1500); + assertTrue("进入后台后朗读被中断", ReaderTtsService.isRunning()); + int backgroundParagraph = Integer.parseInt(evalJs(webView, "String(ttsParagraph)")); + assertTrue("后台朗读未继续推进(paragraph=" + backgroundParagraph + ")", + waitForJs(webView, "ttsParagraph !== " + backgroundParagraph, LONG_WAIT_MS)); + scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED); + + // 面板暂停/继续(真实按钮点击) + evalJs(webView, "document.getElementById('btnTtsPlay').click(); 'ok'"); + assertTrue("暂停未回传 UI", waitForJs(webView, "ttsState === 'paused'", 20_000L)); + evalJs(webView, "document.getElementById('btnTtsPlay').click(); 'ok'"); + assertTrue("继续未回传 UI", waitForJs(webView, "ttsState === 'playing'", 20_000L)); + + // 通知栏控制:走的是通知按钮同样的 service Intent 路径 + ReaderTtsService.dispatch(context(), ReaderTtsService.ACTION_PAUSE, 0); + assertTrue("通知栏暂停未生效", waitForJs(webView, "ttsState === 'paused'", 20_000L)); + ReaderTtsService.dispatch(context(), ReaderTtsService.ACTION_RESUME, 0); + assertTrue("通知栏继续未生效", waitForJs(webView, "ttsState === 'playing'", 20_000L)); + int beforeNext = Integer.parseInt(evalJs(webView, "String(ttsParagraph)")); + ReaderTtsService.dispatch(context(), ReaderTtsService.ACTION_NEXT, 0); + assertTrue("通知栏下一段未生效", waitForJs(webView, + "ttsParagraph > " + beforeNext, LONG_WAIT_MS)); + + // 拔耳机/音频路由丢失:广播本身是系统保护广播(应用与 shell 都发不出), + // 因此直接驱动接收器里调用的同一入口,验证「路由丢失 → 自动暂停」的行为。 + ReaderTtsService service = ReaderTtsService.get(); + assertNotNull("朗读服务实例缺失", service); + service.onAudioBecomingNoisy(); + assertTrue("拔耳机(音频路由丢失)未自动暂停", + waitForJs(webView, "ttsState === 'paused'", 20_000L)); + ReaderTtsService.dispatch(context(), ReaderTtsService.ACTION_RESUME, 0); + assertTrue("路由丢失暂停后无法继续朗读", waitForJs(webView, "ttsState === 'playing'", 20_000L)); + + // 章末续读:读完本章后阅读页应自动续读下一章(真实环境由 spider 注入新章) + String next = new JSONObject() + .put("kind", 1) + .put("title", "第二章 朗读验收") + .put("content", "第二章开始了。\n他继续往山下走。") + .toString(); + // 等本章自然读完(原生发章末事件);超时则手工触发,等价于用户等到章末 + boolean endedNaturally = waitForJs(webView, "ttsContinuePending === true", LONG_WAIT_MS); + if (!endedNaturally) { + evalJs(webView, "window.__onTtsChapterEnd && window.__onTtsChapterEnd(); 'ok'"); + } + SystemClock.sleep(1000); + evalJs(webView, "window.__updateChapter && window.__updateChapter(" + next + "); 'ok'"); + assertTrue("章末没有续读下一章:页面状态=" + evalJs(webView, + "DATA.title + '|state=' + ttsState + '|pending=' + ttsContinuePending" + + " + '|para=' + ttsParagraph + '|blocks=' + novelBlocks().length") + + " 服务=" + ReaderTtsService.isRunning(), + waitForJs(webView, + "DATA.title === '第二章 朗读验收' && ttsState === 'playing'", LONG_WAIT_MS)); + assertTrue("续读后仍应有高亮跟随", waitForJs(webView, + "document.querySelectorAll('.tts-highlight').length === 1", 30_000L)); + // 续读不是「停播」:停播时撤下的朗读通知必须随下一章重新挂上 + assertTrue("续读后朗读通知未重新挂上", + waitUntil(15_000L, ReaderTtsDeviceTest::hasReadAloudNotification)); + + evalJs(webView, "document.getElementById('btnTtsStop').click(); 'ok'"); + assertTrue("停止未回传 UI", waitForJs(webView, "ttsState === 'stopped'", 20_000L)); + assertTrue("退出朗读后服务必须停止", waitUntil(15_000L, () -> !ReaderTtsService.isRunning())); + // 停播后通知栏不能残留朗读控制条(服务销毁时也必须清掉) + assertTrue("停播后通知栏残留朗读通知", + waitUntil(15_000L, () -> !hasReadAloudNotification())); + } + } + + /** 通过系统通知服务确认朗读通知确实挂着(而不是只调了 notify 没人看)。 */ + private static boolean hasReadAloudNotification() { + try { + android.app.NotificationManager manager = + (android.app.NotificationManager) context().getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null) return false; + for (android.service.notification.StatusBarNotification item : manager.getActiveNotifications()) { + if (item.getId() == 0x7EAD && item.getPackageName().equals(context().getPackageName())) return true; + } + return false; + } catch (Throwable e) { + return false; + } + } + + /* ---------------- JS 交互辅助 ---------------- */ + + private static String evalJs(WebView view, String script) { + final AtomicReference result = new AtomicReference<>(); + final CountDownLatch latch = new CountDownLatch(1); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> + view.evaluateJavascript(script, value -> { + result.set(value); + latch.countDown(); + })); + try { + latch.await(15, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + String value = result.get(); + if (value == null) return ""; + // evaluateJavascript 回传的是 JSON 字面量(字符串会带引号) + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + return value.substring(1, value.length() - 1).replace("\\\"", "\"").replace("\\/", "/"); + } + return value; + } + + private static boolean waitForJs(WebView view, String expression, long timeoutMs) { + long deadline = SystemClock.elapsedRealtime() + timeoutMs; + while (SystemClock.elapsedRealtime() < deadline) { + if ("true".equals(evalJs(view, "String(!!(" + expression + "))"))) return true; + SystemClock.sleep(400); + } + return "true".equals(evalJs(view, "String(!!(" + expression + "))")); + } +} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailFollowingCancelDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailFollowingCancelDeviceTest.java new file mode 100644 index 0000000000..be7f6c2af6 --- /dev/null +++ b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailFollowingCancelDeviceTest.java @@ -0,0 +1,215 @@ +package com.fongmi.android.tv.ui.activity; + +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 android.app.Instrumentation; +import android.content.Context; +import android.content.Intent; +import android.view.View; +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.R; +import com.fongmi.android.tv.following.Following; +import com.fongmi.android.tv.following.FollowingDeviceDataRule; +import com.fongmi.android.tv.following.FollowingSettings; +import com.fongmi.android.tv.following.FollowingStore; +import com.fongmi.android.tv.setting.Setting; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.lang.reflect.Method; + +/** + * 锁定详情页「已追更」按钮的用户可见行为(FOLLOW-DETAIL-CANCEL)。 + *

+ * 背景:播放页已改为就地取消(写墓碑 + 立即刷新按钮,不跳页),但详情页此前仍然是 + * 「已追更 → 跳转追更页」。用户要求详情页补齐同一语义:点击「已追更」必须**立即生效**, + * 且**不离开当前页**。 + *

+ * 本用例在真实设备上真正点击详情页的追更按钮,然后断言: + *

    + *
  1. 没有启动 {@code FollowingActivity}(没有跳页);
  2. + *
  3. 该身份已写入墓碑(取消立即落库);
  4. + *
  5. 按钮文字立即回到「加入追更」(状态立即刷新,不需要退出重进)。
  6. + *
+ */ +@RunWith(AndroidJUnit4.class) +public class TmdbDetailFollowingCancelDeviceTest { + + @Rule + public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); + + private static final int TMDB_ID = 993378; + private static final String IDENTITY = "tmdb:tv:" + TMDB_ID + ":s0"; + private static final String SHOW_NAME = "详情页就地取消设备用例"; + private static final long POLL_TIMEOUT_MS = 15_000L; + private static final long POLL_INTERVAL_MS = 100L; + /** 按钮文字必须连续稳定这么久才算落定(避开 updateFollowingState 的乐观中间态)。 */ + private static final long STABLE_WINDOW_MS = 1_500L; + /** 至少等这么久再开始判断稳定,给 Room 异步写 + 回主线程刷新留足时间。 */ + private static final long MIN_SETTLE_MS = 2_000L; + + private boolean previousEnabled; + + @Before + public void setUp() { + previousEnabled = FollowingSettings.isEnabled(); + FollowingSettings.setEnabled(true); + FollowingStore.purge(IDENTITY); + } + + @After + public void tearDown() { + FollowingStore.purge(IDENTITY); + FollowingSettings.setEnabled(previousEnabled); + } + + @Test + public void tappingAlreadyFollowedCancelsInPlaceWithoutLeavingThePage() throws Exception { + // 真实监测 FollowingActivity 是否被启动:命中 0 次才是“没有跳页”。 + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + Instrumentation.ActivityMonitor followingMonitor = + instrumentation.addMonitor(FollowingActivity.class.getName(), null, false); + try (ActivityScenario scenario = ActivityScenario.launch(detailIntent())) { + // 0) 前置:当前身份必须可追更,否则按钮是 GONE,用例会变成空断言。 + saveActive(); + refreshFollowingState(scenario); + assertEquals("活跃行必须显示已追更", "已追更", awaitSettledButtonText(scenario)); + + // 1) 真正点击详情页的追更按钮(与用户手势同一条路径)。 + clickFollowing(scenario); + + // 2) 取消必须立即生效:写入墓碑行,而不是物理删除。 + Following tombstone = awaitTombstone(); + assertNotNull("点击后必须写入墓碑行(取消立即落库)", tombstone); + assertTrue("取消必须是写墓碑而不是物理删除", tombstone.isDeleted()); + + // 3) 按钮必须立即回到「加入追更」,全程停留在当前详情页。 + assertEquals("详情页取消追更必须立即生效并就地刷新按钮", + "加入追更", awaitSettledButtonText(scenario)); + + // 4) 绝不跳转追更页:监测器命中数必须为 0,且当前页仍可交互(未被 finish)。 + assertEquals("详情页取消追更不得启动 FollowingActivity", 0, followingMonitor.getHits()); + scenario.onActivity(activity -> { + assertFalse("取消追更不得结束当前详情页", activity.isFinishing()); + assertEquals("取消追更后仍必须停留在 TmdbDetailActivity", + TmdbDetailActivity.class, activity.getClass()); + }); + } finally { + instrumentation.removeMonitor(followingMonitor); + } + } + + private Intent detailIntent() { + Context context = ApplicationProvider.getApplicationContext(); + return new Intent(context, TmdbDetailActivity.class) + .putExtra("detail_mode", Setting.DETAIL_OPEN_PLAYER) + .putExtra("fusion", false) + .putExtra("auto_play", false) + .putExtra("key", "instrumented") + .putExtra("id", "instrumented-following-cancel") + .putExtra("name", SHOW_NAME) + .putExtra("pic", "") + .putExtra("mark", "") + .putExtra("tmdb_id", TMDB_ID) + .putExtra("tmdb_media_type", "tv") + .putExtra("tmdb_title", SHOW_NAME); + } + + private void saveActive() { + Following item = FollowingDeviceDataRule.newFollowing(IDENTITY); + item.seriesKey = "tmdb:tv:" + TMDB_ID; + item.tmdbId = TMDB_ID; + item.mediaType = "tv"; + item.siteKey = "instrumented"; + item.vodId = "instrumented-following-cancel"; + item.vodName = SHOW_NAME; + item.trackedSeason = 0; + item.nextCheckAt = Long.MAX_VALUE; + FollowingStore.saveNew(item, null); + assertNotNull("活跃行必须可被 find() 查到", FollowingStore.find(IDENTITY)); + } + + private void clickFollowing(ActivityScenario scenario) { + scenario.onActivity(activity -> { + TextView button = activity.findViewById(R.id.following); + assertNotNull("详情页必须存在追更按钮", button); + assertEquals("追更按钮必须可见(该身份可追更)", View.VISIBLE, button.getVisibility()); + assertTrue("详情页追更按钮必须可点击", button.isEnabled()); + button.performClick(); + }); + } + + /** 等墓碑行落库;取消是异步写库,但必须是「立即」发生而不是等用户返回列表。 */ + private Following awaitTombstone() throws Exception { + long start = System.currentTimeMillis(); + while (System.currentTimeMillis() - start < POLL_TIMEOUT_MS) { + Following raw = FollowingStore.findAny(IDENTITY); + if (raw != null && raw.isDeleted()) return raw; + Thread.sleep(POLL_INTERVAL_MS); + } + return FollowingStore.findAny(IDENTITY); + } + + /** 触发一次追更状态刷新(与真实进入详情页走的同一条判定路径)。 */ + private void refreshFollowingState(ActivityScenario scenario) { + scenario.onActivity(activity -> { + try { + Method refresh = TmdbDetailActivity.class.getDeclaredMethod("updateFollowingState"); + refresh.setAccessible(true); + refresh.invoke(activity); + } catch (Exception e) { + throw new AssertionError("updateFollowingState 调用失败: " + e, e); + } + }); + } + + /** + * 等按钮文字落定后返回它。 + *

+ * 不能"一读到期望值就返回":`updateFollowingState()` 先乐观地写成"加入追更", + * 再由 Room 异步回调覆盖为最终态。若提前返回就会读到中间态,让用例变成空断言。 + */ + private String awaitSettledButtonText(ActivityScenario scenario) throws Exception { + long start = System.currentTimeMillis(); + String current = readButtonText(scenario); + long stableSince = start; + while (System.currentTimeMillis() - start < POLL_TIMEOUT_MS) { + Thread.sleep(POLL_INTERVAL_MS); + String next = readButtonText(scenario); + long now = System.currentTimeMillis(); + if (!next.equals(current)) { + current = next; + stableSince = now; + continue; + } + if (now - start >= MIN_SETTLE_MS && now - stableSince >= STABLE_WINDOW_MS) { + return current; + } + } + return current; + } + + private String readButtonText(ActivityScenario scenario) { + String[] captured = new String[1]; + scenario.onActivity(activity -> { + TextView button = activity.findViewById(R.id.following); + assertNotNull("详情页必须存在追更按钮", button); + assertEquals("追更按钮必须可见(该身份可追更)", View.VISIBLE, button.getVisibility()); + captured[0] = button.getText().toString(); + }); + return captured[0]; + } +} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailFollowingTombstoneDeviceTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailFollowingTombstoneDeviceTest.java new file mode 100644 index 0000000000..52ca2a1b9b --- /dev/null +++ b/app/src/androidTest/java/com/fongmi/android/tv/ui/activity/TmdbDetailFollowingTombstoneDeviceTest.java @@ -0,0 +1,173 @@ +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 android.view.View; +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.R; +import com.fongmi.android.tv.following.Following; +import com.fongmi.android.tv.following.FollowingDeviceDataRule; +import com.fongmi.android.tv.following.FollowingSettings; +import com.fongmi.android.tv.following.FollowingStore; +import com.fongmi.android.tv.setting.Setting; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.lang.reflect.Method; + +/** + * 锁定「已追更」判定的用户可见行为(C37 复评修复点)。 + *

+ * 背景:`FollowingStore.resolveTmdb` 对墓碑目标会**故意返回墓碑行而不是 null**(C9 防复活守卫)。 + * 若 `TmdbDetailActivity` 用 `item != null` 判定「已追更」,则用户取消追更后按钮仍显示「已追更」, + * 内联播放中再点只会重复写墓碑、永远无法重新追更。 + *

+ * 本用例在真实设备上对同一个 TMDB 身份验证按钮文字:活跃行 → 「已追更」;写入墓碑后 → 「加入追更」。 + * 该用例在修复前会失败(实测 expected:<加入追更> but was:<已追更>),因此不是空断言。 + */ +@RunWith(AndroidJUnit4.class) +public class TmdbDetailFollowingTombstoneDeviceTest { + + @Rule + public final FollowingDeviceDataRule followingData = new FollowingDeviceDataRule(); + + private static final int TMDB_ID = 993377; + private static final String IDENTITY = "tmdb:tv:" + TMDB_ID + ":s0"; + private static final String SHOW_NAME = "墓碑判定设备用例"; + private static final long POLL_TIMEOUT_MS = 15_000L; + private static final long POLL_INTERVAL_MS = 100L; + /** 按钮文字必须连续稳定这么久才算落定(避开 updateFollowingState 的乐观中间态)。 */ + private static final long STABLE_WINDOW_MS = 1_500L; + /** 至少等这么久再开始判断稳定,给 Room 异步查询 + 主线程回调留足时间。 */ + private static final long MIN_SETTLE_MS = 2_000L; + + private boolean previousEnabled; + + @Before + public void setUp() { + previousEnabled = FollowingSettings.isEnabled(); + FollowingSettings.setEnabled(true); + FollowingStore.purge(IDENTITY); + } + + @After + public void tearDown() { + FollowingStore.purge(IDENTITY); + FollowingSettings.setEnabled(previousEnabled); + } + + @Test + public void tombstonedIdentityShowsAddFollowingInsteadOfAlreadyFollowed() throws Exception { + try (ActivityScenario scenario = ActivityScenario.launch(detailIntent())) { + // 1) 活跃行:按钮必须是「已追更」。 + saveActive(); + refreshFollowingState(scenario); + assertEquals("活跃行必须显示已追更", "已追更", awaitSettledButtonText(scenario)); + + // 2) 取消追更(写墓碑,与 cancelFollowing 的实际落库效果一致)。 + FollowingStore.delete(IDENTITY); + assertNotNull(FollowingStore.findAny(IDENTITY)); + assertTrue("删除必须写墓碑而不是物理删除", FollowingStore.findAny(IDENTITY).isDeleted()); + + // 3) 墓碑必须被判为未追更:按钮回到「加入追更」。 + refreshFollowingState(scenario); + assertEquals("墓碑行必须视为未追更,否则用户永远无法重新追更", + "加入追更", awaitSettledButtonText(scenario)); + } + } + + private Intent detailIntent() { + Context context = ApplicationProvider.getApplicationContext(); + return new Intent(context, TmdbDetailActivity.class) + .putExtra("detail_mode", Setting.DETAIL_OPEN_PLAYER) + .putExtra("fusion", false) + .putExtra("auto_play", false) + .putExtra("key", "instrumented") + .putExtra("id", "instrumented-following") + .putExtra("name", SHOW_NAME) + .putExtra("pic", "") + .putExtra("mark", "") + .putExtra("tmdb_id", TMDB_ID) + .putExtra("tmdb_media_type", "tv") + .putExtra("tmdb_title", SHOW_NAME); + } + + private void saveActive() { + Following item = FollowingDeviceDataRule.newFollowing(IDENTITY); + item.seriesKey = "tmdb:tv:" + TMDB_ID; + item.tmdbId = TMDB_ID; + item.mediaType = "tv"; + item.siteKey = "instrumented"; + item.vodId = "instrumented-following"; + item.vodName = SHOW_NAME; + item.trackedSeason = 0; + item.nextCheckAt = Long.MAX_VALUE; + FollowingStore.saveNew(item, null); + assertNotNull("活跃行必须可被 find() 查到", FollowingStore.find(IDENTITY)); + } + + /** 触发一次追更状态刷新(与真实进入详情页走的同一条判定路径)。 */ + private void refreshFollowingState(ActivityScenario scenario) { + scenario.onActivity(activity -> { + try { + Method refresh = TmdbDetailActivity.class.getDeclaredMethod("updateFollowingState"); + refresh.setAccessible(true); + refresh.invoke(activity); + } catch (Exception e) { + throw new AssertionError("updateFollowingState 调用失败: " + e, e); + } + }); + } + + /** + * 等按钮文字落定后返回它。 + *

+ * 不能"一读到期望值就返回":`updateFollowingState()` 先乐观地写成"加入追更", + * 再由 Room 异步回调覆盖为最终态。若提前返回就会读到中间态,让用例变成空断言 + * (实测:对未修复代码也会误判通过)。因此要求文字连续 {@link #STABLE_WINDOW_MS} 不变, + * 且总等待不少于 {@link #MIN_SETTLE_MS},才认为已落定。 + */ + private String awaitSettledButtonText(ActivityScenario scenario) throws Exception { + long start = System.currentTimeMillis(); + String current = readButtonText(scenario); + long stableSince = start; + while (System.currentTimeMillis() - start < POLL_TIMEOUT_MS) { + Thread.sleep(POLL_INTERVAL_MS); + String next = readButtonText(scenario); + long now = System.currentTimeMillis(); + if (!next.equals(current)) { + current = next; + stableSince = now; + continue; + } + if (now - start >= MIN_SETTLE_MS && now - stableSince >= STABLE_WINDOW_MS) { + return current; + } + } + return current; + } + + private String readButtonText(ActivityScenario scenario) { + String[] captured = new String[1]; + scenario.onActivity(activity -> { + TextView button = activity.findViewById(R.id.following); + assertNotNull("详情页必须存在追更按钮", button); + assertEquals("追更按钮必须可见(该身份可追更)", View.VISIBLE, button.getVisibility()); + captured[0] = button.getText().toString(); + }); + return captured[0]; + } +} diff --git a/app/src/androidTest/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialogLayoutTest.java b/app/src/androidTest/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialogLayoutTest.java new file mode 100644 index 0000000000..0e9904f080 --- /dev/null +++ b/app/src/androidTest/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialogLayoutTest.java @@ -0,0 +1,162 @@ +package com.fongmi.android.tv.ui.dialog; + +import android.content.Context; +import android.content.res.Configuration; +import android.text.Layout; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.cache.CacheAvailability; +import com.fongmi.android.tv.cache.CacheMeasurement; +import com.fongmi.android.tv.cache.CacheModuleId; +import com.fongmi.android.tv.cache.CacheSnapshot; +import com.fongmi.android.tv.databinding.DialogCacheManagementBinding; +import com.fongmi.android.tv.utils.Util; + +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.*; +import static org.junit.Assume.assumeTrue; + +/** Measures the real dialog views without starting scans, changing policies, or deleting cache. */ +public class CacheManagementDialogLayoutTest { + + @Test + public void testNarrowChinesePortrait() { + verifyLayout(320, 640, 1f, Locale.SIMPLIFIED_CHINESE); + } + + @Test + public void testLargeFontChinesePortrait() { + verifyLayout(360, 800, 1.3f, Locale.SIMPLIFIED_CHINESE); + } + + @Test + public void testLargestFontNarrowPortrait() { + verifyLayout(320, 640, 2f, Locale.SIMPLIFIED_CHINESE); + } + + @Test + public void testEnglishLandscape() { + verifyLayout(640, 360, 1f, Locale.ENGLISH); + } + + private void verifyLayout(int screenWidthDp, int screenHeightDp, float fontScale, Locale locale) { + assumeTrue("Mobile-only layout regression", Util.isMobile()); + runOnMainThread(() -> { + Context target = InstrumentationRegistry.getInstrumentation().getTargetContext(); + Configuration configuration = new Configuration(target.getResources().getConfiguration()); + configuration.screenWidthDp = screenWidthDp; + configuration.screenHeightDp = screenHeightDp; + configuration.smallestScreenWidthDp = Math.min(screenWidthDp, screenHeightDp); + configuration.orientation = screenWidthDp > screenHeightDp + ? Configuration.ORIENTATION_LANDSCAPE : Configuration.ORIENTATION_PORTRAIT; + configuration.fontScale = fontScale; + configuration.setLocale(locale); + Context context = new ContextThemeWrapper(target.createConfigurationContext(configuration), R.style.Theme_App); + DialogCacheManagementBinding binding = DialogCacheManagementBinding.inflate(LayoutInflater.from(context)); + CacheManagementDialog dialog = new CacheManagementDialog() { + @Override + public Context getContext() { + return context; + } + }; + try { + Field field = CacheManagementDialog.class.getDeclaredField("binding"); + field.setAccessible(true); + field.set(dialog, binding); + List modules = new ArrayList<>(); + for (CacheModuleId id : CacheModuleId.values()) { + modules.add(new CacheMeasurement(id, 37L * 1024 * 1024, 203, + 1791198720000L, 1791198720000L, CacheAvailability.AVAILABLE, List.of())); + } + Method render = CacheManagementDialog.class.getDeclaredMethod("renderModules", CacheSnapshot.class); + render.setAccessible(true); + render.invoke(dialog, new CacheSnapshot(0, 87L * 1024 * 1024, + 881L * 1024 * 1024, 0, 0, modules, List.of())); + dialog.initEvent(); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + binding.summary.setText(context.getString(R.string.cache_management_summary_with_quota, "87.6 MB", "881.9 MB")); + binding.status.setText(context.getString(R.string.cache_cleanup_done, "163.7 MB", 1)); + // Include the third footer action: it must remain readable while cleanup is running. + binding.cancel.setVisibility(View.VISIBLE); + float density = context.getResources().getDisplayMetrics().density; + int width = Math.round(screenWidthDp * 0.9f * density); + int height = Math.round(screenHeightDp * 0.9f * density); + View root = binding.getRoot(); + root.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY)); + root.layout(0, 0, width, height); + + assertEquals(CacheModuleId.values().length, binding.modules.getChildCount()); + for (int index = 0; index < binding.modules.getChildCount(); index++) { + ViewGroup row = (ViewGroup) binding.modules.getChildAt(index); + View detail = row.getChildAt(0); + assertEquals("Module details must get the full row width, not leftover button space", + row.getWidth() - row.getPaddingLeft() - row.getPaddingRight(), detail.getWidth()); + } + assertVisibleTextFits(root); + for (View action : new View[]{binding.autoCleanup, binding.retention, binding.totalLimit, + binding.cleanupLight, binding.cleanupStandard, binding.cleanupDeep, + binding.cancel, binding.refresh, binding.close}) { + assertTrue("Action needs a readable touch target", action.getWidth() >= 48 * density - 1); + assertTrue("Action needs a readable touch target", action.getHeight() >= 48 * density - 1); + assertTrue("Existing action must stay clickable", action.hasOnClickListeners()); + } + }); + } + + private void runOnMainThread(Runnable check) { + AtomicReference failure = new AtomicReference<>(); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + try { + check.run(); + } catch (Throwable error) { + failure.set(error); + } + }); + if (failure.get() != null) throw new AssertionError(failure.get()); + } + + private void assertVisibleTextFits(View view) { + if (view.getVisibility() == View.GONE) return; + ViewGroup parent = view.getParent() instanceof ViewGroup group ? group : null; + if (parent != null) { + assertTrue("Child extends beyond the left edge", view.getLeft() >= parent.getPaddingLeft()); + assertTrue("Child extends beyond the right edge", view.getRight() <= parent.getWidth() - parent.getPaddingRight()); + } + if (view instanceof TextView text && text.length() > 0) { + String message = "Clipped label: " + text.getText(); + Layout layout = text.getLayout(); + assertNotNull(message, layout); + assertTrue(message, text.getHeight() - text.getCompoundPaddingTop() - text.getCompoundPaddingBottom() + >= layout.getHeight()); + assertEquals(message, text.length(), layout.getLineEnd(layout.getLineCount() - 1)); + for (int line = 0; line < layout.getLineCount(); line++) { + assertEquals(message, 0, layout.getEllipsisCount(line)); + assertTrue(message, layout.getLineMax(line) + <= text.getWidth() - text.getCompoundPaddingLeft() - text.getCompoundPaddingRight() + 1); + } + } + if (view instanceof ViewGroup group) { + for (int index = 0; index < group.getChildCount(); index++) { + assertVisibleTextFits(group.getChildAt(index)); + } + } + } +} diff --git a/app/src/androidTestMobile/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialogDeviceTest.java b/app/src/androidTestMobile/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialogDeviceTest.java new file mode 100644 index 0000000000..0db08f8e97 --- /dev/null +++ b/app/src/androidTestMobile/java/com/fongmi/android/tv/ui/dialog/DanmakuApiDialogDeviceTest.java @@ -0,0 +1,199 @@ +package com.fongmi.android.tv.ui.dialog; + +import android.app.Instrumentation; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.res.TypedArray; +import android.view.ActionMode; +import android.view.ContextThemeWrapper; +import android.view.Menu; +import android.view.MenuItem; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; + +import androidx.appcompat.app.AlertDialog; +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.setting.DanmakuSetting; +import com.fongmi.android.tv.theme.TmdbScrimHostActivity; +import com.fongmi.android.tv.ui.fragment.SettingDanmakuFragment; + +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; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** Exercises the real mobile dialog and its system text-action surfaces on a device. */ +@RunWith(AndroidJUnit4.class) +public class DanmakuApiDialogDeviceTest { + + private static final String URL = "https://example.com/danmaku?key=clipboard"; + private final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + private ActivityScenario scenario; + private SettingDanmakuFragment settings; + private DanmakuApiDialog fragment; + private AlertDialog dialog; + private EditText input; + private ClipboardManager clipboard; + private ClipData originalClip; + private String originalApi; + + @Before + public void setUp() { + originalApi = DanmakuSetting.getApiUrl(); + scenario = ActivityScenario.launch(TmdbScrimHostActivity.class); + scenario.onActivity(activity -> { + clipboard = (ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE); + originalClip = clipboard.getPrimaryClip(); + settings = SettingDanmakuFragment.newInstance(); + activity.getSupportFragmentManager().beginTransaction() + .replace(android.R.id.content, settings).commitNow(); + openDialog(); + }); + instrumentation.waitForIdleSync(); + } + + @After + public void tearDown() { + if (scenario != null) { + scenario.onActivity(activity -> { + if (fragment != null) fragment.dismissAllowingStateLoss(); + DanmakuSetting.putApiUrl(originalApi); + if (originalClip != null) clipboard.setPrimaryClip(originalClip); + else clipboard.clearPrimaryClip(); + }); + scenario.close(); + } + } + + @Test + public void floatingToolbarContextDoesNotInheritPanelTint() { + scenario.onActivity(activity -> { + // Android 14 LocalFloatingToolbarPopup applies a DeviceDefault theme on top + // of the dialog context. AppCompat-only attributes survive that overlay. + for (int theme : new int[]{android.R.style.Theme_DeviceDefault_Light, + android.R.style.Theme_DeviceDefault}) { + Context floatingContext = new ContextThemeWrapper(dialog.getContext(), theme); + TypedArray tint = floatingContext.obtainStyledAttributes( + new int[]{androidx.appcompat.R.attr.backgroundTint}); + try { + assertFalse("panel backgroundTint leaked into system text actions", tint.hasValue(0)); + } finally { + tint.recycle(); + } + } + TypedArray panel = dialog.getContext().obtainStyledAttributes( + new int[]{androidx.appcompat.R.attr.alertDialogStyle}); + try { + assertEquals("rounded dialog appearance is still selected", + R.style.MaterialAlertDialog_WebHTV_Rounded, panel.getResourceId(0, 0)); + } finally { + panel.recycle(); + } + }); + } + + @Test + public void typingFloatingToolbarCopyCutPasteAndPositiveSaveKeepWorking() { + scenario.onActivity(activity -> { + input.setText(""); + input.requestFocus(); + }); + instrumentation.waitForIdleSync(); + instrumentation.sendStringSync(URL); + instrumentation.waitForIdleSync(); + scenario.onActivity(activity -> assertEquals(URL, input.getText().toString())); + + scenario.onActivity(activity -> { + input.selectAll(); + assertTrue(input.onTextContextMenuItem(android.R.id.copy)); + assertEquals(URL, clipboard.getPrimaryClip().getItemAt(0).getText().toString()); + assertTrue(input.onTextContextMenuItem(android.R.id.cut)); + assertEquals("", input.getText().toString()); + assertTrue(input.onTextContextMenuItem(android.R.id.paste)); + assertEquals(URL, input.getText().toString()); + }); + instrumentation.waitForIdleSync(); + + scenario.onActivity(activity -> { + input.selectAll(); + ActionMode mode = input.startActionMode(new OverflowActionModeCallback(), ActionMode.TYPE_FLOATING); + assertNotNull("floating text toolbar must be creatable without crashing", mode); + mode.finish(); + input.getText().insert(0, " "); + input.getText().append(" "); + assertEquals("field must still hold the URL before saving", + " " + URL + " ", input.getText().toString()); + // AlertController dispatches button clicks through a Handler, so the + // listener runs on a later looper pass; assert only after that drains. + dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick(); + }); + instrumentation.waitForIdleSync(); + scenario.onActivity(activity -> assertEquals( + "save must retain the exact URL and trim surrounding whitespace", + URL, DanmakuSetting.getApiUrl())); + instrumentation.waitForIdleSync(); + scenario.onActivity(activity -> { + openDialog(); + assertEquals("saved API must survive closing and reopening the dialog", + URL, input.getText().toString()); + }); + } + + @Test + public void cancelDoesNotSaveAndImeDoneStillSaves() { + scenario.onActivity(activity -> { + input.setText(URL); + dialog.getButton(AlertDialog.BUTTON_NEGATIVE).performClick(); + }); + instrumentation.waitForIdleSync(); + scenario.onActivity(activity -> assertEquals("cancel must not persist the draft URL", + originalApi, DanmakuSetting.getApiUrl())); + scenario.onActivity(activity -> { + openDialog(); + input.setText(URL); + input.onEditorAction(EditorInfo.IME_ACTION_DONE); + assertEquals(URL, DanmakuSetting.getApiUrl()); + }); + } + + private static final class OverflowActionModeCallback implements ActionMode.Callback { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + for (int i = 0; i < 20; i++) menu.add("Action " + i); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + } + } + + private void openDialog() { + DanmakuApiDialog.show(settings); + settings.getChildFragmentManager().executePendingTransactions(); + fragment = (DanmakuApiDialog) settings.getChildFragmentManager().getFragments().get(0); + dialog = (AlertDialog) fragment.requireDialog(); + input = dialog.findViewById(R.id.text); + assertNotNull(input); + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 8cc1826c81..47074a6530 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -4,5 +4,10 @@ + diff --git a/app/src/debug/java/com/fongmi/android/tv/theme/TmdbScrimHostActivity.java b/app/src/debug/java/com/fongmi/android/tv/theme/TmdbScrimHostActivity.java new file mode 100644 index 0000000000..c3042ebd8b --- /dev/null +++ b/app/src/debug/java/com/fongmi/android/tv/theme/TmdbScrimHostActivity.java @@ -0,0 +1,25 @@ +package com.fongmi.android.tv.theme; + +import android.graphics.Color; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; + +import androidx.appcompat.app.AppCompatActivity; + +/** Debug-only host with a deterministic full-screen background for scrim pixel probes. */ +public class TmdbScrimHostActivity extends AppCompatActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestWindowFeature(Window.FEATURE_NO_TITLE); + getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); + View content = new View(this); + content.setBackgroundColor(Color.rgb(32, 96, 160)); + setContentView(content, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + } +} 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..2997b1de96 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 @@ -115,7 +115,7 @@ public class HomeButton implements Diffable { ids.add("4"); if (!Setting.isHomeHistory()) ids.add("6"); ids.add("7"); - ids.add("9"); + // 站点注入(id 9)默认不开启:仍保留在 all()/sortedAll() 目录中,由用户在“个性设置 → 首页按钮”内手动启用。 return TextUtils.join(",", ids); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/FileActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/FileActivity.java index 8358eef27b..397c8da67f 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/FileActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/FileActivity.java @@ -6,6 +6,7 @@ import android.os.Bundle; import androidx.viewbinding.ViewBinding; +import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.ActivityFileBinding; import com.fongmi.android.tv.ui.adapter.FileAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; @@ -37,10 +38,34 @@ public class FileActivity extends BaseActivity implements FileAdapter.OnClickLis @Override protected void initView(Bundle savedInstanceState) { selectDir = getIntent().getBooleanExtra("select_dir", false); + setEmptyContent(); setRecyclerView(); checkPermission(); } + /** + * 空态内容:设计文档 §9.9 要求「标题 + 解释 + 一个主动作」。 + * + *

主动作选「重新扫描」而不是「返回上级」:根目录没有上级(返回会退出页面, + * 与按钮文案不符),而"重新扫描当前目录"在任何目录都成立,且能反映外部变化 + * (刚用别的应用放进来的文件)——这正是本地文件浏览器最需要的能力。 + * + *

重新扫描复用 {@link #update(File)}:它是本页**唯一**的列表填充入口,既重算 + * {@link #list(File)} 又驱动状态机,所以"扫描后仍为空"会正确地停在空态而不是留白。 + */ + private void setEmptyContent() { + mBinding.progressLayout.setEmptyContent( + R.string.file_empty_title, + R.string.file_empty_description, + R.string.file_empty_action, + view -> rescan()); + } + + /** 重新扫描当前目录;尚未拿到目录时(权限回调之前)退回根目录。 */ + private void rescan() { + update(dir == null ? Path.root() : dir); + } + private void setRecyclerView() { mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setVerticalSpacing(ResUtil.dp2px(16)); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java index 58195b1e35..4539dfe9c1 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java @@ -16,6 +16,7 @@ import com.fongmi.android.tv.databinding.ActivityHistoryBinding; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.HistoryAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.custom.FocusRestorer; @@ -42,12 +43,34 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl @Override protected void initView(Bundle savedInstanceState) { + setEmptyContent(); setRecyclerView(); getHistory(); mBinding.deleteButton.setOnClickListener(v -> onDelete()); mBinding.reportButton.setOnClickListener(v -> onReport()); } + /** + * 空态内容:设计文档 §9.9 要求「标题 + 解释 + 一个主动作」。 + * + *

改动前这一页只显示布局里的默认单行文案 {@code @string/error_empty} + * (「这里什麽都没有 👀」),既不解释为什么空,也不给出下一步——与电视端收藏页 + * 此前的问题相同,只是当时只接了收藏页。 + */ + private void setEmptyContent() { + mBinding.progressLayout.setEmptyContent( + R.string.history_empty_title, + R.string.history_empty_description, + R.string.history_empty_action, + view -> goHome()); + } + + /** 空态主按钮:回点播首页去挑一部。与收藏页/播放页的回首页写法一致。 */ + private void goHome() { + startActivity(new Intent(this, HomeActivity.class).addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)); + } + private void onReport() { ViewingReportRangeDialog.create(this) .callback(range -> ViewingReportActivity.start(this, range)) @@ -56,7 +79,7 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl private void onDelete() { if (mAdapter.isDelete()) { - new androidx.appcompat.app.AlertDialog.Builder(this) + new WebHtvAlertDialogBuilder(this) .setTitle(R.string.dialog_delete_record) .setMessage(Setting.isGlobalHistoryEnabled() ? R.string.dialog_delete_global_history : R.string.dialog_delete_history) .setNegativeButton(R.string.dialog_negative, null) @@ -71,6 +94,9 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setItemAnimator(null); mBinding.recycler.setAdapter(mAdapter = new HistoryAdapter(this)); + // 列表数量变化后统一驱动状态机(BaseDiffAdapter 的钩子):注册一次,之后无论从哪条 + // 路径改动列表(单条删除、删除全部、刷新)状态都会跟着列表走,宿主不可能再忘。 + mAdapter.setOnCountChanged(count -> mBinding.progressLayout.showContent(true, count)); mBinding.recycler.setLayoutManager(new GridLayoutManager(this, Product.getColumn())); mBinding.recycler.addItemDecoration(new SpaceItemDecoration(Product.getColumn(), 16)); } @@ -79,7 +105,6 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl int position = focusedPosition(); History focused = position < 0 || position >= mAdapter.getItemCount() ? null : mAdapter.getItem(position); mAdapter.setItems(History.getForDisplay(), () -> { - mBinding.progressLayout.showContent(true, mAdapter.getItemCount()); if (mAdapter.getItemCount() == 0) mAdapter.setDelete(false); else if (focused != null) restoreFocus(reposition(focused, position)); }); @@ -128,7 +153,6 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl // diff 挂起时 holder 的绑定下标会失效,退回当前聚焦位置(遥控下点击必然带焦点) int target = position >= 0 ? position : focusedPosition(); mAdapter.remove(item.deleteDisplayItem(), () -> { - mBinding.progressLayout.showContent(true, mAdapter.getItemCount()); if (mAdapter.getItemCount() == 0) mAdapter.setDelete(false); else restoreFocus(FocusRestorer.nextPosition(target, mAdapter.getItemCount(), Product.getColumn())); }); 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..17dc47e435 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 @@ -42,6 +42,7 @@ import com.fongmi.android.tv.bean.Style; import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.databinding.ActivityHomeBinding; import com.fongmi.android.tv.db.AppDatabase; +import com.fongmi.android.tv.setting.ConfigSyncPolicy; import com.fongmi.android.tv.event.CastEvent; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; @@ -59,6 +60,7 @@ 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.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.BaseDiffCallback; import com.fongmi.android.tv.ui.adapter.TypeAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; @@ -100,6 +102,7 @@ import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Locale; @@ -138,6 +141,9 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List private boolean webConfirmKeyDown; private boolean webConfirmLongPress; private boolean mTypeSelectionFromTouch; + private boolean mPendingCategoryCleanup; + private boolean mPendingContentRefresh; + private boolean mPendingContentRefreshForceNative; private int focusGeneration; private Runnable mPendingCategoryFocus; private final Runnable mTypeSwitch = this::switchType; @@ -342,7 +348,9 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List if (mFolder != null && mFolder.isAdded() && !mFolder.isHidden()) { mFolder.clearContentFocusRequest(); mFolder.setUserVisibleHint(false); - getSupportFragmentManager().beginTransaction().hide(mFolder).commit(); + // 这条路径也会从选中监听(updateTypeRecyclerVisibility)进来,同样可能 + // 落在已保存状态窗口里;本地可见性状态照旧清,事务则只在能提交时才提交。 + if (canCommitFragmentTransaction()) getSupportFragmentManager().beginTransaction().hide(mFolder).commit(); } } @@ -420,15 +428,55 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List clearCategoryContent(); } + /** + * 宿主已保存状态、正在结束或已销毁时不能再提交 Fragment 事务。 + * + *

首页的分类页重建由**进程级**单例 {@code VodConfig} 的加载回调驱动 + * ({@code BaseConfig.loadConfig()} 用 {@code App.post(callback::success)} 投递), + * 回调与 Activity 生命周期无关:外观页改主题色/主题模式/UI 缩放都会 + * {@code recreate()},而配置加载可能在这个重建窗口里才返回。此前 + * {@link #clearCategoryContent()} 无条件 {@code commit()},于是这条延迟回调在 + * 已保存状态上抛 {@code IllegalStateException: Can not perform this action after + * onSaveInstanceState} 并崩溃(见 docs/CRASH-FRAGMENT-STATE-20261022.md)。 + * + *

判定条件与文件内既有的 4 处守卫({@code switchType}、 + * {@code focusCategoryButton}、{@code showCategoryContent}、 + * {@code openCustomCsp})保持一致。 + */ + private boolean canCommitFragmentTransaction() { + return !isFinishing() && !isDestroyed() && !getSupportFragmentManager().isStateSaved(); + } + + /** + * 宿主仍然存活、只是被保存了状态(例如配置回调在用户切走之后才返回)时, + * 被 {@link #canCommitFragmentTransaction()} 拒绝的工作要推迟到回到前台补做; + * 正在结束或已销毁的实例没有可恢复的宿主,直接丢弃。 + */ + private boolean canDeferFragmentWork() { + return !isFinishing() && !isDestroyed(); + } + private void clearCategoryContent() { invalidatePendingFocusRequests(); mBinding.typeRecycler.removeCallbacks(mTypeSwitch); - mPendingTypePosition = -1; + clearStaleSiteTypes(); mCurrentType = null; mFolder = null; mBinding.progressLayout.setVisibility(View.VISIBLE); mBinding.categoryContainer.setVisibility(View.GONE); + // 状态清理是纯内存赋值,与能否提交事务无关;这里必须已经丢弃 mFolder, + // 否则 isCurrentCategory() 会把一个即将消失的 Fragment 当成当前分类。 + if (!canCommitFragmentTransaction()) { + // 这次移除不能丢:旧站点的分类页(含子 Fragment)会一直留在容器里, + // 等容器再次可见就会和新站点的页面叠加。 + if (canDeferFragmentWork()) mPendingCategoryCleanup = true; + return; + } + removeCategoryFragments(); + } + /** 移除所有已加入的分类页;调用方必须先通过 {@link #canCommitFragmentTransaction()}。 */ + private void removeCategoryFragments() { FragmentTransaction transaction = null; for (Fragment fragment : getSupportFragmentManager().getFragments()) { if (!(fragment instanceof FolderFragment folder)) continue; @@ -439,6 +487,14 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List if (transaction != null) transaction.commit(); } + private void clearStaleSiteTypes() { + // Site switching starts asynchronously. Remove the old type row immediately so stale + // category buttons cannot be mistaken for the newly selected site during loading. + mTypeAdapter.addAll(Collections.emptyList()); + mPendingTypePosition = -1; + mBinding.typeRecycler.setVisibility(View.GONE); + } + private void updateToolbarVisibility(boolean visible) { mBinding.toolbar.setVisibility(visible && webToolbarVisible ? View.VISIBLE : View.GONE); syncNativeContentInset(); @@ -503,9 +559,12 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List selector.addPresenter(Integer.class, new HeaderPresenter()); selector.addPresenter(String.class, new ProgressPresenter()); selector.addPresenter(Vod.class, new VodPresenter(this, Style.list())); - selector.addPresenter(ListRow.class, new CustomRowPresenter(16), VodPresenter.class); - selector.addPresenter(ListRow.class, new CustomRowPresenter(16), FuncPresenter.class); - selector.addPresenter(ListRow.class, new CustomRowPresenter(16, FocusHighlight.ZOOM_FACTOR_SMALL, HorizontalGridView.FOCUS_SCROLL_ALIGNED), HistoryPresenter.class); + // 首页三条卡片行统一读设计系统的 TV 卡片间距 token(§6「TV 横向间距 20dp」)。 + // 此前是硬编码 16dp,而 webhtv_size_tv_card_gap 只声明、从未被引用。 + int tvCardGap = ResUtil.dpOf(R.dimen.webhtv_size_tv_card_gap); + selector.addPresenter(ListRow.class, new CustomRowPresenter(tvCardGap), VodPresenter.class); + selector.addPresenter(ListRow.class, new CustomRowPresenter(tvCardGap), FuncPresenter.class); + selector.addPresenter(ListRow.class, new CustomRowPresenter(tvCardGap, FocusHighlight.ZOOM_FACTOR_SMALL, HorizontalGridView.FOCUS_SCROLL_ALIGNED), HistoryPresenter.class); mBinding.recycler.setAdapter(new ItemBridgeAdapter(mAdapter = new ArrayObjectAdapter(selector))); mBinding.recycler.setVerticalSpacing(ResUtil.dp2px(16)); mBinding.typeRecycler.setHorizontalSpacing(ResUtil.dp2px(16)); @@ -610,16 +669,62 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List showContent(); } + /** + * 还没有配置任何内容源(重置应用、重装或恢复出厂后的首次启动)。 + * + *

这不是失败:没有地址可请求、没有失败原因、重试也永远不会成功。改动前 + * {@code BaseConfig} 把它当 {@code error("")} 抛出来,首页便显示「内容源加载失败 / + * 重试」——一个不存在的故障加一个点了没用的按钮。这里改走「未配置」状态: + * 不接管首页、直接进入内容态(导航恢复);添加接口仍走既有的 + * 「设置 → 点播」里的管理面板(`HistoryDialog` 的「添加」→ `ConfigDialog`)。 + */ + @Override + public void noConfig() { + SpiderDebug.log("startup", "no vod config configured"); + skipNextVodConfigRefresh = true; + showNoConfig(); + } + @Override public void error(String msg) { SpiderDebug.log("startup", "config load error cost=%sms msg=%s", System.currentTimeMillis() - App.time(), msg); Notify.show(msg); skipNextVodConfigRefresh = true; - showContent(); + // 设计文档 §9.9:错误态要给出「错误色 + 原因 + 重试动作」。 + // 改动前这里只弹一个会消失的吐司,然后 showContent() 留一个没有任何内容源的 + // 空首页——用户既不知道原因,也没有重试入口。吐司保留(不删既有行为), + // 页面改为进入错误态。 + showConfigError(msg); } }; } + /** 内容源加载失败:进入错误态,原因取服务端返回的 message,主动作是重新加载配置。 */ + private void showConfigError(String msg) { + mBinding.progressLayout.showError( + R.string.error_config_load, + msg, + R.string.error_retry, + view -> { + mBinding.progressLayout.showProgress(); + initConfig(); + }); + } + + /** + * 还没有配置内容源:这不是失败,所以**不接管首页**。 + * + *

点播/直播/设置这一行不是独立导航栏,而是首页列表的第一行({@link #setFunc()} + * 往 {@code mFuncAdapter} 里填按钮);一旦走 {@code progressLayout.showEmpty()}, + * {@code ProgressLayout} 会把内容视图整块 INVISIBLE,导航行跟着消失——新装用户只剩一个 + * 「添加接口」按钮,连设置都进不去。因此这里照常进入内容态(导航恢复);导航行仍只由 + * {@code HomeButton.getVisibleButtons()} 构成,不再额外插入「添加接口」入口(添加接口走 + * 既有的「设置 → 点播」管理面板)。 + */ + private void showNoConfig() { + showContent(); + } + private void showContent() { SpiderDebug.log("startup", "home showContent start cost=%sms", System.currentTimeMillis() - App.time()); mBinding.progressLayout.showContent(); @@ -664,6 +769,16 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List private void getVideo(boolean forceNative) { syncCategorySite(); + // 这次刷新打给的是一个已经过去(已保存状态)的窗口:重建后的实例会由 + // initAfterFirstFrame → initConfig 自己加载。继续重排只会再撞一次事务守卫。 + if (!canCommitFragmentTransaction()) { + // 没有重建、只是被保存了状态时这次刷新不能丢:回到前台(onResume)再补。 + if (canDeferFragmentWork()) { + mPendingContentRefresh = true; + mPendingContentRefreshForceNative |= forceNative; + } + return; + } if (!forceNative && WebHomeTarget.canLoad(getHome())) { ensureWebView(); } @@ -703,7 +818,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List private void setTypes(Result result) { if (result.getTypes().isEmpty()) { - mTypeAdapter.addAll(java.util.Collections.emptyList()); + mTypeAdapter.addAll(Collections.emptyList()); mBinding.typeRecycler.setVisibility(View.GONE); showHomeContent(); return; @@ -833,7 +948,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List performClearHistory(); return; } - new androidx.appcompat.app.AlertDialog.Builder(this) + new WebHtvAlertDialogBuilder(this) .setTitle(R.string.dialog_delete_record) .setMessage(R.string.dialog_delete_global_history) .setNegativeButton(R.string.dialog_negative, null) @@ -887,6 +1002,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List switch (event.getType()) { case HOME: setTitle(); + dismissStateIfConfigured(); SpiderDebug.log("site-dialog", "home refresh start key=%s homePage=%s", getHome().getKey(), getHome().hasHomePage()); if (mWeb != null && mWeb.isVisible()) { if (!mWeb.load(getHome(), true)) getVideo(true); @@ -906,6 +1022,21 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } } + /** + * 状态浮层只会被「又一次启动 / 点重试」收掉,而配置可能在别的路径上变得可用: + * 恢复备份、远程指令切换接口、设置页切换接口、接收推送接口。这些路径发的是 + * {@link RefreshEvent#home()} 而不是重新走 {@link #showContent()},于是用户在这些页面上 + * 操作完回到首页,首页仍顶着那张状态卡,**必须杀进程冷启动才恢复**。 + * + *

这里把「配置真的可用了」当成收卡的唯一判据:HOME 刷新意味着首页马上会重新拉内容, + * 此时若还停在空态/错误态就直接收起,不依赖任何调用方记得收。 + */ + private void dismissStateIfConfigured() { + if (VodConfig.get().getSites().isEmpty()) return; + if (!mBinding.progressLayout.isEmpty() && !mBinding.progressLayout.isError()) return; + mBinding.progressLayout.showContent(); + } + @Subscribe(threadMode = ThreadMode.MAIN) public void onServerEvent(ServerEvent event) { switch (event.type()) { @@ -1046,6 +1177,12 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List else if (mFolder != null && item.equals(mCurrentType)) mFolder.onRefresh(); } + @Override + public void onLongPress(Class item) { + if (item == null || item.isHome() || !isCurrentCategory(item)) return; + mFolder.resetFilter(); + } + @Override public void onItemClick(Vod item) { if (item.isAction()) com.fongmi.android.tv.content.ActionCardHelper.handleAction(this, getHome().getKey(), item.getAction()); @@ -1095,7 +1232,7 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List } /** - * 执行 select_home_menu_key 中某一项对应的动作,下标 1..9(0 是「选项弹窗」自身,不会走到这里)。 + * 执行 select_home_menu_key 中某一项对应的动作,下标 1..11(0 是「选项弹窗」自身,不会走到这里)。 */ @Override public void onHomeMenuItem(int index) { @@ -1109,6 +1246,8 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List case 7 -> PushActivity.start(this, 3); case 8 -> KeepActivity.start(this); case 9 -> SettingActivity.start(this); + case 10 -> FollowingActivity.start(this, null); + case 11 -> openCustomCsp(); } } @@ -1143,12 +1282,20 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List public void setConfig(Config config) { if (config.getType() != 0) return; if (config.getUrl().startsWith("file")) { - PermissionUtil.requestFile(this, allGranted -> VodConfig.load(config, getCallback())); + PermissionUtil.requestFile(this, allGranted -> loadVodConfig(config)); } else { - VodConfig.load(config, getCallback()); + loadVodConfig(config); } } + private void loadVodConfig(Config config) { + String previousVodUrl = VodConfig.getUrl(); + VodConfig.load(config, getCallback()); + if (!ConfigSyncPolicy.shouldSyncLive(previousVodUrl, LiveConfig.getUrl())) return; + Config liveConfig = AppDatabase.get().getConfigDao().find(config.getUrl(), 1); + if (liveConfig != null) LiveConfig.load(liveConfig, new Callback()); + } + @Override public void setSite(Site item) { SpiderDebug.log("site-dialog", "set site key=%s name=%s homePage=%s", item.getKey(), item.getName(), item.hasHomePage()); @@ -1256,6 +1403,21 @@ public class HomeActivity extends BaseActivity implements ExitConfirmDialog.List refreshFollowingCount(); syncTypeItems(); resumeTypeSwitch(); + resumeDeferredFragmentWork(); + } + + /** 回到前台后补做在「已保存状态」窗口里被推迟的分类页清理与首页内容刷新。 */ + private void resumeDeferredFragmentWork() { + if (!canCommitFragmentTransaction()) return; + if (mPendingCategoryCleanup) { + mPendingCategoryCleanup = false; + removeCategoryFragments(); + } + if (!mPendingContentRefresh) return; + mPendingContentRefresh = false; + boolean forceNative = mPendingContentRefreshForceNative; + mPendingContentRefreshForceNative = false; + getVideo(forceNative); } @Override diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java index bcf1a307d3..9ba5115bd2 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java @@ -8,6 +8,7 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.Product; +import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.Config; import com.fongmi.android.tv.bean.Keep; @@ -38,20 +39,44 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis @Override protected void initView(Bundle savedInstanceState) { + setEmptyContent(); setRecyclerView(); getKeep(); } + /** + * 空态内容:设计文档 §9.9 要求「标题 + 解释 + 一个主动作」。 + * + *

收藏页是最典型的空态——用户第一次进来时列表必为空,此前的实现只有一句 + * {@code error_empty}(「这里什麽都没有」),既不解释为什么空,也不给出下一步。 + */ + private void setEmptyContent() { + mBinding.progressLayout.setEmptyContent( + R.string.keep_empty_title, + R.string.keep_empty_description, + R.string.keep_empty_action, + view -> goHome()); + } + + /** 空态主按钮:回点播首页去挑一部。与 LiveActivity/VideoActivity 的回首页写法一致。 */ + private void goHome() { + startActivity(new Intent(this, HomeActivity.class).addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)); + } + private void setRecyclerView() { mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setItemAnimator(null); mBinding.recycler.setAdapter(mAdapter = new KeepAdapter(this)); + // 列表数量变化后统一驱动状态机(BaseDiffAdapter 的钩子):注册一次,之后无论从哪条 + // 路径改动列表(单条删除、删除全部、刷新)状态都会跟着列表走,宿主不可能再忘。 + mAdapter.setOnCountChanged(count -> mBinding.progressLayout.showContent(true, count)); mBinding.recycler.setLayoutManager(new GridLayoutManager(this, Product.getColumn())); mBinding.recycler.addItemDecoration(new SpaceItemDecoration(Product.getColumn(), 16)); } private void getKeep() { - mAdapter.setItems(Keep.getVod(), () -> mBinding.progressLayout.showContent(true, mAdapter.getItemCount())); + mAdapter.setItems(Keep.getVod()); } private void loadConfig(Config config, Keep item) { @@ -64,6 +89,18 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis @Override public void error(String msg) { Notify.show(msg); + // 设计文档 §9.9:错误态要给出「错误色 + 原因 + 重试动作」。 + // 改动前这里只弹一个会消失的吐司,页面没有任何变化——用户既不知道原因会 + // 停留多久,也没有重试入口(只能再点一次卡片,但卡片看起来毫无反应)。 + // 重试直接复用同一条加载路径,所以重试成功与首次成功的行为完全一致。 + mBinding.progressLayout.showError( + R.string.error_config_load, + msg, + R.string.error_retry, + view -> { + mBinding.progressLayout.showProgress(); + loadConfig(config, item); + }); } }); } @@ -84,6 +121,8 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis @Override public void onItemDelete(Keep item) { mAdapter.remove(item.delete(), () -> { + // 状态由 BaseDiffAdapter 的 setOnCountChanged 钩子统一驱动,这里不再手写—— + // 「手写」正是此前漏掉删除全部那条路径的原因。 if (mAdapter.getItemCount() == 0) mAdapter.setDelete(false); }); } 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..2c751e0493 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 @@ -52,6 +52,8 @@ 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.theme.PlaybackMenuScrim; +import com.fongmi.android.tv.setting.PlayerButtonSetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.ui.adapter.ChannelAdapter; import com.fongmi.android.tv.ui.adapter.EpgDataAdapter; @@ -78,8 +80,10 @@ import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.util.ArrayList; +import java.util.HashMap; import java.util.Iterator; import java.util.List; +import java.util.Map; public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnClickListener, ChannelAdapter.OnClickListener, EpgDataAdapter.OnClickListener, CustomKeyDownLive.Listener, CustomLiveListView.Callback, TrackDialog.Listener, PassListener, ConfigListener, LiveListener { @@ -101,6 +105,7 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private LiveViewModel mViewModel; private PlayerOsdController mOsd; private List mHides; + private Map mActionButtons; private String mPlaybackKey; private String mPendingReloadUrl; private String mPendingReloadMsg; @@ -119,6 +124,13 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private boolean playbackCatchup; private int count; private boolean mFailedThisSession; + /** + * 本轮播放失败自动换线的起点线路下标,配合 {@link #mLineFallbackExhausted} 使用。 + * -1 表示当前没有进行中的失败回退轮换;自动换线绕回该下标即本频道所有线路都已试过, + * 置位 exhausted 阻止 onError 立即开启新一轮死循环;手动换线/换台/换源或播放成功后重置。 + */ + private int mLineFallbackAnchor = -1; + private boolean mLineFallbackExhausted; public static void start(Context context) { context.startActivity(new Intent(context, LiveActivity.class).putExtra("empty", LiveConfig.isEmpty())); @@ -192,7 +204,7 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mR0 = this::setSelected; mR1 = this::hideControl; mR2 = this::setTraffic; - mBufferingTimeout = this::startFlow; + mBufferingTimeout = this::onBufferingTimeout; mR3 = this::hideInfo; mR4 = this::hideUI; mEndRetry = this::checkNext; @@ -210,6 +222,7 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli }, 14f); setVideoView(); setViewModel(); + setupActionButtons(); } @Override @@ -258,6 +271,42 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli mBinding.epgData.setAdapter(mEpgDataAdapter = new EpgDataAdapter(this)); } + /** + * 直播动作栏登记点播同名的播放器按钮,使「设置-播放-播放器按钮」的开关与排序对直播同样生效。 + * 直播专属按钮(配置、OSD 设置、直播源、播放/暂停、线路、反色、跨类)不登记,始终按布局显示。 + */ + private void setupActionButtons() { + mActionButtons = new HashMap<>(); + addActionButton(PlayerButtonSetting.PLAYER, mBinding.control.action.player); + addActionButton(PlayerButtonSetting.DECODE, mBinding.control.action.decode); + addActionButton(PlayerButtonSetting.PLAY_PARAMS, mBinding.control.action.playParams); + addActionButton(PlayerButtonSetting.SPEED, mBinding.control.action.speed); + addActionButton(PlayerButtonSetting.SCALE, mBinding.control.action.scale); + addActionButton(PlayerButtonSetting.CHANGE, mBinding.control.action.change); + addActionButton(PlayerButtonSetting.TEXT, mBinding.control.action.text); + addActionButton(PlayerButtonSetting.AUDIO, mBinding.control.action.audio); + addActionButton(PlayerButtonSetting.VIDEO, mBinding.control.action.video); + PlayerButtonSetting.applyOrder(mBinding.control.action.container, mActionButtons); + } + + private void addActionButton(String id, View view) { + mActionButtons.put(id, view); + } + + /** + * 先恢复无动态条件的按钮,再统一套用用户关闭项。 + * 顺序不能反过来:applyVisibility 只隐藏、不恢复,必须在条件判定之后执行。 + */ + private void applyActionButtonVisibility() { + if (mActionButtons == null) return; + mBinding.control.action.player.setVisibility(View.VISIBLE); + mBinding.control.action.decode.setVisibility(View.VISIBLE); + mBinding.control.action.playParams.setVisibility(View.VISIBLE); + mBinding.control.action.scale.setVisibility(View.VISIBLE); + mBinding.control.action.change.setVisibility(View.VISIBLE); + PlayerButtonSetting.applyVisibility(mActionButtons); + } + private void setVideoView() { setScale(LiveSetting.getScale()); findViewById(R.id.timeBar).setNextFocusUpId(R.id.config); @@ -267,7 +316,8 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli } private void applyPlaybackOverlay() { - mBinding.control.getRoot().setBackgroundResource(Setting.isPlaybackOverlayEnabled() ? R.drawable.shape_controller_scrim : R.color.transparent); + if (Setting.isPlaybackOverlayEnabled()) mBinding.control.getRoot().setBackground(PlaybackMenuScrim.create(this)); + else mBinding.control.getRoot().setBackgroundResource(R.color.transparent); } private void setDecode() { @@ -315,13 +365,28 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli } private void getLive() { - mViewModel.parse(getHome()); + Live live = getHome(); + setSource(live); + mViewModel.parse(live); showProgress(); } + /** + * 控制栏的「直播源」按钮显示当前直播源名称:固定写「直播源」看不出此刻播的是哪一路源, + * 用户换源(含失效自动换源)都会重新走 {@link #getLive()} 刷新。名称为空时回退原文案。 + * 按钮文案被源名取代后读屏会只剩一个孤立的源名,因此同步补回「直播源」动作语义。 + */ + private void setSource(Live live) { + String name = live == null ? "" : live.getName(); + boolean named = !name.isEmpty(); + String label = named ? name : getString(R.string.live_control_source); + mBinding.control.action.source.setText(label); + mBinding.control.action.source.setContentDescription(named ? getString(R.string.live_control_source_named, name) : null); + } + private void renderLive(Live live) { if (live == null || live.getGroups().isEmpty()) { - if (LiveSetting.isSourceFallback()) startFlow(); + if (LiveSetting.isSourceFallback()) startSourceFallback(); return; } mViewModel.parseXml(live); @@ -579,7 +644,8 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli @Override protected boolean onSourceHttpError(int statusCode, String msg) { - if (!LiveSetting.isSourceFallback()) return false; + // HTTP 播放错误优先交给直播回退流程:开启自动换线或接口回退任一时短路播放器重试链。 + if (!LiveSetting.isChange() && !LiveSetting.isSourceFallback()) return false; onError(msg); return true; } @@ -598,6 +664,17 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli } } + /** + * 缓冲超时(既没出画面也没报错):等价于本条线路播放失败。 + *

+ * 与 onError 不同,本路径不受 mFailedThisSession 限制:上一轮换线后新线路若一直卡在 + * 解析/缓冲阶段,mFailedThisSession 仍为 true,但此时必须仍能继续换线; + * 确实无线路可换(单线路或本轮已绕回起点)时给出可见的失败提示,避免进度条一直转。 + */ + private void onBufferingTimeout() { + if (!startFlow()) showError(ResUtil.getString(R.string.error_play_url)); + } + @Override protected void onReload(String msg) { if (mChannel == null) { @@ -629,6 +706,7 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli hideProgress(); player().reset(); mFailedThisSession = false; + resetLineFallback(); break; case Player.STATE_ENDED: checkEnded(); @@ -701,6 +779,8 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli } private void showControl(View view) { + // 与 mobile 一致:唤出控制栏时重算轨道类按钮,用户重新开启后不必等下一次轨道变化。 + if (service() != null) setTrackVisible(); setPlayParamsState(); mBinding.control.getRoot().setVisibility(View.VISIBLE); // 控制栏显示时统一由 PlayerOsdController 显示标题、分辨率和时间,避免旧栏重影 @@ -729,6 +809,7 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void setPlayParamsState() { mBinding.control.action.playParams.setSelected(mOsd != null && mOsd.isDiagnosticsVisible()); + applyActionButtonVisibility(); } private void hideCenter() { @@ -846,6 +927,7 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void setChannel(Channel item) { App.post(mR0, 100); mChannel = item; + resetLineFallback(); setArtwork(); showInfo(); } @@ -930,11 +1012,14 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli } private void handleSameReloadUrl(String msg) { - if (mChannel != null && !mChannel.isOnly()) { - nextLine(true); - } else { - onError(msg); - } + // 同 URL 重载失败等价于该线路播放失败:走带绕圈/耗尽保护的自动换线, + // 不重置本轮回退状态,避免 reload 路径重新开启死循环。 + // 沿用重载路径既有语义(不校验用户的自动换线开关),只补齐保护: + // 换不动(单线路或本轮已绕回起点)时必须给出可见失败反馈, + // 否则 fetch() 刚 showProgress() 的进度条会一直转、错误文案永远不出现。 + if (advanceLineForFallback()) return; + App.removeCallbacks(mBufferingTimeout); + showError(msg); } private void resetAdapter() { @@ -987,6 +1072,7 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli player().reset(); player().clear(); player().stop(); + resetLineFallback(); resetAdapter(); hideControl(); getLive(); @@ -1034,6 +1120,7 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli 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); mBinding.control.action.speed.setVisibility(player().isVod() ? View.VISIBLE : View.GONE); + applyActionButtonVisibility(); } private MediaMetadata buildMetadata() { @@ -1045,17 +1132,74 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli player().setMetadata(buildMetadata()); } - private void startFlow() { - Live next = LiveSetting.isSourceFallback() ? LiveConfig.getNextHome() : null; - LiveSourceFallbackPolicy.Action action = LiveSourceFallbackPolicy.decide( + /** + * 线路播放失败的自动回退:只在当前频道的线路间轮换(含最后一条绕回), + * 试完一圈即停,绝不因播放失败直接跳到配置里的下一个直播接口。 + * 调用点:onError、缓冲超时(mBufferingTimeout -> onBufferingTimeout)。 + *

+ * 本方法只负责「还能不能再换一条线路」,换不动时返回 false, + * 失败文案由调用方负责显示。 + * + * @return true 表示已换到另一条线路并重新拉流 + */ + private boolean startFlow() { + LiveSourceFallbackPolicy.Action action = LiveSourceFallbackPolicy.decideLineFailure( LiveSetting.isChange(), - LiveSetting.isSourceFallback(), mChannel != null, - mChannel == null || mChannel.isLast(), - mChannel == null || mChannel.isOnly(), + mChannel != null && !mChannel.isOnly()); + return action == LiveSourceFallbackPolicy.Action.NEXT_LINE && advanceLineForFallback(); + } + + /** + * 直播接口节目列表拉取失败(renderLive 空结果)时的回退:只有此时才直接 + * 跳转到配置里的下一个直播接口。 + */ + private void startSourceFallback() { + Live next = LiveConfig.getNextHome(); + LiveSourceFallbackPolicy.Action action = LiveSourceFallbackPolicy.decideSourceFailure( + LiveSetting.isSourceFallback(), next != null); - if (action == LiveSourceFallbackPolicy.Action.NEXT_LINE) nextLine(true); - else if (action == LiveSourceFallbackPolicy.Action.NEXT_SOURCE) setLive(next); + if (action == LiveSourceFallbackPolicy.Action.NEXT_SOURCE) setLive(next); + } + + /** + * 播放失败自动换线:从失败起点开始轮换,绕回起点即本轮所有线路都已试过,置位 + * exhausted 停止后续回退;否则刷新线路信息并重新拉流。 + *

+ * 本方法是唯一改动 mChannel 线路下标的地方,因此 exhausted 短路必须放在这里: + * 无论从 onError、缓冲超时还是同 URL 重载进来,都不可能再开启新一轮 + * A→B→A 死循环(否则 anchor 未清时换线会重新绕回起点并重新拉流)。 + * + * @return true 表示已换到另一条线路并重新拉流;false 表示没有可换的线路 + * (单线路)或本轮线路已绕回起点(回退耗尽),调用方需要自行给出失败反馈。 + */ + private boolean advanceLineForFallback() { + if (isLineFallbackExhausted()) return false; + if (mChannel == null || mChannel.isOnly()) return false; + if (mLineFallbackAnchor < 0) mLineFallbackAnchor = mChannel.getIndex(); + mChannel.switchLine(true); + if (mChannel.getIndex() == mLineFallbackAnchor) { + mLineFallbackExhausted = true; + return false; + } + showInfo(); + fetch(); + return true; + } + + /** + * 播放失败回退已耗尽(本轮所有线路都试过且都失败)时不再自动换线,避免死循环。 + */ + private boolean isLineFallbackExhausted() { + return mLineFallbackExhausted; + } + + /** + * 手动换线、换台、换直播接口或播放成功后,重置失败回退轮换状态。 + */ + private void resetLineFallback() { + mLineFallbackAnchor = -1; + mLineFallbackExhausted = false; } private void prevChannel() { @@ -1111,6 +1255,7 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void prevLine() { if (mChannel == null || mChannel.isOnly()) return; + resetLineFallback(); mChannel.switchLine(false); showInfo(); fetch(); @@ -1118,6 +1263,7 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private void nextLine(boolean show) { if (mChannel == null || mChannel.isOnly()) return; + resetLineFallback(); mChannel.switchLine(true); if (show) showInfo(); else setInfo(); 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..6683e58f0c 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 @@ -18,6 +18,7 @@ import com.fongmi.android.tv.bean.Live; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.databinding.ActivitySettingBinding; import com.fongmi.android.tv.db.AppDatabase; +import com.fongmi.android.tv.setting.ConfigSyncPolicy; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.impl.Callback; @@ -28,7 +29,6 @@ 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; import com.fongmi.android.tv.ui.dialog.LiveDialog; @@ -36,6 +36,9 @@ 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.utils.AppVersion; +import com.fongmi.android.tv.cache.CacheCenter; +import com.fongmi.android.tv.cache.CachePolicyStore; +import com.fongmi.android.tv.ui.dialog.CacheManagementDialog; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PermissionUtil; @@ -92,10 +95,23 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit } private void setCacheText() { - FileUtil.getCacheSize(new Callback() { - @Override - public void success(String result) { - mBinding.cacheText.setText(result); + if (!CachePolicyStore.isManagementEnabled()) { + FileUtil.getCacheSize(new Callback() { + @Override + public void success(String result) { + if (mBinding != null) mBinding.cacheText.setText(result); + } + }); + return; + } + mBinding.cacheText.setText(R.string.cache_management_scanning); + CacheCenter.get().requestSnapshot(false, snapshot -> { + if (mBinding != null && !isFinishing() && !isDestroyed()) { + String total = FileUtil.byteCountToDisplaySize(snapshot.totalBytes()); + long quota = snapshot.systemQuotaBytes(); + mBinding.cacheText.setText(quota > 0 + ? getString(R.string.cache_management_inline, total, FileUtil.byteCountToDisplaySize(quota)) + : total); } }); } @@ -106,7 +122,11 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit mBinding.doh.setOnClickListener(this::setDoh); mBinding.live.setOnClickListener(this::onLive); mBinding.wall.setOnClickListener(this::onWall); - mBinding.cache.setOnClickListener(this::onCache); + mBinding.cache.setOnClickListener(this::onCacheClear); + // The plain click restores the pre-split one-key clear; the panel moved to the long press. + // The row stays single-line by user request, so §15.2's "not long-press only" intent is a + // recorded deviation rather than a printed hint (docs/CACHE-MGMT-01-cache-management-design.md). + mBinding.cache.setOnLongClickListener(this::onCacheManage); mBinding.backup.setOnClickListener(this::onBackup); mBinding.enhance.setOnClickListener(this::onEnhance); mBinding.tmdb.setOnClickListener(this::onTmdb); @@ -119,11 +139,8 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit mBinding.personal.setOnClickListener(this::onPersonal); mBinding.restore.setOnClickListener(this::onRestore); mBinding.version.setOnClickListener(this::onVersion); - mBinding.vod.setOnLongClickListener(this::onVodEdit); mBinding.vodHome.setOnClickListener(this::onVodHome); - mBinding.live.setOnLongClickListener(this::onLiveEdit); mBinding.liveHome.setOnClickListener(this::onLiveHome); - mBinding.wall.setOnLongClickListener(this::onWallEdit); mBinding.incognito.setOnClickListener(this::setIncognito); mBinding.vodHistory.setOnClickListener(this::onVodHistory); mBinding.liveHistory.setOnClickListener(this::onLiveHistory); @@ -146,7 +163,12 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit private void load(Config config) { switch (config.getType()) { case 0: + String previousVodUrl = VodConfig.getUrl(); VodConfig.load(config, getCallback()); + if (ConfigSyncPolicy.shouldSyncLive(previousVodUrl, LiveConfig.getUrl())) { + Config liveConfig = AppDatabase.get().getConfigDao().find(config.getUrl(), 1); + if (liveConfig != null) LiveConfig.load(liveConfig, new Callback()); + } break; case 1: LiveConfig.load(config, getCallback()); @@ -190,30 +212,15 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit } private void onVod(View view) { - ConfigDialog.create().vod().show(this); + HistoryDialog.create().vod().manage().show(this); } private void onLive(View view) { - ConfigDialog.create().live().show(this); + HistoryDialog.create().live().manage().show(this); } private void onWall(View view) { - ConfigDialog.create().wall().show(this); - } - - private boolean onVodEdit(View view) { - ConfigDialog.create().vod().edit().show(this); - return true; - } - - private boolean onLiveEdit(View view) { - ConfigDialog.create().live().edit().show(this); - return true; - } - - private boolean onWallEdit(View view) { - ConfigDialog.create().wall().edit().show(this); - return true; + HistoryDialog.create().wall().manage().show(this); } private void onVodHome(View view) { @@ -305,13 +312,45 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit mBinding.dohText.setText(doh.getName()); } - private void onCache(View view) { - FileUtil.clearCache(new Callback() { - @Override - public void success() { - setCacheText(); - } - }); + /** + * The settings row's plain click: clear every cache at once, the way this row behaved before it + * opened the cache management panel. + * + *

The full clean runs in the background and reports itself through a notification without + * opening the panel; the row re-reads itself when the cleanup publishes its change. With cache + * management switched off the legacy {@link FileUtil#clearCache} path is kept, because the panel + * and its cleanup engine belong to the feature that switch turns off.

+ */ + private void onCacheClear(View view) { + if (!CachePolicyStore.isManagementEnabled()) { + FileUtil.clearCache(new Callback() { + @Override + public void success() { + setCacheText(); + } + }); + return; + } + CacheManagementDialog.cleanEverything(); + } + + /** + * The settings row's long press: open the cache management panel (graded cleanup, module + * limits, auto cleanup and the running state). + * + *

With the plain click now clearing everything this is the panel's only entry. The row stays + * single-line by user request, so the gesture is not printed under the title and the panel is + * reached by the long press alone (deviation recorded in + * docs/CACHE-MGMT-01-cache-management-design.md). With cache management switched off there is + * no panel to open, so the long press keeps the legacy behaviour and repeats the plain clear.

+ */ + private boolean onCacheManage(View view) { + if (!CachePolicyStore.isManagementEnabled()) { + onCacheClear(view); + return true; + } + CacheManagementDialog.show(this); + return true; } private void onBackup(View view) { @@ -376,6 +415,19 @@ public class SettingActivity extends BaseActivity implements ConfigListener, Sit setWallText(); } + /** + * Re-reads the cache inventory the moment a cleanup (or any other cache mutation) finishes. + * + *

Without this the settings row kept the pre-cleanup value until the user left the activity + * and opened it again, because nothing told the row the cache had changed.

+ */ + @Subscribe(threadMode = ThreadMode.MAIN) + public void onRefreshEvent(RefreshEvent event) { + if (event.getType() != RefreshEvent.Type.CACHE) return; + if (mBinding == null || isFinishing() || isDestroyed()) return; + setCacheText(); + } + private void setWallText() { mBinding.wallUrl.setText(Setting.getWallDesc(WallConfig.getDesc())); } 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..e5f92ea41c 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 @@ -2,59 +2,29 @@ package com.fongmi.android.tv.ui.activity; import android.app.Activity; import android.content.Intent; -import android.net.Uri; import android.os.Bundle; -import android.text.InputType; -import android.view.Gravity; +import android.util.TypedValue; import android.view.View; -import android.widget.EditText; import android.widget.TextView; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.appcompat.app.AlertDialog; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ad.audio.AdAudioRuleSnapshot; -import com.fongmi.android.tv.ad.audio.AdAudioRuleStore; -import com.fongmi.android.tv.ad.audio.AdAudioSetting; -import com.fongmi.android.tv.ad.audio.AdSkipPolicyController; -import com.fongmi.android.tv.ad.audio.ProbeRuleDownloader; -import com.fongmi.android.tv.ad.audio.ProbeRuleStore; -import com.fongmi.android.tv.ad.audio.SpeechAdConfig; -import com.fongmi.android.tv.ad.audio.SpeechAdSetting; 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.databinding.ActivitySettingAdBinding; -import com.fongmi.android.tv.server.Server; -import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.player.IntroSkipKinds; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.subtitle.RealtimeSubtitleSpeechRecognitionFactory; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.AdBlockStatsDialog; import com.fongmi.android.tv.ui.dialog.AdRuleManageDialog; -import com.fongmi.android.tv.ui.dialog.LightDialog; -import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.Task; import com.github.catvod.crawler.SpiderDebug; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import java.util.Locale; public class SettingAdActivity extends BaseActivity { private ActivitySettingAdBinding mBinding; private String[] introSkipMode; - private volatile AdAudioRuleSnapshot adAudioSnapshot = AdAudioRuleStore.get().current(); - private volatile long probeRevision; - 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)); @@ -74,8 +44,6 @@ public class SettingAdActivity extends BaseActivity { mBinding.adblock.requestFocus(); introSkipMode = getResources().getStringArray(R.array.select_auto_skip_intro_outro); setText(); - loadAdAudioRules(); - loadProbeRules(); } @Override @@ -84,39 +52,10 @@ public class SettingAdActivity extends BaseActivity { mBinding.aiAdDetection.setOnClickListener(this::setAiAdDetection); mBinding.adRuleManage.setOnClickListener(view -> AdRuleManageDialog.create().show(this, this::setText)); mBinding.adBlockStats.setOnClickListener(view -> AdBlockStatsDialog.create(this).show()); - mBinding.adAudioFingerprint.setOnClickListener(this::toggleAdAudioFingerprint); - mBinding.adAudioFingerprint.setOnLongClickListener(this::manageAdAudioRules); - mBinding.adAudioAutoSkip.setOnClickListener(this::toggleAdAudioAutoSkip); - mBinding.probeRuleSource.setOnClickListener(this::editProbeRuleSource); - 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); mBinding.introSkipKinds.setOnClickListener(view -> IntroSkipKinds.show(this, this::setText)); } - /** 规则文件要读盘并完整解析,放后台线程,取回后再刷新摘要。 */ - private void loadAdAudioRules() { - Task.execute(() -> { - adAudioSnapshot = AdAudioRuleStore.get().load(); - runOnUiThread(this::setText); - }); - } - - private void loadProbeRules() { - Task.execute(() -> { - ProbeRuleStore store = ProbeRuleStore.get(); - AdAudioRuleSnapshot snapshot = store.load(); - probeRuleCount = snapshot.ruleSet().rules().size(); - probeRevision = store.revision(); - runOnUiThread(this::setText); - }); - } - private void setText() { if (!canSetText()) return; safeSet("adblock", mBinding.adblockText, () -> getSwitch(Setting.isAdblock())); @@ -125,19 +64,6 @@ public class SettingAdActivity extends BaseActivity { safeSet("adRuleManage", mBinding.adRuleManageText, () -> getString(R.string.ad_rule_count_with_pending, UserAdRuleStore.load().size() + RuleConfig.get().getDefaultRules().size(), ImportedAdRuleCandidateStore.pending().size())); - safeSet("adAudioFingerprint", mBinding.adAudioFingerprintText, this::getAdAudioFingerprintText); - safeSet("adAudioAutoSkip", mBinding.adAudioAutoSkipText, () -> getSwitch(AdAudioSetting.isAutoSkipEnabled())); - 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)); safeSet("autoSkipIntroOutro", mBinding.autoSkipIntroOutroText, () -> introSkipMode[Setting.getIntroSkipMode()]); safeSet("introSkipKinds", mBinding.introSkipKindsText, IntroSkipKinds::summary); } @@ -147,7 +73,22 @@ public class SettingAdActivity extends BaseActivity { } private void safeSet(String name, TextView view, TextSupplier supplier) { - safeRun(name, () -> view.setText(supplier.get()), () -> setError(view)); + safeRun(name, () -> setSummary(view, supplier.get()), () -> setError(view)); + } + + /** + * 写入取值摘要,并把颜色复位到常规壁纸前景色。 + * + *

复位这一步是必需的:{@link #setError} 会把同一个 TextView 染成错误色,而这些摘要 + * 全部**复用同一个控件**——不复位的话,某一行失败一次之后,即使后续刷新成功, + * 它也会一直显示为红色。 + */ + private void setSummary(TextView view, CharSequence text) { + if (view == null) return; + view.setText(text); + view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaper)); + // 撤掉错误底材:错误态是"临时"的,刷新成功必须完全回到原样(这些 TextView 原本没有背景)。 + view.setBackground(null); } private void safeRun(String name, Runnable action, Runnable fallback) { @@ -163,8 +104,32 @@ public class SettingAdActivity extends BaseActivity { } } + /** + * 内联错误:设计文档 §9.9 要求错误态有「错误色」。 + * + *

不能直接用 {@code ?attr/colorError}:那是跟随调色板的角色,浅色表的深红 + * ({@code #B3261E})压在深色壁纸行色带上实测只有 1.06:1,完全不可读。这里用与壁纸 + * 同族的 {@code ?attr/webhtvColorOnWallpaperError}(同一底材实测 4.05–4.50:1)。 + * + *

底材:错误色直接画在行色带上只有 4.05–4.50:1,略低于正文门槛 4.5:1,而继续调色无解 + * (要达标就得接近纯白,错误色随之失去语义)。因此这里同时换上一块**实心深底** + * ({@code @drawable/shape_error_value}),核算为 8.84:1,稳过门槛。 + * + *

如实记录:该取值在行色带最亮处是 4.05:1,**略低于正文门槛 4.5:1**。这是底材 + * (中亮度壁纸)决定的取舍,理由与替代方案见 {@code values/webhtv_tokens.xml} 的同名条目 + * 与 docs/TV-ERROR-STATE-20261012.md §7。 + */ private void setError(TextView view) { - if (view != null) view.setText(R.string.error_config_get); + if (view == null) return; + view.setText(R.string.error_config_get); + view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaperError)); + view.setBackgroundResource(R.drawable.shape_error_value); + } + + /** 解析主题属性里的颜色值;未赋值时返回 0(透明)。 */ + private int themeColor(int attr) { + TypedValue value = new TypedValue(); + return getTheme().resolveAttribute(attr, value, true) ? value.data : 0; } private interface TextSupplier { @@ -186,397 +151,6 @@ public class SettingAdActivity extends BaseActivity { setText(); } - private String getAdAudioFingerprintText() { - String enabled = getSwitch(AdAudioSetting.isEnabled()); - AdAudioRuleSnapshot snapshot = adAudioSnapshot; - if (snapshot == null || snapshot.hasError()) { - return enabled + " · " + getString(R.string.setting_ad_audio_rule_error); - } - int count = snapshot.ruleSet().rules().size(); - if (count == 0) return enabled + " · " + getString(R.string.setting_ad_audio_no_rules); - return enabled + " · " + getString(R.string.setting_ad_audio_rule_count, count, snapshot.version()); - } - - private void toggleAdAudioFingerprint(View view) { - AdAudioSetting.setEnabled(!AdAudioSetting.isEnabled()); - notifyAdAudioRuntime(); - setText(); - } - - private void toggleAdAudioAutoSkip(View view) { - AdAudioSetting.setAutoSkipEnabled(!AdAudioSetting.isAutoSkipEnabled()); - notifyAdAudioRuntime(); - setText(); - } - - private boolean manageAdAudioRules(View view) { - new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.setting_ad_audio_title) - .setItems(new String[]{ - getString(R.string.setting_ad_audio_import), - getString(R.string.setting_ad_audio_clear) - }, (dialog, which) -> { - if (which == 0) { - adAudioRulePicker.launch(new String[]{"application/json", "text/json"}); - } else { - confirmClearAdAudioRules(); - } - }) - .setNegativeButton(R.string.dialog_negative, null) - .show(); - return true; - } - - private void importAdAudioRules(Uri uri) { - if (uri == null) return; - Task.execute(() -> { - String message; - try { - adAudioSnapshot = AdAudioRuleStore.get().importUri(getContentResolver(), uri); - message = getString(R.string.setting_ad_audio_imported, - adAudioSnapshot.ruleSet().rules().size(), adAudioSnapshot.version()); - } catch (Exception e) { - message = getString(R.string.setting_ad_audio_import_failed); - } - String result = message; - runOnUiThread(() -> { - if (!canSetText()) return; - Notify.show(result); - notifyAdAudioRuntime(); - setText(); - }); - }); - } - - private void confirmClearAdAudioRules() { - new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.setting_ad_audio_clear) - .setMessage(R.string.setting_ad_audio_clear_confirm) - .setPositiveButton(R.string.dialog_positive, (dialog, which) -> Task.execute(() -> { - try { - adAudioSnapshot = AdAudioRuleStore.get().clear(); - runOnUiThread(() -> { - if (!canSetText()) return; - Notify.show(R.string.setting_ad_audio_clear_done); - notifyAdAudioRuntime(); - setText(); - }); - } catch (RuntimeException e) { - runOnUiThread(() -> Notify.show(R.string.setting_ad_audio_import_failed)); - } - })) - .setNegativeButton(R.string.dialog_negative, null) - .show(); - } - - private String getProbeRuleSourceText() { - String url = AdAudioSetting.getProbeRuleUrl(); - if (url.isEmpty()) return getString(R.string.setting_ad_probe_source_off); - if (url.equals(AdAudioSetting.DEFAULT_PROBE_RULE_URL)) return getString(R.string.setting_ad_probe_source_default); - return url; - } - - private String getProbeRuleRefreshText() { - if (probeRevision <= 0L) return getString(R.string.setting_ad_probe_never); - return getString(R.string.setting_ad_probe_revision, probeRevision, probeRuleCount); - } - - private void editProbeRuleSource(View view) { - EditText input = new EditText(this); - input.setSingleLine(true); - input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); - input.setText(AdAudioSetting.getProbeRuleUrl()); - input.setSelection(input.length()); - AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.setting_ad_probe_source) - .setMessage(R.string.setting_ad_probe_source_hint) - .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 -> { - String value = input.getText().toString().trim(); - // 空串表示关闭社区规则源;规则不签名,传输层是唯一保障,因此只收 https。 - if (!value.isEmpty() && !value.toLowerCase(Locale.ROOT).startsWith("https://")) { - Notify.show(R.string.setting_ad_probe_url_invalid); - return; - } - AdAudioSetting.setProbeRuleUrl(value); - setText(); - dialog.dismiss(); - })); - dialog.show(); - LightDialog.apply(dialog); - } - - private void refreshProbeRules(View view) { - mBinding.probeRuleRefreshText.setText(R.string.setting_ad_probe_refreshing); - boolean started = ProbeRuleDownloader.refreshNow(new ProbeRuleDownloader.Callback() { - @Override - public void onSuccess(AdAudioRuleSnapshot snapshot) { - if (!canSetText()) return; - Notify.show(snapshot.hasRules() - ? getString(R.string.setting_ad_probe_refreshed, snapshot.version(), snapshot.ruleSet().rules().size()) - : getString(R.string.setting_ad_probe_unchanged, 0)); - notifyAdAudioRuntime(); - loadProbeRules(); - } - - @Override - public void onFailure(Throwable error) { - if (!canSetText()) return; - Notify.show(getString(R.string.setting_ad_probe_failed, String.valueOf(error.getMessage()))); - setText(); - } - - @Override - public void onDisabled() { - if (!canSetText()) return; - Notify.show(R.string.setting_ad_probe_source_disabled); - setText(); - } - }); - // 已有刷新在跑时不会有回调,这里必须自己复位,否则文本永远停在「正在刷新」。 - if (!started) setText(); - } - - private String getSpeechAdEnabledText(SpeechAdConfig speech) { - String text = getSwitch(speech.enabled()); - if (speech.enabled() && !RealtimeSubtitleSpeechRecognitionFactory.isSelectedModelReady()) { - text += " · " + getString(R.string.speech_ad_model_not_ready); - } - return text; - } - - private void toggleSpeechAdEnabled(View view) { - SpeechAdSetting.setEnabled(!SpeechAdSetting.snapshot().enabled()); - notifyAdAudioRuntime(); - 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); - input.setGravity(Gravity.TOP | Gravity.START); - input.setMinLines(4); - input.setMaxLines(8); - input.setSingleLine(false); - input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); - input.setText(String.join("\n", speech.keywords().values())); - input.setSelectAllOnFocus(false); - AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_keywords) - .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 -> { - SpeechAdSetting.setKeywords(input.getText().toString()); - notifyAdAudioRuntime(); - setText(); - dialog.dismiss(); - })); - dialog.show(); - LightDialog.apply(dialog); - } - - private void editSpeechAdSkipSeconds(View view) { - EditText input = new EditText(this); - input.setInputType(InputType.TYPE_CLASS_NUMBER); - input.setSelectAllOnFocus(true); - input.setText(String.valueOf(SpeechAdSetting.snapshot().skipSeconds())); - AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_skip_seconds) - .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 -> { - String value = input.getText().toString().trim(); - try { - int seconds = Integer.parseInt(value); - if (seconds < 1 || seconds > 120) throw new NumberFormatException(); - SpeechAdSetting.setSkipSeconds(seconds); - notifyAdAudioRuntime(); - setText(); - dialog.dismiss(); - } catch (NumberFormatException error) { - input.setError(getString(R.string.speech_ad_skip_seconds_invalid)); - input.requestFocus(); - } - })); - dialog.show(); - LightDialog.apply(dialog); - } - - private void selectSpeechAdSkipMode(View view) { - SpeechAdConfig speech = SpeechAdSetting.snapshot(); - String[] modes = { - getString(R.string.speech_ad_skip_mode_prompt), - getString(R.string.speech_ad_skip_mode_auto) - }; - int checked = speech.mode() == AdSkipPolicyController.Mode.AUTO ? 1 : 0; - AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_skip_mode) - .setNegativeButton(R.string.dialog_negative, null) - .setSingleChoiceItems(modes, checked, (shown, which) -> { - SpeechAdSetting.setMode(which == 1 - ? AdSkipPolicyController.Mode.AUTO - : AdSkipPolicyController.Mode.PROMPT); - notifyAdAudioRuntime(); - setText(); - shown.dismiss(); - }) - .create(); - dialog.show(); - LightDialog.apply(dialog); - } - - private void notifyAdAudioRuntime() { - PlaybackService service = Server.get().getService(); - if (service == null || service.player() == null || service.player().isReleased()) return; - service.player().reloadAdAudioSettings(); - } - @Override protected void onResume() { super.onResume(); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAiActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAiActivity.java index 665d3f822e..1d7c06b8b6 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAiActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAiActivity.java @@ -14,13 +14,13 @@ import com.fongmi.android.tv.databinding.ActivitySettingAiBinding; import com.fongmi.android.tv.service.RecommendationFeedbackStore; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.subtitle.RealtimeSubtitleController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.AiConfigDialog; import com.fongmi.android.tv.ui.dialog.RecommendationFeedbackDialog; import com.fongmi.android.tv.ui.dialog.SubtitleSettingsDialog; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class SettingAiActivity extends BaseActivity { @@ -86,7 +86,7 @@ public class SettingAiActivity extends BaseActivity { setText(); return; } - new MaterialAlertDialogBuilder(this) + new WebHtvAlertDialogBuilder(this) .setTitle(R.string.personal_recommendation_confirm_title) .setMessage(R.string.personal_recommendation_confirm_message) .setNegativeButton(R.string.dialog_negative, null) 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 index e1096b22be..8383784dd8 100644 --- 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 @@ -16,7 +16,6 @@ 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; @@ -59,7 +58,6 @@ public class SettingAppearanceActivity extends BaseActivity { 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); @@ -89,12 +87,7 @@ public class SettingAppearanceActivity extends BaseActivity { } private void onWall(View view) { - ConfigDialog.create().wall().show(this); - } - - private boolean onWallEdit(View view) { - ConfigDialog.create().wall().edit().show(this); - return true; + HistoryDialog.create().wall().manage().show(this); } private void setWallDefault(View view) { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingEnhanceActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingEnhanceActivity.java index fefc6d7d63..2ec888fb8f 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingEnhanceActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingEnhanceActivity.java @@ -4,6 +4,7 @@ import android.app.Activity; import android.content.Intent; import android.net.Uri; import android.os.Bundle; +import android.util.TypedValue; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; @@ -172,8 +173,8 @@ public class SettingEnhanceActivity extends BaseActivity { private void setShellProxyText() { safeRun("shellProxy", () -> { int count = ProxySetting.count(); - mBinding.shellProxyText.setText(getSwitch(Setting.isShellProxy()) + " · " + getString(R.string.setting_proxy_rule_count, count)); - mBinding.shellProxyConfigText.setText(getString(R.string.setting_proxy_rule_count, count)); + setSummary(mBinding.shellProxyText, getSwitch(Setting.isShellProxy()) + " · " + getString(R.string.setting_proxy_rule_count, count)); + setSummary(mBinding.shellProxyConfigText, getString(R.string.setting_proxy_rule_count, count)); }, () -> { setError(mBinding.shellProxyText); setError(mBinding.shellProxyConfigText); @@ -188,12 +189,55 @@ public class SettingEnhanceActivity extends BaseActivity { return; } CustomCspSetting.Count count = status.count(); - mBinding.customCspText.setText(getSwitch(status.enabled()) + " · " + getString(R.string.setting_custom_csp_count, count.active(), count.enabled())); + setSummary(mBinding.customCspText, getSwitch(status.enabled()) + " · " + getString(R.string.setting_custom_csp_count, count.active(), count.enabled())); }, () -> setError(mBinding.customCspText)); } private void safeSet(String name, TextView view, TextSupplier supplier) { - safeRun(name, () -> view.setText(supplier.get()), () -> setError(view)); + safeRun(name, () -> setSummary(view, supplier.get()), () -> setError(view)); + } + + /** + * 写入取值摘要,并把颜色复位到常规壁纸前景色。 + * + *

复位这一步是必需的:{@link #setError} 会把同一个 TextView 染成错误色,而这些摘要 + * 全部**复用同一个控件**——不复位的话,某一行失败一次之后,即使后续刷新成功, + * 它也会一直显示为红色。 + */ + private void setSummary(TextView view, CharSequence text) { + if (view == null) return; + view.setText(text); + view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaper)); + // 撤掉错误底材:错误态是"临时"的,刷新成功必须完全回到原样(这些 TextView 原本没有背景)。 + view.setBackground(null); + } + + /** + * 内联错误:设计文档 §9.9 要求错误态有「错误色」。 + * + *

不能直接用 {@code ?attr/colorError}:那是跟随调色板的角色,浅色表的深红 + * ({@code #B3261E})压在深色壁纸行色带上实测只有 1.06:1,完全不可读。这里用与壁纸 + * 同族的 {@code ?attr/webhtvColorOnWallpaperError}(同一底材实测 4.05–4.50:1)。 + * + *

底材:错误色直接画在行色带上只有 4.05–4.50:1,略低于正文门槛 4.5:1,而继续调色无解 + * (要达标就得接近纯白,错误色随之失去语义)。因此这里同时换上一块**实心深底** + * ({@code @drawable/shape_error_value}),核算为 8.84:1,稳过门槛。 + * + *

如实记录:该取值在行色带最亮处是 4.05:1,**略低于正文门槛 4.5:1**。这是底材 + * (中亮度壁纸)决定的取舍,理由与替代方案见 {@code values/webhtv_tokens.xml} 的同名条目 + * 与 docs/TV-ERROR-STATE-20261012.md §7。 + */ + private void setError(TextView view) { + if (view == null) return; + view.setText(R.string.error_config_get); + view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaperError)); + view.setBackgroundResource(R.drawable.shape_error_value); + } + + /** 解析主题属性里的颜色值;未赋值时返回 0(透明)。 */ + private int themeColor(int attr) { + TypedValue value = new TypedValue(); + return getTheme().resolveAttribute(attr, value, true) ? value.data : 0; } private void safeRun(String name, Runnable action, Runnable fallback) { @@ -209,22 +253,18 @@ public class SettingEnhanceActivity extends BaseActivity { } } - private void setError(TextView view) { - if (view != null) view.setText(R.string.error_config_get); - } - private interface TextSupplier { CharSequence get(); } private void setDriveCheck(View view) { Setting.putDriveCheck(!Setting.isDriveCheck()); - mBinding.driveCheckText.setText(getSwitch(Setting.isDriveCheck())); + setSummary(mBinding.driveCheckText, getSwitch(Setting.isDriveCheck())); } private void setDebugLog(View view) { Setting.putDebugLog(!Setting.isDebugLog()); - mBinding.debugLogText.setText(getSwitch(Setting.isDebugLog())); + setSummary(mBinding.debugLogText, getSwitch(Setting.isDebugLog())); if (!Setting.isDebugLog()) return; DebugLogDialog.show(this); } @@ -250,12 +290,12 @@ public class SettingEnhanceActivity extends BaseActivity { private void setPlaybackArtworkWall(View view) { Setting.putPlaybackArtworkWall(!Setting.isPlaybackArtworkWall()); - mBinding.playbackArtworkWallText.setText(getSwitch(Setting.isPlaybackArtworkWall())); + setSummary(mBinding.playbackArtworkWallText, getSwitch(Setting.isPlaybackArtworkWall())); } private void setWebHomeFullscreen(View view) { Setting.putWebHomeFullscreen(!Setting.isWebHomeFullscreen()); - mBinding.webHomeFullscreenText.setText(getSwitch(Setting.isWebHomeFullscreen())); + setSummary(mBinding.webHomeFullscreenText, getSwitch(Setting.isWebHomeFullscreen())); } private void setCspWarmup(View view) { 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..423311c349 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 @@ -19,6 +19,7 @@ import com.fongmi.android.tv.setting.AutoBackupPolicy; import com.fongmi.android.tv.setting.GroupRuleConfig; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.GroupRuleDialog; import com.fongmi.android.tv.ui.dialog.HomeButtonDialog; @@ -29,7 +30,6 @@ 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; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.Locale; @@ -250,7 +250,7 @@ public class SettingPersonalActivity extends BaseActivity { } private void showResetAppDialog(View view) { - new MaterialAlertDialogBuilder(this) + new WebHtvAlertDialogBuilder(this) .setTitle(R.string.dialog_reset_app) .setMessage(R.string.dialog_reset_app_data) .setNegativeButton(R.string.dialog_negative, null) 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..9936dcc437 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 @@ -87,6 +87,7 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B setPreloadText(); mBinding.autoPlayText.setText(getSwitch(PlayerSetting.isAutoPlay())); mBinding.autoChangeText.setText(getSwitch(PlayerSetting.isAutoChange())); + mBinding.deferFlagSwitchText.setText(getSwitch(PlayerSetting.isDeferFlagSwitchPlayback())); 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())); @@ -134,6 +135,7 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.preloadPause.setOnClickListener(this::setPreloadPause); mBinding.autoPlay.setOnClickListener(this::setAutoPlay); mBinding.autoChange.setOnClickListener(this::setAutoChange); + mBinding.deferFlagSwitch.setOnClickListener(this::setDeferFlagSwitch); mBinding.liveSourceFallback.setOnClickListener(view -> { LiveSetting.putSourceFallback(!LiveSetting.isSourceFallback()); mBinding.liveSourceFallbackText.setText(getSwitch(LiveSetting.isSourceFallback())); @@ -379,6 +381,11 @@ public class SettingPlayerActivity extends BaseActivity implements UaListener, B mBinding.autoChangeText.setText(getSwitch(PlayerSetting.isAutoChange())); } + private void setDeferFlagSwitch(View view) { + PlayerSetting.putDeferFlagSwitchPlayback(!PlayerSetting.isDeferFlagSwitchPlayback()); + mBinding.deferFlagSwitchText.setText(getSwitch(PlayerSetting.isDeferFlagSwitchPlayback())); + } + private void setFailureFallback(View view) { ChoiceDialog.showSingle(this, R.string.player_failure_fallback, failureFallback, PlayerSetting.getFailureFallback(), which -> { PlayerSetting.putFailureFallback(which); 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..eda4065e46 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 @@ -12,10 +12,10 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.ActivitySettingTmdbBinding; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; 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.google.android.material.dialog.MaterialAlertDialogBuilder; public class SettingTmdbActivity extends BaseActivity { @@ -92,7 +92,7 @@ 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) -> { + AlertDialog alert = new WebHtvAlertDialogBuilder(this, R.style.Theme_WebHTV_Dialog).setTitle(R.string.setting_detail_open_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailOpenModes(), getDetailOpenModeIndex(), (dialog, which) -> { int mode = DETAIL_OPEN_MODES[which]; Setting.putDetailOpenMode(mode); setText(); @@ -109,7 +109,7 @@ public class SettingTmdbActivity extends BaseActivity { private void setDetailThemeMode(View view) { if (!Setting.isTmdbMode(Setting.getDetailOpenMode())) return; - AlertDialog alert = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog).setTitle(R.string.setting_detail_theme_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailThemeModes(), getDetailThemeModeIndex(), (dialog, which) -> { + AlertDialog alert = new WebHtvAlertDialogBuilder(this, R.style.Theme_WebHTV_Dialog).setTitle(R.string.setting_detail_theme_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailThemeModes(), getDetailThemeModeIndex(), (dialog, which) -> { Setting.putTmdbDetailStyle(DETAIL_THEME_MODES[which]); 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..8401f0d9ff 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 @@ -9,6 +9,7 @@ import android.media.AudioManager; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; +import android.os.SystemClock; import android.text.TextUtils; import android.text.style.ClickableSpan; import android.view.KeyEvent; @@ -57,6 +58,8 @@ import com.fongmi.android.tv.bean.Danmaku; import com.fongmi.android.tv.bean.Episode; import com.fongmi.android.tv.bean.EpisodePositionCache; import com.fongmi.android.tv.bean.Flag; +import com.fongmi.android.tv.bean.FlagPreferenceCache; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.helper.EpisodeSeasonSnapshot; import com.fongmi.android.tv.bean.History; import com.fongmi.android.tv.bean.Keep; @@ -102,7 +105,9 @@ 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.FlagSwitchPlaybackPolicy; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.PlaybackMenuScrim; import com.fongmi.android.tv.setting.SiteHealthStore; import com.fongmi.android.tv.setting.DetailRuntimeModePolicy; import com.fongmi.android.tv.setting.TmdbSitePolicy; @@ -180,6 +185,7 @@ import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Sniffer; import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.utils.TmdbDetailCache; +import com.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.fongmi.android.tv.utils.Util; import com.fongmi.android.tv.utils.VodDetailCache; import com.github.catvod.crawler.SpiderDebug; @@ -259,6 +265,14 @@ import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; public class VideoActivity extends PlaybackActivity implements CustomKeyDownVod.Listener, TrackDialog.Listener, ArrayAdapter.OnClickListener, FlagAdapter.OnClickListener, EpisodeAdapter.OnClickListener, QualityAdapter.OnClickListener, QuickAdapter.OnClickListener, ParseAdapter.OnClickListener, Clock.Callback, SubtitlePlaybackSession.Host, com.fongmi.android.tv.ui.host.TmdbDetailHost, ControlDialog.Listener, CastDialog.Listener, com.fongmi.android.tv.ui.novel.NovelReaderHost { + /** + * 用户拖拽 seek 时加载圈的最小可见时长。 + * + *

必须盖过一个完整的网速采样间隔({@code setTraffic} 每秒一跳),因为读数是由 + * 相邻两次采样的字节差算出的:窗口短于一跳就永远只能拿到没有间隔的首跳(空白)。 + * 取 1.2s 留出余量,避免收圈与首跳采样同帧竞争。 + */ + private static final long SEEK_PROGRESS_MIN_VISIBLE_MS = 1200L; private static final long LYRICS_OFFSET_MIN_MS = -5000L; private static final long LYRICS_OFFSET_MAX_MS = 5000L; private static final long LYRICS_OFFSET_STEP_MS = 500L; @@ -347,6 +361,10 @@ private boolean runtimeSourceOnly; private static final int TMDB_OVERVIEW_ROW_GAP_DP = 12; private static final int TMDB_OVERVIEW_BOTTOM_GUARD_DP = 6; private static final int OMDB_FULL_RATING_TEXT_MAX_LENGTH = 20; + // 原生增强播放页统一焦点规范,取值与 app/src/main/res/drawable/selector_video_item.xml 一致: + // 焦点 3dp @color/tv_item_focus_ring,当前播放 2dp @color/tv_item_current_ring,常态 1dp @color/tv_item_normal_stroke。 + private static final int TV_ITEM_FOCUS_WIDTH_DP = 3; + private static final int TV_ITEM_NORMAL_WIDTH_DP = 1; private static final String EXTRA_TMDB_PLAY_FLAG = "tmdb_play_flag"; private static final String EXTRA_TMDB_PLAY_FLAG_KEY = "tmdb_play_flag_key"; private static final String EXTRA_TMDB_PLAY_EPISODE_NAME = "tmdb_play_episode_name"; @@ -378,6 +396,8 @@ private boolean runtimeSourceOnly; private PartAdapter mPartAdapter; private BackdropAdapter mBackdropAdapter; private Map mActionButtons; + private boolean mProgrammaticFlagSwitch; + private boolean mDeferFlagSwitchPlayback; private final List mCustomActionViews = new ArrayList<>(); private HorizontalScrollView mCustomPortraitButtons; private LinearLayout mCustomPortraitButtonRow; @@ -420,6 +440,8 @@ private boolean runtimeSourceOnly; private Runnable mR1; private Runnable mR2; private Runnable mSeekProgressFallback; + private boolean mSeekProgressPending; + private long mSeekProgressStartedAtMs; private Runnable mTmdbDetailTimeout; private Runnable mTmdbEpisodeTimeout; private final Runnable mPendingTmdbBind = this::flushPendingTmdbBind; @@ -493,6 +515,8 @@ private boolean runtimeSourceOnly; private String playerContentFlag = ""; private String playerContentEpisode = ""; private Result mAppliedPlayerResult; + private boolean mPlaybackRequestActive; + private boolean mPlaybackPlayerStarted; private final ShortDramaQueueCoordinator mShortDramaQueue = new ShortDramaQueueCoordinator(); private PlaySpec mShortDramaQueuedSpec; private Result mShortDramaQueuedResult; @@ -766,6 +790,11 @@ private boolean runtimeSourceOnly; start(activity, key, id, name, pic, mark, false, false, tmdbItem); } + /** 追更页通过 flavor 专用入口调用,并遵循当前详情页模式。 */ + public static void startFromFollowingHistory(Activity activity, History item) { + startFromHistory(activity, item); + } + public static void startFromHistory(Activity activity, History item) { if (shouldOpenLegacyTmdbDetail(item.getSiteKey(), item.getVodId(), false)) { TmdbDetailActivity.startFromHistory(activity, item); @@ -1198,6 +1227,7 @@ private boolean runtimeSourceOnly; ? com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, index) : mTmdbUIAdapter == null ? "" : mTmdbUIAdapter.activeFlagKey(flag); mHistory.setSourceBindingKey(flagKey); + syncHistory(); } private Flag resolveHistoryPlaybackFlag(List flags) { @@ -1205,11 +1235,75 @@ private boolean runtimeSourceOnly; String flagKey = mHistory == null ? "" : mHistory.getSourceBindingKey(); String flagName = mHistory == null ? "" : mHistory.getVodFlag(); String episodeUrl = mHistory == null ? "" : mHistory.getEpisodeUrl(); + // 用户最后一次显式切换的线路优先于 History 里的线路快照:History.vodFlag 只是 + // 「上次起播时的线路」的副产物,切线路而没重新起播时它不会更新,杀进程后若还 + // 以它为准就会退回旧线路甚至第一条。偏好同样要能定位到这次要播的集, + // 否则会劫持跨源解算出的、确实含目标集的那条线路。 + Flag preferred = findPreferredFlag(flags, episodeUrl); + if (preferred != null) return preferred; Flag selected = com.fongmi.android.tv.ui.helper.TmdbUIAdapter.selectPlaybackFlag( flags, flagKey, episodeUrl, flagName); return selected == null ? flags.get(0) : selected; } + /** + * 记住用户显式切换的线路。线路选择原本只作为播放副产物写进 History,而切线路后 + * 不一定重新起播(seamless 没切集时不会走 onRefresh),mHistory 的 vodFlag/ + * sourceBindingKey 仍指向旧线路,杀进程后就退回了旧线路。这里与详情页保持一致, + * 切一下就落盘偏好,不依赖播放进度。 + */ + private void rememberFlagSelection(Flag flag) { + if (flag == null) return; + int index = mFlagAdapter == null ? -1 : mFlagAdapter.indexOf(flag); + if (index < 0 && mVod != null) index = com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagIndex(mVod.getFlags(), flag); + // 索引未知时不写稳定键:Flag.stableKey 会把 -1 夹成 0,留下一个必然命中首条线路的假键。 + String stableKey = index >= 0 ? com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, index) : ""; + FlagPreferenceCache.get().put(getKey(), getId(), stableKey, flag.getFlag()); + Task.execute(() -> FlagPreferenceCache.get().save()); + if (mHistory == null) return; + // History 里的 sourceBindingKey 与既有 setHistoryFlag 保持一致:稳定键不可用时退化到适配器当前键。 + String historyKey = TextUtils.isEmpty(stableKey) + ? (mTmdbUIAdapter == null ? "" : mTmdbUIAdapter.activeFlagKey(flag)) + : stableKey; + mHistory.setVodFlag(flag.getFlag()); + if (!TextUtils.isEmpty(historyKey)) mHistory.setSourceBindingKey(historyKey); + // 还没起播(无进度)时不写库,避免在「最近观看」里留下一条没有进度的空记录。 + if (mHistory.canSave()) syncHistory(); + } + + /** + * 读取上次显式选中的线路。稳定键(线路名#索引)优先,源站线路增删导致索引漂移时 + * 退化用线路名匹配。episodeUrl 非空时要求该线路确实含这一集。 + */ + private Flag findPreferredFlag(List flags, String episodeUrl) { + FlagPreferenceCache.FlagPreference preference = FlagPreferenceCache.get().get(getKey(), getId()); + if (preference == null) return null; + Flag candidate = null; + String stableKey = preference.getStableKey(); + if (!TextUtils.isEmpty(stableKey)) { + for (int i = 0; i < flags.size(); i++) { + Flag flag = flags.get(i); + if (flag != null && TextUtils.equals(stableKey, com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, i))) { + candidate = flag; + break; + } + } + } + if (candidate == null) { + String flagName = preference.getFlagName(); + if (TextUtils.isEmpty(flagName)) return null; + for (Flag flag : flags) { + if (flag != null && TextUtils.equals(flagName, flag.getFlag())) { + candidate = flag; + break; + } + } + } + if (candidate == null) return null; + if (TextUtils.isEmpty(episodeUrl) || candidate.containsEpisodeUrl(Episode.create("", episodeUrl))) return candidate; + return null; + } + private Episode getEpisode() { Flag flag = getFlag(); if (flag != null && !flag.getEpisodes().isEmpty()) return flag.getEpisodes().get(getSelectedEpisodePosition(flag.getEpisodes())); @@ -1221,6 +1315,17 @@ private boolean runtimeSourceOnly; for (int i = 0; i < episodes.size(); i++) if (episodes.get(i).isSelected()) return i; if (mHistory == null) return 0; Episode historyEpisode = mHistory.getEpisode(); + // 历史集 URL 能定位到当前列表条目时(同集多版本并存)先按 URL 锁定版本, + // 否则“集号相同即同集”会把用户点过的第二版本误认成第一版本; + // 换线路/换源/源站刷新后 URL 必然失配,交回下面的集号容错,保住跨线路续播与刷新后选中。 + for (int i = 0; i < episodes.size(); i++) { + if (TextUtils.isEmpty(historyEpisode.getUrl())) break; + if (!TextUtils.equals(historyEpisode.getUrl(), episodes.get(i).getUrl())) continue; + if (episodes.get(i).getTmdbEpisode() != null && episodes.get(i).getTmdbEpisode().getNumber() > 0 + && historyEpisode.getTmdbEpisode() != null && historyEpisode.getTmdbEpisode().getNumber() > 0 + && !episodes.get(i).matchesNumber(historyEpisode)) continue; + return i; + } for (int i = 0; i < episodes.size(); i++) if (episodes.get(i).matchesPlayback(historyEpisode)) return i; return 0; } @@ -1409,6 +1514,8 @@ private boolean runtimeSourceOnly; if (mViewModel != null) mViewModel.cancelPlayerContent(); invalidatePlayerContent(); mAppliedPlayerResult = null; + mPlaybackRequestActive = false; + mPlaybackPlayerStarted = false; playerKernelSwitchRequestId++; setAudioStageVisible(false); restoreImmersiveAudioRequest(); @@ -1474,7 +1581,8 @@ private boolean runtimeSourceOnly; } private void applyPlaybackOverlay() { - mBinding.control.getRoot().setBackgroundResource(Setting.isPlaybackOverlayEnabled() ? R.drawable.shape_controller_scrim : R.color.transparent); + if (Setting.isPlaybackOverlayEnabled()) mBinding.control.getRoot().setBackground(PlaybackMenuScrim.create(this)); + else mBinding.control.getRoot().setBackgroundResource(R.color.transparent); } /** @@ -1536,6 +1644,10 @@ private boolean runtimeSourceOnly; mBinding.control.action.lut.setOnClickListener(guarded(this::onLut)); mBinding.control.action.speed.setOnClickListener(guarded(this::onSpeed)); mBinding.control.action.reset.setOnClickListener(guarded(this::onReset)); + // 错误层的重试复用控制层同一条「重做本次播放」动作:onReset() 会按用户的「重置」 + // 设置决定从头重播还是重新拉流,所以不新写播放路径,也就不会与既有的自动换源 + // (startFlow)产生第二套恢复逻辑。重试成功后的收尾由 showProgress() 负责隐藏错误层。 + mBinding.widget.retry.setOnClickListener(guarded(this::onReset)); mBinding.control.action.title.setOnClickListener(guarded(this::onTitle)); mBinding.control.action.player.setOnClickListener(guarded(this::onPlayerKernel)); mBinding.control.action.player.setOnLongClickListener(view -> onPlayerKernelLong()); @@ -1613,7 +1725,7 @@ private boolean runtimeSourceOnly; private void setupIntroSkipConfirmListener() { mIntroSkipPlayback.setSkipConfirmListener((segment, action) -> { if (mIntroSkipConfirmDialog != null && mIntroSkipConfirmDialog.isShowing()) return false; - mIntroSkipConfirmDialog = new com.google.android.material.dialog.MaterialAlertDialogBuilder(this) + mIntroSkipConfirmDialog = new WebHtvAlertDialogBuilder(this) .setTitle(R.string.intro_skip_confirm_title) .setMessage(IntroSkipKinds.confirmMessage(segment)) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) @@ -1739,6 +1851,11 @@ private boolean runtimeSourceOnly; mBinding.tmdbCast.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); mBinding.tmdbPhotos.setHorizontalSpacing(ResUtil.dp2px(12)); mBinding.tmdbPhotos.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + // 海报行此前漏配 rowHeight(拆分剧照/海报时新增,只加了布局没加这里的配置): + // Leanback 的 HorizontalGridView 不设 rowHeight 时行高塌陷为 0, + // 于是“海报”标签正常显示但一张卡片都看不到。 + mBinding.tmdbPosters.setHorizontalSpacing(ResUtil.dp2px(12)); + mBinding.tmdbPosters.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); mBinding.tmdbCrew.setHorizontalSpacing(ResUtil.dp2px(12)); mBinding.tmdbCrew.setRowHeight(ViewGroup.LayoutParams.WRAP_CONTENT); mBinding.tmdbRecommendations.setHorizontalSpacing(ResUtil.dp2px(12)); @@ -1754,6 +1871,76 @@ private boolean runtimeSourceOnly; attachRecommendationLazyLoader(mBinding.tmdbRecommendations, RecommendationRow.RECOMMENDATIONS); attachRecommendationLazyLoader(mBinding.tmdbPersonalTmdbRecommendations, RecommendationRow.PERSONAL_TMDB); attachRecommendationLazyLoader(mBinding.tmdbPersonalDoubanRecommendations, RecommendationRow.PERSONAL_DOUBAN); + for (HorizontalGridView row : tmdbMediaRows()) installRowCardFocusLinks(row); + } + + /** TMDB 详情区块的横向行,顺序与 getEpisodeFocusOrders() 的纵向焦点链保持一致。 */ + private List tmdbMediaRows() { + return Arrays.asList( + mBinding.tmdbCast, + mBinding.tmdbPhotos, + mBinding.tmdbPosters, + mBinding.tmdbRelatedVideos, + mBinding.tmdbCrew, + mBinding.tmdbRecommendations, + mBinding.tmdbPersonalTmdbRecommendations, + mBinding.tmdbPersonalDoubanRecommendations, + mBinding.tmdbPersonalAiRecommendations); + } + + /** + * 卡片被 RecyclerView 回收后重新接入时,补写它自己的上下焦点目标。 + * 只靠容器的 nextFocusDown 不生效:焦点此刻在卡片上,Android 优先读卡片自身的声明。 + */ + private void installRowCardFocusLinks(HorizontalGridView grid) { + grid.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() { + @Override + public void onChildViewAttachedToWindow(@NonNull View child) { + applyCardFocusLinks(grid, child); + } + + @Override + public void onChildViewDetachedFromWindow(@NonNull View child) { + } + }); + } + + private void applyCardFocusLinks(HorizontalGridView grid, View card) { + card.setNextFocusUpId(grid.getNextFocusUpId()); + card.setNextFocusDownId(grid.getNextFocusDownId()); + } + + /** + * 按当前可见行重排纵向焦点链:每行只指向上下相邻的可见行,跳过已隐藏的区块。 + * 之前只给容器设 nextFocusDown,遥控在行内卡片上按向下会退回几何搜索, + * 于是出现“第一张剧照下不去、第二张可以”这类与横向位置相关的不确定行为。 + */ + private void applyTmdbRowFocusChain() { + List candidates = tmdbMediaRows(); + boolean[] present = new boolean[candidates.size()]; + for (int i = 0; i < candidates.size(); i++) { + HorizontalGridView row = candidates.get(i); + present[i] = row != null && row.getVisibility() == View.VISIBLE && row.getAdapter() != null && row.getAdapter().getItemCount() > 0; + } + // 空行(没数据或标签与行一起隐藏)被直接跳过:剧照下面没有海报就落到相关视频, + // 相关视频也没有就落到主创团队——焦点永远交给一个真实存在的行。 + int[][] links = com.fongmi.android.tv.ui.helper.TmdbRowFocusChain.link(present); + int ratingsId = 0; + ViewGroup ratings = mBinding.getRoot().findViewById(R.id.tmdbOmdbRatings); + if (ratings != null && isVisible(ratings)) ratingsId = R.id.tmdbOmdbRatings; + for (int i = 0; i < candidates.size(); i++) { + HorizontalGridView row = candidates.get(i); + if (row == null || !present[i]) continue; + int upIndex = links[i][0]; + int downIndex = links[i][1]; + int upId = upIndex < 0 ? ratingsId : candidates.get(upIndex).getId(); + int downId = downIndex < 0 ? R.id.flag : candidates.get(downIndex).getId(); + row.setNextFocusUpId(upId == 0 ? View.NO_ID : upId); + row.setNextFocusDownId(downId); + for (int child = 0; child < row.getChildCount(); child++) { + applyCardFocusLinks(row, row.getChildAt(child)); + } + } } private void setVideoView() { @@ -1921,7 +2108,7 @@ private boolean runtimeSourceOnly; * 没有记录时 getPlayerOrDefault 会退回设置页的全局默认。 * 播放服务还没连上时先只记会话内核(取址在工作线程上读它),引擎由 onServiceConnected 补齐。 */ - private int applyHistoryPlayerKernel() { + private int applyHistoryPlayerKernel(boolean forcePrepare) { int kernel = mHistory == null ? PlayerSetting.getPlayer() : mHistory.getPlayerOrDefault(); PlayerSetting.putActivePlayer(kernel); if (service() == null) { @@ -1929,12 +2116,16 @@ private boolean runtimeSourceOnly; return kernel; } mPendingPlayerKernel = PlayerSetting.NONE; - player().preparePlayer(kernel); + player().preparePlayer(kernel, forcePrepare); setPlayerKernel(); setDecode(); return kernel; } + private int applyHistoryPlayerKernel() { + return applyHistoryPlayerKernel(false); + } + /** * 服务连上后补齐本页在等的内核。 * 服务可能是上一次播放留活下来的,它建 PlayerManager 时读到的还是上一部剧的内核, @@ -2200,7 +2391,8 @@ private boolean runtimeSourceOnly; if (!TextUtils.isEmpty(overview) && (TextUtils.isEmpty(item.getContent()) || overview.length() > item.getContent().length())) { item.setContent(overview); } - String title = firstNonEmpty(cachedTmdbString(detail, "title"), cachedTmdbString(detail, "name"), cached.getItem() == null ? "" : cached.getItem().getTitle()); + String title = new com.fongmi.android.tv.service.TmdbService().preferredTitle(cached.getItem(), detail, currentTmdbConfig()); + if (TextUtils.isEmpty(title)) title = firstNonEmpty(cachedTmdbString(detail, "title"), cachedTmdbString(detail, "name"), cached.getItem() == null ? "" : cached.getItem().getTitle()); if (!TextUtils.isEmpty(title) && TextUtils.isEmpty(item.getName())) item.setName(title); String artwork = firstNonEmpty(cachedFastTmdbPoster(cached), cachedFastTmdbBackdrop(cached)); if (!TextUtils.isEmpty(artwork) && TextUtils.isEmpty(item.getPic())) item.setPic(artwork); @@ -2228,7 +2420,7 @@ private boolean runtimeSourceOnly; private TmdbDetailCache.Entry takeFastTmdbDetailCache() { if (mFastTmdbDetailCacheChecked) return mFastTmdbDetailCache; mFastTmdbDetailCacheChecked = true; - mFastTmdbDetailCache = TmdbDetailCache.take(getIntent().getStringExtra(TmdbDetailCache.EXTRA_KEY), getTmdbItem()); + mFastTmdbDetailCache = TmdbDetailCache.take(getIntent().getStringExtra(TmdbDetailCache.EXTRA_KEY), getTmdbItem(), currentTmdbLanguage()); if (mFastTmdbDetailCache != null) { TmdbItem item = mFastTmdbDetailCache.getItem(); SpiderDebug.log("video-flow", "fast tmdb detail memory-cache hit title=%s media=%s id=%d", item == null ? "" : item.getTitle(), item == null ? "" : item.getMediaType(), item == null ? 0 : item.getTmdbId()); @@ -2237,56 +2429,23 @@ private boolean runtimeSourceOnly; } private String cachedTmdbOverview(JsonObject detail) { - String overview = cachedTmdbString(detail, "overview"); - if (!TextUtils.isEmpty(overview)) return overview.trim(); - JsonArray translations = cachedTmdbArray(cachedTmdbObject(detail, "translations"), "translations"); - String language = cachedTmdbLanguage(); - overview = cachedTmdbOverviewForLanguage(translations, language); - if (!TextUtils.isEmpty(overview)) return overview; - overview = cachedTmdbOverviewForLanguage(translations, cachedTmdbLanguageRoot(language)); - if (!TextUtils.isEmpty(overview)) return overview; - overview = cachedTmdbOverviewForLanguage(translations, "zh-CN"); - if (!TextUtils.isEmpty(overview)) return overview; - overview = cachedTmdbOverviewForLanguage(translations, "zh"); - if (!TextUtils.isEmpty(overview)) return overview; - return cachedTmdbOverviewForLanguage(translations, "en"); - } - - private String cachedTmdbOverviewForLanguage(JsonArray translations, String language) { - if (translations == null || TextUtils.isEmpty(language)) return ""; - String target = language.toLowerCase(Locale.ROOT); - for (JsonElement element : translations) { - if (element == null || !element.isJsonObject()) continue; - JsonObject object = element.getAsJsonObject(); - String iso = cachedTmdbString(object, "iso_639_1"); - String name = firstNonEmpty(cachedTmdbString(object, "name"), cachedTmdbString(object, "english_name")); - String code = cachedTmdbString(object, "iso_3166_1"); - if (!cachedTmdbLanguageMatches(target, iso, code, name)) continue; - String overview = cachedTmdbString(cachedTmdbObject(object, "data"), "overview"); - if (!TextUtils.isEmpty(overview)) return overview.trim(); - } - return ""; - } - - private boolean cachedTmdbLanguageMatches(String target, String iso, String code, String name) { - String root = cachedTmdbLanguageRoot(target); - if (target.equalsIgnoreCase(iso) || target.equalsIgnoreCase(iso + "-" + code)) return true; - if (!TextUtils.isEmpty(root) && root.equalsIgnoreCase(iso)) return true; - return target.equalsIgnoreCase(name); - } - - private String cachedTmdbLanguage() { try { - return com.fongmi.android.tv.bean.TmdbConfig.effectiveCurrent().getLanguage(); + return new com.fongmi.android.tv.service.TmdbService().translatedOverview(detail, currentTmdbConfig()); } catch (Throwable e) { - return ""; + return cachedTmdbString(detail, "overview"); } } - private String cachedTmdbLanguageRoot(String language) { - if (TextUtils.isEmpty(language)) return ""; - int separator = language.indexOf('-'); - return separator > 0 ? language.substring(0, separator) : language; + private com.fongmi.android.tv.bean.TmdbConfig currentTmdbConfig() { + try { + return com.fongmi.android.tv.bean.TmdbConfig.effectiveCurrent(); + } catch (Throwable e) { + return null; + } + } + + private String currentTmdbLanguage() { + return TmdbLanguagePolicy.requestLanguage(currentTmdbConfig()); } private String cachedTmdbImage(JsonObject detail, String key) { @@ -2371,7 +2530,9 @@ private boolean runtimeSourceOnly; // 快速 TMDB 播放在历史续播或聚合开启时,允许同一标准季集跨线路共享进度。 boolean crossSource = mHistory.isCrossSourcePlayback(); boolean shareEpisodeProgress = crossSource || isResumeFromHistory() || Setting.isHistoryAggregationEffective(); - boolean sameEpisode = historyEpisode.matchesPlayback(mHistory.getEpisode()); + // 历史集 URL 能定位到当前线路条目(同集多版本并存)时才启用版本消歧;换线路/刷新保留集号容错。 + boolean versionAware = flag.containsEpisodeUrl(mHistory.getEpisode()); + boolean sameEpisode = historyEpisode.matchesPlayback(mHistory.getEpisode(), versionAware); boolean compatibleFlag = shareEpisodeProgress || TextUtils.equals(mHistory.getVodFlag(), flag.getFlag()); if (!sameEpisode || !compatibleFlag) mIntroSkipPlayback.reset(); if (!sameEpisode || !compatibleFlag) { @@ -2562,7 +2723,7 @@ private boolean runtimeSourceOnly; } private void setEmpty(boolean finish) { - if (isFromCollect() || finish) { + if (finish) { finish(); } else if (getName().isEmpty()) { showEmpty(); @@ -2752,26 +2913,42 @@ private boolean runtimeSourceOnly; } // 简介移到站源行下方显示;内容相同时不重置 maxLines,避免播放页再次布局闪动。 - Object desc = mBinding.content.getTag(); - String overview = desc == null ? "" : desc.toString(); - if (!TextUtils.isEmpty(overview)) { - CharSequence overviewText = getString(R.string.detail_content, overview); - if (!TextUtils.equals(mBinding.tmdbOverview.getText(), overviewText)) { - mBinding.tmdbOverview.setSingleLine(false); - mBinding.tmdbOverview.setHorizontallyScrolling(false); - mBinding.tmdbOverview.setMaxLines(Integer.MAX_VALUE); - mBinding.tmdbOverview.setText(overviewText); - mBinding.tmdbOverview.post(this::updateTmdbOverviewButton); - } - mBinding.tmdbOverview.setVisibility(View.VISIBLE); - } else { - mBinding.tmdbOverview.setVisibility(View.GONE); - } + updateOverviewPreview(); // 简介按钮默认隐藏,焦点先把视频右移到简介按钮(按钮显示时)或搜索按钮 mBinding.video.setNextFocusRightId(R.id.content); } + /** + * 把详情文本(mBinding.content 的 tag)铺到简介区做「预览一部分」。 + * + * 影视原生 (DIRECT) 模式下 mTmdbUIAdapter 为 null,applyTmdbDetailFields() 永不运行, + * 此前没有任何代码写这个节点,于是简介只能靠点按钮弹窗看,用户报告「简介不显示, + * 必须点击简介才能弹窗,以前这里会显示一部分」。 + */ + private void updateOverviewPreview() { + Object desc = mBinding.content.getTag(); + String overview = desc == null ? "" : desc.toString(); + if (TextUtils.isEmpty(overview)) { + mBinding.tmdbOverview.setText(""); + mBinding.tmdbOverview.setVisibility(View.GONE); + return; + } + CharSequence overviewText = getString(R.string.detail_content, overview); + if (!TextUtils.equals(mBinding.tmdbOverview.getText(), overviewText)) { + mBinding.tmdbOverview.setSingleLine(false); + mBinding.tmdbOverview.setHorizontallyScrolling(false); + mBinding.tmdbOverview.setMaxLines(Integer.MAX_VALUE); + mBinding.tmdbOverview.setText(overviewText); + } + // 详情列可能已经被沉浸音频收起:setDetail() 里 checkFlag() 排在 setText() 之前, + // checkFlag()->onItemClick()->onRefresh()->getPlayer() 会先 setAudioStageVisible(true) + // 把整列(含本节点)收掉,这里再无条件下 show 就会把刚收起的简介重新亮在音频面板旁边。 + // 详情列的显示/隐藏以 setVideoDetailsVisible() 为唯一所有者,本写入只负责是否“有内容”。 + mBinding.tmdbOverview.setVisibility(mAudioStageVisible ? View.GONE : View.VISIBLE); + mBinding.tmdbOverview.post(this::updateTmdbOverviewButton); + } + private void updateTmdbOverviewButton() { if (isFinishing() || isDestroyed()) return; TextView view = mBinding.tmdbOverview; @@ -2785,8 +2962,11 @@ private boolean runtimeSourceOnly; } } boolean truncated = isTextTruncated(view); - mBinding.content.setVisibility(truncated ? View.VISIBLE : View.GONE); - mBinding.video.setNextFocusRightId(truncated ? R.id.content : R.id.keep); + // 影视原生没有 TMDB 那条「按钮由富集结果决定」的回显路径,简介按钮是看全文的唯一入口, + // 不能因为文字恰好没被截断就把它藏掉,否则简介区一旦放得下全文就再也点不开。 + boolean directMode = !isTmdbSourceEnabled(); + mBinding.content.setVisibility(truncated || directMode ? View.VISIBLE : View.GONE); + mBinding.video.setNextFocusRightId(truncated || directMode ? R.id.content : R.id.keep); } private int getTmdbOverviewMaxLines(TextView view, android.text.Layout layout) { @@ -2843,13 +3023,30 @@ private boolean runtimeSourceOnly; setText(mBinding.director, R.string.detail_director, item.getDirector()); setText(mBinding.actor, R.string.detail_actor, item.getActor()); setText(mBinding.remark, 0, item.getRemarks()); + // TMDB 模式下简介区由 applyTmdbDetailFields() 按富集结果接管,此处只喂影视原生, + // 否则站源简介会先上屏、TMDB 到达后再换一次,产生用户可见的文本闪动。 + if (!isTmdbSourceEnabled()) updateOverviewPreview(); updateAudioStageText(); } private void setText(TextView view, int resId, String text) { - if (TextUtils.isEmpty(text) && !TextUtils.isEmpty(view.getText())) return; + if (TextUtils.isEmpty(text)) { + // 值缺失时必须清掉文本,不能只置 GONE:带标签的字段渲染出来的是 + // getString(resId, ""),也就是 "导演:" 这种非空字符串,随后 + // setVideoDetailsVisible() 按 !getText().isEmpty() 又把它翻回 VISIBLE, + // 于是详情页出现只有标签、没有内容的空行(用户截图所见)。 + // 保留旧文本则会在换集后泄漏上一条目的元数据,同样靠清空消掉。 + // + // resId == 0(remark)不走这条:它的文本可能由 TMDB 集数信息经另一条路径单独写入, + // 无条件清空会把那条路径的结果抹掉,因此保留原来的"空值不覆盖已有文本"语义。 + if (resId > 0) { + if (!TextUtils.isEmpty(view.getText())) view.setText(null); + view.setVisibility(View.GONE); + } + return; + } String value = resId > 0 ? getString(resId, text) : text; - view.setVisibility(text.isEmpty() ? View.GONE : View.VISIBLE); + view.setVisibility(View.VISIBLE); if (TextUtils.equals(view.getText(), value)) return; view.setText(Sniffer.buildClickable(value, this::clickableSpan), TextView.BufferType.SPANNABLE); view.setLinkTextColor(MDColor.YELLOW_500); @@ -2952,7 +3149,15 @@ private boolean runtimeSourceOnly; } private void setPlayer(Result result) { - if (result == null || isFinishing() || isDestroyed()) return; + // null 结果表示这次取流请求已被作废(SiteViewModel.cancelPlayerContent 会向 PLAYER + // LiveData 写入 null),不会再有结果回调来收圈。守卫若留在这里,onStateChanged 与 + // hidePlaybackProgressIfStale 两条清除路径会永久被挡下,圈再无任何清除路径。 + if (result == null) { + mPlaybackRequestActive = false; + mPlaybackPlayerStarted = false; + return; + } + if (isFinishing() || isDestroyed()) return; SpiderDebug.log("video-flow", "player finish cost=%dms useParse=%s multi=%s msg=%s", System.currentTimeMillis() - playerStartTime, result.shouldUseParse(), result.getUrl().isMulti(), result.getMsg()); if (service() == null) { mPendingPlayer = result; @@ -2964,10 +3169,23 @@ private boolean runtimeSourceOnly; return; } if (!canApplyPlayerResult()) { + // 详情尚未绑定就丢弃这个结果,本次不会再走 startPlayer,也就不会有新的 READY 回调来收圈。 + // 守卫若留在这里,onStateChanged 与 hidePlaybackProgressIfStale 都会被它挡下,圈从此没有 + // 任何清除路径——与下面「同一结果已在播」分支同源,一并释放。 + mPlaybackRequestActive = false; + mPlaybackPlayerStarted = false; SpiderDebug.log("video-flow", "drop player result before detail ready key=%s id=%s", getKey(), getId()); return; } - if (result == mAppliedPlayerResult && !player().isEmpty()) return; + if (result == mAppliedPlayerResult && !player().isEmpty()) { + // 同一个结果已经在播,本次不会再走 startPlayer,也就不会有新的 READY 回调来收圈。 + // 守卫若留在这里,onStateChanged 与 hidePlaybackProgressIfStale 都会被它挡下, + // 圈再没有任何清除路径——正是那个兜底方法要防的「画面在动、圈不走」。 + // 此时播放器已在播同一结果,加载态本身是陈旧的,直接释放。 + mPlaybackRequestActive = false; + mPlaybackPlayerStarted = false; + return; + } mAppliedPlayerResult = result; mQualityAdapter.addAll(result); mQualityAdapter.setPosition(mQualityAdapter.getPosition()); @@ -2982,12 +3200,17 @@ private boolean runtimeSourceOnly; applyAudioQueueMetadata(getPlaybackEpisode()); if (result.hasPosition()) mHistory.setPosition(result.getPosition()); mBinding.control.parse.setVisibility(isUseParse() && PlayerButtonSetting.isVisible(PlayerButtonSetting.PARSE) ? View.VISIBLE : View.GONE); - if (redirectToContentHandler(result)) return; + if (redirectToContentHandler(result)) { + mPlaybackRequestActive = false; + mPlaybackPlayerStarted = false; + 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()); long start = System.currentTimeMillis(); if (SubtitleRestoreCoordinator.restore(mHistory, player(), result)) syncHistory(); + mPlaybackPlayerStarted = true; 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); @@ -3037,6 +3260,8 @@ private boolean runtimeSourceOnly; private void beginPlayerContentRequest(String key, String flag, String episode) { if (mViewModel != null) mViewModel.cancelPlayerContent(); + mPlaybackRequestActive = true; + mPlaybackPlayerStarted = false; mPendingPlayer = null; invalidatePlayerContent(); playerContentKey = key; @@ -3408,14 +3633,19 @@ private boolean runtimeSourceOnly; int oldPosition = mFlagAdapter.getSelectedPosition(); mFlagAdapter.setSelected(resolved); if (mTmdbUIAdapter != null) mTmdbUIAdapter.setActiveFlag(resolved); + rememberFlagSelection(resolved); int newPosition = mFlagAdapter.getSelectedPosition(); if (newPosition != RecyclerView.NO_POSITION) mBinding.flag.setSelectedPosition(newPosition); notifyItemsChanged(mBinding.flag, mFlagAdapter, oldPosition, newPosition); setEpisodeAdapter(resolved.getEpisodes()); setQualityVisible(false); + boolean deferFlagSwitch = FlagSwitchPlaybackPolicy.shouldDeferPlayback(PlayerSetting.isDeferFlagSwitchPlayback(), mProgrammaticFlagSwitch); + mDeferFlagSwitchPlayback = deferFlagSwitch && !initialBinding; boolean episodeChanged = seamless(resolved); if (initialBinding && !episodeChanged) onRefresh(); + else if (deferFlagSwitch && !episodeChanged) onRefresh(); loadTmdbRelatedVideosForCurrentEpisode(); + mDeferFlagSwitchPlayback = false; } private void setEpisodeAdapter(List items) { @@ -3633,7 +3863,7 @@ private boolean runtimeSourceOnly; private void onQuality() { Result result = mViewModel.getPlayer().getValue(); if (result == null || !result.getUrl().isMulti()) return; - new com.google.android.material.dialog.MaterialAlertDialogBuilder(this) + new WebHtvAlertDialogBuilder(this) .setTitle(R.string.detail_quality) .setSingleChoiceItems(getQualityItems(result), result.getUrl().getPosition(), (dialog, which) -> { dialog.dismiss(); @@ -3943,6 +4173,7 @@ private boolean runtimeSourceOnly; R.id.episodeFileName, R.id.episode, R.id.episodeGrid, + R.id.tmdbOmdbRatings, R.id.tmdbCast, R.id.tmdbPhotos, R.id.tmdbPosters, @@ -3973,9 +4204,28 @@ private boolean runtimeSourceOnly; updateEpisodeHeaderFocus(); mPartAdapter.setNextFocus(findFocusUp(part), findFocusDown(part)); mQuickAdapter.setNextFocus(findFocusUp(quick), findFocusDown(quick)); + updateRatingChipFocus(); setDetailButtonsNextFocus(findFocusDown(-1)); } + /** + * “评分与数据”卡片纳入纵向焦点链:上接选集网格/列表,下接演员行。 + * 卡片此前不是 focusable,遥控上下键会整行跳过,视觉上凭空少了一段。 + */ + private void updateRatingChipFocus() { + ViewGroup container = mBinding.getRoot().findViewById(R.id.tmdbOmdbRatings); + if (container == null || !isVisible(container)) return; + int ratings = episodeFocusIndex(R.id.tmdbOmdbRatings); + int up = findFocusUp(ratings); + int down = findFocusDown(ratings); + for (int i = 0; i < container.getChildCount(); i++) { + View child = container.getChildAt(i); + if (!child.isFocusable()) continue; + child.setNextFocusUpId(up == 0 ? View.NO_ID : up); + child.setNextFocusDownId(down == 0 ? View.NO_ID : down); + } + } + private void updateEpisodeHeaderFocus() { int title = episodeFocusIndex(R.id.episodeTitle); int reverse = episodeFocusIndex(R.id.episodeReverse); @@ -4072,10 +4322,39 @@ private boolean runtimeSourceOnly; if (!KeyUtil.isActionDown(event) || !KeyUtil.isDownKey(event)) return false; int position = mBinding.array.getSelectedPosition(); if (position <= 1) return false; - selectEpisodeSegment(position, true); + // 只装载分段,不直接抢焦点:声明式的 nextFocusDown 目标是集数表头(“选集 · 第 N 季”), + // 之前这里直接 scrollToEpisode(..., true) 把焦点推进选集网格,遥控在分段行按向下 + // 会跳过整行集数表头,用户报告“往下到不了选季度的按钮”。 + selectEpisodeSegment(position, false); + if (focusEpisodeHeaderTool(View.FOCUS_DOWN)) return true; + scrollToEpisode(getSelectedEpisodePosition(mEpisodeAdapter.getItems()), true); return true; } + /** 集数表头(选集 · 第 N 季 / 倒序 / 列表 / 原文件名)是分段行的声明式下方目标。 */ + private boolean focusEpisodeHeaderTool(int direction) { + if (!isEpisodeFocusTarget(mBinding.episodeTitle)) return false; + mBinding.episodeTitle.requestFocus(direction); + return true; + } + + /** + * “评分与数据”行是选集列表声明式的下方邻居(见 getEpisodeFocusOrders)。 + * 集数卡片由 adapter 动态构建,不会把 nextFocusDown 写回卡片项,所以末行向下需要显式接管。 + */ + private boolean focusRatingRow() { + ViewGroup container = mBinding.getRoot().findViewById(R.id.tmdbOmdbRatings); + if (container == null || !isVisible(container)) return false; + for (int i = 0; i < container.getChildCount(); i++) { + View child = container.getChildAt(i); + if (child.isFocusable() && child.getVisibility() == View.VISIBLE) { + child.requestFocus(View.FOCUS_DOWN); + return true; + } + } + return false; + } + private boolean onEpisodeKey(KeyEvent event) { if (!KeyUtil.isActionDown(event)) return false; @@ -4091,7 +4370,10 @@ private boolean runtimeSourceOnly; int spanCount = layoutManager instanceof GridLayoutManager gridLayoutManager ? gridLayoutManager.getSpanCount() : getEpisodeGridSpanCount(); if (KeyUtil.isDownKey(event)) { int target = TmdbEpisodeGridPolicy.verticalFocusTarget(position, spanCount, mEpisodeGridAdapter.getItemCount(), true); - return target != TmdbEpisodeGridPolicy.NO_FOCUS_TARGET && focusEpisodeGridPosition(target); + if (target != TmdbEpisodeGridPolicy.NO_FOCUS_TARGET) return focusEpisodeGridPosition(target); + // 已到网格最后一行:继续往下必须落到“评分与数据”,否则焦点链在选集底部断掉, + // 用户报告“评分与数据下的卡片无法选中容易遥控跳过”。 + return focusRatingRow(); } if (KeyUtil.isUpKey(event)) { int target = TmdbEpisodeGridPolicy.verticalFocusTarget(position, spanCount, mEpisodeGridAdapter.getItemCount(), false); @@ -4311,6 +4593,17 @@ private boolean runtimeSourceOnly; .seasons(getEpisodeDialogSeasons(), getEpisodeDialogSeasonCounts()).show(this); } + /** + * 全屏菜单键=下一集:与底栏「下一集」按钮、片尾自动续播共用 checkNext(), + * 倒序播放时由 checkNext() 自动改取上一集。无详情/无分集时静默忽略, + * 避免 getFlag() 为空或空分集被当成真实剧集切走。 + */ + private void onMenuNextEpisode() { + Flag flag = getFlag(); + if (flag == null || flag.getEpisodes().isEmpty()) return; + checkNext(); + } + private java.util.List getEpisodeDialogSeasons() { return new java.util.ArrayList<>(getEpisodeDialogSeasonCounts().keySet()); } @@ -4580,8 +4873,17 @@ private boolean runtimeSourceOnly; } private void onRefresh() { - invalidateShortDramaQueue("refresh"); saveHistory(); + // 「切换线路不自动播放」:线路点击只换线路/选集列表,旧流继续播。 + // 抑制必须先于拆流,否则用户还没点具体集数,正在看的视频就先被停掉了; + // 但仍在途的旧线路取流结果必须就地作废,否则它稍后返回会替用户自动起播旧线路。 + if (mDeferFlagSwitchPlayback) { + mDeferFlagSwitchPlayback = false; + if (mViewModel != null) mViewModel.cancelPlayerContent(); + invalidatePlayerContent(); + return; + } + invalidateShortDramaQueue("refresh"); if (mViewModel != null) mViewModel.cancelPlayerContent(); invalidatePlayerContent(); subtitlePlaybackSession.stop(this); @@ -4912,7 +5214,9 @@ private boolean runtimeSourceOnly; return; } - if (mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback); + // seek 窗口开着时不能摘掉它的最小可见计时器:那次 showProgress 属于 seek 自身 + // (BUFFERING 分支),计时器是窗口唯一的收尾路径。 + if (!mSeekProgressPending && mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback); mBinding.progress.getRoot().setVisibility(View.VISIBLE); App.post(mR3, 0); hideCenter(); @@ -4920,6 +5224,9 @@ private boolean runtimeSourceOnly; } private void hideProgress() { + // 任何显式收圈都同时关闭 seek 窗口,避免窗口比圈活得更久。 + mSeekProgressPending = false; + mSeekProgressStartedAtMs = 0; if (mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback); mBinding.progress.getRoot().setVisibility(View.GONE); App.removeCallbacks(mR3); @@ -4934,12 +5241,17 @@ private boolean runtimeSourceOnly; private void showError(String text) { mBinding.widget.error.setVisibility(View.VISIBLE); mBinding.widget.text.setText(text); + // 把焦点交给重试按钮:错误态下用户唯一能做的事就是重试,而播放页平时把焦点放在 + // 视频面上(mBinding.video),浮层里的按钮靠方向键不一定走得过去。 + mBinding.widget.retry.requestFocus(); hideProgress(); } private void hideError() { mBinding.widget.error.setVisibility(View.GONE); mBinding.widget.text.setText(""); + // 只有焦点确实在重试按钮上时才交还视频面:否则会抢走别的控件(例如剧集网格)的焦点。 + if (mBinding.widget.retry.hasFocus()) mBinding.video.requestFocus(); } private void showInfo() { @@ -5128,7 +5440,9 @@ private boolean runtimeSourceOnly; setQualityVisible(false); if (mHistory.isRevSort()) reverseEpisode(true); } else { + mProgrammaticFlagSwitch = true; onItemClick(resolveHistoryPlaybackFlag(item.getFlags())); + mProgrammaticFlagSwitch = false; if (mHistory.isRevSort()) reverseEpisode(true); } } @@ -5275,8 +5589,10 @@ private boolean runtimeSourceOnly; boolean crossSource = mHistory.isCrossSourcePlayback(); boolean shareEpisodeProgress = crossSource || isResumeFromHistory() || Setting.isHistoryAggregationEffective(); boolean compatibleFlag = shareEpisodeProgress || TextUtils.equals(mHistory.getVodFlag(), flag.getFlag()); + // 历史集 URL 能定位到当前线路条目(同集多版本并存)时才启用版本消歧;换线路/刷新保留集号容错。 + boolean versionAware = flag.containsEpisodeUrl(mHistory.getEpisode()); boolean sameEpisode = episode != null && (shareEpisodeProgress - ? historyEpisode.matchesPlayback(mHistory.getEpisode()) + ? historyEpisode.matchesPlayback(mHistory.getEpisode(), versionAware) : episode.matches(mHistory.getEpisode())); if (!compatibleFlag || (episode != null && !sameEpisode)) { mHistory.setPosition(C.TIME_UNSET); @@ -5351,7 +5667,10 @@ private boolean runtimeSourceOnly; private void updateHistory(Episode item) { // 换线路或源站刷新时同一集的 URL、集名格式可能变化,统一按播放恢复规则识别。 Episode historyEpisode = withSourceSeasonEpisodeIdentity(item); - boolean sameEpisode = historyEpisode.matchesPlayback(mHistory.getEpisode()); + // 历史里那集的 URL 仍能定位到当前线路条目时(同集多版本并存)才启用版本消歧; + // 换线路/换源/源站刷新后 URL 必然失配,必须保留集号容错,否则跨线路续播会丢失进度。 + boolean versionAware = getFlag().containsEpisodeUrl(mHistory.getEpisode()); + boolean sameEpisode = historyEpisode.matchesPlayback(mHistory.getEpisode(), versionAware); boolean sameFlag = TextUtils.equals(mHistory.getVodFlag(), getFlag().getFlag()); if (!sameEpisode || !sameFlag) mIntroSkipPlayback.reset(); if ((!sameEpisode || !sameFlag) && service() != null) { @@ -5437,9 +5756,8 @@ private boolean runtimeSourceOnly; FollowingPlaybackBridge.findAsync(identityKey, existing -> { if (isFinishing() || isDestroyed()) return; if (existing != null) { - followingActionPending = false; - mBinding.following.setEnabled(true); - FollowingActivity.start(this, existing.identityKey); + // 播放页保持原地:已追更时再次点击即刻取消,绝不跳转追更页打断播放。 + cancelFollowing(identityKey); return; } Following item = FollowingPlaybackBridge.build(mHistory, currentSourceSeasonNumber()); @@ -5460,6 +5778,23 @@ private boolean runtimeSourceOnly; }); } + /** 播放页取消追更:写墓碑后立即刷新按钮状态,不离开当前播放页。 */ + private void cancelFollowing(String identityKey) { + FollowingScheduler.cancelNext(this, identityKey); + FollowingPlaybackBridge.deleteAsync(identityKey, error -> { + followingActionPending = false; + if (isFinishing() || isDestroyed()) return; + if (error != null) { + mBinding.following.setEnabled(true); + Notify.show(error.getMessage()); + return; + } + updateFollowingState(); + FollowingPlaybackBridge.refreshUnreadCountAsync(null); + Notify.show(R.string.following_canceled); + }); + } + private void updateFollowingState() { boolean eligible = FollowingSettings.isEnabled() && FollowingPlaybackBridge.isEligible(mHistory); int generation = ++followingUiGeneration; @@ -5551,7 +5886,9 @@ private boolean runtimeSourceOnly; tmdbHistoryResumePending = true; } if (!targetFlag.isSelected() || !targetEpisode.isSelected()) { + mProgrammaticFlagSwitch = true; onItemClick(targetFlag); + mProgrammaticFlagSwitch = false; return; } @@ -5765,9 +6102,15 @@ private boolean runtimeSourceOnly; mClock.setCallback(null); clearLyrics(); clearKaraokeState(); + mPlaybackRequestActive = false; + mPlaybackPlayerStarted = false; player().resetTrack(); player().reset(); player().stop(); + // Automatic line fallback continues in the same failed playback session. + // Keep the remembered kernel, but recreate its engine so the next line cannot + // inherit a decoder/Surface failure that audio-only playback can survive. + applyHistoryPlayerKernel(true); showError(msg); startFlow(); } @@ -5802,8 +6145,14 @@ private boolean runtimeSourceOnly; showProgress(); break; case Player.STATE_READY: + if (mPlaybackRequestActive && !mPlaybackPlayerStarted) break; + mPlaybackRequestActive = false; + mPlaybackPlayerStarted = false; mKaraokeResultShown = false; - showPlaybackContent(); + // seek 窗口未关时不能因为一次 READY 读数就收圈:引擎在 seek 真正生效前 + // 仍可能宣称 READY,那正是「拖拽后先卡画面」的那一帧。圈已由 onSeekStarted + // 亮起,这里不收它就仍在屏上,由最小可见计时器与网速 ticker 收尾。 + if (canHideSeekProgress()) showPlaybackContent(); boolean pendingResumeSeekApplied = applyPendingResumeSeek(); refreshLyrics(); player().reset(); @@ -5865,34 +6214,69 @@ private boolean runtimeSourceOnly; applyResizeMode(getScale()); } + /** + * 判断当前能否收掉 seek 期间挂起的加载圈。 + * + *

拖拽进度时 {@code onSeekStarted()} 先亮圈,随后才把 seek 交给控制器。这中间引擎仍 + * 报告 READY,于是挂在网速 ticker 上的兜底收口会在 seek 真正生效前把圈收掉——现象就是 + * 「拖拽后大概率看不到加载中/网速」。窗口打开期间必须先满过最小可见时长,且播放器确实已 + * READY 且不在真实加载阻塞中,才允许收口。窗口没关时圈就一直留着,由每秒一跳的网速 + * ticker 继续重判,不会自己冒出重试循环。 + */ + private boolean canHideSeekProgress() { + if (!mSeekProgressPending) return true; + if (SystemClock.elapsedRealtime() - mSeekProgressStartedAtMs < SEEK_PROGRESS_MIN_VISIBLE_MS) return false; + return service() != null + && player() != null + && !player().isReleased() + && !player().isEmpty() + && player().getPlaybackState() == Player.STATE_READY + && (!player().isLoading() || player().isPlaying()); + } + private void hideSeekProgressIfReady() { - if (service() == null || player() == null || player().isReleased() || player().isEmpty() || !isOwner() || player().getPlaybackState() != Player.STATE_READY) return; + if (!mSeekProgressPending) return; + if (service() == null || player() == null || player().isReleased() || player().isEmpty()) return; + if (!isOwner()) return; + if (!canHideSeekProgress()) return; + mSeekProgressPending = false; showPlaybackContent(); } /** * 加载圈的兜底收口。 * - *

圈只在 {@code STATE_READY} 分支被收(onStateChanged),而那条回调受 isOwner() 把关。 - * 归属判定一旦因任何原因失配,圈就永久留在屏上——画面在动、圈不走。这里不依赖归属, - * 直接读播放器状态:已在播且已 READY 就收圈。要求 {@code !isEmpty()},避免详情尚未加载完 - * (播放器还空着)时把详情页自己的加载态误收。 + *

正常路径在 {@code STATE_READY} 分支收圈;这里保留 owner 校验,避免旧会话的播放器 + * 状态收掉当前条目的加载态。控制器晚绑定导致正常 READY 回调已经错过时,由 + * {@link #onControllerReadyReconciled()} 直接补发收口。要求 {@code !isEmpty()},避免详情 + * 尚未加载完(播放器还空着)时把详情页自己的加载态误收。 * *

挂在 mR3(网速刷新,圈可见时每秒一跳)上,圈不可见时该循环本就已停,无额外开销。 */ private void hidePlaybackProgressIfStale() { if (mBinding.progress.getRoot().getVisibility() != View.VISIBLE) return; + if (mPlaybackRequestActive && !mPlaybackPlayerStarted) return; if (service() == null || player() == null || player().isReleased() || player().isEmpty()) return; if (!isOwner()) return; + if (!canHideSeekProgress()) return; if (player().getPlaybackState() != Player.STATE_READY) return; showPlaybackContent(); } + @Override + protected void onControllerReadyReconciled() { + if (canHideSeekProgress()) showPlaybackContent(); + } + @Override protected void onSeekStarted() { - showProgress(); App.removeCallbacks(mSeekProgressFallback); - App.post(mSeekProgressFallback, 500); + // 窗口必须在 showProgress() 之前打开:它内部会立刻投递网速 ticker, + // 那个 ticker 的兜底收口会读到尚未开始 seek 的 READY。 + mSeekProgressPending = true; + mSeekProgressStartedAtMs = SystemClock.elapsedRealtime(); + showProgress(); + App.post(mSeekProgressFallback, SEEK_PROGRESS_MIN_VISIBLE_MS); } @Override @@ -6176,6 +6560,7 @@ private boolean runtimeSourceOnly; mBinding.tmdbPersonalTmdbRecommendations.setNextFocusDownId(hasDouban ? R.id.tmdbPersonalDoubanRecommendations : hasAi ? R.id.tmdbPersonalAiRecommendations : R.id.flag); mBinding.tmdbPersonalDoubanRecommendations.setNextFocusDownId(hasAi ? R.id.tmdbPersonalAiRecommendations : R.id.flag); mBinding.tmdbPersonalAiRecommendations.setNextFocusDownId(R.id.flag); + applyTmdbRowFocusChain(); } private void attachRecommendationLazyLoader(HorizontalGridView grid, RecommendationRow row) { @@ -6280,6 +6665,7 @@ private boolean runtimeSourceOnly; if (!changed) return; bindRecommendationGrid(mBinding.tmdbPersonalTmdbRecommendations, mBinding.tmdbPersonalTmdbRecommendationsLabel, mTmdbUIAdapter.getPersonalTmdbRecommendations(), RecommendationRow.PERSONAL_TMDB); bindRecommendationGrid(mBinding.tmdbPersonalDoubanRecommendations, mBinding.tmdbPersonalDoubanRecommendationsLabel, mTmdbUIAdapter.getPersonalDoubanRecommendations(), RecommendationRow.PERSONAL_DOUBAN); + applyTmdbRowFocusChain(); }); } else { loadNativePersonalRecommendations(mVod); @@ -6295,6 +6681,7 @@ private boolean runtimeSourceOnly; mBinding.tmdbPersonalTmdbRecommendations.setNextFocusDownId(hasAi ? R.id.tmdbPersonalAiRecommendations : R.id.flag); } mBinding.tmdbPersonalAiRecommendations.setNextFocusDownId(R.id.flag); + applyTmdbRowFocusChain(); } // 细粒度刷新:相关推荐(“猜你喜欢”)异步到达时只重绑该列表,不触碰详情头部与集数, @@ -6302,12 +6689,14 @@ private boolean runtimeSourceOnly; private void refreshTmdbRecommendations() { if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isLoaded() || mTmdbDetailLoading) return; bindRecommendationGrid(mBinding.tmdbRecommendations, mBinding.tmdbRecommendationsLabel, mTmdbUIAdapter.getRecommendations(), RecommendationRow.RECOMMENDATIONS); + applyTmdbRowFocusChain(); } // 细粒度刷新:个性化推荐(TMDB / 豆瓣)异步到达时只重绑这两个列表。 private void refreshTmdbRelatedVideos() { if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isLoaded() || mTmdbDetailLoading) return; bindTmdbVideoGrid(mBinding.tmdbRelatedVideos, mBinding.tmdbRelatedVideosLabel, mTmdbUIAdapter.getRelatedVideos()); + applyTmdbRowFocusChain(); updateFocus(); } @@ -6327,6 +6716,7 @@ private boolean runtimeSourceOnly; if (mTmdbUIAdapter == null || !mTmdbUIAdapter.isLoaded() || mTmdbDetailLoading) return; bindRecommendationGrid(mBinding.tmdbPersonalTmdbRecommendations, mBinding.tmdbPersonalTmdbRecommendationsLabel, mTmdbUIAdapter.getPersonalTmdbRecommendations(), RecommendationRow.PERSONAL_TMDB); bindRecommendationGrid(mBinding.tmdbPersonalDoubanRecommendations, mBinding.tmdbPersonalDoubanRecommendationsLabel, mTmdbUIAdapter.getPersonalDoubanRecommendations(), RecommendationRow.PERSONAL_DOUBAN); + applyTmdbRowFocusChain(); } // 细粒度刷新:集数标题异步补全。useTmdbCard 由 shouldUseTmdbEpisodeCards(hasTmdbEpisodeData) @@ -6560,6 +6950,7 @@ private boolean runtimeSourceOnly; } SpiderDebug.log("tmdb-tv", "绑定完成: 演员=%d 剧照=%d 海报=%d 主创=%d 推荐=%d 个性TMDB=%d 个性豆瓣=%d 个性智能=%d", cast.size(), photos.size(), posters.size(), creators.size(), recommendations.size(), personalTmdbRecommendations.size(), personalDoubanRecommendations.size(), personalAiRecommendations.size()); + applyTmdbRowFocusChain(); updateFocus(); // TMDB / OMDB 多来源评分(TMDB / IMDb / 烂番茄 / Metacritic 等) @@ -7002,6 +7393,10 @@ private boolean runtimeSourceOnly; container.setVisibility(View.GONE); container.setTag(null); container.removeAllViews(); + updateRatingChipFocus(); + // 评分行是本行的邻居,它的显隐变化必须重算行链: + // 否则第一行 TMDB 区块的 up 目标会停留在已隐藏的评分行上。 + applyTmdbRowFocusChain(); } private String omdbRatingCacheKey(String imdbId, String omdbApiKey) { @@ -7014,6 +7409,8 @@ private boolean runtimeSourceOnly; if (chips == null || chips.isEmpty()) { if (label != null) label.setVisibility(View.GONE); container.setVisibility(View.GONE); + updateRatingChipFocus(); + applyTmdbRowFocusChain(); return; } for (String[] chip : chips) { @@ -7021,6 +7418,10 @@ private boolean runtimeSourceOnly; } if (label != null) label.setVisibility(View.VISIBLE); container.setVisibility(View.VISIBLE); + // 卡片是异步到达的(OMDB 回包),到达后必须重算焦点链, + // 否则选集下方仍然指向旧的下一行。 + updateRatingChipFocus(); + applyTmdbRowFocusChain(); } private java.util.List buildTmdbRatingChips() { @@ -7221,12 +7622,12 @@ private boolean runtimeSourceOnly; chip.setGravity(android.view.Gravity.CENTER); chip.setMinimumWidth(ResUtil.dp2px(120)); chip.setPadding(ResUtil.dp2px(16), ResUtil.dp2px(10), ResUtil.dp2px(16), ResUtil.dp2px(10)); - - android.graphics.drawable.GradientDrawable background = new android.graphics.drawable.GradientDrawable(); - background.setColor(0x6610141A); - background.setCornerRadius(ResUtil.dp2px(8)); - background.setStroke(ResUtil.dp2px(1), 0x33FFFFFF); - chip.setBackground(background); + // 评分卡片必须可被遥控选中:此前是纯装饰 LinearLayout,上下键会直接跳过整行 + // “评分与数据”,视觉上像凭空跳了一段。 + chip.setFocusable(true); + chip.setFocusableInTouchMode(false); + chip.setClickable(true); + chip.setTag(R.id.tmdbOmdbRatings, platform); TextView platformView = new TextView(this); platformView.setText(platform); @@ -7258,9 +7659,33 @@ private boolean runtimeSourceOnly; ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); params.setMarginEnd(ResUtil.dp2px(12)); chip.setLayoutParams(params); + styleRatingChipBackground(chip, false); + chip.setOnFocusChangeListener((view, focused) -> styleRatingChipBackground(view, focused)); return chip; } + /** + * 评分卡片背景。常态保留原有深色玻璃底(0x6610141A)+ 1dp 半透明描边, + * 让白/黄文字在明亮剧照上依旧可读;只在获得焦点时换成统一的 + * 3dp @color/tv_item_focus_ring 圆环,与选集/线路/推荐卡保持同一种“停下”视觉。 + */ + private void styleRatingChipBackground(View view, boolean focused) { + if (view == null) return; + android.graphics.drawable.GradientDrawable background = new android.graphics.drawable.GradientDrawable(); + background.setColor(0x6610141A); + background.setCornerRadius(ResUtil.dp2px(8)); + if (focused) { + background.setStroke(ResUtil.dp2px(TV_ITEM_FOCUS_WIDTH_DP), getColor(R.color.tv_item_focus_ring)); + } else { + background.setStroke(ResUtil.dp2px(1), 0x33FFFFFF); + } + view.setBackground(background); + View label = mBinding.getRoot().findViewById(R.id.tmdbOmdbRatingsLabel); + if (label instanceof TextView text) { + text.setTextColor(focused ? getColor(R.color.tv_item_focus_ring) : getColor(android.R.color.white)); + } + } + private void setupBackdropSlideshow(java.util.List photos) { if (photos == null || photos.isEmpty()) { // 无可用 TMDB 背景图:清除幻灯片激活标志并隐藏 pager,恢复 contextWall 作为后备背景。 @@ -7678,7 +8103,9 @@ private boolean runtimeSourceOnly; private void nextFlag(int position) { Flag flag = mFlagAdapter.get(position + 1); Notify.show(getString(R.string.play_switch_flag, flag.getFlag())); + mProgrammaticFlagSwitch = true; onItemClick(flag); + mProgrammaticFlagSwitch = false; } private void nextSite() { @@ -7806,8 +8233,14 @@ private boolean runtimeSourceOnly; return mFocus1 == null || mFocus1.getVisibility() != View.VISIBLE ? mBinding.video : mFocus1; } + private boolean hasRememberedFocus() { + return mFocus2 != null && mFocus2.getVisibility() == View.VISIBLE + && PlayerControlFocusHelper.isDescendant(mBinding.control.getRoot(), mFocus2) + && mFocus2 != mBinding.control.action.opening && mFocus2 != mBinding.control.action.ending; + } + private View getFocus2() { - return mFocus2 == null || mFocus2.getVisibility() != View.VISIBLE || !PlayerControlFocusHelper.isDescendant(mBinding.control.getRoot(), mFocus2) || mFocus2 == mBinding.control.action.opening || mFocus2 == mBinding.control.action.ending ? mBinding.control.action.next : mFocus2; + return hasRememberedFocus() ? mFocus2 : mBinding.control.action.next; } private boolean dispatchOpeningEndingAdjust(KeyEvent event) { @@ -7858,7 +8291,9 @@ private boolean runtimeSourceOnly; } if (isVisible(mBinding.lutQuick)) return dispatchLutQuickKey(event); if (isFullscreen() && KeyUtil.isMenuKey(event)) { - if (Setting.getFullscreenMenuKey() == 1) onEpisodes(); + int menuKey = Setting.getFullscreenMenuKey(); + if (menuKey == Setting.FULLSCREEN_MENU_KEY_EPISODES) onEpisodes(); + else if (menuKey == Setting.FULLSCREEN_MENU_KEY_NEXT_EPISODE) onMenuNextEpisode(); else onToggle(); } if (isVisible(mBinding.control.getRoot())) { @@ -8041,13 +8476,37 @@ private boolean runtimeSourceOnly; public void onKeyUp() { long position = player().getPosition(); long duration = player().getDuration(); - if (player().canSetOpening(position, duration)) { - showControl(mBinding.control.action.opening); - } else if (player().canSetEnding(position, duration)) { + if (isEndingShortcutDue(position, duration)) { showControl(mBinding.control.action.ending); - } else { - showControl(getFocus2()); + return; } + if (!hasRememberedFocus()) { + if (player().canSetOpening(position, duration)) { + showControl(mBinding.control.action.opening); + return; + } + if (player().canSetEnding(position, duration)) { + showControl(mBinding.control.action.ending); + return; + } + } + showControl(getFocus2()); + } + + /** + * 「快播完」时按上键的目标就是设片尾,这个入口必须优先于「恢复上次控件」。 + * + *

09-29 的 c7fba76a46 为修「片头区间唤醒被抢焦点」把片头/片尾两个分支一起收进 + * {@link #hasRememberedFocus()};而记忆焦点只在进出全屏时清空,用户只要碰过一次控制栏 + * (按过下键、点过选集/字幕/弹幕等),此后整场播放的片尾快捷入口就永久失效,片头分支 + * 却仍会在片头区间生效,表现为「能跳片头、跳不了片尾」。片头区间保留该收敛语义, + * 片尾区间恢复上游的快捷聚焦。 + * + *

限定「已过中点」才抢焦点:短剧/短片整段都落在两侧可设窗口内,开场阶段按上键 + * 仍应给片头。该条件对长内容恒成立(片尾窗口必定在后半程),不改变原本能命中的场景。 + */ + private boolean isEndingShortcutDue(long position, long duration) { + return position > 0 && duration > 0 && position >= duration / 2 && player().canSetEnding(position, duration); } @Override @@ -9874,7 +10333,7 @@ private boolean showKaraokeResultIfNeeded(@Nullable Runnable after) { if (result == null) return false; mKaraokeResultShown = true; KaraokeResultView view = new KaraokeResultView(this).setLeanbackLandscapeExpanded(true).setResult(result); - AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_WebHTV_LightDialog).setView(view).create(); + AlertDialog dialog = new WebHtvAlertDialogBuilder(this, R.style.ThemeOverlay_WebHTV_Dialog).setView(view).create(); view.setAction(() -> { dialog.dismiss(); runAfterKaraokeResult(after); @@ -10128,6 +10587,9 @@ private void setVideoDetailsVisible(boolean visible) { mBinding.row1.setVisibility(value); mBinding.director.setVisibility(showNativeMetadata && !TextUtils.isEmpty(mBinding.director.getText()) ? View.VISIBLE : View.GONE); mBinding.actor.setVisibility(showNativeMetadata && !TextUtils.isEmpty(mBinding.actor.getText()) ? View.VISIBLE : View.GONE); + // 简介区必须跟着一起收:沉浸音频/纯音频播放时它叠在播放器右侧,不收会在音频面板旁边 + // 留一块详情文本。影视原生此前没有内容所以看不出来,现在它真的有文字了。 + mBinding.tmdbOverview.setVisibility(visible && !TextUtils.isEmpty(mBinding.tmdbOverview.getText()) ? View.VISIBLE : View.GONE); mBinding.row2.setVisibility(value); mBinding.scroll.setVisibility(value); } 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..1734c8040c 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 @@ -217,6 +217,14 @@ public class VodActivity extends BaseActivity implements TypeAdapter.OnClickList getFragment().onRefresh(); } + @Override + public void onLongPress(Class item) { + if (item == null || mAdapter.indexOf(item) != mBinding.pager.getCurrentItem()) return; + invalidatePendingFocusRequests(); + FolderFragment fragment = getFragment(); + if (fragment != null) fragment.resetFilter(); + } + @Override public void onCategoryContentHorizontalEdge(Class item, int contentRow, boolean towardEnd) { int position = mAdapter.indexOf(item); 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..e1a798cec5 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 @@ -1,13 +1,17 @@ package com.fongmi.android.tv.ui.adapter; import android.text.TextUtils; +import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; +import android.view.ViewParent; import android.view.ViewGroup; import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Config; import com.fongmi.android.tv.setting.InterfaceOrderStore; import com.fongmi.android.tv.databinding.AdapterConfigBinding; @@ -19,6 +23,8 @@ public class ConfigAdapter extends RecyclerView.Adapter mItems; private boolean readOnly; + private boolean protectCurrent; + private Config current; public ConfigAdapter(OnClickListener listener) { this.listener = listener; @@ -30,6 +36,8 @@ public class ConfigAdapter extends RecyclerView.Adapter TextUtils.equals(item.getUrl(), currentUrl)); + if (!readOnly && !protectCurrent && !TextUtils.isEmpty(currentUrl)) mItems.removeIf(item -> TextUtils.equals(item.getUrl(), currentUrl)); return this; } + private boolean isCurrent(Config item) { + if (!protectCurrent || current == null) return false; + if (current.getId() > 0 && item.getId() == current.getId()) return true; + return current.getType() == item.getType() + && !TextUtils.isEmpty(current.getInterfaceKey()) + && TextUtils.equals(current.getInterfaceKey(), item.getInterfaceKey()); + } + + public boolean isProtectedCurrent(Config item) { + return isCurrent(item); + } + public int remove(Config item) { int position = mItems.indexOf(item); if (position == -1) return -1; @@ -82,13 +108,120 @@ public class ConfigAdapter extends RecyclerView.Adapter listener.onTextClick(item)); - holder.binding.text.setOnLongClickListener(v -> listener.onTextLongClick(holder)); + // 当前接口不能重复执行“使用”,但必须保持可聚焦,否则遥控器向下移动时会 + // 跳过整行,用户既看不到当前项焦点,也无法继续到它右侧的编辑/删除操作。 + holder.binding.text.setEnabled(true); + holder.binding.text.setFocusable(true); + bindVerticalFocus(holder.binding.text, position); + holder.binding.text.setOnClickListener(v -> { + if (!current) listener.onTextClick(item); + }); + holder.binding.text.setOnLongClickListener(v -> !current && listener.onTextLongClick(holder)); + holder.binding.edit.setVisibility(readOnly ? View.GONE : View.VISIBLE); + bindVerticalFocus(holder.binding.edit, position); + holder.binding.edit.setOnClickListener(v -> listener.onEditClick(item)); holder.binding.delete.setVisibility(readOnly ? View.GONE : View.VISIBLE); + holder.binding.delete.setAlpha(current ? 0.38f : 1f); + bindVerticalFocus(holder.binding.delete, position); holder.binding.delete.setOnClickListener(v -> listener.onDeleteClick(item)); } + private void bindVerticalFocus(View view, int position) { + view.setOnKeyListener((source, keyCode, event) -> { + if (event.getAction() != KeyEvent.ACTION_DOWN) return false; + if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN) return moveFocus(source, position + 1); + if (keyCode == KeyEvent.KEYCODE_DPAD_UP) return moveFocus(source, position - 1); + return false; + }); + } + + private boolean moveFocus(View source, int position) { + if (position < 0 || position >= getItemCount()) return false; + RecyclerView recycler = findRecycler(source); + if (recycler == null) return false; + requestFocus(recycler, position, source.getId()); + return true; + } + + private void requestFocus(RecyclerView recycler, int position, int targetId) { + FocusRequest focus = new FocusRequest(recycler, position, targetId); + recycler.stopScroll(); + // 目标行可能尚未挂载;监听它实际挂载的时机,避免提前回调无效后焦点停在原行。 + focus.listenForTarget(); + if (recycler.findViewHolderForAdapterPosition(position) == null) { + RecyclerView.LayoutManager layoutManager = recycler.getLayoutManager(); + if (layoutManager instanceof LinearLayoutManager linearLayoutManager) { + linearLayoutManager.scrollToPositionWithOffset(position, recycler.getPaddingTop()); + } else if (layoutManager != null) { + layoutManager.scrollToPosition(position); + } + } + // 已挂载的目标直接处理;未挂载的目标由 FocusRequest 的 attach 回调处理。 + recycler.postOnAnimation(focus::run); + } + + private class FocusRequest implements RecyclerView.OnChildAttachStateChangeListener { + + private final RecyclerView recycler; + private final int position; + private final int targetId; + private boolean listening; + private boolean finished; + + private FocusRequest(RecyclerView recycler, int position, int targetId) { + this.recycler = recycler; + this.position = position; + this.targetId = targetId; + } + + private void listenForTarget() { + recycler.addOnChildAttachStateChangeListener(this); + listening = true; + } + + private void run() { + if (finished) return; + RecyclerView.ViewHolder holder = recycler.findViewHolderForAdapterPosition(position); + if (holder == null) return; + // 上下移动时保持当前列:接口名称到接口名称、修改到修改、删除到删除。 + View target = holder.itemView.findViewById(targetId); + if (target == null || target.getVisibility() != View.VISIBLE || !target.isFocusable()) { + target = holder.itemView.findViewById(R.id.text); + } + if (target != null && target.getVisibility() == View.VISIBLE && target.isFocusable()) { + finish(target); + } else { + finish(holder.itemView); + } + } + + private void finish(View target) { + finished = true; + if (listening) { + recycler.removeOnChildAttachStateChangeListener(this); + listening = false; + } + target.requestFocus(); + } + + @Override + public void onChildViewAttachedToWindow(@NonNull View view) { + if (recycler.getChildAdapterPosition(view) == position) run(); + } + + @Override + public void onChildViewDetachedFromWindow(@NonNull View view) { + } + } + + private RecyclerView findRecycler(View source) { + ViewParent parent = source.getParent(); + while (parent != null && !(parent instanceof RecyclerView)) parent = parent.getParent(); + return parent instanceof RecyclerView ? (RecyclerView) parent : null; + } + public class ViewHolder extends RecyclerView.ViewHolder { private final AdapterConfigBinding binding; diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/EpisodeAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/EpisodeAdapter.java index 292a2deb02..2bf0a55d86 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/EpisodeAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/EpisodeAdapter.java @@ -315,13 +315,9 @@ public class EpisodeAdapter extends RecyclerView.Adapter { - boolean active = item.isSelected() || hasFocus; - textView.setSelected(active); - if (holder.nativeFileSizeView != null) holder.nativeFileSizeView.setSelected(active); - }); + textView.setOnFocusChangeListener((view, hasFocus) -> applyTextActive(holder, item, hasFocus)); textView.setOnKeyListener(keyListener); textView.setOnClickListener(v -> mListener.onItemClick(item)); if (mLongClickListener != null) { @@ -332,6 +328,18 @@ public class EpisodeAdapter extends RecyclerView.Adapter { public void onBindViewHolder(@NonNull ViewHolder holder, int position) { Flag item = mItems.get(position); holder.binding.text.setText(item.getShow()); + // selected 只是跑马灯开关(selector_video_item 的 state_selected 无描边); + // 「当前播放线路」的绿色 2dp tvCurrentRing 描边走 state_activated, + // 见 selector_video_item.xml 顶部的统一规范注释。 + holder.binding.text.setActivated(item.isSelected()); holder.binding.text.setSelected(item.isSelected()); holder.binding.text.setNextFocusDownId(nextFocusDown); holder.binding.text.setOnKeyListener(keyListener); 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..ce5d034fc2 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 @@ -10,6 +10,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.Product; import com.fongmi.android.tv.bean.History; import com.fongmi.android.tv.databinding.AdapterVodBinding; +import com.fongmi.android.tv.ui.helper.TvFocusScale; import com.fongmi.android.tv.utils.ImgUtil; import com.fongmi.android.tv.utils.ResUtil; @@ -114,13 +115,8 @@ public class HistoryAdapter extends BaseDiffAdapter { setMarquee(hasFocus); - if (hasFocus) { - v.animate().scaleX(1.1f).scaleY(1.1f).setDuration(150).start(); - v.setTranslationZ(10f); - } else { - v.animate().scaleX(1f).scaleY(1f).setDuration(150).start(); - v.setTranslationZ(0f); - } + TvFocusScale.apply(v, hasFocus); + v.setTranslationZ(hasFocus ? 10f : 0f); }); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/KeepAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/KeepAdapter.java index 1e729de0f9..63d0ebe42a 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/KeepAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/KeepAdapter.java @@ -10,6 +10,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.Product; import com.fongmi.android.tv.bean.Keep; import com.fongmi.android.tv.databinding.AdapterVodBinding; +import com.fongmi.android.tv.ui.helper.TvFocusScale; import com.fongmi.android.tv.utils.ImgUtil; import com.fongmi.android.tv.utils.ResUtil; @@ -94,13 +95,8 @@ public class KeepAdapter extends BaseDiffAdapter { private void setFocusListener() { itemView.setOnFocusChangeListener((v, hasFocus) -> { setMarquee(hasFocus); - if (hasFocus) { - v.animate().scaleX(1.1f).scaleY(1.1f).setDuration(150).start(); - v.setTranslationZ(10f); - } else { - v.animate().scaleX(1f).scaleY(1f).setDuration(150).start(); - v.setTranslationZ(0f); - } + TvFocusScale.apply(v, hasFocus); + v.setTranslationZ(hasFocus ? 10f : 0f); }); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/QuickAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/QuickAdapter.java index a3ab173b0c..84257e1e3e 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/QuickAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/QuickAdapter.java @@ -1,5 +1,6 @@ package com.fongmi.android.tv.ui.adapter; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -22,6 +23,7 @@ public class QuickAdapter extends RecyclerView.Adapter private int width; private int nextFocusUp; private int nextFocusDown; + private String currentSiteKey = ""; public QuickAdapter(OnClickListener listener) { mListener = listener; @@ -54,6 +56,13 @@ public class QuickAdapter extends RecyclerView.Adapter notifyDataSetChanged(); } + public void setCurrentSiteKey(String currentSiteKey) { + String value = currentSiteKey == null ? "" : currentSiteKey; + if (TextUtils.equals(this.currentSiteKey, value)) return; + this.currentSiteKey = value; + notifyDataSetChanged(); + } + public void setWidth(int width) { if (this.width == width) return; this.width = width; @@ -91,6 +100,7 @@ public class QuickAdapter extends RecyclerView.Adapter holder.binding.name.setText(item.getName()); holder.binding.site.setText(item.getSiteName()); holder.binding.remark.setText(item.getRemarks()); + holder.binding.getRoot().setActivated(TextUtils.equals(currentSiteKey, item.getSiteKey())); holder.binding.getRoot().setNextFocusUpId(nextFocusUp == 0 ? View.NO_ID : nextFocusUp); holder.binding.getRoot().setNextFocusDownId(nextFocusDown == 0 ? View.NO_ID : nextFocusDown); holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(item)); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java index 5c4d8ee6ee..83403ad7d6 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/SiteAdapter.java @@ -1,7 +1,6 @@ package com.fongmi.android.tv.ui.adapter; import android.content.res.ColorStateList; -import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -16,6 +15,7 @@ import com.fongmi.android.tv.databinding.AdapterSiteSwitchBinding; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.setting.SiteHealthStore; import com.fongmi.android.tv.setting.SiteNameStore; +import com.fongmi.android.tv.ui.helper.SiteDialogTheme; import com.github.catvod.crawler.SpiderDebug; import java.util.ArrayList; @@ -199,10 +199,12 @@ public class SiteAdapter extends RecyclerView.Adapter { super(binding.getRoot()); this.actionBinding = binding; this.switchBinding = null; - binding.text.setGravity(Gravity.CENTER); binding.getRoot().setOnClickListener(v -> click()); binding.getRoot().setOnLongClickListener(v -> longClick()); - binding.getRoot().setOnFocusChangeListener((v, hasFocus) -> binding.text.setSelected(hasFocus || isSelected())); + binding.getRoot().setOnFocusChangeListener((v, hasFocus) -> { + binding.text.setSelected(hasFocus || isSelected()); + SiteDialogTheme.updateSiteText(binding.text, hasFocus, isSelected()); + }); binding.getRoot().setOnKeyListener((v, keyCode, event) -> onKey(keyCode, event)); } @@ -210,10 +212,12 @@ public class SiteAdapter extends RecyclerView.Adapter { super(binding.getRoot()); this.actionBinding = null; this.switchBinding = binding; - binding.text.setGravity(Gravity.CENTER); binding.getRoot().setOnClickListener(v -> click()); binding.getRoot().setOnLongClickListener(v -> longClick()); - binding.getRoot().setOnFocusChangeListener((v, hasFocus) -> binding.text.setSelected(hasFocus || isSelected())); + binding.getRoot().setOnFocusChangeListener((v, hasFocus) -> { + binding.text.setSelected(hasFocus || isSelected()); + SiteDialogTheme.updateSiteText(binding.text, hasFocus, isSelected()); + }); binding.getRoot().setOnKeyListener((v, keyCode, event) -> onKey(keyCode, event)); } @@ -225,11 +229,15 @@ public class SiteAdapter extends RecyclerView.Adapter { actionBinding.check.setChecked(getChecked(item)); actionBinding.text.setSelected(item.isSelected()); actionBinding.getRoot().setSelected(item.isSelected()); + SiteDialogTheme.applySiteItem(actionBinding.getRoot(), actionBinding.text, actionBinding.check); + SiteDialogTheme.updateSiteText(actionBinding.text, actionBinding.getRoot().hasFocus(), item.isSelected()); } else { switchBinding.text.setText(item.getDisplayName()); switchBinding.health.setBackgroundTintList(ColorStateList.valueOf(SiteHealthStore.getColor(item))); switchBinding.text.setSelected(item.isSelected()); switchBinding.getRoot().setSelected(item.isSelected()); + SiteDialogTheme.applySiteItem(switchBinding.getRoot(), switchBinding.text, null); + SiteDialogTheme.updateSiteText(switchBinding.text, switchBinding.getRoot().hasFocus(), item.isSelected()); } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/TypeAdapter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/TypeAdapter.java index e3688dd556..3fab011b84 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/TypeAdapter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/adapter/TypeAdapter.java @@ -60,6 +60,7 @@ public class TypeAdapter extends RecyclerView.Adapter { holder.binding.text.setCompoundDrawablePadding(ResUtil.dp2px(4)); holder.binding.text.setCompoundDrawablesWithIntrinsicBounds(0, 0, getIcon(item), 0); holder.binding.text.setListener(() -> mListener.onRefresh(item)); + holder.binding.text.setLongPressListener(() -> mListener.onLongPress(item)); holder.binding.getRoot().setOnClickListener(v -> mListener.onItemClick(item)); } @@ -72,6 +73,8 @@ public class TypeAdapter extends RecyclerView.Adapter { void onItemClick(Class item); void onRefresh(Class item); + + void onLongPress(Class item); } public static class ViewHolder extends RecyclerView.ViewHolder { 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..ac19124f2b 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 @@ -12,6 +12,9 @@ import android.view.Window; import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; @@ -24,6 +27,7 @@ import com.fongmi.android.tv.Updater; 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.theme.ThemeController; import com.fongmi.android.tv.ui.custom.CustomWallView; import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.Util; @@ -50,15 +54,46 @@ public abstract class BaseActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + ThemeController.applyFromPreferences(this); registerFragmentLifecycleCallbacks(); - setContentView(getBinding().getRoot()); + // getBinding() inflates on every call, so resolve it once: the root that is set as + // content must be the same instance that initView()/initEvent() configure. + View content = getBinding().getRoot(); + setContentView(content); + bindFullWindowContent(); + ThemeController.bindTheme(content); EventBus.getDefault().register(this); initView(savedInstanceState); + ThemeController.bindTheme(content); Util.hideSystemUI(this); setBackCallback(); initEvent(); } + /** + * 让电视端内容区等于整块窗口,避免底部出现整条黑带。 + * + *

背景(实测):模拟器 5554 的屏幕带底部 overscan 90px + * ({@code dumpsys window displays} 里 {@code overscan (0,0,0,90)}),系统会把这条内边距 + * 下发给窗口;AppCompat 的 decor 里有两层都会各自应用一次 fitsSystemWindows 内边距 + * (外层 window content 与内层 {@code android:id/content}),于是内容区被扣掉 2×90=180px。 + * 同一块屏上 launcher 的内容区是 990(只扣一次),本应用实测是 900,卡片标题被齐腰切断、 + * {@code 900..1080} 整条是窗口背景黑。 + * + *

电视端本来就全屏沉浸({@link Util#hideSystemUI}),且各弹层自己留了 overscan 安全边距 + * (见 AboutDialog / UpdateDialog 的注释),所以这里在 DecorView 上装一个"只消费、不加内边距" + * 的监听器,inset 不再下发给任何一层,内容铺满窗口。没有 inset 的电视/盒子上这条路径 + * 与改动前完全一致(insets 为 0 时各层本来就不会加内边距)。 + * + *

弹层是独立窗口,不受影响;软键盘仍是窗口 resize(adjustResize),不依赖这条 inset。 + */ + private void bindFullWindowContent() { + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + ViewCompat.setOnApplyWindowInsetsListener(getWindow().getDecorView(), (view, insets) -> WindowInsetsCompat.CONSUMED); + View decorContent = findViewById(android.R.id.content); + if (decorContent != null) decorContent.setFitsSystemWindows(false); + } + @Override public void setContentView(View view) { super.setContentView(view); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTypeView.java b/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTypeView.java index 5a244e35b4..c2945fedae 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTypeView.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/custom/CustomTypeView.java @@ -14,8 +14,17 @@ import com.google.android.material.textview.MaterialTextView; public class CustomTypeView extends MaterialTextView { + public static final long LONG_PRESS_MS = 550; + private Listener listener; + private LongPressListener longPressListener; private boolean coolDown; + private boolean longPressFired; + + private final Runnable longPressRunnable = () -> { + longPressFired = true; + if (longPressListener != null) longPressListener.onLongPress(); + }; public CustomTypeView(@NonNull Context context) { super(context); @@ -29,14 +38,49 @@ public class CustomTypeView extends MaterialTextView { this.listener = listener; } + public void setLongPressListener(LongPressListener listener) { + this.longPressListener = listener; + } + private boolean hasEvent(KeyEvent event) { return !coolDown && KeyUtil.isActionDown(event) && KeyUtil.isUpKey(event); } + private boolean hasLongPressEvent(KeyEvent event) { + return longPressListener != null && KeyUtil.isEnterKey(event); + } + @Override public boolean dispatchKeyEvent(KeyEvent event) { if (listener != null && hasEvent(event)) return onKeyDown(); - else return super.dispatchKeyEvent(event); + if (hasLongPressEvent(event)) return onLongPressKey(event); + return super.dispatchKeyEvent(event); + } + + private boolean onLongPressKey(KeyEvent event) { + if (KeyUtil.isActionDown(event)) { + // Key repeats must not restart the timer; only the first press arms it. A fresh press + // also clears any latch left behind by a release this view never received (pause, + // sleep, detach), otherwise that stale latch would swallow the next click. + if (event.getRepeatCount() == 0) { + longPressFired = false; + App.post(longPressRunnable, LONG_PRESS_MS); + } + return super.dispatchKeyEvent(event); + } + if (KeyUtil.isActionUp(event)) { + cancelLongPressTimer(); + boolean consumed = longPressFired; + // The long press already ran, so this release must not deliver a click as well. + if (consumed) longPressFired = false; + else super.dispatchKeyEvent(event); + return consumed; + } + return super.dispatchKeyEvent(event); + } + + private void cancelLongPressTimer() { + App.removeCallbacks(longPressRunnable); } private boolean onKeyDown() { @@ -46,15 +90,40 @@ public class CustomTypeView extends MaterialTextView { return true; } + @Override + public boolean performClick() { + if (longPressFired) { + longPressFired = false; + return true; + } + return super.performClick(); + } + @Override protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { super.onFocusChanged(focused, direction, previouslyFocusedRect); App.post(() -> coolDown = false, 500); if (focused) coolDown = true; + else { + cancelLongPressTimer(); + longPressFired = false; + } + } + + @Override + protected void onDetachedFromWindow() { + cancelLongPressTimer(); + longPressFired = false; + super.onDetachedFromWindow(); } public interface Listener { void onRefresh(); } + + public interface LongPressListener { + + void onLongPress(); + } } 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..7a96c0aa3a 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 @@ -26,11 +26,11 @@ 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.theme.WebHtvAlertDialogBuilder; 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; @@ -66,7 +66,7 @@ public class AdBlockStatsDialog { private AdBlockStatsDialog(Activity activity) { this.activity = activity; this.binding = DialogAdBlockStatsBinding.inflate(LayoutInflater.from(activity)); - this.dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) + this.dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) .setView(binding.getRoot()) .create(); } @@ -84,8 +84,8 @@ public class AdBlockStatsDialog { 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.max(1, DialogSafeArea.width(activity, window) - horizontalMargin * 2); + int height = Math.max(1, DialogSafeArea.height(activity, window) - verticalMargin * 2); WindowManager.LayoutParams params = window.getAttributes(); params.width = width; params.height = height; @@ -379,7 +379,7 @@ public class AdBlockStatsDialog { } private void onReset() { - new MaterialAlertDialogBuilder(activity) + new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.ad_stats_reset) .setMessage(R.string.ad_stats_reset_confirm) .setPositiveButton(android.R.string.ok, (d, which) -> { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java index a28c221fdf..9047095f01 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java @@ -19,6 +19,7 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.UserAdRuleStore; import com.fongmi.android.tv.bean.UserAdRule; import com.fongmi.android.tv.databinding.DialogAdRuleEditBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -59,7 +60,7 @@ public class AdRuleEditDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override 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..49af999ce9 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 @@ -31,6 +31,7 @@ import com.fongmi.android.tv.bean.Rule; import com.fongmi.android.tv.bean.UserAdRule; import com.fongmi.android.tv.databinding.DialogAdRuleManageBinding; import com.fongmi.android.tv.databinding.DialogAdRuleDetailBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.AdRuleAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.utils.ResUtil; @@ -71,7 +72,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -217,7 +218,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter } private void onDisableImported() { - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) .setTitle(R.string.ad_rule_disable_imported) .setMessage(R.string.ad_rule_disable_imported_confirm) .setPositiveButton(android.R.string.ok, (dialog, which) -> { @@ -247,7 +248,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter () -> 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) + MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) .setTitle(R.string.ad_rule_import_title) .setCustomTitle(toolbar) .setMultiChoiceItems(labels, selected, (dialog, which, checked) -> selected[which] = checked) @@ -394,7 +395,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void showTextDetail(String name, String content, Runnable editAction) { DialogAdRuleDetailBinding detail = DialogAdRuleDetailBinding.inflate(getLayoutInflater()); detail.content.setText(content); - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) .setTitle(name) .setView(detail.getRoot()) .setPositiveButton(android.R.string.ok, null); @@ -421,7 +422,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter } private void confirmDisable(String name, int messageRes, Runnable action) { - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) .setTitle(name) .setMessage(messageRes) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) @@ -487,7 +488,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter @Override public void onDeleteClick(UserAdRule item) { - AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + AlertDialog alert = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) .setTitle(R.string.ad_rule_delete_confirm) .setMessage(getString(R.string.ad_rule_delete_message, item.getName())) .setPositiveButton(R.string.ad_rule_delete_confirm, (dialog, which) -> deleteUserRule(item)) diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java index fc9b67e53d..3cc95a88fa 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java @@ -15,6 +15,7 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.AdDetectionResult; import com.fongmi.android.tv.databinding.DialogAdRulePreviewBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -53,7 +54,7 @@ public class AdRulePreviewDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override 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 index 871cf5584d..1f2311f5f3 100644 --- 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 @@ -1,7 +1,6 @@ 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; @@ -19,19 +18,32 @@ 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.theme.AppearanceRowTheme; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeProfile; +import com.fongmi.android.tv.theme.ThemeProfileStore; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.textview.MaterialTextView; -public final class AppearanceDialog extends DialogFragment implements ThemeDialog.Listener { +import java.util.ArrayList; +import java.util.List; + +public final class AppearanceDialog extends DialogFragment { private String[] uiScales; private String[] languages; private String[] imageSizes; + private final List rows = new ArrayList<>(); private MaterialTextView uiScaleValue; + private MaterialTextView themeModeValue; private MaterialTextView themeValue; private MaterialTextView imageSizeValue; private MaterialTextView languageValue; + /** One dialog row, kept so a theme change can re-colour it without a rebuild. */ + private record Row(LinearLayout root, MaterialTextView title, MaterialTextView summary) { + } + public static void show(Fragment fragment) { new AppearanceDialog().show(fragment.getChildFragmentManager(), AppearanceDialog.class.getSimpleName()); } @@ -53,6 +65,7 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo LinearLayout content = new LinearLayout(requireContext()); content.setOrientation(LinearLayout.VERTICAL); uiScaleValue = addRow(content, R.string.setting_ui_scale, uiScales[Setting.getUiScaleIndex()], this::chooseUiScale); + themeModeValue = addRow(content, R.string.setting_theme_mode, getThemeModeText(), this::chooseThemeMode); 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); @@ -63,7 +76,6 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo 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); @@ -71,14 +83,12 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo 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); @@ -87,12 +97,28 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo summaryParams.leftMargin = dp(12); row.addView(summary, summaryParams); + // Row fill, stroke and both text colours must come from one palette. The row used + // to keep the fixed light selector_git_cloud_card while its text already followed + // ThemeController.current(), which on the TV dark table measured 1.16:1 / 1.36:1. + AppearanceRowTheme.apply(row, title, summary, ThemeController.current()); + rows.add(new Row(row, title, summary)); + LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(58)); rowParams.bottomMargin = dp(8); content.addView(row, rowParams); return summary; } + /** + * Re-colours the rows with the active palette. + * + *

Called when the dialog is shown and again after a theme change, so an open + * dialog follows a new profile without being rebuilt. + */ + private void applyRowTheme() { + for (Row row : rows) AppearanceRowTheme.apply(row.root(), row.title(), row.summary(), ThemeController.current()); + } + private void chooseUiScale(View view) { ChoiceDialog.showSingle(this, R.string.setting_ui_scale, uiScales, Setting.getUiScaleIndex(), which -> { if (which == Setting.getUiScaleIndex()) return; @@ -103,6 +129,32 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo }); } + private void chooseThemeMode(View view) { + String[] modes = {getString(R.string.setting_theme_mode_system), getString(R.string.setting_theme_mode_light), getString(R.string.setting_theme_mode_dark)}; + int current = switch (Setting.getThemeMode()) { + case 0 -> 1; + case 1 -> 2; + default -> 0; + }; + ChoiceDialog.showSingle(this, R.string.setting_theme_mode, modes, current, which -> { + int mode = which == 0 ? -1 : which - 1; + if (mode == Setting.getThemeMode()) return; + Setting.putThemeMode(mode); + themeModeValue.setText(getThemeModeText()); + ThemeController.applyNightModeToApp(); + dismissAllowingStateLoss(); + RefreshEvent.theme(); + }); + } + + private String getThemeModeText() { + return switch (Setting.getThemeMode()) { + case 0 -> getString(R.string.setting_theme_mode_light); + case 1 -> getString(R.string.setting_theme_mode_dark); + default -> getString(R.string.setting_theme_mode_system); + }; + } + private void chooseImageSize(View view) { ChoiceDialog.showSingle(this, R.string.setting_size, imageSizes, PlayerSetting.getSize(), which -> { imageSizeValue.setText(imageSizes[which]); @@ -122,15 +174,24 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo } @Override - public void setTheme(int color) { - themeValue.setText(themeText(color)); - Setting.putThemeColor(color); + public void onStart() { + super.onStart(); + // A profile applied while this dialog stayed on screen must reach the rows, and + // the rows are only attached after createContent(), so the repaint happens here. + applyRowTheme(); + } + + void onThemeProfileApplied() { + themeValue.setText(getThemeText()); + applyRowTheme(); dismissAllowingStateLoss(); RefreshEvent.theme(); } private String getThemeText() { - return themeText(Setting.getThemeColor()); + ThemeProfile profile = ThemeProfileStore.load(); + String name = profile.displayName(); + return "Default".equals(name) ? themeText(Setting.getThemeColor()) : name; } private String themeText(int color) { diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java index 231433fbf3..738fd60c6c 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java @@ -3,9 +3,16 @@ package com.fongmi.android.tv.ui.dialog; import android.app.Dialog; import android.app.Activity; import android.content.Intent; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; import android.os.Bundle; import android.text.TextUtils; +import android.view.Gravity; import android.view.View; +import android.view.Window; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.WindowManager; import android.view.inputmethod.EditorInfo; import androidx.activity.result.ActivityResultLauncher; @@ -25,12 +32,13 @@ import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.databinding.DialogConfigBinding; import com.fongmi.android.tv.event.ServerEvent; import com.fongmi.android.tv.impl.ConfigListener; +import com.fongmi.android.tv.playback.PlaybackIdentityResolver; import com.fongmi.android.tv.server.Server; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.Notify; -import com.fongmi.android.tv.utils.QRCode; import com.fongmi.android.tv.utils.ResUtil; +import com.fongmi.android.tv.utils.QRCode; import com.github.catvod.utils.Path; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -38,11 +46,14 @@ import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; public class ConfigDialog extends BaseAlertDialog { private DialogConfigBinding binding; + private Config config; private boolean append = true; private boolean edit; private String url; @@ -72,14 +83,24 @@ public class ConfigDialog extends BaseAlertDialog { return this; } + public ConfigDialog target(Config config) { + this.config = config; + return this; + } + public void show(FragmentActivity activity) { show(activity.getSupportFragmentManager(), null); } + public void show(androidx.fragment.app.Fragment fragment) { + show(fragment.getChildFragmentManager(), null); + } + @Override @NonNull public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - Dialog dialog = LightDialog.create(requireContext(), getDialogTitle(), getBinding().getRoot()); + int height = Math.max(1, ResUtil.getScreenHeight(requireContext()) - ResUtil.dp2px(48)); + Dialog dialog = LightDialog.create(requireContext(), getDialogTitle(), getBinding().getRoot(), 0.95f, 0.95f, 2000, height); initView(); initEvent(); return dialog; @@ -97,13 +118,15 @@ public class ConfigDialog extends BaseAlertDialog { @Override protected void initView() { - Config config = getConfig(); + if (config == null) config = edit ? getConfig() : Config.create(type); binding.name.setText(config.getName()); binding.text.setText(url = config.getUrl()); binding.text.setSelection(TextUtils.isEmpty(url) ? 0 : url.length()); + binding.addresses.setText(addressesText(config)); binding.positive.setText(edit ? R.string.dialog_edit : R.string.dialog_positive); binding.code.setImageBitmap(QRCode.getLightBitmap(Server.get().getAddress(4), 200, 0)); binding.info.setText(ResUtil.getString(R.string.push_info, Server.get().getAddress()).replace("\uff0c", "\n")); + binding.name.post(binding.name::requestFocus); } @Override @@ -196,27 +219,47 @@ public class ConfigDialog extends BaseAlertDialog { private void onPositive(View view) { String name = binding.name.getText().toString().trim(); String text = binding.text.getText().toString().trim(); - Config config = saveConfig(text, name); + Config config = saveConfig(text, name, binding.addresses.getText().toString()); if (config == null) { Notify.show(R.string.remote_trust_config_url_required); binding.text.requestFocus(); return; } ((ConfigListener) requireActivity()).setConfig(config); + PlaybackIdentityResolver.resolveSaved(config); dismiss(); } - private Config saveConfig(String text, String name) { + private Config saveConfig(String text, String name, String addresses) { + Config saved; if (text.isEmpty()) { if (!edit) return null; if (!TextUtils.isEmpty(url)) Config.delete(url, type); return getStoredConfig(); } else if (edit) { - return Config.find(url, type).url(text).name(name).update(); + saved = Config.find(config.getId()).url(text).name(name).update(); } else { Config exists = AppDatabase.get().getConfigDao().find(text, type); - return exists != null ? exists.name(name).update() : Config.create(type).url(text).name(name).update(); + saved = exists != null ? exists.name(name).update() : null; + if (saved == null) { + return config = Config.create(type).url(text).name(name) + .urls(addresses(text, addresses)).insert().update(); + } } + return config = saved.urls(addresses(text, addresses)).update(); + } + + private List addresses(String primary, String text) { + List result = new ArrayList<>(); + result.add(primary); + for (String item : text.split("\\R")) if (!TextUtils.isEmpty(item.trim())) result.add(item.trim()); + return result; + } + + private String addressesText(Config config) { + List addresses = new ArrayList<>(config.getUrls()); + addresses.remove(config.getUrl()); + return TextUtils.join("\n", addresses); } private void onNegative(View view) { @@ -234,10 +277,53 @@ public class ConfigDialog extends BaseAlertDialog { @Override public void onStart() { super.onStart(); - setWidth(0.55f); + configureWindow(); EventBus.getDefault().register(this); } + private void configureWindow() { + if (getDialog() == null || getDialog().getWindow() == null) return; + Window window = getDialog().getWindow(); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); + int width = screenWidth - ResUtil.dp2px(48); + int height = screenHeight - ResUtil.dp2px(48); + WindowManager.LayoutParams params = window.getAttributes(); + params.width = width; + params.height = height; + 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); + expandContentToWindow(width, height); + } + + private void expandContentToWindow(int width, int height) { + View content = binding == null ? null : binding.getRoot(); + if (content == null) return; + content.setMinimumWidth(width); + content.setMinimumHeight(height); + ViewGroup.LayoutParams contentParams = content.getLayoutParams(); + if (contentParams != null) { + contentParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + contentParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + content.setLayoutParams(contentParams); + } + ViewParent parent = content.getParent(); + while (parent instanceof ViewGroup group) { + group.setMinimumWidth(width); + group.setMinimumHeight(height); + ViewGroup.LayoutParams params = group.getLayoutParams(); + if (params != null) { + params.width = ViewGroup.LayoutParams.MATCH_PARENT; + params.height = ViewGroup.LayoutParams.MATCH_PARENT; + group.setLayoutParams(params); + } + parent = group.getParent(); + } + } + @Override public void onStop() { super.onStop(); @@ -255,7 +341,7 @@ public class ConfigDialog extends BaseAlertDialog { App.post(() -> { if (activity.isFinishing() || activity.isDestroyed()) return; dismissAllowingStateLoss(); - App.post(() -> ((ConfigListener) activity).setConfig(saveConfig("file:/" + path.replace(Path.rootPath(), ""), binding.name.getText().toString().trim())), 100); + App.post(() -> ((ConfigListener) activity).setConfig(saveConfig("file:/" + path.replace(Path.rootPath(), ""), binding.name.getText().toString().trim(), binding.addresses.getText().toString())), 100); }, 100); }); } 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..3204a9f497 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 @@ -20,6 +20,7 @@ import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.bean.TmdbPerson; import com.fongmi.android.tv.service.TmdbService; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.EpisodePhotoAdapter; import com.fongmi.android.tv.ui.adapter.TmdbPersonAdapter; import com.fongmi.android.tv.utils.ResUtil; @@ -171,7 +172,7 @@ public class EpisodeDetailDialog { loadEpisodeMedia(activity, tmdbEpisode, site, light, photosLabel, photosGrid, guestsLabel, guestsGrid); } - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity) + MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(activity) .setView(view); AlertDialog alertDialog = builder.create(); @@ -274,7 +275,7 @@ public class EpisodeDetailDialog { guestsLabel.setVisibility(View.GONE); guestsGrid.setVisibility(View.GONE); - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity) + MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(activity) .setView(view); AlertDialog alertDialog = builder.create(); @@ -334,7 +335,7 @@ public class EpisodeDetailDialog { private static void showSimpleDialog(FragmentActivity activity, Episode episode, android.content.DialogInterface.OnDismissListener dismissListener) { // 标题放固定文案,源站文件名放可换行的正文,避免长名被单行标题截断 - AlertDialog dialog = new MaterialAlertDialogBuilder(activity) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.detail_tmdb_empty) .setMessage(episode.getName()) .setPositiveButton("关闭", null) 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..2437c33f95 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,5 +1,13 @@ package com.fongmi.android.tv.ui.dialog; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; + import androidx.fragment.app.FragmentActivity; import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; @@ -13,8 +21,11 @@ import com.fongmi.android.tv.api.config.WallConfig; import com.fongmi.android.tv.bean.Config; import com.fongmi.android.tv.databinding.DialogHistoryBinding; import com.fongmi.android.tv.impl.ConfigListener; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.ConfigAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; +import com.fongmi.android.tv.utils.Notify; +import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnClickListener { @@ -23,6 +34,7 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl private ConfigAdapter adapter; private ConfigListener listener; private boolean readOnly; + private boolean manage; private int type; private ItemTouchHelper sortTouchHelper; @@ -50,6 +62,11 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl return this; } + public HistoryDialog manage() { + manage = true; + return this; + } + public void show(FragmentActivity activity) { show(activity.getSupportFragmentManager(), null); if (activity instanceof ConfigListener) listener = (ConfigListener) activity; @@ -76,10 +93,22 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl binding.recycler.setItemAnimator(null); binding.recycler.setHasFixedSize(false); binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 16)); - binding.recycler.setAdapter(adapter.readOnly(readOnly).addAll(type, getConfig())); + binding.recycler.setAdapter(adapter.readOnly(readOnly).protectCurrent(manage).addAll(type, getConfig())); + binding.add.setVisibility(manage ? View.VISIBLE : View.GONE); + binding.add.setOnClickListener(v -> onAdd()); if (type == 0 && !readOnly) attachSortHelper(); } + private void onAdd() { + ConfigDialog dialog = ConfigDialog.create(); + if (type == 0) dialog.vod(); + else if (type == 1) dialog.live(); + else dialog.wall(); + if (getParentFragment() != null) dialog.show(getParentFragment()); + else dialog.show(requireActivity()); + dismiss(); + } + private void attachSortHelper() { sortTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { @Override public boolean isLongPressDragEnabled() { return false; } @@ -114,9 +143,24 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl return true; } + @Override + public void onEditClick(Config item) { + ConfigDialog dialog = ConfigDialog.create().target(item).edit(); + if (type == 0) dialog.vod(); + else if (type == 1) dialog.live(); + else dialog.wall(); + if (getParentFragment() != null) dialog.show(getParentFragment()); + else dialog.show(requireActivity()); + dismiss(); + } + @Override public void onDeleteClick(Config item) { - androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(requireContext()) + if (adapter.isProtectedCurrent(item)) { + Notify.show(R.string.config_current_delete_message); + return; + } + androidx.appcompat.app.AlertDialog dialog = new WebHtvAlertDialogBuilder(requireContext()) .setTitle(R.string.config_delete_title) .setMessage(getString(R.string.config_delete_message, item.getDesc())) .setNegativeButton(R.string.dialog_negative, null) @@ -131,7 +175,29 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl @Override public void onStart() { super.onStart(); - if (adapter.getItemCount() == 0) dismiss(); - else setWidth(0.4f); + if (adapter.getItemCount() == 0 && !manage) dismiss(); + else configureWindow(); + } + + private void configureWindow() { + if (getDialog() == null || getDialog().getWindow() == null) return; + Window window = getDialog().getWindow(); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); + int width = screenWidth - ResUtil.dp2px(48); + int height = screenHeight - ResUtil.dp2px(48); + WindowManager.LayoutParams params = window.getAttributes(); + params.width = width; + params.height = height; + 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); + ViewGroup.LayoutParams recyclerParams = binding.recycler.getLayoutParams(); + recyclerParams.height = 0; + if (recyclerParams instanceof androidx.appcompat.widget.LinearLayoutCompat.LayoutParams linearParams) linearParams.weight = 1; + binding.recycler.setLayoutParams(recyclerParams); } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuDialog.java index 0e3be1ecff..604093c738 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuDialog.java @@ -26,7 +26,7 @@ import java.util.List; public class HomeMenuDialog extends BaseAlertDialog { private static final String TAG = "home_menu_dialog"; - // 9 项按 3 列排布,正好一屏显示完,与参考实现一致 + // 菜单项按 3 列排布;去掉首项后 11 项共 4 行,仍在 maxHeight 一屏内显示完 private static final int GRID_COUNT = 3; private DialogHomeMenuBinding binding; @@ -35,7 +35,7 @@ public class HomeMenuDialog extends BaseAlertDialog { public interface Listener { /** - * @param index select_home_menu_key 中的下标,取值 1..9 + * @param index select_home_menu_key 中的下标,取值 1..11 */ void onHomeMenuItem(int index); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuKeyDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuKeyDialog.java index 6a00dd7875..65503e2168 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuKeyDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/HomeMenuKeyDialog.java @@ -1,10 +1,10 @@ package com.fongmi.android.tv.ui.dialog; -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.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; public class HomeMenuKeyDialog { @@ -12,7 +12,7 @@ public class HomeMenuKeyDialog { String[] items = activity.getResources().getStringArray(R.array.select_home_menu_key); int current = Setting.getHomeMenuKey(); - new AlertDialog.Builder(activity) + new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.setting_home_menu_key) .setSingleChoiceItems(items, current, (dialog, which) -> { Setting.putHomeMenuKey(which); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java index 3cadc4048c..76722040fb 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java @@ -20,6 +20,8 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterPlayerOsdBinding; import com.fongmi.android.tv.databinding.DialogPlayerOsdBinding; +import com.fongmi.android.tv.theme.PlayerSettingsRowTheme; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.utils.ResUtil; import java.util.Arrays; @@ -115,11 +117,12 @@ public final class PlayerOsdDialog extends DialogFragment { public void onBindViewHolder(@NonNull ViewHolder holder, int position) { boolean visible = checked[position]; holder.binding.name.setText(items[position]); - holder.binding.name.setAlpha(visible ? 1f : 0.55f); holder.binding.visible.setImageResource(visible ? R.drawable.ic_player_button_visible : R.drawable.ic_player_button_hidden); holder.binding.visible.setContentDescription(getString(visible ? R.string.setting_show : R.string.setting_hide)); holder.binding.visible.setSelected(visible); holder.binding.visible.setActivated(visible); + PlayerSettingsRowTheme.apply(holder.binding.root, holder.binding.name, ThemeController.current(), visible); + PlayerSettingsRowTheme.applySwitch(holder.binding.visible, ThemeController.current()); holder.binding.root.setOnClickListener(view -> toggle(holder.getBindingAdapterPosition())); holder.binding.visible.setOnClickListener(view -> toggle(holder.getBindingAdapterPosition())); holder.binding.visible.setNextFocusLeftId(R.id.visible); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/QuickSearchDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/QuickSearchDialog.java index 41585e5a21..5c6606f1ee 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/QuickSearchDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/QuickSearchDialog.java @@ -42,6 +42,7 @@ public class QuickSearchDialog extends BaseAlertDialog implements QuickAdapter.O private int progressCurrent; private int progressTotal; private boolean searchFinished; + private String currentSiteKey = ""; public QuickSearchDialog() { pending = new ArrayList<>(); @@ -56,6 +57,12 @@ public class QuickSearchDialog extends BaseAlertDialog implements QuickAdapter.O return this; } + public QuickSearchDialog currentSiteKey(String currentSiteKey) { + this.currentSiteKey = currentSiteKey == null ? "" : currentSiteKey; + if (adapter != null) adapter.setCurrentSiteKey(this.currentSiteKey); + return this; + } + public QuickSearchDialog dismissListener(DialogInterface.OnDismissListener dismissListener) { this.dismissListener = dismissListener; return this; @@ -101,6 +108,7 @@ public class QuickSearchDialog extends BaseAlertDialog implements QuickAdapter.O binding.recycler.setItemAnimator(null); binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 8)); binding.recycler.setAdapter(adapter = new QuickAdapter(this)); + adapter.setCurrentSiteKey(currentSiteKey); adapter.setWidth(panelWidth - ResUtil.dp2px(32)); adapter.setNextFocus(0, 0); updateProgress(); 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..223b63f0da 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 @@ -2,10 +2,12 @@ package com.fongmi.android.tv.ui.dialog; import android.app.Dialog; import android.graphics.Color; +import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; import android.os.Bundle; import android.os.SystemClock; import android.text.TextUtils; +import android.view.Gravity; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; @@ -16,7 +18,6 @@ import android.view.WindowManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.LinearLayoutCompat; -import androidx.core.content.ContextCompat; import androidx.fragment.app.FragmentActivity; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -32,8 +33,10 @@ import com.fongmi.android.tv.databinding.DialogSiteBinding; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.impl.SiteListener; import com.fongmi.android.tv.setting.SiteGroupOrderStore; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.ui.adapter.SiteAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; +import com.fongmi.android.tv.ui.helper.SiteDialogTheme; import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.KeyUtil; import com.fongmi.android.tv.utils.Notify; @@ -49,6 +52,23 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi private static final int GRID_COUNT = 3; private static final String TAG = "site_dialog"; private static final int INITIAL_BATCH = 48; + /** 站点卡片高度,必须与 adapter_site*.xml 的 layout_height 一致。 */ + private static final int ROW_HEIGHT_DP = 52; + /** 网格行间距,必须与 SpaceItemDecoration(GRID_COUNT, 16) 的 16 一致。 */ + private static final int ROW_SPACING_DP = 16; + /** + * 面板占屏宽的比例,对齐仓库既有大弹窗惯例(CacheManagementDialog.SCREEN_FRACTION = 0.9f)。 + * 这里刻意不再叠加 dp 上限:真机电视常见 density 1.0,1920px 屏即 1920dp,旧上限 1180dp + * 会把面板压到只剩 61% 屏宽,分组标签行放不下、末尾标签被硬裁。 + */ + private static final float PANEL_WIDTH_RATIO = 0.9f; + /** 极窄屏兜底宽度,避免面板窄到无法阅读。 */ + private static final int PANEL_MIN_WIDTH_DP = 480; + /** + * 面板高度占可用高度的上限。同样对齐 0.9 惯例,让一屏能多显示几行站点; + * 网格高度仍按整数行收敛,不会把末行裁半截。 + */ + private static final float PANEL_MAX_HEIGHT_RATIO = 0.9f; private static String selectedGroup = ""; @@ -115,6 +135,9 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi initView(); initEvent(); dialog.setOnKeyListener((d, keyCode, event) -> onDialogKey(keyCode, event)); + ThemeController.bindDialog(dialog); + SiteDialogTheme.applyShell(binding.getRoot(), binding.keyword, binding.config, binding.search, + binding.change, binding.select, binding.cancel, binding.mode); return dialog; } @@ -145,6 +168,9 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi directDialog.show(); log("show call end cost=%sms total=%sms", cost(showDialogStart), cost()); applyWindow(directDialog.getWindow()); + ThemeController.bindDialog(directDialog); + SiteDialogTheme.applyShell(binding.getRoot(), binding.keyword, binding.config, binding.search, + binding.change, binding.select, binding.cancel, binding.mode); TouchOptimizationHelper.sync(directDialog); log("window applied total=%sms", cost()); } @@ -159,6 +185,9 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi long start = System.currentTimeMillis(); setRootWidth(); setRecyclerHeight(INITIAL_BATCH); + binding.header.setVisibility(View.VISIBLE); + binding.title.setText(action ? R.string.site_dialog_title_action : R.string.site_dialog_title); + binding.count.setVisibility(View.GONE); binding.searchBar.setVisibility(View.GONE); binding.keyword.setVisibility(View.GONE); binding.actionGap.setVisibility(View.GONE); @@ -168,6 +197,8 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi binding.select.setVisibility(action ? View.VISIBLE : View.GONE); binding.cancel.setVisibility(action ? View.VISIBLE : View.GONE); binding.mode.setVisibility(View.GONE); + SiteDialogTheme.applyShell(binding.getRoot(), binding.keyword, binding.config, binding.search, + binding.change, binding.select, binding.cancel, binding.mode); setActionEnabled(false); binding.recycler.setAdapter(null); binding.recycler.setItemAnimator(null); @@ -196,18 +227,35 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi long layoutStart = System.currentTimeMillis(); setType(type); setRecyclerView(); - setRecyclerHeight(adapter.getItemCount()); setMode(); setGroupView(); + setRecyclerHeight(adapter.getItemCount()); setActionEnabled(true); + updateCountText(); log("view configured cost=%sms total=%sms", cost(layoutStart), cost()); runAfterFirstPreDraw("list preDraw", () -> { if (adapter != null) adapter.showAll(); log("list expanded total=%sms items=%s", cost(), adapter == null ? -1 : adapter.getItemCount()); + // 首帧后 wrap_content 的标题/分组行才有真实高度,此时再收敛一次网格高度, + // 面板高度才是「内容 + 整数行网格」,不会在屏幕外多留一行。 + if (binding != null && adapter != null) { + setRecyclerHeight(adapter.getItemCount()); + updateCountText(); + } focusSelectedSite(); }); } + private void updateCountText() { + if (binding == null || adapter == null) return; + int count = adapter.getItemCount(); + // 走视图自己的 context:TV 版是裸 Dialog(showDirect),Fragment 从未 attach 到 + // FragmentManager,Fragment.getString()/requireContext() 会抛 + // IllegalStateException: not attached to a context。 + binding.count.setText(binding.getRoot().getContext().getString(R.string.site_dialog_count, count)); + binding.count.setVisibility(count > 0 ? View.VISIBLE : View.GONE); + } + @Override protected void initEvent() { binding.config.setOnClickListener(v -> { @@ -232,6 +280,7 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi setRecyclerView(); setRecyclerHeight(adapter.getItemCount()); setMode(); + updateCountText(); setWidth(); } }); @@ -247,17 +296,75 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi } private void setRecyclerHeight(int count) { - // 全屏模式下,recycler 由布局 layout_weight 决定高度,无需动态计算 + // 面板不再铺满:网格高度收敛到整数行,多余的行交给滚动,避免最后一行被裁掉半截。 + // 高度在这里显式给定,因此不再需要 maxHeight 兜底(Integer.MAX_VALUE 还会被 + // CustomRecyclerView 折成 AT_MOST 的畸形 spec)。 ViewGroup.LayoutParams params = binding.recycler.getLayoutParams(); - params.height = ViewGroup.LayoutParams.MATCH_PARENT; + params.height = gridHeight(count); binding.recycler.setLayoutParams(params); - binding.recycler.setMaxHeight(Integer.MAX_VALUE); + } + + private int gridHeight(int count) { + int rows = Math.max(1, (Math.max(0, count) + getCount() - 1) / getCount()); + int row = ResUtil.dp2px(ROW_HEIGHT_DP); + int spacing = ResUtil.dp2px(ROW_SPACING_DP); + int budget = gridBudgetHeight(); + if (budget < row) return row; + int fitRows = Math.max(1, (budget + spacing) / (row + spacing)); + int visibleRows = Math.min(rows, fitRows); + return visibleRows * row + (visibleRows - 1) * spacing; + } + + /** 网格高度预算 = 可用高度 × 面板高度上限 − 面板里除网格以外的固定内容高度。 */ + private int gridBudgetHeight() { + return Math.max(0, Math.round(windowVisibleHeight() * PANEL_MAX_HEIGHT_RATIO) - panelChromeHeight()); + } + + /** + * 窗口实际可用高度。优先用 decor 的可见显示区域:它已经扣掉了系统栏, + * 比直接用屏幕高度更接近面板真正能占的高度;拿不到时退回屏幕高度。 + */ + private int windowVisibleHeight() { + View root = binding == null ? null : binding.getRoot(); + if (root != null) { + Rect frame = new Rect(); + root.getWindowVisibleDisplayFrame(frame); + if (frame.height() > 0) return frame.height(); + } + return ResUtil.getScreenHeight(getDialogActivity()); + } + + /** + * 面板除网格以外的固定高度。按实际布局测量,避免把 dialog_site.xml 的尺寸写死成第二份常量: + * 改布局的人不需要记得同步这里。 + */ + private int panelChromeHeight() { + if (binding == null) return 0; + int chrome = binding.getRoot().getPaddingTop() + binding.getRoot().getPaddingBottom(); + chrome += fixedHeight(binding.header); + chrome += fixedHeight(binding.searchBar); + chrome += fixedHeight(binding.groupScroll); + chrome += fixedHeight(binding.groupHint); + if (binding.recycler.getParent() instanceof View container && container.getLayoutParams() instanceof ViewGroup.MarginLayoutParams margins) { + chrome += Math.max(0, margins.topMargin); + } + return chrome; + } + + private int fixedHeight(View view) { + if (view == null || view.getVisibility() == View.GONE) return 0; + int height = view.getMeasuredHeight(); + if (height <= 0 && view.getLayoutParams() != null) height = Math.max(0, view.getLayoutParams().height); + if (view.getLayoutParams() instanceof ViewGroup.MarginLayoutParams margins) height += Math.max(0, margins.topMargin); + return height; } private void setRootWidth() { ViewGroup.LayoutParams params = binding.getRoot().getLayoutParams(); - if (params == null) params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + if (params == null) params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + // 宽度填满窗口(窗口本身已按屏宽比例收窄),高度贴合内容。 params.width = ViewGroup.LayoutParams.MATCH_PARENT; + params.height = ViewGroup.LayoutParams.WRAP_CONTENT; binding.getRoot().setLayoutParams(params); } @@ -289,8 +396,10 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi private void setActionEnabled(boolean enabled) { binding.search.setEnabled(enabled); binding.change.setEnabled(enabled); - binding.select.setEnabled(enabled && type > 0); - binding.cancel.setEnabled(enabled && type > 0); + // Keep bulk actions in the D-pad focus chain even in plain switch mode. + // setType() controls whether they are clickable; enabled only reflects loading state. + binding.select.setEnabled(enabled); + binding.cancel.setEnabled(enabled); } @Override @@ -399,12 +508,22 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi window.setWindowAnimations(0); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); WindowManager.LayoutParams params = window.getAttributes(); - params.width = WindowManager.LayoutParams.MATCH_PARENT; - params.height = WindowManager.LayoutParams.MATCH_PARENT; + // 居中浮层面板:四周留出安全边距,高度贴合内容,不再铺满整屏。 + params.width = panelWidth(); + params.height = WindowManager.LayoutParams.WRAP_CONTENT; + params.gravity = Gravity.CENTER; + params.dimAmount = 0.62f; + window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); window.setAttributes(params); window.setLayout(params.width, params.height); } + private int panelWidth() { + int screen = ResUtil.getScreenWidth(getDialogActivity()); + int min = Math.min(ResUtil.dp2px(PANEL_MIN_WIDTH_DP), screen); + return Math.max(min, Math.round(screen * PANEL_WIDTH_RATIO)); + } + private void runAfterFirstPreDraw(String label, Runnable action) { View root = binding == null ? null : binding.getRoot(); if (root == null) { @@ -480,16 +599,17 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi private androidx.appcompat.widget.AppCompatTextView getGroupView(String group, String text) { androidx.appcompat.widget.AppCompatTextView button = new androidx.appcompat.widget.AppCompatTextView(getDialogActivity()); LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.setMarginEnd(ResUtil.dp2px(12)); + // 间距与内边距收紧到 10dp/14dp:面板 90% 宽时 14 个标签刚好放得下, + // 不再让末尾标签被面板右缘裁掉半个(超出时仍由 groupScroll 横向滚动兜底)。 + params.setMarginEnd(ResUtil.dp2px(10)); button.setLayoutParams(params); button.setTag(group); button.setText(text); button.setSingleLine(true); button.setAllCaps(false); button.setGravity(android.view.Gravity.CENTER); - button.setPadding(ResUtil.dp2px(16), ResUtil.dp2px(8), ResUtil.dp2px(16), ResUtil.dp2px(8)); - button.setTextColor(ContextCompat.getColorStateList(getDialogActivity(), R.color.selector_group_text)); - button.setBackgroundResource(R.drawable.selector_group_button); + button.setPadding(ResUtil.dp2px(14), ResUtil.dp2px(8), ResUtil.dp2px(14), ResUtil.dp2px(8)); + SiteDialogTheme.applyGroup(button); button.setFocusable(true); button.setClickable(true); button.setNextFocusDownId(binding.recycler.getId()); @@ -609,6 +729,7 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi updateGroupView(); adapter.filter(selectedGroup, ""); setRecyclerHeight(adapter.getItemCount()); + updateCountText(); scrollRecyclerToTop(); centerGroup(view); } @@ -635,6 +756,7 @@ public class SiteDialog extends BaseAlertDialog implements SiteAdapter.OnClickLi button.setContentDescription(TextUtils.isEmpty(group) ? text : getDialogActivity().getString(description, text)); view.setAlpha(groupReordering && !reordering ? 0.55f : 1.0f); view.setSelected(selected); + SiteDialogTheme.applyGroup(button); } } 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 index 39297af16d..128a6e1276 100644 --- 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 @@ -1,46 +1,533 @@ package com.fongmi.android.tv.ui.dialog; +import android.app.Dialog; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.fragment.app.DialogFragment; 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.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.ui.adapter.ThemeAdapter; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.fongmi.android.tv.theme.ThemeColorPickerDialog; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeEditor; +import com.fongmi.android.tv.theme.ThemeEditorUi; +import com.fongmi.android.tv.theme.ThemePaletteStyle; +import com.fongmi.android.tv.theme.ThemePresets; +import com.fongmi.android.tv.theme.ThemePreviewView; +import com.fongmi.android.tv.theme.ThemeProfile; +import com.fongmi.android.tv.theme.ThemeProfileCodec; +import com.fongmi.android.tv.theme.ThemeProfileStore; +import com.fongmi.android.tv.theme.ThemeTokens; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; +import com.fongmi.android.tv.utils.ResUtil; +import com.fongmi.android.tv.utils.Util; -public class ThemeDialog extends BaseAlertDialog implements ThemeAdapter.OnClickListener { +import java.util.EnumMap; - private static final int[] COLORS = {-1, 0, 0xFF6750A4, 0xFF3949AB, 0xFF1E88E5, 0xFF00ACC1, 0xFF00897B, 0xFF43A047, 0xFF7CB342, 0xFFFB8C00, 0xFFE53935, 0xFFD81B60, 0xFF8E24AA, 0xFF6D4C41,}; - private DialogThemeBinding binding; +/** Locally themed editing session. Only Save publishes the draft to the rest of the app. */ +public final class ThemeDialog extends DialogFragment implements ThemePreviewView.Callbacks { + + private final EnumMap cards = new EnumMap<>(ThemePresets.Preset.class); + private final EnumMap styles = new EnumMap<>(ThemePaletteStyle.class); + private ThemeEditor editor; + private ThemeTokens previewTokens; + private boolean dark; + private boolean modeChanged; + private int wallpaperColor; + private int savedScroll; + /** + * System-bar insets reported by the dialog window, or {@code null} before they arrive. + * + *

{@code onStart} runs before the window is always laid out, so the insets can still + * be missing when the window is first sized; the listener installed in {@link #onStart} + * re-sizes the window once they are known. + */ + private ThemeDialogLayout.Insets windowInsets; + private LinearLayout root; + private ThemePreviewView panel; + private ScrollView scroll; + private TextView title; + private TextView note; + private TextView presetTitle; + private TextView paletteTitle; + private TextView modeTitle; + private TextView status; + private Button lightButton; + private Button darkButton; + private Button importButton; + private Button resetButton; + private Button cancelButton; + private Button saveButton; public static void show(Fragment fragment) { - new ThemeDialog().show(fragment.getChildFragmentManager(), null); + new ThemeDialog().show(fragment.getChildFragmentManager(), ThemeDialog.class.getSimpleName()); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + editor = ThemeEditor.load(); + int savedMode = Setting.getThemeMode(); + dark = savedMode == 1 || (savedMode < 0 && ThemeController.isNight(requireContext())); + wallpaperColor = Setting.getWallColor(); + if (savedInstanceState != null) { + dark = savedInstanceState.getBoolean("preview_dark", dark); + modeChanged = savedInstanceState.getBoolean("mode_changed", false); + savedScroll = savedInstanceState.getInt("preview_scroll", 0); + String draft = savedInstanceState.getString("preview_draft"); + if (draft != null) { + try { + editor.replace(ThemeProfileCodec.parse(draft)); + } catch (RuntimeException ignored) { /* Invalid saved UI state must not block opening settings. */ } + } + } + previewTokens = editor.preview(dark, wallpaperColor); + root = ThemeEditorUi.column(requireContext()); + // This subtree belongs to the draft, not the builder's persisted-theme binder. + root.setTag("webhtv:ignore"); + ThemeEditorUi.padding(root, 18, 16); + buildHeader(); + + scroll = new ScrollView(requireContext()); + scroll.setClipToPadding(false); + LinearLayout content = ThemeEditorUi.column(requireContext()); + content.setPadding(0, 0, 0, dp(12)); + presetTitle = heading(R.string.theme_editor_presets); + content.addView(presetTitle, ThemeEditorUi.fullWidth(requireContext(), 14)); + // Default is first in the very first configuration group, ahead of palette styles. + content.addView(buildPresetRow(), ThemeEditorUi.fullWidth(requireContext(), 10)); + content.addView(buildModeRow(), ThemeEditorUi.fullWidth(requireContext(), 14)); + paletteTitle = heading(R.string.theme_editor_palette_style); + content.addView(paletteTitle, ThemeEditorUi.fullWidth(requireContext(), 12)); + content.addView(buildPaletteRow(), ThemeEditorUi.fullWidth(requireContext(), 8)); + panel = ThemePreviewView.createPanel(requireContext(), editor, dark, previewTokens, this); + content.addView(panel); + scroll.addView(content); + root.addView(scroll, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + buildFooter(); + render(); + // Custom actions avoid AlertDialog's unconditional positive-button dismissal on save failure. + return new WebHtvAlertDialogBuilder(requireContext()).setView(root).create(); + } + + private void buildHeader() { + LinearLayout header = ThemeEditorUi.row(requireContext()); + title = ThemeEditorUi.text(requireContext(), getString(R.string.setting_theme_color), 22, previewTokens.colorOnSurface()); + ThemeEditorUi.heading(title); + header.addView(title, ThemeEditorUi.weighted()); + importButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_import); + importButton.setOnClickListener(view -> ThemeImportDialog.show(this, profile -> { + ThemeEditor.Result result = editor.replace(profile); + if (result.success()) render(); + else setStatus(result.error()); + })); + header.addView(importButton); + root.addView(header); + note = ThemeEditorUi.text(requireContext(), getString(R.string.theme_editor_inline_hint), 12, previewTokens.colorOnSurfaceVariant()); + root.addView(note, ThemeEditorUi.fullWidth(requireContext(), 6)); + } + + private View buildPresetRow() { + LinearLayout row = ThemeEditorUi.row(requireContext()); + row.setPadding(dp(2), dp(2), dp(2), dp(2)); + for (ThemePresets.Preset preset : ThemePresets.Preset.values()) { + PresetCard card = new PresetCard(preset); + cards.put(preset, card); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp(128), ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMarginEnd(dp(10)); + row.addView(card.view, params); + } + return horizontalScroll(row); + } + + private View buildModeRow() { + LinearLayout row = ThemeEditorUi.row(requireContext()); + modeTitle = heading(R.string.theme_editor_edit_mode); + row.addView(modeTitle, ThemeEditorUi.weighted()); + lightButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_light); + darkButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_dark); + lightButton.setOnClickListener(view -> selectMode(false)); + darkButton.setOnClickListener(view -> selectMode(true)); + LinearLayout.LayoutParams lightParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + lightParams.setMarginEnd(dp(8)); + row.addView(lightButton, lightParams); + row.addView(darkButton); + return row; + } + + private View buildPaletteRow() { + LinearLayout row = ThemeEditorUi.row(requireContext()); + row.setPadding(dp(2), dp(2), dp(2), dp(2)); + for (ThemePaletteStyle style : ThemePaletteStyle.values()) { + Button button = ThemeEditorUi.button(requireContext(), paletteLabel(style)); + button.setOnClickListener(view -> { + editor.replace(ThemePresets.withPaletteStyle(editor.draft(), style)); + render(); + }); + styles.put(style, button); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMarginEnd(dp(8)); + row.addView(button, params); + } + return horizontalScroll(row); + } + + private View horizontalScroll(View child) { + HorizontalScrollView scroll = new HorizontalScrollView(requireContext()); + scroll.setHorizontalScrollBarEnabled(false); + scroll.addView(child); + return scroll; + } + + private void buildFooter() { + status = ThemeEditorUi.text(requireContext(), "", 12, previewTokens.colorOnSurfaceVariant()); + status.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE); + root.addView(status, ThemeEditorUi.fullWidth(requireContext(), 10)); + LinearLayout actions = ThemeEditorUi.row(requireContext()); + cancelButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_cancel); + resetButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_reset); + saveButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_apply); + cancelButton.setOnClickListener(view -> dismiss()); + resetButton.setOnClickListener(view -> { editor.reset(); render(); }); + saveButton.setOnClickListener(view -> applyDraft()); + for (Button button : new Button[]{cancelButton, resetButton, saveButton}) { + LinearLayout.LayoutParams params = ThemeEditorUi.weighted(); + if (button != saveButton) params.setMarginEnd(dp(8)); + actions.addView(button, params); + } + root.addView(actions, ThemeEditorUi.fullWidth(requireContext(), 8)); + } + + /** One snapshot drives every real control; no view is recreated during a colour edit or drag. */ + private void render() { + previewTokens = editor.preview(dark, wallpaperColor); + ThemeTokens tokens = previewTokens; + title.setTextColor(tokens.colorOnSurface()); + note.setTextColor(tokens.colorOnSurfaceVariant()); + for (TextView heading : new TextView[]{presetTitle, paletteTitle, modeTitle}) { + heading.setTextColor(tokens.colorOnSurfaceVariant()); + } + ThemeEditorUi.buttonColors(importButton, tokens.colorSecondaryContainer(), tokens.colorOnSecondaryContainer(), tokens, false); + modeButton(lightButton, !dark); + modeButton(darkButton, dark); + ThemeProfile draft = editor.draft(); + for (PresetCard card : cards.values()) card.render(draft); + for (ThemePaletteStyle style : styles.keySet()) { + boolean selected = style.id().equals(draft.paletteStyle); + modeButton(styles.get(style), selected); + } + if (panel != null) panel.render(editor, dark, tokens); + ThemeEditorUi.buttonColors(cancelButton, tokens.colorSurfaceContainer(), tokens.colorOnSurface(), tokens, false); + ThemeEditorUi.buttonColors(resetButton, tokens.colorSurfaceContainer(), tokens.colorOnSurface(), tokens, false); + ThemeEditorUi.buttonColors(saveButton, tokens.colorPrimary(), tokens.colorOnPrimary(), tokens, false); + setStatus(getString(editor.isDirty() ? R.string.theme_editor_dirty : R.string.theme_editor_saved_state)); + Dialog dialog = getDialog(); + if (dialog != null && dialog.getWindow() != null) { + Window window = dialog.getWindow(); + window.setBackgroundDrawable(ThemeEditorUi.shape(requireContext(), + ThemeEditorUi.withAlpha(tokens.colorSurface(), tokens.dialogOpacity()), 0, 0, 24)); + window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + WindowManager.LayoutParams params = window.getAttributes(); + params.dimAmount = ThemeEditorUi.alpha(tokens.colorScrim()); + window.setAttributes(params); + } + } + + private void modeButton(Button button, boolean selected) { + ThemeTokens tokens = previewTokens; + ThemeEditorUi.buttonColors(button, selected ? tokens.colorPrimaryContainer() : tokens.colorSurfaceContainer(), + selected ? tokens.colorOnPrimaryContainer() : tokens.colorOnSurface(), tokens, selected); + } + + private final class PresetCard { + final ThemePresets.Preset preset; + final ThemeProfile profile; + final ThemeTokens light; + final ThemeTokens night; + final LinearLayout view; + final TextView name; + final TextView detail; + final View[] swatches = new View[4]; + + PresetCard(ThemePresets.Preset preset) { + this.preset = preset; + profile = preset.profile(); + ThemeEditor sample = new ThemeEditor(profile); + light = sample.preview(false, wallpaperColor); + night = sample.preview(true, wallpaperColor); + view = ThemeEditorUi.column(requireContext()); + ThemeEditorUi.padding(view, 12, 12); + view.setFocusable(true); + view.setTag("theme-preset:" + preset.name()); + view.setOnClickListener(ignored -> { + ThemeProfile next = profile.copy(); + if (preset != ThemePresets.Preset.DEFAULT) next.name = getString(preset.label); + next.mode = editor.draft().mode; + editor.replace(next); + ThemeDialog.this.render(); + }); + name = ThemeEditorUi.text(requireContext(), getString(preset.label), 14, light.colorOnSurface()); + ThemeEditorUi.heading(name); + name.setSingleLine(true); + view.addView(name); + detail = ThemeEditorUi.text(requireContext(), "", 10, light.colorOnSurfaceVariant()); + detail.setSingleLine(true); + view.addView(detail, ThemeEditorUi.fullWidth(requireContext(), 6)); + LinearLayout row = ThemeEditorUi.row(requireContext()); + for (int i = 0; i < swatches.length; i++) { + swatches[i] = new View(requireContext()); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, dp(18), 1); + if (i < swatches.length - 1) params.setMarginEnd(dp(4)); + row.addView(swatches[i], params); + } + view.addView(row, ThemeEditorUi.fullWidth(requireContext(), 10)); + } + + void render(ThemeProfile draft) { + ThemeTokens tokens = dark ? night : light; + boolean active = ThemePresets.find(draft) == preset; + boolean modified = false; + if (active) { + ThemeProfile comparable = draft.copy(); + comparable.name = profile.name; + comparable.mode = profile.mode; + modified = !ThemeProfileCodec.encode(comparable).equals(ThemeProfileCodec.encode(profile)); + } + name.setText((active ? "✓ " : "") + getString(preset.label)); + name.setTextColor(tokens.colorOnSurface()); + detail.setText(modified ? R.string.theme_editor_preset_modified + : preset == ThemePresets.Preset.DEFAULT ? R.string.theme_editor_preset_original + : preset == ThemePresets.Preset.WALLPAPER ? R.string.theme_editor_preset_dynamic : R.string.theme_editor_preset_pair); + detail.setTextColor(tokens.colorOnSurfaceVariant()); + view.setSelected(active); + view.setContentDescription(getString(preset.label) + (active ? " · " + getString(R.string.theme_editor_selected) : "")); + view.setBackground(ThemeEditorUi.interactive(requireContext(), tokens.colorSurface(), tokens.colorOutline(), + previewTokens.colorFocus(), active)); + int[] colors = {tokens.colorPrimary(), tokens.colorSecondaryContainer(), tokens.colorSurfaceContainerHigh(), tokens.colorSuccess()}; + for (int i = 0; i < swatches.length; i++) swatches[i].setBackground(ThemeEditorUi.shape(requireContext(), colors[i], 0, 0, 5)); + } } @Override - protected ViewBinding getBinding() { - return binding = DialogThemeBinding.inflate(getLayoutInflater()); + public void onStart() { + super.onStart(); + Dialog dialog = getDialog(); + if (dialog == null) return; + configureWindow(dialog); + render(); + // The window can be laid out before the platform reports its system-bar insets, in + // which case the first sizing falls back to the display bounds and the footer row + // ends up under the navigation bar. Re-sizing when the real insets arrive is what + // makes the fix independent of that ordering. + Window window = dialog.getWindow(); + View decorView = window == null ? null : window.getDecorView(); + if (decorView != null) { + ViewCompat.setOnApplyWindowInsetsListener(decorView, (view, insets) -> { + androidx.core.graphics.Insets bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); + ThemeDialogLayout.Insets next = new ThemeDialogLayout.Insets( + bars.left, bars.top, bars.right, bars.bottom); + if (!next.equals(windowInsets)) { + windowInsets = next; + Dialog current = getDialog(); + if (current != null && current.getWindow() != null) configureWindow(current); + } + return insets; + }); + ViewCompat.requestApplyInsets(decorView); + } + if (savedScroll > 0) scroll.post(() -> scroll.scrollTo(0, savedScroll)); + } + + private void configureWindow(Dialog dialog) { + Window window = dialog.getWindow(); + if (window == null) return; + int margin = ResUtil.dp2px(ThemeDialogLayout.marginDp(Util.isLeanback())); + View decorView = window.getDecorView(); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); + ThemeDialogLayout.Area area = ThemeDialogLayout.safeArea(screenWidth, screenHeight, + systemBarInsets(screenWidth, screenHeight)); + int width = ThemeDialogLayout.width(area.width(), margin); + int height = ThemeDialogLayout.height(area.height(), margin); + WindowManager.LayoutParams params = window.getAttributes(); + params.width = width; + params.height = height; + params.gravity = Gravity.CENTER; + Drawable panel = ThemeDialogLayout.panelBackground(decorView.getBackground()); + if (panel != null) window.setBackgroundDrawable(panel); + decorView.setPadding(0, 0, 0, 0); + window.setAttributes(params); + window.setLayout(width, height); + if (root != null && root.getLayoutParams() != null) { + ViewGroup.LayoutParams rootParams = root.getLayoutParams(); + rootParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + rootParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + root.setLayoutParams(rootParams); + } + } + + /** + * The system bars the editor window has to stay clear of. + * + *

Insets reported by the dialog window are exact and preferred. Until they arrive, the + * space the host already gave up to the bars (the display minus its content view) is + * used; that value also covers the host's own chrome, so it can only over-reserve, never + * clip. A host content view that has not been laid out yet reports nothing, and the + * display is used until the listener re-sizes the window. + */ + private ThemeDialogLayout.Insets systemBarInsets(int screenWidth, int screenHeight) { + // 1) The dialog window's own report, which is the exact value whenever the platform + // actually fills it in. + if (windowInsets != null && !windowInsets.isEmpty()) return windowInsets; + // 2) The host activity's root window insets. This is the same source Util.isFullscreen + // already trusts, and it stays correct while a floating dialog window keeps reporting + // systemBars() = [0,0,0,0] on API 28. + ThemeDialogLayout.Insets host = hostWindowInsets(); + if (!host.isEmpty()) return host; + // 3) The space the host content view has already given up to the bars. + ThemeDialogLayout.Insets fallback = + ThemeDialogLayout.Insets.ofContent(screenWidth, screenHeight, contentWidth(), contentHeight()); + return fallback.isEmpty() ? ThemeDialogLayout.Insets.none() : fallback; + } + + /** + * The system bars the host activity has to keep clear. + * + *

Used because a floating dialog window is not guaranteed to report its own insets: on + * API 28 the editor's window reports {@code systemBars() = [0,0,0,0]} while the navigation bar + * is on screen, and accepting that as "no bars" is what pushed the footer row under it.

+ */ + private ThemeDialogLayout.Insets hostWindowInsets() { + try { + WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(requireActivity().getWindow().getDecorView()); + if (insets == null) return ThemeDialogLayout.Insets.none(); + androidx.core.graphics.Insets bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); + return new ThemeDialogLayout.Insets(bars.left, bars.top, bars.right, bars.bottom); + } catch (RuntimeException error) { + return ThemeDialogLayout.Insets.none(); + } + } + + /** + * The host activity's content view size. + * + *

Its difference from the display is the system-bar space the host gave up, which is + * the safe-area fallback while the dialog window's own insets are still unknown. + */ + private int contentWidth() { + View content = requireActivity().findViewById(android.R.id.content); + return content == null ? 0 : content.getWidth(); + } + + private int contentHeight() { + View content = requireActivity().findViewById(android.R.id.content); + return content == null ? 0 : content.getHeight(); } @Override - protected MaterialAlertDialogBuilder getBuilder() { - return builder().setTitle(R.string.setting_theme_color).setView(getBinding().getRoot()); + public void onSaveInstanceState(@NonNull Bundle state) { + super.onSaveInstanceState(state); + if (editor != null) state.putString("preview_draft", ThemeProfileCodec.encode(editor.draft())); + state.putBoolean("preview_dark", dark); + state.putBoolean("mode_changed", modeChanged); + state.putInt("preview_scroll", scroll == null ? savedScroll : scroll.getScrollY()); } @Override - protected void initView() { - binding.recycler.setAdapter(new ThemeAdapter(this, COLORS, Setting.getThemeColor())); + public void onDestroyView() { + super.onDestroyView(); + cards.clear(); + styles.clear(); + root = null; + panel = null; + scroll = null; + // The next window reports its own insets; a stale value from this one must not be reused. + windowInsets = null; } @Override - public void onItemClick(int color) { - ((Listener) requireParentFragment()).setTheme(color); - dismiss(); + public void onColorSlotClicked(ThemeEditor.Slot slot, boolean slotDark) { + String initial = ThemeEditorUi.hex(ThemePreviewView.colorOf(previewTokens, slot)); + ThemeColorPickerDialog.create(requireContext(), getString(ThemePreviewView.labelOf(slot)), initial, previewTokens, hex -> { + ThemeEditor.Result result = editor.set(slot, slotDark, hex, 0f); + if (result.success()) render(); + else setStatus(result.error()); + }).show(); } - public interface Listener { + @Override + public void onDraftChanged() { + render(); + } - void setTheme(int color); + private void selectMode(boolean dark) { + this.dark = dark; + modeChanged = true; + editor.setMode(dark ? ThemeProfile.MODE_DARK : ThemeProfile.MODE_LIGHT); + render(); + } + + private void applyDraft() { + ThemeProfileStore.ApplyResult result = editor.apply(); + if (!result.success()) { + setStatus(getString(R.string.theme_editor_save_failed, result.error())); + return; + } + if (modeChanged) { + Setting.putThemeMode(dark ? 1 : 0); + ThemeController.applyNightModeToApp(); + } + dismissAllowingStateLoss(); + // AppearanceDialog already publishes the refresh event. Do not post a second recreation. + if (getParentFragment() instanceof AppearanceDialog appearance) appearance.onThemeProfileApplied(); + else RefreshEvent.theme(); + } + + private TextView heading(int label) { + TextView heading = ThemeEditorUi.text(requireContext(), getString(label), 13, previewTokens.colorOnSurfaceVariant()); + ThemeEditorUi.heading(heading); + return heading; + } + + private void setStatus(String message) { + status.setTextColor(previewTokens.colorOnSurfaceVariant()); + status.setText(message == null ? "" : message); + } + + private int paletteLabel(ThemePaletteStyle style) { + return switch (style) { + case TONAL_SPOT -> R.string.theme_editor_palette_tonal; + case VIBRANT -> R.string.theme_editor_palette_vibrant; + case EXPRESSIVE -> R.string.theme_editor_palette_expressive; + case RAINBOW -> R.string.theme_editor_palette_rainbow; + case FRUIT_SALAD -> R.string.theme_editor_palette_fruit_salad; + case FIDELITY -> R.string.theme_editor_palette_fidelity; + case CONTENT -> R.string.theme_editor_palette_content; + case NEUTRAL -> R.string.theme_editor_palette_neutral; + case MONOCHROME -> R.string.theme_editor_palette_monochrome; + }; + } + + private int dp(int value) { + return ThemeEditorUi.dp(requireContext(), value); } } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java index 4ee19b7cf8..3b37c959fe 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java @@ -16,6 +16,10 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Update; import com.fongmi.android.tv.databinding.DialogUpdateBinding; import com.fongmi.android.tv.impl.UpdateListener; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeTokens; +import com.fongmi.android.tv.theme.UpdateVersionRowTheme; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.AppVersion; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.MarkdownText; @@ -75,7 +79,7 @@ public class UpdateDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog_NoInset).setView(getBinding().getRoot()).setCancelable(false); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog_NoInset).setView(getBinding().getRoot()).setCancelable(false); } @Override @@ -166,6 +170,7 @@ public class UpdateDialog extends BaseAlertDialog { View content = stableChannel ? binding.stableContent : binding.betaContent; item.setSelected(expanded); content.setVisibility(expanded ? View.VISIBLE : View.GONE); + applyCardTheme(channel, expanded); if (stableChannel) { binding.stableVersion.setText(getVersion(update)); binding.stableStatus.setText(getStatus(update)); @@ -187,6 +192,20 @@ public class UpdateDialog extends BaseAlertDialog { } } + /** + * Paints one version card from the active palette; see + * {@link UpdateVersionRowTheme} for why the card cannot rely on the static selector. + */ + private void applyCardTheme(String channel, boolean expanded) { + ThemeTokens tokens = ThemeController.current(); + boolean stableChannel = Update.CHANNEL_STABLE.equals(channel); + if (stableChannel) { + UpdateVersionRowTheme.apply(binding.stableItem, binding.stableName, binding.stableVersion, binding.stableStatus, binding.stableExpand, binding.stableDesc, expanded, tokens); + } else { + UpdateVersionRowTheme.apply(binding.betaItem, binding.betaName, binding.betaVersion, binding.betaStatus, binding.betaExpand, binding.betaDesc, expanded, tokens); + } + } + private void renderAction() { Update update = getSelected(); if (update == null || !update.hasUpdate()) binding.cancel.setText(R.string.about_acknowledge); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java index c30d9511c3..7015c07178 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/dialog/WebDialog.java @@ -7,15 +7,15 @@ import android.view.WindowManager; import androidx.appcompat.app.AlertDialog; import com.fongmi.android.tv.App; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class WebDialog { private final AlertDialog dialog; private WebDialog(View view) { - this.dialog = new MaterialAlertDialogBuilder(App.activity()).setView(view).create(); + this.dialog = new WebHtvAlertDialogBuilder(App.activity()).setView(view).create(); this.dialog.setOnDismissListener((DialogInterface.OnDismissListener) view); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java b/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java index 4cfd9f7409..95e0ed8760 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/fragment/CollectFragment.java @@ -79,7 +79,7 @@ public class CollectFragment extends BaseFragment implements CustomScroller.Call private void setRecyclerView() { setGridSpec(); CustomSelector selector = new CustomSelector(); - selector.addPresenter(ListRow.class, new CustomRowPresenter(16, FocusHighlight.ZOOM_FACTOR_NONE, HorizontalGridView.FOCUS_SCROLL_ALIGNED), VodPresenter.class); + selector.addPresenter(ListRow.class, new CustomRowPresenter(ResUtil.dpOf(R.dimen.webhtv_size_tv_card_gap), FocusHighlight.ZOOM_FACTOR_NONE, HorizontalGridView.FOCUS_SCROLL_ALIGNED), VodPresenter.class); mBinding.recycler.setAdapter(new ItemBridgeAdapter(mAdapter = new ArrayObjectAdapter(selector))); mBinding.recycler.addOnScrollListener(mScroller = new CustomScroller(this)); mBinding.recycler.setHeader(getActivity(), R.id.result, R.id.recycler); 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..5f34a7ab85 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 @@ -201,6 +201,11 @@ public class FolderFragment extends BaseFragment { Optional.ofNullable(getChild()).ifPresent(TypeFragment::onRefresh); } + public boolean resetFilter() { + TypeFragment child = getChild(); + return child != null && child.resetFilter(); + } + public boolean canBack() { return getChildFragmentManager().getBackStackEntryCount() > 0; } 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..8543e783f3 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 @@ -156,7 +156,7 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac private void setRecyclerView() { CustomSelector selector = new CustomSelector(); selector.addPresenter(Vod.class, new VodPresenter(this, Style.list())); - selector.addPresenter(ListRow.class, new CustomRowPresenter(16, this::onContentHorizontalEdge), VodPresenter.class); + selector.addPresenter(ListRow.class, new CustomRowPresenter(ResUtil.dpOf(R.dimen.webhtv_size_tv_card_gap), this::onContentHorizontalEdge), VodPresenter.class); selector.addPresenter(ListRow.class, new CustomRowPresenter(8, FocusHighlight.ZOOM_FACTOR_NONE, HorizontalGridView.FOCUS_SCROLL_ALIGNED, true), FilterPresenter.class); mBinding.recycler.setAdapter(new ItemBridgeAdapter(mAdapter = new ArrayObjectAdapter(selector))); mBinding.recycler.setHeader(getActivity(), getParent().getScrollHeaderIds()); @@ -278,6 +278,29 @@ public class TypeFragment extends BaseFragment implements CustomScroller.Callbac else mBinding.swipeLayout.setRefreshing(true); } + public boolean resetFilter() { + if (mFilters.isEmpty()) return false; + boolean changed = !mExtends.isEmpty(); + for (Filter filter : mFilters) { + for (Value value : filter.getValue()) { + if (value.isSelected()) changed = true; + value.setSelected(false); + } + } + mExtends.clear(); + if (changed) { + // Rebuilding the rows is what makes the chips render as unselected; skip it when + // nothing was selected so a no-op long press does not scroll the panel back to row 0. + if (filterVisible) { + hideFilter(); + showFilter(); + } + onRefresh(); + Notify.show(R.string.filter_reset); + } + return changed; + } + public void onRefresh() { getVideo(); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java b/app/src/leanback/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java new file mode 100644 index 0000000000..9e64b6ac90 --- /dev/null +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/helper/SiteDialogTheme.java @@ -0,0 +1,137 @@ +package com.fongmi.android.tv.ui.helper; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.StateListDrawable; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeTokens; +import com.google.android.material.checkbox.MaterialCheckBox; + +/** Applies the active semantic palette to the TV site selector's programmatic states. */ +public final class SiteDialogTheme { + + private SiteDialogTheme() { + } + + public static void applyShell(View root, TextView keyword, ImageView... actions) { + ThemeTokens tokens = ThemeController.current(); + if (root != null) root.setBackground(shell(root.getContext(), tokens)); + if (keyword != null) { + keyword.setTextColor(tokens.colorOnSurface()); + keyword.setHintTextColor(tokens.colorOnSurfaceVariant()); + keyword.setBackground(input(keyword.getContext(), tokens)); + } + if (actions != null) for (ImageView action : actions) applyAction(action); + } + + public static void applySiteItem(View root, TextView text, MaterialCheckBox check) { + ThemeTokens tokens = ThemeController.current(); + if (root != null) root.setBackground(siteItem(root.getContext(), tokens)); + if (text != null) text.setTextColor(textColors(tokens)); + if (check != null) check.setButtonTintList(checkColors(tokens)); + } + + public static void updateSiteText(TextView text, boolean focused, boolean selected) { + if (text == null) return; + ThemeTokens tokens = ThemeController.current(); + text.setTextColor(focused ? tokens.colorOnPrimary() : selected ? tokens.colorPrimary() : tokens.colorOnSurface()); + } + + public static void applyGroup(TextView button) { + if (button == null) return; + ThemeTokens tokens = ThemeController.current(); + button.setTextColor(textColors(tokens)); + button.setBackground(groupButton(button.getContext(), tokens)); + } + + public static void applyAction(ImageView action) { + if (action == null) return; + ThemeTokens tokens = ThemeController.current(); + action.setImageTintList(actionColors(tokens)); + action.setBackground(actionBackground(action.getContext(), tokens)); + } + + private static GradientDrawable shell(Context context, ThemeTokens tokens) { + // 面板是浮在暗化背景上的独立层,需要一条可见的轮廓把它和背景分开; + // 全屏时代面板边缘就是屏幕边缘,0 描边看不出来,收窄成浮层后必须补回来。 + return rounded(context, tokens.colorSurfaceContainer(), tokens.colorOutlineVariant(), 22, 1); + } + + private static StateListDrawable input(Context context, ThemeTokens tokens) { + StateListDrawable result = new StateListDrawable(); + result.addState(new int[]{android.R.attr.state_focused}, rounded(context, tokens.colorPrimaryContainer(), tokens.colorPrimary(), 12, 1)); + result.addState(new int[]{}, rounded(context, tokens.colorSurfaceContainerHigh(), tokens.colorOutline(), 12, 1)); + return result; + } + + private static StateListDrawable siteItem(Context context, ThemeTokens tokens) { + StateListDrawable result = new StateListDrawable(); + result.addState(new int[]{android.R.attr.state_focused}, rounded(context, tokens.colorPrimary(), tokens.colorOnPrimary(), 12, 2)); + result.addState(new int[]{android.R.attr.state_selected}, rounded(context, tokens.colorPrimaryContainer(), tokens.colorPrimary(), 12, 2)); + // 常态填充必须是比面板高一层的容器色:面板用 colorSurfaceContainer, + // 卡片若也用同一色,卡片边界在面板上完全消失(只剩文字)。 + result.addState(new int[]{}, rounded(context, tokens.colorSurfaceContainerHigh(), tokens.colorOutlineVariant(), 12, 1)); + return result; + } + + private static StateListDrawable groupButton(Context context, ThemeTokens tokens) { + StateListDrawable result = new StateListDrawable(); + result.addState(new int[]{android.R.attr.state_focused}, rounded(context, tokens.colorPrimary(), tokens.colorOnPrimary(), 20, 2)); + result.addState(new int[]{android.R.attr.state_selected}, rounded(context, tokens.colorPrimaryContainer(), tokens.colorPrimary(), 20, 2)); + result.addState(new int[]{}, rounded(context, tokens.colorSurface(), tokens.colorOutline(), 20, 1)); + return result; + } + + private static StateListDrawable actionBackground(Context context, ThemeTokens tokens) { + StateListDrawable result = new StateListDrawable(); + result.addState(new int[]{android.R.attr.state_focused}, rounded(context, tokens.colorPrimaryContainer(), tokens.colorPrimary(), 12, 2)); + result.addState(new int[]{android.R.attr.state_selected}, rounded(context, tokens.colorPrimaryContainer(), tokens.colorPrimary(), 12, 2)); + result.addState(new int[]{}, rounded(context, tokens.colorSurfaceContainer(), tokens.colorOutline(), 12, 1)); + return result; + } + + private static ColorStateList textColors(ThemeTokens tokens) { + return new ColorStateList( + new int[][]{ + {android.R.attr.state_focused}, + {android.R.attr.state_selected}, + {}, + }, + new int[]{tokens.colorOnPrimary(), tokens.colorPrimary(), tokens.colorOnSurface()}); + } + + private static ColorStateList checkColors(ThemeTokens tokens) { + return new ColorStateList( + new int[][]{ + {android.R.attr.state_focused}, + {android.R.attr.state_checked}, + {}, + }, + new int[]{tokens.colorOnPrimary(), tokens.colorPrimary(), tokens.colorOnSurfaceVariant()}); + } + + private static ColorStateList actionColors(ThemeTokens tokens) { + return new ColorStateList( + new int[][]{ + {android.R.attr.state_focused}, + {android.R.attr.state_selected}, + {}, + }, + new int[]{tokens.colorPrimary(), tokens.colorPrimary(), tokens.colorOnSurfaceVariant()}); + } + + private static GradientDrawable rounded(Context context, int fill, int stroke, int radiusDp, int strokeDp) { + float density = context.getResources().getDisplayMetrics().density; + GradientDrawable drawable = new GradientDrawable(); + drawable.setShape(GradientDrawable.RECTANGLE); + drawable.setColor(fill); + drawable.setCornerRadius(radiusDp * density); + if (strokeDp > 0) drawable.setStroke(Math.max(1, Math.round(strokeDp * density)), stroke); + return drawable; + } +} diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbCastPresenter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbCastPresenter.java index 203400191c..e4228db753 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbCastPresenter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbCastPresenter.java @@ -11,15 +11,17 @@ import androidx.leanback.widget.Presenter; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.TmdbPerson; import com.fongmi.android.tv.databinding.AdapterTmdbCastBinding; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.utils.ImgUtil; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.card.MaterialCardView; public class TmdbCastPresenter extends Presenter { - private static final int CARD_BACKGROUND = 0xFF16202A; - private static final int STROKE_NORMAL = 0x26FFFFFF; - private static final int STROKE_FOCUSED = 0xFFFFFFFF; + // 统一焦点规范(与 selector_video_item.xml / selector_tmdb_cast_focus.xml 一致): + // 常态 1dp @color/tv_item_normal_stroke,焦点环 3dp @color/tv_item_focus_ring 由 foreground 绘制。 + private static final int STROKE_NORMAL = 0x33FFFFFF; + private static final int STROKE_WIDTH_NORMAL_DP = 1; private final OnClickListener mListener; @@ -63,10 +65,14 @@ public class TmdbCastPresenter extends Presenter { } private void applyFocusStyle(MaterialCardView card, boolean focused) { + var tokens = ThemeController.current(); card.setActivated(focused); - card.setCardBackgroundColor(CARD_BACKGROUND); - card.setStrokeColor(focused ? STROKE_FOCUSED : STROKE_NORMAL); - card.setStrokeWidth(ResUtil.dp2px(focused ? 3 : 1)); + // 卡面走统一主题 token,自定义主题可覆写; + // 焦点环已由 foreground 的 selector 统一绘制,这里只维护常态描边, + // 避免"焦点用 3dp 卡片描边 + foreground 再画一圈"的双重描边。 + card.setCardBackgroundColor(tokens.colorSurfaceContainerHigh()); + card.setStrokeColor(STROKE_NORMAL); + card.setStrokeWidth(ResUtil.dp2px(STROKE_WIDTH_NORMAL_DP)); card.setCardElevation(0); card.setTranslationZ(0); } diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbPhotoPresenter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbPhotoPresenter.java index f382c798dc..5b4c15677f 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbPhotoPresenter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbPhotoPresenter.java @@ -1,5 +1,7 @@ package com.fongmi.android.tv.ui.presenter; +import android.content.res.ColorStateList; +import android.os.Build; import android.view.LayoutInflater; import android.view.ViewGroup; @@ -10,6 +12,7 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterTmdbPhotoBinding; import com.fongmi.android.tv.utils.ImgUtil; import com.fongmi.android.tv.utils.ResUtil; +import com.google.android.material.card.MaterialCardView; public class TmdbPhotoPresenter extends Presenter { @@ -38,6 +41,7 @@ public class TmdbPhotoPresenter extends Presenter { params.height = ResUtil.dp2px(222); binding.getRoot().setLayoutParams(params); } + bindFocusStyle(binding.getRoot()); return new ViewHolder(binding); } @@ -56,7 +60,26 @@ public class TmdbPhotoPresenter extends Presenter { public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) { } - public static class ViewHolder extends Presenter.ViewHolder { + /** + * 剧照/海报卡片此前完全没有焦点外观:布局里关闭了系统默认焦点高亮,presenter 也不改描边, + * 所以遥控停在卡片上时看不出焦点在哪里。这里用前景 selector 画 3dp 焦点环, + * 与演员卡(selector_tmdb_cast_focus.xml)同一套做法:前景绘制在图片之上, + * 不需要在每次绑定时改动卡片描边。 + */ + private static void bindFocusStyle(MaterialCardView card) { + card.setRippleColor(ColorStateList.valueOf(0x00000000)); + card.setForeground(card.getContext().getDrawable(R.drawable.selector_tmdb_media_focus)); + card.setStateListAnimator(null); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) card.setDefaultFocusHighlightEnabled(false); + applyFocusStyle(card, card.hasFocus()); + card.setOnFocusChangeListener((view, focused) -> applyFocusStyle(card, focused)); + } + + private static void applyFocusStyle(MaterialCardView card, boolean focused) { + card.setActivated(focused); + } + + static class ViewHolder extends Presenter.ViewHolder { private final AdapterTmdbPhotoBinding binding; diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbRecommendationPresenter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbRecommendationPresenter.java index 53155d1620..6c62453ce2 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbRecommendationPresenter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbRecommendationPresenter.java @@ -7,14 +7,25 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.leanback.widget.Presenter; +import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.databinding.AdapterTmdbRecommendationBinding; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.ui.helper.TmdbRatingFormatter; import com.fongmi.android.tv.utils.ImgUtil; +import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.TmdbImageSelector; +import com.google.android.material.card.MaterialCardView; public class TmdbRecommendationPresenter extends Presenter { + // 焦点环宽度与环色都不得在本类里写死: + // 宽度 → @dimen/webhtv_focus_ring_width(全 TV 唯一宽度来源); + // 环色 → ThemeController.focusRingColor()(= ?attr/tvFocusRing 的代码路径同一来源)。 + // 此前这里写死 FOCUS_WIDTH_DP = 3 与 R.color.tv_item_focus_ring,于是前几轮统一 + // drawable 宽度时漏掉了本卡(用户报告「个性推荐等卡片的边框粗细没有改小没有统一」)。 + private static final int NORMAL_WIDTH_DP = 1; + private final OnClickListener mListener; private final OnLongClickListener mLongClickListener; private final OnFocusListener mFocusListener; @@ -68,8 +79,10 @@ public class TmdbRecommendationPresenter extends Presenter { }); holder.view.setOnLongClickListener(view -> mLongClickListener != null && mLongClickListener.onItemLongClick(tmdbItem)); holder.view.setOnFocusChangeListener((view, focused) -> { + applyUnifiedFocus((MaterialCardView) view, focused); if (mFocusListener != null) mFocusListener.onItemFocus(tmdbItem, focused); }); + applyUnifiedFocus((MaterialCardView) holder.view, holder.view.hasFocus()); if (holder.view.hasFocus() && mFocusListener != null) mFocusListener.onItemFocus(tmdbItem, true); } @@ -84,6 +97,21 @@ public class TmdbRecommendationPresenter extends Presenter { viewHolder.view.setOnFocusChangeListener(null); } + /** + * 统一焦点外观:焦点用 @dimen/webhtv_focus_ring_width 的主题环,失焦恢复常态描边。 + * 只作用于 leanback 播放页的推荐卡,不改变共享布局和独立详情页的焦点契约。 + */ + private static void applyUnifiedFocus(MaterialCardView card, boolean focused) { + if (focused) { + card.setStrokeWidth(card.getContext().getResources() + .getDimensionPixelSize(R.dimen.webhtv_focus_ring_width)); + card.setStrokeColor(ThemeController.focusRingColor(card.getContext())); + } else { + card.setStrokeWidth(ResUtil.dp2px(NORMAL_WIDTH_DP)); + card.setStrokeColor(card.getContext().getColor(R.color.tv_item_normal_stroke)); + } + } + public static class ViewHolder extends Presenter.ViewHolder { private final AdapterTmdbRecommendationBinding binding; diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbVideoPresenter.java b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbVideoPresenter.java index df8be47fc6..b5d8f37213 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbVideoPresenter.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbVideoPresenter.java @@ -1,15 +1,20 @@ package com.fongmi.android.tv.ui.presenter; +import android.content.res.ColorStateList; +import android.os.Build; import android.view.LayoutInflater; +import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.leanback.widget.Presenter; +import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.TmdbVideo; import com.fongmi.android.tv.databinding.AdapterTmdbVideoBinding; +import com.fongmi.android.tv.ui.helper.TvFocusScale; import com.fongmi.android.tv.utils.ImgUtil; -import com.fongmi.android.tv.utils.ResUtil; +import com.google.android.material.card.MaterialCardView; public class TmdbVideoPresenter extends Presenter { @@ -36,20 +41,43 @@ public class TmdbVideoPresenter extends Presenter { holder.binding.title.setText(name); holder.binding.subtitle.setText(video.getDisplayType() + " ? " + video.getScopeLabel()); ImgUtil.load(name, video.getThumbnailUrl(), holder.binding.poster, true, 300, 169); + bindFocusStyle(holder); setOnClickListener(holder, view -> { if (listener != null) listener.onItemClick(video); }); - holder.view.setOnFocusChangeListener((view, focused) -> { - float scale = focused ? 1.04f : 1.0f; - view.animate().scaleX(scale).scaleY(scale).setDuration(120).start(); - holder.binding.getRoot().setStrokeWidth(ResUtil.dp2px(focused ? 2 : 1)); - holder.binding.getRoot().setStrokeColor(focused ? 0xFFFFD166 : 0x33FFFFFF); - }); } @Override public void onUnbindViewHolder(Presenter.ViewHolder viewHolder) { - viewHolder.view.setOnFocusChangeListener(null); + View root = viewHolder.view; + TvFocusScale.reset(root); + root.setOnFocusChangeListener(null); + root.setForeground(null); + root.setActivated(false); + } + + /** + * 相关视频卡片的封面 ImageView 是 match_parent 全出血的,而 MaterialCardView 的描边画在 + * 背景层,会被封面完全盖住——这正是“剧照修好之后,相关视频仍然看不出焦点在哪”的原因。 + * 与剧照/海报、演员卡一致,改用前景 selector 把 3dp 焦点环画在封面之上。 + */ + private void bindFocusStyle(ViewHolder holder) { + MaterialCardView card = holder.binding.getRoot(); + card.setRippleColor(ColorStateList.valueOf(0x00000000)); + card.setForeground(card.getContext().getDrawable(R.drawable.selector_tmdb_media_focus)); + card.setStateListAnimator(null); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) card.setDefaultFocusHighlightEnabled(false); + applyFocusChrome(holder, card.hasFocus()); + card.setOnFocusChangeListener((view, focused) -> applyFocusChrome(holder, focused)); + } + + /** + * RecyclerView 复用时卡片可能已经持有焦点,只挂监听不会触发回调, + * 必须按当前真实焦点状态补一次,否则“停下”的那张卡看不到焦点环。 + */ + private void applyFocusChrome(ViewHolder holder, boolean focused) { + holder.binding.getRoot().setActivated(focused); + TvFocusScale.applyCompact(holder.view, focused); } static final class ViewHolder extends Presenter.ViewHolder { diff --git a/app/src/leanback/res/color/bg_remark.xml b/app/src/leanback/res/color/bg_remark.xml index 0f9d0b475d..dfe7254c8f 100644 --- a/app/src/leanback/res/color/bg_remark.xml +++ b/app/src/leanback/res/color/bg_remark.xml @@ -1,4 +1,12 @@ + - + diff --git a/app/src/leanback/res/color/bg_site.xml b/app/src/leanback/res/color/bg_site.xml index d351f36970..0310bcfef7 100644 --- a/app/src/leanback/res/color/bg_site.xml +++ b/app/src/leanback/res/color/bg_site.xml @@ -1,4 +1,8 @@ + - + diff --git a/app/src/leanback/res/color/bg_year.xml b/app/src/leanback/res/color/bg_year.xml index 3951b5b321..683a2784cd 100644 --- a/app/src/leanback/res/color/bg_year.xml +++ b/app/src/leanback/res/color/bg_year.xml @@ -1,4 +1,8 @@ + - + diff --git a/app/src/leanback/res/color/config_history_icon.xml b/app/src/leanback/res/color/config_history_icon.xml index 535afaebaf..a315f16570 100644 --- a/app/src/leanback/res/color/config_history_icon.xml +++ b/app/src/leanback/res/color/config_history_icon.xml @@ -1,6 +1,6 @@ - - - + + + diff --git a/app/src/leanback/res/color/config_history_text.xml b/app/src/leanback/res/color/config_history_text.xml index cc1716050b..cde6c4bb42 100644 --- a/app/src/leanback/res/color/config_history_text.xml +++ b/app/src/leanback/res/color/config_history_text.xml @@ -1,6 +1,6 @@ - - - + + + diff --git a/app/src/leanback/res/color/home_menu_text.xml b/app/src/leanback/res/color/home_menu_text.xml new file mode 100644 index 0000000000..830b9c44d7 --- /dev/null +++ b/app/src/leanback/res/color/home_menu_text.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/leanback/res/color/selector_group_text.xml b/app/src/leanback/res/color/selector_group_text.xml index 82475bcbc6..cf5695c19c 100644 --- a/app/src/leanback/res/color/selector_group_text.xml +++ b/app/src/leanback/res/color/selector_group_text.xml @@ -1,6 +1,6 @@ - - - + + + diff --git a/app/src/leanback/res/color/site_action_icon.xml b/app/src/leanback/res/color/site_action_icon.xml index ad0b6d872e..f5f5c0159a 100644 --- a/app/src/leanback/res/color/site_action_icon.xml +++ b/app/src/leanback/res/color/site_action_icon.xml @@ -1,7 +1,7 @@ - - - - + + + + diff --git a/app/src/leanback/res/color/site_item_check.xml b/app/src/leanback/res/color/site_item_check.xml new file mode 100644 index 0000000000..66cfb24692 --- /dev/null +++ b/app/src/leanback/res/color/site_item_check.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/leanback/res/color/site_item_text.xml b/app/src/leanback/res/color/site_item_text.xml new file mode 100644 index 0000000000..cbfa0da5af --- /dev/null +++ b/app/src/leanback/res/color/site_item_text.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/leanback/res/color/text.xml b/app/src/leanback/res/color/text.xml index ba92946818..99e71a354c 100644 --- a/app/src/leanback/res/color/text.xml +++ b/app/src/leanback/res/color/text.xml @@ -1,8 +1,8 @@ - - - - - - \ No newline at end of file + + + + + + diff --git a/app/src/leanback/res/drawable/ic_banner.png b/app/src/leanback/res/drawable/ic_banner.png deleted file mode 100644 index 29924fe231..0000000000 Binary files a/app/src/leanback/res/drawable/ic_banner.png and /dev/null differ diff --git a/app/src/leanback/res/drawable/ic_banner_foreground.xml b/app/src/leanback/res/drawable/ic_banner_foreground.xml deleted file mode 100644 index c2b3440adc..0000000000 --- a/app/src/leanback/res/drawable/ic_banner_foreground.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/app/src/leanback/res/drawable/selector_ad_stats_item.xml b/app/src/leanback/res/drawable/selector_ad_stats_item.xml index 4be2706b17..cfbaa6f433 100644 --- a/app/src/leanback/res/drawable/selector_ad_stats_item.xml +++ b/app/src/leanback/res/drawable/selector_ad_stats_item.xml @@ -1,10 +1,23 @@ + - + 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..ca2247fc66 100644 --- a/app/src/leanback/res/drawable/selector_control_sheet_button.xml +++ b/app/src/leanback/res/drawable/selector_control_sheet_button.xml @@ -1,4 +1,20 @@ + @@ -8,8 +24,8 @@ + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvPlayerCurrentRing" /> @@ -17,8 +33,8 @@ + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvPlayerRing" /> @@ -26,8 +42,22 @@ + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvPlayerCurrentRing" /> + + + + + + + + @@ -36,7 +66,7 @@ + android:color="?attr/tvNormalStroke" /> diff --git a/app/src/leanback/res/drawable/selector_danmaku_result_item.xml b/app/src/leanback/res/drawable/selector_danmaku_result_item.xml index 4af3436a8a..ef820fa30f 100644 --- a/app/src/leanback/res/drawable/selector_danmaku_result_item.xml +++ b/app/src/leanback/res/drawable/selector_danmaku_result_item.xml @@ -1,11 +1,19 @@ + diff --git a/app/src/leanback/res/drawable/selector_danmaku_search_action.xml b/app/src/leanback/res/drawable/selector_danmaku_search_action.xml index 6c14285fe6..2a52ecc71a 100644 --- a/app/src/leanback/res/drawable/selector_danmaku_search_action.xml +++ b/app/src/leanback/res/drawable/selector_danmaku_search_action.xml @@ -1,11 +1,17 @@ + diff --git a/app/src/leanback/res/drawable/selector_empty_action.xml b/app/src/leanback/res/drawable/selector_empty_action.xml new file mode 100644 index 0000000000..72aebf81aa --- /dev/null +++ b/app/src/leanback/res/drawable/selector_empty_action.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/leanback/res/drawable/selector_episode_dialog_item.xml b/app/src/leanback/res/drawable/selector_episode_dialog_item.xml index 1fc40bee2c..c75a4e4c09 100644 --- a/app/src/leanback/res/drawable/selector_episode_dialog_item.xml +++ b/app/src/leanback/res/drawable/selector_episode_dialog_item.xml @@ -1,20 +1,44 @@ + + + + + + + + - - - - - - - + android:color="@color/white" /> diff --git a/app/src/leanback/res/drawable/selector_episode_dialog_page.xml b/app/src/leanback/res/drawable/selector_episode_dialog_page.xml index a935914b3f..ee0894023e 100644 --- a/app/src/leanback/res/drawable/selector_episode_dialog_page.xml +++ b/app/src/leanback/res/drawable/selector_episode_dialog_page.xml @@ -1,11 +1,22 @@ + + android:width="@dimen/webhtv_focus_ring_width" + android:color="@color/white" /> @@ -14,7 +25,7 @@ + android:color="@color/white" /> diff --git a/app/src/leanback/res/drawable/selector_exit_confirm_primary.xml b/app/src/leanback/res/drawable/selector_exit_confirm_primary.xml index 6f33a2cbc4..5d7d8628bf 100644 --- a/app/src/leanback/res/drawable/selector_exit_confirm_primary.xml +++ b/app/src/leanback/res/drawable/selector_exit_confirm_primary.xml @@ -1,4 +1,16 @@ + @@ -6,7 +18,7 @@ @@ -16,7 +28,7 @@ diff --git a/app/src/leanback/res/drawable/selector_exit_confirm_secondary.xml b/app/src/leanback/res/drawable/selector_exit_confirm_secondary.xml index 91a240c1d0..742c9e9cde 100644 --- a/app/src/leanback/res/drawable/selector_exit_confirm_secondary.xml +++ b/app/src/leanback/res/drawable/selector_exit_confirm_secondary.xml @@ -1,4 +1,12 @@ + @@ -6,7 +14,7 @@ @@ -16,7 +24,7 @@ diff --git a/app/src/leanback/res/drawable/selector_search_scope_item.xml b/app/src/leanback/res/drawable/selector_search_scope_item.xml index e20e89ba24..11f4bbcf82 100644 --- a/app/src/leanback/res/drawable/selector_search_scope_item.xml +++ b/app/src/leanback/res/drawable/selector_search_scope_item.xml @@ -1,4 +1,22 @@ + @@ -6,17 +24,27 @@ + + + + + + + + - + diff --git a/app/src/leanback/res/drawable/selector_video_item.xml b/app/src/leanback/res/drawable/selector_video_item.xml index a1aa0eb093..1fb7845b70 100644 --- a/app/src/leanback/res/drawable/selector_video_item.xml +++ b/app/src/leanback/res/drawable/selector_video_item.xml @@ -1,44 +1,66 @@ + - - + + + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvFocusRing" /> - - + + + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvCurrentRing" /> - - - + + - - + + + diff --git a/app/src/leanback/res/drawable/shape_ad_rule_card.xml b/app/src/leanback/res/drawable/shape_ad_rule_card.xml index 5b38019a12..42d03864a8 100644 --- a/app/src/leanback/res/drawable/shape_ad_rule_card.xml +++ b/app/src/leanback/res/drawable/shape_ad_rule_card.xml @@ -1,6 +1,6 @@ - + - + diff --git a/app/src/leanback/res/drawable/shape_chip_activated.xml b/app/src/leanback/res/drawable/shape_chip_activated.xml index 6e442e95fd..03c78b0ef6 100644 --- a/app/src/leanback/res/drawable/shape_chip_activated.xml +++ b/app/src/leanback/res/drawable/shape_chip_activated.xml @@ -10,4 +10,8 @@ android:right="12dp" android:top="8dp" /> + + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_chip_focused.xml b/app/src/leanback/res/drawable/shape_chip_focused.xml index 69e2dc98d0..e66068ebcd 100644 --- a/app/src/leanback/res/drawable/shape_chip_focused.xml +++ b/app/src/leanback/res/drawable/shape_chip_focused.xml @@ -1,4 +1,18 @@ + @@ -11,7 +25,7 @@ android:top="8dp" /> + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvPlayerRing" /> - \ No newline at end of file + diff --git a/app/src/leanback/res/drawable/shape_chip_round_activated.xml b/app/src/leanback/res/drawable/shape_chip_round_activated.xml index 53be586478..4de15c3e0c 100644 --- a/app/src/leanback/res/drawable/shape_chip_round_activated.xml +++ b/app/src/leanback/res/drawable/shape_chip_round_activated.xml @@ -10,4 +10,8 @@ android:right="14dp" android:top="8dp" /> + + \ No newline at end of file diff --git a/app/src/leanback/res/drawable/shape_chip_round_focused.xml b/app/src/leanback/res/drawable/shape_chip_round_focused.xml index bacc422c15..73a5322c07 100644 --- a/app/src/leanback/res/drawable/shape_chip_round_focused.xml +++ b/app/src/leanback/res/drawable/shape_chip_round_focused.xml @@ -1,4 +1,19 @@ + @@ -11,7 +26,7 @@ android:top="8dp" /> + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvPlayerRing" /> - \ No newline at end of file + 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..36e6def6a3 100644 --- a/app/src/leanback/res/drawable/shape_config_history_dialog.xml +++ b/app/src/leanback/res/drawable/shape_config_history_dialog.xml @@ -2,7 +2,7 @@ - + diff --git a/app/src/leanback/res/drawable/shape_config_history_item_focused.xml b/app/src/leanback/res/drawable/shape_config_history_item_focused.xml index cb5bfbdb97..769b1dbf2a 100644 --- a/app/src/leanback/res/drawable/shape_config_history_item_focused.xml +++ b/app/src/leanback/res/drawable/shape_config_history_item_focused.xml @@ -1,8 +1,18 @@ + - + @@ -13,7 +23,7 @@ android:top="8dp" /> + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/colorOnPrimary" /> diff --git a/app/src/leanback/res/drawable/shape_config_history_item_normal.xml b/app/src/leanback/res/drawable/shape_config_history_item_normal.xml index 35dbe26010..5701f053a7 100644 --- a/app/src/leanback/res/drawable/shape_config_history_item_normal.xml +++ b/app/src/leanback/res/drawable/shape_config_history_item_normal.xml @@ -2,7 +2,7 @@ - + @@ -14,6 +14,6 @@ + android:color="?attr/colorOutline" /> diff --git a/app/src/leanback/res/drawable/shape_empty_action_focused.xml b/app/src/leanback/res/drawable/shape_empty_action_focused.xml new file mode 100644 index 0000000000..b225cfddce --- /dev/null +++ b/app/src/leanback/res/drawable/shape_empty_action_focused.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/app/src/leanback/res/drawable/shape_exit_confirm_badge.xml b/app/src/leanback/res/drawable/shape_exit_confirm_badge.xml index 079cd9ba26..1b3ff255a8 100644 --- a/app/src/leanback/res/drawable/shape_exit_confirm_badge.xml +++ b/app/src/leanback/res/drawable/shape_exit_confirm_badge.xml @@ -2,10 +2,10 @@ - + + android:color="?attr/colorOutlineVariant" /> 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..c24e7dce6a 100644 --- a/app/src/leanback/res/drawable/shape_exit_confirm_dialog.xml +++ b/app/src/leanback/res/drawable/shape_exit_confirm_dialog.xml @@ -2,12 +2,12 @@ - + + android:color="?attr/colorOutlineVariant" /> diff --git a/app/src/leanback/res/drawable/shape_group_button_focused.xml b/app/src/leanback/res/drawable/shape_group_button_focused.xml index 7d95427ce6..1691cad413 100644 --- a/app/src/leanback/res/drawable/shape_group_button_focused.xml +++ b/app/src/leanback/res/drawable/shape_group_button_focused.xml @@ -1,13 +1,20 @@ + - + + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/colorOnPrimary" /> diff --git a/app/src/leanback/res/drawable/shape_group_button_normal.xml b/app/src/leanback/res/drawable/shape_group_button_normal.xml index a6946147ca..1ca42991fc 100644 --- a/app/src/leanback/res/drawable/shape_group_button_normal.xml +++ b/app/src/leanback/res/drawable/shape_group_button_normal.xml @@ -2,12 +2,12 @@ - + + android:color="?attr/colorOutline" /> diff --git a/app/src/leanback/res/drawable/shape_group_button_selected.xml b/app/src/leanback/res/drawable/shape_group_button_selected.xml index 5c4b09a2a4..99a4161a66 100644 --- a/app/src/leanback/res/drawable/shape_group_button_selected.xml +++ b/app/src/leanback/res/drawable/shape_group_button_selected.xml @@ -2,12 +2,12 @@ - + + android:color="?attr/colorPrimary" /> diff --git a/app/src/leanback/res/drawable/shape_item_focused.xml b/app/src/leanback/res/drawable/shape_item_focused.xml index a6e8c67a48..e9c20bd0d1 100644 --- a/app/src/leanback/res/drawable/shape_item_focused.xml +++ b/app/src/leanback/res/drawable/shape_item_focused.xml @@ -1,4 +1,17 @@ + @@ -13,7 +26,7 @@ android:top="8dp" /> + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvFocusRing" /> - \ No newline at end of file + diff --git a/app/src/leanback/res/drawable/shape_item_round_focused.xml b/app/src/leanback/res/drawable/shape_item_round_focused.xml index a15a7191d0..57ec722d4a 100644 --- a/app/src/leanback/res/drawable/shape_item_round_focused.xml +++ b/app/src/leanback/res/drawable/shape_item_round_focused.xml @@ -1,4 +1,9 @@ + @@ -13,7 +18,7 @@ android:top="8dp" /> + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvFocusRing" /> - \ No newline at end of file + diff --git a/app/src/leanback/res/drawable/shape_item_selected.xml b/app/src/leanback/res/drawable/shape_item_selected.xml index 57f07b7d89..8b88b3f9b5 100644 --- a/app/src/leanback/res/drawable/shape_item_selected.xml +++ b/app/src/leanback/res/drawable/shape_item_selected.xml @@ -1,4 +1,10 @@ + @@ -13,7 +19,7 @@ android:top="8dp" /> + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvFocusRing" /> - \ No newline at end of file + diff --git a/app/src/leanback/res/drawable/shape_keyboard_focused.xml b/app/src/leanback/res/drawable/shape_keyboard_focused.xml index 3ecccbbf8f..7b231676ab 100644 --- a/app/src/leanback/res/drawable/shape_keyboard_focused.xml +++ b/app/src/leanback/res/drawable/shape_keyboard_focused.xml @@ -1,4 +1,11 @@ + @@ -7,7 +14,7 @@ + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvFocusRing" /> - \ No newline at end of file + diff --git a/app/src/leanback/res/drawable/shape_keyboard_normal.xml b/app/src/leanback/res/drawable/shape_keyboard_normal.xml index 967d62ab95..701cce4560 100644 --- a/app/src/leanback/res/drawable/shape_keyboard_normal.xml +++ b/app/src/leanback/res/drawable/shape_keyboard_normal.xml @@ -1,9 +1,21 @@ + - + - \ No newline at end of file + diff --git a/app/src/leanback/res/drawable/shape_live_focused.xml b/app/src/leanback/res/drawable/shape_live_focused.xml index dfac27b036..f296e5da0e 100644 --- a/app/src/leanback/res/drawable/shape_live_focused.xml +++ b/app/src/leanback/res/drawable/shape_live_focused.xml @@ -1,12 +1,20 @@ + + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvPlayerRing" /> - \ No newline at end of file + diff --git a/app/src/leanback/res/drawable/shape_search_hot_word_focused.xml b/app/src/leanback/res/drawable/shape_search_hot_word_focused.xml index 90d3976b2c..701195f8e7 100644 --- a/app/src/leanback/res/drawable/shape_search_hot_word_focused.xml +++ b/app/src/leanback/res/drawable/shape_search_hot_word_focused.xml @@ -1,4 +1,11 @@ + @@ -7,7 +14,7 @@ + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvFocusRing" /> diff --git a/app/src/leanback/res/drawable/shape_site_dialog.xml b/app/src/leanback/res/drawable/shape_site_dialog.xml index 658271618c..d691255ce2 100644 --- a/app/src/leanback/res/drawable/shape_site_dialog.xml +++ b/app/src/leanback/res/drawable/shape_site_dialog.xml @@ -1,9 +1,17 @@ + - + + + 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..518b4ef35c 100644 --- a/app/src/leanback/res/drawable/shape_site_item_focused.xml +++ b/app/src/leanback/res/drawable/shape_site_item_focused.xml @@ -1,14 +1,20 @@ + - + + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/colorOnPrimary" /> + - + + + + - + + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/colorPrimary" /> + + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvPlayerRing" /> - \ No newline at end of file + diff --git a/app/src/leanback/res/drawable/shape_subtitle_pressed.xml b/app/src/leanback/res/drawable/shape_subtitle_pressed.xml index cbcbfd03db..35186773be 100644 --- a/app/src/leanback/res/drawable/shape_subtitle_pressed.xml +++ b/app/src/leanback/res/drawable/shape_subtitle_pressed.xml @@ -1,11 +1,16 @@ + + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvPlayerRing" /> - \ No newline at end of file + diff --git a/app/src/leanback/res/drawable/shape_video_focused.xml b/app/src/leanback/res/drawable/shape_video_focused.xml index 59328d5ad3..69be25e27d 100644 --- a/app/src/leanback/res/drawable/shape_video_focused.xml +++ b/app/src/leanback/res/drawable/shape_video_focused.xml @@ -1,9 +1,26 @@ + - + - \ No newline at end of file + + + diff --git a/app/src/leanback/res/drawable/shape_video_item_focused.xml b/app/src/leanback/res/drawable/shape_video_item_focused.xml index afbb33d511..6acae66a74 100644 --- a/app/src/leanback/res/drawable/shape_video_item_focused.xml +++ b/app/src/leanback/res/drawable/shape_video_item_focused.xml @@ -1,4 +1,12 @@ + @@ -11,7 +19,7 @@ android:top="8dp" /> diff --git a/app/src/leanback/res/drawable/shape_vod_focused.xml b/app/src/leanback/res/drawable/shape_vod_focused.xml index 1d4d00e4b7..5047f9fa2e 100644 --- a/app/src/leanback/res/drawable/shape_vod_focused.xml +++ b/app/src/leanback/res/drawable/shape_vod_focused.xml @@ -1,11 +1,37 @@ + - + + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvFocusRing" /> - \ No newline at end of file + diff --git a/app/src/leanback/res/drawable/shape_vod_name.xml b/app/src/leanback/res/drawable/shape_vod_name.xml index d341a9d804..398caba748 100644 --- a/app/src/leanback/res/drawable/shape_vod_name.xml +++ b/app/src/leanback/res/drawable/shape_vod_name.xml @@ -1,4 +1,15 @@ + @@ -6,6 +17,6 @@ android:bottomLeftRadius="8dp" android:bottomRightRadius="8dp" /> - + - \ No newline at end of file + diff --git a/app/src/leanback/res/drawable/shape_vod_oval_focused.xml b/app/src/leanback/res/drawable/shape_vod_oval_focused.xml index 0ef858270f..405c3b1fd5 100644 --- a/app/src/leanback/res/drawable/shape_vod_oval_focused.xml +++ b/app/src/leanback/res/drawable/shape_vod_oval_focused.xml @@ -1,9 +1,14 @@ + + android:width="@dimen/webhtv_focus_ring_width" + android:color="?attr/tvFocusRing" /> - \ No newline at end of file + diff --git a/app/src/leanback/res/layout/activity_crash.xml b/app/src/leanback/res/layout/activity_crash.xml index 1f9e89ea1b..77262ae0c3 100644 --- a/app/src/leanback/res/layout/activity_crash.xml +++ b/app/src/leanback/res/layout/activity_crash.xml @@ -1,57 +1,124 @@ - + android:fillViewport="true" + android:scrollbars="none"> - - - + android:orientation="vertical" + android:paddingTop="16dp" + android:paddingBottom="16dp"> - + - + - + - + + + + + + + + + + + + + + + + + diff --git a/app/src/leanback/res/layout/activity_following.xml b/app/src/leanback/res/layout/activity_following.xml new file mode 100644 index 0000000000..39f8392753 --- /dev/null +++ b/app/src/leanback/res/layout/activity_following.xml @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/leanback/res/layout/activity_home.xml b/app/src/leanback/res/layout/activity_home.xml index 77cb69997b..ee8985222f 100644 --- a/app/src/leanback/res/layout/activity_home.xml +++ b/app/src/leanback/res/layout/activity_home.xml @@ -112,7 +112,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="@color/white" - android:textSize="24sp" + android:textSize="16sp" tools:text="09:20:00" /> diff --git a/app/src/leanback/res/layout/activity_setting.xml b/app/src/leanback/res/layout/activity_setting.xml index a50964fd49..6c6ec12624 100644 --- a/app/src/leanback/res/layout/activity_setting.xml +++ b/app/src/leanback/res/layout/activity_setting.xml @@ -12,6 +12,11 @@ android:orientation="vertical" android:padding="24dp"> + + @@ -100,7 +105,7 @@ android:layout_marginEnd="16dp" android:layout_weight="0.3" android:text="@string/setting_live" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -165,7 +170,7 @@ android:layout_marginEnd="16dp" android:layout_weight="0.3" android:text="@string/setting_wall" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -206,6 +211,10 @@ + + @@ -255,7 +264,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_player" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -278,7 +287,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_danmaku" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -295,7 +304,7 @@ android:nextFocusLeft="@id/ad" android:nextFocusRight="@id/ad" android:nextFocusUp="@id/player" - android:nextFocusDown="@id/tmdb" + android:nextFocusDown="@id/subtitle" android:orientation="horizontal"> + + + + + + + + @@ -360,15 +396,15 @@ android:focusableInTouchMode="true" android:nextFocusLeft="@id/tmdb" android:nextFocusRight="@id/ai" - android:nextFocusUp="@id/ad" - android:nextFocusDown="@id/appearance" + android:nextFocusUp="@id/subtitle" + android:nextFocusDown="@id/personal" android:orientation="horizontal"> @@ -391,7 +427,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_language" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -414,30 +450,6 @@ android:layout_marginTop="16dp" android:orientation="horizontal"> - - - - - - @@ -480,12 +492,16 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_personal" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> + + @@ -512,7 +528,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_incognito" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -538,7 +554,7 @@ android:focusableInTouchMode="true" android:nextFocusLeft="@id/incognito" android:nextFocusRight="@id/doh" - android:nextFocusUp="@id/appearance" + android:nextFocusUp="@id/personal" android:nextFocusDown="@id/restore" android:orientation="horizontal"> @@ -547,7 +563,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_doh" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -590,7 +606,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_backup" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -614,7 +630,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_restore" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -647,7 +663,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_cache" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -681,7 +697,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_about" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_ad.xml b/app/src/leanback/res/layout/activity_setting_ad.xml index 5ace24b005..c5495e918f 100644 --- a/app/src/leanback/res/layout/activity_setting_ad.xml +++ b/app/src/leanback/res/layout/activity_setting_ad.xml @@ -27,13 +27,13 @@ android:layout_marginEnd="16dp" android:layout_weight="1" android:text="@string/setting_ad" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="24sp" android:textStyle="bold" /> - + @@ -80,7 +80,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_ai_ad_detection" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> - + @@ -141,314 +141,12 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/ad_block_stats" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -495,7 +193,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_intro_skip_kinds" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_ai.xml b/app/src/leanback/res/layout/activity_setting_ai.xml index 56325fc7ab..2a0a2d9c81 100644 --- a/app/src/leanback/res/layout/activity_setting_ai.xml +++ b/app/src/leanback/res/layout/activity_setting_ai.xml @@ -27,7 +27,7 @@ android:layout_marginEnd="16dp" android:layout_weight="1" android:text="@string/setting_ai" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="24sp" android:textStyle="bold" /> @@ -39,7 +39,7 @@ - + @@ -83,7 +83,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_personal_recommendation" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -111,7 +111,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_recommendation_feedback" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -148,7 +148,7 @@ - + @@ -202,7 +202,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_ai_max_concurrency" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -231,7 +231,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_ai_chunk_count" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_appearance.xml b/app/src/leanback/res/layout/activity_setting_appearance.xml index c13a69d4ae..5f4886bb00 100644 --- a/app/src/leanback/res/layout/activity_setting_appearance.xml +++ b/app/src/leanback/res/layout/activity_setting_appearance.xml @@ -25,7 +25,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_ui_scale" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -54,7 +54,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_language" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -83,7 +83,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_size" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -121,7 +121,7 @@ android:layout_marginEnd="16dp" android:layout_weight="0.3" android:text="@string/setting_wall" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_danmaku.xml b/app/src/leanback/res/layout/activity_setting_danmaku.xml index 08d6b4bf92..61245c6733 100644 --- a/app/src/leanback/res/layout/activity_setting_danmaku.xml +++ b/app/src/leanback/res/layout/activity_setting_danmaku.xml @@ -27,7 +27,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_load" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -58,7 +58,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_api" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -91,7 +91,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_auto_load" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -122,7 +122,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_spider_first" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_enhance.xml b/app/src/leanback/res/layout/activity_setting_enhance.xml index 1c0b67fc6a..d4da9dfaaf 100644 --- a/app/src/leanback/res/layout/activity_setting_enhance.xml +++ b/app/src/leanback/res/layout/activity_setting_enhance.xml @@ -27,7 +27,7 @@ android:layout_marginEnd="16dp" android:layout_weight="1" android:text="@string/setting_enhance" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="24sp" android:textStyle="bold" /> @@ -40,7 +40,7 @@ android:focusable="true" android:padding="10dp" android:src="@drawable/ic_repo_github" - app:tint="@color/white" /> + app:tint="?attr/webhtvColorOnWallpaper" /> + app:tint="?attr/webhtvColorOnWallpaper" /> @@ -71,7 +71,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_lab" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -100,7 +100,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_drive_check" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -129,7 +129,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_site_name" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -157,7 +157,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_audio_source" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -186,7 +186,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_short_drama_source" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -218,7 +218,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_debug_log" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -247,7 +247,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_manage_page" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -276,7 +276,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_remote_trust" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -307,7 +307,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_git_cloud" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -338,7 +338,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_site_health_sort" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -367,7 +367,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_shell_proxy" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -397,7 +397,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_shell_proxy_config" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -428,7 +428,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_custom_csp" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -459,7 +459,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_web_home_extension" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -488,7 +488,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_web_home_theme" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -519,7 +519,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_web_home_fullscreen" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -548,7 +548,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_csp_warmup" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -577,7 +577,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_playback_artwork_wall" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -606,7 +606,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_viewing_record_sync" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -635,7 +635,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_login_state" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -666,7 +666,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_one_key_sync" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_personal.xml b/app/src/leanback/res/layout/activity_setting_personal.xml index 4e30b97708..1c2a8bcbaf 100644 --- a/app/src/leanback/res/layout/activity_setting_personal.xml +++ b/app/src/leanback/res/layout/activity_setting_personal.xml @@ -25,7 +25,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_home_vod_auto_load" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -54,7 +54,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_home_site_lock" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -83,7 +83,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_following" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -112,7 +112,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_auto_backup" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -141,7 +141,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_home_buttons" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -170,7 +170,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_fullscreen_menu_key" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -199,7 +199,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_home_menu_key" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -228,7 +228,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_play_back_to_detail" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -257,7 +257,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_playback_overlay" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -286,7 +286,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_episode_history" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -315,7 +315,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_global_history" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -344,7 +344,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_interface_failover" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -374,7 +374,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_play_speed" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -406,7 +406,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_group_rule" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -435,7 +435,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_home_history" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -465,7 +465,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_thread" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -494,7 +494,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_ui" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -523,7 +523,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_result_sort" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -553,7 +553,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_column" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -582,7 +582,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_reset_app" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -601,7 +601,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_app_branding" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -631,7 +631,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_touch_optimization" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_player.xml b/app/src/leanback/res/layout/activity_setting_player.xml index d1db67db5c..e0c2fa2749 100644 --- a/app/src/leanback/res/layout/activity_setting_player.xml +++ b/app/src/leanback/res/layout/activity_setting_player.xml @@ -27,7 +27,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_render" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -56,7 +56,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_button_config" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -87,7 +87,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_exo_4k_compat" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -118,7 +118,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_kernel" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -148,7 +148,7 @@ android:layout_marginEnd="16dp" android:singleLine="true" android:text="@string/player_mpv_config" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -180,7 +180,7 @@ android:layout_marginEnd="16dp" android:singleLine="true" android:text="@string/player_mpv_render" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -211,7 +211,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_bluray_menu" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -240,7 +240,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_scale" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -269,7 +269,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_lut" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -300,7 +300,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_caption" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -329,7 +329,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_osd" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -360,7 +360,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_buffer" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -378,7 +378,7 @@ android:layout_height="wrap_content" android:layout_marginStart="4dp" android:text="@string/times" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -398,7 +398,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_buffer_bytes" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -429,7 +429,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_back_buffer" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -460,7 +460,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_cache" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -491,7 +491,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -522,7 +522,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_threads" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -553,7 +553,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_size" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -584,7 +584,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_time" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -615,7 +615,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_ahead" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -646,7 +646,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_pause" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -677,7 +677,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_auto_play" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -708,7 +708,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_auto_change" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> + + + + + + + + @@ -770,7 +801,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_failure_fallback" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -801,7 +832,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_speed" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -819,7 +850,7 @@ android:layout_height="wrap_content" android:layout_marginStart="4dp" android:text="@string/times" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -839,7 +870,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_tunnel" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -868,7 +899,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_music_notification" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -897,7 +928,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_audio_book_notification" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -926,7 +957,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_audio_decode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -955,7 +986,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_audio_passthrough" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -984,7 +1015,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_video_decode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -1013,7 +1044,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_aac_track" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -1042,7 +1073,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_background" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -1071,7 +1102,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_ua" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_subtitle.xml b/app/src/leanback/res/layout/activity_setting_subtitle.xml index 5b5dc760c6..53ce2addb9 100644 --- a/app/src/leanback/res/layout/activity_setting_subtitle.xml +++ b/app/src/leanback/res/layout/activity_setting_subtitle.xml @@ -25,7 +25,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_auto_match" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -54,7 +54,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_language" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -85,7 +85,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_source" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_setting_tmdb.xml b/app/src/leanback/res/layout/activity_setting_tmdb.xml index 7ae2792c73..73363b6582 100644 --- a/app/src/leanback/res/layout/activity_setting_tmdb.xml +++ b/app/src/leanback/res/layout/activity_setting_tmdb.xml @@ -27,7 +27,7 @@ android:layout_marginEnd="16dp" android:layout_weight="1" android:text="@string/setting_tmdb" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="24sp" android:textStyle="bold" /> @@ -53,7 +53,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_tmdb_source" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -85,7 +85,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_detail_open_mode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -115,7 +115,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_detail_theme_mode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -146,7 +146,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_tmdb_match_mode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -175,7 +175,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_tmdb_episode_file_size" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -205,7 +205,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_history_aggregation" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> diff --git a/app/src/leanback/res/layout/activity_video.xml b/app/src/leanback/res/layout/activity_video.xml index 34d7201a8a..4ea582b8bf 100644 --- a/app/src/leanback/res/layout/activity_video.xml +++ b/app/src/leanback/res/layout/activity_video.xml @@ -127,7 +127,7 @@ android:ellipsize="end" android:gravity="center" android:maxLines="2" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="22sp" android:textStyle="bold" tools:text="八段锦口令音乐" /> @@ -580,7 +580,7 @@ android:layout_marginEnd="24dp" android:layout_toEndOf="@+id/video" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="24sp" tools:text="慶餘年第二季" /> @@ -594,7 +594,7 @@ android:layout_marginTop="6dp" android:layout_marginEnd="24dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="更新到第二季" /> @@ -615,7 +615,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="站源:泥巴" /> @@ -626,7 +626,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="年份:2022" /> @@ -637,7 +637,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="地區:台灣" /> @@ -647,7 +647,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="類型:科幻" /> @@ -664,17 +664,24 @@ android:layout_marginEnd="24dp" android:nextFocusDown="@id/actor" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="導演:FongMi" /> - + @@ -701,7 +708,7 @@ android:ellipsize="end" android:maxLines="2" android:nextFocusDown="@id/content" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="演員:FongMi" /> @@ -730,7 +737,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" @@ -739,7 +745,7 @@ android:nextFocusDown="@id/tmdbCast" android:singleLine="true" android:text="@string/detail_desc" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" app:drawableStartCompat="@drawable/ic_detail_desc" /> @@ -758,7 +764,7 @@ android:nextFocusDown="@id/flag" android:singleLine="true" android:text="@string/play_short_display" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" app:drawableStartCompat="@drawable/ic_detail_short_display" /> @@ -778,7 +784,7 @@ android:nextFocusDown="@id/flag" android:singleLine="true" android:text="@string/play_search" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" app:drawableStartCompat="@drawable/ic_action_search" /> @@ -798,7 +804,7 @@ android:nextFocusDown="@id/tmdbCast" android:singleLine="true" android:text="@string/play_change" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" android:visibility="gone" app:drawableStartCompat="@drawable/ic_detail_change" /> @@ -819,7 +825,7 @@ android:nextFocusDown="@id/tmdbCast" android:singleLine="true" android:text="@string/keep" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" app:drawableStartCompat="@drawable/ic_detail_keep_off" /> @@ -841,7 +847,7 @@ android:nextFocusDown="@id/tmdbCast" android:singleLine="true" android:text="@string/following_add" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" app:drawableStartCompat="@drawable/ic_home_following" /> @@ -860,7 +866,7 @@ android:nextFocusDown="@id/tmdbCast" android:singleLine="true" android:text="@string/detail_tmdb_rematch" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" android:visibility="gone" app:drawableStartCompat="@drawable/ic_action_search" /> @@ -941,7 +947,7 @@ android:id="@+id/episodeTitle" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:background="@drawable/selector_button" + android:background="@drawable/selector_video_item" android:clickable="true" android:focusable="true" android:focusableInTouchMode="false" @@ -952,7 +958,7 @@ android:paddingStart="12dp" android:paddingEnd="12dp" android:text="@string/detail_episode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" /> @@ -965,14 +971,14 @@ android:id="@+id/episodeReverse" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:background="@drawable/selector_button" + android:background="@drawable/selector_video_item" android:focusable="true" android:focusableInTouchMode="true" android:gravity="center" android:minWidth="76dp" android:nextFocusLeft="@id/episodeTitle" android:text="@string/detail_episode_reverse" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -1048,14 +1054,14 @@ + android:indeterminateTint="?attr/webhtvColorOnWallpaper" /> @@ -1071,7 +1077,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_ratings_label" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1095,7 +1101,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_cast_label" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1123,7 +1129,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_photos_label" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1152,7 +1158,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_posters_label" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1181,7 +1187,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_related_videos_label" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1198,7 +1204,9 @@ android:nextFocusDown="@id/tmdbCrew" android:nextFocusUp="@id/tmdbPosters" android:paddingStart="24dp" + android:paddingTop="6dp" android:paddingEnd="24dp" + android:paddingBottom="6dp" android:visibility="gone" /> @@ -1212,7 +1220,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_crew_label" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1241,7 +1249,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_recommendations_label" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1270,7 +1278,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_personal_recommendations_tmdb_label" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1299,7 +1307,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_personal_recommendations_douban_label" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1328,7 +1336,7 @@ android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/tmdb_personal_recommendations_ai_label" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -1358,7 +1366,7 @@ android:ellipsize="end" android:lineSpacingExtra="2dp" android:maxLines="2" - android:textColor="#CCFFFFFF" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="15sp" android:visibility="gone" /> diff --git a/app/src/leanback/res/layout/adapter_ad_rule.xml b/app/src/leanback/res/layout/adapter_ad_rule.xml index c6627dd8de..83dfab6818 100644 --- a/app/src/leanback/res/layout/adapter_ad_rule.xml +++ b/app/src/leanback/res/layout/adapter_ad_rule.xml @@ -40,7 +40,7 @@ android:layout_marginTop="2dp" android:ellipsize="end" android:singleLine="true" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="13sp" tools:text="AI 生成 · 域名 2 · 广告规则 1" /> @@ -49,7 +49,7 @@ android:layout_height="wrap_content" android:layout_marginTop="6dp" android:text="@string/ad_rule_detail_hint" - android:textColor="#174EA6" + android:textColor="?attr/colorPrimary" android:textSize="12sp" /> 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..6c2dc91724 100644 --- a/app/src/leanback/res/layout/adapter_ad_stats_item.xml +++ b/app/src/leanback/res/layout/adapter_ad_stats_item.xml @@ -32,7 +32,7 @@ android:layout_gravity="center_vertical" android:layout_marginEnd="16dp" android:text="默认" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="12sp" android:visibility="gone" /> diff --git a/app/src/leanback/res/layout/adapter_array.xml b/app/src/leanback/res/layout/adapter_array.xml index 9c50bbcda0..48d56feb43 100644 --- a/app/src/leanback/res/layout/adapter_array.xml +++ b/app/src/leanback/res/layout/adapter_array.xml @@ -10,6 +10,6 @@ android:gravity="center" android:nextFocusUp="@id/quality" android:nextFocusDown="@id/episode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="11" /> diff --git a/app/src/leanback/res/layout/adapter_channel.xml b/app/src/leanback/res/layout/adapter_channel.xml index 6b1356acf9..9dc76f6573 100644 --- a/app/src/leanback/res/layout/adapter_channel.xml +++ b/app/src/leanback/res/layout/adapter_channel.xml @@ -16,7 +16,7 @@ android:layout_marginEnd="12dp" android:duplicateParentState="true" android:fontFeatureSettings="tnum" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="01" /> @@ -34,7 +34,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="CNN" /> diff --git a/app/src/leanback/res/layout/adapter_config.xml b/app/src/leanback/res/layout/adapter_config.xml index 9afb031239..ac1a4d329c 100644 --- a/app/src/leanback/res/layout/adapter_config.xml +++ b/app/src/leanback/res/layout/adapter_config.xml @@ -4,29 +4,55 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" + android:descendantFocusability="afterDescendants" android:orientation="horizontal"> - + + + android:background="@color/webhtv_color_primary" /> @@ -33,7 +33,7 @@ android:layout_width="wrap_content" android:layout_height="match_parent" android:gravity="center_vertical" - android:textColor="@color/white_60" + android:textColor="@color/webhtv_color_on_surface_variant" android:textSize="13sp" tools:text="3" /> diff --git a/app/src/leanback/res/layout/adapter_device.xml b/app/src/leanback/res/layout/adapter_device.xml index e967577855..efb66e766a 100644 --- a/app/src/leanback/res/layout/adapter_device.xml +++ b/app/src/leanback/res/layout/adapter_device.xml @@ -34,7 +34,7 @@ android:layout_height="wrap_content" android:layout_marginTop="2dp" android:singleLine="true" - android:textColor="?attr/colorOnSurface" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="12sp" tools:text="127.0.0.1" /> diff --git a/app/src/leanback/res/layout/adapter_doh.xml b/app/src/leanback/res/layout/adapter_doh.xml index 9c9d1ab02a..d0877b1735 100644 --- a/app/src/leanback/res/layout/adapter_doh.xml +++ b/app/src/leanback/res/layout/adapter_doh.xml @@ -9,6 +9,6 @@ android:focusableInTouchMode="true" android:gravity="center" android:singleLine="true" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" tools:text="Google" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_epg_data.xml b/app/src/leanback/res/layout/adapter_epg_data.xml index 3a14a3b7eb..33a1694270 100644 --- a/app/src/leanback/res/layout/adapter_epg_data.xml +++ b/app/src/leanback/res/layout/adapter_epg_data.xml @@ -15,7 +15,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="七龍珠" /> @@ -26,7 +26,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="18:00 ~ 19:00" /> diff --git a/app/src/leanback/res/layout/adapter_episode.xml b/app/src/leanback/res/layout/adapter_episode.xml index c712b8495e..a50285b7e7 100644 --- a/app/src/leanback/res/layout/adapter_episode.xml +++ b/app/src/leanback/res/layout/adapter_episode.xml @@ -19,7 +19,7 @@ android:nextFocusDown="@id/part" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="第20集" /> diff --git a/app/src/leanback/res/layout/adapter_episode_card.xml b/app/src/leanback/res/layout/adapter_episode_card.xml index dc658bd516..d96cd43bd6 100644 --- a/app/src/leanback/res/layout/adapter_episode_card.xml +++ b/app/src/leanback/res/layout/adapter_episode_card.xml @@ -6,14 +6,14 @@ android:id="@+id/cardContainer" android:layout_width="280dp" android:layout_height="160dp" - android:layout_marginEnd="12dp" + android:layout_marginEnd="@dimen/webhtv_size_tv_card_gap" android:defaultFocusHighlightEnabled="false" android:focusable="true" android:focusableInTouchMode="true" android:foreground="@drawable/selector_episode_card" android:stateListAnimator="@null" - app:cardBackgroundColor="#141A20" - app:cardCornerRadius="8dp" + app:cardBackgroundColor="?attr/colorSurfaceContainerHigh" + app:cardCornerRadius="@dimen/webhtv_card_radius_default" app:cardElevation="0dp"> @@ -52,7 +52,7 @@ android:paddingEnd="10dp" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="13sp" android:textStyle="bold" android:visibility="gone" @@ -77,7 +77,7 @@ android:paddingEnd="10dp" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="13sp" android:textStyle="bold" android:visibility="gone" @@ -97,7 +97,7 @@ android:minWidth="52dp" android:paddingStart="10dp" android:paddingEnd="10dp" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="13sp" android:textStyle="bold" android:visibility="gone" @@ -122,7 +122,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" tools:text="1. 楚朝遭灭门傅九舍命" /> @@ -135,7 +135,7 @@ android:ellipsize="end" android:lineSpacingExtra="2dp" android:maxLines="2" - android:textColor="#E6FFFFFF" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="15sp" android:visibility="gone" tools:text="楚朝是大将军楚岑之女,跟着父亲在大楚边境的云中郡长大。" diff --git a/app/src/leanback/res/layout/adapter_episode_dialog.xml b/app/src/leanback/res/layout/adapter_episode_dialog.xml index c539b11480..99248a0ac9 100644 --- a/app/src/leanback/res/layout/adapter_episode_dialog.xml +++ b/app/src/leanback/res/layout/adapter_episode_dialog.xml @@ -14,6 +14,6 @@ android:focusableInTouchMode="true" android:gravity="center" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="第08集 权术之介" /> diff --git a/app/src/leanback/res/layout/adapter_episode_page.xml b/app/src/leanback/res/layout/adapter_episode_page.xml index 7d0a9c3b25..c82406d416 100644 --- a/app/src/leanback/res/layout/adapter_episode_page.xml +++ b/app/src/leanback/res/layout/adapter_episode_page.xml @@ -15,7 +15,7 @@ android:paddingStart="18dp" android:paddingEnd="18dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="15sp" android:textStyle="bold" tools:text="1 - 12" /> diff --git a/app/src/leanback/res/layout/adapter_file.xml b/app/src/leanback/res/layout/adapter_file.xml index 7083edc1a8..d0cbc8f4be 100644 --- a/app/src/leanback/res/layout/adapter_file.xml +++ b/app/src/leanback/res/layout/adapter_file.xml @@ -27,7 +27,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="Download" /> diff --git a/app/src/leanback/res/layout/adapter_filter.xml b/app/src/leanback/res/layout/adapter_filter.xml index f4ed870390..5dfa463be4 100644 --- a/app/src/leanback/res/layout/adapter_filter.xml +++ b/app/src/leanback/res/layout/adapter_filter.xml @@ -7,6 +7,6 @@ android:background="@drawable/selector_item_round" android:focusable="true" android:focusableInTouchMode="true" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="全部" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_flag.xml b/app/src/leanback/res/layout/adapter_flag.xml index a2d068ddea..eb300eb1bb 100644 --- a/app/src/leanback/res/layout/adapter_flag.xml +++ b/app/src/leanback/res/layout/adapter_flag.xml @@ -14,6 +14,6 @@ android:nextFocusDown="@id/episode" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="泥巴" /> diff --git a/app/src/leanback/res/layout/adapter_func.xml b/app/src/leanback/res/layout/adapter_func.xml index 454ed9ca64..9fdc2bfe6b 100644 --- a/app/src/leanback/res/layout/adapter_func.xml +++ b/app/src/leanback/res/layout/adapter_func.xml @@ -23,7 +23,7 @@ android:layout_height="wrap_content" android:layout_marginStart="8dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" tools:text="點播" /> diff --git a/app/src/leanback/res/layout/adapter_group.xml b/app/src/leanback/res/layout/adapter_group.xml index 239141dedc..4d649154c2 100644 --- a/app/src/leanback/res/layout/adapter_group.xml +++ b/app/src/leanback/res/layout/adapter_group.xml @@ -16,7 +16,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="收藏" /> diff --git a/app/src/leanback/res/layout/adapter_header.xml b/app/src/leanback/res/layout/adapter_header.xml index 797a3e0de3..cf1e700d51 100644 --- a/app/src/leanback/res/layout/adapter_header.xml +++ b/app/src/leanback/res/layout/adapter_header.xml @@ -4,6 +4,6 @@ android:id="@+id/text" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="24sp" tools:text="@string/home_recommend" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_home_button.xml b/app/src/leanback/res/layout/adapter_home_button.xml index 2d35314fcf..5d69116ba7 100644 --- a/app/src/leanback/res/layout/adapter_home_button.xml +++ b/app/src/leanback/res/layout/adapter_home_button.xml @@ -37,7 +37,7 @@ android:layout_marginStart="10dp" android:layout_weight="1" android:gravity="center_vertical" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" tools:text="Search" /> diff --git a/app/src/leanback/res/layout/adapter_home_menu.xml b/app/src/leanback/res/layout/adapter_home_menu.xml index b385e000b2..6bac3ad3b7 100644 --- a/app/src/leanback/res/layout/adapter_home_menu.xml +++ b/app/src/leanback/res/layout/adapter_home_menu.xml @@ -17,7 +17,7 @@ android:focusableInTouchMode="true" android:gravity="center" android:singleLine="true" - android:textColor="@color/config_history_text" + android:textColor="@color/home_menu_text" android:textSize="18sp" app:autoSizeMaxTextSize="18sp" app:autoSizeMinTextSize="14sp" diff --git a/app/src/leanback/res/layout/adapter_keyboard_text.xml b/app/src/leanback/res/layout/adapter_keyboard_text.xml index 9ecfa90818..40e22975ab 100644 --- a/app/src/leanback/res/layout/adapter_keyboard_text.xml +++ b/app/src/leanback/res/layout/adapter_keyboard_text.xml @@ -8,6 +8,6 @@ android:focusable="true" android:focusableInTouchMode="true" android:gravity="center" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="0" /> diff --git a/app/src/leanback/res/layout/adapter_live.xml b/app/src/leanback/res/layout/adapter_live.xml index 0d0745125f..c5f37c29b2 100644 --- a/app/src/leanback/res/layout/adapter_live.xml +++ b/app/src/leanback/res/layout/adapter_live.xml @@ -16,7 +16,7 @@ android:focusableInTouchMode="true" android:gravity="center" android:singleLine="true" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" tools:text="https://fongmi.github.io/live.json" /> diff --git a/app/src/leanback/res/layout/adapter_parse.xml b/app/src/leanback/res/layout/adapter_parse.xml index 60d1cf455a..b3683e770c 100644 --- a/app/src/leanback/res/layout/adapter_parse.xml +++ b/app/src/leanback/res/layout/adapter_parse.xml @@ -8,6 +8,6 @@ android:focusable="true" android:focusableInTouchMode="true" android:gravity="center" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="解析" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_part.xml b/app/src/leanback/res/layout/adapter_part.xml index 05bf625436..f4ebc8a131 100644 --- a/app/src/leanback/res/layout/adapter_part.xml +++ b/app/src/leanback/res/layout/adapter_part.xml @@ -11,6 +11,6 @@ android:gravity="center" android:nextFocusUp="@id/array" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="分詞" /> diff --git a/app/src/leanback/res/layout/adapter_player_osd.xml b/app/src/leanback/res/layout/adapter_player_osd.xml index afdba9bcd8..f24f3d33cd 100644 --- a/app/src/leanback/res/layout/adapter_player_osd.xml +++ b/app/src/leanback/res/layout/adapter_player_osd.xml @@ -7,7 +7,7 @@ android:layout_height="48dp" android:layout_marginBottom="8dp" android:addStatesFromChildren="true" - android:background="@drawable/selector_git_cloud_card" + android:background="@drawable/selector_player_settings_row" android:clickable="true" android:descendantFocusability="afterDescendants" android:gravity="center_vertical" @@ -22,7 +22,7 @@ android:layout_weight="1" android:ellipsize="end" android:singleLine="true" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="15sp" tools:text="Playback params" /> @@ -30,12 +30,12 @@ android:id="@+id/visible" android:layout_width="40dp" android:layout_height="36dp" - android:background="@drawable/selector_dialog_switch" + android:background="@drawable/selector_player_settings_switch" android:contentDescription="@string/setting_show" android:focusable="true" android:padding="8dp" android:scaleType="center" android:src="@drawable/ic_player_button_visible" - app:tint="@color/dialog_tonal_button_text" /> + app:tint="@color/player_settings_icon_tint" /> diff --git a/app/src/leanback/res/layout/adapter_quality.xml b/app/src/leanback/res/layout/adapter_quality.xml index bd9ccf4c66..94cf6c5c1f 100644 --- a/app/src/leanback/res/layout/adapter_quality.xml +++ b/app/src/leanback/res/layout/adapter_quality.xml @@ -12,6 +12,6 @@ android:nextFocusUp="@id/flag" android:nextFocusDown="@id/episode" android:singleLine="true" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="FHD" /> diff --git a/app/src/leanback/res/layout/adapter_quick.xml b/app/src/leanback/res/layout/adapter_quick.xml index aa97af8deb..993ca02493 100644 --- a/app/src/leanback/res/layout/adapter_quick.xml +++ b/app/src/leanback/res/layout/adapter_quick.xml @@ -13,7 +13,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="蜘蛛人" /> diff --git a/app/src/leanback/res/layout/adapter_recommendation_feedback.xml b/app/src/leanback/res/layout/adapter_recommendation_feedback.xml index 980d9d2399..7a92a293f9 100644 --- a/app/src/leanback/res/layout/adapter_recommendation_feedback.xml +++ b/app/src/leanback/res/layout/adapter_recommendation_feedback.xml @@ -19,7 +19,7 @@ android:layout_height="wrap_content" android:ellipsize="end" android:singleLine="true" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="16sp" android:textStyle="bold" tools:text="芸汐传" /> @@ -31,7 +31,7 @@ android:layout_marginTop="2dp" android:ellipsize="end" android:singleLine="true" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="12sp" tools:text="剧集 · 2018 · 智能推荐 · 剩余约 89 天 · 点击恢复" /> diff --git a/app/src/leanback/res/layout/adapter_search.xml b/app/src/leanback/res/layout/adapter_search.xml index 38b728ff5e..0f50ebd6ad 100644 --- a/app/src/leanback/res/layout/adapter_search.xml +++ b/app/src/leanback/res/layout/adapter_search.xml @@ -11,7 +11,7 @@ android:id="@+id/image" android:layout_width="match_parent" android:layout_height="0dp" - android:background="@color/black_20" + android:background="?attr/colorSurfaceContainer" android:scaleType="centerCrop" app:shapeAppearanceOverlay="@style/Vod.Grid" /> @@ -26,7 +26,7 @@ android:marqueeRepeatLimit="marquee_forever" android:maxWidth="96dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="13sp" android:visibility="gone" /> @@ -41,7 +41,7 @@ android:ellipsize="marquee" android:marqueeRepeatLimit="marquee_forever" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" /> diff --git a/app/src/leanback/res/layout/adapter_search_hot_word.xml b/app/src/leanback/res/layout/adapter_search_hot_word.xml index fd1ff3214f..c7155b0220 100644 --- a/app/src/leanback/res/layout/adapter_search_hot_word.xml +++ b/app/src/leanback/res/layout/adapter_search_hot_word.xml @@ -12,6 +12,6 @@ android:paddingStart="10dp" android:paddingEnd="10dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="15sp" tools:text="熱搜詞" /> diff --git a/app/src/leanback/res/layout/adapter_search_list.xml b/app/src/leanback/res/layout/adapter_search_list.xml index 62c42236b2..60f43a12c9 100644 --- a/app/src/leanback/res/layout/adapter_search_list.xml +++ b/app/src/leanback/res/layout/adapter_search_list.xml @@ -17,7 +17,7 @@ android:layout_alignParentStart="true" android:layout_alignParentTop="true" android:layout_alignParentBottom="true" - android:background="@color/black_20" + android:background="?attr/colorSurfaceContainer" android:scaleType="centerCrop" app:shapeAppearanceOverlay="@style/Vod.Grid.Large" /> @@ -32,7 +32,7 @@ android:marqueeRepeatLimit="marquee_forever" android:maxWidth="80dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="12sp" android:visibility="gone" tools:text="Site" @@ -54,7 +54,7 @@ android:ellipsize="marquee" android:marqueeRepeatLimit="marquee_forever" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" tools:text="Movie title" /> @@ -66,7 +66,7 @@ android:ellipsize="marquee" android:marqueeRepeatLimit="marquee_forever" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="15sp" tools:text="1080P | 125 min" /> diff --git a/app/src/leanback/res/layout/adapter_search_record.xml b/app/src/leanback/res/layout/adapter_search_record.xml index 1858d6bce2..ebb54fcc42 100644 --- a/app/src/leanback/res/layout/adapter_search_record.xml +++ b/app/src/leanback/res/layout/adapter_search_record.xml @@ -10,6 +10,6 @@ android:focusable="true" android:focusableInTouchMode="true" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="歷史" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_search_word.xml b/app/src/leanback/res/layout/adapter_search_word.xml index b578762642..4a1f9d24bf 100644 --- a/app/src/leanback/res/layout/adapter_search_word.xml +++ b/app/src/leanback/res/layout/adapter_search_word.xml @@ -10,6 +10,6 @@ android:focusable="true" android:focusableInTouchMode="true" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="推薦" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_site.xml b/app/src/leanback/res/layout/adapter_site.xml index 941f748e87..486e624004 100644 --- a/app/src/leanback/res/layout/adapter_site.xml +++ b/app/src/leanback/res/layout/adapter_site.xml @@ -1,19 +1,31 @@ + + android:orientation="horizontal" + android:paddingStart="14dp" + android:paddingEnd="14dp"> + android:textColor="@color/site_item_text" + android:textSize="17sp" /> + app:buttonTint="@color/site_item_check" /> diff --git a/app/src/leanback/res/layout/adapter_site_switch.xml b/app/src/leanback/res/layout/adapter_site_switch.xml index 06e1d1e097..af199e2f36 100644 --- a/app/src/leanback/res/layout/adapter_site_switch.xml +++ b/app/src/leanback/res/layout/adapter_site_switch.xml @@ -1,18 +1,27 @@ + + android:orientation="horizontal" + android:paddingStart="14dp" + android:paddingEnd="14dp"> + android:textColor="@color/site_item_text" + android:textSize="17sp" /> diff --git a/app/src/leanback/res/layout/adapter_type.xml b/app/src/leanback/res/layout/adapter_type.xml index 6c7281d4ab..a408523f06 100644 --- a/app/src/leanback/res/layout/adapter_type.xml +++ b/app/src/leanback/res/layout/adapter_type.xml @@ -7,6 +7,6 @@ android:background="@drawable/selector_item_round" android:focusable="true" android:focusableInTouchMode="true" - android:textColor="@color/text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="電影" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/adapter_vod.xml b/app/src/leanback/res/layout/adapter_vod.xml index ae54994088..f0808c33ca 100644 --- a/app/src/leanback/res/layout/adapter_vod.xml +++ b/app/src/leanback/res/layout/adapter_vod.xml @@ -12,7 +12,7 @@ android:id="@+id/image" android:layout_width="match_parent" android:layout_height="80dp" - android:background="@color/black_20" + android:background="?attr/colorSurfaceContainer" android:scaleType="centerCrop" app:shapeAppearanceOverlay="@style/Vod.Grid" /> @@ -24,7 +24,7 @@ android:layout_alignTop="@+id/image" android:layout_alignEnd="@+id/image" android:layout_alignBottom="@+id/image" - android:background="@color/black_50" + android:background="@color/webhtv_color_player_scrim" android:scaleType="center" android:src="@drawable/ic_vod_delete" android:visibility="gone" @@ -41,7 +41,7 @@ android:ellipsize="end" android:maxWidth="104dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" android:visibility="gone" tools:text="2022" @@ -58,7 +58,7 @@ android:ellipsize="end" android:maxWidth="116dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" android:visibility="gone" tools:text="瓜子[优](DS)" @@ -74,7 +74,7 @@ android:ellipsize="end" android:maxWidth="132dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="00:32 / 13:08" /> @@ -85,8 +85,8 @@ android:layout_below="@+id/image" android:layout_alignStart="@+id/image" android:layout_alignEnd="@+id/image" - android:background="@color/black_20" - app:indicatorColor="@color/white" + android:background="?attr/colorSurfaceContainer" + app:indicatorColor="?attr/colorPrimary" app:trackColor="@color/transparent" app:trackStopIndicatorSize="0dp" app:trackThickness="2dp" /> @@ -116,7 +116,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="云秀行" /> @@ -131,7 +131,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="@color/white_70" + android:textColor="?attr/webhtvColorOnWallpaperVariant" android:textSize="14sp" tools:text="第01集. 海边散步" /> diff --git a/app/src/leanback/res/layout/adapter_vod_list.xml b/app/src/leanback/res/layout/adapter_vod_list.xml index 608cca4b61..7df65f0890 100644 --- a/app/src/leanback/res/layout/adapter_vod_list.xml +++ b/app/src/leanback/res/layout/adapter_vod_list.xml @@ -34,7 +34,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="蜘蛛人" /> @@ -44,7 +44,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="1080p" /> diff --git a/app/src/leanback/res/layout/adapter_vod_oval.xml b/app/src/leanback/res/layout/adapter_vod_oval.xml index f059d22608..7a739136bb 100644 --- a/app/src/leanback/res/layout/adapter_vod_oval.xml +++ b/app/src/leanback/res/layout/adapter_vod_oval.xml @@ -12,7 +12,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="8dp" - android:background="@color/black_20" + android:background="?attr/colorSurfaceContainer" app:shapeAppearanceOverlay="@style/Vod.Circle" /> diff --git a/app/src/leanback/res/layout/adapter_vod_rect.xml b/app/src/leanback/res/layout/adapter_vod_rect.xml index 3847c13395..4b5ebca736 100644 --- a/app/src/leanback/res/layout/adapter_vod_rect.xml +++ b/app/src/leanback/res/layout/adapter_vod_rect.xml @@ -12,7 +12,7 @@ android:id="@+id/image" android:layout_width="match_parent" android:layout_height="80dp" - android:background="@color/black_20" + android:background="?attr/colorSurfaceContainer" app:shapeAppearanceOverlay="@style/Vod.Grid" /> @@ -63,7 +63,7 @@ android:gravity="center" android:padding="6dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="蜘蛛人" /> 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..2af1573769 100644 --- a/app/src/leanback/res/layout/dialog_ad_block_stats.xml +++ b/app/src/leanback/res/layout/dialog_ad_block_stats.xml @@ -24,14 +24,14 @@ android:minHeight="52dp" android:overScrollMode="never" app:tabGravity="fill" - app:tabIndicatorColor="#1A73E8" + app:tabIndicatorColor="?attr/colorPrimary" app:tabIndicatorFullWidth="true" app:tabIndicatorHeight="3dp" app:tabMode="fixed" app:tabRippleColor="@android:color/transparent" - app:tabSelectedTextColor="#1A73E8" + app:tabSelectedTextColor="?attr/colorPrimary" app:tabTextAppearance="@style/TextAppearance.MaterialComponents.Button" - app:tabTextColor="#5F6368" /> + app:tabTextColor="?attr/colorOnSurfaceVariant" /> @@ -113,7 +113,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:text="@string/ad_feedback_count" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="15sp" /> @@ -137,7 +137,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:text="@string/ad_ai_success_rate" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="15sp" /> @@ -146,7 +146,7 @@ android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="20dp" - android:background="@color/black_10" /> + android:background="@color/webhtv_color_scrim" /> @@ -251,7 +251,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/ad_stats_empty" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="15sp" android:visibility="gone" /> + android:background="@color/webhtv_color_scrim" /> + app:backgroundTint="@color/dialog_primary_button_bg" + app:strokeWidth="@dimen/webhtv_focus_ring_width" + app:strokeColor="@color/focus_ring_primary_container" /> diff --git a/app/src/leanback/res/layout/dialog_ad_rule_edit.xml b/app/src/leanback/res/layout/dialog_ad_rule_edit.xml index 375d65a8b0..8238a9a4ec 100644 --- a/app/src/leanback/res/layout/dialog_ad_rule_edit.xml +++ b/app/src/leanback/res/layout/dialog_ad_rule_edit.xml @@ -20,7 +20,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/ad_rule_edit_title" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="20sp" android:textStyle="bold" /> @@ -29,10 +29,10 @@ android:layout_height="wrap_content" android:layout_marginTop="20dp" android:hint="@string/ad_rule_field_name" - android:textColorHint="#5F6368" + android:textColorHint="?attr/colorOnSurfaceVariant" app:boxBackgroundMode="outline" - app:boxStrokeColor="@color/dialog_outlined_button_stroke" - app:hintTextColor="#5F6368"> + app:boxStrokeColor="?attr/colorOutline" + app:hintTextColor="?attr/colorOnSurfaceVariant"> @@ -52,13 +52,13 @@ android:layout_height="wrap_content" android:layout_marginTop="14dp" android:hint="@string/ad_rule_field_hosts" - android:textColorHint="#5F6368" + android:textColorHint="?attr/colorOnSurfaceVariant" app:boxBackgroundMode="outline" - app:boxStrokeColor="@color/dialog_outlined_button_stroke" + app:boxStrokeColor="?attr/colorOutline" app:helperText="@string/ad_rule_field_hosts_hint" app:helperTextEnabled="true" - app:helperTextTextColor="#5F6368" - app:hintTextColor="#5F6368"> + app:helperTextTextColor="?attr/colorOnSurfaceVariant" + app:hintTextColor="?attr/colorOnSurfaceVariant"> @@ -79,13 +79,13 @@ android:layout_height="wrap_content" android:layout_marginTop="14dp" android:hint="@string/ad_rule_field_regex" - android:textColorHint="#5F6368" + android:textColorHint="?attr/colorOnSurfaceVariant" app:boxBackgroundMode="outline" - app:boxStrokeColor="@color/dialog_outlined_button_stroke" + app:boxStrokeColor="?attr/colorOutline" app:helperText="@string/ad_rule_field_regex_hint" app:helperTextEnabled="true" - app:helperTextTextColor="#5F6368" - app:hintTextColor="#5F6368"> + app:helperTextTextColor="?attr/colorOnSurfaceVariant" + app:hintTextColor="?attr/colorOnSurfaceVariant"> @@ -106,13 +106,13 @@ android:layout_height="wrap_content" android:layout_marginTop="14dp" android:hint="@string/ad_rule_field_exclude" - android:textColorHint="#5F6368" + android:textColorHint="?attr/colorOnSurfaceVariant" app:boxBackgroundMode="outline" - app:boxStrokeColor="@color/dialog_outlined_button_stroke" + app:boxStrokeColor="?attr/colorOutline" app:helperText="@string/ad_rule_field_exclude_hint" app:helperTextEnabled="true" - app:helperTextTextColor="#5F6368" - app:hintTextColor="#5F6368"> + app:helperTextTextColor="?attr/colorOnSurfaceVariant" + app:hintTextColor="?attr/colorOnSurfaceVariant"> @@ -137,7 +137,7 @@ + app:backgroundTint="@color/dialog_primary_button_bg" + app:strokeWidth="@dimen/webhtv_focus_ring_width" + app:strokeColor="@color/focus_ring_primary_container" /> 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..631010cb56 100644 --- a/app/src/leanback/res/layout/dialog_ad_rule_manage.xml +++ b/app/src/leanback/res/layout/dialog_ad_rule_manage.xml @@ -10,7 +10,7 @@ @@ -110,7 +110,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="10dp" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="13sp" tools:text="来自配置 · 共 12 条(只读)" /> diff --git a/app/src/leanback/res/layout/dialog_ad_rule_preview.xml b/app/src/leanback/res/layout/dialog_ad_rule_preview.xml index 1f6e927a55..5f958dd069 100644 --- a/app/src/leanback/res/layout/dialog_ad_rule_preview.xml +++ b/app/src/leanback/res/layout/dialog_ad_rule_preview.xml @@ -17,7 +17,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/ad_rule_preview_title" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="17sp" android:textStyle="bold" /> @@ -26,7 +26,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="14sp" tools:text="置信度:75%" /> @@ -38,7 +38,7 @@ android:background="@drawable/shape_warning_background" android:padding="10dp" android:text="@string/ad_rule_low_confidence_warning" - android:textColor="#B3261E" + android:textColor="?attr/colorError" android:textSize="13sp" android:visibility="gone" tools:visibility="visible" /> @@ -48,7 +48,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="13sp" tools:text="检测到 DISCONTINUITY 标记和跨域切片,典型广告特征" /> @@ -57,7 +57,7 @@ android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/ad_rule_preview_hosts" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="14sp" android:textStyle="bold" /> @@ -67,7 +67,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:fontFamily="monospace" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textIsSelectable="true" android:textSize="12sp" tools:text="ad.example.com\nadservice.provider.net" /> @@ -77,7 +77,7 @@ android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/ad_rule_preview_regex" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="14sp" android:textStyle="bold" /> @@ -87,7 +87,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:fontFamily="monospace" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textIsSelectable="true" android:textSize="12sp" tools:text=".*\\.mp4\\?ad=.*\n.*\\/preroll\\/.*" /> @@ -97,7 +97,7 @@ android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/ad_rule_preview_exclude" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="14sp" android:textStyle="bold" /> @@ -107,7 +107,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:fontFamily="monospace" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textIsSelectable="true" android:textSize="12sp" tools:text=".*trailer.*\n.*preview.*" /> @@ -121,7 +121,7 @@ + app:backgroundTint="@color/dialog_primary_button_bg" + app:strokeWidth="@dimen/webhtv_focus_ring_width" + app:strokeColor="@color/focus_ring_primary_container" /> diff --git a/app/src/leanback/res/layout/dialog_config.xml b/app/src/leanback/res/layout/dialog_config.xml index 67367111e6..aa4f9428ee 100644 --- a/app/src/leanback/res/layout/dialog_config.xml +++ b/app/src/leanback/res/layout/dialog_config.xml @@ -24,20 +24,28 @@ - + android:layout_marginTop="8dp" + android:hint="@string/dialog_config_name" + android:importantForAutofill="no" + android:inputType="text" + android:singleLine="true" + android:textColor="?attr/colorOnSurface" + android:textColorHint="?attr/colorOnSurfaceVariant" + android:textSize="18sp" /> + + + app:strokeColor="@color/focus_ring_primary_container" + app:strokeWidth="@dimen/webhtv_focus_ring_width" /> diff --git a/app/src/leanback/res/layout/dialog_content.xml b/app/src/leanback/res/layout/dialog_content.xml index 13a36e5b0b..04caa6f1c3 100644 --- a/app/src/leanback/res/layout/dialog_content.xml +++ b/app/src/leanback/res/layout/dialog_content.xml @@ -11,7 +11,7 @@ android:letterSpacing="0.05" android:lineSpacingExtra="8dp" android:padding="24dp" - android:textColor="@color/white" + android:textColor="?attr/colorOnSurface" android:textSize="16sp" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/dialog_control.xml b/app/src/leanback/res/layout/dialog_control.xml index d08143e463..884250845e 100644 --- a/app/src/leanback/res/layout/dialog_control.xml +++ b/app/src/leanback/res/layout/dialog_control.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/shape_dialog_control_glass_panel" - tools:background="#1F1F1F"> + tools:background="?attr/colorSurfaceContainerLow"> + android:textColor="?attr/webhtvColorOnWallpaper" /> + android:textColor="?attr/webhtvColorOnWallpaper" /> diff --git a/app/src/leanback/res/layout/dialog_episode.xml b/app/src/leanback/res/layout/dialog_episode.xml index 269eecbb2e..adc69f51ff 100644 --- a/app/src/leanback/res/layout/dialog_episode.xml +++ b/app/src/leanback/res/layout/dialog_episode.xml @@ -21,7 +21,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/detail_episode" - android:textColor="#EAF2F8" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> + app:tint="?attr/webhtvColorOnWallpaper" /> diff --git a/app/src/leanback/res/layout/dialog_episode_detail.xml b/app/src/leanback/res/layout/dialog_episode_detail.xml index d1273df1e9..04976d757a 100644 --- a/app/src/leanback/res/layout/dialog_episode_detail.xml +++ b/app/src/leanback/res/layout/dialog_episode_detail.xml @@ -6,7 +6,7 @@ android:id="@+id/root" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/black"> + android:background="?attr/colorSurfaceContainerLowest"> @@ -54,7 +54,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" - android:textColor="#000000" + android:textColor="?attr/colorOnSurface" android:textSize="22sp" android:textStyle="bold" tools:text="第1集 - 去有鳳凰的地方" /> @@ -65,7 +65,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="6dp" - android:textColor="#666666" + android:textColor="?attr/colorOnSurfaceVariant" android:textIsSelectable="true" android:textSize="14sp" android:visibility="gone" @@ -83,7 +83,7 @@ android:id="@+id/date" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:textColor="#555555" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="15sp" tools:text="播出日期: 2024-01-01" /> @@ -92,7 +92,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="20dp" - android:textColor="#555555" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="15sp" tools:text="时长: 45 分钟" /> @@ -104,7 +104,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="6dp" - android:textColor="#F57C00" + android:textColor="@color/webhtv_color_warning" android:textSize="17sp" tools:text="★ 8.5" /> @@ -115,7 +115,7 @@ android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="剧情简介" - android:textColor="#1A1A1A" + android:textColor="?attr/colorOnSurface" android:textSize="18sp" android:textStyle="bold" /> @@ -126,7 +126,7 @@ android:layout_height="wrap_content" android:layout_marginTop="8dp" android:lineSpacingExtra="4dp" - android:textColor="#333333" + android:textColor="?attr/colorOnSurface" android:textSize="15sp" tools:text="这是剧集的详细简介内容..." /> @@ -137,7 +137,7 @@ android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="本集图片" - android:textColor="#1A1A1A" + android:textColor="?attr/colorOnSurface" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> @@ -162,7 +162,7 @@ android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="@string/detail_episode_guests" - android:textColor="#1A1A1A" + android:textColor="?attr/colorOnSurface" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" /> diff --git a/app/src/leanback/res/layout/dialog_episode_list.xml b/app/src/leanback/res/layout/dialog_episode_list.xml index 1f4ca03dc7..e16ea5e119 100644 --- a/app/src/leanback/res/layout/dialog_episode_list.xml +++ b/app/src/leanback/res/layout/dialog_episode_list.xml @@ -9,7 +9,8 @@ android:clipToPadding="false" android:fillViewport="true" android:paddingStart="20dp" - android:paddingEnd="20dp"> + android:paddingEnd="20dp" + android:theme="@style/ThemeOverlay.WebHTV.GlassFocusRings"> @@ -43,7 +44,7 @@ android:layout_marginTop="8dp" android:gravity="center_vertical" android:text="@string/detail_season" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" android:visibility="gone" @@ -66,7 +67,7 @@ android:layout_marginTop="8dp" android:gravity="center_vertical" android:text="@string/detail_episode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" /> diff --git a/app/src/leanback/res/layout/dialog_exit_confirm.xml b/app/src/leanback/res/layout/dialog_exit_confirm.xml index e1c504cc38..09436d4a99 100644 --- a/app/src/leanback/res/layout/dialog_exit_confirm.xml +++ b/app/src/leanback/res/layout/dialog_exit_confirm.xml @@ -25,7 +25,7 @@ android:gravity="center" android:includeFontPadding="false" android:text="!" - android:textColor="#174EA6" + android:textColor="?attr/colorPrimary" android:textSize="24sp" android:textStyle="bold" /> @@ -42,7 +42,7 @@ android:layout_height="wrap_content" android:singleLine="true" android:text="@string/exit_confirm_title" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="22sp" android:textStyle="bold" tools:text="退出应用" /> @@ -54,7 +54,7 @@ android:layout_marginTop="8dp" android:lineSpacingExtra="4dp" android:text="@string/exit_confirm_message" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="14sp" tools:text="应用将关闭,播放和后台服务也会停止。" /> @@ -67,7 +67,7 @@ android:layout_height="1dp" android:layout_marginTop="22dp" android:layout_marginBottom="18dp" - android:background="#E8EDF5" /> + android:background="?attr/colorSurfaceContainer" /> @@ -109,7 +109,7 @@ android:nextFocusRight="@id/positive" android:singleLine="true" android:text="@string/exit_confirm_background" - android:textColor="@color/exit_confirm_secondary_text" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="15sp" android:textStyle="bold" /> @@ -125,7 +125,7 @@ android:gravity="center" android:singleLine="true" android:text="@string/exit_confirm_positive" - android:textColor="#FFFFFF" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="15sp" android:textStyle="bold" /> diff --git a/app/src/leanback/res/layout/dialog_history.xml b/app/src/leanback/res/layout/dialog_history.xml index c1f5cab81b..8cf65af91d 100644 --- a/app/src/leanback/res/layout/dialog_history.xml +++ b/app/src/leanback/res/layout/dialog_history.xml @@ -1,13 +1,36 @@ - + android:orientation="vertical" + android:padding="24dp"> + + + + + + diff --git a/app/src/leanback/res/layout/dialog_home_button.xml b/app/src/leanback/res/layout/dialog_home_button.xml index e4577bdd80..54fbc3ffe0 100644 --- a/app/src/leanback/res/layout/dialog_home_button.xml +++ b/app/src/leanback/res/layout/dialog_home_button.xml @@ -17,7 +17,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_home_buttons" - android:textColor="@color/white" + android:textColor="?attr/colorOnSurface" android:textSize="22sp" /> diff --git a/app/src/leanback/res/layout/dialog_home_menu.xml b/app/src/leanback/res/layout/dialog_home_menu.xml index 7295c969f9..f0fe75455d 100644 --- a/app/src/leanback/res/layout/dialog_home_menu.xml +++ b/app/src/leanback/res/layout/dialog_home_menu.xml @@ -13,5 +13,5 @@ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" app:maxHeight="352dp" app:spanCount="3" - tools:itemCount="9" + tools:itemCount="11" tools:listitem="@layout/adapter_home_menu" /> diff --git a/app/src/leanback/res/layout/dialog_offset.xml b/app/src/leanback/res/layout/dialog_offset.xml index 0d3b14750d..5a5705a7aa 100644 --- a/app/src/leanback/res/layout/dialog_offset.xml +++ b/app/src/leanback/res/layout/dialog_offset.xml @@ -23,7 +23,7 @@ android:layout_weight="1" android:text="@string/offset_setting" android:textAppearance="?attr/textAppearanceHeadlineSmall" - android:textColor="@color/white" /> + android:textColor="?attr/webhtvColorOnWallpaper" /> + android:textColor="?attr/webhtvColorOnWallpaper" /> @@ -65,7 +65,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?attr/textAppearanceBodyMedium" - android:textColor="@color/white" /> + android:textColor="?attr/webhtvColorOnWallpaper" /> @@ -111,7 +111,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="?attr/textAppearanceBodyMedium" - android:textColor="@color/white" /> + android:textColor="?attr/webhtvColorOnWallpaper" /> diff --git a/app/src/leanback/res/layout/dialog_pass.xml b/app/src/leanback/res/layout/dialog_pass.xml index 093baaf0b8..41b1b28a25 100644 --- a/app/src/leanback/res/layout/dialog_pass.xml +++ b/app/src/leanback/res/layout/dialog_pass.xml @@ -30,7 +30,7 @@ android:gravity="center" android:singleLine="true" android:text="@string/dialog_positive" - android:textColor="@color/white" + android:textColor="?attr/colorOnSurface" android:textSize="14sp" /> \ No newline at end of file diff --git a/app/src/leanback/res/layout/dialog_player_osd.xml b/app/src/leanback/res/layout/dialog_player_osd.xml index 6e259a2290..11c618c08d 100644 --- a/app/src/leanback/res/layout/dialog_player_osd.xml +++ b/app/src/leanback/res/layout/dialog_player_osd.xml @@ -19,7 +19,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/player_osd" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="18sp" android:textStyle="bold" /> diff --git a/app/src/leanback/res/layout/dialog_quick_search.xml b/app/src/leanback/res/layout/dialog_quick_search.xml index 577e0253af..4b47ec0cc2 100644 --- a/app/src/leanback/res/layout/dialog_quick_search.xml +++ b/app/src/leanback/res/layout/dialog_quick_search.xml @@ -10,7 +10,8 @@ android:clipToPadding="false" android:orientation="vertical" android:paddingStart="16dp" - android:paddingEnd="16dp"> + android:paddingEnd="16dp" + android:theme="@style/ThemeOverlay.WebHTV.GlassFocusRings"> @@ -37,7 +38,7 @@ android:focusableInTouchMode="true" android:gravity="center" android:text="@string/search_loading" - android:textColor="@color/white_80" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" /> @@ -52,7 +52,7 @@ + + android:paddingStart="20dp" + android:paddingTop="16dp" + android:paddingEnd="20dp" + android:paddingBottom="16dp"> + + + + + + + + @@ -30,8 +70,8 @@ android:paddingStart="16dp" android:paddingEnd="16dp" android:singleLine="true" - android:textColor="@color/white" - android:textColorHint="@color/text" + android:textColor="?attr/colorOnSurface" + android:textColorHint="?attr/colorOnSurfaceVariant" android:textSize="16sp" app:drawableStartCompat="@drawable/ic_site_search" /> @@ -48,7 +88,7 @@ android:id="@+id/groupScroll" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="6dp" + android:layout_marginTop="14dp" android:clipToPadding="false" android:overScrollMode="never" android:scrollbars="none" @@ -66,40 +106,38 @@ android:id="@+id/groupHint" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="10dp" + android:layout_marginTop="8dp" android:accessibilityLiveRegion="polite" android:importantForAccessibility="yes" android:text="@string/site_group_sort_tv_hint" - android:textColor="@color/text" - android:textSize="14sp" + android:textColor="?attr/colorOnSurfaceVariant" + android:textSize="13sp" android:visibility="gone" /> + app:tint="?attr/colorOnSurfaceVariant" /> + app:tint="?attr/colorOnSurfaceVariant" /> + app:tint="?attr/colorOnSurfaceVariant" /> + app:tint="?attr/colorOnSurfaceVariant" /> + app:tint="?attr/colorOnSurfaceVariant" /> diff --git a/app/src/leanback/res/layout/dialog_timer.xml b/app/src/leanback/res/layout/dialog_timer.xml index 48750c4eed..909a4b4ae7 100644 --- a/app/src/leanback/res/layout/dialog_timer.xml +++ b/app/src/leanback/res/layout/dialog_timer.xml @@ -20,7 +20,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/play_timer" - android:textColor="@color/white_70" + android:textColor="@color/webhtv_color_player_control_muted" android:textSize="16sp" /> @@ -98,7 +98,7 @@ android:layout_height="wrap_content" android:layout_marginTop="24dp" android:layout_marginBottom="24dp" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="48sp" tools:text="5:00" /> @@ -110,9 +110,9 @@ android:layout_marginBottom="8dp" android:singleLine="true" android:text="@string/timer_delay" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" app:backgroundTint="@color/selector_dialog_glass_button_tint" - app:rippleColor="#33FFFFFF" /> + app:rippleColor="@color/webhtv_color_overlay_light" /> + android:textColor="?attr/webhtvColorOnWallpaper" + app:strokeColor="?attr/colorOutline" /> diff --git a/app/src/leanback/res/layout/dialog_title.xml b/app/src/leanback/res/layout/dialog_title.xml index 1bffd8c47e..14edfbed3c 100644 --- a/app/src/leanback/res/layout/dialog_title.xml +++ b/app/src/leanback/res/layout/dialog_title.xml @@ -22,7 +22,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/dialog_select_title" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> diff --git a/app/src/leanback/res/layout/dialog_track.xml b/app/src/leanback/res/layout/dialog_track.xml index 3103d7c8f9..55f7682db0 100644 --- a/app/src/leanback/res/layout/dialog_track.xml +++ b/app/src/leanback/res/layout/dialog_track.xml @@ -21,7 +21,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="選擇字幕" /> @@ -34,7 +34,7 @@ android:gravity="center" android:paddingHorizontal="12dp" android:text="@string/play_track_secondary_subtitle" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="13sp" android:visibility="gone" tools:visibility="visible" /> @@ -142,7 +142,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/subtitle_realtime_source_language" - android:textColor="@color/white_90" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -163,7 +163,7 @@ android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingBottom="12dp" - android:textColor="@color/white_70" + android:textColor="@color/webhtv_color_player_control_muted" android:textSize="13sp" android:visibility="gone" tools:text="AI 正在翻譯:1/3 分塊,約剩 20s" diff --git a/app/src/leanback/res/layout/dialog_ua.xml b/app/src/leanback/res/layout/dialog_ua.xml index cd41ba0e58..48abba304b 100644 --- a/app/src/leanback/res/layout/dialog_ua.xml +++ b/app/src/leanback/res/layout/dialog_ua.xml @@ -23,7 +23,7 @@ android:maxLines="3" android:paddingStart="4dp" android:paddingEnd="4dp" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="18sp" tools:text="@string/push_info" /> @@ -40,8 +40,8 @@ android:inputType="text" android:nextFocusDown="@id/positive" android:singleLine="true" - android:textColor="#202124" - android:textColorHint="#8A8F98" + android:textColor="?attr/colorOnSurface" + android:textColorHint="?attr/colorOnSurfaceVariant" android:textSize="18sp" /> + app:backgroundTint="@color/dialog_primary_button_bg" + app:strokeWidth="@dimen/webhtv_focus_ring_width" + app:strokeColor="@color/focus_ring_primary_container" /> + app:strokeColor="?attr/colorOutline" /> diff --git a/app/src/leanback/res/layout/dialog_update.xml b/app/src/leanback/res/layout/dialog_update.xml index f6b527eecb..5e3b24f885 100644 --- a/app/src/leanback/res/layout/dialog_update.xml +++ b/app/src/leanback/res/layout/dialog_update.xml @@ -23,7 +23,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/update_title" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="22sp" android:textStyle="bold" /> @@ -37,7 +37,7 @@ android:nextFocusDown="@id/stableItem" android:padding="10dp" android:src="@drawable/ic_dialog_close" - app:tint="#5F6368" /> + app:tint="?attr/colorOnSurfaceVariant" /> @@ -47,7 +47,7 @@ android:layout_height="wrap_content" android:layout_marginTop="6dp" android:text="@string/update_hint" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="14sp" /> @@ -88,8 +89,9 @@ android:id="@+id/stableName" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:duplicateParentState="true" android:text="@string/update_channel_stable" - android:textColor="#202124" + android:textColor="@color/update_version_item_title" android:textSize="18sp" android:textStyle="bold" /> @@ -104,7 +106,7 @@ android:paddingTop="5dp" android:paddingEnd="9dp" android:paddingBottom="5dp" - android:textColor="#174EA6" + android:textColor="?attr/colorPrimary" android:textSize="13sp" tools:text="可更新" /> @@ -114,8 +116,9 @@ android:layout_height="wrap_content" android:layout_marginStart="12dp" android:layout_weight="1" + android:duplicateParentState="true" android:gravity="end" - android:textColor="#174EA6" + android:textColor="@color/update_version_item_accent" android:textSize="14sp" tools:text="收起" /> @@ -126,8 +129,9 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" + android:duplicateParentState="true" android:maxLines="2" - android:textColor="#5F6368" + android:textColor="@color/update_version_item_meta" android:textSize="14sp" tools:text="5.6.0-202608101200" /> @@ -136,6 +140,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="14dp" + android:duplicateParentState="true" android:orientation="vertical" tools:visibility="visible"> @@ -143,8 +148,9 @@ android:id="@+id/stableDesc" android:layout_width="match_parent" android:layout_height="wrap_content" + android:duplicateParentState="true" android:lineSpacingExtra="3dp" - android:textColor="#303134" + android:textColor="@color/update_version_item_title" android:textSize="16sp" tools:text="### 更新内容 - 稳定版更新说明" /> @@ -163,7 +169,9 @@ android:textColor="@color/dialog_primary_button_text" android:textSize="15sp" app:backgroundTint="@color/dialog_primary_button_bg" - app:cornerRadius="8dp" /> + app:cornerRadius="8dp" + app:strokeColor="@color/focus_ring_primary_container" + app:strokeWidth="@dimen/webhtv_focus_ring_width" /> @@ -185,6 +193,7 @@ @@ -192,8 +201,9 @@ android:id="@+id/betaName" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:duplicateParentState="true" android:text="@string/update_channel_beta" - android:textColor="#202124" + android:textColor="@color/update_version_item_title" android:textSize="18sp" android:textStyle="bold" /> @@ -208,7 +218,7 @@ android:paddingTop="5dp" android:paddingEnd="9dp" android:paddingBottom="5dp" - android:textColor="#174EA6" + android:textColor="?attr/colorPrimary" android:textSize="13sp" tools:text="可更新" /> @@ -218,8 +228,9 @@ android:layout_height="wrap_content" android:layout_marginStart="12dp" android:layout_weight="1" + android:duplicateParentState="true" android:gravity="end" - android:textColor="#174EA6" + android:textColor="@color/update_version_item_accent" android:textSize="14sp" tools:text="展开" /> @@ -230,8 +241,9 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" + android:duplicateParentState="true" android:maxLines="2" - android:textColor="#5F6368" + android:textColor="@color/update_version_item_meta" android:textSize="14sp" tools:text="5.6.0-beta-202608101200" /> @@ -240,6 +252,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="14dp" + android:duplicateParentState="true" android:orientation="vertical" tools:visibility="gone"> @@ -247,8 +260,9 @@ android:id="@+id/betaDesc" android:layout_width="match_parent" android:layout_height="wrap_content" + android:duplicateParentState="true" android:lineSpacingExtra="3dp" - android:textColor="#303134" + android:textColor="@color/update_version_item_title" android:textSize="16sp" tools:text="### 更新内容 - 测试版更新说明" /> @@ -267,7 +281,9 @@ android:textColor="@color/dialog_primary_button_text" android:textSize="15sp" app:backgroundTint="@color/dialog_primary_button_bg" - app:cornerRadius="8dp" /> + app:cornerRadius="8dp" + app:strokeColor="@color/focus_ring_primary_container" + app:strokeWidth="@dimen/webhtv_focus_ring_width" /> @@ -290,7 +306,7 @@ android:id="@+id/progressText" android:layout_width="match_parent" android:layout_height="wrap_content" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="14sp" tools:text="正在下载 46% · 3.2 MB/s · 已用 00:12" /> @@ -320,6 +336,8 @@ android:textColor="@color/dialog_primary_button_text" android:textSize="15sp" app:backgroundTint="@color/dialog_primary_button_bg" - app:cornerRadius="8dp" /> + app:cornerRadius="8dp" + app:strokeColor="@color/focus_ring_primary_container" + app:strokeWidth="@dimen/webhtv_focus_ring_width" /> 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..227743c5da 100644 --- a/app/src/leanback/res/layout/view_control_live_action.xml +++ b/app/src/leanback/res/layout/view_control_live_action.xml @@ -6,7 +6,9 @@ android:fillViewport="true" android:scrollbars="none"> + + + android:textColor="?attr/webhtvColorOnWallpaper" + android:textSize="40sp" /> - \ No newline at end of file + + + + + diff --git a/app/src/leanback/res/layout/view_progress.xml b/app/src/leanback/res/layout/view_progress.xml index b75a215528..3c50d77a2a 100644 --- a/app/src/leanback/res/layout/view_progress.xml +++ b/app/src/leanback/res/layout/view_progress.xml @@ -5,12 +5,17 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + app:indicatorColor="?attr/webhtvColorOnWallpaper" /> + + + - - - - diff --git a/app/src/leanback/res/mipmap-hdpi/ic_banner.png b/app/src/leanback/res/mipmap-hdpi/ic_banner.png new file mode 100644 index 0000000000..a539020936 Binary files /dev/null and b/app/src/leanback/res/mipmap-hdpi/ic_banner.png differ diff --git a/app/src/leanback/res/mipmap-mdpi/ic_banner.png b/app/src/leanback/res/mipmap-mdpi/ic_banner.png new file mode 100644 index 0000000000..35b6959e02 Binary files /dev/null and b/app/src/leanback/res/mipmap-mdpi/ic_banner.png differ diff --git a/app/src/leanback/res/mipmap-xhdpi/ic_banner.png b/app/src/leanback/res/mipmap-xhdpi/ic_banner.png new file mode 100644 index 0000000000..5924b902b6 Binary files /dev/null and b/app/src/leanback/res/mipmap-xhdpi/ic_banner.png differ diff --git a/app/src/leanback/res/mipmap-xxhdpi/ic_banner.png b/app/src/leanback/res/mipmap-xxhdpi/ic_banner.png new file mode 100644 index 0000000000..a2e51d1107 Binary files /dev/null and b/app/src/leanback/res/mipmap-xxhdpi/ic_banner.png differ diff --git a/app/src/leanback/res/mipmap-xxxhdpi/ic_banner.png b/app/src/leanback/res/mipmap-xxxhdpi/ic_banner.png new file mode 100644 index 0000000000..1de2ee5dfc Binary files /dev/null and b/app/src/leanback/res/mipmap-xxxhdpi/ic_banner.png differ diff --git a/app/src/leanback/res/values-zh-rCN/strings.xml b/app/src/leanback/res/values-zh-rCN/strings.xml index f0acf06eb8..b65ba7f22c 100644 --- a/app/src/leanback/res/values-zh-rCN/strings.xml +++ b/app/src/leanback/res/values-zh-rCN/strings.xml @@ -47,4 +47,5 @@ 删除全部记录? 观看记录将会全部删除。 + 已重置筛选 diff --git a/app/src/leanback/res/values-zh-rTW/strings.xml b/app/src/leanback/res/values-zh-rTW/strings.xml index 3b431bac0b..6bc0a80ad7 100644 --- a/app/src/leanback/res/values-zh-rTW/strings.xml +++ b/app/src/leanback/res/values-zh-rTW/strings.xml @@ -43,4 +43,5 @@ 1 小時 延長 5 分鐘 取消定時器 + 已重置篩選 diff --git a/app/src/leanback/res/values/strings.xml b/app/src/leanback/res/values/strings.xml index cde8c2c6d1..f3f44c4062 100644 --- a/app/src/leanback/res/values/strings.xml +++ b/app/src/leanback/res/values/strings.xml @@ -47,4 +47,5 @@ Delete all records? All history records will be removed. + Filters cleared diff --git a/app/src/leanback/res/values/styles.xml b/app/src/leanback/res/values/styles.xml index 217c08c6e7..1e32296eb7 100644 --- a/app/src/leanback/res/values/styles.xml +++ b/app/src/leanback/res/values/styles.xml @@ -1,8 +1,24 @@ - + + + @@ -42,7 +84,7 @@ true true @id/timeBar - @color/text + @color/player_control_text 14sp @@ -51,7 +93,7 @@ wrap_content 13dp 7dp - @color/white_70 + @color/webhtv_color_player_control_muted 13sp bold @@ -75,7 +117,7 @@ 1 ?attr/selectableItemBackgroundBorderless center - @color/white + @color/webhtv_color_player_control 12sp true @@ -87,7 +129,7 @@ true 8dp center - @color/white + @color/webhtv_color_player_control - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d04325f4d2..e0d9917a78 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,36 +22,13 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/app/src/main/assets/VodPlus/EnvFiles/T4Proxy.js b/app/src/main/assets/VodPlus/EnvFiles/T4Proxy.js new file mode 100644 index 0000000000..cceff0f862 --- /dev/null +++ b/app/src/main/assets/VodPlus/EnvFiles/T4Proxy.js @@ -0,0 +1,450 @@ +#!/usr/bin/env node +// ============================================================================ +// WebHTV 实验室 · T4 代理服务器(Node 版,参考实现) +// ============================================================================ +// +// 用途:把壳子当前加载的所有站源(jar / js / py / php 以及 t1 / t4 远端接口) +// 统一暴露成苹果CMS V10(T4)接口,供 T4 客户端直接订阅。 +// +// 实现方式:调用 App 内置 /vod/api(默认 http://127.0.0.1:9978/vod/api)。 +// 转换只在 App 端维护,Node 入口不再重复走旧 /spider 的 T3-only/无分页路径。 +// Node 运行时仅用于保留实验室入口;不安装 Node 也能直接使用 App 内置网关。 +// +// 与 App 内置 /vod/api 的关系: +// - App 内置 /vod/api(见 app/src/main/java/.../server/process/VodApi.java) +// 在同一进程里直接调用爬虫,不需要安装任何实验室包,是首选方式; +// - 本脚本是不装 Node 也能读的参考实现,也是「实验室 → Node.js → T4代理服务器」 +// 命令实际执行的脚本,端口默认 10998。 +// +// 用法: +// node T4Proxy.js [--port 10998] [--host http://127.0.0.1:9978] [--key 站点key] +// +// 接口: +// 配置接口: /?ac=config +// 首页接口: /站源key +// 分类接口: /站源key?t=分类ID&pg=页码 +// 详情接口: /站源key?ids=视频ID +// 搜索接口: /站源key?wd=关键词&pg=页码 +// 播放接口: /站源key?flag=播放源&id=播放ID +// 筛选参数: area=地区&year=年份&type=类型&class=剧情&lang=语言 +// +// 提示:ac 参数可省略,系统会根据其他参数自动识别接口类型。 +// ============================================================================ + +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); +const os = require('os'); + +// ============================================================================ +// 配置常量 +// ============================================================================ +const DEFAULT_SPIDER_API_HOST = 'http://127.0.0.1:9978'; +const DEFAULT_PORT = 10998; +const REQUEST_TIMEOUT = 30000; // 毫秒 +const FILTER_FIELDS = ['area', 'year', 'type', 'class', 'lang']; + +// ============================================================================ +// Spider API 客户端 +// ============================================================================ +class SpiderApiClient { + constructor(host, timeout = REQUEST_TIMEOUT) { + this.host = host.replace(/\/$/, ''); + this.timeout = timeout; + } + + async configAction() { + const data = await this.post('/vod/api', { ac: 'config' }); + return Array.isArray(data.sites) ? { code: 0, data } : data; + } + + async execute(key, method, params = {}) { + const input = { key, ...params }; + if (method === 'categoryContent') input.t = params.tid; + if (method === 'playerContent') input.play = params.id; + const data = await this.post('/vod/api', input); + return data.code === -1 ? data : { code: 0, data }; + } + + homeContent(key, filter = true) { + return this.execute(key, 'homeContent', { filter }); + } + + categoryContent(key, tid, page = 1, filter = true, extend = {}) { + return this.execute(key, 'categoryContent', { tid, pg: page, filter, extend }); + } + + detailContent(key, ids) { + return this.execute(key, 'detailContent', { ids: Array.isArray(ids) ? ids : [ids] }); + } + + searchContent(key, keyword, page = 1, quick = false) { + return this.execute(key, 'searchContent', { wd: keyword, pg: page, quick }); + } + + playerContent(key, flag, id, flags = []) { + return this.execute(key, 'playerContent', { flag, id, flags }); + } + + post(endpoint, data) { + return new Promise((resolve) => { + let parsedUrl; + try { + parsedUrl = new URL(this.host + endpoint); + } catch { + resolve({ code: -1, msg: `无效的 Spider API 地址: ${this.host}` }); + return; + } + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + resolve({ code: -1, msg: '仅支持 HTTP/HTTPS 网关地址' }); + return; + } + const isHttps = parsedUrl.protocol === 'https:'; + const client = isHttps ? https : http; + const postData = JSON.stringify(data); + const options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (isHttps ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(postData) + }, + timeout: this.timeout + }; + + const req = client.request(options, (res) => { + let body = ''; + res.setEncoding('utf8'); + let bytes = 0; + res.on('data', (chunk) => { + bytes += Buffer.byteLength(chunk); + if (bytes > 16 * 1024 * 1024) req.destroy(new Error('Response too large')); + else body += chunk; + }); + res.on('error', () => resolve({ code: -1, msg: 'Response interrupted' })); + res.on('aborted', () => resolve({ code: -1, msg: 'Response interrupted' })); + res.on('end', () => { + if (res.statusCode < 200 || res.statusCode >= 300) { + resolve({ code: -1, msg: `Gateway HTTP ${res.statusCode}` }); + return; + } + try { + const data = JSON.parse(body); + resolve(data && typeof data === 'object' && !Array.isArray(data) ? data : { code: -1, msg: 'Invalid JSON object' }); + } catch { + resolve({ code: -1, msg: 'Invalid JSON response' }); + } + }); + }); + + req.on('error', (e) => resolve({ code: -1, msg: `Request failed: ${e.message}` })); + req.on('timeout', () => { + req.destroy(); + resolve({ code: -1, msg: 'Request timeout' }); + }); + + req.write(postData); + req.end(); + }); + } +} + +// ============================================================================ +// Type 4 代理 +// ============================================================================ +class SpiderApiProxy { + constructor(client, defaultKey = '') { + this.client = client; + this.defaultKey = defaultKey; + } + + async handle(req, res) { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, HEAD, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.statusCode = 204; + res.end(); + return; + } + + if (!['GET', 'POST', 'HEAD'].includes(req.method)) { + res.statusCode = 405; + res.end(JSON.stringify({ code: -1, msg: 'Unsupported HTTP method' })); + return; + } + try { + res.end(JSON.stringify(this.externalize(await this.dispatch(req), req), null, 2)); + } catch (e) { + res.end(JSON.stringify({ code: -1, msg: e.message })); + } + } + + async dispatch(req) { + const input = await this.getInput(req); + + let key = this.getKeyFromPath(req); + if (!key) key = input.key || this.defaultKey; + + // 兼容处理:play 参数作为 id 的别名 + if (input.play && !input.id) input.id = input.play; + + const ac = this.route(input); + if (ac === 'config' || (!key && Object.keys(input).length === 0)) return await this.getProxiedConfig(req); + + if (!key) return { code: -1, msg: '缺少 key 参数' }; + + const pg = Math.max(1, parseInt(input.pg, 10) || 1); + let apiResult = null; + + switch (ac) { + case 'search': + apiResult = await this.client.searchContent(key, input.wd, pg, ['1', 'true', 'yes'].includes(String(input.quick).toLowerCase())); + break; + + case 'detail': { + const ids = Array.isArray(input.ids) ? input.ids : String(input.ids || '').split(',').filter(Boolean); + if (!ids.length) return { code: -1, msg: '缺少 ids 参数' }; + apiResult = await this.client.detailContent(key, ids); + break; + } + + case 'play': { + if (!input.flag || !input.id) return { code: -1, msg: '缺少 flag 或 id 参数' }; + const flags = input.flags ? String(input.flags).split(',') : []; + apiResult = await this.client.playerContent(key, input.flag, input.id, flags); + // 播放接口直接返回原始数据,不做苹果CMS 信封包装 + if (apiResult && apiResult.code === 0) return apiResult.data || apiResult; + return { code: -1, msg: (apiResult && apiResult.msg) || '请求失败' }; + } + + case 'list': + default: { + const t = input.t || ''; + const filterValue = input.filter === undefined ? input.f : input.filter; + const filter = filterValue === undefined || ['1', 'true', 'yes'].includes(String(filterValue).toLowerCase()); + if (!t) { + apiResult = await this.client.homeContent(key, filter); + } else { + const extend = Object.create(null); + const merge = (value) => { + if (value && typeof value === 'object' && !Array.isArray(value)) Object.assign(extend, value); + }; + try { if (input.ext) merge(JSON.parse(Buffer.from(String(input.ext), 'base64').toString('utf8'))); } catch { } + try { merge(typeof input.extend === 'string' ? JSON.parse(input.extend) : input.extend); } catch { } + FILTER_FIELDS.forEach((field) => { + if (input[field]) extend[field] = input[field]; + }); + apiResult = await this.client.categoryContent(key, t, pg, filter, extend); + } + break; + } + } + + if (apiResult && apiResult.code === 0) return this.formatMacCmsResponse(apiResult.data, pg); + return { code: -1, msg: (apiResult && apiResult.msg) || '请求失败' }; + } + + /** ac=detail 也用于分类;保持与 App T4 客户端一致。 */ + route(input) { + if (input.ac === 'config' || input.ac === 'site') return 'config'; + if (input.wd) return 'search'; + if (input.ids !== undefined) return 'detail'; + if (input.ac === 'play' || input.play || (input.flag && input.id)) return 'play'; + return 'list'; + } + + getKeyFromPath(req) { + const pathParts = req.url.split('?')[0].split('/').filter((p) => p); + if (pathParts.length === 0) return ''; + try { + return decodeURIComponent(pathParts[0]); + } catch { + return pathParts[0]; + } + } + + formatMacCmsResponse(data, currentPage = 1) { + const list = Array.isArray(data && data.list) ? data.list : []; + const listCount = list.length; + const page = data && data.page ? parseInt(data.page) : currentPage; + const limit = data && data.limit ? parseInt(data.limit) : (listCount > 0 ? listCount : 20); + // 与 App 内置 /vod/api 一致:站源没给总页数时按 9999 处理 + const pagecount = data && data.pagecount ? parseInt(data.pagecount) : (listCount ? 9999 : page); + const total = data && data.total !== undefined ? parseInt(data.total) : pagecount * limit; + + const response = { page, pagecount, limit, total, list }; + if (data && data.class) response.class = data.class; + if (data && data.filters) response.filters = data.filters; + return response; + } + + async getProxiedConfig(req) { + const status = await this.client.configAction('status'); + if (status.code !== 0 || !status.data || !status.data.sites) { + return { code: -1, msg: '获取配置失败:请运行含 /vod/api 的 WebHTV,并加载有效点播源' }; + } + + const baseUrl = this.getBaseUrl(req); + const sites = status.data.sites.map((site) => ({ + key: site.key, + name: site.name, + api: `${baseUrl}/?key=${encodeURIComponent(site.key)}`, + type: '4', + searchable: site.searchable ?? 1, + quickSearch: site.quickSearch ?? 1, + filterable: site.filterable ?? 1 + })); + + return { + spider: '', + wallpaper: '', + warningText: '资源来自网络,仅供学习使用', + sites, + doh: status.data.doh || [], + rules: status.data.rules || [], + lives: [] + }; + } + + getBaseUrl(req) { + const protocol = req.connection && req.connection.encrypted ? 'https' : 'http'; + return `${protocol}://${req.headers.host || 'localhost'}`; + } + + // Node 和 App 是两个端口。客户端仍需能访问 App 的媒体代理端口。 + externalize(data, req) { + let upstream; + let publicHost; + try { + upstream = new URL(this.client.host); + publicHost = new URL(this.getBaseUrl(req)).hostname; + } catch { + throw new Error('无效的网关或 Host 地址'); + } + if (!['127.0.0.1', 'localhost', '[::1]'].includes(upstream.hostname)) return data; + const rewrite = (value) => { + if (typeof value === 'string') { + try { + const url = new URL(value); + if (['http:', 'https:'].includes(url.protocol) + && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) + && (url.port || '80') === (upstream.port || '80')) { + url.hostname = publicHost; + return url.toString(); + } + } catch { } + return value; + } + if (Array.isArray(value)) return value.map(rewrite); + if (value && typeof value === 'object') { + for (const field of ['url', 'v', 'values', 'server']) if (value[field] !== undefined) value[field] = rewrite(value[field]); + } + return value; + }; + for (const field of ['url', 'urls', 'playUrl', 'subs', 'drm']) if (data[field] !== undefined) data[field] = rewrite(data[field]); + for (const vod of data.list || []) if (vod && vod.vod_pic) vod.vod_pic = rewrite(vod.vod_pic); + return data; + } + + async getInput(req) { + const input = Object.create(null); + const queryIndex = req.url.indexOf('?'); + if (queryIndex !== -1) { + for (const [key, value] of new URLSearchParams(req.url.slice(queryIndex + 1))) { + input[key] = value; + } + } + if (req.method === 'POST') { + const body = await this.readBody(req); + let parsed; + try { parsed = JSON.parse(body); } catch { throw new Error('POST body 必须是有效 JSON'); } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('POST body 必须是 JSON 对象'); + Object.assign(input, parsed); + } + return input; + } + + readBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + let bytes = 0; + req.on('data', (chunk) => { + bytes += chunk.length; + if (bytes > 1024 * 1024) reject(new Error('Request too large')); + else body += chunk; + }); + req.on('end', () => resolve(body)); + req.on('error', reject); + req.on('aborted', () => reject(new Error('Request interrupted'))); + }); + } +} + +// ============================================================================ +// 主入口 +// ============================================================================ +function parseArgs(argv) { + const args = { port: DEFAULT_PORT, host: DEFAULT_SPIDER_API_HOST, key: '' }; + for (let i = 0; i < argv.length; i++) { + const value = argv[i + 1]; + if ((argv[i] === '--port' || argv[i] === '-p') && value) args.port = parseInt(value, 10); + else if ((argv[i] === '--host' || argv[i] === '-H') && value) args.host = value; + else if ((argv[i] === '--key' || argv[i] === '-k') && value) args.key = value; + } + return args; +} + +function lanAddresses() { + const result = []; + const interfaces = os.networkInterfaces(); + for (const name of Object.keys(interfaces)) { + for (const net of interfaces[name]) { + if (net.family === 'IPv4' && !net.internal) result.push(net.address); + } + } + return result; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const client = new SpiderApiClient(args.host, REQUEST_TIMEOUT); + const proxy = new SpiderApiProxy(client, args.key); + const server = http.createServer((req, res) => proxy.handle(req, res)); + + server.listen(args.port, '0.0.0.0', () => { + console.log('Spider API Type 4 代理服务器已启动'); + console.log(`监听地址: http://0.0.0.0:${args.port}`); + lanAddresses().forEach((ip) => console.log(` http://${ip}:${args.port}`)); + console.log(`Spider API: ${args.host}`); + console.log(''); + console.log('使用示例:'); + console.log(` 配置接口: http://localhost:${args.port}?ac=config`); + console.log(` 首页接口: http://localhost:${args.port}/站源key`); + console.log(` 分类接口: http://localhost:${args.port}/站源key?t=分类ID&pg=页码`); + console.log(` 详情接口: http://localhost:${args.port}/站源key?ids=视频ID`); + console.log(` 搜索接口: http://localhost:${args.port}/站源key?wd=关键词&pg=页码`); + console.log(` 播放接口: http://localhost:${args.port}/站源key?flag=播放源&id=播放ID`); + console.log(''); + console.log('提示: ac参数可省略,系统会根据其他参数自动识别接口类型'); + console.log('筛选参数: area=地区&year=年份&type=类型&class=剧情&lang=语言'); + console.log('App 内置同能力接口(无需安装本脚本): /vod/api'); + }); + + server.on('error', (e) => { + if (e.code === 'EADDRINUSE') { + console.error(`错误: 端口 ${args.port} 已被占用,请强制结束app或更换端口后重新运行`); + } else { + console.error('服务器错误:', e.message); + } + process.exit(1); + }); +} + +if (require.main === module) main(); + +module.exports = { SpiderApiClient, SpiderApiProxy, parseArgs }; diff --git a/app/src/main/assets/css/ui.css b/app/src/main/assets/css/ui.css index a12f04498c..8b730f3050 100644 --- a/app/src/main/assets/css/ui.css +++ b/app/src/main/assets/css/ui.css @@ -1,55 +1,121 @@ :root { - --md-primary: #006C4C; - --md-on-primary: #FFFFFF; - --md-primary-container: #89F7C6; - --md-on-primary-container: #00210F; - --md-secondary-container: #C8E6D5; - --md-on-secondary-container: #082015; - --md-error: #BA1A1A; - --md-on-error: #FFFFFF; - --md-error-container: #FFDAD6; - --md-on-error-container: #410002; - --md-surface: #FFFFFF; - --md-on-surface: #15140E; - --md-surface-variant: #E3E3DC; - --md-on-surface-variant: #343430; - --md-outline: #575752; - --md-outline-variant: #C7C7C0; - --md-surface-container: #FFFFFF; - --md-surface-container-high: #F0EFE9; - --md-inverse-surface: #2A2A25; - --md-inverse-on-surface: #F2F1EB; - --md-scrim: rgba(0,0,0,0.32); - --md-shadow: rgba(0,0,0,0.20); + --webhtv-primary: #0B57D0; + --webhtv-on-primary: #FFFFFF; + --webhtv-primary-container: #D3E3FD; + --webhtv-on-primary-container: #041E49; + --webhtv-secondary-container: #DFE2E6; + --webhtv-on-secondary-container: #191C1E; + --webhtv-surface: #F8FAFD; + --webhtv-surface-variant: #E3E3DC; + --webhtv-surface-container: #ECEEF4; + --webhtv-surface-container-high: #E7E8EF; + --webhtv-on-surface: #1A1C1E; + --webhtv-on-surface-variant: #44474F; + --webhtv-outline: #74777F; + --webhtv-outline-variant: #C4C6CF; + --webhtv-error: #B3261E; + --webhtv-on-error: #FFFFFF; + --webhtv-error-container: #F9DEDC; + --webhtv-on-error-container: #410E0B; + --webhtv-success: #146C2E; + --webhtv-warning: #7A5900; + --webhtv-warning-container: #FFDF9A; + --webhtv-on-warning-container: #261A00; + --webhtv-focus: #0B57D0; + --webhtv-inverse-surface: #2F3033; + --webhtv-inverse-on-surface: #F1F0F4; + --webhtv-scrim: rgba(0,0,0,0.32); + --webhtv-shadow: rgba(0,0,0,0.20); + --webhtv-shape-small: 8px; + --webhtv-shape-medium: 12px; + --webhtv-shape-large: 16px; + --webhtv-space-1: 4px; + --webhtv-space-2: 8px; + --webhtv-space-3: 12px; + --webhtv-space-4: 16px; + + --md-primary: var(--webhtv-primary); + --md-on-primary: var(--webhtv-on-primary); + --md-primary-container: var(--webhtv-primary-container); + --md-on-primary-container: var(--webhtv-on-primary-container); + --md-secondary-container: var(--webhtv-secondary-container); + --md-on-secondary-container: var(--webhtv-on-secondary-container); + --md-error: var(--webhtv-error); + --md-on-error: var(--webhtv-on-error); + --md-error-container: var(--webhtv-error-container); + --md-on-error-container: var(--webhtv-on-error-container); + --md-surface: var(--webhtv-surface); + --md-on-surface: var(--webhtv-on-surface); + --md-surface-variant: var(--webhtv-surface-variant); + --md-on-surface-variant: var(--webhtv-on-surface-variant); + --md-outline: var(--webhtv-outline); + --md-outline-variant: var(--webhtv-outline-variant); + --md-surface-container: var(--webhtv-surface-container); + --md-surface-container-high: var(--webhtv-surface-container-high); + --md-inverse-surface: var(--webhtv-inverse-surface); + --md-inverse-on-surface: var(--webhtv-inverse-on-surface); + --md-scrim: var(--webhtv-scrim); + --md-shadow: var(--webhtv-shadow); } @media (prefers-color-scheme: dark) { :root { - --md-primary: #6CDBA4; - --md-on-primary: #003824; - --md-primary-container: #005237; - --md-on-primary-container: #89F7C6; - --md-secondary-container: #354B40; - --md-on-secondary-container: #CFE9D8; - --md-error: #FFB4AB; - --md-on-error: #690005; - --md-error-container: #93000A; - --md-on-error-container: #FFDAD6; - --md-surface: #101510; - --md-on-surface: #DEE4DF; - --md-surface-variant: #414942; - --md-on-surface-variant: #C0C9BF; - --md-outline: #8B9389; - --md-outline-variant: #414942; - --md-surface-container: #1C211D; - --md-surface-container-high: #262B27; - --md-inverse-surface: #DEE4DF; - --md-inverse-on-surface: #181D19; - --md-scrim: rgba(0,0,0,0.5); - --md-shadow: rgba(0,0,0,0.5); + --webhtv-primary: #A8C7FA; + --webhtv-on-primary: #062E6F; + --webhtv-primary-container: #0842A0; + --webhtv-on-primary-container: #D3E3FD; + --webhtv-secondary-container: #44474A; + --webhtv-on-secondary-container: #E2E2E6; + --webhtv-surface: #101418; + --webhtv-surface-variant: #414942; + --webhtv-surface-container: #1F2428; + --webhtv-surface-container-high: #2A2F34; + --webhtv-on-surface: #E2E2E9; + --webhtv-on-surface-variant: #C4C6D0; + --webhtv-outline: #8E9099; + --webhtv-outline-variant: #44474F; + --webhtv-error: #FFB4AB; + --webhtv-on-error: #690005; + --webhtv-error-container: #93000A; + --webhtv-on-error-container: #FFDAD6; + --webhtv-success: #8EDB9F; + --webhtv-warning: #F2C66D; + --webhtv-warning-container: #5C4200; + --webhtv-on-warning-container: #FFDF9A; + --webhtv-focus: #A8C7FA; + --webhtv-inverse-surface: #E2E2E9; + --webhtv-inverse-on-surface: #2F3033; + --webhtv-scrim: rgba(0,0,0,0.54); + --webhtv-shadow: rgba(0,0,0,0.50); + + --md-primary: var(--webhtv-primary); + --md-on-primary: var(--webhtv-on-primary); + --md-primary-container: var(--webhtv-primary-container); + --md-on-primary-container: var(--webhtv-on-primary-container); + --md-secondary-container: var(--webhtv-secondary-container); + --md-on-secondary-container: var(--webhtv-on-secondary-container); + --md-error: var(--webhtv-error); + --md-on-error: var(--webhtv-on-error); + --md-error-container: var(--webhtv-error-container); + --md-on-error-container: var(--webhtv-on-error-container); + --md-surface: var(--webhtv-surface); + --md-on-surface: var(--webhtv-on-surface); + --md-surface-variant: var(--webhtv-surface-variant); + --md-on-surface-variant: var(--webhtv-on-surface-variant); + --md-outline: var(--webhtv-outline); + --md-outline-variant: var(--webhtv-outline-variant); + --md-surface-container: var(--webhtv-surface-container); + --md-surface-container-high: var(--webhtv-surface-container-high); + --md-inverse-surface: var(--webhtv-inverse-surface); + --md-inverse-on-surface: var(--webhtv-inverse-on-surface); + --md-scrim: var(--webhtv-scrim); + --md-shadow: var(--webhtv-shadow); } } +:root[data-theme="light"] { color-scheme: light; } +:root[data-theme="dark"] { color-scheme: dark; } + /* ══ Reset ══ */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } @@ -62,14 +128,27 @@ html, body { } body.manage-page { - --md-primary: #1A62D0; - --md-on-primary: #FFFFFF; - --md-secondary-container: #D9E7FF; - --md-on-secondary-container: #0D2B57; - --md-surface: #F4F6FA; - --md-surface-container: #FFFFFF; - --md-surface-container-high: #F8FAFD; - --md-outline-variant: #DDE3EE; +--webhtv-primary: #2563EB; +--webhtv-on-primary: #FFFFFF; +--webhtv-secondary-container: #E8F0FE; +--webhtv-on-secondary-container: #174EA6; +--webhtv-surface: #F6F7FB; +--webhtv-surface-container: #FFFFFF; +--webhtv-surface-container-high: #F9FAFC; +--webhtv-outline: #8B95A7; +--webhtv-outline-variant: #E3E7EF; +--webhtv-success: #11845B; +--webhtv-warning: #B7791F; +--md-primary: var(--webhtv-primary); +--md-on-primary: var(--webhtv-on-primary); +--md-secondary-container: var(--webhtv-secondary-container); +--md-on-secondary-container: var(--webhtv-on-secondary-container); +--md-surface: var(--webhtv-surface); +--md-surface-container: var(--webhtv-surface-container); +--md-surface-container-high: var(--webhtv-surface-container-high); +--md-outline: var(--webhtv-outline); +--md-outline-variant: var(--webhtv-outline-variant); +--webhtv-soft-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); } body.manage-page.local-mode .remote-only { @@ -78,15 +157,28 @@ body.manage-page.local-mode .remote-only { @media (prefers-color-scheme: dark) { body.manage-page { - --md-primary: #8DB8FF; - --md-on-primary: #052B61; - --md-secondary-container: #1D3557; - --md-on-secondary-container: #D9E7FF; - --md-surface: #10151D; - --md-surface-container: #171D27; - --md-surface-container-high: #1F2632; - --md-outline-variant: #354052; - } + --webhtv-primary: #8AB4FF; + --webhtv-on-primary: #0B1B3A; + --webhtv-secondary-container: #24344E; + --webhtv-on-secondary-container: #D6E3FF; + --webhtv-surface: #101418; + --webhtv-surface-container: #171C22; + --webhtv-surface-container-high: #20262E; + --webhtv-outline: #8C96A8; + --webhtv-outline-variant: #303946; + --webhtv-success: #6DD6A2; + --webhtv-warning: #F2C66D; + --md-primary: var(--webhtv-primary); + --md-on-primary: var(--webhtv-on-primary); + --md-secondary-container: var(--webhtv-secondary-container); + --md-on-secondary-container: var(--webhtv-on-secondary-container); + --md-surface: var(--webhtv-surface); + --md-surface-container: var(--webhtv-surface-container); + --md-surface-container-high: var(--webhtv-surface-container-high); + --md-outline: var(--webhtv-outline); + --md-outline-variant: var(--webhtv-outline-variant); + --webhtv-soft-shadow: none; +} } /* ══ App Shell ══ */ @@ -206,8 +298,8 @@ body.manage-page.local-mode .remote-only { } .status-pill.warn { - background: #FFE08A; - color: #312300; + background: var(--webhtv-on-warning-container); + color: var(--webhtv-on-warning-container); } .md-btn-compact { @@ -223,9 +315,9 @@ body.manage-page.local-mode .remote-only { .keepalive-card { display: none; margin: 10px 16px 0; - border-color: #D19A00; - background: #FFF4CE; - color: #312300; + border-color: var(--webhtv-warning); + background: var(--webhtv-warning-container); + color: var(--webhtv-on-warning-container); } .md-btn:disabled, @@ -275,9 +367,9 @@ body.manage-page.local-mode .remote-only { @media (prefers-color-scheme: dark) { .status-pill.warn, .keepalive-card { - background: #4D3900; - color: #FFE08A; - border-color: #B88A00; + background: var(--webhtv-warning-container); + color: var(--webhtv-on-warning-container); + border-color: var(--webhtv-warning); } } @@ -1445,35 +1537,53 @@ body.manage-page.local-mode .remote-only { } body.manage-page { - --md-primary: #2563EB; - --md-on-primary: #FFFFFF; - --md-secondary-container: #E8F0FE; - --md-on-secondary-container: #174EA6; - --md-surface: #F6F7FB; - --md-surface-container: #FFFFFF; - --md-surface-container-high: #F9FAFC; - --md-outline: #8B95A7; - --md-outline-variant: #E3E7EF; - --manage-success: #11845B; - --manage-warn: #B7791F; - --manage-soft-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); +--webhtv-primary: #2563EB; +--webhtv-on-primary: #FFFFFF; +--webhtv-secondary-container: #E8F0FE; +--webhtv-on-secondary-container: #174EA6; +--webhtv-surface: #F6F7FB; +--webhtv-surface-container: #FFFFFF; +--webhtv-surface-container-high: #F9FAFC; +--webhtv-outline: #8B95A7; +--webhtv-outline-variant: #E3E7EF; +--webhtv-success: #11845B; +--webhtv-warning: #B7791F; +--md-primary: var(--webhtv-primary); +--md-on-primary: var(--webhtv-on-primary); +--md-secondary-container: var(--webhtv-secondary-container); +--md-on-secondary-container: var(--webhtv-on-secondary-container); +--md-surface: var(--webhtv-surface); +--md-surface-container: var(--webhtv-surface-container); +--md-surface-container-high: var(--webhtv-surface-container-high); +--md-outline: var(--webhtv-outline); +--md-outline-variant: var(--webhtv-outline-variant); +--webhtv-soft-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); } @media (prefers-color-scheme: dark) { body.manage-page { - --md-primary: #8AB4FF; - --md-on-primary: #0B1B3A; - --md-secondary-container: #24344E; - --md-on-secondary-container: #D6E3FF; - --md-surface: #101418; - --md-surface-container: #171C22; - --md-surface-container-high: #20262E; - --md-outline: #8C96A8; - --md-outline-variant: #303946; - --manage-success: #6DD6A2; - --manage-warn: #F2C66D; - --manage-soft-shadow: none; - } + --webhtv-primary: #8AB4FF; + --webhtv-on-primary: #0B1B3A; + --webhtv-secondary-container: #24344E; + --webhtv-on-secondary-container: #D6E3FF; + --webhtv-surface: #101418; + --webhtv-surface-container: #171C22; + --webhtv-surface-container-high: #20262E; + --webhtv-outline: #8C96A8; + --webhtv-outline-variant: #303946; + --webhtv-success: #6DD6A2; + --webhtv-warning: #F2C66D; + --md-primary: var(--webhtv-primary); + --md-on-primary: var(--webhtv-on-primary); + --md-secondary-container: var(--webhtv-secondary-container); + --md-on-secondary-container: var(--webhtv-on-secondary-container); + --md-surface: var(--webhtv-surface); + --md-surface-container: var(--webhtv-surface-container); + --md-surface-container-high: var(--webhtv-surface-container-high); + --md-outline: var(--webhtv-outline); + --md-outline-variant: var(--webhtv-outline-variant); + --webhtv-soft-shadow: none; +} } .manage-page .manage-top { @@ -1515,7 +1625,7 @@ body.manage-page { border: 1px solid var(--md-outline-variant); border-radius: 8px; background: var(--md-surface-container); - box-shadow: var(--manage-soft-shadow); + box-shadow: var(--webhtv-soft-shadow); } .workspace-panel { @@ -1541,8 +1651,8 @@ body.manage-page { padding: 10px 14px; border: 1px solid rgba(183, 121, 31, 0.36); border-radius: 8px; - background: #FFF7E6; - color: #5B3B00; + background: var(--webhtv-warning-container); + color: var(--webhtv-on-warning-container); } .keepalive-text { @@ -1566,23 +1676,23 @@ body.manage-page { height: 10px; margin-top: 6px; border-radius: 50%; - background: var(--manage-success); + background: var(--webhtv-success); box-shadow: 0 0 0 4px rgba(17, 132, 91, 0.16); flex: 0 0 auto; } .warn-dot { - background: var(--manage-warn); + background: var(--webhtv-warning); box-shadow: 0 0 0 4px rgba(183, 121, 31, 0.16); } .ok-dot { - background: var(--manage-success); + background: var(--webhtv-success); box-shadow: 0 0 0 4px rgba(17, 132, 91, 0.16); } .pending-dot { - background: var(--manage-warn); + background: var(--webhtv-warning); box-shadow: 0 0 0 4px rgba(183, 121, 31, 0.16); } @@ -1601,7 +1711,7 @@ body.manage-page { border: 1px solid var(--md-outline-variant); border-radius: 8px; background: var(--md-surface-container); - box-shadow: var(--manage-soft-shadow); + box-shadow: var(--webhtv-soft-shadow); } .target-strip.remote-offline { @@ -1697,13 +1807,13 @@ body.manage-page { width: 9px; height: 9px; border-radius: 50%; - background: var(--manage-warn); + background: var(--webhtv-warning); box-shadow: 0 0 0 3px rgba(183, 121, 31, 0.14); flex: 0 0 auto; } .device-item.online .device-dot { - background: var(--manage-success); + background: var(--webhtv-success); box-shadow: 0 0 0 3px rgba(17, 132, 91, 0.14); } @@ -1945,11 +2055,11 @@ body.manage-page { .login-file-row.pending { border-color: rgba(191, 116, 0, 0.36); - background: #FFF8ED; + background: var(--webhtv-warning-container); } .login-file-row.finding { - background: #F1FAF4; + background: var(--webhtv-secondary-container); } .login-file-row.missing { @@ -2010,14 +2120,14 @@ body.manage-page { .file-tag.high, .file-tag.selected { - background: #DDF4E7; - color: #0B6B42; + background: var(--webhtv-secondary-container); + color: var(--webhtv-success); } .file-tag.medium, .file-tag.pending { - background: #FFE6BF; - color: #7A4300; + background: var(--webhtv-warning-container); + color: var(--webhtv-on-warning-container); } .file-tag.low { @@ -2165,14 +2275,14 @@ body.manage-page { } .login-state-badge.selected { - background: #DDF4E7; - color: #0B6B42; + background: var(--webhtv-secondary-container); + color: var(--webhtv-success); } .login-state-badge.pending, .login-state-badge.partial { - background: #FFE6BF; - color: #7A4300; + background: var(--webhtv-warning-container); + color: var(--webhtv-on-warning-container); } .login-state-badge.missing { @@ -2181,13 +2291,13 @@ body.manage-page { } .login-state-badge.file-type.text { - background: #E5F5EC; - color: #206844; + background: var(--webhtv-secondary-container); + color: var(--webhtv-success); } .login-state-badge.file-type.binary { - background: #FCE8EE; - color: #8A1C3D; + background: var(--webhtv-error-container); + color: var(--webhtv-on-error-container); } .card-actions { @@ -3046,9 +3156,9 @@ body.manage-page { @media (prefers-color-scheme: dark) { .keepalive-card { - background: #3A2A0A; - color: #F2C66D; - border-color: #6F4C16; + background: var(--webhtv-warning-container); + color: var(--webhtv-warning); + border-color: var(--webhtv-warning); } } diff --git a/app/src/main/assets/reader.html b/app/src/main/assets/reader.html index b947bf38f2..3b866eaa87 100644 --- a/app/src/main/assets/reader.html +++ b/app/src/main/assets/reader.html @@ -5,30 +5,171 @@ 阅读器 @@ -492,8 +635,13 @@ body.comic-active .chapter-drawer .drawer-tab-content { color:#e8ecf1; border-co

点击播放开始朗读
系统
-
腾讯
-
微软
+
微软
+
百度
+
在线
+
+
+ 音色 +
@@ -502,13 +650,36 @@ body.comic-active .chapter-drawer .drawer-tab-content { color:#e8ecf1; border-co
语速 - + 1.0x
+
+ 音调 + + 1.0 +
+
+ 定时 +
+
关
+
10分
+
20分
+
30分
+
60分
+
+
跟随
开
+
停止朗读
@@ -1280,35 +1451,39 @@ $('seekPage').addEventListener('input', function(){ } }); -/* ===================== TTS 语音朗读 ===================== */ -var ttsPlaying = false, ttsPaused = false; -var ttsSpeed = parseFloat(getSet('novel_ttsSpeed', '1')); -var ttsFollow = getSet('novel_ttsFollow', '1') === '1'; +/* ===================== TTS 语音朗读(原生朗读 + 浏览器兜底) ===================== + * + * 主线走原生朗读(AndroidReader.ttsStart 等):Android WebView 不实现 Web Speech API, + * 直接调 speechSynthesis 在真机上完全无声,因此 JS 只负责收集段落、显示状态与高亮跟随, + * 合成/播放/后台服务/通知栏控制都在原生侧(见 ReaderTtsService / ReaderTtsController)。 + * 只有在浏览器里预览 reader.html(没有 AndroidReader 桥)时才回落到 speechSynthesis。 + */ +var ttsState = 'stopped', ttsParagraph = -1, ttsTotal = 0; var ttsSource = getSet('novel_ttsSource', 'system'); -var ttsParagraphs = [], ttsIndex = 0, ttsUtterance = null; -var ttsVoiceName = getSet('novel_ttsVoice', ''); +var ttsSpeed = parseFloat(getSet('novel_ttsSpeed', '1')); +var ttsPitch = parseFloat(getSet('novel_ttsPitch', '1')); +var ttsTimer = parseInt(getSet('novel_ttsTimer', '0'), 10); +var ttsFollow = getSet('novel_ttsFollow', '1') === '1'; +var ttsParagraphs = []; // [{el, text}],当前 DOM 顺序 +var ttsVoiceCache = {}; // source -> [{id,name}] +var ttsInfo = {}; // ttsInfo() 原始值 +var ttsContinuePending = false; // 章末续读:等新章注入后续读 +var ttsLastEl = null; // 最后朗读的段落元素,用于追加模式续读定位 +var ttsWebUtterance = null; // 浏览器兜底用 +var ttsEngineDirty = false; -function ttsRefreshSource(){ - qsa('.tts-source-row .pill-opt').forEach(function(b){ - b.classList.toggle('active', b.dataset.ttsSource === ttsSource); - }); +function ttsNative(){ + return !!(window.AndroidReader && typeof AndroidReader.ttsStart === 'function'); } -qsa('.tts-source-row .pill-opt').forEach(function(b){ - b.addEventListener('click', function(){ - ttsSource = this.dataset.ttsSource; - putSet('novel_ttsSource', ttsSource); - ttsRefreshSource(); - if(ttsPlaying && ttsSource !== 'system') { ttsStopNative(); ttsPlaying = false; ttsPaused = false; } - if(ttsSource === 'tencent') showToast('已选择腾讯云语音'); - else if(ttsSource === 'microsoft') showToast('微软云语音需在原生端配置,暂用系统语音'); - }); -}); -function ttsSetStatus(s){ $('ttsStatus').textContent = s; } +function ttsSetStatus(s){ var el = $('ttsStatus'); if(el) el.textContent = s; } +function ttsSetPlayGlyph(playing){ $('btnTtsPlay').textContent = playing ? '⏸' : '▶'; } +function ttsVoicePrefKey(source){ return 'novel_ttsVoice_' + source; } + function ttsGetParagraphs(){ var els = novelBlocks(); var out = []; for(var i = 0; i < els.length; i++){ - var t = els[i].textContent.trim(); + var t = (els[i].textContent || '').trim(); if(t) out.push({el: els[i], text: t}); } return out; @@ -1316,128 +1491,365 @@ function ttsGetParagraphs(){ function ttsClearHighlight(){ qsa('.tts-highlight').forEach(function(el){ el.classList.remove('tts-highlight'); }); } -function ttsNativeAvailable(){ - return ttsSource === 'tencent' && window.AndroidReader && typeof AndroidReader.ttsSpeak === 'function'; -} -function ttsStopNative(){ - if(window.AndroidReader && typeof AndroidReader.ttsStop === 'function'){ - try { AndroidReader.ttsStop(); } catch(e){} - } -} -function ttsSpeak(idx){ - if(idx >= ttsParagraphs.length){ ttsStop(); ttsSetStatus('朗读完毕'); return; } - ttsIndex = idx; - ttsClearHighlight(); - var p = ttsParagraphs[idx]; +function ttsHighlight(index, doScroll){ + var p = ttsParagraphs[index]; + if(!p) return; + qsa('.tts-highlight').forEach(function(el){ if(el !== p.el) el.classList.remove('tts-highlight'); }); p.el.classList.add('tts-highlight'); - if(ttsFollow) p.el.scrollIntoView({behavior:'smooth', block:'center'}); - if(ttsNativeAvailable()){ - try { - AndroidReader.ttsSpeak(p.text); - var np = ttsParagraphs[idx + 1]; - if(np) AndroidReader.ttsPrefetch(np.text); - } catch(e){ showToast('腾讯云语音调用失败: ' + e); } - ttsSetStatus('正在朗读(腾讯云)'); + ttsLastEl = p.el; + if(doScroll && ttsFollow) p.el.scrollIntoView({behavior:'smooth', block:'center'}); +} + +/* ---------- 音源 / 音色 ---------- */ +function ttsRefreshSource(){ + qsa('.tts-source-row .pill-opt').forEach(function(b){ + b.classList.toggle('active', b.dataset.ttsSource === ttsSource); + }); + var box = $('ttsEngineBox'); + if(box) box.style.display = ttsSource === 'custom' ? 'flex' : 'none'; + // 音调只有系统引擎与微软 Edge 支持;百度发音接口没有音调参数, + // 与其留一个「拖了没反应」的控件,不如按音源隐藏。 + var pitchRow = $('ttsPitchRow'); + if(pitchRow) pitchRow.style.display = (ttsSource === 'system' || ttsSource === 'edge') ? '' : 'none'; + if(ttsSource === 'custom') ttsLoadEngineForm(); + ttsLoadVoices(ttsSource); +} +function ttsLoadEngineForm(){ + if(!ttsNative() || !ttsInfo.custom) return; + if(ttsEngineDirty) return; + $('ttsEngineName').value = ttsInfo.custom.name || ''; + $('ttsEngineUrl').value = ttsInfo.custom.url || ''; + $('ttsEngineHeader').value = ttsInfo.custom.header || ''; +} +function ttsRenderVoices(list){ + var sel = $('ttsVoiceSelect'); + sel.innerHTML = ''; + var current = getSet(ttsVoicePrefKey(ttsSource), ''); + var matched = false; + (list || []).forEach(function(v){ + var opt = document.createElement('option'); + opt.value = v.id; + opt.textContent = v.name || v.id; + if(v.id === current){ opt.selected = true; matched = true; } + sel.appendChild(opt); + }); + if(!matched && sel.options.length > 0){ + sel.options[0].selected = true; + putSet(ttsVoicePrefKey(ttsSource), sel.options[0].value); + } + var row = $('ttsVoiceRow'); + if(row) row.style.display = sel.options.length > 0 ? '' : 'none'; +} +function ttsLoadVoices(source){ + var cached = ttsVoiceCache[source]; + if(cached){ ttsRenderVoices(cached); return; } + if(!ttsNative()){ ttsRenderVoices([]); return; } + try { + var list = JSON.parse(AndroidReader.ttsVoices(source) || '[]'); + if(list && list.length > 0){ + ttsVoiceCache[source] = list; + ttsRenderVoices(list); + return; + } + // 系统音色要等 TextToSpeech 初始化,原生随后用 __onTtsVoices 推送 + ttsRenderVoices([]); + } catch(e){ ttsRenderVoices([]); } +} +function ttsCurrentVoice(){ var sel = $('ttsVoiceSelect'); return sel ? (sel.value || '') : ''; } + +/* ---------- 启动 / 控制 ---------- */ +function ttsSourceTitle(){ + return DATA.title || DATA.vodName || '小说'; +} +function ttsStartFrom(paragraphs, startIndex){ + ttsParagraphs = paragraphs || ttsGetParagraphs(); + if(ttsParagraphs.length === 0){ ttsSetStatus('没有可朗读的内容'); return; } + var index = Math.max(0, Math.min(startIndex | 0, ttsParagraphs.length - 1)); + ttsParagraph = index; + ttsTotal = ttsParagraphs.length; + if(!ttsNative()){ + ttsWebStart(index); return; } - var u = new SpeechSynthesisUtterance(p.text); - u.rate = ttsSpeed; u.lang = 'zh-CN'; - var voices = speechSynthesis.getVoices(); - var v = voices.filter(function(x){ return x.lang.indexOf('zh') === 0; }); - if(ttsVoiceName){ - var found = voices.filter(function(x){ return x.name === ttsVoiceName; })[0]; - if(found) u.voice = found; - } else if(v.length > 0){ - u.voice = v[0]; + var payload = { + source: ttsSource, + voice: ttsCurrentVoice(), + rate: ttsSpeed, + pitch: ttsPitch, + timer: ttsTimer, + index: index, + title: ttsSourceTitle(), + paragraphs: ttsParagraphs.map(function(p){ return p.text; }) + }; + if(ttsSource === 'custom'){ + var rule = {name: $('ttsEngineName').value.trim(), url: $('ttsEngineUrl').value.trim(), header: $('ttsEngineHeader').value.trim(), contentType: 'audio/mpeg'}; + if(!rule.url){ ttsSetStatus('请先填写在线朗读规则'); showToast('请先填写在线朗读规则'); return; } + payload.rule = JSON.stringify(rule); + } + try { + AndroidReader.ttsStart(JSON.stringify(payload)); + ttsSetStatus('正在朗读…'); + ttsSetPlayGlyph(true); + } catch(e){ + ttsSetStatus('朗读启动失败:' + e); + showToast('朗读启动失败:' + e); } - u.onend = function(){ if(ttsPlaying) ttsSpeak(idx + 1); }; - u.onerror = function(){ if(ttsPlaying) ttsSpeak(idx + 1); }; - ttsUtterance = u; - speechSynthesis.speak(u); -} -function ttsStart(){ - ttsParagraphs = ttsGetParagraphs(); - if(ttsParagraphs.length === 0){ ttsSetStatus('没有可朗读的内容'); return; } - ttsPlaying = true; ttsPaused = false; - $('btnTtsPlay').textContent = '⏸'; - ttsSetStatus('正在朗读...'); - ttsSpeak(0); -} -function ttsPause(){ - if(!ttsPlaying || ttsPaused) return; - if(ttsSource === 'tencent') { ttsStopNative(); ttsPaused = true; } - else { speechSynthesis.pause(); ttsPaused = true; } - $('btnTtsPlay').textContent = '▶'; - ttsSetStatus('已暂停'); -} -function ttsResume(){ - if(!ttsPlaying || !ttsPaused) return; - if(ttsSource === 'tencent') { ttsPaused = false; ttsSpeak(ttsIndex); } - else { speechSynthesis.resume(); ttsPaused = false; } - $('btnTtsPlay').textContent = '⏸'; - ttsSetStatus('正在朗读...'); } +function ttsStart(){ ttsStartFrom(ttsGetParagraphs(), 0); } function ttsStop(){ - ttsStopNative(); - speechSynthesis.cancel(); - ttsPlaying = false; ttsPaused = false; - $('btnTtsPlay').textContent = '▶'; + ttsContinuePending = false; + ttsLastEl = null; + if(ttsNative()){ try { AndroidReader.ttsStop(); } catch(e){} } + ttsWebStop(); + ttsState = 'stopped'; ttsParagraph = -1; + ttsSetPlayGlyph(false); ttsClearHighlight(); ttsSetStatus('点击播放开始朗读'); } -$('btnTtsPlay').addEventListener('click', function(){ - if(ttsPlaying && !ttsPaused) ttsPause(); - else if(ttsPlaying && ttsPaused) ttsResume(); - else ttsStart(); +function ttsToggle(){ + if(ttsState === 'playing' || ttsState === 'paused'){ + if(!ttsNative()){ ttsWebToggle(); return; } + try { AndroidReader.ttsToggle(); } catch(e){} + return; + } + ttsStart(); +} +function ttsPrev(){ + if(ttsState === 'stopped' || ttsParagraph < 0){ ttsStart(); return; } + if(!ttsNative()){ ttsWebSeek(Math.max(0, ttsParagraph - 1)); return; } + try { AndroidReader.ttsPrev(); } catch(e){} +} +function ttsNext(){ + if(ttsState === 'stopped' || ttsParagraph < 0){ ttsStart(); return; } + if(!ttsNative()){ ttsWebSeek(Math.min(ttsParagraphs.length - 1, ttsParagraph + 1)); return; } + try { AndroidReader.ttsNext(); } catch(e){} +} + +/* ---------- 章节切换时的续读 ---------- */ +function ttsOnChapterSwitching(){ + // 手动切章 / 章末续读:都等新章注入后从「下一个未读段落」继续 + if(ttsState !== 'playing' && ttsState !== 'paused') return; + ttsContinuePending = true; +} +function ttsContinueAfterChapter(){ + if(!ttsContinuePending) return; + ttsContinuePending = false; + if(!ttsNative()){ + ttsWebStop(); + ttsState = 'stopped'; + ttsSetPlayGlyph(false); + ttsSetStatus('已切换章节,请重新开始朗读'); + return; + } + var paragraphs = ttsGetParagraphs(); + var start = 0; + if(ttsLastEl){ + for(var i = 0; i < paragraphs.length; i++){ + if(paragraphs[i].el === ttsLastEl){ start = i + 1; break; } + } + } + ttsLastEl = null; + if(start >= paragraphs.length){ + // 新章没有可续读的段落(例如空章):停止,避免原生队列空转 + ttsStop(); + return; + } + ttsStartFrom(paragraphs, start); +} +function ttsLoadNextChapter(){ + if(DATA.kind !== 1 || !DATA.chapters || DATA.chapters.length === 0){ ttsStop(); return; } + if(DATA.current >= DATA.chapters.length - 1){ + ttsStop(); + ttsSetStatus('朗读完毕(已是最后一章)'); + showToast('朗读完毕'); + return; + } + // 章末续读意图在此确定:新章数据注入后由 ttsContinueAfterChapter 接上。 + // 不能依赖 ttsState(原生可能刚把它置成 stopped),否则会丢掉续读。 + ttsContinuePending = true; + if(isPageMode()) goChapter(DATA.current + 1); + else loadNextNovelAppend(); +} + +/* ---------- 原生回调 ---------- */ +window.__onTtsState = function(json){ + ttsState = json.state || 'stopped'; + ttsParagraph = (typeof json.paragraph === 'number') ? json.paragraph : -1; + ttsTotal = json.total || ttsTotal; + if(ttsState === 'playing'){ + ttsSetPlayGlyph(true); + ttsSetStatus('正在朗读 ' + Math.max(0, ttsParagraph + 1) + '/' + (ttsTotal || ttsParagraphs.length) + (ttsTimer > 0 ? '(定时 ' + ttsTimer + ' 分钟)' : '')); + } else if(ttsState === 'paused'){ + ttsSetPlayGlyph(false); + ttsSetStatus('已暂停'); + } else if(ttsState === 'error'){ + ttsSetPlayGlyph(false); + ttsSetStatus(json.message || '朗读失败'); + ttsClearHighlight(); + if(json.message) showToast(json.message); + } else { + ttsSetPlayGlyph(false); + if(json.message) ttsSetStatus(json.message); + } +}; +window.__onTtsProgress = function(json){ + ttsParagraph = json.paragraph; + ttsTotal = json.total || ttsTotal; + ttsHighlight(json.paragraph, true); + if(ttsState === 'playing') ttsSetStatus('正在朗读 ' + (json.paragraph + 1) + '/' + (ttsTotal || ttsParagraphs.length)); +}; +window.__onTtsChapterEnd = function(){ ttsLoadNextChapter(); }; +window.__onTtsVoices = function(list){ + if(!list || !list.length) return; + ttsVoiceCache[ttsSource] = list; + ttsRenderVoices(list); +}; + +/* ---------- 浏览器兜底(无原生桥时) ---------- */ +function ttsWebStart(index){ + if(!window.speechSynthesis){ ttsSetStatus('当前环境不支持语音朗读'); return; } + ttsWebSpeak(index); +} +function ttsWebSpeak(index){ + if(index >= ttsParagraphs.length){ ttsWebStop(); ttsSetStatus('朗读完毕'); return; } + ttsParagraph = index; + ttsHighlight(index, true); + var u = new SpeechSynthesisUtterance(ttsParagraphs[index].text); + u.rate = ttsSpeed; u.pitch = ttsPitch; u.lang = 'zh-CN'; + var voiceId = ttsCurrentVoice(); + if(voiceId){ + var found = speechSynthesis.getVoices().filter(function(x){ return x.name === voiceId || x.voiceURI === voiceId; })[0]; + if(found) u.voice = found; + } + u.onend = function(){ if(ttsState === 'playing') ttsWebSpeak(index + 1); }; + u.onerror = function(){ if(ttsState === 'playing') ttsWebSpeak(index + 1); }; + ttsWebUtterance = u; + ttsState = 'playing'; + ttsSetPlayGlyph(true); + ttsSetStatus('正在朗读 ' + (index + 1) + '/' + ttsParagraphs.length); + speechSynthesis.speak(u); +} +function ttsWebSeek(index){ speechSynthesis.cancel(); ttsWebSpeak(index); } +function ttsWebToggle(){ + if(ttsState === 'playing'){ speechSynthesis.pause(); ttsState = 'paused'; ttsSetPlayGlyph(false); ttsSetStatus('已暂停'); } + else if(ttsState === 'paused'){ speechSynthesis.resume(); ttsState = 'playing'; ttsSetPlayGlyph(true); ttsSetStatus('正在朗读…'); } +} +function ttsWebStop(){ + if(window.speechSynthesis) speechSynthesis.cancel(); + ttsWebUtterance = null; +} + +/* ---------- 初始化 ---------- */ +function ttsInitSettings(){ + $('seekTtsSpeed').value = Math.round(ttsSpeed * 100); + $('tvTtsSpeed').textContent = ttsSpeed.toFixed(1) + 'x'; + $('seekTtsPitch').value = Math.round(ttsPitch * 100); + $('tvTtsPitch').textContent = ttsPitch.toFixed(1); + $('btnTtsFollow').textContent = ttsFollow ? '开' : '关'; + $('btnTtsFollow').classList.toggle('active', ttsFollow); + qsa('#ttsTimerGroup .pill-opt').forEach(function(b){ + b.classList.toggle('active', parseInt(b.dataset.ttstimer, 10) === ttsTimer); + }); + if(ttsNative()){ + try { ttsInfo = JSON.parse(AndroidReader.ttsInfo() || '{}'); } catch(e){ ttsInfo = {}; } + if(ttsInfo.edgeVoices && ttsInfo.edgeVoices.length){ + ttsVoiceCache['edge'] = ttsInfo.edgeVoices; + } + if(ttsInfo.custom) ttsEngineDirty = false; + } + ttsRefreshSource(); + ttsSetPlayGlyph(ttsState === 'playing'); + if(ttsInfo.state && ttsInfo.state !== 'stopped' && ttsInfo.paragraph >= 0){ + // 重新打开面板时与原生状态对齐 + ttsState = ttsInfo.state; + ttsParagraphs = ttsGetParagraphs(); + ttsHighlight(ttsInfo.paragraph, false); + ttsSetPlayGlyph(ttsState === 'playing'); + ttsSetStatus(ttsState === 'playing' ? '正在朗读 ' + (ttsInfo.paragraph + 1) + '/' + (ttsParagraphs.length || 0) : '已暂停'); + } else if(ttsInfo.systemAvailable === false && ttsSource === 'system'){ + ttsSetStatus('设备未安装系统 TTS 引擎,建议改用「微软」或「百度」在线音源'); + } +} +qsa('.tts-source-row .pill-opt').forEach(function(b){ + b.addEventListener('click', function(){ + if(ttsState === 'playing' || ttsState === 'paused') ttsStop(); + ttsSource = this.dataset.ttsSource; + putSet('novel_ttsSource', ttsSource); + ttsRefreshSource(); + if(ttsSource === 'system' && ttsInfo.systemAvailable === false){ + ttsSetStatus('设备未安装系统 TTS 引擎,建议改用「微软」或「百度」在线音源'); + } else if(ttsSource === 'edge'){ + ttsSetStatus('微软 Edge 语音(Neural 音色),需要网络'); + } else if(ttsSource === 'baidu'){ + ttsSetStatus('百度在线语音,需要网络'); + } else { + ttsSetStatus('自定义在线朗读引擎'); + } + }); }); -$('btnTtsPrev').addEventListener('click', function(){ - if(!ttsPlaying) { ttsStart(); return; } - ttsStopNative(); - speechSynthesis.cancel(); - ttsSpeak(Math.max(0, ttsIndex - 1)); +$('ttsVoiceSelect').addEventListener('change', function(){ + putSet(ttsVoicePrefKey(ttsSource), this.value); + if(ttsNative() && (ttsState === 'playing' || ttsState === 'paused')){ + try { AndroidReader.ttsSetVoice(this.value); } catch(e){} + } }); -$('btnTtsNext').addEventListener('click', function(){ - if(!ttsPlaying) { ttsStart(); return; } - ttsStopNative(); - speechSynthesis.cancel(); - ttsSpeak(ttsIndex + 1); -}); -$('btnTtsStop').addEventListener('click', ttsStop); +$('btnTtsPlay').addEventListener('click', ttsToggle); +$('btnTtsPrev').addEventListener('click', ttsPrev); +$('btnTtsNext').addEventListener('click', ttsNext); +$('btnTtsStop').addEventListener('click', function(){ ttsStop(); }); $('seekTtsSpeed').addEventListener('input', function(){ ttsSpeed = parseFloat(this.value) / 100; $('tvTtsSpeed').textContent = ttsSpeed.toFixed(1) + 'x'; putSet('novel_ttsSpeed', ttsSpeed); - if(ttsUtterance) ttsUtterance.rate = ttsSpeed; + if(ttsNative() && ttsState !== 'stopped'){ + try { AndroidReader.ttsSetRate(ttsSpeed); } catch(e){} + } else if(ttsWebUtterance){ ttsWebUtterance.rate = ttsSpeed; } +}); +$('seekTtsPitch').addEventListener('input', function(){ + ttsPitch = parseFloat(this.value) / 100; + $('tvTtsPitch').textContent = ttsPitch.toFixed(1); + putSet('novel_ttsPitch', ttsPitch); + if(ttsNative() && ttsState !== 'stopped'){ + try { AndroidReader.ttsSetPitch(ttsPitch); } catch(e){} + } else if(ttsWebUtterance){ ttsWebUtterance.pitch = ttsPitch; } +}); +qsa('#ttsTimerGroup .pill-opt').forEach(function(b){ + b.addEventListener('click', function(){ + ttsTimer = parseInt(this.dataset.ttstimer, 10) || 0; + putSet('novel_ttsTimer', ttsTimer); + qsa('#ttsTimerGroup .pill-opt').forEach(function(x){ + x.classList.toggle('active', parseInt(x.dataset.ttstimer, 10) === ttsTimer); + }); + if(ttsNative() && ttsState !== 'stopped'){ + try { AndroidReader.ttsSetTimer(ttsTimer); } catch(e){} + } + ttsSetStatus(ttsTimer > 0 ? '朗读定时 ' + ttsTimer + ' 分钟' : '已取消定时'); + }); }); $('btnTtsFollow').addEventListener('click', function(){ ttsFollow = !ttsFollow; putSet('novel_ttsFollow', ttsFollow ? '1' : '0'); - $('btnTtsFollow').textContent = ttsFollow ? '开' : '关'; - $('btnTtsFollow').classList.toggle('active', ttsFollow); + this.textContent = ttsFollow ? '开' : '关'; + this.classList.toggle('active', ttsFollow); }); -function ttsInitSettings(){ - $('seekTtsSpeed').value = ttsSpeed * 100; - $('tvTtsSpeed').textContent = ttsSpeed.toFixed(1) + 'x'; - $('btnTtsFollow').textContent = ttsFollow ? '开' : '关'; - $('btnTtsFollow').classList.toggle('active', ttsFollow); - ttsRefreshSource(); -} - -function nativeTtsStart(){ - if(ttsPlaying && !ttsPaused && ttsSource === 'tencent') ttsSetStatus('正在朗读(腾讯云)'); -} -function nativeTtsDone(){ - if(ttsPlaying && !ttsPaused && ttsSource === 'tencent') ttsSpeak(ttsIndex + 1); -} -function nativeTtsError(){ - if(ttsPlaying && !ttsPaused && ttsSource === 'tencent'){ - ttsSetStatus('腾讯云语音异常,继续下一段'); - ttsSpeak(ttsIndex + 1); +['ttsEngineName','ttsEngineUrl','ttsEngineHeader'].forEach(function(id){ + $(id).addEventListener('input', function(){ ttsEngineDirty = true; }); +}); +$('btnTtsEngineSave').addEventListener('click', function(){ + if(!ttsNative()){ showToast('浏览器环境无需配置'); return; } + var rule = {name: $('ttsEngineName').value.trim(), url: $('ttsEngineUrl').value.trim(), header: $('ttsEngineHeader').value.trim(), contentType: 'audio/mpeg'}; + if(!rule.url){ showToast('请填写在线朗读规则'); return; } + var ok = false; + try { ok = AndroidReader.ttsSaveEngine(JSON.stringify(rule)); } catch(e){ ok = false; } + if(ok){ + ttsEngineDirty = false; + ttsInfo.custom = rule; + showToast('在线引擎已保存'); + ttsSetStatus('在线引擎已保存,点击播放开始朗读'); + } else { + showToast('规则无法解析(暂不支持 @js: 规则)'); } -} -window.__onNativeTtsStart = nativeTtsStart; -window.__onNativeTtsDone = nativeTtsDone; -window.__onNativeTtsError = nativeTtsError; +}); /* ===================== 自动阅读 ===================== */ var autoMode = getSet('novel_autoMode', 'scroll'); @@ -1693,6 +2105,7 @@ function __updateChapter(data){ if(switchWatchdog){ clearTimeout(switchWatchdog); switchWatchdog = null; } switchingChapter = false; reportProgress(); + if(typeof ttsContinueAfterChapter === 'function') ttsContinueAfterChapter(); } function setTitle(t){ document.title = t || '阅读'; @@ -1863,6 +2276,8 @@ function appendNovelChapter(data){ updateCharCount(); var t = DATA.chapters && DATA.chapters[novelAppendIndex] ? DATA.chapters[novelAppendIndex].name : data.title; setTitle(t || DATA.vodName || '阅读'); + // 无缝追加模式下同样需要为朗读续上新增章节的段落 + if(typeof ttsContinueAfterChapter === 'function') ttsContinueAfterChapter(); } /* ===== 漫画:影视+ 独立阅读器 ===== */ var comicIdx = 0, comicLoading = false; @@ -2193,6 +2608,8 @@ function goChapter(i){ if(i < 0){ showToast('已是第一章'); return; } if(i >= DATA.chapters.length){ showToast('已到最终章'); stopAuto(); return; } reportProgress(); + // 朗读中手动切章(或章末续读):新章内容注入后从下一段继续朗读 + if(typeof ttsOnChapterSwitching === 'function') ttsOnChapterSwitching(); prevChapterIndex = DATA.current | 0; switchingChapter = true; if(switchWatchdog) clearTimeout(switchWatchdog); diff --git a/app/src/main/assets/webhome/eclipse-detail.html b/app/src/main/assets/webhome/eclipse-detail.html index 5ee9e5bc03..6b261ec0fe 100644 --- a/app/src/main/assets/webhome/eclipse-detail.html +++ b/app/src/main/assets/webhome/eclipse-detail.html @@ -5,12 +5,254 @@ Eclipse · 详情 + " - + "

调试日志

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

调试日志

" + status + " · " + DebugLogStore.bytes() / 1024 + " KB
" + + "
AI诊断下载" + clear + "
" + "" + "
" + + banner + "
" + logs + "
" - + diagnosticHtml(localUrl, lanUrl, enabled) + + diagnosticHtml(localUrl, lanUrl, enabled, temporary) + "" - + ""; + + ""; } private String css() { @@ -250,6 +255,7 @@ public class DebugLogs implements Process { .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} + .crash-banner{margin:0 8px 8px;padding:8px 10px;border:1px solid #d0d7de;border-left:4px solid #bf8700;border-radius:7px;background:#fff8c5;color:#7d4e00;font-size:13px} .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} @@ -286,8 +292,9 @@ public class DebugLogs implements Process { """; } - 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');" + private String scriptEnhanced(boolean temporary) { + // 崩溃会话的标题由页面固定:轮询只刷新字节数,不能把「崩溃会话」改回「记录中」。 + 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'),crashSession=" + (temporary ? "true" : "false") + ";" + "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')};" @@ -323,10 +330,13 @@ public class DebugLogs implements Process { + "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();"; + + "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=(crashSession?'崩溃会话 · 自动开启':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) { + private String diagnosticHtml(String localUrl, String lanUrl, boolean enabled, boolean temporary) { + String control = temporary + ? "崩溃会话临时日志:本页自动开启,重启后恢复原设置。" + : ""; return String.format(java.util.Locale.ROOT, """ @@ -354,14 +364,14 @@ public class DebugLogs implements Process {

- """, enabled ? "/debug/disable" : "/debug/enable", enabled ? "关闭采集" : "开启采集", + """, control, escape(localUrl), escape(localUrl), escape(lanUrl), escape(lanUrl)); } 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..3dbf835832 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 @@ -116,9 +116,7 @@ public class M3u8 implements Process { 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 + " 个广告片段"; + String message = HlsAdblockNotice.message(removed, clean.removedDurationSec()); App.post(() -> Notify.show(message)); } 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..e808555724 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 @@ -9,6 +9,8 @@ import com.fongmi.android.tv.api.config.LiveConfig; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.Backup; import com.fongmi.android.tv.bean.Config; +import com.fongmi.android.tv.db.AppDatabase; +import com.fongmi.android.tv.setting.ConfigSyncPolicy; import com.fongmi.android.tv.bean.Device; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.bean.SyncOptions; @@ -345,9 +347,14 @@ public class Manage implements Process { int type = intValue(params.get("type"), 0); String url = params.getOrDefault("url", "").trim(); String name = params.getOrDefault("name", "").trim(); + String interfaceKey = params.getOrDefault("interfaceKey", "").trim(); if (TextUtils.isEmpty(url)) return Nano.error(Status.BAD_REQUEST, "Missing url"); - Config config = Config.find(url, type).name(name); - config.save(); + Config config = TextUtils.isEmpty(interfaceKey) + ? Config.find(url, type) + : AppDatabase.get().getConfigDao().findByInterfaceKey(interfaceKey, type); + if (config == null) config = Config.create(type); + config.interfaceKey(interfaceKey).url(url).name(name).save(); + com.fongmi.android.tv.playback.PlaybackIdentityResolver.resolveSaved(config); } JsonObject object = new JsonObject(); JsonArray items = new JsonArray(); @@ -362,32 +369,52 @@ public class Manage implements Process { private Response configUse(Map params) { int type = intValue(params.get("type"), 0); - String url = params.getOrDefault("url", "").trim(); - if (TextUtils.isEmpty(url)) return Nano.error(Status.BAD_REQUEST, "Missing url"); - Config config = Config.find(url, type); - if (config.isEmpty()) return Nano.error(Status.NOT_FOUND, "Config not found"); + Config config = findConfig(params, type); + if (config == null || config.isEmpty()) return Nano.error(Status.NOT_FOUND, "Config not found"); switch (type) { case 1 -> LiveConfig.load(config, new Callback()); case 2 -> WallConfig.load(config, new Callback()); - default -> VodConfig.load(config, new Callback()); + default -> { + String previousVodUrl = VodConfig.getUrl(); + VodConfig.load(config, new Callback()); + if (ConfigSyncPolicy.shouldSyncLive(previousVodUrl, LiveConfig.getUrl())) { + Config liveConfig = AppDatabase.get().getConfigDao().find(config.getUrl(), 1); + if (liveConfig != null) LiveConfig.load(liveConfig, new Callback()); + } + } } return configs(java.util.Collections.emptyMap()); } private Response configDelete(Map params) { int type = intValue(params.get("type"), 0); - String url = params.getOrDefault("url", "").trim(); - if (TextUtils.isEmpty(url)) return Nano.error(Status.BAD_REQUEST, "Missing url"); - Config.find(url, type).delete(); + Config config = findConfig(params, type); + if (config == null || config.isEmpty()) return Nano.error(Status.NOT_FOUND, "Config not found"); + config.delete(); return configs(java.util.Collections.emptyMap()); } + private Config findConfig(Map params, int type) { + String interfaceKey = params.getOrDefault("interfaceKey", "").trim(); + if (!TextUtils.isEmpty(interfaceKey)) { + Config config = AppDatabase.get().getConfigDao().findByInterfaceKey(interfaceKey, type); + if (config != null) return config; + } + String url = params.getOrDefault("url", "").trim(); + return TextUtils.isEmpty(url) ? null : AppDatabase.get().getConfigDao().find(url, type); + } + private JsonObject configObject(Config config, boolean forceActive) { JsonObject item = new JsonObject(); item.addProperty("type", config.getType()); item.addProperty("typeName", configTypeName(config.getType())); item.addProperty("name", config.getName()); item.addProperty("url", config.getUrl()); + item.addProperty("interfaceKey", config.ensureInterfaceKey()); + item.add("urls", App.gson().toJsonTree(config.getUrls())); + item.add("legacyConfigKeys", App.gson().toJsonTree(config.getLegacyConfigKeys())); + item.add("addressMatchAliases", App.gson().toJsonTree(config.getAddressMatchAliases())); + item.addProperty("identityResolutionState", config.getIdentityResolutionState()); item.addProperty("desc", config.getDesc()); item.addProperty("time", config.getTime()); item.addProperty("active", forceActive || isCurrentConfig(config)); @@ -397,14 +424,14 @@ public class Manage implements Process { private boolean containsConfig(JsonArray items, Config config) { for (int i = 0; i < items.size(); i++) { JsonObject item = items.get(i).getAsJsonObject(); - if (item.get("type").getAsInt() == config.getType() && item.get("url").getAsString().equals(config.getUrl())) return true; + if (item.get("type").getAsInt() == config.getType() && item.get("interfaceKey").getAsString().equals(config.ensureInterfaceKey())) return true; } return false; } private boolean isCurrentConfig(Config config) { Config current = currentConfig(config.getType()); - return current.getUrl().equals(config.getUrl()); + return current.getType() == config.getType() && current.ensureInterfaceKey().equals(config.ensureInterfaceKey()); } private Config currentConfig(int type) { diff --git a/app/src/main/java/com/fongmi/android/tv/server/process/PlaybackHistoryApi.java b/app/src/main/java/com/fongmi/android/tv/server/process/PlaybackHistoryApi.java new file mode 100644 index 0000000000..16b13018cf --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/server/process/PlaybackHistoryApi.java @@ -0,0 +1,158 @@ +package com.fongmi.android.tv.server.process; + +import android.text.TextUtils; + +import com.fongmi.android.tv.playback.PlaybackApi; +import com.fongmi.android.tv.playback.PlaybackRecord; +import com.fongmi.android.tv.playback.ViewingRecordSyncStore; +import com.fongmi.android.tv.server.Nano; +import com.fongmi.android.tv.server.impl.Process; +import com.github.catvod.crawler.SpiderDebug; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.List; +import java.util.Map; + +import fi.iki.elonen.NanoHTTPD; +import fi.iki.elonen.NanoHTTPD.IHTTPSession; +import fi.iki.elonen.NanoHTTPD.Response; + +/** + * 按站点读取播放历史的只读端点。 + * + *

为什么需要它:既有的 {@code /api/playback/current} 读的是 + * {@code PlaybackRuntime.currentHistory()}——一个只在**播放中**有值的内存字段, + * 未播放或已息屏时恒为 {@code null} 并返回 404。对端(WebHTV PC 的实时同步) + * 需要的是"这个站点有哪些历史",而不是"现在正在播什么",因此必须有一个 + * 从数据库读的端点。 + * + *

设计约束: + *

    + *
  • 只读:不接受 POST,不落库,不触发任何播放副作用;
  • + *
  • 无记录返回空数组(200),而不是 404——"没有历史"与"端点不存在" + * 必须可区分,否则对端无法判断自己是否该降级;
  • + *
  • 字段裁剪与 {@code /api/playback/current} 一致 + * ({@link com.fongmi.android.tv.playback.PlaybackFieldPolicy#apiSafe()}), + * 不因为新增端点而多暴露字段;
  • + *
  • 隐身模式返回空数组:隐身是用户显式表达的"不要记录/不要外泄"意图;
  • + *
  • 观影记录同步关闭时返回 403:与写端点 {@code /api/playback/progress} + * 同款开关({@link com.fongmi.android.tv.playback.ViewingRecordSyncStore#isEnabled()}), + * 关闭后本端点不再向局域网暴露任何历史;
  • + *
  • siteKey 用精确前缀匹配({@code substr}),不用 {@code LIKE}—— + * siteKey 可能含 {@code %} / {@code _},{@code LIKE} 会误命中。
  • + *
+ * + *

响应形态(对端按此解析): + *

+ * {
+ *   "siteKey": "csp_xxx",
+ *   "items": [ { ...apiSafe 字段集... } ],
+ *   "total": 3
+ * }
+ * 
+ * + *

{@code total} 是**本次响应 items 的长度**,不是该站点的历史总数: + * 本端点没有分页参数,结果被 {@code limit} 截断(默认 20,上限 100)后一次返回。 + * 对端若看到 {@code total == limit},不能据此推断"已取全"。 + */ +public class PlaybackHistoryApi implements Process { + + @Override + public boolean isRequest(IHTTPSession session, String url) { + return "/api/playback/history".equals(url) || "/playback/history".equals(url); + } + + @Override + public Response doResponse(IHTTPSession session, String url, Map files) { + if (session.getMethod() == NanoHTTPD.Method.OPTIONS) return cors(json(Response.Status.NO_CONTENT, ""), session); + if (session.getMethod() != NanoHTTPD.Method.GET) return cors(error(Response.Status.METHOD_NOT_ALLOWED, 405, "只支持 GET"), session); + // 与写端点 /api/playback/progress 同款门控:观影记录同步关闭时,本端点会一次 + // 暴露整站最多 100 条历史(vodName/episodeName/positionMs),暴露面远大于 + // /api/playback/current(1 条且要求正在播放),因此必须同样受开关约束。 + if (!ViewingRecordSyncStore.isEnabled()) return cors(error(Response.Status.FORBIDDEN, 403, "观影记录同步未开启"), session); + Map params = session.getParms(); + String siteKey = siteKey(params); + if (TextUtils.isEmpty(siteKey)) return cors(error(Response.Status.BAD_REQUEST, 400, "siteKey不能为空"), session); + try { + List records = PlaybackApi.history(siteKey, limit(params)); + SpiderDebug.log("playback-api", "history site=%s total=%s", siteKey, records.size()); + return cors(json(Response.Status.OK, payload(siteKey, records).toString()), session); + } catch (Throwable e) { + // 同 PlaybackApi.history:SpiderDebug 受 DebugLogStore.enabled(默认 false)门控, + // 默认发行版里不会落盘,必须补 printStackTrace 才能让 500 在 Logcat 里可查。 + SpiderDebug.log("playback-api", e); + e.printStackTrace(); + // 不回显 e.getMessage():本端点在局域网内无需鉴权即可访问,SQLite 异常文本 + // 会泄露表名/列名等结构信息。诊断走上面的 Logcat。 + return cors(error(Response.Status.INTERNAL_ERROR, 500, "读取播放历史失败"), session); + } + } + + private JsonObject payload(String siteKey, List records) { + JsonObject object = new JsonObject(); + object.addProperty("siteKey", siteKey); + JsonArray items = new JsonArray(); + // 字段裁剪已在 PlaybackApi.history 内完成;这里只做序列化。 + for (PlaybackRecord record : records) { + if (record == null) continue; + items.add(record.toJson(com.fongmi.android.tv.playback.PlaybackFieldPolicy.apiSafe())); + } + object.add("items", items); + object.addProperty("total", items.size()); + return object; + } + + /** + * 从查询参数里取 siteKey。 + * + *

包级可见而不是私有:参数归一化是纯逻辑,值得被单测直接锁定, + * 不必为了测它去 mock 一整个 HTTP session。 + * + *

{@code siteKey} 存在但只有空白时,**也要**回退到 {@code site} 别名: + * {@code ?siteKey=%20&site=csp_x} 里调用方明确给了别名,若因为主参数是空白就 + * 直接返回空串,端点会报 400 而调用方拿不到任何解释。只有两者都空才返回空串。 + */ + static String siteKey(Map params) { + String value = params.get("siteKey"); + if (TextUtils.isEmpty(value) || TextUtils.isEmpty(value.trim())) value = params.get("site"); + return value == null ? "" : value.trim(); + } + + /** + * 解析 {@code limit}。非法值(非数字、负数、零)一律回落默认, + * 而不是报 400——对端传了一个坏参数不该让整次拉取失败。 + */ + static int limit(Map params) { + String value = params.get("limit"); + if (TextUtils.isEmpty(value)) return 0; + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return 0; + } + } + + private Response error(Response.Status status, int code, String message) { + JsonObject object = new JsonObject(); + object.addProperty("code", code); + object.addProperty("message", TextUtils.isEmpty(message) ? "请求失败" : message); + return json(status, object.toString()); + } + + private Response json(Response.Status status, String text) { + return NanoHTTPD.newFixedLengthResponse(status, "application/json; charset=utf-8", text); + } + + private Response cors(Response response, IHTTPSession session) { + String origin = session.getHeaders().get("origin"); + response.addHeader("Access-Control-Allow-Origin", TextUtils.isEmpty(origin) ? "*" : origin); + response.addHeader("Access-Control-Allow-Credentials", "true"); + response.addHeader("Access-Control-Allow-Methods", "GET,OPTIONS"); + response.addHeader("Access-Control-Allow-Headers", "*"); + response.addHeader("Access-Control-Expose-Headers", "*"); + response.addHeader("Access-Control-Max-Age", "86400"); + response.addHeader("Cache-Control", "no-store"); + return response; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/server/process/Proxy.java b/app/src/main/java/com/fongmi/android/tv/server/process/Proxy.java index c555dcaa88..eb57221266 100644 --- a/app/src/main/java/com/fongmi/android/tv/server/process/Proxy.java +++ b/app/src/main/java/com/fongmi/android/tv/server/process/Proxy.java @@ -5,6 +5,7 @@ import com.fongmi.android.tv.server.Nano; import com.fongmi.android.tv.server.impl.Process; import com.github.catvod.crawler.SpiderDebug; +import java.io.ByteArrayInputStream; import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; @@ -55,20 +56,34 @@ public class Proxy implements Process { SpiderDebug.log("proxy", "response object do=%s type=%s", params.get("do"), rs[0].getClass().getName()); return response; } - if (rs.length < 3 || !(rs[0] instanceof Integer code) || !(rs[2] instanceof InputStream stream)) { + if (rs.length < 3 || !(rs[0] instanceof Integer code)) { SpiderDebug.log("proxy", "response invalid do=%s status=%s mime=%s body=%s headers=%s", params.get("do"), rs.length > 0 ? rs[0] : null, rs.length > 1 ? rs[1] : null, rs.length > 2 ? rs[2] : null, rs.length > 3 ? rs[3] : null); return Nano.error(INVALID_RESPONSE); } - SpiderDebug.log("proxy", "response do=%s status=%s mime=%s body=%s headers=%s", params.get("do"), code, rs[1], stream.getClass().getName(), rs.length > 3 ? rs[3] : null); + InputStream stream = rs[2] instanceof InputStream candidate ? candidate : null; + if (stream == null && !canResponseHaveEmptyBody(code)) { + SpiderDebug.log("proxy", "response invalid do=%s status=%s mime=%s body=%s headers=%s", params.get("do"), code, rs[1], rs[2], rs.length > 3 ? rs[3] : null); + return Nano.error(INVALID_RESPONSE); + } Map headers = headers(rs.length > 3 ? rs[3] : null); - stream = wrapStream(params, headers, stream); - Response response = NanoHTTPD.newChunkedResponse(toStatus(code), Objects.toString(rs[1], null), stream); + if (stream == null && (headers == null || header(headers, "Location") == null)) { + SpiderDebug.log("proxy", "response invalid do=%s status=%s mime=%s body=%s headers=%s", params.get("do"), code, rs[1], rs[2], headers); + return Nano.error(INVALID_RESPONSE); + } + SpiderDebug.log("proxy", "response do=%s status=%s mime=%s body=%s headers=%s", params.get("do"), code, rs[1], rs[2] == null ? "empty" : rs[2].getClass().getName(), rs.length > 3 ? rs[3] : null); + + if (stream != null) stream = wrapStream(params, headers, stream); + Response response = NanoHTTPD.newChunkedResponse(toStatus(code), Objects.toString(rs[1], null), stream == null ? new ByteArrayInputStream(new byte[0]) : stream); addHeaders(response, headers); long rangeStart = ProxyRangeResponsePolicy.resolveStart(code, first(params.get("range"), params.get("Range"))); if (rangeStart >= 0) response.addHeader(ProxyRangeResponsePolicy.HEADER_RANGE_START, String.valueOf(rangeStart)); return response; } + private static boolean canResponseHaveEmptyBody(int code) { + return code >= 300 && code < 400; + } + private static void addHeaders(Response response, Map headers) { if (headers == null) return; for (Map.Entry entry : headers.entrySet()) { diff --git a/app/src/main/java/com/fongmi/android/tv/server/process/VodApi.java b/app/src/main/java/com/fongmi/android/tv/server/process/VodApi.java new file mode 100644 index 0000000000..e879aad100 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/server/process/VodApi.java @@ -0,0 +1,620 @@ +package com.fongmi.android.tv.server.process; + +import android.text.TextUtils; + +import androidx.collection.ArrayMap; + +import com.fongmi.android.tv.api.SiteApi; +import com.fongmi.android.tv.api.config.VodConfig; +import com.fongmi.android.tv.bean.Class; +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.Rule; +import com.fongmi.android.tv.bean.Site; +import com.fongmi.android.tv.bean.Value; +import com.fongmi.android.tv.bean.Vod; +import com.fongmi.android.tv.server.impl.Process; +import com.fongmi.android.tv.utils.Sniffer; +import com.github.catvod.bean.Doh; +import com.github.catvod.crawler.Spider; +import com.github.catvod.crawler.SpiderDebug; +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.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import okio.ByteString; + +import fi.iki.elonen.NanoHTTPD; +import fi.iki.elonen.NanoHTTPD.IHTTPSession; +import fi.iki.elonen.NanoHTTPD.Response; + +/** + * 当前已加载站源的 T4 网关,复用 NanoHTTPD 端口(默认 9978)。 + * 配置:/vod/api?ac=config;站点:/vod/api?key=... 或 /vod/api/。 + * T3 直接调用 Spider;远端站源仅复用 SiteApi.call 的 HTTP 层,不进入本机播放器。 + * 协议参考用户提供的实验室 T4Proxy.js,并以 SiteApi 的实际 T4 请求为互操作契约。 + */ +public class VodApi implements Process { + + static final String PATH = "/vod/api"; + static final String WARNING = "资源来自网络,仅供学习使用"; + /** 与 T4Proxy.js / vod.php 一致:站源没给总页数时按 9999 处理。 */ + static final int DEFAULT_PAGE_COUNT = 9999; + static final int DEFAULT_LIMIT = 20; + private static final String[] FIELDS = {"area", "year", "type", "class", "lang"}; + /** 与 {@code App.gson()} 等价(那里就是 new Gson()),独立持有以便单元测试。 */ + private static final Gson GSON = new Gson(); + private final VodConfig sources; + + public VodApi() { + this(VodConfig.get()); + } + + VodApi(VodConfig sources) { + this.sources = sources; + } + + @Override + public boolean isRequest(IHTTPSession session, String url) { + return PATH.equals(url) || (url != null && url.startsWith(PATH + "/")); + } + + @Override + public Response doResponse(IHTTPSession session, String url, Map files) { + if (session.getMethod() == NanoHTTPD.Method.OPTIONS) { + return cors(NanoHTTPD.newFixedLengthResponse(Response.Status.NO_CONTENT, "application/json", "")); + } + if (session.getMethod() != NanoHTTPD.Method.GET && session.getMethod() != NanoHTTPD.Method.POST + && session.getMethod() != NanoHTTPD.Method.HEAD) { + return cors(NanoHTTPD.newFixedLengthResponse(Response.Status.METHOD_NOT_ALLOWED, "application/json", error("不支持的 HTTP 方法").toString())); + } + try { + JsonObject input = input(session, files); + String origin = origin(session); + String key = siteKeyFromPath(url); + if (key.isEmpty()) key = text(input, "key"); + if (isConfig(input) || (key.isEmpty() && input.isEmpty())) { + return json(configJson(sources.getSites(), origin + PATH, sources.getDoh(), sources.getRules())); + } + SpiderDebug.log("vod-api", "route=%s", route(input)); + JsonObject result = handle(input, key, origin); + Site site = sources.getSite(key); + String proxyKey = site != null && !site.isEmpty() && site.getType() == 3 && supportsDirectProxy(site.spider()) ? key : ""; + externalize(result, origin, proxyKey, com.github.catvod.Proxy.getPort()); + return json(result); + } catch (Exception e) { + SpiderDebug.log("vod-api", e); + return json(error(e instanceof IllegalArgumentException ? e.getMessage() : "站源请求失败,请检查站源或查看应用日志")); + } + } + + private JsonObject handle(JsonObject input, String key, String origin) throws Exception { + if (key.isEmpty()) return error("缺少站点 key 参数"); + Site site = sources.getSite(key); + if (site == null || site.isEmpty()) return error("site not found: " + key); + if (isSelfGateway(site.getApi(), origin, com.github.catvod.Proxy.getPort())) return error("不能把本网关的配置作为本机源再次导入"); + int page = Math.max(1, parseInt(text(input, "pg"), 1)); + String route = route(input); + String flag = text(input, "flag"); + String id = text(input, "id").isEmpty() ? text(input, "play") : text(input, "id"); + List ids = ids(input); + if ("play".equals(route) && (flag.isEmpty() || id.isEmpty())) return error("缺少 flag 或 id 参数"); + if ("detail".equals(route) && ids.isEmpty()) return error("缺少 ids 参数"); + String tid = text(input, "t"); + boolean filter = !input.has("f") || truthy(text(input, "f")); + if (input.has("filter")) filter = truthy(text(input, "filter")); + JsonObject raw; + if (site.getType() == 3) { + // 不切换全局 recent loader;返回 /proxy 时以 siteKey 明确路由。 + Spider spider = site.spider(); + String content; + switch (route) { + case "search": + boolean quick = truthy(text(input, "quick")); + content = page == 1 ? spider.searchContent(text(input, "wd"), quick) + : spider.searchContent(text(input, "wd"), quick, String.valueOf(page)); + break; + case "detail": content = spider.detailContent(ids); break; + case "play": content = spider.playerContent(flag, id, sources.getFlags()); break; + default: + content = tid.isEmpty() ? spider.homeContent(filter) + : spider.categoryContent(tid, String.valueOf(page), filter, extendFrom(input)); + } + raw = sourceObject(content); + if ("list".equals(route) && tid.isEmpty()) { + // 推荐内容是可选的;保留已经成功拿到的首页分类和筛选。 + try { + JsonObject video = sourceObject(spider.homeVideoContent()); + if (video.has("list") && video.get("list").isJsonArray() && !video.getAsJsonArray("list").isEmpty()) raw.add("list", video.get("list")); + } catch (Exception e) { + SpiderDebug.log("vod-api", "homeVideoContent unavailable: %s", e.getClass().getSimpleName()); + } + } + } else { + if ("play".equals(route) && site.getType() != 4) { + Result result = new Result(); + result.setUrl(id); + result.setFlag(flag); + result.setHeader(site.getHeader()); + result.setPlayUrl(site.getPlayUrl()); + result.setParse(Sniffer.isVideoFormat(id) && site.getPlayUrl().isEmpty() ? 0 : 1); + return play(result, flag); + } + ArrayMap params = new ArrayMap<>(); + switch (route) { + case "search": + params.put("wd", text(input, "wd")); + params.put("pg", String.valueOf(page)); + params.put("quick", String.valueOf(truthy(text(input, "quick")))); + break; + case "detail": + params.put("ac", site.getType() == 0 ? "videolist" : "detail"); + params.put("ids", String.join(",", ids)); + break; + case "play": params.put("play", id); params.put("flag", flag); break; + default: + if (tid.isEmpty()) { + params.put("filter", String.valueOf(filter)); + if (site.getType() == 4) site.fetchExt(); + } else { + params.put("ac", site.getType() == 0 ? "videolist" : "detail"); + params.put("t", tid); + params.put("pg", String.valueOf(page)); + String extend = GSON.toJson(extendFrom(input)); + if (site.getType() == 4) params.put("ext", ByteString.encodeUtf8(extend).base64Url()); + if (site.getType() == 1) params.put("f", extend); + } + } + String content = SiteApi.call(site, params); + raw = site.getType() == 0 ? GSON.toJsonTree(Result.fromXml(content)).getAsJsonObject() : sourceObject(content); + } + if (raw.has("error") || "-1".equals(text(raw, "code"))) return error("站源返回错误"); + if ("play".equals(route)) return playJson(raw, flag, site); + return formatContent(raw, page); + } + + private static JsonObject sourceObject(String raw) { + if (raw == null || raw.isEmpty()) return new JsonObject(); + JsonObject object = jsonObject(raw); + if (object == null) throw new IllegalArgumentException("站源返回的内容不是 JSON 对象"); + return object; + } + + private static List ids(JsonObject input) { + List ids = new ArrayList<>(); + JsonElement raw = input.get("ids"); + if (raw != null && raw.isJsonArray()) { + for (JsonElement id : raw.getAsJsonArray()) if (id.isJsonPrimitive() && !id.getAsString().trim().isEmpty()) ids.add(id.getAsString().trim()); + } else { + for (String id : csv(input, "ids").split(",")) if (!id.trim().isEmpty()) ids.add(id.trim()); + } + if (ids.size() > 100) throw new IllegalArgumentException("一次最多查询 100 个视频"); + return ids; + } + + /** ac=detail 既用于分类也用于详情;具体参数优先于 ac。 */ + static String route(JsonObject input) { + String ac = text(input, "ac"); + if ("config".equals(ac) || "site".equals(ac)) return "config"; + if (!text(input, "wd").isEmpty()) return "search"; + if (input.has("ids")) return "detail"; + if ("play".equals(ac) || !text(input, "play").isEmpty() + || (!text(input, "flag").isEmpty() && !text(input, "id").isEmpty())) return "play"; + return "list"; + } + + private static boolean isConfig(JsonObject input) { + return "config".equals(route(input)); + } + + /** NanoHTTPD 已解码 URI;不要再次解码或将 key 中的 '?'、'/' 当分隔符。 */ + static String siteKeyFromPath(String url) { + return url != null && url.startsWith(PATH + "/") ? url.substring(PATH.length() + 1) : ""; + } + + /** 保留原始分页/筛选元数据,同时用公共字段白名单隔离宿主内部状态。 */ + static JsonObject formatContent(JsonObject raw, int page) { + Result result = new Result(); + List vods = new ArrayList<>(); + List originals = new ArrayList<>(); + JsonElement rawList = raw.get("list"); + if (rawList != null && rawList.isJsonArray()) { + for (JsonElement element : rawList.getAsJsonArray()) { + if (!element.isJsonObject()) continue; + JsonObject item = element.getAsJsonObject().deepCopy(); + item.remove("site"); + item.remove("tmdb"); + Vod vod = GSON.fromJson(item, Vod.class); + if (item.has("flags") && item.get("flags").isJsonArray()) vod.setFlags(Arrays.asList(GSON.fromJson(item.get("flags"), Flag[].class))); + vods.add(vod); + originals.add(item); + } + } + result.setList(vods); + List types = new ArrayList<>(); + JsonElement rawTypes = raw.get("class"); + if (rawTypes != null && rawTypes.isJsonArray()) { + for (JsonElement type : rawTypes.getAsJsonArray()) if (type.isJsonObject()) types.add(GSON.fromJson(type, Class.class)); + } + result.setTypes(types); + JsonObject out = macCms(result, page); + int actualPage = Math.max(1, parseInt(text(raw, "page"), page)); + int pageCount = Math.max(1, parseInt(text(raw, "pagecount"), vods.isEmpty() ? actualPage : DEFAULT_PAGE_COUNT)); + int limit = Math.max(1, parseInt(text(raw, "limit"), vods.isEmpty() ? DEFAULT_LIMIT : vods.size())); + out.addProperty("page", actualPage); + out.addProperty("pagecount", pageCount); + out.addProperty("limit", limit); + long total = (long) pageCount * limit; + try { total = Math.max(0L, Long.parseLong(text(raw, "total"))); } catch (NumberFormatException ignored) { } + out.addProperty("total", total); + for (int i = 0; i < originals.size(); i++) { + JsonObject item = out.getAsJsonArray("list").get(i).getAsJsonObject(); + for (String name : new String[]{"type_id", "vod_tag"}) { + if (originals.get(i).has(name) && originals.get(i).get(name).isJsonPrimitive()) item.add(name, originals.get(i).get(name)); + } + } + if (raw.has("filters") && raw.get("filters").isJsonObject()) out.add("filters", raw.get("filters").deepCopy()); + if (!text(raw, "msg").isEmpty()) out.addProperty("msg", text(raw, "msg")); + return out; + } + + /** 保留 Spider 的解析提示/URL 数组,不假定服务端已经完成嗅探或提取。 */ + static JsonObject playJson(JsonObject raw, String flag, Site site) { + JsonObject out = new JsonObject(); + for (String field : new String[]{"url", "urls", "playUrl", "flag", "parse", "jx", "format", "header", "drm", "subs", "click", "jxFrom", "msg", "danmaku"}) { + if (raw.has(field) && !raw.get(field).isJsonNull()) out.add(field, raw.get(field).deepCopy()); + } + if (!out.has("url")) out.addProperty("url", ""); + if (text(out, "flag").isEmpty()) prop(out, "flag", flag); + if (!out.has("parse")) out.addProperty("parse", 0); + if (!out.has("jx")) out.addProperty("jx", 0); + if (!out.has("header") || (out.get("header").isJsonObject() && out.getAsJsonObject("header").isEmpty())) out.add("header", GSON.toJsonTree(site.getHeader())); + return out; + } + + /** 只改实际 URL 字段;请求头、视频 ID 和签名 query 内嵌字符串不能做全局替换。 */ + static void externalize(JsonObject out, String origin, String key, int port) { + for (String field : new String[]{"url", "urls", "playUrl", "subs", "drm"}) { + if (out.has(field)) out.add(field, mediaUrls(out.get(field), origin, key, port)); + } + if (out.has("list") && out.get("list").isJsonArray()) { + for (JsonElement element : out.getAsJsonArray("list")) { + if (!element.isJsonObject()) continue; + JsonObject item = element.getAsJsonObject(); + if (item.has("vod_pic")) item.add("vod_pic", mediaUrls(item.get("vod_pic"), origin, key, port)); + } + } + } + + private static JsonElement mediaUrls(JsonElement value, String origin, String key, int port) { + if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { + return new com.google.gson.JsonPrimitive(remoteUrl(value.getAsString(), origin, key, port)); + } + if (value.isJsonArray()) { + JsonArray array = new JsonArray(); + for (JsonElement item : value.getAsJsonArray()) array.add(mediaUrls(item, origin, key, port)); + return array; + } + if (value.isJsonObject()) { + JsonObject object = value.getAsJsonObject(); + for (String field : new String[]{"url", "v", "values", "server"}) { + if (object.has(field)) object.add(field, mediaUrls(object.get(field), origin, key, port)); + } + } + return value; + } + + static String remoteUrl(String url, String origin, String key, int port) { + try { + URI uri = URI.create(url); + String host = uri.getHost(); + if (port <= 0 || uri.getPort() != port || host == null || uri.getRawUserInfo() != null + || !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) return url; + if (!loopback(host) && !host.equalsIgnoreCase(URI.create(origin).getHost())) return url; + String path = uri.getRawPath(); + String query = uri.getRawQuery(); + if (("/proxy".equals(path) || path.startsWith("/proxy/")) && !key.isEmpty() + && (query == null || !(query.startsWith("siteKey=") || query.contains("&siteKey=")))) { + query = (query == null || query.isEmpty() ? "" : query + "&") + "siteKey=" + encode(key); + } + return origin + path + (query == null ? "" : "?" + query) + (uri.getRawFragment() == null ? "" : "#" + uri.getRawFragment()); + } catch (IllegalArgumentException ignored) { + return url; + } + } + + /** 老式 JAR 的代理在集中 Proxy 类上;强加 siteKey 会绕过 JarLoader 的兼容分派。 */ + static boolean supportsDirectProxy(Spider spider) { + try { + return spider.getClass().getMethod("proxy", Map.class).getDeclaringClass() != Spider.class; + } catch (ReflectiveOperationException e) { + return false; + } + } + + private static boolean loopback(String host) { + return "127.0.0.1".equals(host) || "localhost".equalsIgnoreCase(host) || "[::1]".equals(host) || "::1".equals(host); + } + + static boolean isSelfGateway(String api, String origin, int port) { + try { + URI uri = URI.create(api); + String path = uri.getPath(); + if (uri.getHost() == null || path == null || !(PATH.equals(path) || path.startsWith(PATH + "/"))) return false; + return (loopback(uri.getHost()) && uri.getPort() == port) || uri.getRawAuthority().equalsIgnoreCase(URI.create(origin).getRawAuthority()); + } catch (IllegalArgumentException ignored) { + return false; + } + } + + /** 苹果CMS 列表信封。 */ + static JsonObject macCms(Result result, int page) { + Result safe = result == null ? Result.empty() : result; + JsonArray list = new JsonArray(); + for (Vod vod : safe.getList()) list.add(vodJson(vod)); + int limit = safe.getList().isEmpty() ? DEFAULT_LIMIT : safe.getList().size(); + int pageCount = safe.getPageCount() > 0 ? safe.getPageCount() : safe.getList().isEmpty() ? Math.max(1, page) : DEFAULT_PAGE_COUNT; + JsonObject out = new JsonObject(); + out.addProperty("page", page); + out.addProperty("pagecount", pageCount); + out.addProperty("limit", limit); + out.addProperty("total", (long) pageCount * limit); + out.add("list", list); + JsonArray types = new JsonArray(); + for (Class type : safe.getTypes()) types.add(typeJson(type)); + if (!types.isEmpty()) out.add("class", types); + JsonElement filters = GSON.toJsonTree(safe.getFilters()); + if (filters.isJsonObject() && !filters.getAsJsonObject().isEmpty()) out.add("filters", filters); + return out; + } + + /** 苹果CMS 播放信封:给 T4 客户端一条已解析好的可播放地址。 */ + static JsonObject play(Result result, String flag) { + Result safe = result == null ? Result.empty() : result; + JsonObject out = new JsonObject(); + prop(out, "url", safe.getRealUrl()); + prop(out, "flag", safe.getFlag().isEmpty() ? flag : safe.getFlag()); + out.addProperty("parse", safe.getParse()); + out.addProperty("jx", safe.getJx()); + String format = safe.getFormat(); + if (!TextUtils.isEmpty(format)) prop(out, "format", format); + JsonElement header = GSON.toJsonTree(safe.getHeader()); + if (header.isJsonObject() && !header.getAsJsonObject().isEmpty()) out.add("header", header); + if (safe.getDrm() != null) out.add("drm", GSON.toJsonTree(safe.getDrm())); + if (!safe.getSubs().isEmpty()) out.add("subs", GSON.toJsonTree(safe.getSubs())); + if (safe.getUrl().isMulti()) { + JsonArray urls = new JsonArray(); + for (Value value : safe.getUrl().getValues()) urls.add(value.getV()); + out.add("urls", urls); + } + if (safe.hasMsg()) prop(out, "msg", safe.getMsg()); + return out; + } + + /** 单个视频条目,只输出公开的苹果CMS 字段,不泄漏站点配置等内部状态。 */ + static JsonObject vodJson(Vod vod) { + JsonObject item = new JsonObject(); + prop(item, "vod_id", vod.getId()); + prop(item, "vod_name", vod.getName()); + prop(item, "type_id", ""); + prop(item, "type_name", vod.getTypeName()); + prop(item, "vod_pic", vod.getPic()); + prop(item, "vod_remarks", vod.getRemarks()); + prop(item, "vod_year", vod.getYear()); + prop(item, "vod_area", vod.getArea()); + prop(item, "vod_director", vod.getDirector()); + prop(item, "vod_actor", vod.getActor()); + prop(item, "vod_content", vod.getContent()); + prop(item, "vod_play_from", playFrom(vod)); + prop(item, "vod_play_url", playUrl(vod)); + String action = vod.getAction(); + if (!TextUtils.isEmpty(action)) prop(item, "action", action); + return item; + } + + static JsonObject typeJson(Class type) { + JsonObject item = new JsonObject(); + prop(item, "type_id", type.getTypeId()); + prop(item, "type_name", type.getTypeName()); + if (!type.getTypeFlag().isEmpty()) prop(item, "type_flag", type.getTypeFlag()); + return item; + } + + /** 字段可能来自爬虫的原始 JSON,缺失时是 null;统一转成空串,避免写响应时 NPE。 */ + private static void prop(JsonObject target, String key, String value) { + target.addProperty(key, value == null ? "" : value); + } + + /** + * 线路名。T3 爬虫常只回 {@code flags} 数组而不回 {@code vod_play_from}, + * 直接透传会让 T4 客户端看到空线路,所以这里按苹果CMS 的 {@code $$$} 规则回填。 + */ + static String playFrom(Vod vod) { + String raw = vod.getPlayFrom(); + if (!raw.isEmpty()) return raw; + List flags = new ArrayList<>(); + for (Flag flag : vod.getFlags()) if (flag != null) flags.add(flag.getFlag()); + return String.join("$$$", flags); + } + + /** 播放地址,回填规则同 {@link #playFrom(Vod)},集与集之间用 {@code #}。 */ + static String playUrl(Vod vod) { + String raw = vod.getPlayUrl(); + if (!raw.isEmpty()) return raw; + List groups = new ArrayList<>(); + for (Flag flag : vod.getFlags()) { + if (flag == null) continue; + if (flag.getEpisodes() == null || flag.getEpisodes().isEmpty()) { + groups.add(flag.getUrls()); + continue; + } + List episodes = new ArrayList<>(); + for (Episode episode : flag.getEpisodes()) if (episode != null) episodes.add(episode.getName() + "$" + episode.getUrl()); + groups.add(String.join("#", episodes)); + } + return String.join("$$$", groups); + } + + /** T4 配置:站点 {@code api} 指回本网关,因此整条链路不再依赖外部脚本。 */ + static JsonObject configJson(List sites, String baseUrl, List doh, List rules) { + JsonObject config = new JsonObject(); + config.addProperty("spider", ""); + config.addProperty("wallpaper", ""); + config.addProperty("warningText", WARNING); + JsonArray array = new JsonArray(); + if (sites != null) { + for (Site site : sites) { + if (site == null || site.getKey().isEmpty()) continue; + JsonObject item = new JsonObject(); + item.addProperty("key", site.getKey()); + item.addProperty("name", site.getName()); + item.addProperty("api", baseUrl + "?key=" + encode(site.getKey())); + item.addProperty("type", "4"); + item.addProperty("searchable", site.isSearchable() ? 1 : 0); + item.addProperty("quickSearch", site.isQuickSearch() ? 1 : 0); + item.addProperty("filterable", 1); + array.add(item); + } + } + config.add("sites", array); + config.add("doh", doh == null ? new JsonArray() : GSON.toJsonTree(doh)); + config.add("rules", rules == null ? new JsonArray() : GSON.toJsonTree(rules)); + config.add("lives", new JsonArray()); + return config; + } + + /** 筛选参数:base64 {@code ext} → JSON {@code extend} → 显式字段,后者覆盖前者。 */ + static HashMap extendFrom(JsonObject input) { + HashMap extend = new HashMap<>(); + putAll(extend, decodeExtend(text(input, "ext"))); + JsonElement raw = input.get("extend"); + putAll(extend, raw != null && raw.isJsonObject() ? raw.getAsJsonObject() : jsonObject(text(input, "extend"))); + for (String field : FIELDS) { + String value = text(input, field); + if (!value.isEmpty()) extend.put(field, value); + } + return extend; + } + + private static void putAll(HashMap target, JsonObject source) { + if (source == null) return; + for (String key : source.keySet()) { + JsonElement value = source.get(key); + if (value != null && value.isJsonPrimitive()) target.put(key, value.getAsString()); + } + } + + /** TVBox 的 T4 约定:URL-safe base64 的 JSON。 */ + static JsonObject decodeExtend(String ext) { + if (TextUtils.isEmpty(ext)) return null; + try { + ByteString decoded = ByteString.decodeBase64(ext); + return decoded == null ? null : jsonObject(decoded.utf8()); + } catch (Throwable e) { + return null; + } + } + + static JsonObject jsonObject(String json) { + if (TextUtils.isEmpty(json)) return null; + try { + JsonElement element = JsonParser.parseString(json); + return element != null && element.isJsonObject() ? element.getAsJsonObject() : null; + } catch (Throwable e) { + return null; + } + } + + private static JsonObject input(IHTTPSession session, Map files) { + JsonObject input = new JsonObject(); + for (Map.Entry entry : session.getParms().entrySet()) input.addProperty(entry.getKey(), entry.getValue()); + String post = files == null ? null : files.get("postData"); + JsonObject body = jsonObject(post); + if (post != null && !post.isEmpty() && body == null) throw new IllegalArgumentException("POST body 必须是 JSON 对象"); + if (body != null) for (String key : body.keySet()) input.add(key, body.get(key)); + return input; + } + + private static String origin(IHTTPSession session) { + String host = session.getHeaders().get("host"); + if (TextUtils.isEmpty(host)) host = "127.0.0.1:" + com.github.catvod.Proxy.getPort(); + URI uri = URI.create("http://" + host); + if (uri.getHost() == null || uri.getRawUserInfo() != null || !uri.getRawPath().isEmpty() + || uri.getRawQuery() != null || uri.getRawFragment() != null) throw new IllegalArgumentException("无效的 Host"); + return "http://" + host; + } + + private static String encode(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20"); + } catch (java.io.UnsupportedEncodingException impossible) { + throw new IllegalStateException(impossible); + } + } + + static String text(JsonObject input, String key) { + if (input == null || !input.has(key)) return ""; + JsonElement element = input.get(key); + if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) return ""; + return element.getAsString(); + } + + /** {@code ids} 允许逗号分隔或 JSON 数组(POST body)。 */ + static String csv(JsonObject input, String key) { + if (input == null || !input.has(key)) return ""; + JsonElement element = input.get(key); + if (element == null || element.isJsonNull()) return ""; + if (element.isJsonArray()) { + StringBuilder builder = new StringBuilder(); + for (JsonElement item : element.getAsJsonArray()) { + if (item == null || item.isJsonNull()) continue; + if (builder.length() > 0) builder.append(","); + builder.append(item.getAsString()); + } + return builder.toString(); + } + return element.isJsonPrimitive() ? element.getAsString() : ""; + } + + private static boolean truthy(String value) { + return "1".equals(value) || "true".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value); + } + + private static int parseInt(String value, int fallback) { + try { + return Integer.parseInt(value.trim()); + } catch (Exception e) { + return fallback; + } + } + + static JsonObject error(String msg) { + JsonObject result = new JsonObject(); + result.addProperty("code", -1); + result.addProperty("msg", msg == null ? "" : msg); + return result; + } + + private static Response json(JsonObject object) { + return cors(NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "application/json; charset=utf-8", object.toString())); + } + + private static Response cors(Response response) { + response.addHeader("Access-Control-Allow-Origin", "*"); + response.addHeader("Access-Control-Allow-Methods", "GET, POST, HEAD, OPTIONS"); + response.addHeader("Access-Control-Allow-Headers", "Content-Type"); + return response; + } +} 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..03df37d090 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 @@ -34,6 +34,7 @@ import com.fongmi.android.tv.browse.BrowseTree; import com.fongmi.android.tv.event.ActionEvent; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.playback.PlaybackEventCollector; +import com.fongmi.android.tv.player.PlaybackOwnershipKeys; import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.engine.PlaySpec; import com.fongmi.android.tv.player.lyrics.DesktopLyricsWindow; @@ -447,8 +448,15 @@ private AudioHistory.Record audioHistoryRecord; setNavigationCallback(null, null); } + /** + * 导航归属:本服务记录的 {@link #navigationKey} 是否就是当前播放会话。 + * + *

自动连播时服务自己用「浏览下一集」的 {@code VE:...} 键起播,而 {@code navigationKey} + * 仍是页面写入的记录键;两者字符串不等但同属一次会话。旧实现按字符串判假,后果是下一次 + * ENDED 被当成「无主」事件,服务直接再 {@code navigateItem(1)} 跳一集,连播变成连跳。 + */ private boolean isNavigationOwner() { - return isPlayerAvailable() && (navigationKey == null || navigationKey.equals(player.getKey())); + return isPlayerAvailable() && PlaybackOwnershipKeys.sameSession(navigationKey, player.getKey()); } public void addPlayerCallback(PlayerCallback callback) { diff --git a/app/src/main/java/com/fongmi/android/tv/service/ReaderTtsService.java b/app/src/main/java/com/fongmi/android/tv/service/ReaderTtsService.java new file mode 100644 index 0000000000..2447be7612 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/service/ReaderTtsService.java @@ -0,0 +1,598 @@ +package com.fongmi.android.tv.service; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; +import android.text.TextUtils; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.tts.ReaderTtsController; +import com.fongmi.android.tv.tts.TtsEngineConfig; +import com.fongmi.android.tv.tts.TtsOptions; +import com.fongmi.android.tv.tts.TtsVoice; +import com.github.catvod.crawler.SpiderDebug; + +import java.util.List; + +/** + * 小说朗读前台服务。 + * + * 对齐 legado {@code BaseReadAloudService} 的能力:朗读在后台/锁屏后继续、通知栏提供 + * 上一段/播放暂停/下一段/停止/定时、遵守音频焦点、拔耳机(ACTION_AUDIO_BECOMING_NOISY)自动暂停。 + * + * 与服务外的交互采用本仓库既有模式({@link PlaybackService} 的静态访问 + {@code App.get()}), + * 不额外引入 Binder/绑定抽象:阅读页启动服务后通过 {@link #get()} 拿到实例下发朗读任务, + * 服务再通过 {@link WebCallback} 把状态回传给阅读页 WebView。 + */ +public class ReaderTtsService extends Service implements ReaderTtsController.Listener { + + private static final String TAG = "TV-tts"; + private static final String CHANNEL_ID = "reader_tts"; + private static final int NOTIFY_ID = 0x7EAD; + + public static final String ACTION_START = "com.fongmi.android.tv.tts.START"; + public static final String ACTION_PAUSE = "com.fongmi.android.tv.tts.PAUSE"; + public static final String ACTION_RESUME = "com.fongmi.android.tv.tts.RESUME"; + public static final String ACTION_TOGGLE = "com.fongmi.android.tv.tts.TOGGLE"; + public static final String ACTION_PREV = "com.fongmi.android.tv.tts.PREV"; + public static final String ACTION_NEXT = "com.fongmi.android.tv.tts.NEXT"; + public static final String ACTION_STOP = "com.fongmi.android.tv.tts.STOP"; + public static final String ACTION_TIMER = "com.fongmi.android.tv.tts.TIMER"; + public static final String ACTION_TIMER_OFF = "com.fongmi.android.tv.tts.TIMER_OFF"; + + /** 阅读页(WebView)回调:由 WebReaderActivity 注册,阅读页销毁时必须清空。 */ + public interface WebCallback { + void onTtsState(String state, int paragraph, int total, String message, String title); + + void onTtsParagraph(int paragraph, int total, String text); + + void onTtsChapterEnd(); + + void onTtsVoices(List voices); + } + + /** + * 阅读页下发的朗读任务。 + * + * 朗读正文可能很长(整章段落),不走 Intent extra(Binder 事务上限), + * 而是静态传参:阅读页先赋值再 startForegroundService,服务在 onStartCommand + * 的 ACTION_START 分支取走。同进程同主线程,顺序确定。 + */ + public static final class StartRequest { + public final String source; + public final TtsOptions options; + public final TtsEngineConfig rule; + public final List paragraphs; + public final int index; + public final int timerMinutes; + public final String title; + + public StartRequest(String source, TtsOptions options, TtsEngineConfig rule, + List paragraphs, int index, int timerMinutes, String title) { + this.source = source; + this.options = options; + this.rule = rule; + this.paragraphs = paragraphs; + this.index = index; + this.timerMinutes = timerMinutes; + this.title = title; + } + } + + private static volatile StartRequest pendingStart; + + /** 阅读页在 startForegroundService 之前调用。 */ + public static void setPendingStart(StartRequest request) { + pendingStart = request; + } + + private static volatile WebCallback pendingWebCallback; + + /** + * 阅读页在服务尚未创建时先登记回调(服务 onCreate 后接管)。 + * 阅读器首次点朗读时服务还不存在,而状态回调(如系统音色列表)在服务创建后就会发出, + * 没有这个登记就会丢掉第一次回调。 + */ + public static void setPendingWebCallback(WebCallback callback) { + pendingWebCallback = callback; + } + + /** + * 章末等待阅读页续读的时间:读完一章后阅读页会立刻用新章的段落重新 start; + * 若它决定不续读(最后一章/用户已退出),服务在此时限后自行结束,避免常驻。 + */ + private static final long IDLE_STOP_DELAY_MS = 6000L; + + private static volatile ReaderTtsService instance; + + private ReaderTtsController controller; + private WebCallback webCallback; + private PowerManager.WakeLock wakeLock; + private Object focusRequest; + private boolean focusHeld; + private boolean foreground; + private final android.os.Handler handler = new android.os.Handler(android.os.Looper.getMainLooper()); + private final Runnable idleStop = this::stopSelf; + private final BroadcastReceiver noisyReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) { + onAudioBecomingNoisy(); + } + } + }; + + @Nullable + public static ReaderTtsService get() { + return instance; + } + + public static boolean isRunning() { + ReaderTtsService service = instance; + return service != null && service.controller != null && service.controller.isActive(); + } + + /** 停止朗读并结束服务(阅读页退出、或用户点停止时调用)。 */ + public static void stopAndQuit() { + ReaderTtsService service = instance; + if (service != null) service.shutdown(); + try { + App.get().stopService(new Intent(App.get(), ReaderTtsService.class)); + } catch (Throwable ignore) { + } + } + + /** 通知栏动作:不依赖阅读页是否在前台。 */ + public static void dispatch(Context context, String action, int timerMinutes) { + Intent intent = new Intent(context, ReaderTtsService.class).setAction(action); + if (timerMinutes > 0) intent.putExtra("minutes", timerMinutes); + try { + context.startService(intent); + } catch (Throwable e) { + SpiderDebug.log(TAG, "dispatch failed action=%s %s", action, e.getMessage()); + } + } + + @Override + public void onCreate() { + super.onCreate(); + instance = this; + controller = new ReaderTtsController(this); + controller.setListener(this); + WebCallback pending = pendingWebCallback; + if (pending != null) { + webCallback = pending; + pendingWebCallback = null; + } + createChannel(); + registerReceiver(noisyReceiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + String action = intent == null ? null : intent.getAction(); + if (action == null) { + // 服务被系统重建:没有朗读任务就退出,避免留下不可控的后台常驻 + if (controller == null || !controller.isActive()) stopSelf(); + return START_NOT_STICKY; + } + switch (action) { + case ACTION_PAUSE: + controller.pause(); + break; + case ACTION_RESUME: + controller.resume(); + break; + case ACTION_TOGGLE: + controller.toggle(); + break; + case ACTION_PREV: + controller.previous(); + break; + case ACTION_NEXT: + controller.next(); + break; + case ACTION_TIMER: + controller.addTimer(intent.getIntExtra("minutes", 30)); + break; + case ACTION_TIMER_OFF: + controller.setTimer(0); + break; + case ACTION_STOP: + controller.stop(null); + stopSelf(); + break; + case ACTION_START: + default: { + StartRequest request = pendingStart; + pendingStart = null; + if (request != null) startReadAloudNow(request); + break; + } + } + if (controller.isActive() || ReaderTtsController.STATE_ERROR.equals(controller.state())) { + startForegroundCompat(); + } + return START_NOT_STICKY; + } + + /** + * 音频输出路由丢失(拔耳机/蓝牙断开)→ 自动暂停。 + * + * 保持 public 是为了让真机测试能驱动广播接收器里的同一条路径: + * {@code ACTION_AUDIO_BECOMING_NOISY} 是系统保护广播,应用和 shell 都发不出去。 + */ + public void onAudioBecomingNoisy() { + SpiderDebug.log(TAG, "audio becoming noisy, pause read aloud"); + if (controller != null) controller.pause(); + } + + /* ---------------- 阅读页(WebView)调用入口 ---------------- */ + + public void setWebCallback(WebCallback callback) { + this.webCallback = callback; + } + + public void clearWebCallback(WebCallback callback) { + if (webCallback == callback) webCallback = null; + if (pendingWebCallback == callback) pendingWebCallback = null; + } + + public void startReadAloud(String source, TtsOptions options, TtsEngineConfig rule, + List paragraphs, int startIndex, int timerMinutes, String title) { + startReadAloudNow(new StartRequest(source, options, rule, paragraphs, startIndex, timerMinutes, title)); + } + + private void startReadAloudNow(StartRequest request) { + handler.removeCallbacks(idleStop); + startForegroundCompat(); + controller.start(request.source, request.options, request.rule, request.paragraphs, + request.index, request.timerMinutes, request.title); + } + + public void pauseReadAloud() { + controller.pause(); + } + + public void resumeReadAloud() { + controller.resume(); + } + + public void toggleReadAloud() { + controller.toggle(); + } + + public void prevParagraph() { + controller.previous(); + } + + public void nextParagraph() { + controller.next(); + } + + public void seekParagraph(int paragraph) { + controller.seekParagraph(paragraph); + } + + public void setRate(float rate) { + controller.setRate(rate); + } + + public void setPitch(float pitch) { + controller.setPitch(pitch); + } + + public void setVoice(String voice) { + controller.setVoice(voice); + } + + public void setTimer(int minutes) { + controller.setTimer(minutes); + } + + public String state() { + return controller.state(); + } + + public int currentParagraph() { + return controller.currentParagraph(); + } + + public int paragraphCount() { + return controller.paragraphCount(); + } + + public int timerMinutes() { + return controller.timerMinutes(); + } + + public String sourceId() { + return controller.sourceId(); + } + + /** 停播并结束服务(阅读页退出时调用)。 */ + public void shutdown() { + handler.removeCallbacks(idleStop); + if (controller != null) controller.stop(null); + stopForegroundCompat(); + stopSelf(); + } + + @Override + public void onDestroy() { + instance = null; + handler.removeCallbacks(idleStop); + try { + unregisterReceiver(noisyReceiver); + } catch (Throwable ignore) { + } + if (controller != null) { + controller.release(); + controller = null; + } + abandonFocus(); + releaseWakeLock(); + // 兜住退出路径上的残留通知:任何原因导致服务销毁时都清掉朗读通知条 + try { + android.app.NotificationManager manager = (android.app.NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (manager != null) manager.cancel(NOTIFY_ID); + } catch (Throwable ignore) { + } + super.onDestroy(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + /* ---------------- ReaderTtsController.Listener ---------------- */ + + @Override + public void onTtsState(String state, int paragraph, int total, String message, String title) { + boolean stopped = ReaderTtsController.STATE_STOPPED.equals(state); + if (stopped) { + // 章节读完(或用户停播):先给阅读页留出「续读下一章」的窗口,再自行结束 + abandonFocus(); + stopForegroundCompat(); + handler.removeCallbacks(idleStop); + handler.postDelayed(idleStop, IDLE_STOP_DELAY_MS); + } else if (ReaderTtsController.STATE_ERROR.equals(state)) { + abandonFocus(); + handler.removeCallbacks(idleStop); + startForegroundCompat(); + handler.postDelayed(idleStop, IDLE_STOP_DELAY_MS); + } else if (ReaderTtsController.STATE_PAUSED.equals(state)) { + // 暂停即让出音频焦点,否则会挡住用户暂停朗读后去放别的音频 + abandonFocus(); + handler.removeCallbacks(idleStop); + startForegroundCompat(); + } else { + handler.removeCallbacks(idleStop); + startForegroundCompat(); + acquireWakeLock(); + } + // 停播时通知已被 stopForegroundCompat 撤掉,不能再 notify: + // 否则通知栏会残留一条标题写着「正在朗读」的失效控制条, + // 而且服务随后退出也不会把它清掉(用户只能手动划掉)。 + if (!stopped) updateNotification(state, paragraph, total, message, title); + WebCallback callback = webCallback; + if (callback != null) callback.onTtsState(state, paragraph, total, message, title); + } + + @Override + public void onTtsParagraph(int paragraph, int total, String text) { + WebCallback callback = webCallback; + if (callback != null) callback.onTtsParagraph(paragraph, total, text); + } + + @Override + public void onTtsChapterEnd() { + WebCallback callback = webCallback; + if (callback != null) callback.onTtsChapterEnd(); + } + + @Override + public void onTtsVoices(List voices) { + WebCallback callback = webCallback; + if (callback != null) callback.onTtsVoices(voices); + } + + /* ---------------- 前台服务 / 通知 ---------------- */ + + private void createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager == null) return; + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, getString(R.string.reader_tts_channel), NotificationManager.IMPORTANCE_LOW); + channel.setShowBadge(false); + channel.setSound(null, null); + manager.createNotificationChannel(channel); + } + + private void startForegroundCompat() { + if (foreground) return; + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForeground(NOTIFY_ID, buildNotification(controller == null ? null : controller.state(), + controller == null ? -1 : controller.currentParagraph(), + controller == null ? 0 : controller.paragraphCount(), null, "")); + } else { + Notification notification = buildNotification(controller == null ? null : controller.state(), + controller == null ? -1 : controller.currentParagraph(), + controller == null ? 0 : controller.paragraphCount(), null, ""); + android.app.NotificationManager manager = (android.app.NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (manager != null) manager.notify(NOTIFY_ID, notification); + } + foreground = true; + } catch (Throwable e) { + SpiderDebug.log(TAG, "startForeground failed %s", e.getMessage()); + } + } + + private void stopForegroundCompat() { + if (!foreground) return; + foreground = false; + try { + stopForeground(true); + } catch (Throwable ignore) { + } + try { + android.app.NotificationManager manager = (android.app.NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (manager != null) manager.cancel(NOTIFY_ID); + } catch (Throwable ignore) { + } + releaseWakeLock(); + } + + private void updateNotification(String state, int paragraph, int total, String message, String title) { + NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (manager == null) return; + try { + manager.notify(NOTIFY_ID, buildNotification(state, paragraph, total, message, title)); + } catch (Throwable e) { + SpiderDebug.log(TAG, "notify failed %s", e.getMessage()); + } + } + + private Notification buildNotification(String state, int paragraph, int total, String message, String title) { + boolean playing = ReaderTtsController.STATE_PLAYING.equals(state); + String heading; + if (ReaderTtsController.STATE_PAUSED.equals(state)) heading = getString(R.string.reader_tts_paused); + else if (ReaderTtsController.STATE_ERROR.equals(state)) heading = getString(R.string.reader_tts_error); + else heading = getString(R.string.reader_tts_playing); + if (controller != null && controller.timerMinutes() > 0) { + heading += " · " + getString(R.string.reader_tts_timer, controller.timerMinutes()); + } + String track = TextUtils.isEmpty(title) ? getString(R.string.reader_tts_default_title) : title; + String detail = TextUtils.isEmpty(message) + ? (paragraph >= 0 ? getString(R.string.reader_tts_progress, paragraph + 1, Math.max(total, paragraph + 1)) : track) + : message; + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_lock_silent_mode_off) + .setContentTitle(heading + " · " + track) + .setContentText(detail) + .setOngoing(playing) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setVibrate(null) + .setSound(null) + .addAction(0, getString(R.string.reader_tts_prev), servicePending(ACTION_PREV, 1)) + .addAction(0, playing ? getString(R.string.reader_tts_pause) : getString(R.string.reader_tts_resume), + servicePending(playing ? ACTION_PAUSE : ACTION_RESUME, 2)) + .addAction(0, getString(R.string.reader_tts_next), servicePending(ACTION_NEXT, 3)) + .addAction(0, getString(R.string.reader_tts_timer_action), servicePending(ACTION_TIMER, 4)) + .addAction(0, getString(R.string.reader_tts_stop), servicePending(ACTION_STOP, 5)); + return builder.build(); + } + + private PendingIntent servicePending(String action, int requestCode) { + Intent intent = new Intent(this, ReaderTtsService.class).setAction(action); + if (ACTION_TIMER.equals(action)) intent.putExtra("minutes", 30); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags |= PendingIntent.FLAG_IMMUTABLE; + return PendingIntent.getService(this, requestCode, intent, flags); + } + + /* ---------------- 音频焦点 / 电源 ---------------- */ + + /** 请求音频焦点;被其他应用抢占时暂停朗读(对齐 legado 的焦点策略)。 */ + private boolean requestFocus() { + AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (manager == null) return true; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (focusRequest == null) { + AudioAttributes attributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(); + focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(attributes) + .setWillPauseWhenDucked(true) + .setOnAudioFocusChangeListener(this::onAudioFocusChange) + .build(); + } + return manager.requestAudioFocus((AudioFocusRequest) focusRequest) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; + } + return manager.requestAudioFocus(this::onAudioFocusChange, AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; + } + + private void abandonFocus() { + if (!focusHeld) return; + focusHeld = false; + AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (manager == null) return; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest instanceof AudioFocusRequest) { + manager.abandonAudioFocusRequest((AudioFocusRequest) focusRequest); + } else { + manager.abandonAudioFocus(null); + } + focusRequest = null; + } + + private void onAudioFocusChange(int change) { + if (controller == null || !controller.isActive()) return; + switch (change) { + case AudioManager.AUDIOFOCUS_LOSS: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + controller.pause(); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + controller.pause(); + break; + default: + break; + } + } + + private void acquireWakeLock() { + // 焦点只请求一次:每次状态回调都去 requestAudioFocus 会刷爆系统焦点栈, + // 也让「暂停后让出焦点」永远无法生效。 + if (!focusHeld) { + focusHeld = requestFocus(); + if (!focusHeld) { + SpiderDebug.log(TAG, "audio focus denied, pause"); + if (controller != null && controller.isPlaying()) controller.pause(); + return; + } + } + if (wakeLock == null) { + PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); + if (powerManager == null) return; + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WebHTV:ReaderTts"); + wakeLock.setReferenceCounted(false); + } + if (!wakeLock.isHeld()) wakeLock.acquire(4 * 60 * 60 * 1000L); + } + + private void releaseWakeLock() { + if (wakeLock != null && wakeLock.isHeld()) { + try { + wakeLock.release(); + } catch (Throwable ignore) { + } + } + } +} 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 index f1fab1aaf6..ec510ec09f 100644 --- a/app/src/main/java/com/fongmi/android/tv/service/TmdbConfigTestService.java +++ b/app/src/main/java/com/fongmi/android/tv/service/TmdbConfigTestService.java @@ -1,9 +1,11 @@ package com.fongmi.android.tv.service; import com.fongmi.android.tv.bean.TmdbConfig; +import com.fongmi.android.tv.utils.TmdbProxy; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import java.util.List; import java.util.Locale; import java.util.concurrent.TimeUnit; @@ -25,58 +27,121 @@ public final class TmdbConfigTestService { } public static Result test(String credential, String apiHost, String imageHost, String omdbApiKey) { - return test(CLIENT, credential, apiHost, imageHost, omdbApiKey, "https://www.omdbapi.com/"); + return test(credential, apiHost, imageHost, "", omdbApiKey); + } + + public static Result test(String credential, String apiHost, String imageHost, String proxyBase, String omdbApiKey) { + return test(CLIENT, credential, apiHost, imageHost, proxyBase, omdbApiKey, "https://www.omdbapi.com/"); } static Result test(OkHttpClient client, String credential, String apiHost, String imageHost, String omdbApiKey, String omdbBaseUrl) { + return test(client, credential, apiHost, imageHost, "", omdbApiKey, omdbBaseUrl); + } + + static Result test(OkHttpClient client, String credential, String apiHost, String imageHost, String proxyBase, String omdbApiKey, String omdbBaseUrl) { return new Result( - timed(() -> testApi(client, credential, apiHost)), - timed(() -> testImage(client, imageHost)), + timed(() -> testApi(client, credential, apiHost, proxyBase)), + timed(() -> testImage(client, imageHost, proxyBase)), timed(() -> testOmdb(client, omdbApiKey, omdbBaseUrl))); } static Check testApi(OkHttpClient client, String credential, String apiHost) { + return testApi(client, credential, apiHost, ""); + } + + static Check testApi(OkHttpClient client, String credential, String apiHost, String proxyBase) { 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"); + TmdbConfig config = config(credential, apiHost, null, proxyBase, null); + Check last = Check.failed("API route unavailable"); + for (String route : config.getApiCandidates()) { + long started = System.nanoTime(); + try { + HttpUrl base = HttpUrl.parse(route + "/configuration"); + if (base == null) { + last = Check.failed("invalid URL"); + continue; } - return Check.success(); + 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()) { + last = Check.failed("HTTP " + response.code()); + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, route); + continue; + } + ResponseBody body = response.body(); + if (body == null) { + last = Check.failed("empty response"); + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, route); + continue; + } + JsonObject json = JsonParser.parseString(body.string()).getAsJsonObject(); + if (!json.has("images") || !json.get("images").isJsonObject()) { + last = Check.failed("response is not TMDB configuration data"); + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, route); + continue; + } + TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.API, route, + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)); + return Check.success(); + } + } catch (Exception e) { + last = Check.failed(message(e)); + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, route); } - } catch (Exception e) { - return Check.failed(message(e)); } + return last; } static Check testImage(OkHttpClient client, String imageHost) { - TmdbConfig config = config(null, null, imageHost, null); + return testImage(client, imageHost, ""); + } + + static Check testImage(OkHttpClient client, String imageHost, String proxyBase) { + TmdbConfig config = config(null, null, imageHost, proxyBase, 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"); + Check last = Check.failed("image route unavailable"); + List candidates = config.getImageCandidates(); + for (String route : candidates) { + long started = System.nanoTime(); + String base = config.isImageAuto() ? TmdbProxy.imageBaseFor(route, "w342") : config.getImageBase(); + HttpUrl url = HttpUrl.parse(TmdbProxy.imageUrl(base, "/wwemzKWzjKYJFfCeiB57q3r4Bcm.png")); + if (url == null) continue; + try (Response response = client.newCall(new Request.Builder().url(url).get().build()).execute()) { + if (!response.isSuccessful()) { + last = Check.failed("HTTP " + response.code()); + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.IMAGE, route); + continue; + } + ResponseBody body = response.body(); + String type = response.header("Content-Type", "").toLowerCase(Locale.ROOT); + if (!type.startsWith("image/")) { + last = Check.failed("response is not an image"); + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.IMAGE, route); + continue; + } + if (body == null || body.contentLength() == 0) { + last = Check.failed("empty image"); + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.IMAGE, route); + continue; + } + byte[] prefix = body.source().peek().readByteArray(16); + if (!hasImageSignature(prefix)) { + last = Check.failed("invalid image data"); + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.IMAGE, route); + continue; + } + TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.IMAGE, route, + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)); + return Check.success(); + } } + return last; } catch (Exception e) { return Check.failed(message(e)); } @@ -109,13 +174,24 @@ public final class TmdbConfigTestService { } } - private static TmdbConfig config(String credential, String apiHost, String imageHost, String omdbApiKey) { + private static TmdbConfig config(String credential, String apiHost, String imageHost, String proxyBase, 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 (apiHost != null) { + if (TmdbProxy.isAuto(apiHost)) { + json.addProperty("apiAuto", true); + json.addProperty("apiBase", TmdbProxy.OFFICIAL_API); + } else json.addProperty("apiBase", trim(apiHost)); + } + if (imageHost != null) { + if (TmdbProxy.isAuto(imageHost)) { + json.addProperty("imageAuto", true); + json.addProperty("imageBase", TmdbProxy.OFFICIAL_IMAGE); + } else json.addProperty("imageBase", trim(imageHost)); + } + if (proxyBase != null) json.addProperty("proxyBase", trim(proxyBase)); if (omdbApiKey != null) json.addProperty("omdbApiKey", trim(omdbApiKey)); return TmdbConfig.objectFrom(json.toString()); } 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..fb23d8fac7 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 @@ -12,6 +12,8 @@ import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.bean.TmdbPerson; import com.fongmi.android.tv.bean.TmdbVideo; import com.fongmi.android.tv.utils.TmdbImageSelector; +import com.fongmi.android.tv.utils.TmdbLanguagePolicy; +import com.fongmi.android.tv.utils.TmdbProxy; import com.github.catvod.crawler.SpiderDebug; import com.github.catvod.utils.Path; import com.google.gson.JsonArray; @@ -36,6 +38,8 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; +import androidx.annotation.Nullable; + import okhttp3.HttpUrl; import okhttp3.Request; import okhttp3.Response; @@ -143,6 +147,27 @@ public class TmdbService { return requestJson(url, config, "detail", sourceDetailCacheKey(item, seasonNumber, config, missing), fallbackKeys, DETAIL_CACHE_TTL, "TMDB 详情返回为空", "TMDB 详情失败: HTTP ", false); } + /** + * 自动匹配消歧专用详情:只取打分真正用到的字段。 + *

+ * 消歧只读 {@code credits}/{@code aggregate_credits}(人物打分)与根对象基础字段 + * (标题/年份/国家/季数/分季标题),不读 images、translations、external_ids、 + * content_ratings、release_dates、recommendations、similar。2026-10-21 实测: + * TV 样例 446,404B → 335,387B(−25%),电影样例 192,107B → 56,056B(−71%); + * 最多 6 个候选时按倍数累积。 + *

+ * 与 {@link #detail} 共用文件缓存机制但使用独立命名空间({@code detail-lite}), + * 避免 lite 结果污染完整详情缓存;必须保留 {@code aggregate_credits},因为 + * {@link #cast} 优先读它,去掉会改变人物打分输入。 + */ + public JsonObject detailForDisambiguation(@NonNull TmdbItem item, @NonNull TmdbConfig config) throws Exception { + ensureReady(config); + String url = detailUrl(item, config, false, true); + String cacheKey = disambiguationCacheKey(item, config); + return requestJson(url, config, "detail-lite", cacheKey, List.of(), DETAIL_CACHE_TTL, + "TMDB 详情返回为空", "TMDB 详情失败: HTTP ", false); + } + private String detailAppend(@NonNull TmdbItem item, boolean includeRelated) { boolean tv = "tv".equalsIgnoreCase(item.getMediaType()); String append = tv @@ -151,11 +176,19 @@ public class TmdbService { return includeRelated ? append + ",recommendations,similar" : append; } + private String disambiguationAppend(@NonNull TmdbItem item) { + return "tv".equalsIgnoreCase(item.getMediaType()) ? "credits,aggregate_credits" : "credits"; + } + private String detailUrl(@NonNull TmdbItem item, @NonNull TmdbConfig config, boolean includeRelated) { + return detailUrl(item, config, includeRelated, false); + } + + private String detailUrl(@NonNull TmdbItem item, @NonNull TmdbConfig config, boolean includeRelated, boolean disambiguation) { return apiBuilder(config.getApiBase() + "/" + item.getMediaType() + "/" + item.getTmdbId(), config) - .addQueryParameter("language", config.getLanguage()) - .addQueryParameter("append_to_response", detailAppend(item, includeRelated)) - .addQueryParameter("include_image_language", config.getLanguage() + ",null") + .addQueryParameter("language", requestLanguage(config)) + .addQueryParameter("append_to_response", disambiguation ? disambiguationAppend(item) : detailAppend(item, includeRelated)) + .addQueryParameter("include_image_language", requestLanguage(config) + ",null") .build() .toString(); } @@ -163,9 +196,9 @@ public class TmdbService { public JsonObject season(@NonNull TmdbItem item, int seasonNumber, @NonNull TmdbConfig config) throws Exception { ensureReady(config); HttpUrl url = apiBuilder(config.getApiBase() + "/tv/" + item.getTmdbId() + "/season/" + seasonNumber, config) - .addQueryParameter("language", config.getLanguage()) + .addQueryParameter("language", requestLanguage(config)) .addQueryParameter("append_to_response", "images,credits,aggregate_credits,translations") - .addQueryParameter("include_image_language", config.getLanguage() + ",null") + .addQueryParameter("include_image_language", requestLanguage(config) + ",null") .build(); return requestJson(url.toString(), config, "season", seasonCacheKey(item, seasonNumber, config), SEASON_CACHE_TTL, "TMDB 分季返回为空", "TMDB 分季失败: HTTP "); } @@ -177,9 +210,9 @@ public class TmdbService { public JsonObject season(@NonNull TmdbItem item, int seasonNumber, @NonNull TmdbConfig config, JsonObject detail, boolean refresh) throws Exception { ensureReady(config); HttpUrl url = apiBuilder(config.getApiBase() + "/tv/" + item.getTmdbId() + "/season/" + seasonNumber, config) - .addQueryParameter("language", config.getLanguage()) + .addQueryParameter("language", requestLanguage(config)) .addQueryParameter("append_to_response", "images,credits,aggregate_credits,translations") - .addQueryParameter("include_image_language", config.getLanguage() + ",null") + .addQueryParameter("include_image_language", requestLanguage(config) + ",null") .build(); return requestJson(url.toString(), config, "season", seasonCacheKey(item, seasonNumber, config), seasonCacheTtl(detail), "TMDB 分季返回为空", "TMDB 分季失败: HTTP ", refresh); } @@ -187,9 +220,9 @@ public class TmdbService { public JsonObject episode(@NonNull TmdbItem item, int seasonNumber, int episodeNumber, @NonNull TmdbConfig config, JsonObject detail) throws Exception { ensureReady(config); HttpUrl url = apiBuilder(config.getApiBase() + "/tv/" + item.getTmdbId() + "/season/" + seasonNumber + "/episode/" + episodeNumber, config) - .addQueryParameter("language", config.getLanguage()) + .addQueryParameter("language", requestLanguage(config)) .addQueryParameter("append_to_response", "images,credits,translations") - .addQueryParameter("include_image_language", config.getLanguage() + ",null") + .addQueryParameter("include_image_language", requestLanguage(config) + ",null") .build(); return requestJson(url.toString(), config, "episode", episodeCacheKey(item, seasonNumber, episodeNumber, config), seasonCacheTtl(detail), "TMDB 单集返回为空", "TMDB 单集失败: HTTP "); } @@ -197,9 +230,9 @@ public class TmdbService { public JsonObject episode(int tmdbId, int seasonNumber, int episodeNumber, @NonNull TmdbConfig config) throws Exception { ensureReady(config); HttpUrl url = apiBuilder(config.getApiBase() + "/tv/" + tmdbId + "/season/" + seasonNumber + "/episode/" + episodeNumber, config) - .addQueryParameter("language", config.getLanguage()) + .addQueryParameter("language", requestLanguage(config)) .addQueryParameter("append_to_response", "images,credits,translations") - .addQueryParameter("include_image_language", config.getLanguage() + ",null") + .addQueryParameter("include_image_language", requestLanguage(config) + ",null") .build(); return requestJson(url.toString(), config, "episode", episodeCacheKey(tmdbId, seasonNumber, episodeNumber, config), SEASON_CACHE_TTL, "TMDB 单集返回为空", "TMDB 单集失败: HTTP "); } @@ -207,7 +240,7 @@ public class TmdbService { public JsonObject person(int personId, @NonNull TmdbConfig config) throws Exception { ensureReady(config); HttpUrl url = apiBuilder(config.getApiBase() + "/person/" + personId, config) - .addQueryParameter("language", config.getLanguage()) + .addQueryParameter("language", requestLanguage(config)) .addQueryParameter("append_to_response", "combined_credits,images,translations,external_ids") .build(); if (System.currentTimeMillis() >= 0) return requestJson(url.toString(), config, "person", PERSON_CACHE_TTL, "TMDB 演员作品返回为空", "TMDB 演员作品失败: HTTP "); @@ -336,16 +369,19 @@ public class TmdbService { List items = new ArrayList<>(); try { JsonArray results = array(season, "episodes"); + String target = requestLanguage(config); for (JsonElement element : results) { try { if (!element.isJsonObject()) continue; JsonObject object = element.getAsJsonObject(); int number = object.has("episode_number") && !object.get("episode_number").isJsonNull() ? object.get("episode_number").getAsInt() : items.size() + 1; + String title = episodeValue(object, season, number, "name", target); + String overview = episodeValue(object, season, number, "overview", target); items.add(new TmdbEpisode( number, - string(object, "name"), + title, string(object, "air_date"), - string(object, "overview"), + overview, image(config.getBackdropBase(), string(object, "still_path")), object.has("vote_average") && !object.get("vote_average").isJsonNull() ? object.get("vote_average").getAsDouble() : 0, object.has("runtime") && !object.get("runtime").isJsonNull() ? object.get("runtime").getAsInt() : 0, @@ -493,7 +529,7 @@ public class TmdbService { private List requestVideos(TmdbItem item, String path, TmdbVideo.Scope scope, int seasonNumber, int episodeNumber, TmdbConfig config) throws Exception { ensureReady(config); HttpUrl url = apiBuilder(config.getApiBase() + path, config) - .addQueryParameter("language", config.getLanguage()) + .addQueryParameter("language", requestLanguage(config)) .addQueryParameter("include_video_language", videoLanguages(config)) .build(); String cacheKey = videoCacheKey(item, scope, seasonNumber, episodeNumber, config); @@ -553,26 +589,73 @@ public class TmdbService { String mediaType = normalizeMediaType(item.getMediaType()); if (TextUtils.isEmpty(mediaType) || item.getTmdbId() <= 0) return new ArrayList<>(); HttpUrl url = apiBuilder(config.getApiBase() + "/" + mediaType + "/" + item.getTmdbId() + "/" + type, config) - .addQueryParameter("language", config.getLanguage()) + .addQueryParameter("language", requestLanguage(config)) .addQueryParameter("page", String.valueOf(Math.max(1, page))) .build(); JsonObject body = requestJson(url.toString(), config, type, DETAIL_CACHE_TTL, "TMDB 推荐返回为空", "TMDB 推荐失败: HTTP "); return items(array(body, "results"), config, mediaType); } + public String preferredTitle(@Nullable TmdbItem item, @Nullable JsonObject detail, @NonNull TmdbConfig config) { + if (item == null || detail == null) return ""; + String primary = "movie".equalsIgnoreCase(item.getMediaType()) ? string(detail, "title", "name") : string(detail, "name", "title"); + return TmdbLanguagePolicy.bestDisplayValue(primary, array(detail, "translations", "translations"), preferredTitleKey(item), requestLanguage(config)); + } + + public String seasonName(@Nullable JsonObject season, @NonNull TmdbConfig config) { + return TmdbLanguagePolicy.bestDisplayValue(string(season, "name"), translations(season), "name", requestLanguage(config)); + } + + public String seasonOverview(@Nullable JsonObject season, @NonNull TmdbConfig config) { + return TmdbLanguagePolicy.bestDisplayValue(string(season, "overview"), translations(season), "overview", requestLanguage(config)); + } + public String translatedOverview(JsonObject detail, @NonNull TmdbConfig config) { - String current = string(detail, "overview"); - if (!TextUtils.isEmpty(current)) return current; - JsonArray translations = array(detail, "translations", "translations"); - String preferred = overviewForLanguage(translations, config.getLanguage()); - if (!TextUtils.isEmpty(preferred)) return preferred; - preferred = overviewForLanguage(translations, languageRoot(config.getLanguage())); - if (!TextUtils.isEmpty(preferred)) return preferred; - preferred = overviewForLanguage(translations, "zh-CN"); - if (!TextUtils.isEmpty(preferred)) return preferred; - preferred = overviewForLanguage(translations, "zh"); - if (!TextUtils.isEmpty(preferred)) return preferred; - return overviewForLanguage(translations, "en"); + String preferred = TmdbLanguagePolicy.bestDisplayValue(string(detail, "overview"), translations(detail), "overview", requestLanguage(config)); + return preferred == null ? "" : preferred; + } + + private String preferredTitleKey(TmdbItem item) { + return "movie".equalsIgnoreCase(item.getMediaType()) ? "title" : "name"; + } + + private JsonArray translations(JsonObject object) { + return array(object, "translations", "translations"); + } + + private String episodeValue(JsonObject episode, JsonObject season, int number, String key, String target) { + JsonArray episodeTranslations = translations(episode); + if (episodeTranslations.size() > 0) { + String value = TmdbLanguagePolicy.bestDisplayValue(string(episode, key), episodeTranslations, key, target); + if (!TextUtils.isEmpty(value)) return value; + } + JsonObject seasonTranslation = seasonTranslationFor(season, number); + if (seasonTranslation != null) { + String value = TmdbLanguagePolicy.bestDisplayValue(string(episode, key), array(seasonTranslation, "translations"), key, target); + if (!TextUtils.isEmpty(value)) return value; + } + return string(episode, key); + } + + private JsonObject seasonTranslationFor(JsonObject season, int number) { + if (season == null || number <= 0) return null; + for (JsonElement element : array(season, "translations", "translations")) { + if (element == null || !element.isJsonObject()) continue; + JsonObject object = element.getAsJsonObject(); + JsonObject data = object.has("data") && object.get("data").isJsonObject() ? object.getAsJsonObject("data") : null; + if (data == null) continue; + JsonArray episodes = data.has("episode_translations") && data.get("episode_translations").isJsonArray() ? data.getAsJsonArray("episode_translations") : new JsonArray(); + for (JsonElement episodeElement : episodes) { + if (episodeElement == null || !episodeElement.isJsonObject()) continue; + JsonObject episode = episodeElement.getAsJsonObject(); + if (episode.has("episode_number") && !episode.get("episode_number").isJsonNull() && episode.get("episode_number").getAsInt() == number) return object; + } + } + return null; + } + + private String requestLanguage(TmdbConfig config) { + return TmdbLanguagePolicy.requestLanguage(config); } public List personWorks(JsonObject person, @NonNull TmdbConfig config) { @@ -592,12 +675,12 @@ public class TmdbService { public String image(String base, String path) { if (TextUtils.isEmpty(path)) return ""; - return base + (path.startsWith("/") ? path : "/" + path); + return TmdbProxy.imageUrl(base, path); } private String searchUrl(String keyword, TmdbConfig config) { return apiBuilder(config.getApiBase() + "/search/multi", config) - .addQueryParameter("language", config.getLanguage()) + .addQueryParameter("language", requestLanguage(config)) .addQueryParameter("query", keyword) .build() .toString(); @@ -661,13 +744,36 @@ public class TmdbService { private Response execute(String url, TmdbConfig config) throws Exception { 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())); + String currentBase = config.getApiBase(); + List candidates = config.getApiCandidates(); + Exception last = null; + for (String candidate : candidates) { + String requestUrl = url; + if (config.isApiAuto() && url.startsWith(currentBase)) { + requestUrl = candidate + url.substring(currentBase.length()); + } + long started = System.nanoTime(); + try { + Request.Builder builder = new Request.Builder().url(requestUrl); + if (!TextUtils.isEmpty(config.getAccessToken())) builder.header("Authorization", "Bearer " + config.getAccessToken()); + Response response = com.github.catvod.net.OkHttp.client().newCall(builder.build()).execute(); + if (response.isSuccessful() || !config.isApiAuto() || response.code() == 401 || response.code() == 403 || candidate.equals(candidates.get(candidates.size() - 1))) { + if (response.isSuccessful() && config.isApiAuto()) { + TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.API, candidate, + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)); + } + return response; + } + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, candidate); + response.close(); + } catch (Exception e) { + last = e; + if (!config.isApiAuto() || candidate.equals(candidates.get(candidates.size() - 1))) break; + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, candidate); + } } + if (last != null) throw new IOException(redactMessage(last.getMessage()), last); + throw new IOException("TMDB request failed on all automatic routes"); } public static String redactMessage(String message) { @@ -841,6 +947,11 @@ public class TmdbService { return cacheKey("detail", item.getMediaType(), item.getTmdbId(), cacheLanguage(config), includeRelated ? "full" : "core"); } + /** 消歧 lite 详情的缓存键:与完整详情、core 详情都不共用条目。 */ + String disambiguationCacheKey(@NonNull TmdbItem item, @NonNull TmdbConfig config) { + return cacheKey("detail-lite", item.getMediaType(), item.getTmdbId(), cacheLanguage(config)); + } + 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)); @@ -889,8 +1000,7 @@ public class TmdbService { } private String cacheLanguage(TmdbConfig config) { - String language = config == null ? "" : config.getLanguage(); - return TextUtils.isEmpty(language) ? "zh-CN" : language; + return TmdbLanguagePolicy.requestLanguage(config); } private boolean isOnAir(JsonObject detail) { diff --git a/app/src/main/java/com/fongmi/android/tv/setting/ConfigSyncPolicy.java b/app/src/main/java/com/fongmi/android/tv/setting/ConfigSyncPolicy.java new file mode 100644 index 0000000000..de24136edf --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/setting/ConfigSyncPolicy.java @@ -0,0 +1,12 @@ +package com.fongmi.android.tv.setting; + +import android.text.TextUtils; + +public final class ConfigSyncPolicy { + + private ConfigSyncPolicy() {} + + public static boolean shouldSyncLive(String previousVodUrl, String currentLiveUrl) { + return TextUtils.equals(currentLiveUrl, previousVodUrl); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/setting/CustomCspSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/CustomCspSetting.java index cd7441bde6..3733ac732a 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/CustomCspSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/CustomCspSetting.java @@ -463,6 +463,20 @@ public class CustomCspSetting { return load().getItems().size(); } + public static boolean matchesSearch(Item item, String query) { + if (item == null) return false; + if (item.isOther()) return matchesSearch(query, item.getName(), item.getOtherKey(), item.getOtherKeys(), item.getOtherText()); + return matchesSearch(query, item.getName(), item.getKey(), item.getApi(), item.getHomePage(), item.getUrl(), item.getExt(), item.getJar(), item.getExtensionsText()); + } + + public static boolean matchesSearch(String query, String... fields) { + String needle = query == null ? "" : query.trim().toLowerCase(Locale.ROOT); + if (needle.isEmpty()) return true; + if (fields == null) return false; + for (String field : fields) if (field != null && field.toLowerCase(Locale.ROOT).contains(needle)) return true; + return false; + } + public static Count count() { return count(load()); } diff --git a/app/src/main/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackPolicy.java b/app/src/main/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackPolicy.java new file mode 100644 index 0000000000..06fa23523d --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackPolicy.java @@ -0,0 +1,11 @@ +package com.fongmi.android.tv.setting; + +public final class FlagSwitchPlaybackPolicy { + + private FlagSwitchPlaybackPolicy() { + } + + public static boolean shouldDeferPlayback(boolean deferFlagSwitchPlayback, boolean programmaticSwitch) { + return deferFlagSwitchPlayback && !programmaticSwitch; + } +} 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..59e345740d 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 @@ -35,8 +35,11 @@ public final class MpvPerformanceSetting { public static final int VULKAN_BACKEND_LEGACY = 2; public static final int VULKAN_BACKEND_STABLE = 3; - // Keep automatic Surface direct output paused independently from hardware decoder quirks. - private static final boolean SURFACE_AUTO_STABILITY_GUARD_ENABLED = true; + // 自动电视直出只由「设备是否支持零拷贝」决定,不再有全局暂停开关。 + // 历史上 506639c9b0 曾用全局 kill switch 暂停自动直出(当时 native Surface 拆除 + // 尚未加固),但 22f2333317 已明确要求生产环境保留自动直出,且 P4-1/P4-3 已修好 + // 拆除与终态释放;残留的开关会让自动档在电视上永久退化为 GPU 合成 + 拷贝, + // 4K 高帧率下更容易掉帧/音频欠载。 private static final boolean ZERO_COPY_DEVICE_GUARD_ENABLED = true; private static final String KEY_OUTPUT_MODE = "perf_mpv_output_mode"; private static final String KEY_HWDEC = "perf_mpv_hwdec"; @@ -76,8 +79,13 @@ public final class MpvPerformanceSetting { }; } + /** + * 自动档是否允许进入电视直出。只剩设备级零拷贝保护这一个否决条件: + * 命中 {@link MpvHardwarePolicy} 的已知不兼容设备(Kirin/HiSilicon、x86 转译、 + * 模拟器)时仍强制 GPU + 兼容复制,其余设备恢复自动直出。 + */ public static boolean isAutoSurfaceDirectEnabled() { - return !SURFACE_AUTO_STABILITY_GUARD_ENABLED && !isZeroCopyBlocked(); + return !isZeroCopyBlocked(); } public static boolean isZeroCopyBlocked() { @@ -97,7 +105,7 @@ public final class MpvPerformanceSetting { return switch (clamp(outputMode, OUTPUT_AUTO, OUTPUT_SURFACE_DIRECT)) { case OUTPUT_SURFACE_DIRECT -> true; case OUTPUT_GPU -> false; - default -> !SURFACE_AUTO_STABILITY_GUARD_ENABLED && leanback && autoEligible; + default -> leanback && autoEligible; }; } 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..14476e58c3 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 @@ -110,9 +110,9 @@ public final class PlaybackPerformanceCatalog { options.add(option(LOAD_SELECTED_TRACKS, BUFFER, "只加载选中轨道", "作用:只请求当前音视频轨道,减少带宽和内存。网速/内存紧张建议开启;经常切换清晰度、音轨时可关闭以减少重新请求。代价:切换轨道可能需要重新缓冲。")); addPreload(options, false); options.add(option(CODEC_ASYNC, DECODE, "MediaCodec 队列", "作用:决定解码输出由异步还是同步队列驱动。保持自动(默认)通常吞吐最高;只有旧设备异步回调异常时才改同步。代价:同步可能更稳,但会增加等待和 CPU 调度压力。")); - options.add(option(DYNAMIC_SCHEDULING, DECODE, "Media3 动态调度", "作用:按渲染器可工作时间调度播放循环。保持开启(默认)通常更省 CPU、掉帧更少;遇到特定机型时序异常再关闭。代价:关闭后可能增加无效唤醒。")); - options.add(option(DURATION_PROGRESS, DECODE, "解码耗时推进", "作用:把异步解码耗时反馈给播放器,减少无效等待。异步队列下建议开启(默认);同步队列不生效。代价很小,关闭只用于排查时序问题。")); - options.add(option(LATE_DROP, DECODE, "输入丢帧阈值", "作用:输入帧明显迟到时提前丢弃,优先保证“跟上进度”。CPU不足、4K掉帧时建议开启;希望保留每一帧可关闭。代价:画面可能跳帧,但通常比持续延迟更容易接受。")); + options.add(option(DYNAMIC_SCHEDULING, DECODE, "Media3 动态调度", "作用:按渲染器可工作时间调度播放循环。开启后通常更省 CPU;是否“掉帧更少”取决于机型时序精度,电视盒上不保证,出现卡顿可先关此项对比。代价:关闭后可能增加无效唤醒。")); + options.add(option(DURATION_PROGRESS, DECODE, "解码耗时推进", "作用:把异步解码耗时反馈给播放器,让播放循环按预计出帧时间推迟唤醒。默认关闭(与上游一致):它只在动态调度开启且解码器为异步时生效,省 CPU 但会把唤醒预算压到很近,时序精度较差的设备上可能表现为掉帧。仅在确认设备时序稳定时可开启;同步队列不生效。")); + options.add(option(LATE_DROP, DECODE, "输入丢帧阈值", "作用:输入帧迟到超过阈值(15 毫秒,与上游默认一致)时提前丢弃,优先保证“跟上进度”。CPU不足、4K掉帧时建议开启(默认);希望保留每一帧可关闭。代价:开启时画面可能跳帧,但通常比持续延迟更容易接受;关闭后不丢输入帧,解码跟不上时可能累积音画延迟。")); options.add(option(SURFACE_FIXED_SIZE, DECODE, "Surface 固定尺寸", "作用:按视频尺寸创建 Surface,减少超高分辨率合成压力。电视4K建议开启(默认);切清晰度/旋转出现画面尺寸异常时关闭。代价:少数设备切换分辨率需要重建 Surface。")); options.add(option(DECODER_FALLBACK, DECODE, "解码器兜底", "作用:首选硬解初始化失败时尝试其他解码器。兼容性优先建议开启(默认);只想快速暴露硬件问题可关闭。代价:可能多等待一次初始化,且备用解码器性能可能较低。")); options.add(option(DV7_HDR10_FALLBACK, DECODE, "DV7处理", "默认“升级P8.1”:设备不支持当前DV7硬解、但支持P8.1硬解时,使用libdovi mode 2实时改写RPU并丢弃增强层;原生DV7可硬解时保持原样。P8.1模式会锁定整次播放,不会自动降级HDR10;转换数据无效时会停止播放。选择“降级HDR10”会整次使用基底层,兼容性更高但失去Dolby Vision动态元数据。")); 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..c8393ea076 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 @@ -2,6 +2,7 @@ package com.fongmi.android.tv.setting; import android.content.SharedPreferences; +import com.fongmi.android.tv.player.exo.ExoDecoderRuntimeProfiles; import com.github.catvod.utils.Prefers; import java.util.Collections; @@ -33,6 +34,8 @@ public class PlaybackPerformanceSetting { private static final String KEY_EXO_LOAD_CONTROL_MIGRATED = "playback_performance_exo_load_control_v2"; private static final String KEY_EXO_BACK_BUFFER_MIGRATED = "playback_performance_exo_back_buffer_v1"; private static final String KEY_EXO_REBUFFER_MIGRATED = "playback_performance_exo_rebuffer_v3"; + private static final String KEY_EXO_FRAME_SCHEDULING_PARITY_MIGRATED = + "playback_performance_exo_frame_scheduling_parity_v1"; private static final String KEY_MPV_REBUFFER_MIGRATED = "playback_performance_mpv_rebuffer_v1"; private static final String KEY_MPV_AUTO_BASELINE_MIGRATED = "playback_performance_mpv_auto_baseline_v1"; private static final String KEY_PROFILE_MERGE_SCHEMA = @@ -79,6 +82,7 @@ public class PlaybackPerformanceSetting { migrateExoLoadControl(); migrateExoBackBuffer(); migrateExoRebuffer(); + migrateExoFrameSchedulingParity(); migrateMpvRebuffer(); migrateMpvAutoBaseline(); // The former recommended-profile rollback must not run after the @@ -331,7 +335,7 @@ public class PlaybackPerformanceSetting { public static boolean isVideoDurationProgressEnabled() { ensureInitialized(); - return Prefers.getBoolean(KEY_VIDEO_DURATION_PROGRESS, true); + return Prefers.getBoolean(KEY_VIDEO_DURATION_PROGRESS, false); } public static void putVideoDurationProgressEnabled(boolean value) { @@ -495,7 +499,11 @@ public class PlaybackPerformanceSetting { public static String getSummary() { ensureInitialized(); - return getProfileName(); + // The decoder exclusion is EXO-only, while the profile name follows the selected kernel. + // Appending it for MPV/IJK would report a hardware-decode fallback that cannot happen there. + String exclusions = PlayerSetting.getPlayer() == PlayerSetting.EXO + ? ExoDecoderRuntimeProfiles.process().blacklistSummary() : ""; + return exclusions.isEmpty() ? getProfileName() : getProfileName() + "," + exclusions; } public static String getForwardBufferText() { @@ -578,7 +586,7 @@ public class PlaybackPerformanceSetting { private static void putRecommendedFlags() { put(KEY_CODEC_ASYNC_QUEUEING, true); put(KEY_DYNAMIC_SCHEDULING, true); - put(KEY_VIDEO_DURATION_PROGRESS, true); + put(KEY_VIDEO_DURATION_PROGRESS, false); put(KEY_LATE_DROP_INPUT, true); put(KEY_TRACK_LIMIT, true); put(KEY_ADAPTIVE_DOWNGRADE, true); @@ -728,6 +736,20 @@ public class PlaybackPerformanceSetting { return clampProfile(profile) != PROFILE_CUSTOM; } + private static void migrateExoFrameSchedulingParity() { + if (Prefers.getBoolean(KEY_EXO_FRAME_SCHEDULING_PARITY_MIGRATED)) return; + int profile = clampProfile(Prefers.getInt(profileKey(PlayerSetting.EXO), PROFILE_RECOMMENDED)); + if (shouldMigrateExoFrameSchedulingParity(profile) + && !isOverridden(PlayerSetting.EXO, PlaybackPerformanceCatalog.DURATION_PROGRESS)) { + put(KEY_VIDEO_DURATION_PROGRESS, false); + } + Prefers.put(KEY_EXO_FRAME_SCHEDULING_PARITY_MIGRATED, true); + } + + static boolean shouldMigrateExoFrameSchedulingParity(int profile) { + return clampProfile(profile) != PROFILE_CUSTOM; + } + private static void migrateMpvRebuffer() { if (Prefers.getBoolean(KEY_MPV_REBUFFER_MIGRATED)) return; int profile = clampProfile(Prefers.getInt(profileKey(PlayerSetting.MPV), PROFILE_RECOMMENDED)); diff --git a/app/src/main/java/com/fongmi/android/tv/setting/PlayerButtonSetting.java b/app/src/main/java/com/fongmi/android/tv/setting/PlayerButtonSetting.java index fa95e078e9..f711d22a7a 100644 --- a/app/src/main/java/com/fongmi/android/tv/setting/PlayerButtonSetting.java +++ b/app/src/main/java/com/fongmi/android/tv/setting/PlayerButtonSetting.java @@ -53,11 +53,12 @@ public class PlayerButtonSetting { private static final String ORDER = "player_button_order"; private static final String HIDDEN = "player_button_hidden"; + private static final String HIDDEN_SEEDED = "player_button_hidden_seeded"; private static final List DEFAULT = List.of( new Item(PLAYER, R.string.play_exo), new Item(DECODE, R.string.play_decode), new Item(PLAY_PARAMS, R.string.play_params), - new Item(MULTI_THREAD_PROXY, R.string.multi_thread_proxy_button), + new Item(MULTI_THREAD_PROXY, R.string.multi_thread_proxy_button, false), new Item(CODEC_CAPABILITY, R.string.codec_capability_short), new Item(SPEED, R.string.play_speed), new Item(SCALE, R.string.play_scale), @@ -139,6 +140,7 @@ public class PlayerButtonSetting { public static void reset() { Prefers.remove(ORDER); Prefers.remove(HIDDEN); + Prefers.remove(HIDDEN_SEEDED); } public static void applyOrder(ViewGroup container, Map views) { @@ -215,6 +217,11 @@ public class PlayerButtonSetting { private static Set getHidden() { Set hidden = new HashSet<>(); for (String id : split(Prefers.getString(HIDDEN))) if (contains(id)) hidden.add(id); + if (Prefers.getBoolean(HIDDEN_SEEDED)) return hidden; + // 首次运行只写入 DEFAULT 中标记为不显示的按钮,之后完全以用户在播放设置-播放器按钮中的选择为准。 + for (Item item : DEFAULT) if (!item.visible()) hidden.add(item.id()); + Prefers.put(HIDDEN, join(hidden)); + Prefers.put(HIDDEN_SEEDED, true); return hidden; } 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..ab8ddf9b6d 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 @@ -454,6 +454,15 @@ public class PlayerSetting { Prefers.put("playback_bluray_menu", enabled); } + /** Whether switching to another line must wait for an explicit play action. */ + public static boolean isDeferFlagSwitchPlayback() { + return Prefers.getBoolean("player_defer_flag_switch", false); + } + + public static void putDeferFlagSwitchPlayback(boolean defer) { + Prefers.put("player_defer_flag_switch", defer); + } + public static int getBackground() { int stored = Prefers.getInt("background", BackgroundPlaybackPolicy.ON); int normalized = BackgroundPlaybackPolicy.normalize(stored); 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..656e0a0771 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 @@ -74,6 +74,10 @@ public class Setting { public static final int GLOBAL_HISTORY_OFF = 0; public static final int GLOBAL_HISTORY_AUTO = 1; public static final int GLOBAL_HISTORY_SEARCH = 2; + // 全屏菜单键动作:0/1 为既有取值,2 为新增「下一集」,只能追加不能重排。 + public static final int FULLSCREEN_MENU_KEY_CONTROL = 0; + public static final int FULLSCREEN_MENU_KEY_EPISODES = 1; + public static final int FULLSCREEN_MENU_KEY_NEXT_EPISODE = 2; public static final int DETAIL_INTERACTION_SYSTEM = 0; public static final int INTRO_SKIP_OFF = 0; public static final int INTRO_SKIP_AUTO = 1; @@ -890,6 +894,15 @@ public class Setting { Prefers.put("zhuyin", zhuyin); } + /** Appearance mode: -1 follows system, 0 forces light, 1 forces dark. */ + public static int getThemeMode() { + return Prefers.getInt("theme_mode", -1); + } + + public static void putThemeMode(int mode) { + Prefers.put("theme_mode", mode < 0 ? -1 : Math.min(mode, 1)); + } + public static int getThemeColor() { return Prefers.getInt("theme_color", -1); } @@ -898,6 +911,14 @@ public class Setting { Prefers.put("theme_color", color); } + public static boolean isThemeColorEnabled() { + return Prefers.getBoolean("theme_color_enabled"); + } + + public static void putThemeColorEnabled(boolean enabled) { + Prefers.put("theme_color_enabled", enabled); + } + public static int getWallColor() { return Prefers.getInt("wall_color", 0); } @@ -1330,11 +1351,17 @@ public class Setting { } public static int getGlobalHistoryMode() { - return clampGlobalHistoryMode(Prefers.getInt("global_history_mode", GLOBAL_HISTORY_OFF)); + // Read the raw value so a legacy Boolean can be migrated without + // mistaking an Integer 0 for Boolean false during app startup. + Object value = Prefers.getPrefers().getAll().get("global_history_mode"); + if (value instanceof Boolean legacy) return legacy ? GLOBAL_HISTORY_AUTO : GLOBAL_HISTORY_OFF; + if (value instanceof Number number) return clampGlobalHistoryMode(number.intValue()); + return GLOBAL_HISTORY_OFF; } public static void putGlobalHistoryMode(int mode) { - Prefers.put("global_history_mode", clampGlobalHistoryMode(mode)); + mode = clampGlobalHistoryMode(mode); + Prefers.put("global_history_mode", mode == GLOBAL_HISTORY_OFF ? -1 : mode); } public static int getInterfaceFailoverMode() { @@ -1400,7 +1427,9 @@ public class Setting { } public static int getFullscreenMenuKey() { - return Prefers.getInt("fullscreen_menu_key", 0); + int menuKey = Prefers.getInt("fullscreen_menu_key", FULLSCREEN_MENU_KEY_CONTROL); + // 旧版本不认新增取值,钳制可避免设置页用越界下标取 string-array。 + return menuKey < FULLSCREEN_MENU_KEY_CONTROL || menuKey > FULLSCREEN_MENU_KEY_NEXT_EPISODE ? FULLSCREEN_MENU_KEY_CONTROL : menuKey; } public static void putFullscreenMenuKey(int menuKey) { @@ -1409,7 +1438,7 @@ public class Setting { public static int getHomeMenuKey() { int menuKey = Prefers.getInt("home_menu_key", 0); - return menuKey < 0 || menuKey > 9 ? 0 : menuKey; + return menuKey < 0 || menuKey > 11 ? 0 : menuKey; } public static void putHomeMenuKey(int menuKey) { 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..09ce64a680 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 @@ -43,7 +43,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(); @@ -58,15 +57,7 @@ final class RealtimeSubtitleRecognizer { 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,32 +75,18 @@ 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; }); @@ -262,9 +239,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 +250,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 +269,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 +327,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 deleted file mode 100644 index 0e15af925e..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactory.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.fongmi.android.tv.subtitle; - -import com.fongmi.android.tv.App; -import com.fongmi.android.tv.setting.Setting; - -import java.io.File; -import java.util.Objects; - -public final class RealtimeSubtitleSpeechRecognitionFactory implements SpeechRecognitionFactory { - - private final File root; - private final RealtimeSubtitleModelCatalog.ModelSpec spec; - - public RealtimeSubtitleSpeechRecognitionFactory() { - this(defaultRoot(), selectedModel()); - } - - private RealtimeSubtitleSpeechRecognitionFactory( - File root, RealtimeSubtitleModelCatalog.ModelSpec spec) { - this.root = Objects.requireNonNull(root, "root"); - this.spec = Objects.requireNonNull(spec, "spec"); - } - - public static RealtimeSubtitleSpeechRecognitionFactory forRoot(File root) { - return new RealtimeSubtitleSpeechRecognitionFactory(root, selectedModel()); - } - - public static boolean isSelectedModelReady() { - return new RealtimeSubtitleSpeechRecognitionFactory().isReady(); - } - - @Override - public boolean isReady() { - for (RealtimeSubtitleModelCatalog.ModelFile model - : RealtimeSubtitleModelCatalog.downloads(spec)) { - File target = modelFile(model); - if (!RealtimeSubtitleModelVerifier.isVerified( - target, model.size(), model.sha256())) { - return false; - } - } - return true; - } - - @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() { - @Override - public void onResult(String text, long startUs, long endUs, - int timelineToken) { - listener.onResult(text, startUs, endUs, timelineToken); - } - - @Override - public void onError(Throwable error) { - listener.onError(error); - } - }); - return new Session() { - private boolean closed; - - @Override - public void accept(float[] samples, long startUs, long endUs, - int timelineToken) { - if (!closed) recognizer.accept(samples, startUs, endUs, timelineToken); - } - - @Override - public void reset() { - if (!closed) recognizer.reset(); - } - - @Override - public void close() { - if (closed) return; - closed = true; - recognizer.release(); - } - }; - } - - private File modelFile(RealtimeSubtitleModelCatalog.ModelFile model) { - File parent = model.equals(RealtimeSubtitleModelCatalog.vad()) - ? root : modelDirectory(); - return new File(parent, model.relativePath()); - } - - private File modelDirectory() { - return new File(root, spec.directory()); - } - - private File vadFile() { - return new File(root, RealtimeSubtitleModelCatalog.vad().relativePath()); - } - - private static File defaultRoot() { - return new File(App.get().getFilesDir(), "realtime_subtitle"); - } - - private static RealtimeSubtitleModelCatalog.ModelSpec selectedModel() { - return RealtimeSubtitleModelCatalog.find(Setting.getRealtimeSubtitleModel()); - } -} 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 deleted file mode 100644 index 39ca7cc1d4..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/SpeechRecognitionFactory.java +++ /dev/null @@ -1,33 +0,0 @@ -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); - - void onError(Throwable error); - } - - interface Session extends AutoCloseable { - void accept(float[] samples, long startUs, long endUs, int timelineToken); - - void reset(); - - @Override - void close(); - } - - 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); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleTmdbResolver.java b/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleTmdbResolver.java index 6f72e794a9..ccdca0abae 100644 --- a/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleTmdbResolver.java +++ b/app/src/main/java/com/fongmi/android/tv/subtitle/SubtitleTmdbResolver.java @@ -1,7 +1,9 @@ package com.fongmi.android.tv.subtitle; import com.fongmi.android.tv.bean.TmdbEpisode; +import com.fongmi.android.tv.bean.TmdbConfig; import com.fongmi.android.tv.bean.TmdbItem; +import com.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.subtitle.model.ResolvedMediaIdentity; import com.fongmi.android.tv.subtitle.model.SubtitleRequest; @@ -22,7 +24,7 @@ public final class SubtitleTmdbResolver { TmdbItem tmdbItem = request.getTmdbItem(); TmdbEpisode tmdbEpisode = request.getTmdbEpisode(); if (tmdbItem == null && request.isAllowTmdbLookup() && !SubtitleStrings.isEmpty(request.getSiteKey()) && !SubtitleStrings.isEmpty(request.getVodId())) { - tmdbItem = Setting.getTmdbMatchCache().find(request.getSiteKey(), request.getVodId(), request.getVodName()); + tmdbItem = Setting.getTmdbMatchCache().find(request.getSiteKey(), request.getVodId(), request.getVodName(), TmdbLanguagePolicy.requestLanguage(TmdbConfig.effectiveCurrent())); } ResolvedMediaIdentity.Builder builder = ResolvedMediaIdentity.builder(); diff --git a/app/src/main/java/com/fongmi/android/tv/theme/AppearanceRowTheme.java b/app/src/main/java/com/fongmi/android/tv/theme/AppearanceRowTheme.java new file mode 100644 index 0000000000..8171f09baa --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/AppearanceRowTheme.java @@ -0,0 +1,91 @@ +package com.fongmi.android.tv.theme; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.StateListDrawable; +import android.view.View; +import android.widget.TextView; + +/** + * Row rendering for the “Appearance & language” dialog. + * + *

Why this exists (measured defect, not a style preference): both + * {@code AppearanceDialog} flavours used to paint every row with the fixed light + * {@code selector_git_cloud_card} ({@code #F8F9FA} fill, {@code #DADCE0} stroke, + * {@code #E8F0FE}/{@code #0B57D0} focus) while the row text already came from + * {@link ThemeController#current()}. The TV flavour compiles the dark token table + * ({@code values/webhtv_tokens.xml} is overridden by the dark table and the flavour + * ships no light table), so on TV the label rendered {@code #E2E2E9} and the value + * {@code #C4C6D0} on a {@code #F8F9FA} card. Measured on device + * ({@code 192.168.50.3:5559}, 1920x1080): label 1.16:1 and value 1.36:1 against the + * row background - i.e. the reported "text is unreadable". The same fixed card also + * ignored any custom theme profile. + * + *

The row therefore has to follow the same palette as its own text. It cannot be a + * static {@code ?attr/color*} drawable: {@code ?attr/color*} is a compile-time + * resource, Android exposes no public API to rewrite it at runtime, and the whole + * point of the profile feature is that a user override must reach this dialog. So the + * fill/stroke are built here from {@link ThemeController#current()} - the same single + * source of truth the row text already used - and {@link ThemeController#bindTheme} + * then re-colours the already-attached rows whenever the user changes the profile. + * + *

The non-focused fill is {@code colorSurfaceContainerHigh}, the role + * {@code shape_shell_proxy_dialog} gives the dialog panel. That is deliberate: a row + * that reuses the panel colour reads as an inset list inside the panel (the Material 3 + * "surface container" pattern), and on the shipped palettes it clears WCAG AA for both + * row texts by a wide margin - dark 9.05:1 / 6.85:1, light 13.23:1 / 7.19:1. + */ +public final class AppearanceRowTheme { + + private static final int CORNER_DP = 8; + + private AppearanceRowTheme() { + } + + /** + * The row background for the current palette. + * + *

Focus is the only state that needs its own colour: the row is a TV remote + * target, so it must show a ring even before it is pressed. The ring is drawn in + * {@code colorPrimary} inside a {@code colorPrimaryContainer} fill rather than as a + * primary fill, because the focused row also keeps its own on-surface text - a + * primary fill would put that text at 1.3-2.4:1 on the shipped palettes. Measured + * on the shipped dark table: focused fill vs row text 7.09:1, ring vs fill 5.32:1, + * ring vs the surrounding panel 6.78:1. + */ + public static Drawable background(Context context, ThemeTokens tokens) { + ThemeTokens safe = tokens == null ? ThemeTokens.dark() : tokens; + StateListDrawable states = new StateListDrawable(); + states.addState(new int[]{android.R.attr.state_focused}, + shape(context, safe.colorPrimaryContainer(), safe.colorPrimary(), 2)); + states.addState(new int[]{android.R.attr.state_pressed}, + shape(context, safe.colorPrimaryContainer(), safe.colorPrimary(), 2)); + states.addState(new int[]{}, + shape(context, safe.colorSurfaceContainerHigh(), safe.colorOutlineVariant(), 1)); + return states; + } + + /** + * Applies the palette to one row: background plus the two text colours the row owns. + * + *

{@code title} and {@code summary} may be {@code null} so the caller can colour a + * partially built row. + */ + public static void apply(View row, TextView title, TextView summary, ThemeTokens tokens) { + ThemeTokens safe = tokens == null ? ThemeTokens.dark() : tokens; + if (row != null) row.setBackground(background(row.getContext(), safe)); + if (title != null) title.setTextColor(safe.colorOnSurface()); + if (summary != null) summary.setTextColor(safe.colorOnSurfaceVariant()); + } + + private static GradientDrawable shape(Context context, int fill, int stroke, int strokeDp) { + float density = context.getResources().getDisplayMetrics().density; + GradientDrawable drawable = new GradientDrawable(); + drawable.setShape(GradientDrawable.RECTANGLE); + drawable.setColor(fill); + drawable.setCornerRadius(CORNER_DP * density); + if (strokeDp > 0) drawable.setStroke(Math.max(1, Math.round(strokeDp * density)), stroke); + return drawable; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/PlaybackMenuScrim.java b/app/src/main/java/com/fongmi/android/tv/theme/PlaybackMenuScrim.java new file mode 100644 index 0000000000..14f28a92d2 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/PlaybackMenuScrim.java @@ -0,0 +1,54 @@ +package com.fongmi.android.tv.theme; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; + +/** + * 播放页底部菜单的黑色遮盖层。 + * + *

强度来自主题 profile 的 {@code playerScrimOpacity}(见 + * {@link ThemeController#configuredPlayerScrimOpacity(boolean)}):未设置时回落到 + * {@link #DEFAULT_OPACITY},顶部恒为底部的 {@link #TOP_RATIO},保留从视频到控制条的 + * 柔和过渡。色相固定取 {@code colorPlayerScrim} 的 RGB:主题 profile 只能改 alpha, + * 不能把播放菜单染成不可读的配色。 + */ +public final class PlaybackMenuScrim { + + /** 未设置主题槽时的默认强度;0.80 对应顶部 60% / 底部 80% 黑。 */ + public static final float DEFAULT_OPACITY = 0.80f; + + /** 顶部相对底部的强度比例。 */ + static final float TOP_RATIO = 0.75f; + + private static final float CORNER_DP = 8f; + + private PlaybackMenuScrim() { + } + + /** The scrim for the current theme snapshot, or {@code null} when unavailable. */ + public static Drawable create(Context context) { + if (context == null) return null; + int rgb = ThemeController.current().colorPlayerScrim() & 0x00FFFFFF; + float opacity = resolveOpacity(ThemeController.configuredPlayerScrimOpacity(!ThemeController.isNight(context))); + GradientDrawable drawable = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, gradientColors(rgb, opacity)); + float radius = CORNER_DP * context.getResources().getDisplayMetrics().density; + drawable.setCornerRadii(new float[]{radius, radius, radius, radius, 0f, 0f, 0f, 0f}); + return drawable; + } + + static float resolveOpacity(Float configured) { + float value = configured == null ? DEFAULT_OPACITY : configured; + return Math.max(0f, Math.min(1f, value)); + } + + static int[] gradientColors(int rgb, float opacity) { + float clamped = Math.max(0f, Math.min(1f, opacity)); + return new int[]{withAlpha(rgb, clamped * TOP_RATIO), withAlpha(rgb, clamped)}; + } + + static int withAlpha(int rgb, float opacity) { + int alpha = Math.max(0, Math.min(255, Math.round(opacity * 255f))); + return alpha << 24 | (rgb & 0x00FFFFFF); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/PlayerSettingsRowTheme.java b/app/src/main/java/com/fongmi/android/tv/theme/PlayerSettingsRowTheme.java new file mode 100644 index 0000000000..309f218726 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/PlayerSettingsRowTheme.java @@ -0,0 +1,103 @@ +package com.fongmi.android.tv.theme; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.StateListDrawable; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.core.widget.TintableImageSourceView; + +/** + * Theme-aware rendering for the player OSD/button settings rows. + * + *

The legacy rows paired a fixed light card with dark-theme foregrounds. + * This renderer owns both sides of the color pair. Its text and icons opt out + * of baseline recoloring: an active dark color can equal a different light + * baseline role and must not be interpreted twice. + */ +public final class PlayerSettingsRowTheme { + + private static final int ROW_RADIUS_DP = 8; + private static final int SWITCH_RADIUS_DP = 24; + + private PlayerSettingsRowTheme() { + } + + /** Applies the row surface and the visible/hidden label role. */ + public static void apply(View row, TextView name, ThemeTokens tokens, boolean visible) { + ThemeTokens safe = tokens == null ? ThemeTokens.dark() : tokens; + if (row != null) row.setBackground(rowBackground(row.getContext(), safe)); + if (name != null) { + name.setTag("webhtv:ignore"); + name.setAlpha(1f); + name.setTextColor(ThemeEditorUi.readable( + visible ? safe.colorOnSurface() : safe.colorOnSurfaceVariant(), safe.colorSurfaceContainerHigh())); + } + } + + /** Applies the semantic surface and icon roles to the eye/eye-slash control. */ + public static void applySwitch(ImageView view, ThemeTokens tokens) { + if (view == null) return; + ThemeTokens safe = tokens == null ? ThemeTokens.dark() : tokens; + view.setTag("webhtv:ignore"); + view.setBackground(switchBackground(view.getContext(), safe)); + ColorStateList tint = switchTint(safe); + if (view instanceof TintableImageSourceView support) support.setSupportImageTintList(tint); + view.setImageTintList(tint); + } + + static Drawable rowBackground(Context context, ThemeTokens tokens) { + // Keep the fill stable when a child takes focus: its label does not own + // the child's drawable state. The ring alone identifies the focused row. + int fill = tokens.colorSurfaceContainerHigh(); + int ring = ThemeEditorUi.readable(tokens.colorFocus(), fill); + StateListDrawable states = new StateListDrawable(); + states.addState(new int[]{android.R.attr.state_focused}, + shape(context, fill, ring, 3, ROW_RADIUS_DP)); + states.addState(new int[]{android.R.attr.state_pressed}, + shape(context, fill, ring, 3, ROW_RADIUS_DP)); + states.addState(new int[]{}, + shape(context, tokens.colorSurfaceContainerHigh(), tokens.colorOutlineVariant(), 1, ROW_RADIUS_DP)); + return states; + } + + static Drawable switchBackground(Context context, ThemeTokens tokens) { + int ring = ThemeEditorUi.readable(tokens.colorFocus(), tokens.colorPrimaryContainer()); + StateListDrawable states = new StateListDrawable(); + states.addState(new int[]{android.R.attr.state_focused}, + shape(context, tokens.colorPrimaryContainer(), ring, 3, SWITCH_RADIUS_DP)); + states.addState(new int[]{android.R.attr.state_pressed}, + shape(context, tokens.colorPrimaryContainer(), ring, 3, SWITCH_RADIUS_DP)); + states.addState(new int[]{}, + shape(context, tokens.colorSurfaceContainerHighest(), tokens.colorOutlineVariant(), 1, SWITCH_RADIUS_DP)); + return states; + } + + private static ColorStateList switchTint(ThemeTokens tokens) { + int[][] states = new int[][]{ + {android.R.attr.state_focused}, + {android.R.attr.state_pressed}, + {} + }; + int[] colors = new int[]{ + tokens.colorOnPrimaryContainer(), + tokens.colorOnPrimaryContainer(), + ThemeEditorUi.readable(tokens.colorOnSurfaceVariant(), tokens.colorSurfaceContainerHighest()) + }; + return new ColorStateList(states, colors); + } + + private static GradientDrawable shape(Context context, int fill, int stroke, int widthDp, int radiusDp) { + float density = context.getResources().getDisplayMetrics().density; + GradientDrawable drawable = new GradientDrawable(); + drawable.setShape(GradientDrawable.RECTANGLE); + drawable.setColor(fill); + drawable.setCornerRadius(radiusDp * density); + drawable.setStroke(Math.max(1, Math.round(widthDp * density)), stroke); + return drawable; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java new file mode 100644 index 0000000000..e99bd8e7d3 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java @@ -0,0 +1,417 @@ +package com.fongmi.android.tv.theme; + +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.InsetDrawable; +import android.os.Looper; +import android.os.SystemClock; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.card.MaterialCardView; +import com.google.android.material.shape.MaterialShapeDrawable; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Locale; +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Controlled runtime channel for user theme overrides. + * + *

Android offers no public API to rewrite a compiled {@code ?attr/color*} + * value, so the binder walks the already-created view tree and swaps only colors + * that provably came from a semantic role: a view color is rewritten when it + * exactly equals a frozen baseline role value and is not stateful. Everything + * else - player surfaces, media, posters, brand and health colors, focus + * selectors, unknown colors - is left untouched. + * + *

This runs on the main thread only, is a no-op when the active tokens equal + * the baseline, and never touches a view more than once per token signature. + */ +public final class ThemeBinder { + + private static final String IGNORE_TAG = "webhtv:ignore"; + private static final String[] EXEMPT_CLASS_MARKERS = { + "surface", "texture", "video", "player", "danmaku", "subtitle", + "karaoke", "wall", "logo", "rating", "media", + }; + + private static final Map APPLIED = new WeakHashMap<>(); + private static final Map WATCHED = new WeakHashMap<>(); + private static final Map ROOTS = new WeakHashMap<>(); + private static final Map REBUILT = new WeakHashMap<>(); + + private static long cachedSignature; + + private static volatile long lastBindMillis; + private static volatile int lastBoundViews; + private static volatile int lastWalkedViews; + + private ThemeBinder() { + } + + /** Elapsed time of the most recent non-trivial bind, for the performance gate. */ + public static long lastBindMillis() { + return lastBindMillis; + } + + /** Number of views rewritten by the most recent non-trivial bind. */ + public static int lastBoundViews() { + return lastBoundViews; + } + + public static void bind(View root, ThemeTokens baseline, ThemeTokens active) { + if (root == null || baseline == null || active == null) return; + if (Looper.myLooper() != Looper.getMainLooper()) return; + if (baseline.equals(active)) { + // Default profile: the static Layer 1 theme is already correct, so the + // binder must cost nothing and change nothing. + lastBindMillis = 0L; + lastBoundViews = 0; + return; + } + long started = SystemClock.elapsedRealtimeNanos(); + long signature = signature(active); + if (signature != cachedSignature) { + REBUILT.clear(); + cachedSignature = signature; + } + ThemeColorIndex index = ThemeColorIndex.of(baseline); + int bound = walk(root, index, active, signature); + watchRoot(root, index, active, signature); + lastBoundViews = bound; + lastBindMillis = (SystemClock.elapsedRealtimeNanos() - started) / 1_000_000L; + } + + private static int walk(View root, ThemeColorIndex index, ThemeTokens active, long signature) { + int bound = 0; + int walked = 0; + Deque queue = new ArrayDeque<>(); + queue.add(root); + while (!queue.isEmpty()) { + View view = queue.poll(); + if (view == null) continue; + walked++; + if (isExempt(view)) continue; + if (bindView(view, index, active, signature)) bound++; + if (view instanceof RecyclerView recyclerView) watchChildren(recyclerView, index, active, signature); + if (view instanceof ViewGroup group) { + for (int child = 0; child < group.getChildCount(); child++) { + queue.add(group.getChildAt(child)); + } + } + } + lastWalkedViews = walked; + return bound; + } + + /** + * Fragments and async content attach after the Activity bind points. The root + * is re-walked only when its descendant count actually changed, which keeps the + * scroll path free of repeated traversals. + */ + private static void watchRoot(View root, ThemeColorIndex index, ThemeTokens active, long signature) { + if (ROOTS.containsKey(root)) return; + int[] state = {countDescendants(root)}; + ROOTS.put(root, state); + root.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + if (state[0] == countDescendants(view)) return; + state[0] = countDescendants(view); + walk(view, index, active, signature); + }); + } + + private static int countDescendants(View view) { + if (!(view instanceof ViewGroup group)) return 1; + int count = 1; + for (int child = 0; child < group.getChildCount(); child++) count += countDescendants(group.getChildAt(child)); + return count; + } + + private static void watchChildren(RecyclerView recyclerView, ThemeColorIndex index, ThemeTokens active, long signature) { + if (WATCHED.containsKey(recyclerView)) return; + RecyclerView.OnChildAttachStateChangeListener listener = new RecyclerView.OnChildAttachStateChangeListener() { + @Override + public void onChildViewAttachedToWindow(View view) { + walk(view, index, active, signature); + } + + @Override + public void onChildViewDetachedFromWindow(View view) { + APPLIED.remove(view); + } + }; + WATCHED.put(recyclerView, listener); + recyclerView.addOnChildAttachStateChangeListener(listener); + } + + private static boolean bindView(View view, ThemeColorIndex index, ThemeTokens active, long signature) { + Long applied = APPLIED.get(view); + if (applied != null && applied == signature) return false; + ThemeRole explicit = ThemeRole.fromTag(view.getTag()); + boolean changed = false; + if (view instanceof TextView textView) changed |= bindText(textView, explicit, index, active); + if (view instanceof ImageView imageView) changed |= bindImageTint(imageView, explicit, index, active); + if (view instanceof MaterialCardView cardView) changed |= bindCard(cardView, explicit, index, active); + if (view instanceof MaterialButton button) changed |= bindButton(button, explicit, index, active); + changed |= bindBackgroundTint(view, explicit, index, active); + changed |= bindBackground(view, explicit, index, active); + if (changed) APPLIED.put(view, signature); + return changed; + } + + private static boolean bindText(TextView view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { + boolean changed = false; + ColorStateList colors = rewrite(view.getTextColors(), explicit, index, active); + if (colors != null) { + view.setTextColor(colors); + changed = true; + } + ColorStateList hint = rewrite(view.getHintTextColors(), explicit, index, active); + if (hint != null) { + view.setHintTextColor(hint); + changed = true; + } + return changed; + } + + private static boolean bindImageTint(ImageView view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { + ColorStateList tint = rewrite(view.getImageTintList(), explicit, index, active); + if (tint == null) return false; + view.setImageTintList(tint); + return true; + } + + private static boolean bindCard(MaterialCardView view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { + boolean changed = false; + ColorStateList background = rewrite(view.getCardBackgroundColor(), explicit, index, active); + if (background != null) { + view.setCardBackgroundColor(background); + changed = true; + } + ColorStateList stroke = rewrite(ColorStateList.valueOf(view.getStrokeColor()), + index.uniqueRoleFor(view.getStrokeColor()) == null ? ThemeRole.OUTLINE : null, index, active); + if (stroke != null) { + view.setStrokeColor(stroke); + changed = true; + } + return changed; + } + + /** + * Filled and outlined Material buttons keep their fill in a background tint and + * their border in a stroke colour rather than in a GradientDrawable, so the + * generic drawable path never reached them and a themed button stayed on the + * compiled palette. Both channels are still resolved from the same semantic + * attributes and obey the identical exact-match rule. + */ + private static boolean bindButton(MaterialButton view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { + boolean changed = false; + ColorStateList stroke = rewrite(view.getStrokeColor(), explicit, index, active); + if (stroke != null) { + view.setStrokeColor(stroke); + changed = true; + } + ColorStateList icon = rewrite(view.getIconTint(), explicit, index, active); + if (icon != null) { + view.setIconTint(icon); + changed = true; + } + return changed; + } + + /** + * Covers every view that fills itself through {@code app:backgroundTint} - the + * Material button family, chips, FABs and text-input boxes - with the same + * "only an exact baseline role colour" guard the rest of the binder uses. + */ + private static boolean bindBackgroundTint(View view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { + ColorStateList tint = rewrite(view.getBackgroundTintList(), explicit, index, active); + if (tint == null) return false; + view.setBackgroundTintList(tint); + return true; + } + + private static boolean bindBackground(View view, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { + return bindDrawable(view.getBackground(), explicit, index, active); + } + + /** + * Rewrites a window-level background drawable. + * + *

{@code MaterialAlertDialogBuilder} builds the dialog panel as a + * {@link MaterialShapeDrawable} wrapped in an {@link InsetDrawable} and hands it to + * {@code Window.setBackgroundDrawable}. That drawable belongs to the window, not to + * any {@code View}, so the ordinary view-tree walk can never reach it - which is why + * a themed dialog changed its text but kept the compiled panel colour. The builder + * exposes the very same instance through {@code getBackground()}, so it is rewritten + * here through one shared code path instead of a second colour model. + */ + public static boolean bindWindowBackground(Drawable drawable, ThemeTokens baseline, ThemeTokens active) { + if (drawable == null || baseline == null || active == null) return false; + if (Looper.myLooper() != Looper.getMainLooper()) return false; + if (baseline.equals(active)) return false; + boolean changed = bindDrawable(drawable, null, ThemeColorIndex.of(baseline), active); + // dialogOpacity is documented as "the dialog/BottomSheet shell only, never the text + // alpha", so it is applied to this window background and nowhere else. Without this + // the editor's dialog-opacity slider changed only the preview swatch and the Web + // snapshot while real dialogs stayed fully opaque. + changed |= applyShellOpacity(drawable, active.dialogOpacity()); + return changed; + } + + /** + * Scales the alpha of a window background fill, preserving whatever transparency the + * drawable already had. A shell opacity of 1.0 is the shipped default and must stay a + * strict no-op so the frozen palette path remains byte-identical. + */ + private static boolean applyShellOpacity(Drawable drawable, float opacity) { + if (drawable == null || opacity >= 1f) return false; + if (opacity < 0f) return false; + if (drawable instanceof InsetDrawable inset) { + return applyShellOpacity(inset.getDrawable(), opacity); + } + if (drawable instanceof MaterialShapeDrawable shape) { + ColorStateList fill = shape.getFillColor(); + if (fill == null) return false; + int original = fill.getDefaultColor(); + int target = scaleAlpha(original, opacity); + if (target == original) return false; + shape.setFillColor(ColorStateList.valueOf(target)); + return true; + } + if (drawable instanceof GradientDrawable shape) { + ColorStateList color = shape.getColor(); + if (color == null) return false; + int original = color.getDefaultColor(); + int target = scaleAlpha(original, opacity); + if (target == original) return false; + shape.setColor(target); + return true; + } + return false; + } + + /** Package-private so the alpha arithmetic can be asserted without an Android device. */ + static int scaleAlpha(int color, float opacity) { + int alpha = Math.round(((color >>> 24) & 0xFF) * Math.max(0f, Math.min(1f, opacity))); + return (color & 0x00FFFFFF) | (Math.max(0, Math.min(255, alpha)) << 24); + } + + /** + * Rewrites the solid fill of a background drawable, unwrapping the transparent + * wrappers Android and Material put around real panels. + * + *

Two families are covered by the same exact-match rule as everything else: + * {@link GradientDrawable} (classic XML shapes) and + * {@link MaterialShapeDrawable} (Chip, text-input box, bottom sheet and the + * AlertDialog panel). Material's alert background is additionally wrapped in an + * {@link InsetDrawable} carrying the window insets, so wrappers are unwrapped + * recursively instead of being treated as an unknown drawable. + */ + private static boolean bindDrawable(Drawable drawable, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { + if (drawable == null) return false; + if (drawable instanceof InsetDrawable inset) { + return bindDrawable(inset.getDrawable(), explicit, index, active); + } + if (drawable instanceof GradientDrawable shape) { + ColorStateList color = rewrite(shape.getColor(), explicit, index, active); + if (color == null) return false; + shape.setColor(color); + return true; + } + if (drawable instanceof MaterialShapeDrawable shape) { + ColorStateList color = rewrite(shape.getFillColor(), explicit, index, active); + if (color == null) return false; + shape.setFillColor(color); + return true; + } + return false; + } + + /** + * Returns a recolored copy of the list, preserving its state behaviour, or null + * when the colour is unknown/unchanged or the state shape cannot be rebuilt. + */ + private static ColorStateList rewrite(ColorStateList source, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { + if (source == null) return null; + if (!source.isStateful()) { + Integer replacement = singleColor(source.getDefaultColor(), explicit, index, active); + return replacement == null ? null : ColorStateList.valueOf(replacement); + } + ColorStateList cached = REBUILT.get(source); + if (cached != null) return cached; + int[][] states = ThemeColorIndex.stateSkeleton(); + int[] colors = new int[states.length]; + boolean changed = false; + // Material state lists usually vary alpha only. Resolve the role from the + // default colour first, then keep each state's original alpha so pressed / + // disabled / focused states stay distinguishable. + int defaultColor = source.getDefaultColor(); + Integer defaultReplacement = singleColor(defaultColor, explicit, index, active); + for (int position = 0; position < states.length; position++) { + int original = resolvedColor(source, states[position]); + Integer replacement = singleColor(original, explicit, index, active); + if (replacement == null && defaultReplacement != null && sameRgb(original, defaultColor)) { + replacement = withAlpha(defaultReplacement, original >>> 24); + } + colors[position] = replacement == null ? original : replacement; + changed |= replacement != null; + } + if (!changed) return null; + // ColorStateList exposes a public (int[][], int[]) constructor, so the list + // can be rebuilt directly without XML inflation or hidden platform APIs. + ColorStateList rebuilt = new ColorStateList(states, colors); + REBUILT.put(source, rebuilt); + return rebuilt; + } + + private static boolean sameRgb(int first, int second) { + return (first & 0x00FFFFFF) == (second & 0x00FFFFFF); + } + + private static int withAlpha(int color, int alpha) { + return (color & 0x00FFFFFF) | ((alpha & 0xFF) << 24); + } + + private static int resolvedColor(ColorStateList source, int[] state) { + int color = source.getColorForState(state, source.getDefaultColor()); + return color == 0 ? source.getDefaultColor() : color; + } + + private static Integer singleColor(int current, ThemeRole explicit, ThemeColorIndex index, ThemeTokens active) { + if (explicit != null) { + Integer baseline = index.baselineOf(explicit); + int target = explicit.colorOf(active); + if (target == 0 || target == current) return null; + if (baseline != null && baseline != current) return null; + return target; + } + return index.replacementFor(current, active); + } + + private static boolean isExempt(View view) { + Object tag = view.getTag(); + if (tag instanceof String value && IGNORE_TAG.equals(value.trim().toLowerCase(Locale.ROOT))) return true; + String name = view.getClass().getName().toLowerCase(Locale.ROOT); + for (String marker : EXEMPT_CLASS_MARKERS) { + if (name.contains(marker)) return true; + } + return false; + } + + private static long signature(ThemeTokens tokens) { + // ThemeTokens is a content-based record; its generated hashCode is stable + // across the desugared Android runtime. Java-16 record reflection is + // deliberately avoided because it does not exist on Android API < 33. + return tokens.hashCode(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorIndex.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorIndex.java new file mode 100644 index 0000000000..f32cbd73b5 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorIndex.java @@ -0,0 +1,116 @@ +package com.fongmi.android.tv.theme; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * Maps a concrete ARGB value back to the semantic role it played in the frozen + * baseline palette. + * + *

The binder never guesses: a view color is only rewritten when it exactly + * equals a baseline role value, and only when every role sharing that value + * resolves to the same active color. Ambiguous or unknown colors are left alone. + */ +public final class ThemeColorIndex { + + private final Map> rolesByColor; + private final Map baselineByRole; + + private ThemeColorIndex(Map> rolesByColor, Map baselineByRole) { + this.rolesByColor = rolesByColor; + this.baselineByRole = baselineByRole; + } + + public static ThemeColorIndex of(ThemeTokens baseline) { + Map> byColor = new LinkedHashMap<>(); + Map byRole = new EnumMap<>(ThemeRole.class); + if (baseline != null) { + for (ThemeRole role : ThemeRole.values()) { + int color = role.colorOf(baseline); + if (color == 0) continue; + byRole.put(role, color); + byColor.computeIfAbsent(color, ignored -> new LinkedHashSet<>()).add(role); + } + } + Map> frozen = new LinkedHashMap<>(); + byColor.forEach((color, roles) -> frozen.put(color, Collections.unmodifiableSet(roles))); + return new ThemeColorIndex(Collections.unmodifiableMap(frozen), Collections.unmodifiableMap(byRole)); + } + + public boolean isEmpty() { + return rolesByColor.isEmpty(); + } + + public Set rolesFor(int color) { + Set roles = rolesByColor.get(color); + return roles == null ? Set.of() : roles; + } + + /** + * The single role a baseline color belongs to, or {@code null} when the color + * is unknown or shared by several roles. + */ + public ThemeRole uniqueRoleFor(int color) { + Set roles = rolesFor(color); + return roles.size() == 1 ? roles.iterator().next() : null; + } + + /** + * The replacement color for a baseline view color, or {@code null} when the + * color must be left untouched. + * + *

When several roles share the baseline color the replacement is only + * returned if every one of those roles agrees on the active value; otherwise + * the correct role cannot be determined and the view keeps its static color. + */ + public Integer replacementFor(int color, ThemeTokens active) { + Set roles = rolesFor(color); + if (roles.isEmpty() || active == null) return null; + Integer candidate = null; + for (ThemeRole role : roles) { + int resolved = role.colorOf(active); + if (candidate == null) candidate = resolved; + else if (candidate != resolved) return null; + } + return candidate == null || candidate == color ? null : candidate; + } + + /** Baseline value of a role, used for explicit role tags. */ + public Integer baselineOf(ThemeRole role) { + return baselineByRole.get(role); + } + + /** + * A practical, ordered set of common state combinations. Android exposes no + * public accessor for a ColorStateList's original state array, so a stateful + * list is rebuilt against this skeleton: every entry keeps the colour the + * original list resolves to for that state, and unknown states therefore keep + * their original value instead of being dropped. + */ + public static final int[][] STATE_SKELETON = { + {-android.R.attr.state_enabled}, + {android.R.attr.state_enabled, android.R.attr.state_pressed}, + {android.R.attr.state_enabled, android.R.attr.state_focused}, + {android.R.attr.state_enabled, android.R.attr.state_hovered}, + {android.R.attr.state_enabled, android.R.attr.state_selected}, + {android.R.attr.state_enabled, android.R.attr.state_activated}, + {android.R.attr.state_enabled, android.R.attr.state_checked}, + {android.R.attr.state_enabled, -android.R.attr.state_checked, android.R.attr.state_checkable}, + {android.R.attr.state_enabled, android.R.attr.state_expanded}, + {android.R.attr.state_enabled, -android.R.attr.state_expanded}, + {android.R.attr.state_enabled}, + {}, + }; + + /** The ordered state skeleton used when a stateful list must be recolored. */ + public static int[][] stateSkeleton() { + int[][] copy = new int[STATE_SKELETON.length][]; + for (int index = 0; index < STATE_SKELETON.length; index++) copy[index] = STATE_SKELETON[index].clone(); + return copy; + } + +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorPickerDialog.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorPickerDialog.java new file mode 100644 index 0000000000..9535a20c74 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorPickerDialog.java @@ -0,0 +1,202 @@ +package com.fongmi.android.tv.theme; + +import android.content.Context; +import android.graphics.drawable.GradientDrawable; +import android.text.InputType; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; + +import com.fongmi.android.tv.R; + +import java.util.Locale; + +/** + * Colour picker with both hue/saturation/value sliders and exact hex entry. + * + *

Values are validated with {@link ThemeProfileValidator#normalizeColor} before + * the callback fires, so an invalid string can never reach the editor draft. The + * dialog itself owns the parsing; callers only receive a normalised {@code #RRGGBB}. + */ +public final class ThemeColorPickerDialog { + + /** Receives the validated colour when the user confirms. */ + public interface Listener { + void onColorPicked(String hex); + } + + private ThemeColorPickerDialog() { + } + + public static AlertDialog create(Context context, String title, String initial, Listener listener) { + return create(context, title, initial, ThemeController.current(), listener); + } + + /** The editor passes its local snapshot; opening a picker never publishes the draft globally. */ + public static AlertDialog create(Context context, String title, String initial, ThemeTokens tokens, Listener listener) { + int start = ThemeProfileValidator.parseColor(initial, 0xFF000000); + float[] hsv = new float[3]; + android.graphics.Color.colorToHSV(start, hsv); + + LinearLayout root = column(context); + root.setTag("webhtv:ignore"); + View swatch = new View(context); + root.addView(swatch, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(context, 44))); + + SeekBar hue = slider(context, root, 360, Math.round(hsv[0])); + SeekBar saturation = slider(context, root, 100, Math.round(hsv[1] * 100f)); + SeekBar brightness = slider(context, root, 100, Math.round(hsv[2] * 100f)); + for (SeekBar bar : new SeekBar[]{hue, saturation, brightness}) { + bar.setProgressTintList(android.content.res.ColorStateList.valueOf(tokens.colorPrimary())); + bar.setThumbTintList(android.content.res.ColorStateList.valueOf(tokens.colorPrimary())); + bar.setMinimumHeight(dp(context, 48)); + } + + // These are platform widgets created from the Activity context, so they carry no + // Material role and would otherwise keep the framework default colour (the editor + // rendered them as Material's #49454F on whatever surface the user picked). + TextView hexLabel = new TextView(context); + hexLabel.setText(R.string.theme_editor_hex_label); + hexLabel.setTextColor(tokens.colorOnSurface()); + root.addView(hexLabel); + + EditText hexInput = new EditText(context); + hexInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + hexInput.setSingleLine(true); + hexInput.setHint(R.string.theme_editor_hex_hint); + hexInput.setTextColor(tokens.colorOnSurface()); + hexInput.setHintTextColor(tokens.colorOnSurfaceVariant()); + hexInput.setBackgroundTintList(android.content.res.ColorStateList.valueOf(tokens.colorPrimary())); + root.addView(hexInput); + + TextView error = new TextView(context); + error.setTextColor(ThemeEditorUi.readable(tokens.colorError(), tokens.colorSurface())); + root.addView(error); + + Button useHex = new Button(context); + useHex.setText(R.string.theme_editor_hex_apply); + useHex.setAllCaps(false); + useHex.setTextColor(tokens.colorOnSurface()); + useHex.setBackground(outlinedPill(context, tokens)); + int pillPadding = dp(context, 10); + useHex.setPadding(pillPadding, pillPadding, pillPadding, pillPadding); + root.addView(useHex); + + java.util.function.IntConsumer showColor = color -> { + GradientDrawable shape = new GradientDrawable(); + shape.setCornerRadius(dp(context, 10)); + shape.setColor(color); + swatch.setBackground(shape); + hexInput.setText(toHex(color)); + error.setText(""); + }; + // Integer HSV sliders are deliberately approximate. Only an actual slider edit + // may quantize a color; opening or confirming an exact HEX must stay lossless. + Runnable syncFromHsv = () -> showColor.accept(android.graphics.Color.HSVToColor( + new float[]{hue.getProgress(), saturation.getProgress() / 100f, brightness.getProgress() / 100f})); + + SeekBar.OnSeekBarChangeListener onChange = new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser) syncFromHsv.run(); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + }; + hue.setOnSeekBarChangeListener(onChange); + saturation.setOnSeekBarChangeListener(onChange); + brightness.setOnSeekBarChangeListener(onChange); + + useHex.setOnClickListener(view -> { + String normalized = ThemeProfileValidator.normalizeColor(hexInput.getText().toString()); + if (normalized == null) { + error.setText(R.string.theme_editor_hex_invalid); + return; + } + int exact = ThemeProfileValidator.parseColor(normalized, 0xFF000000); + float[] parsed = new float[3]; + android.graphics.Color.colorToHSV(exact, parsed); + hue.setProgress(Math.round(parsed[0])); + saturation.setProgress(Math.round(parsed[1] * 100f)); + brightness.setProgress(Math.round(parsed[2] * 100f)); + showColor.accept(exact); + }); + + showColor.accept(start); + AlertDialog dialog = new WebHtvAlertDialogBuilder(context) + .setTitle(title) + .setView(root) + .setPositiveButton(R.string.theme_editor_confirm, null) + .setNegativeButton(R.string.theme_editor_cancel, null) + .create(); + // Validate before dismissing so an invalid hex never closes the picker. + dialog.setOnShowListener(ignored -> { + if (dialog.getWindow() != null) { + dialog.getWindow().setBackgroundDrawable(ThemeEditorUi.shape(context, tokens.colorSurface(), 0, 0, 20)); + } + TextView dialogTitle = dialog.findViewById(androidx.appcompat.R.id.alertTitle); + if (dialogTitle != null) dialogTitle.setTextColor(tokens.colorOnSurface()); + ThemeEditorUi.buttonColors(dialog.getButton(AlertDialog.BUTTON_POSITIVE), tokens.colorPrimary(), tokens.colorOnPrimary(), tokens, false); + ThemeEditorUi.buttonColors(dialog.getButton(AlertDialog.BUTTON_NEGATIVE), tokens.colorSurfaceContainer(), tokens.colorOnSurface(), tokens, false); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(view -> { + String normalized = ThemeProfileValidator.normalizeColor(hexInput.getText().toString()); + if (normalized == null) { + error.setText(R.string.theme_editor_hex_invalid); + return; + } + listener.onColorPicked(normalized); + dialog.dismiss(); + }); + }); + return dialog; + } + + /** + * A themed pill for the framework {@code Button}, which ships its own light background + * and would otherwise stay white-on-white once the text colour follows the theme. + */ + private static GradientDrawable outlinedPill(Context context, ThemeTokens tokens) { + GradientDrawable shape = new GradientDrawable(); + shape.setShape(GradientDrawable.RECTANGLE); + shape.setCornerRadius(dp(context, 18)); + shape.setColor(tokens.colorSurfaceContainer()); + shape.setStroke(dp(context, 1), tokens.colorOutline()); + return shape; + } + + private static SeekBar slider(Context context, LinearLayout root, int max, int initial) { + SeekBar bar = new SeekBar(context); + bar.setMax(max); + bar.setProgress(initial); + root.addView(bar, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + return bar; + } + + private static LinearLayout column(Context context) { + LinearLayout root = new LinearLayout(context); + root.setOrientation(LinearLayout.VERTICAL); + int pad = dp(context, 16); + root.setPadding(pad, pad, pad, pad); + return root; + } + + static String toHex(int color) { + return String.format(Locale.ROOT, "#%06X", color & 0xFFFFFF); + } + + private static int dp(Context context, int value) { + return Math.round(value * context.getResources().getDisplayMetrics().density); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorUtil.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorUtil.java new file mode 100644 index 0000000000..c52ce9af7b --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeColorUtil.java @@ -0,0 +1,137 @@ +package com.fongmi.android.tv.theme; + +import java.util.Locale; + +/** Bounded CSS color conversion for the allowlisted TweakCN importer. */ +public final class ThemeColorUtil { + + private ThemeColorUtil() { + } + + public static String normalizeCss(String value) { + String hex = ThemeProfileValidator.normalizeColor(value); + if (hex != null) return hex; + if (value == null) return null; + String color = value.trim().toLowerCase(Locale.ROOT); + try { + if (color.startsWith("oklch(") && color.endsWith(")")) { + return format(parseOklch(color.substring(6, color.length() - 1))); + } + if ((color.startsWith("hsl(") || color.startsWith("hsla(")) && color.endsWith(")")) { + int offset = color.startsWith("hsla(") ? 5 : 4; + return format(parseHsl(color.substring(offset, color.length() - 1))); + } + if ((color.startsWith("rgb(") || color.startsWith("rgba(")) && color.endsWith(")")) { + int offset = color.startsWith("rgba(") ? 5 : 4; + return format(parseRgb(color.substring(offset, color.length() - 1))); + } + if (color.matches("[-+0-9.]+\\s+[-+0-9.]+%\\s+[-+0-9.]+%(\\s+[-+0-9.]+%?)?")) { + return format(parseHsl(color)); + } + } catch (RuntimeException ignored) { + // Unsupported CSS is reported as an import warning, never executed. + } + return null; + } + + private static int parseOklch(String body) { + String[] parts = components(body); + if (parts.length != 3 && parts.length != 4) throw new IllegalArgumentException("oklch components"); + if (parts.length == 4 && !isOpaqueAlpha(parts[3])) throw new IllegalArgumentException("transparent color"); + double lightness = number(parts[0], 1d, false); + double chroma = number(parts[1], 0.4d, true); + double hue = number(parts[2], 360d, false); + if (lightness < 0d || lightness > 1d || chroma < 0d || !finite(lightness, chroma, hue)) { + throw new IllegalArgumentException("oklch range"); + } + double radians = Math.toRadians(hue % 360d); + double a = chroma * Math.cos(radians); + double b = chroma * Math.sin(radians); + double l = lightness + 0.3963377774d * a + 0.2158037573d * b; + double m = lightness - 0.1055613458d * a - 0.0638541728d * b; + double s = lightness - 0.0894841775d * a - 1.2914855480d * b; + double l3 = l * l * l; + double m3 = m * m * m; + double s3 = s * s * s; + return rgb( + gamma(4.0767416621d * l3 - 3.3077115913d * m3 + 0.2309699292d * s3), + gamma(-1.2684380046d * l3 + 2.6097574011d * m3 - 0.3413193965d * s3), + gamma(-0.0041960863d * l3 - 0.7034186147d * m3 + 1.7076147010d * s3)); + } + + private static int parseHsl(String body) { + String[] parts = components(body); + if (parts.length != 3 && parts.length != 4) throw new IllegalArgumentException("hsl components"); + if (parts.length == 4 && !isOpaqueAlpha(parts[3])) throw new IllegalArgumentException("transparent color"); + double h = number(parts[0], 360d, false) % 360d; + double saturation = number(parts[1], 1d, true); + double lightness = number(parts[2], 1d, true); + if (!finite(h, saturation, lightness) || saturation < 0d || saturation > 1d || lightness < 0d || lightness > 1d) { + throw new IllegalArgumentException("hsl range"); + } + double c = (1d - Math.abs(2d * lightness - 1d)) * saturation; + double x = c * (1d - Math.abs((h / 60d) % 2d - 1d)); + double match = lightness - c / 2d; + double r = 0d, g = 0d, b = 0d; + if (h < 60d) { r = c; g = x; } else if (h < 120d) { r = x; g = c; } + else if (h < 180d) { g = c; b = x; } else if (h < 240d) { g = x; b = c; } + else if (h < 300d) { r = x; b = c; } else { r = c; b = x; } + return rgb(r + match, g + match, b + match); + } + + private static int parseRgb(String body) { + String[] parts = components(body); + if (parts.length != 3 && parts.length != 4) throw new IllegalArgumentException("rgb components"); + if (parts.length == 4 && !isOpaqueAlpha(parts[3])) throw new IllegalArgumentException("transparent color"); + return rgb(channel(parts[0]), channel(parts[1]), channel(parts[2])); + } + + private static String[] components(String body) { + return body.trim().replace(',', ' ').replace('/', ' ').trim().split("\\s+"); + } + + private static double number(String value, double percentScale, boolean percentageAllowed) { + String part = value.trim(); + if (part.endsWith("%")) { + if (!percentageAllowed) throw new IllegalArgumentException("unexpected percentage"); + return Double.parseDouble(part.substring(0, part.length() - 1)) / 100d * percentScale; + } + return Double.parseDouble(part); + } + + private static double channel(String value) { + String part = value.trim(); + double result = part.endsWith("%") + ? Double.parseDouble(part.substring(0, part.length() - 1)) / 100d + : Double.parseDouble(part) / 255d; + if (!finite(result) || result < 0d || result > 1d) throw new IllegalArgumentException("rgb range"); + return result; + } + + private static boolean isOpaqueAlpha(String value) { + double alpha = number(value, 1d, true); + return finite(alpha) && alpha >= 0.999d; + } + + private static double gamma(double value) { + if (!finite(value)) throw new IllegalArgumentException("color conversion"); + return value <= 0.0031308d ? 12.92d * value : 1.055d * Math.pow(value, 1d / 2.4d) - 0.055d; + } + + private static int rgb(double red, double green, double blue) { + if (!finite(red, green, blue)) throw new IllegalArgumentException("color conversion"); + int r = Math.round((float) (Math.max(0d, Math.min(1d, red)) * 255d)); + int g = Math.round((float) (Math.max(0d, Math.min(1d, green)) * 255d)); + int b = Math.round((float) (Math.max(0d, Math.min(1d, blue)) * 255d)); + return 0xFF000000 | r << 16 | g << 8 | b; + } + + private static String format(int color) { + return String.format(Locale.ROOT, "#%06X", color & 0xFFFFFF); + } + + private static boolean finite(double... values) { + for (double value : values) if (Double.isNaN(value) || Double.isInfinite(value)) return false; + return true; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeContrast.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeContrast.java new file mode 100644 index 0000000000..f86b661017 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeContrast.java @@ -0,0 +1,67 @@ +package com.fongmi.android.tv.theme; + +final class ThemeContrast { + + private ThemeContrast() { + } + + static void require(ThemeTokens tokens) { + require(tokens.colorOnPrimary(), tokens.colorPrimary(), 4.5, "onPrimary/primary"); + require(tokens.colorOnPrimaryContainer(), tokens.colorPrimaryContainer(), 4.5, "onPrimaryContainer/primaryContainer"); + require(tokens.colorOnSecondary(), tokens.colorSecondary(), 4.5, "onSecondary/secondary"); + require(tokens.colorOnSecondaryContainer(), tokens.colorSecondaryContainer(), 4.5, "onSecondaryContainer/secondaryContainer"); + require(tokens.colorOnTertiary(), tokens.colorTertiary(), 4.5, "onTertiary/tertiary"); + require(tokens.colorOnError(), tokens.colorError(), 4.5, "onError/error"); + require(tokens.colorOnErrorContainer(), tokens.colorErrorContainer(), 4.5, "onErrorContainer/errorContainer"); + require(tokens.colorOnSuccess(), tokens.colorSuccess(), 4.5, "onSuccess/success"); + require(tokens.colorOnSuccessContainer(), tokens.colorSuccessContainer(), 4.5, "onSuccessContainer/successContainer"); + require(tokens.colorOnWarning(), tokens.colorWarning(), 4.5, "onWarning/warning"); + require(tokens.colorOnWarningContainer(), tokens.colorWarningContainer(), 4.5, "onWarningContainer/warningContainer"); + require(tokens.colorOnSurface(), tokens.colorSurface(), 4.5, "onSurface/surface"); + require(tokens.colorOnSurfaceVariant(), tokens.colorSurface(), 4.5, "onSurfaceVariant/surface"); + require(tokens.colorPrimary(), tokens.colorSurface(), 4.5, "primary/surface"); + require(tokens.colorPrimary(), tokens.colorSurfaceContainer(), 4.5, "primary/surfaceContainer"); + require(tokens.colorPrimary(), tokens.colorSurfaceContainerHigh(), 4.5, "primary/surfaceContainerHigh"); + require(tokens.colorPrimary(), tokens.colorSurfaceContainerHighest(), 4.5, "primary/surfaceContainerHighest"); + require(tokens.colorOnSurface(), tokens.colorSurfaceContainerHigh(), 4.5, "onSurface/surfaceContainerHigh"); + require(tokens.colorOnSurface(), tokens.colorSurfaceContainerHighest(), 4.5, "onSurface/surfaceContainerHighest"); + require(tokens.colorInverseOnSurface(), tokens.colorInverseSurface(), 4.5, "inverseOnSurface/inverseSurface"); + require(tokens.colorOutline(), tokens.colorSurface(), 3.0, "outline/surface"); + require(tokens.colorFocus(), tokens.colorSurface(), 3.0, "focus/surface"); + require(tokens.colorPlayerControlActive(), composite(tokens.colorPlayerScrim(), 0xFF000000), 4.5, "playerControlActive/playerScrim"); + } + + static double ratio(int foreground, int background) { + double light = relativeLuminance(foreground); + double dark = relativeLuminance(background); + return (Math.max(light, dark) + 0.05) / (Math.min(light, dark) + 0.05); + } + + static int composite(int foreground, int background) { + int alpha = (foreground >>> 24) & 0xFF; + int inverse = 255 - alpha; + int red = (((foreground >>> 16) & 0xFF) * alpha + ((background >>> 16) & 0xFF) * inverse + 127) / 255; + int green = (((foreground >>> 8) & 0xFF) * alpha + ((background >>> 8) & 0xFF) * inverse + 127) / 255; + int blue = ((foreground & 0xFF) * alpha + (background & 0xFF) * inverse + 127) / 255; + return 0xFF000000 | (red << 16) | (green << 8) | blue; + } + + private static void require(int foreground, int background, double minimum, String pair) { + double ratio = ratio(foreground, background); + if (ratio + 0.0001 < minimum) { + throw new IllegalArgumentException(pair + " contrast " + String.format(java.util.Locale.US, "%.2f", ratio) + " < " + minimum); + } + } + + private static double relativeLuminance(int color) { + double red = channel((color >>> 16) & 0xFF); + double green = channel((color >>> 8) & 0xFF); + double blue = channel(color & 0xFF); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; + } + + private static double channel(int value) { + double normalized = value / 255.0; + return normalized <= 0.04045 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeController.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeController.java new file mode 100644 index 0000000000..dc4a32920d --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeController.java @@ -0,0 +1,432 @@ +package com.fongmi.android.tv.theme; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.app.Dialog; +import android.os.Build; +import android.util.TypedValue; +import android.view.View; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.content.ContextCompat; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.utils.Util; + +/** Read-only application entry point for future theme adoption. */ +public final class ThemeController { + + private static volatile ThemeTokens current = ThemeTokens.light(); + private static volatile ThemeTokens baseline = ThemeTokens.light(); + private static volatile ThemeProfile profile = ThemeProfile.defaultProfile(); + + private ThemeController() { + } + + public static void apply(AppCompatActivity activity, ThemeTokens tokens) { + ThemeTokens safe = tokens == null ? ThemeTokens.light() : tokens; + current = safe; + int color = safe.colorSurface(); + activity.getWindow().setStatusBarColor(color); + activity.getWindow().setNavigationBarColor(color); + } + + /** + * Keeps AppCompat's DayNight configuration aligned with the persisted + * appearance mode so the whole activity tree (mobile and leanback) shares + * one resolution order: explicit mode > product default > system. + * + *

TV 的产品默认是**深色**(与上游一致,上游 TV 主题直接继承 + * {@code Theme.Material3.Dark})。理由与上游相同:电视多在暗环境观看, + * 浅色大面积亮底在夜间刺眼;而 TV 常见的深色底上,上游那套白色焦点环 + * (18.5:1)才能成立(白环对浅色表 surface 只有 1.05:1,等于看不见)。 + * + *

“跟随系统”对 TV 不再等于“跟随一个通常恒为浅色的系统默认”,否则电视上 + * 会默认落到浅色表。用户仍可在外观设置里显式选择浅色/深色。 + * mobile 保持既有的“跟随系统”语义不变。 + */ + public static void applyNightModeToApp() { + int mode = com.fongmi.android.tv.setting.Setting.getThemeMode(); + int delegate = switch (mode) { + case 0 -> AppCompatDelegate.MODE_NIGHT_NO; + case 1 -> AppCompatDelegate.MODE_NIGHT_YES; + default -> Util.isLeanback() + ? AppCompatDelegate.MODE_NIGHT_YES + : AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM; + }; + if (AppCompatDelegate.getDefaultNightMode() != delegate) { + AppCompatDelegate.setDefaultNightMode(delegate); + } + } + + public static ThemeTokens current() { + return current; + } + + /** The frozen Layer 1 palette the binder compares against. */ + public static ThemeTokens baseline() { + return baseline; + } + + /** + * Single source of truth for "the current app appearance is dark". + * + *

The Activity configuration already reflects a forced {@code theme_mode}, so + * ordinary UI must ask this method instead of reading the raw system night bit, + * which would contradict an explicit light/dark choice. + */ + public static boolean isNight(Context context) { + Configuration configuration = context == null ? Resources.getSystem().getConfiguration() + : context.getResources().getConfiguration(); + return (configuration.uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + } + + /** + * The user's explicit scrim opacity, or {@code null} when they never set one. + * + *

Callers must keep this distinguished from "the set opacity happens to equal + * the shipped default". Modal scrims in this app do not use the token colour: + * the episode-detail and TMDB-person dialogs paint their own scrim (a translucent + * white in light mode, translucent black in dark) and that is the shipped design. + * Replacing those colours with {@code colorScrim()} was measured to flip light-mode + * scrims from lightening to darkening, so the wiring instead lets the user drive + * only the alpha of each dialog's own scrim colour, and stays a strict no-op while + * the slot is unset so the default rendering is byte-identical. + * + * @param light which slot set the caller resolved for its own colours; the scrim + * owner decides light/dark, so the same answer is reused here instead + * of re-deriving it from the uiMode bit + * @return the configured alpha in {@code 0..1}, or {@code null} when unset + */ + public static Float configuredScrimOpacity(boolean light) { + ThemeProfile active = profile; + if (active == null) return null; + ThemeProfile.SlotSet slots = light ? active.light : active.dark; + if (slots == null) return null; + Float value = slots.scrimOpacity; + if (value == null || !Float.isFinite(value)) return null; + return Math.max(0f, Math.min(1f, value)); + } + + /** + * The user's explicit playback-menu scrim strength for one mode, or {@code null}. + * + *

播放菜单遮盖层是"每个宿主自己决定明暗"的黑幕:它没有 ThemeTokens 分量, + * 由播放页在创建时通过本方法读取,未设置时由 {@link PlaybackMenuScrim} 回落到 + * 它自己的 DEFAULT_OPACITY,因此老 profile 的默认观感是确定的。 + */ + public static Float configuredPlayerScrimOpacity(boolean light) { + ThemeProfile active = profile; + if (active == null) return null; + ThemeProfile.SlotSet slots = light ? active.light : active.dark; + if (slots == null) return null; + Float value = slots.playerScrimOpacity; + if (value == null || !Float.isFinite(value)) return null; + return Math.max(0f, Math.min(1f, value)); + } + + /** + * Applies a scrim opacity to a surface's own scrim colour, replacing only the alpha. + * + *

{@code null} (the user never set the slot) returns {@code base} untouched, which + * is what keeps the shipped look byte-identical. Package-visible and taking the + * opacity explicitly so both branches can be asserted without an Android device. + */ + public static int applyScrimOpacity(int base, Float opacity) { + if (opacity == null) return base; + int alpha = Math.max(0, Math.min(255, Math.round(opacity * 255f))); + return (base & 0x00FFFFFF) | (alpha << 24); + } + + public static void refresh() { + boolean systemDark = (Resources.getSystem().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + current = ThemeResolver.resolve(currentThemeMode(), ThemeSeed.NONE, 0, 0, systemDark); + } + + /** + * Resolves the active semantic snapshot from the persisted appearance + * preferences. {@code themeColor} follows the existing WebHTV contract: + * {@code -1} disables tinting, {@code 0} uses the wallpaper seed and any + * other value is an explicit ARGB seed. + */ + public static ThemeTokens resolveFromPreferences() { + ThemeProfile stored = null; + try { + stored = ThemeProfileStore.load(); + } catch (RuntimeException ignored) { + // Preference access is unavailable; Layer 1 defaults remain valid. + } + return resolveWith(stored, null); + } + + /** + * Resolves the active snapshot against the table the given context inflates with. + * + *

{@code context} must be the activity whose views are about to be inflated, or + * {@code null} where no activity exists yet (the read-only probe device tests use). + * It is what decides light/dark: see {@link #resolvedDark(Context)}. + */ + private static ThemeTokens resolveWith(ThemeProfile profile, Context context) { + // Both the base palette and the profile's light/dark slot selection follow the + // table that context resolves, so the active tokens stay comparable with inflated + // colours on every flavour (see resolvedDark(Context)). + boolean systemDark = resolvedDark(context); + ThemeMode themeMode = currentThemeMode(); + int themeColor = com.fongmi.android.tv.setting.Setting.getThemeColor(); + if (themeColor == -1) { + return ThemeResolver.resolve(themeMode, ThemeSeed.NONE, 0, 0, profile, null, systemDark); + } + ThemeSeed seed = themeColor == 0 ? ThemeSeed.WALLPAPER : ThemeSeed.EXPLICIT; + int explicit = themeColor == 0 ? 0 : themeColor; + int wallpaper = com.fongmi.android.tv.setting.Setting.getWallColor(); + return ThemeResolver.resolve(themeMode, seed, explicit, wallpaper, profile, null, systemDark); + } + + /** + * The frozen compiled palette the binder compares view colours against. + * + *

Every {@code ?attr/color*} attribute was resolved from the static + * {@code webhtv_color_*} resources when the view was inflated, so this baseline + * must ignore {@code theme_color}/{@code wall_color}: those preferences only + * reach the activity through {@link #resolveWith(ThemeProfile, Context)}. Deriving + * the baseline from the active seed made it describe colours no inflated view ever + * held, and {@link ThemeBinder} then had nothing left to match - the feature + * silently degraded to the legacy site dialog. + */ + private static ThemeTokens frozenPalette(Context context) { + return ThemeResolver.resolve(ThemeMode.SYSTEM, ThemeSeed.NONE, 0, 0, null, null, resolvedDark(context)); + } + + private static ThemeMode currentThemeMode() { + int mode = com.fongmi.android.tv.setting.Setting.getThemeMode(); + return mode < 0 ? ThemeMode.SYSTEM : (mode == 0 ? ThemeMode.LIGHT : ThemeMode.DARK); + } + + /** + * Whether the palette the running build actually ships is the dark table. + * + *

{@link #frozenPalette(Context)} must describe the colours inflation produced, + * and those come from the compiled {@code webhtv_color_*} resources. Resource + * selection is not purely a function of uiMode: a flavour may override + * {@code values/} with a fixed table and ship no table of its own for the other + * mode. When that happens a uiMode-derived baseline describes colours no inflated + * view ever held. Every binder rewrite compares against that baseline exactly, so + * the mismatch silently turned the whole TV theme channel into a no-op (measured: + * probe vs default = 0 changed pixels on TV while the identical probe changed + * ~49k pixels on mobile). + * + *

The compiled table is therefore read from resources whenever it can be + * identified, and only an unidentifiable palette falls back to the uiMode rule. + * + *

WebHTV no longer ships such an override: the TV flavour used to carry a + * permanent dark {@code values/webhtv_tokens.xml} and that file is gone, so both + * flavours now resolve the same day/night tables and this method normally agrees + * with the uiMode rule. The resource probe is kept because it is what keeps the + * baseline honest if a flavour ever pins a palette again. + * + *

The probe must run against the context that inflates the UI, never against + * the application context. A forced appearance mode is applied by + * {@code AppCompatDelegate.setDefaultNightMode}, which rewrites the resources of the + * activities it delegates for; on API < 31 it does not rewrite the application's + * own resources. The TV flavour forces {@code MODE_NIGHT_YES} by product default + * (see {@link #applyNightModeToApp()}), so on a day-mode TV box the application + * context still resolved the light table while every inflated {@code ?attr/color*} + * resolved the dark one. The snapshot therefore came out light under a dark UI, and + * the dialog shell painted from it ({@code LightDialog.shell}) ended up under dark + * foregrounds - measured 1.06-1.41:1 on the 新增点播 dialog. The activity's own + * configuration ({@link #isNight(Context)}) is the only source that already reflects + * the forced mode, so it is what {@code context} must be. + */ + static boolean resolvedDark(Context context) { + boolean systemDark = context == null + ? (Resources.getSystem().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + : isNight(context); + return darkPaletteFor(compiledDarkPalette(context), currentThemeMode(), systemDark); + } + + /** + * The dark/light decision shared by the baseline and the active palette. + * + *

When the compiled table is identifiable it is authoritative: it is literally + * what inflation resolved, so it must win even over an explicit appearance mode. + * A flavour that pins {@code values/} to one table needs that - a light-mode device + * (or a user who picked light) would otherwise be described by the wrong baseline. + * Only an unidentifiable palette falls back to the historical mode/uiMode rule. + */ + static boolean darkPaletteFor(Boolean compiledDark, ThemeMode mode, boolean systemDark) { + if (compiledDark != null) return compiledDark; + return mode == ThemeMode.DARK || (mode != ThemeMode.LIGHT && systemDark); + } + + /** + * Identifies which canonical table the given context resolves by comparing every role + * the binder may rewrite against its {@code webhtv_color_*} resource. Returns + * {@code null} when no context is available or neither table matches exactly, so + * an unmodelled future palette keeps the previous uiMode behaviour instead of + * silently binding against the wrong baseline. + * + *

The context must be the one that inflated the views (see + * {@link #resolvedDark(Context)}); it falls back to the application context only for + * callers that have no activity, such as the read-only device-test probe. + */ + static Boolean compiledDarkPalette(Context context) { + Context probe = context == null ? App.get() : context; + if (probe == null) return null; + ThemeTokens light = ThemeTokens.light(); + ThemeTokens dark = ThemeTokens.dark(); + boolean matchesLight = true; + boolean matchesDark = true; + for (ThemeRole role : ThemeRole.values()) { + int resource = colorResourceOf(role); + if (resource == 0) continue; + int compiled = ContextCompat.getColor(probe, resource); + matchesLight &= compiled == role.colorOf(light); + matchesDark &= compiled == role.colorOf(dark); + } + if (matchesLight == matchesDark) return null; + return matchesDark; + } + + /** The compiled default of a binder role, or 0 when the role has no resource. */ + private static int colorResourceOf(ThemeRole role) { + return switch (role) { + case PRIMARY -> R.color.webhtv_color_primary; + case PRIMARY_CONTAINER -> R.color.webhtv_color_primary_container; + case SECONDARY_CONTAINER -> R.color.webhtv_color_secondary_container; + case FOCUS -> R.color.webhtv_color_focus; + case SURFACE -> R.color.webhtv_color_surface; + case SURFACE_CONTAINER -> R.color.webhtv_color_surface_container; + case SURFACE_CONTAINER_HIGH -> R.color.webhtv_color_surface_container_high; + case ON_SURFACE -> R.color.webhtv_color_on_surface; + case ON_SURFACE_VARIANT -> R.color.webhtv_color_on_surface_variant; + case OUTLINE -> R.color.webhtv_color_outline; + case ERROR -> R.color.webhtv_color_error; + case SUCCESS -> R.color.webhtv_color_success; + case WARNING -> R.color.webhtv_color_warning; + case ON_PRIMARY -> R.color.webhtv_color_on_primary; + case ON_PRIMARY_CONTAINER -> R.color.webhtv_color_on_primary_container; + case ON_SECONDARY_CONTAINER -> R.color.webhtv_color_on_secondary_container; + case ON_ERROR -> R.color.webhtv_color_on_error; + case ON_SUCCESS -> R.color.webhtv_color_on_success; + case ON_WARNING -> R.color.webhtv_color_on_warning; + }; + } + + /** + * Applies the persisted appearance snapshot to one activity. + * + *

Only the read-only token snapshot is refreshed here. System-bar colors + * are updated solely when the window still owns an opaque bar; edge-to-edge + * windows keep their transparent bars so this hook cannot regress the + * existing immersive layouts. + */ + public static void applyFromPreferences(AppCompatActivity activity) { + ThemeProfile stored = null; + try { + stored = ThemeProfileStore.load(); + } catch (RuntimeException ignored) { + // Preference access is unavailable; Layer 1 defaults remain valid. + } + profile = stored == null ? ThemeProfile.defaultProfile() : stored; + // `activity` is the context whose resources inflate every view of this screen, so + // it is also what decides which compiled table the snapshot must describe. + baseline = frozenPalette(activity); + current = resolveWith(profile, activity); + if (activity == null || activity.isFinishing()) return; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return; + if (activity.getWindow().getStatusBarColor() != android.graphics.Color.TRANSPARENT) { + activity.getWindow().setStatusBarColor(current.colorSurface()); + } + if (activity.getWindow().getNavigationBarColor() != android.graphics.Color.TRANSPARENT) { + activity.getWindow().setNavigationBarColor(current.colorSurface()); + } + } + + /** + * Applies the active profile to an already-created view tree. + * + *

When no profile override is active this returns immediately, so the + * default Layer 1 path is byte-identical and costs nothing. + */ + public static void bindTheme(View root) { + ThemeBinder.bind(root, baseline, current); + } + + /** Same controlled channel for a dialog or bottom-sheet window. */ + public static void bindDialog(Dialog dialog) { + if (dialog == null || dialog.getWindow() == null) return; + bindTheme(dialog.getWindow().getDecorView()); + } + + /** + * Applies the active profile to a window-level background drawable. + * + *

Alert dialog panels live on the window rather than on a view, so they need + * this second entry point next to {@link #bindTheme(View)}. It is a no-op while the + * active tokens equal the frozen baseline. + */ + public static void bindWindowBackground(Drawable background) { + if (background == null) return; + ThemeBinder.bindWindowBackground(background, baseline, current); + } + + /** True when the active palette differs from the frozen compiled baseline. */ + public static boolean hasProfileOverrides() { + return !baseline.equals(current); + } + + // ---------------------------------------------------------------- TV focus ring + + /** + * The one TV focus-ring colour source, for the code paths that must set a stroke + * imperatively (dynamic rows, adapters, drawables built at runtime). + * + *

TV 的焦点环只允许有一个取值源:{@code tv_item_focus_ring}(由 + * {@code ?attr/tvFocusRing} 提供给 XML 选择器,由本方法提供给代码路径)。此前详情页把 + * {@code #FFD166} 写死在 Java 里,于是 {@code tv_item_focus_ring} 接线到主题 FOCUS 槽 + * 之后,同一个 TV 应用里就出现了两种焦点环色:播放页/追更页跟随主题,详情页永远黄色。 + * + *

主题覆写的传播方式与其它 {@code ?attr/tvFocusRing} 消费者完全一致,本方法不 + * 额外做一套。 {@code ?attr/tvFocusRing} 是编译期静态资源,Android 没有公开 API 能在 + * 运行时改写已编译的 {@code ?attr/color*}(见 {@link ThemeBinder} 类注释),因此用户覆写 + * 只能由 {@code ThemeBinder} 在视图树上按基线角色改写生效——实测 MaterialButton 的 + * stroke 会被改写(seed 改 {@code #00B0FF} 后 {@code #A8C7FA} → {@code #96CCF8}),而 + * 前景选择器与 MaterialCardView 的歧义描边按 binder 既有边界保持静态。 + * 本方法因此也返回同一个静态值:若只让代码路径动态取 {@code current().colorFocus()}, + * 就会在主题覆写后重新造出「代码路径跟随、选择器不跟随」的两种环色,反而倒退。 + * + *

同时这也修正了浅色详情页的可读性缺陷:{@code #FFD166} 画在浅色底板 + * (cinema {@code #EBE3DA}、chip {@code #F5F8FB})上只有 1.14–1.35:1,环几乎看不见, + * 而主题 FOCUS 槽的浅色取值是 5.03–5.99:1(Resolver 另有 ≥3:1 的对比度门)。 + */ + public static int focusRingColor(Context context) { + if (context != null) { + TypedValue value = new TypedValue(); + if (context.getTheme().resolveAttribute(R.attr.tvFocusRing, value, true)) { + if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT) { + return value.data; + } + if (value.resourceId != 0) return ContextCompat.getColor(context, value.resourceId); + } + } + return ContextCompat.getColor(App.get(), R.color.tv_item_focus_ring); + } + + /** + * The focus-ring colour with an explicit alpha, matching the historical + * {@code 0x55FFD166} / {@code 0x1AFFD166} fills now that the hue is theme-driven. + */ + public static int focusRingColor(Context context, float alpha) { + int color = focusRingColor(context); + int scaled = Math.round(((color >>> 24) & 0xFF) * Math.max(0f, Math.min(1f, alpha))); + return (color & 0x00FFFFFF) | (Math.max(0, Math.min(255, scaled)) << 24); + } + + public static ThemeProfile activeProfile() { + return profile; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeEditor.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeEditor.java new file mode 100644 index 0000000000..33dfebe486 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeEditor.java @@ -0,0 +1,221 @@ +package com.fongmi.android.tv.theme; + +/** + * Editing session for one B-safe theme profile. + * + *

The draft is a deep copy. Every mutation is validated before it reaches the + * draft, so an invalid hex value or an out-of-range opacity can never be applied. + * {@link #apply()} is the only method that writes to disk; it is atomic and + * refreshes the legacy {@code theme_color} mirror through {@link ThemeProfileStore}. + * Cancelling simply drops the draft. + */ +public final class ThemeEditor { + + /** A user-editable slot of one mode. */ + public enum Slot { + PRIMARY, PRIMARY_CONTAINER, SECONDARY_CONTAINER, FOCUS, + /** + * 播放页「当前播放/当前生效」环色。独立于 FOCUS:播放页里两个状态会同时出现, + * 必须能区分;解析时会在播放页玻璃面板上做可读性夹取。 + */ + PLAYER_CURRENT, + SURFACE, SURFACE_CONTAINER, SURFACE_CONTAINER_HIGH, + ON_SURFACE, ON_SURFACE_VARIANT, OUTLINE, + ERROR, SUCCESS, WARNING, + SCRIM_OPACITY, DIALOG_OPACITY, OVERLAY_OPACITY, + /** + * 播放菜单遮盖层强度。只缩放黑幕 alpha、不改色相,所以仍走 opacity 分支。 + */ + PLAYER_SCRIM_OPACITY; + + public boolean isColor() { + return ordinal() <= WARNING.ordinal(); + } + } + + private final ThemeProfile original; + private ThemeProfile draft; + + public ThemeEditor(ThemeProfile source) { + this.original = source == null ? ThemeProfile.defaultProfile() : source.copy(); + this.draft = this.original.copy(); + } + + public static ThemeEditor load() { + return new ThemeEditor(ThemeProfileStore.load()); + } + + public ThemeProfile draft() { + return draft.copy(); + } + + public boolean isDirty() { + return !ThemeProfileCodec.encode(original).equals(ThemeProfileCodec.encode(draft)); + } + + /** Applies a color or opacity value to one slot of one mode. */ + public Result set(Slot slot, boolean dark, String color, float opacity) { + if (slot == null) return Result.failure("unknown slot"); + ThemeProfile.SlotSet slots = draft.slots(dark); + if (slot.isColor()) { + String normalized = ThemeProfileValidator.normalizeColor(color); + if (normalized == null) return Result.failure("invalid color"); + setColor(slots, slot, normalized); + } else { + Double range = clamp(slot, opacity); + if (range == null) return Result.failure("opacity out of range"); + setOpacity(slots, slot, opacity); + } + ThemeProfileValidator.Result validated = ThemeProfileValidator.validate(draft); + if (!validated.valid()) return Result.failure(validated.message()); + return Result.success(draft.copy()); + } + + /** Clears one slot so it inherits the built-in token or seed result again. */ + public Result clear(Slot slot, boolean dark) { + if (slot == null) return Result.failure("unknown slot"); + ThemeProfile.SlotSet slots = draft.slots(dark); + if (slot.isColor()) setColor(slots, slot, null); + else setOpacity(slots, slot, null); + return Result.success(draft.copy()); + } + + public Result setMode(String mode) { + draft.mode = ThemeProfile.normalizeMode(mode); + return Result.success(draft.copy()); + } + + public Result setPaletteStyle(String style) { + draft.paletteStyle = ThemeProfile.normalizePaletteStyle(style); + return Result.success(draft.copy()); + } + + public Result setSeed(String seedSource, String seedColor) { + String source = ThemeProfile.normalizeSeedSource(seedSource); + if (ThemeProfile.SEED_CUSTOM.equals(source)) { + String normalized = ThemeProfileValidator.normalizeColor(seedColor); + if (normalized == null) return Result.failure("invalid seed color"); + draft.seedSource = source; + draft.seedColor = normalized; + } else { + draft.seedSource = source; + if (ThemeProfile.SEED_NONE.equals(source)) draft.seedColor = null; + } + return Result.success(draft.copy()); + } + + /** Resolves the draft for previewing without touching persisted state. */ + public ThemeTokens preview(ThemeMode mode, ThemeSeed seed, int seedColor, int wallpaperColor, boolean systemDark) { + return ThemeResolver.resolve(mode, seed, seedColor, wallpaperColor, draft, null, systemDark); + } + + /** Atomically persists the draft. */ + public ThemeProfileStore.ApplyResult apply() { + ThemeProfileValidator.Result validated = ThemeProfileValidator.validate(draft); + if (!validated.valid()) return ThemeProfileStore.ApplyResult.failure(validated.message()); + return ThemeProfileStore.apply(validated.profile()); + } + + /** Resolves the actual editing mode, including the live wallpaper seed, without global state. */ + public ThemeTokens preview(boolean dark, int wallpaperColor) { + ThemeSeed seed = ThemeProfile.SEED_WALLPAPER.equals(draft.seedSource) ? ThemeSeed.WALLPAPER + : ThemeProfile.SEED_CUSTOM.equals(draft.seedSource) ? ThemeSeed.EXPLICIT : ThemeSeed.NONE; + return preview(dark ? ThemeMode.DARK : ThemeMode.LIGHT, seed, + ThemeProfileValidator.parseColor(draft.seedColor, 0), wallpaperColor, dark); + } + + /** Replaces only the draft; imports and presets retain the original dirty-state baseline. */ + public Result replace(ThemeProfile profile) { + ThemeProfileValidator.Result validated = ThemeProfileValidator.validate(profile == null ? null : profile.copy()); + if (!validated.valid()) return Result.failure(validated.message()); + draft = validated.profile(); + return Result.success(draft.copy()); + } + + /** Restores the frozen default in memory. Like every edit, this still needs Apply. */ + public Result reset() { + return replace(ThemeProfile.defaultProfile()); + } + + public String valueOf(Slot slot, boolean dark) { + if (slot == null) return null; + ThemeProfile.SlotSet slots = draft.slots(dark); + return switch (slot) { + case PRIMARY -> slots.primary; + case PRIMARY_CONTAINER -> slots.primaryContainer; + case SECONDARY_CONTAINER -> slots.secondaryContainer; + case FOCUS -> slots.focus; + case PLAYER_CURRENT -> slots.playerCurrent; + case SURFACE -> slots.surface; + case SURFACE_CONTAINER -> slots.surfaceContainer; + case SURFACE_CONTAINER_HIGH -> slots.surfaceContainerHigh; + case ON_SURFACE -> slots.onSurface; + case ON_SURFACE_VARIANT -> slots.onSurfaceVariant; + case OUTLINE -> slots.outline; + case ERROR -> slots.error; + case SUCCESS -> slots.success; + case WARNING -> slots.warning; + case SCRIM_OPACITY -> slots.scrimOpacity == null ? null : String.valueOf(slots.scrimOpacity); + case DIALOG_OPACITY -> slots.dialogOpacity == null ? null : String.valueOf(slots.dialogOpacity); + case OVERLAY_OPACITY -> slots.overlayOpacity == null ? null : String.valueOf(slots.overlayOpacity); + case PLAYER_SCRIM_OPACITY -> slots.playerScrimOpacity == null ? null : String.valueOf(slots.playerScrimOpacity); + }; + } + + private static void setColor(ThemeProfile.SlotSet slots, Slot slot, String value) { + switch (slot) { + case PRIMARY -> slots.primary = value; + case PRIMARY_CONTAINER -> slots.primaryContainer = value; + case SECONDARY_CONTAINER -> slots.secondaryContainer = value; + case FOCUS -> slots.focus = value; + case PLAYER_CURRENT -> slots.playerCurrent = value; + case SURFACE -> slots.surface = value; + case SURFACE_CONTAINER -> slots.surfaceContainer = value; + case SURFACE_CONTAINER_HIGH -> slots.surfaceContainerHigh = value; + case ON_SURFACE -> slots.onSurface = value; + case ON_SURFACE_VARIANT -> slots.onSurfaceVariant = value; + case OUTLINE -> slots.outline = value; + case ERROR -> slots.error = value; + case SUCCESS -> slots.success = value; + case WARNING -> slots.warning = value; + default -> throw new IllegalArgumentException("not a color slot"); + } + } + + private static void setOpacity(ThemeProfile.SlotSet slots, Slot slot, Float value) { + switch (slot) { + case SCRIM_OPACITY -> slots.scrimOpacity = value; + case DIALOG_OPACITY -> slots.dialogOpacity = value; + case OVERLAY_OPACITY -> slots.overlayOpacity = value; + case PLAYER_SCRIM_OPACITY -> slots.playerScrimOpacity = value; + default -> throw new IllegalArgumentException("not an opacity slot"); + } + } + + /** Returns the allowed range when the value is valid, otherwise null. */ + public static Double clamp(Slot slot, float value) { + if (!Float.isFinite(value)) return null; + return switch (slot) { + case SCRIM_OPACITY -> inRange(value, ThemeProfileValidator.MIN_SCRIM_OPACITY, ThemeProfileValidator.MAX_SCRIM_OPACITY); + case DIALOG_OPACITY -> inRange(value, ThemeProfileValidator.MIN_DIALOG_OPACITY, ThemeProfileValidator.MAX_DIALOG_OPACITY); + case OVERLAY_OPACITY -> inRange(value, ThemeProfileValidator.MIN_OVERLAY_OPACITY, ThemeProfileValidator.MAX_OVERLAY_OPACITY); + case PLAYER_SCRIM_OPACITY -> inRange(value, ThemeProfileValidator.MIN_PLAYER_SCRIM_OPACITY, ThemeProfileValidator.MAX_PLAYER_SCRIM_OPACITY); + default -> null; + }; + } + + private static Double inRange(float value, float minimum, float maximum) { + return value < minimum || value > maximum ? null : (double) value; + } + + public record Result(boolean success, ThemeProfile profile, String error) { + + public static Result success(ThemeProfile profile) { + return new Result(true, profile, ""); + } + + public static Result failure(String error) { + return new Result(false, null, error == null ? "unknown error" : error); + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeEditorUi.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeEditorUi.java new file mode 100644 index 0000000000..7e48f884ea --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeEditorUi.java @@ -0,0 +1,128 @@ +package com.fongmi.android.tv.theme; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.StateListDrawable; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; + +/** Small, dialog-local rendering helpers. Never changes the app's active palette. */ +public final class ThemeEditorUi { + + private ThemeEditorUi() { + } + + public static TextView text(Context context, CharSequence text, int size, int color) { + TextView view = new TextView(context); + view.setText(text); + view.setTextSize(TypedValue.COMPLEX_UNIT_SP, size); + view.setTextColor(color); + view.setIncludeFontPadding(false); + return view; + } + + public static Button button(Context context, int label) { + Button button = new Button(context); + button.setText(label); + button.setTextColor(0xFF1A1C1E); // Replaced with local tokens before the view is shown. + button.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + button.setAllCaps(false); + button.setMinHeight(dp(context, 48)); + button.setMinimumHeight(dp(context, 48)); + button.setMinWidth(0); + button.setMinimumWidth(0); + button.setPadding(dp(context, 14), dp(context, 8), dp(context, 14), dp(context, 8)); + button.setStateListAnimator(null); + button.setGravity(Gravity.CENTER); + return button; + } + + public static void buttonColors(Button button, int fill, int foreground, ThemeTokens tokens, boolean selected) { + button.setBackgroundTintList(null); + int ink = readable(foreground, fill); + button.setTextColor(new ColorStateList(new int[][]{{-android.R.attr.state_enabled}, {}}, + new int[]{withAlpha(ink, 0.45f), ink})); + button.setBackground(interactive(button.getContext(), fill, tokens.colorOutline(), tokens.colorFocus(), selected)); + button.setSelected(selected); + } + + public static LinearLayout column(Context context) { + LinearLayout layout = new LinearLayout(context); + layout.setOrientation(LinearLayout.VERTICAL); + return layout; + } + + public static LinearLayout row(Context context) { + LinearLayout layout = new LinearLayout(context); + layout.setOrientation(LinearLayout.HORIZONTAL); + layout.setGravity(Gravity.CENTER_VERTICAL); + return layout; + } + + public static void heading(TextView text) { + text.setTypeface(text.getTypeface(), Typeface.BOLD); + } + + public static GradientDrawable shape(Context context, int fill, int stroke, int width, int radius) { + GradientDrawable shape = new GradientDrawable(); + shape.setColor(fill); + shape.setCornerRadius(dp(context, radius)); + if (width > 0) shape.setStroke(dp(context, width), stroke); + return shape; + } + + /** A visible focus ring independent of selection, also usable by a TV remote. */ + public static StateListDrawable interactive(Context context, int fill, int outline, int focus, boolean selected) { + int ring = ThemeContrast.ratio(focus, fill) >= 3 ? focus : readable(focus, fill); + StateListDrawable states = new StateListDrawable(); + states.addState(new int[]{android.R.attr.state_focused}, shape(context, fill, ring, 4, 14)); + states.addState(new int[]{android.R.attr.state_pressed}, shape(context, fill, ring, 4, 14)); + states.addState(new int[]{}, shape(context, fill, selected ? ring : outline, selected ? 2 : 1, 14)); + return states; + } + + public static int readable(int preferred, int background) { + if (ThemeContrast.ratio(preferred, background) >= 4.5) return preferred; + return ThemeContrast.ratio(0xFF000000, background) >= ThemeContrast.ratio(0xFFFFFFFF, background) + ? 0xFF000000 : 0xFFFFFFFF; + } + + public static int withAlpha(int color, float opacity) { + return (color & 0x00FFFFFF) | (Math.max(0, Math.min(255, Math.round(opacity * 255))) << 24); + } + + public static float alpha(int color) { + return (color >>> 24) / 255f; + } + + public static String hex(int color) { + return ThemeProfileValidator.formatColor(color); + } + + public static LinearLayout.LayoutParams fullWidth(Context context, int topMargin) { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.topMargin = dp(context, topMargin); + return params; + } + + public static LinearLayout.LayoutParams weighted() { + return new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1); + } + + public static void padding(View view, int horizontal, int vertical) { + view.setPadding(dp(view.getContext(), horizontal), dp(view.getContext(), vertical), + dp(view.getContext(), horizontal), dp(view.getContext(), vertical)); + } + + public static int dp(Context context, int value) { + return Math.round(value * context.getResources().getDisplayMetrics().density); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeMode.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeMode.java new file mode 100644 index 0000000000..0e3813f1c6 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeMode.java @@ -0,0 +1,7 @@ +package com.fongmi.android.tv.theme; + +public enum ThemeMode { + SYSTEM, + LIGHT, + DARK +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemePaletteStyle.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemePaletteStyle.java new file mode 100644 index 0000000000..ebffadb51c --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemePaletteStyle.java @@ -0,0 +1,63 @@ +package com.fongmi.android.tv.theme; + +import com.google.android.material.color.utilities.DynamicScheme; +import com.google.android.material.color.utilities.Hct; +import com.google.android.material.color.utilities.SchemeContent; +import com.google.android.material.color.utilities.SchemeExpressive; +import com.google.android.material.color.utilities.SchemeFidelity; +import com.google.android.material.color.utilities.SchemeFruitSalad; +import com.google.android.material.color.utilities.SchemeMonochrome; +import com.google.android.material.color.utilities.SchemeNeutral; +import com.google.android.material.color.utilities.SchemeRainbow; +import com.google.android.material.color.utilities.SchemeTonalSpot; +import com.google.android.material.color.utilities.SchemeVibrant; + +/** Material palette variants inspired by aShellYou's palette-style selector. */ +public enum ThemePaletteStyle { + TONAL_SPOT("tonalSpot"), + VIBRANT("vibrant"), + EXPRESSIVE("expressive"), + RAINBOW("rainbow"), + FRUIT_SALAD("fruitSalad"), + FIDELITY("fidelity"), + CONTENT("content"), + NEUTRAL("neutral"), + MONOCHROME("monochrome"); + + private final String id; + + ThemePaletteStyle(String id) { + this.id = id; + } + + public String id() { + return id; + } + + public static ThemePaletteStyle from(String value) { + if (value == null) return TONAL_SPOT; + for (ThemePaletteStyle style : values()) { + if (style.id.equalsIgnoreCase(value) || style.name().equalsIgnoreCase(value)) return style; + } + return TONAL_SPOT; + } + + public int defaultSeed() { + return 0xFF6750A4; + } + + public DynamicScheme create(int seedColor, boolean dark) { + Hct source = Hct.fromInt(seedColor); + return switch (this) { + case TONAL_SPOT -> new SchemeTonalSpot(source, dark, 0.0); + case VIBRANT -> new SchemeVibrant(source, dark, 0.0); + case EXPRESSIVE -> new SchemeExpressive(source, dark, 0.0); + case RAINBOW -> new SchemeRainbow(source, dark, 0.0); + case FRUIT_SALAD -> new SchemeFruitSalad(source, dark, 0.0); + case FIDELITY -> new SchemeFidelity(source, dark, 0.0); + case CONTENT -> new SchemeContent(source, dark, 0.0); + case NEUTRAL -> new SchemeNeutral(source, dark, 0.0); + case MONOCHROME -> new SchemeMonochrome(source, dark, 0.0); + }; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemePresets.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemePresets.java new file mode 100644 index 0000000000..db6b499604 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemePresets.java @@ -0,0 +1,163 @@ +package com.fongmi.android.tv.theme; + +import com.fongmi.android.tv.R; +import com.google.android.material.color.utilities.Blend; +import com.google.android.material.color.utilities.DynamicScheme; +import com.google.android.material.color.utilities.Hct; + +import java.util.Objects; + +/** Complete, independently editable light/dark palettes. No preferences or UI state are written here. */ +public final class ThemePresets { + + public enum Preset { + DEFAULT("webhtv.local", "Default", R.string.theme_editor_preset_default, 0, 0, 0, 0, ThemePaletteStyle.TONAL_SPOT), + OCEAN("webhtv.preset.ocean", "Ocean", R.string.theme_editor_preset_blue, + 0xFF1565C0, 0xFF006B74, 0xFF25734A, 0xFF845B00, ThemePaletteStyle.VIBRANT), + LAGOON("webhtv.preset.lagoon", "Lagoon", R.string.theme_editor_preset_teal, + 0xFF007F7A, 0xFF535DA8, 0xFF23704F, 0xFF8B5900, ThemePaletteStyle.TONAL_SPOT), + FOREST("webhtv.preset.forest", "Forest", R.string.theme_editor_preset_green, + 0xFF386A20, 0xFF80561D, 0xFF3F6B24, 0xFF7D6200, ThemePaletteStyle.TONAL_SPOT), + AMBER("webhtv.preset.amber", "Amber", R.string.theme_editor_preset_orange, + 0xFFA65D13, 0xFF456A51, 0xFF526B27, 0xFF975200, ThemePaletteStyle.FIDELITY), + ROSE("webhtv.preset.rose", "Rose", R.string.theme_editor_preset_red, + 0xFFAE315B, 0xFF705289, 0xFF247456, 0xFF946000, ThemePaletteStyle.TONAL_SPOT), + IRIS("webhtv.preset.iris", "Iris", R.string.theme_editor_preset_purple, + 0xFF7043A6, 0xFF8B4A65, 0xFF317450, 0xFF8F6500, ThemePaletteStyle.VIBRANT), + GRAPHITE("webhtv.preset.graphite", "Graphite", R.string.theme_editor_preset_graphite, + 0xFF596168, 0xFF596168, 0xFF426957, 0xFF78613A, ThemePaletteStyle.NEUTRAL), + WALLPAPER("webhtv.wallpaper", "Wallpaper", R.string.theme_editor_preset_wallpaper, + 0, 0, 0, 0, ThemePaletteStyle.TONAL_SPOT); + + public final String id; + public final String name; + public final int label; + public final ThemePaletteStyle style; + private final int seed; + private final int secondary; + private final int success; + private final int warning; + + Preset(String id, String name, int label, int seed, int secondary, int success, int warning, + ThemePaletteStyle style) { + this.id = id; + this.name = name; + this.label = label; + this.seed = seed; + this.secondary = secondary; + this.success = success; + this.warning = warning; + this.style = style; + } + + public boolean isComplete() { + return this != DEFAULT && this != WALLPAPER; + } + + public ThemeProfile profile() { + return profile(style); + } + + public ThemeProfile profile(ThemePaletteStyle selectedStyle) { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.id = id; + profile.name = name; + profile.paletteStyle = selectedStyle.id(); + if (this == DEFAULT) return profile; + if (this == WALLPAPER) { + // Keep this genuinely dynamic: freezing slots here would stop following wallpaper changes. + profile.seedSource = ThemeProfile.SEED_WALLPAPER; + return profile; + } + profile.seedSource = ThemeProfile.SEED_CUSTOM; + profile.seedColor = ThemeProfileValidator.formatColor(seed); + profile.light = slots(false, selectedStyle); + profile.dark = slots(true, selectedStyle); + // Store the same safe values the runtime will display, not a misleading pre-correction swatch. + for (boolean dark : new boolean[]{false, true}) { + ThemeTokens resolved = new ThemeEditor(profile).preview(dark, 0); + if (ThemeResolver.lastDiagnostic().startsWith("fallback")) { + throw new IllegalStateException("Invalid built-in palette: " + id + "/" + selectedStyle); + } + ThemeProfile.SlotSet slots = profile.slots(dark); + slots.primary = hex(resolved.colorPrimary()); + slots.focus = slots.primary; // Preserve the binder's shared primary/focus baseline mapping. + } + return profile; + } + + private ThemeProfile.SlotSet slots(boolean dark, ThemePaletteStyle selectedStyle) { + DynamicScheme scheme = selectedStyle.create(seed, dark); + ThemeProfile.SlotSet slots = new ThemeProfile.SlotSet(); + boolean monochrome = selectedStyle == ThemePaletteStyle.MONOCHROME; + double neutralChroma = monochrome ? 0 : selectedStyle == ThemePaletteStyle.NEUTRAL ? 4 : 14; + double hue = Hct.fromInt(scheme.getSurface()).getHue(); + slots.primary = hex(scheme.getPrimary()); + slots.primaryContainer = hex(scheme.getPrimaryContainer()); + slots.secondaryContainer = hex(monochrome ? scheme.getSecondaryContainer() + : Hct.from(Hct.fromInt(secondary).getHue(), 24, dark ? 28 : 90).toInt()); + slots.focus = slots.primary; + slots.surface = hex(Hct.from(hue, neutralChroma, dark ? 7 : 98).toInt()); + slots.surfaceContainer = hex(Hct.from(hue, neutralChroma, dark ? 12 : 94).toInt()); + slots.surfaceContainerHigh = hex(Hct.from(hue, neutralChroma, dark ? 18 : 90).toInt()); + slots.onSurface = hex(Hct.from(hue, neutralChroma / 2, dark ? 94 : 12).toInt()); + slots.onSurfaceVariant = hex(Hct.from(hue, neutralChroma / 2, dark ? 80 : 32).toInt()); + slots.outline = hex(Hct.from(hue, neutralChroma, dark ? 62 : 48).toInt()); + slots.success = semantic(success, dark); + // 播放页「当前播放」环色:默认沿用成功色的语义绿,但保持独立槽位可单独编辑。 + slots.playerCurrent = slots.success; + slots.warning = semantic(warning, dark); + slots.error = semantic(0xFFBA1A1A, dark); + return slots; + } + + private String semantic(int color, boolean dark) { + // Material harmonization shifts hue by at most 15 degrees; green/amber/red remain semantic. + Hct harmonized = Hct.fromInt(Blend.harmonize(color, seed)); + return hex(Hct.from(harmonized.getHue(), harmonized.getChroma(), dark ? 80 : 36).toInt()); + } + } + + private ThemePresets() { + } + + public static Preset find(ThemeProfile profile) { + if (profile == null) return null; + for (Preset preset : Preset.values()) { + if (!preset.id.equals(profile.id)) continue; + if (preset == Preset.DEFAULT && !ThemeProfile.SEED_NONE.equals(profile.seedSource)) return null; + return preset; + } + return null; + } + + /** A style change updates template-generated slots, but never discards individual user edits. */ + public static ThemeProfile withPaletteStyle(ThemeProfile source, ThemePaletteStyle style) { + Preset preset = find(source); + if (preset == null || !preset.isComplete()) { + ThemeProfile copy = source.copy(); + copy.paletteStyle = style.id(); + return copy; + } + ThemeEditor before = new ThemeEditor(preset.profile(ThemePaletteStyle.from(source.paletteStyle))); + ThemeEditor current = new ThemeEditor(source); + ThemeEditor after = new ThemeEditor(preset.profile(style)); + for (boolean dark : new boolean[]{false, true}) { + for (ThemeEditor.Slot slot : ThemeEditor.Slot.values()) { + String value = current.valueOf(slot, dark); + if (Objects.equals(value, before.valueOf(slot, dark))) continue; + if (value == null) after.clear(slot, dark); + else if (slot.isColor()) after.set(slot, dark, value, 0); + else after.set(slot, dark, null, Float.parseFloat(value)); + } + } + ThemeProfile result = after.draft(); + result.name = source.name; + result.mode = source.mode; + return result; + } + + private static String hex(int color) { + return ThemeProfileValidator.formatColor(color); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemePreviewView.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemePreviewView.java new file mode 100644 index 0000000000..b0b8ec40f0 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemePreviewView.java @@ -0,0 +1,336 @@ +package com.fongmi.android.tv.theme; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Typeface; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.fongmi.android.tv.R; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; + +/** The settings themselves are the preview: stable, locally themed controls, with no sample panel. */ +public final class ThemePreviewView extends LinearLayout { + + public interface Callbacks { + void onColorSlotClicked(ThemeEditor.Slot slot, boolean dark); + void onDraftChanged(); + } + + private final Callbacks callbacks; + private final EnumMap colors = new EnumMap<>(ThemeEditor.Slot.class); + private final EnumMap opacities = new EnumMap<>(ThemeEditor.Slot.class); + private final List groups = new ArrayList<>(); + private ThemeEditor editor; + private boolean dark; + private boolean binding; + + private ThemePreviewView(Context context, Callbacks callbacks) { + super(context); + this.callbacks = callbacks; + setOrientation(VERTICAL); + boolean wide = context.getResources().getConfiguration().screenWidthDp >= 700; + addGroups(wide, + colorGroup(R.string.theme_editor_group_accent, ThemeEditor.Slot.PRIMARY, + ThemeEditor.Slot.PRIMARY_CONTAINER, ThemeEditor.Slot.SECONDARY_CONTAINER, ThemeEditor.Slot.FOCUS), + colorGroup(R.string.theme_editor_group_surfaces, ThemeEditor.Slot.SURFACE, + ThemeEditor.Slot.SURFACE_CONTAINER, ThemeEditor.Slot.SURFACE_CONTAINER_HIGH)); + // 「当前播放环色」单独一组:它不是页面角色,而是播放页叠在视频上的状态色, + // 解析时会在固定玻璃面板上做可读性夹取。 + addView(colorGroup(R.string.theme_editor_group_player, ThemeEditor.Slot.PLAYER_CURRENT).view, + ThemeEditorUi.fullWidth(context, 12)); + addGroups(wide, + colorGroup(R.string.theme_editor_group_text, ThemeEditor.Slot.ON_SURFACE, + ThemeEditor.Slot.ON_SURFACE_VARIANT, ThemeEditor.Slot.OUTLINE), + colorGroup(R.string.theme_editor_group_states, ThemeEditor.Slot.SUCCESS, + ThemeEditor.Slot.WARNING, ThemeEditor.Slot.ERROR)); + Group layers = group(R.string.theme_editor_group_layers); + for (ThemeEditor.Slot slot : new ThemeEditor.Slot[]{ThemeEditor.Slot.SCRIM_OPACITY, + ThemeEditor.Slot.DIALOG_OPACITY, ThemeEditor.Slot.OVERLAY_OPACITY, + ThemeEditor.Slot.PLAYER_SCRIM_OPACITY}) { + OpacityRow row = new OpacityRow(slot); + opacities.put(slot, row); + layers.view.addView(row.view, ThemeEditorUi.fullWidth(context, 8)); + } + addView(layers.view, ThemeEditorUi.fullWidth(context, 12)); + } + + public static ThemePreviewView createPanel(Context context, ThemeEditor editor, boolean dark, + ThemeTokens tokens, Callbacks callbacks) { + ThemePreviewView panel = new ThemePreviewView(context, callbacks); + panel.render(editor, dark, tokens); + return panel; + } + + /** Rebinds in place so dragging, accessibility focus and the scroll position cannot be lost. */ + public void render(ThemeEditor editor, boolean dark, ThemeTokens tokens) { + this.editor = editor; + this.dark = dark; + binding = true; + for (Group group : groups) { + group.view.setBackground(ThemeEditorUi.shape(getContext(), tokens.colorSurfaceContainer(), 0, 0, 18)); + group.title.setTextColor(ThemeEditorUi.readable(tokens.colorOnSurfaceVariant(), tokens.colorSurfaceContainer())); + } + for (ColorRow row : colors.values()) row.render(tokens); + for (OpacityRow row : opacities.values()) row.render(tokens); + binding = false; + } + + private Group colorGroup(int label, ThemeEditor.Slot... slots) { + Group group = group(label); + for (ThemeEditor.Slot slot : slots) { + ColorRow row = new ColorRow(slot); + colors.put(slot, row); + group.view.addView(row.view, ThemeEditorUi.fullWidth(getContext(), 6)); + } + return group; + } + + private Group group(int label) { + LinearLayout view = ThemeEditorUi.column(getContext()); + ThemeEditorUi.padding(view, 12, 12); + TextView title = ThemeEditorUi.text(getContext(), getContext().getString(label), 13, 0xFF44474F); + ThemeEditorUi.heading(title); + view.addView(title, ThemeEditorUi.fullWidth(getContext(), 0)); + Group group = new Group(view, title); + groups.add(group); + return group; + } + + private void addGroups(boolean wide, Group first, Group second) { + if (!wide) { + addView(first.view, ThemeEditorUi.fullWidth(getContext(), 12)); + addView(second.view, ThemeEditorUi.fullWidth(getContext(), 12)); + return; + } + LinearLayout row = ThemeEditorUi.row(getContext()); + row.setGravity(Gravity.TOP); + LinearLayout.LayoutParams left = ThemeEditorUi.weighted(); + left.setMarginEnd(ThemeEditorUi.dp(getContext(), 12)); + row.addView(first.view, left); + row.addView(second.view, ThemeEditorUi.weighted()); + addView(row, ThemeEditorUi.fullWidth(getContext(), 12)); + } + + private final class ColorRow { + final ThemeEditor.Slot slot; + final LinearLayout view; + final TextView swatch; + final TextView label; + final TextView value; + final Button clear; + + ColorRow(ThemeEditor.Slot slot) { + this.slot = slot; + Context context = getContext(); + view = ThemeEditorUi.row(context); + view.setTag("theme-slot:" + slot.name()); + ThemeEditorUi.padding(view, 8, 8); + view.setMinimumHeight(ThemeEditorUi.dp(context, 64)); + view.setFocusable(true); + view.setOnClickListener(ignored -> callbacks.onColorSlotClicked(slot, dark)); + + swatch = ThemeEditorUi.text(context, symbol(slot), 14, 0xFF000000); + swatch.setGravity(Gravity.CENTER); + swatch.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + int size = ThemeEditorUi.dp(context, 32); + LinearLayout.LayoutParams swatchParams = new LinearLayout.LayoutParams(size, size); + swatchParams.setMarginEnd(ThemeEditorUi.dp(context, 10)); + view.addView(swatch, swatchParams); + + LinearLayout text = ThemeEditorUi.column(context); + label = ThemeEditorUi.text(context, context.getString(labelOf(slot)), 14, 0xFF1A1C1E); + value = ThemeEditorUi.text(context, "", 11, 0xFF44474F); + value.setTypeface(Typeface.MONOSPACE); + text.addView(label); + text.addView(value, ThemeEditorUi.fullWidth(context, 5)); + view.addView(text, ThemeEditorUi.weighted()); + + clear = ThemeEditorUi.button(context, R.string.theme_editor_clear_short); + clear.setContentDescription(context.getString(R.string.theme_editor_clear) + " · " + label.getText()); + clear.setOnClickListener(ignored -> { + editor.clear(slot, dark); + callbacks.onDraftChanged(); + }); + view.addView(clear, new LinearLayout.LayoutParams(ThemeEditorUi.dp(context, 48), ViewGroup.LayoutParams.WRAP_CONTENT)); + } + + void render(ThemeTokens tokens) { + int color = colorOf(tokens, slot); + int fill = slot == ThemeEditor.Slot.SURFACE_CONTAINER_HIGH + ? tokens.colorSurfaceContainerHigh() : tokens.colorSurfaceContainer(); + String raw = editor.valueOf(slot, dark); + String actual = ThemeEditorUi.hex(color); + value.setText(raw == null ? getContext().getString(R.string.theme_editor_value_auto, actual) + : raw.equalsIgnoreCase(actual) ? actual + : getContext().getString(R.string.theme_editor_value_adjusted, raw, actual)); + label.setTextColor(ThemeEditorUi.readable(tokens.colorOnSurface(), fill)); + value.setTextColor(ThemeEditorUi.readable(tokens.colorOnSurfaceVariant(), fill)); + swatch.setBackground(ThemeEditorUi.shape(getContext(), color, tokens.colorOutline(), 1, 9)); + swatch.setTextColor(ThemeEditorUi.readable(tokens.colorOnSurface(), color)); + view.setBackground(ThemeEditorUi.interactive(getContext(), fill, tokens.colorOutlineVariant(), tokens.colorFocus(), false)); + clear.setEnabled(raw != null); + ThemeEditorUi.buttonColors(clear, fill, tokens.colorOnSurfaceVariant(), tokens, false); + } + } + + private final class OpacityRow { + final ThemeEditor.Slot slot; + final LinearLayout view; + final TextView label; + final TextView value; + final SeekBar bar; + final Button clear; + + OpacityRow(ThemeEditor.Slot slot) { + this.slot = slot; + Context context = getContext(); + view = ThemeEditorUi.column(context); + LinearLayout header = ThemeEditorUi.row(context); + label = ThemeEditorUi.text(context, context.getString(labelOf(slot)), 14, 0xFF1A1C1E); + header.addView(label, ThemeEditorUi.weighted()); + value = ThemeEditorUi.text(context, "", 12, 0xFF44474F); + header.addView(value); + view.addView(header); + + LinearLayout controls = ThemeEditorUi.row(context); + bar = new SeekBar(context); + bar.setTag("theme-opacity:" + slot.name()); + bar.setMax(100); + bar.setMinimumHeight(ThemeEditorUi.dp(context, 48)); + controls.addView(bar, ThemeEditorUi.weighted()); + clear = ThemeEditorUi.button(context, R.string.theme_editor_clear_short); + clear.setContentDescription(context.getString(R.string.theme_editor_clear) + " · " + label.getText()); + clear.setOnClickListener(ignored -> { + editor.clear(slot, dark); + callbacks.onDraftChanged(); + }); + controls.addView(clear, new LinearLayout.LayoutParams(ThemeEditorUi.dp(context, 48), ViewGroup.LayoutParams.WRAP_CONTENT)); + view.addView(controls); + bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (!fromUser || binding) return; + float opacity = minimumOf(slot) + (maximumOf(slot) - minimumOf(slot)) * progress / 100f; + if (editor.set(slot, dark, null, opacity).success()) callbacks.onDraftChanged(); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + seekBar.getParent().requestDisallowInterceptTouchEvent(true); + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + seekBar.getParent().requestDisallowInterceptTouchEvent(false); + } + }); + } + + void render(ThemeTokens tokens) { + String raw = editor.valueOf(slot, dark); + float opacity = switch (slot) { + case SCRIM_OPACITY -> ThemeEditorUi.alpha(tokens.colorScrim()); + case DIALOG_OPACITY -> tokens.dialogOpacity(); + case PLAYER_SCRIM_OPACITY -> PlaybackMenuScrim.DEFAULT_OPACITY; + default -> ThemeEditorUi.alpha(tokens.colorOverlayLight()); + }; + if (raw != null) opacity = Float.parseFloat(raw); + String percentage = Math.round(opacity * 100) + "%"; + value.setText(raw == null ? getContext().getString(R.string.theme_editor_value_auto, percentage) : percentage); + int fill = tokens.colorSurfaceContainer(); + label.setTextColor(ThemeEditorUi.readable(tokens.colorOnSurface(), fill)); + value.setTextColor(ThemeEditorUi.readable(tokens.colorOnSurfaceVariant(), fill)); + bar.setProgress(Math.round((opacity - minimumOf(slot)) / (maximumOf(slot) - minimumOf(slot)) * 100)); + bar.setContentDescription(label.getText() + " · " + percentage); + bar.setProgressTintList(ColorStateList.valueOf(tokens.colorPrimary())); + bar.setProgressBackgroundTintList(ColorStateList.valueOf(tokens.colorOutline())); + bar.setThumbTintList(ColorStateList.valueOf(tokens.colorPrimary())); + bar.setBackground(ThemeEditorUi.interactive(getContext(), fill, fill, tokens.colorFocus(), false)); + clear.setEnabled(raw != null); + ThemeEditorUi.buttonColors(clear, fill, tokens.colorOnSurfaceVariant(), tokens, false); + } + } + + private record Group(LinearLayout view, TextView title) { } + + private static String symbol(ThemeEditor.Slot slot) { + return switch (slot) { + case ON_SURFACE, ON_SURFACE_VARIANT -> "Aa"; + case SUCCESS -> "✓"; + case WARNING -> "!"; + case ERROR -> "×"; + case PLAYER_CURRENT -> "▶"; + default -> ""; + }; + } + + public static int colorOf(ThemeTokens tokens, ThemeEditor.Slot slot) { + return switch (slot) { + case PRIMARY -> tokens.colorPrimary(); + case PRIMARY_CONTAINER -> tokens.colorPrimaryContainer(); + case SECONDARY_CONTAINER -> tokens.colorSecondaryContainer(); + case FOCUS -> tokens.colorFocus(); + case PLAYER_CURRENT -> tokens.colorPlayerCurrentRing(); + case SURFACE -> tokens.colorSurface(); + case SURFACE_CONTAINER -> tokens.colorSurfaceContainer(); + case SURFACE_CONTAINER_HIGH -> tokens.colorSurfaceContainerHigh(); + case ON_SURFACE -> tokens.colorOnSurface(); + case ON_SURFACE_VARIANT -> tokens.colorOnSurfaceVariant(); + case OUTLINE -> tokens.colorOutline(); + case ERROR -> tokens.colorError(); + case SUCCESS -> tokens.colorSuccess(); + case WARNING -> tokens.colorWarning(); + default -> throw new IllegalArgumentException("Not a colour slot: " + slot); + }; + } + + private static float minimumOf(ThemeEditor.Slot slot) { + return switch (slot) { + case SCRIM_OPACITY -> ThemeProfileValidator.MIN_SCRIM_OPACITY; + case DIALOG_OPACITY -> ThemeProfileValidator.MIN_DIALOG_OPACITY; + case PLAYER_SCRIM_OPACITY -> ThemeProfileValidator.MIN_PLAYER_SCRIM_OPACITY; + default -> ThemeProfileValidator.MIN_OVERLAY_OPACITY; + }; + } + + private static float maximumOf(ThemeEditor.Slot slot) { + return switch (slot) { + case SCRIM_OPACITY -> ThemeProfileValidator.MAX_SCRIM_OPACITY; + case DIALOG_OPACITY -> ThemeProfileValidator.MAX_DIALOG_OPACITY; + case PLAYER_SCRIM_OPACITY -> ThemeProfileValidator.MAX_PLAYER_SCRIM_OPACITY; + default -> ThemeProfileValidator.MAX_OVERLAY_OPACITY; + }; + } + + public static int labelOf(ThemeEditor.Slot slot) { + return switch (slot) { + case PRIMARY -> R.string.theme_editor_slot_primary; + case PRIMARY_CONTAINER -> R.string.theme_editor_slot_primary_container; + case SECONDARY_CONTAINER -> R.string.theme_editor_slot_secondary_container; + case FOCUS -> R.string.theme_editor_slot_focus; + case PLAYER_CURRENT -> R.string.theme_editor_slot_player_current; + case SURFACE -> R.string.theme_editor_slot_surface; + case SURFACE_CONTAINER -> R.string.theme_editor_slot_surface_container; + case SURFACE_CONTAINER_HIGH -> R.string.theme_editor_slot_surface_container_high; + case ON_SURFACE -> R.string.theme_editor_slot_on_surface; + case ON_SURFACE_VARIANT -> R.string.theme_editor_slot_on_surface_variant; + case OUTLINE -> R.string.theme_editor_slot_outline; + case ERROR -> R.string.theme_editor_slot_error; + case SUCCESS -> R.string.theme_editor_slot_success; + case WARNING -> R.string.theme_editor_slot_warning; + case SCRIM_OPACITY -> R.string.theme_editor_slot_scrim_opacity; + case DIALOG_OPACITY -> R.string.theme_editor_slot_dialog_opacity; + case OVERLAY_OPACITY -> R.string.theme_editor_slot_overlay_opacity; + case PLAYER_SCRIM_OPACITY -> R.string.theme_editor_slot_player_scrim_opacity; + }; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfile.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfile.java new file mode 100644 index 0000000000..e6988bfb9d --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfile.java @@ -0,0 +1,136 @@ +package com.fongmi.android.tv.theme; + +import java.util.Locale; + +/** Persisted B-safe theme profile limited to the 17 user-editable semantic slots. */ +public final class ThemeProfile { + + public static final int SCHEMA_VERSION = 2; + public static final String FORMAT = "webhtv-theme"; + public static final String MODE_SYSTEM = "system"; + public static final String MODE_LIGHT = "light"; + public static final String MODE_DARK = "dark"; + public static final String SEED_NONE = "none"; + public static final String SEED_WALLPAPER = "wallpaper"; + public static final String SEED_CUSTOM = "custom"; + + public int schemaVersion = SCHEMA_VERSION; + public String format = FORMAT; + public String id = "webhtv.local"; + public String name = "Default"; + public String mode = MODE_SYSTEM; + public String paletteStyle = ThemePaletteStyle.TONAL_SPOT.id(); + public String seedSource = SEED_NONE; + public String seedColor; + public SlotSet light = new SlotSet(); + public SlotSet dark = new SlotSet(); + + public ThemeProfile() { + } + + public static ThemeProfile defaultProfile() { + return new ThemeProfile(); + } + + public ThemeProfile copy() { + ThemeProfile copy = new ThemeProfile(); + copy.schemaVersion = schemaVersion; + copy.format = format; + copy.id = id; + copy.name = name; + copy.mode = mode; + copy.paletteStyle = paletteStyle; + copy.seedSource = seedSource; + copy.seedColor = seedColor; + copy.light = light == null ? null : light.copy(); + copy.dark = dark == null ? null : dark.copy(); + return copy; + } + + public SlotSet slots(boolean dark) { + if (dark) { + if (this.dark == null) this.dark = new SlotSet(); + return this.dark; + } + if (light == null) light = new SlotSet(); + return light; + } + + public String displayName() { + return name == null || name.isBlank() ? "Default" : name; + } + + public static String normalizeMode(String value) { + if (MODE_LIGHT.equals(value)) return MODE_LIGHT; + if (MODE_DARK.equals(value)) return MODE_DARK; + return MODE_SYSTEM; + } + + public static String normalizePaletteStyle(String value) { + return ThemePaletteStyle.from(value).id(); + } + + public static String normalizeSeedSource(String value) { + if (SEED_CUSTOM.equals(value)) return SEED_CUSTOM; + if (SEED_WALLPAPER.equals(value)) return SEED_WALLPAPER; + return SEED_NONE; + } + + /** The 13 color slots plus 4 opacity slots a user may edit. */ + public static final class SlotSet { + public String primary; + public String primaryContainer; + public String secondaryContainer; + public String focus; + /** + * 播放页「当前播放/当前生效」环色。与 focus 分开,因为这两个状态在播放页同时存在、 + * 必须可区分;取值会在播放页固定玻璃面板上做可读性夹取(ThemeResolver)。 + */ + public String playerCurrent; + public String surface; + public String surfaceContainer; + public String surfaceContainerHigh; + public String onSurface; + public String onSurfaceVariant; + public String outline; + public String error; + public String success; + public String warning; + public Float scrimOpacity; + public Float dialogOpacity; + public Float overlayOpacity; + /** + * 播放菜单遮盖层(播放页底部控制条黑幕)的强度,只缩放 alpha,色相固定取 + * colorPlayerScrim;null 表示回落到 {@code PlaybackMenuScrim.DEFAULT_OPACITY}。 + */ + public Float playerScrimOpacity; + + public SlotSet copy() { + SlotSet copy = new SlotSet(); + copy.primary = primary; + copy.primaryContainer = primaryContainer; + copy.secondaryContainer = secondaryContainer; + copy.focus = focus; + copy.playerCurrent = playerCurrent; + copy.surface = surface; + copy.surfaceContainer = surfaceContainer; + copy.surfaceContainerHigh = surfaceContainerHigh; + copy.onSurface = onSurface; + copy.onSurfaceVariant = onSurfaceVariant; + copy.outline = outline; + copy.error = error; + copy.success = success; + copy.warning = warning; + copy.scrimOpacity = scrimOpacity; + copy.dialogOpacity = dialogOpacity; + copy.overlayOpacity = overlayOpacity; + copy.playerScrimOpacity = playerScrimOpacity; + return copy; + } + } + + @Override + public String toString() { + return String.format(Locale.ROOT, "%s (%s)", displayName(), mode); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileCodec.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileCodec.java new file mode 100644 index 0000000000..6ca1626c82 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileCodec.java @@ -0,0 +1,103 @@ +package com.fongmi.android.tv.theme; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** JSON codec for the bounded B-safe profile; rejects executable or oversized payloads. */ +public final class ThemeProfileCodec { + + private static final Gson GSON = new GsonBuilder().serializeNulls().create(); + + private ThemeProfileCodec() { + } + + public static ThemeProfile parse(String json) { + if (json == null || json.isBlank()) throw new IllegalArgumentException("theme JSON is empty"); + validateJsonBounds(json); + JsonElement root; + try { + root = JsonParser.parseString(json); + } catch (RuntimeException error) { + throw new IllegalArgumentException("theme JSON is invalid", error); + } + if (!root.isJsonObject()) throw new IllegalArgumentException("theme JSON must be an object"); + inspect(root, 0); + ThemeProfile parsed; + try { + parsed = GSON.fromJson(root, ThemeProfile.class); + } catch (RuntimeException error) { + throw new IllegalArgumentException("theme JSON has an invalid field type", error); + } + if (parsed == null) throw new IllegalArgumentException("theme JSON must be an object"); + applyMissingDefaults(parsed); + ThemeProfileValidator.Result result = ThemeProfileValidator.validate(parsed); + if (!result.valid()) throw new IllegalArgumentException(result.message()); + return result.profile(); + } + + /** + * Gson allocates without running field initializers, so absent keys arrive as + * null/0. Bounded defaults keep an old or partial payload loadable. + */ + static void applyMissingDefaults(ThemeProfile profile) { + if (profile.format == null) profile.format = ThemeProfile.FORMAT; + if (profile.schemaVersion == 0) profile.schemaVersion = ThemeProfile.SCHEMA_VERSION; + if (profile.light == null) profile.light = new ThemeProfile.SlotSet(); + if (profile.dark == null) profile.dark = new ThemeProfile.SlotSet(); + } + + public static String encode(ThemeProfile profile) { + ThemeProfileValidator.Result result = ThemeProfileValidator.validate(profile); + if (!result.valid()) throw new IllegalArgumentException(result.message()); + return GSON.toJson(result.profile()); + } + + static void validateJsonBounds(String json) { + if (json.getBytes(StandardCharsets.UTF_8).length > ThemeProfileValidator.MAX_JSON_BYTES) { + throw new IllegalArgumentException("theme JSON is too large"); + } + int depth = 0; + boolean quoted = false; + boolean escaped = false; + for (int index = 0; index < json.length(); index++) { + char current = json.charAt(index); + if (quoted) { + if (escaped) escaped = false; + else if (current == '\\') escaped = true; + else if (current == '"') quoted = false; + continue; + } + if (current == '"') quoted = true; + else if (current == '{' || current == '[') { + if (++depth > ThemeProfileValidator.MAX_NESTING_DEPTH) { + throw new IllegalArgumentException("theme JSON is too deeply nested"); + } + } else if (current == '}' || current == ']') { + if (--depth < 0) throw new IllegalArgumentException("theme JSON is invalid"); + } + } + if (quoted || depth != 0) throw new IllegalArgumentException("theme JSON is invalid"); + } + + private static void inspect(JsonElement element, int depth) { + if (depth > ThemeProfileValidator.MAX_NESTING_DEPTH) throw new IllegalArgumentException("theme JSON is too deeply nested"); + if (element == null || element.isJsonNull() || element.isJsonPrimitive()) return; + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) inspect(child, depth + 1); + return; + } + JsonObject object = element.getAsJsonObject(); + for (Map.Entry entry : object.entrySet()) { + if (!ThemeProfileValidator.isSafeJsonKey(entry.getKey())) { + throw new IllegalArgumentException("unsupported executable theme field: " + entry.getKey()); + } + inspect(entry.getValue(), depth + 1); + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileStore.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileStore.java new file mode 100644 index 0000000000..ea37089c8b --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileStore.java @@ -0,0 +1,167 @@ +package com.fongmi.android.tv.theme; + +import com.fongmi.android.tv.setting.Setting; +import com.github.catvod.utils.Prefers; + +/** SharedPreferences-backed store for the v2 profile with last-good recovery. */ +public final class ThemeProfileStore { + + public static final String KEY_PROFILE = "theme_profile_v2_json"; + public static final String KEY_LAST_GOOD = "theme_profile_v2_last_good"; + public static final String KEY_SCHEMA = "theme_profile_v2_schema"; + public static final String LEGACY_KEY_PROFILE = "theme_profile_json"; + + private ThemeProfileStore() { + } + + /** Reads the active profile; corrupted or missing data never blocks the caller. */ + public static ThemeProfile load() { + ThemeProfile current = parse(Prefers.getString(KEY_PROFILE)); + if (current != null) return current; + ThemeProfile lastGood = parse(Prefers.getString(KEY_LAST_GOOD)); + return lastGood != null ? lastGood : migrateLegacy(Setting.getThemeColor(), Prefers.getString(LEGACY_KEY_PROFILE)); + } + + /** Returns the last successfully persisted profile, or null when none is usable. */ + public static ThemeProfile loadLastGood() { + return parse(Prefers.getString(KEY_LAST_GOOD)); + } + + /** Persists the one-time legacy migration so later loads do not re-derive it. */ + public static ApplyResult ensureMigrated() { + ThemeProfile current = parse(Prefers.getString(KEY_PROFILE)); + if (current != null) return ApplyResult.success(current); + ThemeProfile migrated = load(); + return apply(migrated); + } + + public static ApplyResult apply(ThemeProfile draft) { + ThemeProfileValidator.Result result = ThemeProfileValidator.validate(draft); + if (!result.valid()) return ApplyResult.failure(result.message()); + String json; + try { + json = ThemeProfileCodec.encode(result.profile()); + } catch (RuntimeException error) { + return ApplyResult.failure(error.getMessage() == null ? "theme serialization failed" : error.getMessage()); + } + if (!writeJson(result.profile(), json)) return ApplyResult.failure("theme preferences could not be saved"); + return ApplyResult.success(result.profile()); + } + + public static ApplyResult reset() { + return apply(ThemeProfile.defaultProfile()); + } + + /** Maps the legacy theme_color mirror and, when present, bounded v1 profile fields. */ + public static ThemeProfile migrateLegacy(int legacyThemeColor, String legacyProfileJson) { + ThemeProfile migrated = fromLegacyJson(legacyProfileJson); + applyLegacyThemeColor(migrated, legacyThemeColor); + return migrated; + } + + public static ThemeProfile migrateLegacy(int legacyThemeColor) { + return migrateLegacy(legacyThemeColor, null); + } + + /** Mirrors seed source/color back into the legacy theme_color preference. */ + public static int legacyThemeColor(ThemeProfile profile) { + if (profile == null || ThemeProfile.SEED_NONE.equals(profile.seedSource)) return -1; + if (ThemeProfile.SEED_WALLPAPER.equals(profile.seedSource)) return 0; + return ThemeProfileValidator.parseColor(profile.seedColor, -1); + } + + private static ThemeProfile fromLegacyJson(String json) { + ThemeProfile profile = ThemeProfile.defaultProfile(); + if (json == null || json.isBlank()) return profile; + try { + com.google.gson.JsonElement parsed = com.google.gson.JsonParser.parseString(json); + if (!parsed.isJsonObject()) return profile; + com.google.gson.JsonObject root = parsed.getAsJsonObject(); + profile.mode = ThemeProfile.normalizeMode(string(root, "mode")); + profile.seedSource = ThemeProfile.normalizeSeedSource(string(root, "seedSource")); + String seed = nullableString(root, "seedColor"); + if (seed != null) profile.seedColor = ThemeProfileValidator.normalizeColor(seed); + com.google.gson.JsonObject colors = object(root, "colors"); + if (colors != null) { + copyLegacySlots(object(colors, "light"), profile.slots(false)); + copyLegacySlots(object(colors, "dark"), profile.slots(true)); + } + } catch (RuntimeException ignored) { + // A malformed legacy profile must not block startup; theme_color remains the fallback. + } + return profile; + } + + private static void copyLegacySlots(com.google.gson.JsonObject source, ThemeProfile.SlotSet target) { + if (source == null) return; + target.primary = ThemeProfileValidator.normalizeColor(nullableString(source, "primary")); + target.surface = ThemeProfileValidator.normalizeColor(nullableString(source, "surface")); + target.onSurface = ThemeProfileValidator.normalizeColor(nullableString(source, "onSurface")); + target.outline = ThemeProfileValidator.normalizeColor(nullableString(source, "outline")); + target.error = ThemeProfileValidator.normalizeColor(nullableString(source, "error")); + } + + private static void applyLegacyThemeColor(ThemeProfile profile, int legacyThemeColor) { + if (legacyThemeColor == -1) { + if (ThemeProfile.SEED_NONE.equals(profile.seedSource)) profile.seedColor = null; + return; + } + profile.seedSource = legacyThemeColor == 0 ? ThemeProfile.SEED_WALLPAPER : ThemeProfile.SEED_CUSTOM; + profile.seedColor = legacyThemeColor == 0 ? null : ThemeProfileValidator.formatColor(legacyThemeColor); + if (legacyThemeColor != 0) { + profile.id = "webhtv.legacy-custom"; + profile.name = "Custom"; + } + } + + private static ThemeProfile parse(String raw) { + if (raw == null || raw.isBlank()) return null; + try { + return ThemeProfileCodec.parse(raw); + } catch (RuntimeException error) { + return null; + } + } + + private static boolean writeJson(ThemeProfile profile, String json) { + try { + android.content.SharedPreferences.Editor editor = Prefers.getPrefers().edit() + .putString(KEY_PROFILE, json) + .putString(KEY_LAST_GOOD, json) + .putInt(KEY_SCHEMA, ThemeProfile.SCHEMA_VERSION) + .putInt("theme_color", legacyThemeColor(profile)); + return editor.commit(); + } catch (RuntimeException error) { + return false; + } + } + + private static String string(com.google.gson.JsonObject object, String key) { + if (object == null || !object.has(key) || object.get(key).isJsonNull()) return null; + try { + return object.get(key).getAsString(); + } catch (RuntimeException error) { + return null; + } + } + + private static String nullableString(com.google.gson.JsonObject object, String key) { + return string(object, key); + } + + private static com.google.gson.JsonObject object(com.google.gson.JsonObject parent, String key) { + if (parent == null || !parent.has(key) || !parent.get(key).isJsonObject()) return null; + return parent.getAsJsonObject(key); + } + + public record ApplyResult(boolean success, ThemeProfile profile, String error) { + + public static ApplyResult success(ThemeProfile profile) { + return new ApplyResult(true, profile, ""); + } + + public static ApplyResult failure(String error) { + return new ApplyResult(false, null, error == null ? "unknown error" : error); + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileValidator.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileValidator.java new file mode 100644 index 0000000000..df3e1705b5 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeProfileValidator.java @@ -0,0 +1,149 @@ +package com.fongmi.android.tv.theme; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** Validates and normalizes a B-safe theme profile before it is persisted or resolved. */ +public final class ThemeProfileValidator { + + public static final int MAX_JSON_BYTES = 128 * 1024; + public static final int MAX_NESTING_DEPTH = 8; + public static final float MIN_SCRIM_OPACITY = 0f; + public static final float MAX_SCRIM_OPACITY = 0.85f; + public static final float MIN_DIALOG_OPACITY = 0.70f; + public static final float MAX_DIALOG_OPACITY = 1f; + public static final float MIN_OVERLAY_OPACITY = 0.05f; + public static final float MAX_OVERLAY_OPACITY = 0.60f; + public static final float MIN_PLAYER_SCRIM_OPACITY = 0.20f; + public static final float MAX_PLAYER_SCRIM_OPACITY = 1f; + + private static final Set DANGEROUS_KEYS = Set.of( + "script", "javascript", "resource", "resourcepath", "intent", "classname", "class", "css", + "url", "uri", "path", "file", "assets", "src", "srcset", "href", "onload", "onerror"); + + private ThemeProfileValidator() { + } + + public static Result validate(ThemeProfile profile) { + List errors = new ArrayList<>(); + if (profile == null) return Result.invalid("theme profile is missing"); + if (profile.schemaVersion != ThemeProfile.SCHEMA_VERSION) { + errors.add("schemaVersion must be " + ThemeProfile.SCHEMA_VERSION); + } + if (!ThemeProfile.FORMAT.equals(profile.format)) errors.add("format must be " + ThemeProfile.FORMAT); + profile.id = text(profile.id, "webhtv.local"); + profile.name = text(profile.name, "Default"); + if (profile.id.length() > 64) errors.add("id is too long"); + if (profile.name.length() > 64) errors.add("name is too long"); + profile.mode = ThemeProfile.normalizeMode(profile.mode); + profile.paletteStyle = ThemeProfile.normalizePaletteStyle(profile.paletteStyle); + profile.seedSource = ThemeProfile.normalizeSeedSource(profile.seedSource); + profile.seedColor = color(profile.seedColor, errors, "seedColor"); + if (ThemeProfile.SEED_CUSTOM.equals(profile.seedSource) && profile.seedColor == null) { + errors.add("seedColor is required for a custom seed"); + } + if (ThemeProfile.SEED_NONE.equals(profile.seedSource)) profile.seedColor = null; + if (profile.light == null) profile.light = new ThemeProfile.SlotSet(); + if (profile.dark == null) profile.dark = new ThemeProfile.SlotSet(); + validateSlots(profile.light, errors, "light"); + validateSlots(profile.dark, errors, "dark"); + return new Result(errors.isEmpty() ? profile : null, errors); + } + + private static void validateSlots(ThemeProfile.SlotSet slots, List errors, String path) { + slots.primary = color(slots.primary, errors, path + ".primary"); + slots.primaryContainer = color(slots.primaryContainer, errors, path + ".primaryContainer"); + slots.secondaryContainer = color(slots.secondaryContainer, errors, path + ".secondaryContainer"); + slots.focus = color(slots.focus, errors, path + ".focus"); + slots.playerCurrent = color(slots.playerCurrent, errors, path + ".playerCurrent"); + slots.surface = color(slots.surface, errors, path + ".surface"); + slots.surfaceContainer = color(slots.surfaceContainer, errors, path + ".surfaceContainer"); + slots.surfaceContainerHigh = color(slots.surfaceContainerHigh, errors, path + ".surfaceContainerHigh"); + slots.onSurface = color(slots.onSurface, errors, path + ".onSurface"); + slots.onSurfaceVariant = color(slots.onSurfaceVariant, errors, path + ".onSurfaceVariant"); + slots.outline = color(slots.outline, errors, path + ".outline"); + slots.error = color(slots.error, errors, path + ".error"); + slots.success = color(slots.success, errors, path + ".success"); + slots.warning = color(slots.warning, errors, path + ".warning"); + slots.scrimOpacity = opacity(slots.scrimOpacity, MIN_SCRIM_OPACITY, MAX_SCRIM_OPACITY, errors, path + ".scrimOpacity"); + slots.dialogOpacity = opacity(slots.dialogOpacity, MIN_DIALOG_OPACITY, MAX_DIALOG_OPACITY, errors, path + ".dialogOpacity"); + slots.overlayOpacity = opacity(slots.overlayOpacity, MIN_OVERLAY_OPACITY, MAX_OVERLAY_OPACITY, errors, path + ".overlayOpacity"); + slots.playerScrimOpacity = opacity(slots.playerScrimOpacity, MIN_PLAYER_SCRIM_OPACITY, MAX_PLAYER_SCRIM_OPACITY, errors, path + ".playerScrimOpacity"); + } + + private static String color(String value, List errors, String path) { + if (value == null || value.isBlank()) return null; + String normalized = normalizeColor(value); + if (normalized == null) errors.add(path + " must be an opaque #RRGGBB color"); + return normalized; + } + + /** Only opaque #RRGGBB colors are accepted; transparent values must use an opacity slot. */ + static String normalizeColor(String value) { + if (value == null) return null; + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (normalized.length() == 4 && normalized.charAt(0) == '#') { + normalized = "#" + normalized.charAt(1) + normalized.charAt(1) + + normalized.charAt(2) + normalized.charAt(2) + + normalized.charAt(3) + normalized.charAt(3); + } + if (normalized.length() != 7 || normalized.charAt(0) != '#') return null; + for (int index = 1; index < normalized.length(); index++) { + char current = normalized.charAt(index); + boolean hex = (current >= '0' && current <= '9') || (current >= 'A' && current <= 'F'); + if (!hex) return null; + } + return normalized; + } + + static int parseColor(String value, int fallback) { + String normalized = normalizeColor(value); + if (normalized == null) return fallback; + return (int) (0xFF000000L | Long.parseLong(normalized.substring(1), 16)); + } + + static String formatColor(int value) { + return String.format(Locale.ROOT, "#%06X", value & 0xFFFFFF); + } + + private static Float opacity(Float value, float minimum, float maximum, List errors, String path) { + if (value == null) return null; + if (!Float.isFinite(value) || value < minimum || value > maximum) { + errors.add(path + " must be between " + minimum + " and " + maximum); + return value; + } + return value; + } + + private static String text(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } + + public static boolean isSafeJsonKey(String key) { + if (key == null) return false; + String normalized = key.replace("_", "").replace("-", "").toLowerCase(Locale.ROOT); + return !DANGEROUS_KEYS.contains(normalized); + } + + public record Result(ThemeProfile profile, List errors) { + + public Result { + errors = errors == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(errors)); + } + + public boolean valid() { + return errors.isEmpty(); + } + + public String message() { + return valid() ? "" : String.join("; ", errors); + } + + public static Result invalid(String error) { + return new Result(null, List.of(error)); + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeResolver.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeResolver.java new file mode 100644 index 0000000000..e22a639152 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeResolver.java @@ -0,0 +1,373 @@ +package com.fongmi.android.tv.theme; + +import com.google.android.material.color.utilities.DynamicScheme; +import com.google.android.material.color.utilities.Hct; + +/** Resolves semantic tokens without exposing seed colors directly to UI surfaces. */ +public final class ThemeResolver { + + private static final ThreadLocal LAST_DIAGNOSTIC = ThreadLocal.withInitial(() -> "default"); + + /** Material's own baseline scheme keeps primary at or above this on every surface role. */ + private static final double MIN_ACCENT_CONTRAST = 4.5; + + /** + * 播放页(视频层)环色必须可读的背景。 + * + *

视频层环色的宿主是**固定深色玻璃**:控制面板 + * ({@code shape_dialog_control_glass_panel})、快捷搜索/选集面板 + * ({@code shape_quick_search_dialog})、播放器底部抽屉,三档渐变都是 + * {@code #E62F315E → #D6282955 → #CC303463},叠在任何画面上都保持深色。 + * 浅色表的 FOCUS 深蓝 #0B57D0 在它们上面只有 1.83:1(dev1 实机实测), + * 所以浅色表必须夹取到 ≥3:1(派生结果 #447BF5)——这正是本方法存在的理由。 + * + *

纯白视频(雪/白墙)刻意不在列表里。用户报告(2026-10-11) + * 「播放页存在多个不同颜色的高亮效果」:把纯白视频也作为约束,会把深色表的 + * 近白环压成灰蓝 #7695C5,于是同一页出现「应用表面近白 + 视频层灰蓝」两种高亮。 + * 深色表两类宿主的实际底色都是深色,近白环对它们全部可读,所以深色表统一取近白 + * (与应用表面焦点环同值),只有浅色表保留夹取。 + * 已知取舍:近白环在纯白视频上对比度不足,仅在「亮场景视频 + 焦点落在画面框/播控条」 + * 时出现,而绝大多数播放页宿主的局部底色是深玻璃。 + */ + private static final int[] PLAYER_RING_BACKDROPS = { + 0xFF2F315E, 0xFF282955, 0xFF303463, // 控制面板固定玻璃渐变的三档 + }; + + /** + * 播放页环色的对比度门槛。取 WCAG 2.2 SC 1.4.11「非文本对比度」的 3:1(与 + * {@code outline}/{@code focus} 的 {@code ensureContrast(..., 3.0)} 同一口径); + * 4.5:1 是正文文本的档位,用在环上会把浅色表的蓝压得过暗。 + */ + private static final double MIN_PLAYER_RING_CONTRAST = 3.0; + + private ThemeResolver() { + } + + public static ThemeTokens resolve(ThemeMode mode, ThemeSeed seed, int seedColor, + int wallpaperColor, boolean systemDark) { + return resolve(mode, seed, seedColor, wallpaperColor, null, systemDark); + } + + /** + * Resolves the frozen palette, an optional seed, and the B-safe profile in one + * deterministic order. A profile may only narrow the 16 user slots; it cannot + * reach player colors or the derived on* pairs. + */ + public static ThemeTokens resolve(ThemeMode mode, ThemeSeed seed, int seedColor, + int wallpaperColor, ThemeProfile profile, boolean systemDark) { + ThemeProfile lastGood = null; + try { + lastGood = ThemeProfileStore.loadLastGood(); + } catch (RuntimeException ignored) { + // Android preference access is unavailable (for example in a JVM test); defaults remain valid. + } + return resolve(mode, seed, seedColor, wallpaperColor, profile, lastGood, systemDark); + } + + /** Testable overload with an explicit last-good fallback. */ + public static ThemeTokens resolve(ThemeMode mode, ThemeSeed seed, int seedColor, int wallpaperColor, + ThemeProfile profile, ThemeProfile lastGood, boolean systemDark) { + boolean dark = mode == ThemeMode.DARK || (mode != ThemeMode.LIGHT && systemDark); + ThemeTokens fallback = dark ? ThemeTokens.dark() : ThemeTokens.light(); + ThemeTokens base = fallback; + String path = "default"; + ThemePaletteStyle paletteStyle = profile == null + ? ThemePaletteStyle.TONAL_SPOT + : ThemePaletteStyle.from(profile.paletteStyle); + try { + if (seed != null && seed != ThemeSeed.NONE) { + int source = seed == ThemeSeed.WALLPAPER ? wallpaperColor : seedColor; + if (!isOpaque(source)) { + LAST_DIAGNOSTIC.set("fallback:invalid-seed:" + seed.name().toLowerCase(java.util.Locale.US)); + return fallback; + } + base = derive(source, dark, fallback, paletteStyle); + path = "seed:" + seed.name().toLowerCase(java.util.Locale.US); + } else if (profile != null && paletteStyle != ThemePaletteStyle.TONAL_SPOT) { + base = derive(paletteStyle.defaultSeed(), dark, fallback, paletteStyle); + path = "palette:" + paletteStyle.id(); + } + ThemeTokens candidate = profile == null ? base : applyProfile(base, profile, dark); + candidate.requireContrast(); + LAST_DIAGNOSTIC.set(profile == null ? path : path + "+profile:" + profile.displayName()); + return candidate; + } catch (RuntimeException error) { + String cause = error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage(); + if (lastGood != null && lastGood != profile) { + try { + ThemeTokens recovered = applyProfile(base, lastGood, dark); + recovered.requireContrast(); + LAST_DIAGNOSTIC.set("last-good:" + cause); + return recovered; + } catch (RuntimeException ignored) { + // Fall through to the frozen default palette. + } + } + LAST_DIAGNOSTIC.set("fallback:profile-resolve:" + cause); + return fallback; + } + } + + /** + * Applies only the slots the user actually overrode. Empty/null slots keep the + * resolved base value, and on* pairs are recomputed from black/white contrast so + * the profile cannot create unreadable foreground/background pairs. + */ + static ThemeTokens applyProfile(ThemeTokens base, ThemeProfile profile, boolean dark) { + ThemeProfileValidator.Result validated = ThemeProfileValidator.validate(profile); + if (!validated.valid()) throw new IllegalArgumentException(validated.message()); + ThemeProfile.SlotSet slots = dark ? validated.profile().dark : validated.profile().light; + + boolean hasPrimary = slots.primary != null; + boolean hasPrimaryContainer = slots.primaryContainer != null; + boolean hasSecondaryContainer = slots.secondaryContainer != null; + boolean hasFocus = slots.focus != null; + boolean hasSurface = slots.surface != null; + boolean hasContainer = slots.surfaceContainer != null; + boolean hasContainerHigh = slots.surfaceContainerHigh != null; + boolean hasOnSurface = slots.onSurface != null; + boolean hasOnSurfaceVariant = slots.onSurfaceVariant != null; + boolean hasOutline = slots.outline != null; + boolean hasError = slots.error != null; + boolean hasSuccess = slots.success != null; + boolean hasWarning = slots.warning != null; + + int primary = color(slots.primary, base.colorPrimary()); + int primaryContainer = color(slots.primaryContainer, base.colorPrimaryContainer()); + int secondaryContainer = color(slots.secondaryContainer, base.colorSecondaryContainer()); + int surface = color(slots.surface, base.colorSurface()); + int surfaceContainer = color(slots.surfaceContainer, base.colorSurfaceContainer()); + int surfaceContainerHigh = color(slots.surfaceContainerHigh, base.colorSurfaceContainerHigh()); + if (hasContainer && surfaceContainer == surface) { + surfaceContainer = mix(surface, dark ? 0xFFFFFFFF : 0xFF000000, 0.06); + } + if (hasContainerHigh && surfaceContainerHigh == surfaceContainer) { + surfaceContainerHigh = mix(surfaceContainer, dark ? 0xFFFFFFFF : 0xFF000000, 0.06); + } + int surfaceContainerHighest = hasContainer || hasContainerHigh + ? mix(surfaceContainerHigh, surfaceContainer, 0.35) + : base.colorSurfaceContainerHighest(); + // Material 3 derives primary and the surface roles from one tonal palette, so + // primary is legible on every surface role by construction (the shipped baseline + // scheme measures 4.97-10.91:1). The editor exposes both slots independently, + // which can break that guarantee - primary is also the dialog action-button text + // colour, so a broken pair renders actions invisible. Restoring the guarantee is + // what the documented B-safe contract promises: a profile must never be able to + // create an unreadable pair. The move keeps hue and chroma and only shifts tone. + int readablePrimary = readableAccent( + primary, surface, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest); + boolean primaryAdjusted = readablePrimary != primary; + primary = readablePrimary; + int error = color(slots.error, base.colorError()); + int success = color(slots.success, base.colorSuccess()); + int warning = color(slots.warning, base.colorWarning()); + + // Derived pairs are only recomputed when the user actually touched a slot that + // feeds them; an empty profile must stay byte-identical to the frozen palette. + int onPrimary = (hasPrimary || primaryAdjusted) + ? readableOn(primary, base.colorOnPrimary()) : base.colorOnPrimary(); + int onPrimaryContainer = hasPrimaryContainer + ? readableOn(primaryContainer, base.colorOnPrimaryContainer()) : base.colorOnPrimaryContainer(); + int onSecondaryContainer = hasSecondaryContainer + ? readableOn(secondaryContainer, base.colorOnSecondaryContainer()) : base.colorOnSecondaryContainer(); + int onError = hasError ? readableOn(error, base.colorOnError()) : base.colorOnError(); + int onSuccess = hasSuccess ? readableOn(success, base.colorOnSuccess()) : base.colorOnSuccess(); + int onWarning = hasWarning ? readableOn(warning, base.colorOnWarning()) : base.colorOnWarning(); + + boolean surfaceChanged = hasSurface || hasContainer || hasContainerHigh; + int onSurface = surfaceChanged || hasOnSurface + ? enforceBase(color(slots.onSurface, base.colorOnSurface()), surface, + hasContainer ? surfaceContainer : -1, + hasContainerHigh ? surfaceContainerHigh : -1) + : base.colorOnSurface(); + int onSurfaceVariant = surfaceChanged || hasOnSurfaceVariant + ? enforceBase(color(slots.onSurfaceVariant, base.colorOnSurfaceVariant()), surface, + hasContainer ? surfaceContainer : -1, -1) + : base.colorOnSurfaceVariant(); + int outline = hasOutline || hasSurface + ? ensureContrast(color(slots.outline, base.colorOutline()), surface, 3.0) + : base.colorOutline(); + // The frozen palette deliberately ships focus == primary. Keeping that relationship + // matters beyond aesthetics: the binder resolves a view colour by its semantic role, + // and a baseline colour shared by two roles is only rewritten when both agree. If + // focus drifted away from primary, primary-as-text views (dialog actions) would keep + // their compiled colour and stay unreadable. `primary` already clears 4.5:1 on the + // surface, so it also clears the 3.0:1 focus requirement. + int focus = hasFocus || hasSurface + ? ensureContrast(color(slots.focus, primary), surface, 3.0) + : base.colorFocus(); + + int surfaceDim = hasSurface ? mix(surface, 0xFF000000, dark ? 0.06 : 0.10) : base.colorSurfaceDim(); + int surfaceBright = hasSurface ? mix(surface, 0xFFFFFFFF, dark ? 0.14 : 0.08) : base.colorSurfaceBright(); + + // ---------------------------------------------------------------- 播放页(视频层)环色 + // 视频层环色的宿主是固定深色玻璃与深色视频画面,与应用表面无关,所以不能直接用 + // focus:浅色表的 focus #0B57D0 在固定深靛玻璃上只有 1.83:1(实机 dev1 复核), + // 焦点不可见。因此用仓库已有的 readableAccent:保持用户 FOCUS 槽的色相与彩度、 + // 只走明度,直到在固定玻璃上达到 MIN_PLAYER_RING_CONTRAST(3.0)。 + // 深色表的 focus 本身就是近白 #F5F7FF,对深玻璃与深色 video 都远远达标,夹取是 + // 恒等变换——这正是用户要的「播放页只有一种高亮色」:深色表下视频层环 = 应用表面环。 + int playerFocusRing = hasFocus + ? readableAccent(focus, MIN_PLAYER_RING_CONTRAST, PLAYER_RING_BACKDROPS) + : base.colorPlayerFocusRing(); + // 「当前播放」有独立的用户槽位(默认绿);同样做固定玻璃上的可读性夹取。 + boolean hasPlayerCurrent = slots.playerCurrent != null; + int playerCurrentRing = hasPlayerCurrent + ? readableAccent(color(slots.playerCurrent, base.colorPlayerCurrentRing()), + MIN_PLAYER_RING_CONTRAST, PLAYER_RING_BACKDROPS) + : base.colorPlayerCurrentRing(); + + float scrimOpacity = slots.scrimOpacity == null ? (base.colorScrim() >>> 24) / 255f : slots.scrimOpacity; + float dialogOpacity = slots.dialogOpacity == null ? base.dialogOpacity() : slots.dialogOpacity; + float overlayOpacity = slots.overlayOpacity == null ? (base.colorOverlayLight() >>> 24) / 255f : slots.overlayOpacity; + + return new ThemeTokens( + primary, onPrimary, primaryContainer, onPrimaryContainer, + base.colorSecondary(), base.colorOnSecondary(), + secondaryContainer, onSecondaryContainer, + base.colorTertiary(), base.colorOnTertiary(), + error, onError, base.colorErrorContainer(), base.colorOnErrorContainer(), + success, onSuccess, base.colorSuccessContainer(), base.colorOnSuccessContainer(), + warning, onWarning, base.colorWarningContainer(), base.colorOnWarningContainer(), + surface, surfaceDim, surfaceBright, base.colorSurfaceContainerLowest(), + base.colorSurfaceContainerLow(), surfaceContainer, surfaceContainerHigh, surfaceContainerHighest, + onSurface, onSurfaceVariant, outline, base.colorOutlineVariant(), + base.colorInverseSurface(), base.colorInverseOnSurface(), base.colorInversePrimary(), + withAlpha(base.colorScrim(), scrimOpacity), base.colorShadow(), focus, base.focusScale(), + playerFocusRing, playerCurrentRing, dialogOpacity, + base.colorPlayerControl(), base.colorPlayerControlMuted(), base.colorPlayerControlActive(), base.colorPlayerScrim(), + base.colorHealthGood(), base.colorHealthWarn(), base.colorHealthBad(), + withAlpha(base.colorOverlayLight(), overlayOpacity), base.colorOverlayDark() + ); + } + + private static int enforceBase(int foreground, int surface, int container, int containerHigh) { + int result = ensureContrast(foreground, surface, 4.5); + if (container >= 0) result = ensureContrast(result, container, 4.5); + if (containerHigh >= 0) result = ensureContrast(result, containerHigh, 4.5); + return result; + } + + private static int color(String value, int fallback) { + return value == null ? fallback : ThemeProfileValidator.parseColor(value, fallback); + } + + private static int readableOn(int background, int preferred) { + double black = ThemeContrast.ratio(0xFF000000, background); + double white = ThemeContrast.ratio(0xFFFFFFFF, background); + boolean useBlack = black >= white; + if ((preferred == 0xFF000000) == useBlack && ThemeContrast.ratio(preferred, background) >= 4.5) return preferred; + return useBlack ? 0xFF000000 : 0xFFFFFFFF; + } + + /** + * Keeps {@code color} when it clears {@link #MIN_ACCENT_CONTRAST} on every backdrop, + * otherwise returns the nearest tone on the same hue/chroma that does. + * + *

Walking tone rather than snapping to black/white preserves the user's brand + * hue - the same thing Material's tonal palette does when it assigns primary. + */ + private static int readableAccent(int color, int... backdrops) { + return readableAccent(color, MIN_ACCENT_CONTRAST, backdrops); + } + + /** + * Same walk, with an explicit threshold. The player ring uses + * {@link #MIN_PLAYER_RING_CONTRAST} (3:1, the WCAG non-text state bar) because + * 4.5:1 would drag the light-table blue far too dark for a ring that sits on + * fixed dark glass. + */ + private static int readableAccent(int color, double minimum, int... backdrops) { + if (clearsContrast(color, minimum, backdrops)) return color; + Hct source = Hct.fromInt(color); + double hue = source.getHue(); + double chroma = source.getChroma(); + double original = source.getTone(); + int best = color; + double bestDistance = Double.MAX_VALUE; + for (int tone = 0; tone <= 100; tone++) { + int candidate = Hct.from(hue, chroma, tone).toInt(); + if (!clearsContrast(candidate, minimum, backdrops)) continue; + double distance = Math.abs(tone - original); + if (distance < bestDistance) { + bestDistance = distance; + best = candidate; + } + } + if (bestDistance != Double.MAX_VALUE) return best; + // Unreachable for real surfaces (tone 0 / 100 always bracket), but never return + // an unreadable colour if a future call site passes something degenerate. + int fallback = ensureContrast(color, backdrops[0], minimum); + for (int backdrop : backdrops) fallback = ensureContrast(fallback, backdrop, minimum); + return fallback; + } + + private static boolean clearsContrast(int color, int... backdrops) { + return clearsContrast(color, MIN_ACCENT_CONTRAST, backdrops); + } + + private static boolean clearsContrast(int color, double minimum, int... backdrops) { + for (int backdrop : backdrops) { + if (ThemeContrast.ratio(color, backdrop) + 0.0001 < minimum) return false; + } + return true; + } + + private static int ensureContrast(int foreground, int background, double minimum) { + if (ThemeContrast.ratio(foreground, background) + 0.0001 >= minimum) return foreground; + double black = ThemeContrast.ratio(0xFF000000, background); + double white = ThemeContrast.ratio(0xFFFFFFFF, background); + return black >= white ? 0xFF000000 : 0xFFFFFFFF; + } + + private static int mix(int first, int second, double amount) { + double inverse = 1.0 - amount; + int red = (int) Math.round(((first >>> 16) & 0xFF) * inverse + ((second >>> 16) & 0xFF) * amount); + int green = (int) Math.round(((first >>> 8) & 0xFF) * inverse + ((second >>> 8) & 0xFF) * amount); + int blue = (int) Math.round((first & 0xFF) * inverse + (second & 0xFF) * amount); + return 0xFF000000 | (red << 16) | (green << 8) | blue; + } + + private static int withAlpha(int color, float opacity) { + int alpha = Math.max(0, Math.min(255, Math.round(opacity * 255f))); + return (color & 0x00FFFFFF) | (alpha << 24); + } + + public static String lastDiagnostic() { + return LAST_DIAGNOSTIC.get(); + } + + static ThemeTokens requireOrFallback(ThemeTokens candidate, ThemeTokens fallback) { + try { + return candidate.requireContrast(); + } catch (IllegalArgumentException error) { + LAST_DIAGNOSTIC.set("fallback:seed-contrast:" + error.getMessage()); + return fallback; + } + } + + private static ThemeTokens derive(int seedColor, boolean dark, ThemeTokens fallback, + ThemePaletteStyle paletteStyle) { + DynamicScheme scheme = paletteStyle.create(seedColor, dark); + return new ThemeTokens( + scheme.getPrimary(), scheme.getOnPrimary(), scheme.getPrimaryContainer(), scheme.getOnPrimaryContainer(), + scheme.getSecondary(), scheme.getOnSecondary(), scheme.getSecondaryContainer(), scheme.getOnSecondaryContainer(), + scheme.getTertiary(), scheme.getOnTertiary(), scheme.getError(), scheme.getOnError(), + scheme.getErrorContainer(), scheme.getOnErrorContainer(), + fallback.colorSuccess(), fallback.colorOnSuccess(), fallback.colorSuccessContainer(), fallback.colorOnSuccessContainer(), + fallback.colorWarning(), fallback.colorOnWarning(), fallback.colorWarningContainer(), fallback.colorOnWarningContainer(), + scheme.getSurface(), scheme.getSurfaceDim(), scheme.getSurfaceBright(), scheme.getSurfaceContainerLowest(), + scheme.getSurfaceContainerLow(), scheme.getSurfaceContainer(), scheme.getSurfaceContainerHigh(), scheme.getSurfaceContainerHighest(), + scheme.getOnSurface(), scheme.getOnSurfaceVariant(), scheme.getOutline(), scheme.getOutlineVariant(), + scheme.getInverseSurface(), scheme.getInverseOnSurface(), scheme.getInversePrimary(), scheme.getScrim(), + scheme.getShadow(), scheme.getPrimary(), fallback.focusScale(), + fallback.colorPlayerFocusRing(), fallback.colorPlayerCurrentRing(), fallback.dialogOpacity(), fallback.colorPlayerControl(), + fallback.colorPlayerControlMuted(), fallback.colorPlayerControlActive(), fallback.colorPlayerScrim(), + fallback.colorHealthGood(), fallback.colorHealthWarn(), fallback.colorHealthBad(), fallback.colorOverlayLight(), fallback.colorOverlayDark() + ); + } + + private static boolean isOpaque(int color) { + return ((color >>> 24) & 0xFF) == 0xFF; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeRole.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeRole.java new file mode 100644 index 0000000000..9e0c586375 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeRole.java @@ -0,0 +1,78 @@ +package com.fongmi.android.tv.theme; + +import java.util.Locale; + +/** + * The bounded set of semantic roles the controlled binder may rewrite. + * + *

Only the 13 user-editable color slots and the six foreground roles the + * resolver derives from them are listed. Player, media, logo, rating, health and + * brand colors are intentionally absent so a profile can never reach them. + */ +public enum ThemeRole { + PRIMARY, + PRIMARY_CONTAINER, + SECONDARY_CONTAINER, + FOCUS, + SURFACE, + SURFACE_CONTAINER, + SURFACE_CONTAINER_HIGH, + ON_SURFACE, + ON_SURFACE_VARIANT, + OUTLINE, + ERROR, + SUCCESS, + WARNING, + ON_PRIMARY, + ON_PRIMARY_CONTAINER, + ON_SECONDARY_CONTAINER, + ON_ERROR, + ON_SUCCESS, + ON_WARNING; + + public static final String TAG_PREFIX = "webhtv:"; + + /** Resolves an explicit {@code android:tag="webhtv:primary"} marker. */ + public static ThemeRole fromTag(Object tag) { + if (!(tag instanceof String value)) return null; + String normalized = value.trim().toLowerCase(Locale.ROOT); + if (!normalized.startsWith(TAG_PREFIX)) return null; + String name = normalized.substring(TAG_PREFIX.length()).trim(); + if (name.isEmpty()) return null; + for (ThemeRole role : values()) { + if (role.name().toLowerCase(Locale.ROOT).equals(name)) return role; + } + return null; + } + + /** The active value of this role, or 0 when the role is not bound. */ + public int colorOf(ThemeTokens tokens) { + if (tokens == null) return 0; + return switch (this) { + case PRIMARY -> tokens.colorPrimary(); + case PRIMARY_CONTAINER -> tokens.colorPrimaryContainer(); + case SECONDARY_CONTAINER -> tokens.colorSecondaryContainer(); + case FOCUS -> tokens.colorFocus(); + case SURFACE -> tokens.colorSurface(); + case SURFACE_CONTAINER -> tokens.colorSurfaceContainer(); + case SURFACE_CONTAINER_HIGH -> tokens.colorSurfaceContainerHigh(); + case ON_SURFACE -> tokens.colorOnSurface(); + case ON_SURFACE_VARIANT -> tokens.colorOnSurfaceVariant(); + case OUTLINE -> tokens.colorOutline(); + case ERROR -> tokens.colorError(); + case SUCCESS -> tokens.colorSuccess(); + case WARNING -> tokens.colorWarning(); + case ON_PRIMARY -> tokens.colorOnPrimary(); + case ON_PRIMARY_CONTAINER -> tokens.colorOnPrimaryContainer(); + case ON_SECONDARY_CONTAINER -> tokens.colorOnSecondaryContainer(); + case ON_ERROR -> tokens.colorOnError(); + case ON_SUCCESS -> tokens.colorOnSuccess(); + case ON_WARNING -> tokens.colorOnWarning(); + }; + } + + /** Roles whose value a user profile may directly override. */ + public boolean isUserSlot() { + return ordinal() <= WARNING.ordinal(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeSeed.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeSeed.java new file mode 100644 index 0000000000..ce2a24157d --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeSeed.java @@ -0,0 +1,8 @@ +package com.fongmi.android.tv.theme; + +public enum ThemeSeed { + NONE, + WALLPAPER, + EXPLICIT, + CONTENT +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeTokens.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeTokens.java new file mode 100644 index 0000000000..e3ff805fec --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeTokens.java @@ -0,0 +1,193 @@ +package com.fongmi.android.tv.theme; + +/** Immutable Material 3 semantic color and player-control contract. */ +public record ThemeTokens( + int colorPrimary, + int colorOnPrimary, + int colorPrimaryContainer, + int colorOnPrimaryContainer, + int colorSecondary, + int colorOnSecondary, + int colorSecondaryContainer, + int colorOnSecondaryContainer, + int colorTertiary, + int colorOnTertiary, + int colorError, + int colorOnError, + int colorErrorContainer, + int colorOnErrorContainer, + int colorSuccess, + int colorOnSuccess, + int colorSuccessContainer, + int colorOnSuccessContainer, + int colorWarning, + int colorOnWarning, + int colorWarningContainer, + int colorOnWarningContainer, + int colorSurface, + int colorSurfaceDim, + int colorSurfaceBright, + int colorSurfaceContainerLowest, + int colorSurfaceContainerLow, + int colorSurfaceContainer, + int colorSurfaceContainerHigh, + int colorSurfaceContainerHighest, + int colorOnSurface, + int colorOnSurfaceVariant, + int colorOutline, + int colorOutlineVariant, + int colorInverseSurface, + int colorInverseOnSurface, + int colorInversePrimary, + int colorScrim, + int colorShadow, + int colorFocus, + float focusScale, + /** + * 播放页(视频层)焦点环色。由 FOCUS 槽派生并在固定玻璃面板上做了 ≥4.5:1 的可读性夹取, + * 因为播放页控制条是叠在视频上的固定深靛玻璃:浅色表的 FOCUS 深蓝在它上面只有 1.83:1。 + */ + int colorPlayerFocusRing, + /** 播放页「当前播放/当前生效」环色。有独立的用户可编辑槽位(profile 的 playerCurrent)。 */ + int colorPlayerCurrentRing, + float dialogOpacity, + int colorPlayerControl, + int colorPlayerControlMuted, + int colorPlayerControlActive, + int colorPlayerScrim, + int colorHealthGood, + int colorHealthWarn, + int colorHealthBad, + int colorOverlayLight, + int colorOverlayDark +) { + + public static ThemeTokens light() { + return new ThemeTokens( + 0xFF0B57D0, + 0xFFFFFFFF, + 0xFFD3E3FD, + 0xFF041E49, + 0xFF5B5F63, + 0xFFFFFFFF, + 0xFFDFE2E6, + 0xFF191C1E, + 0xFF00639B, + 0xFFFFFFFF, + 0xFFB3261E, + 0xFFFFFFFF, + 0xFFF9DEDC, + 0xFF410E0B, + 0xFF146C2E, + 0xFFFFFFFF, + 0xFFC4EED0, + 0xFF00210B, + 0xFF7A5900, + 0xFFFFFFFF, + 0xFFFFDF9A, + 0xFF261A00, + 0xFFF8FAFD, + 0xFFD8DAE0, + 0xFFF8FAFD, + 0xFFFFFFFF, + 0xFFF2F3F9, + 0xFFECEEF4, + 0xFFE7E8EF, + 0xFFE1E2E9, + 0xFF1A1C1E, + 0xFF44474F, + 0xFF74777F, + 0xFFC4C6CF, + 0xFF2F3033, + 0xFFF1F0F4, + 0xFFA8C7FA, + 0x52000000, + 0xFF000000, + 0xFF0B57D0, + // 焦点缩放倍率与明暗模式无关:它是**动效**属性,不是配色属性。此前浅色表为 + // 1.0、深色表为 1.1,而各适配器一律写死 1.1,于是浅色主题的电视一旦改走 token + // 就会丢掉缩放反馈。这里统一为 1.1(= 既有实机行为,也是 Android TV 按钮 + // 指南与设计文档 §5.2 记录的取值)。 + 1.1f, + // 浅色表视频层环:FOCUS 深蓝在固定深靛玻璃上仅 1.83:1, + // 取 ThemeResolver 夹取到 ≥3:1 的派生值(与 values/webhtv_tokens.xml 同步)。 + 0xFF447BF5, + 0xFF00A95A, + 1.0f, + 0xFFFFFFFF, + 0xCCFFFFFF, + 0xFFFFD35C, + 0x7A000000, + 0xFF146C2E, + 0xFF7A5900, + 0xFFB3261E, + 0x14FFFFFF, + 0x52000000 + ); + } + + public static ThemeTokens dark() { + return new ThemeTokens( + 0xFFA8C7FA, + 0xFF062E6F, + 0xFF0842A0, + 0xFFD3E3FD, + 0xFFC6C6C9, + 0xFF2F3133, + 0xFF44474A, + 0xFFE2E2E6, + 0xFF8ECDFF, + 0xFF003353, + 0xFFFFB4AB, + 0xFF690005, + 0xFF93000A, + 0xFFFFDAD6, + 0xFF8EDB9F, + 0xFF003918, + 0xFF00522A, + 0xFFC4EED0, + 0xFFF2C66D, + 0xFF402D00, + 0xFF5C4200, + 0xFFFFDF9A, + 0xFF101418, + 0xFF0B0E11, + 0xFF363A3F, + 0xFF0B0E11, + 0xFF181C20, + 0xFF1F2428, + 0xFF2A2F34, + 0xFF34393E, + 0xFFE2E2E9, + 0xFFC4C6D0, + 0xFF8E9099, + 0xFF44474F, + 0xFFE2E2E9, + 0xFF2F3033, + 0xFF0B57D0, + 0x8A000000, + 0xFF000000, + 0xFFF5F7FF, + 1.1f, + // 深色表视频层环 = 应用表面焦点环(近白):深玻璃/深色画面都可读, + // 因此播放页只有一种高亮色。浅色表仍为固定玻璃上的派生夹取值。 + 0xFFF5F7FF, + 0xFF00A95A, + 1.0f, + 0xFFFFFFFF, + 0xCCFFFFFF, + 0xFFFFD35C, + 0x8F000000, + 0xFF8EDB9F, + 0xFFF2C66D, + 0xFFFFB4AB, + 0x14FFFFFF, + 0x66000000 + ); + } + + public ThemeTokens requireContrast() { + ThemeContrast.require(this); + return this; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeTransfer.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeTransfer.java new file mode 100644 index 0000000000..4bc4a9ed03 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeTransfer.java @@ -0,0 +1,158 @@ +package com.fongmi.android.tv.theme; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.Proxy; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +import okhttp3.Authenticator; +import okhttp3.CookieJar; +import okhttp3.Dns; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** Bounded, no-cookie transport used by the theme draft importer. */ +public final class ThemeTransfer { + + public static final int MAX_BYTES = ThemeProfileValidator.MAX_JSON_BYTES; + private static final OkHttpClient CLIENT = new OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .cookieJar(CookieJar.NO_COOKIES) + .proxy(Proxy.NO_PROXY) + .authenticator(Authenticator.NONE) + .proxyAuthenticator(Authenticator.NONE) + .connectTimeout(8, TimeUnit.SECONDS) + .readTimeout(8, TimeUnit.SECONDS) + .callTimeout(12, TimeUnit.SECONDS) + .build(); + + private ThemeTransfer() { + } + + public static boolean isHttps(String value) { + try { + URI uri = new URI(value == null ? "" : value.trim()); + return "https".equalsIgnoreCase(uri.getScheme()) + && uri.getHost() != null + && uri.getUserInfo() == null + && uri.getPort() != 0 + && uri.getPort() <= 65535 + && !isBlockedHost(uri.getHost()); + } catch (URISyntaxException | RuntimeException error) { + return false; + } + } + + public static String host(String value) { + try { + URI uri = new URI(value); + return uri.getHost() == null ? "" : uri.getHost(); + } catch (URISyntaxException | RuntimeException error) { + return ""; + } + } + + public static String read(InputStream input) throws IOException { + if (input == null) throw new IOException("theme source is unavailable"); + ByteArrayOutputStream output = new ByteArrayOutputStream(Math.min(MAX_BYTES, 16 * 1024)); + byte[] buffer = new byte[8192]; + int count; + int total = 0; + while ((count = input.read(buffer)) != -1) { + if (count > MAX_BYTES - total) throw new IOException("theme JSON is too large"); + output.write(buffer, 0, count); + total += count; + } + return new String(output.toByteArray(), StandardCharsets.UTF_8); + } + + public static String fetch(String value) throws IOException { + if (!isHttps(value)) throw new IOException("theme URL must be HTTPS and public"); + String requestedHost = host(value); + List addresses = lookupPublic(requestedHost); + OkHttpClient client = CLIENT.newBuilder().dns(host -> { + if (requestedHost.equalsIgnoreCase(host)) return addresses; + return Dns.SYSTEM.lookup(host); + }).build(); + Request request = new Request.Builder().url(value).get().build(); + try (Response response = client.newCall(request).execute()) { + if (response.code() >= 300 && response.code() < 400) { + throw new IOException("theme URL redirects are not allowed"); + } + if (!response.isSuccessful()) throw new IOException("theme URL returned HTTP " + response.code()); + ResponseBody body = response.body(); + if (body == null) throw new IOException("theme URL returned an empty response"); + if (body.contentLength() > MAX_BYTES) throw new IOException("theme JSON is too large"); + return read(body.byteStream()); + } + } + + private static List lookupPublic(String hostname) throws IOException { + if (hostname == null || hostname.isBlank()) throw new IOException("theme URL host is missing"); + List addresses; + try { + addresses = Dns.SYSTEM.lookup(hostname); + } catch (RuntimeException error) { + throw new IOException("theme URL host cannot be resolved", error); + } + if (addresses.isEmpty()) throw new IOException("theme URL host cannot be resolved"); + for (InetAddress address : addresses) { + if (isBlockedAddress(address)) throw new IOException("private or special theme host is not allowed"); + } + return addresses; + } + + private static boolean isBlockedHost(String host) { + String normalized = host == null ? "" : host.toLowerCase(Locale.ROOT); + if (normalized.isBlank() || "localhost".equals(normalized) + || normalized.endsWith(".localhost") || normalized.endsWith(".local")) return true; + if (!isIpLiteral(normalized)) return false; + try { + return isBlockedAddress(InetAddress.getByName(normalized)); + } catch (java.net.UnknownHostException | RuntimeException error) { + return true; + } + } + + private static boolean isIpLiteral(String host) { + if (host.indexOf(':') >= 0) return true; + String[] parts = host.split("\\.", -1); + if (parts.length != 4) return false; + for (String part : parts) { + if (part.isEmpty() || part.length() > 3) return false; + int value = 0; + for (int index = 0; index < part.length(); index++) { + char current = part.charAt(index); + if (current < '0' || current > '9') return false; + value = value * 10 + current - '0'; + } + if (value > 255) return false; + } + return true; + } + + private static boolean isBlockedAddress(InetAddress address) { + if (address == null || address.isAnyLocalAddress() || address.isLoopbackAddress() + || address.isLinkLocalAddress() || address.isSiteLocalAddress() || address.isMulticastAddress()) return true; + byte[] bytes = address.getAddress(); + if (bytes.length == 16 && (bytes[0] & 0xfe) == 0xfc) return true; + if (bytes.length != 4) return false; + int first = bytes[0] & 0xff; + int second = bytes[1] & 0xff; + return first == 0 || (first == 100 && second >= 64 && second <= 127) + || (first == 192 && second == 0) || (first == 192 && second == 2) + || (first == 198 && (second == 18 || second == 19)) + || (first == 198 && second == 51) || (first == 203 && second == 0) + || first >= 224; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeTweakCnAdapter.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeTweakCnAdapter.java new file mode 100644 index 0000000000..11e6edc856 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeTweakCnAdapter.java @@ -0,0 +1,173 @@ +package com.fongmi.android.tv.theme; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** Offline allowlist adapter for TweakCN/shadcn theme JSON. */ +public final class ThemeTweakCnAdapter { + + private static final int MAX_WARNINGS = 32; + + private ThemeTweakCnAdapter() { + } + + public static Result parse(String json) { + if (json == null || json.isBlank()) throw new IllegalArgumentException("theme JSON is empty"); + ThemeProfileCodec.validateJsonBounds(json); + JsonElement root; + try { + root = JsonParser.parseString(json); + } catch (RuntimeException error) { + throw new IllegalArgumentException("theme JSON is invalid", error); + } + if (!root.isJsonObject()) throw new IllegalArgumentException("theme JSON must be an object"); + inspectSafeKeys(root, 0); + JsonObject object = root.getAsJsonObject(); + if (object.has("format") || object.has("schemaVersion")) { + return new Result(ThemeProfileCodec.parse(json), List.of()); + } + + ThemeProfile profile = ThemeProfile.defaultProfile(); + List warnings = new ArrayList<>(); + JsonObject cssVars = object.has("cssVars") && object.get("cssVars").isJsonObject() + ? object.getAsJsonObject("cssVars") : object; + int recognized = 0; + if (cssVars.has("light") || cssVars.has("dark")) { + if (cssVars.has("light") && cssVars.get("light").isJsonObject()) { + recognized += applyObject(cssVars.getAsJsonObject("light"), profile.light, warnings, "light"); + } + if (cssVars.has("dark") && cssVars.get("dark").isJsonObject()) { + recognized += applyObject(cssVars.getAsJsonObject("dark"), profile.dark, warnings, "dark"); + } + warnUnsupported(cssVars, warnings, "cssVars"); + copyMissing(profile.light, profile.dark); + } else { + recognized = applyObject(cssVars, profile.light, warnings, "theme"); + copyMissing(profile.light, profile.dark); + } + if (recognized == 0) throw new IllegalArgumentException("no supported TweakCN color tokens"); + + String name = object.has("name") && object.get("name").isJsonPrimitive() + ? object.get("name").getAsString() : "Imported TweakCN theme"; + profile.id = "webhtv.tweakcn.imported"; + profile.name = name == null || name.isBlank() ? "Imported TweakCN theme" + : name.trim().substring(0, Math.min(64, name.trim().length())); + String seed = profile.light.primary != null ? profile.light.primary : profile.dark.primary; + if (seed != null) { + profile.seedSource = ThemeProfile.SEED_CUSTOM; + profile.seedColor = seed; + } + ThemeProfileValidator.Result checked = ThemeProfileValidator.validate(profile); + if (!checked.valid()) throw new IllegalArgumentException(checked.message()); + return new Result(checked.profile(), warnings); + } + + private static void inspectSafeKeys(JsonElement element, int depth) { + if (depth > ThemeProfileValidator.MAX_NESTING_DEPTH) throw new IllegalArgumentException("theme JSON is too deeply nested"); + if (element == null || element.isJsonNull() || element.isJsonPrimitive()) return; + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) inspectSafeKeys(child, depth + 1); + return; + } + for (Map.Entry entry : element.getAsJsonObject().entrySet()) { + if (!ThemeProfileValidator.isSafeJsonKey(entry.getKey())) { + throw new IllegalArgumentException("unsupported executable theme field: " + entry.getKey()); + } + inspectSafeKeys(entry.getValue(), depth + 1); + } + } + + private static int applyObject(JsonObject object, ThemeProfile.SlotSet target, + List warnings, String path) { + int recognized = 0; + for (Map.Entry entry : object.entrySet()) { + String key = normalizeKey(entry.getKey()); + String value = color(entry.getValue()); + if (value == null) { + addWarning(warnings, path + "." + entry.getKey() + " ignored: unsupported field or color format"); + continue; + } + if (apply(key, value, target)) recognized++; + else addWarning(warnings, path + "." + entry.getKey() + " ignored: unsupported token"); + } + return recognized; + } + + private static void warnUnsupported(JsonObject object, List warnings, String path) { + for (Map.Entry entry : object.entrySet()) { + String key = normalizeKey(entry.getKey()); + if (!key.equals("--light") && !key.equals("--dark") && !isColorToken(key) && !"--theme".equals(key)) { + addWarning(warnings, path + "." + entry.getKey() + " ignored: unsupported field"); + } + } + } + + private static void copyMissing(ThemeProfile.SlotSet from, ThemeProfile.SlotSet to) { + if (to.primary == null) to.primary = from.primary; + if (to.primaryContainer == null) to.primaryContainer = from.primaryContainer; + if (to.surface == null) to.surface = from.surface; + if (to.surfaceContainer == null) to.surfaceContainer = from.surfaceContainer; + if (to.surfaceContainerHigh == null) to.surfaceContainerHigh = from.surfaceContainerHigh; + if (to.onSurface == null) to.onSurface = from.onSurface; + if (to.onSurfaceVariant == null) to.onSurfaceVariant = from.onSurfaceVariant; + if (to.outline == null) to.outline = from.outline; + if (to.focus == null) to.focus = from.focus; + if (to.error == null) to.error = from.error; + if (to.success == null) to.success = from.success; + if (to.warning == null) to.warning = from.warning; + } + + private static boolean apply(String key, String value, ThemeProfile.SlotSet target) { + switch (key) { + case "--primary" -> target.primary = value; + case "--primary-container", "--accent" -> target.primaryContainer = value; + case "--background", "--foreground" -> { + if ("--background".equals(key)) target.surface = value; + else target.onSurface = value; + } + case "--card", "--secondary", "--muted" -> target.surfaceContainer = value; + case "--popover", "--accent-foreground" -> target.surfaceContainerHigh = value; + case "--card-foreground", "--popover-foreground", "--secondary-foreground" -> target.onSurface = value; + case "--muted-foreground" -> target.onSurfaceVariant = value; + case "--border", "--input" -> target.outline = value; + case "--ring" -> target.focus = value; + case "--destructive" -> target.error = value; + default -> { return false; } + } + return true; + } + + private static boolean isColorToken(String key) { + return key.contains("primary") || key.contains("background") || key.contains("foreground") + || key.contains("card") || key.contains("popover") || key.contains("muted") + || key.contains("border") || key.contains("input") || key.contains("ring") + || key.contains("destructive") || key.contains("secondary") || key.contains("accent"); + } + + private static String normalizeKey(String value) { + String key = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + return key.startsWith("--") ? key : "--" + key; + } + + private static String color(JsonElement value) { + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) return null; + return ThemeColorUtil.normalizeCss(value.getAsString()); + } + + private static void addWarning(List warnings, String warning) { + if (warnings.size() < MAX_WARNINGS) warnings.add(warning); + } + + public record Result(ThemeProfile profile, List warnings) { + public Result { + warnings = warnings == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(warnings)); + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/ThemeWebBridge.java b/app/src/main/java/com/fongmi/android/tv/theme/ThemeWebBridge.java new file mode 100644 index 0000000000..9832946a73 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/ThemeWebBridge.java @@ -0,0 +1,42 @@ +package com.fongmi.android.tv.theme; + +/** Produces a bounded, read-only token snapshot for trusted WebTheme pages. */ +public final class ThemeWebBridge { + + private ThemeWebBridge() { + } + + public static String snapshotJson(ThemeTokens tokens) { + ThemeTokens safe = tokens == null ? ThemeTokens.light() : tokens; + return "{\"primary\":\"" + hex(safe.colorPrimary()) + "\"" + + ",\"onPrimary\":\"" + hex(safe.colorOnPrimary()) + "\"" + + ",\"surface\":\"" + hex(safe.colorSurface()) + "\"" + + ",\"surfaceContainer\":\"" + hex(safe.colorSurfaceContainer()) + "\"" + + ",\"surfaceContainerHigh\":\"" + hex(safe.colorSurfaceContainerHigh()) + "\"" + + ",\"onSurface\":\"" + hex(safe.colorOnSurface()) + "\"" + + ",\"onSurfaceVariant\":\"" + hex(safe.colorOnSurfaceVariant()) + "\"" + + ",\"outline\":\"" + hex(safe.colorOutline()) + "\"" + + ",\"outlineVariant\":\"" + hex(safe.colorOutlineVariant()) + "\"" + + ",\"error\":\"" + hex(safe.colorError()) + "\"" + + ",\"success\":\"" + hex(safe.colorSuccess()) + "\"" + + ",\"warning\":\"" + hex(safe.colorWarning()) + "\"" + + ",\"focus\":\"" + hex(safe.colorFocus()) + "\"" + + ",\"focusScale\":" + decimal(safe.focusScale()) + + ",\"scrimOpacity\":" + decimal(alpha(safe.colorScrim())) + + ",\"dialogOpacity\":" + decimal(safe.dialogOpacity()) + + ",\"overlayOpacity\":" + decimal(alpha(safe.colorOverlayLight())) + "}"; + } + + private static String hex(int color) { + return String.format(java.util.Locale.US, "#%08X", color); + } + + /** Alpha channel of a colour as a stable 2-decimal fraction. */ + private static float alpha(int color) { + return ((color >>> 24) & 0xFF) / 255f; + } + + private static String decimal(float value) { + return String.format(java.util.Locale.US, "%.2f", value); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/UpdateVersionRowTheme.java b/app/src/main/java/com/fongmi/android/tv/theme/UpdateVersionRowTheme.java new file mode 100644 index 0000000000..fa0e1cfb1a --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/UpdateVersionRowTheme.java @@ -0,0 +1,279 @@ +package com.fongmi.android.tv.theme; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.StateListDrawable; +import android.view.View; +import android.widget.TextView; + +import com.fongmi.android.tv.R; + +/** + * Row rendering for the version cards inside the “版本更新” dialog. + * + *

Why this exists (measured defect, not a style preference): both + * {@code UpdateDialog} flavours painted each version card with the static + * {@code selector_update_version_item} drawable (resting fill + * {@code ?attr/colorSurfaceContainerHighest}, selected fill + * {@code ?attr/colorPrimaryContainer}) while the card text came from + * {@code @color/update_version_item_title} / {@code _meta} / {@code _accent}, i.e. from + * {@code ?attr/colorOnSurface} / {@code ?attr/colorOnSurfaceVariant} / + * {@code ?attr/colorPrimary}. Those are two independent resource graphs: the fill is a + * {@code StateListDrawable} that {@link ThemeBinder} never rewrites + * ({@code bindDrawable} only handles {@code GradientDrawable} and + * {@code MaterialShapeDrawable}), while the text colours are {@code ColorStateList}s that + * it does rewrite. A user on the default theme sees the compiled pair and everything + * matches; a user on “壁纸取色” or a custom colour gets text re-derived from the dynamic + * palette on top of a fill that did not move - and the card goes unreadable. + * + *

Reported case (user screenshot, 1080x2340, dark wallpaper, “主题色彩 = 壁纸取色”): the + * beta card rendered as a solid {@code #0F1D26} block carrying {@code #49454F} text, i.e. + * 1.5:1, while the stable card next to it stayed legible - the two cards resolve + * different {@code ?attr} entries once the profile overlay is involved, so the mismatch + * only shows up on some cards for some users. + * + *

The card therefore has to take its fill and every text colour from one + * {@link ThemeController#current()} snapshot. {@code ?attr/color*} cannot do that: it is + * a compile-time resource, Android exposes no public API to rewrite it at runtime, and + * the whole point of the profile feature is that a user override must reach this dialog. + * + *

The card keeps the three states the static selector defined, with the same role swap + * between them: resting ({@code colorSurfaceContainerHighest} fill), the expanded/selected + * channel ({@code colorPrimaryContainer} fill) and focus ({@code colorPrimary} fill). The + * selector deliberately inverted fill and ring between "focused" and "selected" so that + * "where the focus is" and "which channel is open" stay two independent clues, and it used + * a closed ring for both. Collapsing the focus fill into {@code colorPrimaryContainer} + * would leave the TV dialog - which always opens with {@code stableItem.requestFocus()} - + * showing a focused card that looks selected; the ring width therefore comes from the + * single {@code @dimen/webhtv_focus_ring_width} token, not from a literal. + * + *

Every foreground is additionally re-checked with + * {@link ThemeEditorUi#readable(int, int)} against the fills it can actually land on, so + * an arbitrary user palette cannot produce a low-contrast card even when the two roles + * happen to collide. The card subtree is tagged {@link #IGNORE_TAG} because this class + * now owns it; otherwise {@link ThemeBinder} would re-colour the text a second time from + * a different role mapping and undo that guarantee. + */ +public final class UpdateVersionRowTheme { + + /** Same marker {@link ThemeBinder} honours for views that paint themselves. */ + public static final String IGNORE_TAG = "webhtv:ignore"; + + /** WCAG AA for body text; the card carries both a title and a version line. */ + private static final double AA = 4.5; + + /** WCAG 1.4.11 non-text contrast, used for the selected/focused ring. */ + private static final double RING = 3.0; + + private static final int CORNER_DP = 8; + + private static final int PILL_RADIUS_DP = 12; + + private UpdateVersionRowTheme() { + } + + /** + * The card fill: {@code colorSurfaceContainerHighest} at rest (the same role the + * static selector used, and the Material 3 "inset list" pattern already used by + * {@link AppearanceRowTheme}) and {@code colorPrimaryContainer} for the expanded + * channel, which is what the {@code state_selected} entry used to paint. + */ + public static int fill(ThemeTokens tokens, boolean expanded) { + ThemeTokens safe = safe(tokens); + return expanded ? safe.colorPrimaryContainer() : safe.colorSurfaceContainerHighest(); + } + + /** The card outline: {@code colorPrimary} when expanded, else the quiet hairline. */ + public static int ring(ThemeTokens tokens, boolean expanded) { + ThemeTokens safe = safe(tokens); + return expanded ? safe.colorPrimary() : safe.colorOutlineVariant(); + } + + /** Channel name (“稳定版” / “测试版”). */ + public static int nameColor(ThemeTokens tokens, boolean expanded) { + return readableOn(safe(tokens).colorOnSurface(), fill(tokens, expanded)); + } + + /** Version line (“5.6.0-beta-…”), the role that was unreadable in the report. */ + public static int versionColor(ThemeTokens tokens, boolean expanded) { + return readableOn(safe(tokens).colorOnSurfaceVariant(), fill(tokens, expanded)); + } + + /** Release notes body. */ + public static int descColor(ThemeTokens tokens, boolean expanded) { + return readableOn(safe(tokens).colorOnSurface(), fill(tokens, expanded)); + } + + /** The “展开” affordance. */ + public static int expandColor(ThemeTokens tokens, boolean expanded) { + return readableOn(safe(tokens).colorPrimary(), fill(tokens, expanded)); + } + + /** The “已最新” pill sits on {@code colorSurface}, so it stays visible on either fill. */ + public static int pillFill(ThemeTokens tokens) { + return safe(tokens).colorSurface(); + } + + /** The pill label; {@code colorPrimary} is already contracted against {@code colorSurface}. */ + public static int pillTextColor(ThemeTokens tokens) { + return readableOn(safe(tokens).colorPrimary(), pillFill(tokens)); + } + + /** + * The fill the card paints while it holds focus: {@code colorPrimary}, i.e. the role the + * static selector used for {@code state_focused} and deliberately swapped with the + * selected fill so the two cues stay independent. + */ + public static int focusFill(ThemeTokens tokens) { + return safe(tokens).colorPrimary(); + } + + /** + * The ink for the focused card. {@code colorOnPrimary} is already contracted against + * {@code colorPrimary}, so on a shipped palette this is the exact colour the old + * {@code update_version_item_*} state lists used; the fallback only fires for a user + * palette that breaks that pair. + */ + public static int focusInk(ThemeTokens tokens) { + ThemeTokens safe = safe(tokens); + return readableOn(safe.colorOnPrimary(), safe.colorPrimary()); + } + + /** + * One text role as a state list: focused and pressed take the on-focus ink, everything + * else the colour clamped against the resting and expanded fills. + * + *

The card's text views declare {@code android:duplicateParentState="true"} in both + * layouts; that is what lets the row's focus state reach them, and without it the + * focused branch would never be selected. + */ + static ColorStateList textColors(int resting, int focused) { + return new ColorStateList( + new int[][]{new int[]{android.R.attr.state_focused}, new int[]{android.R.attr.state_pressed}, new int[]{}}, + new int[]{focused, focused, resting}); + } + + /** + * Applies the palette to one version card. + * + *

The text colours are checked against both the resting and the expanded fill + * because the card keeps them while the user toggles it open: a colour that only + * clears AA on one of the two fills would go unreadable on the other. + * + * @param expanded whether this channel is the expanded one + */ + public static void apply(View row, TextView name, TextView version, TextView status, TextView expand, TextView desc, boolean expanded, ThemeTokens tokens) { + ThemeTokens safe = safe(tokens); + int resting = safe.colorSurfaceContainerHighest(); + int expandedFill = safe.colorPrimaryContainer(); + if (row != null) { + row.setTag(IGNORE_TAG); + row.setBackground(background(row.getContext(), safe)); + } + int focusedInk = focusInk(safe); + if (name != null) name.setTextColor(textColors(readableOn(safe.colorOnSurface(), resting, expandedFill), focusedInk)); + if (version != null) version.setTextColor(textColors(readableOn(safe.colorOnSurfaceVariant(), resting, expandedFill), focusedInk)); + if (desc != null) desc.setTextColor(textColors(readableOn(safe.colorOnSurface(), resting, expandedFill), focusedInk)); + if (expand != null) expand.setTextColor(textColors(readableOn(safe.colorPrimary(), resting, expandedFill), focusedInk)); + if (status != null) { + status.setTextColor(pillTextColor(safe)); + status.setBackground(pill(status.getContext(), pillFill(safe))); + } + } + + /** + * The card background for the current palette. + * + *

This is the runtime twin of {@code selector_update_version_item}: focus paints the + * {@code colorPrimary} fill with an on-primary ring, the selected channel paints + * {@code colorPrimaryContainer} with a primary ring, and the resting card keeps the + * quiet hairline. Focus is the only state whose text flips (see + * {@link #textColors(int, int)}), which is what makes a primary focus fill readable + * again. + */ + public static Drawable background(Context context, ThemeTokens tokens) { + ThemeTokens safe = safe(tokens); + StateListDrawable states = new StateListDrawable(); + states.addState(new int[]{android.R.attr.state_focused}, + faced(context, safe.colorPrimary(), ringColor(safe.colorOnPrimary(), safe.colorPrimary()))); + states.addState(new int[]{android.R.attr.state_pressed}, + faced(context, safe.colorPrimary(), ringColor(safe.colorOnPrimary(), safe.colorPrimary()))); + states.addState(new int[]{android.R.attr.state_selected}, + faced(context, safe.colorPrimaryContainer(), ringColor(safe.colorPrimary(), safe.colorPrimaryContainer()))); + states.addState(new int[]{}, + ThemeEditorUi.shape(context, safe.colorSurfaceContainerHighest(), safe.colorOutlineVariant(), 1, CORNER_DP)); + return states; + } + + /** + * A card face with the shared focus-ring width. {@link ThemeEditorUi#shape} skips its own + * stroke when the width is 0, so the width is set here from the single repository token + * instead of a per-widget literal. + */ + private static GradientDrawable faced(Context context, int fill, int stroke) { + GradientDrawable shape = ThemeEditorUi.shape(context, fill, stroke, 0, CORNER_DP); + shape.setStroke(focusRingWidth(context), stroke); + return shape; + } + + /** The single focus-ring width source, {@code app/src/main/res/values/webhtv_dimens.xml}. */ + private static int focusRingWidth(Context context) { + return Math.max(1, Math.round(context.getResources().getDimension(R.dimen.webhtv_focus_ring_width))); + } + + /** The ring role when it clears the non-text floor against its own fill, else a readable fallback. */ + private static int ringColor(int preferred, int fill) { + return ThemeContrast.ratio(preferred, fill) >= RING ? preferred : readableOn(preferred, fill); + } + + private static GradientDrawable pill(Context context, int fill) { + return ThemeEditorUi.shape(context, fill, 0, 0, PILL_RADIUS_DP); + } + + /** + * {@code preferred} when it clears AA on every fill it can land on, otherwise the + * better of black and white against all of them. + * + *

{@link ThemeEditorUi#readable(int, int)} only takes one background; a card whose + * text survives a fill change needs the conservative variant. One of black/white + * always clears AA against a given fill, and taking the minimum over the fills keeps + * that property for the pair. + */ + static int readableOn(int preferred, int... fills) { + if (clears(preferred, fills)) return preferred; + int best = 0xFF000000; + double bestScore = -1; + for (int candidate : new int[]{0xFF000000, 0xFFFFFFFF}) { + double score = Double.MAX_VALUE; + for (int fill : fills) score = Math.min(score, ThemeContrast.ratio(candidate, fill)); + if (score > bestScore) { + bestScore = score; + best = candidate; + } + } + return best; + } + + private static boolean clears(int color, int... fills) { + for (int fill : fills) if (ThemeContrast.ratio(color, fill) < AA) return false; + return true; + } + + /** + * The expanded card draws a real indicator ring, so it must clear the non-text + * contrast floor against its own fill. The resting card draws a decorative hairline + * only - the same {@code colorOutlineVariant} hairline the static selector used - so it + * is checked for distinguishability instead of the 3:1 indicator floor. + */ + static boolean ringIsVisible(ThemeTokens tokens, boolean expanded) { + ThemeTokens safe = safe(tokens); + if (expanded) return ThemeContrast.ratio(safe.colorPrimary(), safe.colorPrimaryContainer()) >= RING; + return safe.colorOutlineVariant() != safe.colorSurfaceContainerHighest(); + } + + private static ThemeTokens safe(ThemeTokens tokens) { + return tokens == null ? ThemeTokens.dark() : tokens; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/theme/WebHtvAlertDialogBuilder.java b/app/src/main/java/com/fongmi/android/tv/theme/WebHtvAlertDialogBuilder.java new file mode 100644 index 0000000000..e76d71f390 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/theme/WebHtvAlertDialogBuilder.java @@ -0,0 +1,43 @@ +package com.fongmi.android.tv.theme; + +import android.content.Context; + +import androidx.appcompat.app.AlertDialog; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +/** + * The single construction point for Material alert dialogs in this app. + * + *

{@code MaterialAlertDialogBuilder.show()} is implemented as + * {@code create().show()} and {@code create()} is virtual, so overriding it here + * binds the dialog window to the active theme tokens without the caller having to do + * anything. Dialogs built with the raw Material builder were never passed to + * {@link ThemeController#bindDialog(android.app.Dialog)}, so their panels and text + * stayed on the compiled {@code webhtv_color_*} palette no matter which theme the + * user picked - the reason "theme colour" only appeared to affect the site dialog. + * + *

Binding is a no-op whenever the active tokens equal the compiled baseline, so + * the default appearance stays byte-identical to the previous behaviour and there is + * no cost for users who never change the theme. + */ +public class WebHtvAlertDialogBuilder extends MaterialAlertDialogBuilder { + + public WebHtvAlertDialogBuilder(Context context) { + super(context); + } + + public WebHtvAlertDialogBuilder(Context context, int themeResId) { + super(context, themeResId); + } + + @Override + public AlertDialog create() { + AlertDialog dialog = super.create(); + // The panel itself is this builder's window background, not a view background, + // so it is recoloured separately from the view tree. + ThemeController.bindWindowBackground(getBackground()); + ThemeController.bindDialog(dialog); + return dialog; + } +} 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..6e57f35278 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 @@ -1,7 +1,9 @@ package com.fongmi.android.tv.title; import com.fongmi.android.tv.bean.AiConfig; +import com.fongmi.android.tv.bean.TmdbConfig; import com.fongmi.android.tv.bean.TmdbItem; +import com.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.fongmi.android.tv.service.AiTitleExtractionService; import com.fongmi.android.tv.setting.Setting; import com.github.catvod.crawler.SpiderDebug; @@ -132,7 +134,7 @@ public final class MediaTitleResolver { } private void applyTmdbCache(MediaTitleRequest request, MediaTitleResolution resolution) { - TmdbItem item = Setting.getTmdbMatchCache().find(request.getSiteKey(), request.getVodId(), request.getRawTitle()); + TmdbItem item = Setting.getTmdbMatchCache().find(request.getSiteKey(), request.getVodId(), request.getRawTitle(), TmdbLanguagePolicy.requestLanguage(TmdbConfig.effectiveCurrent())); if (item == null || item.getTitle().isEmpty()) return; if (!isTmdbCacheCompatible(item, resolution)) return; resolution.setCanonicalTitle(item.getTitle()); diff --git a/app/src/main/java/com/fongmi/android/tv/tts/AudioFileTtsEngine.java b/app/src/main/java/com/fongmi/android/tv/tts/AudioFileTtsEngine.java new file mode 100644 index 0000000000..acf3b10eeb --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/AudioFileTtsEngine.java @@ -0,0 +1,391 @@ +package com.fongmi.android.tv.tts; + +import android.media.MediaPlayer; +import android.os.Handler; +import android.os.Looper; + +import com.github.catvod.crawler.SpiderDebug; + +import java.io.File; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 「按片段合成音频 → 顺序播放」的在线朗读基类。 + * + * 对齐 legado HttpReadAloudService 的关键行为,但按 WebHTV 的规模做了收敛: + * - 每个片段一次请求,结果按 MD5 落盘缓存({@link TtsAudioCache}),命中即直接播放; + * - 顺序预取后面 {@link #PREFETCH} 个片段,避免「读完一句等下一句」; + * - 播放用 {@link MediaPlayer}(片段是几秒到几十秒的 mp3,不需要 media3 的缓存/下载体系); + * - 单片段失败重试 {@link #RETRY} 次后跳过该片段;连续 {@link #MAX_CONSECUTIVE_ERRORS} + * 个片段失败则判定引擎不可用(fatal),避免坏引擎无限空转。 + */ +abstract class AudioFileTtsEngine implements TtsEngine { + + private static final String TAG = "TV-tts"; + private static final int PREFETCH = 3; + private static final int RETRY = 2; + private static final int MAX_CONSECUTIVE_ERRORS = 3; + /** 同一片段最多容忍的播放失败次数(缓存损坏重下后仍失败就跳过)。 */ + private static final int MAX_PLAY_ERRORS = 2; + + private final Handler main = new Handler(Looper.getMainLooper()); + private final ExecutorService fetchExecutor = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "reader-tts-fetch"); + thread.setDaemon(true); + return thread; + }); + private final AtomicInteger generation = new AtomicInteger(); + private final java.util.Map playErrorCounts = new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * 缓存目录必须懒建:{@code id()} 由子类实现,而子类字段(如自定义引擎的 id)在 + * 父类构造期间还没赋值,在构造函数里取 id() 会拿到 null 并把所有引擎的缓存 + * 混进同一个 default 目录。 + */ + private TtsAudioCache cache; + + protected Listener listener; + protected TtsOptions options = new TtsOptions(1f, 1f, ""); + + /** + * 最近一次合成失败的原因(HTTP 状态码/Content-Type/错误体片段/异常信息)。 + * 在线朗读最容易被吐槽的就是「点了没声音却不知道哪里错」,因此把原因带进 UI 提示。 + */ + protected volatile String lastFetchError = ""; + + private List chunks = Collections.emptyList(); + private MediaPlayer player; + private int current = -1; + private int consecutiveErrors; + private boolean playing; + private boolean paused; + private boolean released; + private String preparedPath = ""; + + AudioFileTtsEngine() { + } + + /** 合成一个片段到目标文件;返回 false 表示失败。实现必须阻塞直到完成或失败。 */ + protected abstract boolean fetchChunk(String text, File target); + + /** 缓存键文本:引擎参数(音色/语速)必须参与,否则换音色会播到旧音频。 */ + protected abstract String cacheKey(String text); + + protected TtsAudioCache cache() { + TtsAudioCache local = cache; + if (local == null) { + synchronized (playErrorCounts) { + if (cache == null) cache = new TtsAudioCache(TtsEngines.cacheRoot(), id()); + local = cache; + } + } + return local; + } + + protected Handler mainHandler() { + return main; + } + + @Override + public List voices() { + return Collections.emptyList(); + } + + @Override + public void prepare(TtsOptions opts, Listener l) { + this.listener = l; + if (opts != null) this.options = opts; + } + + @Override + public void speak(List list, int fromIndex) { + if (released) return; + chunks = list == null ? Collections.emptyList() : list; + if (chunks.isEmpty()) { + if (listener != null) listener.onTtsQueueDone(); + return; + } + releasePlayer(); + playing = true; + paused = false; + consecutiveErrors = 0; + playErrorCounts.clear(); + generation.incrementAndGet(); + int start = Math.max(0, Math.min(fromIndex, chunks.size() - 1)); + playFrom(start); + } + + @Override + public void pause() { + if (!playing || paused) return; + paused = true; + try { + if (player != null && player.isPlaying()) player.pause(); + } catch (Throwable e) { + SpiderDebug.log(TAG, "pause failed %s", e.getMessage()); + } + } + + @Override + public void resume() { + if (!playing || !paused) return; + paused = false; + MediaPlayer mp = player; + if (mp != null) { + try { + mp.start(); + // 暂停期间改过语速的话在此补上:平台文档说明已 prepared 的播放器 + // 用非零速度调 setPlaybackParams 等价于 start(),因此不会重置进度 + applyRate(mp); + return; + } catch (Throwable e) { + SpiderDebug.log(TAG, "resume failed, replay current %s", e.getMessage()); + } + } + playFrom(Math.max(0, current)); + } + + @Override + public void stop() { + playing = false; + paused = false; + generation.incrementAndGet(); + releasePlayer(); + } + + @Override + public void setRate(float rate) { + options = options.withRate(rate); + MediaPlayer mp = player; + // 暂停中不能直接改播放器速率:平台文档明确「已 prepared 的播放器用非零速度 + // 调 setPlaybackParams 等价于 start()」,会让界面显示「已暂停」时突然出声。 + // 暂停期间只更新 options,恢复播放时由 resume() 补上(后续片段也会走新语速)。 + if (mp != null && !paused) applyRate(mp); + } + + /** 把当前语速应用到已 prepared 的播放器。 */ + private void applyRate(MediaPlayer mp) { + try { + android.media.PlaybackParams params = mp.getPlaybackParams(); + params.setSpeed(options.rate); + mp.setPlaybackParams(params); + } catch (Throwable e) { + SpiderDebug.log(TAG, "setRate failed %s", e.getMessage()); + } + } + + @Override + public void setPitch(float pitch) { + options = options.withPitch(pitch); + } + + @Override + public void setVoice(String voiceId) { + options = options.withVoice(voiceId); + } + + @Override + public void release() { + released = true; + stop(); + fetchExecutor.shutdownNow(); + } + + protected boolean isReleased() { + return released; + } + + private void playFrom(int index) { + if (!playing || released) return; + if (index >= chunks.size()) { + playing = false; + if (listener != null) listener.onTtsQueueDone(); + return; + } + current = index; + final int gen = generation.get(); + String text = chunks.get(index).text; + fetch(index, text, gen, file -> { + if (!playing || generation.get() != gen) return; + if (file == null) { + // 合成失败:记为失败并跳过,连续失败则判定引擎不可用 + consecutiveErrors++; + if (listener != null) { + listener.onTtsError(index, "在线语音合成失败:" + lastFetchError + ",已跳过该段", + consecutiveErrors >= MAX_CONSECUTIVE_ERRORS); + } + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + playing = false; + return; + } + main.post(() -> playFrom(index + 1)); + return; + } + consecutiveErrors = 0; + startPlayer(file, index, gen); + prefetch(index + 1, gen); + }); + } + + /** 取缓存或合成;回调一定在主线程执行。 */ + private void fetch(int index, String text, int gen, FetchResult callback) { + File file = cache().file(id(), cacheKey(text)); + if (cache().has(file)) { + main.post(() -> { + if (generation.get() == gen) callback.onResult(file); + }); + return; + } + fetchExecutor.execute(() -> { + if (released || generation.get() != gen) return; + boolean ok = false; + File target = cache().file(id(), cacheKey(text)); + for (int attempt = 0; attempt <= RETRY && !ok; attempt++) { + if (released || generation.get() != gen) return; + try { + ok = fetchChunk(text, target) && cache().has(target); + } catch (Throwable e) { + SpiderDebug.log(TAG, "fetch failed engine=%s attempt=%d %s", id(), attempt, e.getMessage()); + ok = false; + } + if (!ok && attempt < RETRY) { + try { + Thread.sleep(400L * (attempt + 1)); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; + } + } + } + final boolean success = ok; + main.post(() -> { + if (generation.get() == gen) callback.onResult(success ? target : null); + }); + }); + } + + /** 顺序预取后续片段,填满缓存窗口。 */ + private void prefetch(int fromIndex, int gen) { + if (!playing || released) return; + for (int i = fromIndex; i < Math.min(chunks.size(), fromIndex + PREFETCH); i++) { + final int index = i; + String text = chunks.get(index).text; + File file = cache().file(id(), cacheKey(text)); + if (cache().has(file)) continue; + fetchExecutor.execute(() -> { + if (released || generation.get() != gen || !playing) return; + File target = cache().file(id(), cacheKey(text)); + try { + if (!cache().has(target)) fetchChunk(text, target); + } catch (Throwable e) { + SpiderDebug.log(TAG, "prefetch failed index=%d %s", index, e.getMessage()); + } + }); + } + } + + private void startPlayer(File file, int index, int gen) { + releasePlayer(); + try { + MediaPlayer mp = new MediaPlayer(); + player = mp; + preparedPath = file.getAbsolutePath(); + mp.setDataSource(preparedPath); + mp.setOnPreparedListener(prepared -> { + if (!playing || generation.get() != gen) { + releasePlayer(); + return; + } + try { + android.media.PlaybackParams params = prepared.getPlaybackParams(); + params.setSpeed(options.rate); + prepared.setPlaybackParams(params); + } catch (Throwable e) { + SpiderDebug.log(TAG, "apply rate failed %s", e.getMessage()); + } + if (paused) return; + prepared.start(); + if (listener != null) listener.onTtsStart(index, chunks.get(index).text); + }); + mp.setOnCompletionListener(completed -> { + if (generation.get() != gen) return; + if (listener != null) listener.onTtsChunkDone(index); + main.post(() -> playFrom(index + 1)); + }); + mp.setOnErrorListener((errored, what, extra) -> { + SpiderDebug.log(TAG, "player error index=%d what=%d extra=%d", index, what, extra); + // 缓存文件可能损坏:删掉缓存重下;同一片段最多重试两次, + // 否则「下到坏音频 → 播放失败 → 再下」会无限循环。 + File cached = new File(preparedPath); + //noinspection ResultOfMethodCallIgnored + cached.delete(); + if (generation.get() != gen) return true; + int attempts = playErrorCounts.merge(index, 1, Integer::sum); + main.post(() -> { + if (attempts <= MAX_PLAY_ERRORS) { + playFrom(index); + } else { + playErrorCounts.remove(index); + if (listener != null) { + listener.onTtsError(index, "音频播放失败,已跳过该段", false); + } + playFrom(index + 1); + } + }); + return true; + }); + mp.prepareAsync(); + } catch (Throwable e) { + SpiderDebug.log(TAG, "startPlayer failed index=%d %s", index, e.getMessage()); + if (listener != null) listener.onTtsError(index, "音频播放失败:" + e.getMessage(), false); + main.post(() -> playFrom(index + 1)); + } + } + + private void releasePlayer() { + MediaPlayer mp = player; + player = null; + preparedPath = ""; + if (mp == null) return; + try { + mp.setOnPreparedListener(null); + mp.setOnCompletionListener(null); + mp.setOnErrorListener(null); + mp.reset(); + } catch (Throwable ignore) { + } + try { + mp.release(); + } catch (Throwable ignore) { + } + } + + private interface FetchResult { + void onResult(File file); + } + + /** 便捷方法:把输入流写入指定文件(供实现类复用)。 */ + protected static boolean storeStream(File target, java.io.InputStream in) { + try (java.io.InputStream stream = in; + java.io.OutputStream out = new java.io.FileOutputStream(target)) { + byte[] buf = new byte[8192]; + long total = 0; + int n; + while ((n = stream.read(buf)) > 0) { + total += n; + if (total > 8L * 1024 * 1024) throw new IllegalStateException("audio too large"); + out.write(buf, 0, n); + } + out.flush(); + return total > 0; + } catch (Throwable e) { + //noinspection ResultOfMethodCallIgnored + target.delete(); + return false; + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/BaiduTtsEngine.java b/app/src/main/java/com/fongmi/android/tv/tts/BaiduTtsEngine.java new file mode 100644 index 0000000000..feadb4d98d --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/BaiduTtsEngine.java @@ -0,0 +1,34 @@ +package com.fongmi.android.tv.tts; + +/** + * 百度在线语音(翻译发音接口,免 Key)。 + * + * 接口:{@code https://fanyi.baidu.com/gettts?lan=zh&text=…&spd=…&source=web} + * 实测(2026-10-09,测试设备):HTTP 200 + {@code audio/mpeg};{@code spd} 有效区间 1..7, + * 超出范围会返回空响应,因此 {@link HttpTtsEngine#speakSpeed(float)} 做了 1..7 夹取。 + * + * 选择它作为内置轻量音源的原因:单句延迟低、无需注册、对中文长句稳定; + * 音色质量不如微软 Neural,故保留 Edge 音源供用户选择。 + */ +final class BaiduTtsEngine extends HttpTtsEngine { + + private static final String RULE = + "https://fanyi.baidu.com/gettts?lan=zh&text={{java.encodeURI(speakText)}}&spd={{speakSpeed}}&source=web"; + + BaiduTtsEngine() { + super(config(), TtsEngines.SOURCE_BAIDU); + } + + private static TtsEngineConfig config() { + TtsEngineConfig c = new TtsEngineConfig(); + c.name = "百度在线语音"; + c.url = RULE; + c.contentType = "audio/mpeg"; + return c; + } + + @Override + public String detail() { + return "百度在线语音(免配置,依赖网络)"; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/EdgeTtsEngine.java b/app/src/main/java/com/fongmi/android/tv/tts/EdgeTtsEngine.java new file mode 100644 index 0000000000..fecdcb6b2f --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/EdgeTtsEngine.java @@ -0,0 +1,427 @@ +package com.fongmi.android.tv.tts; + +import android.os.SystemClock; + +import com.github.catvod.crawler.SpiderDebug; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okio.ByteString; + +/** + * 微软 Edge「大声朗读」引擎(免 Key,Neural 音色)。 + * + * 协议参考社区实现 {@code wangz-code/legado-edge-tts} 的 {@code EdgeSpeakFetch.kt}: + * 1) 连接 {@code wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1}, + * 查询参数带 {@code ConnectionId}、{@code Sec-MS-GEC}、{@code Sec-MS-GEC-Version} 与 + * {@code TrustedClientToken}; + * 2) {@code Sec-MS-GEC} = SHA-256(「Windows epoch 秒数按 300 秒取整 ×10^7」+ TrustedClientToken) 的大写十六进制; + * 3) 先发 {@code speech.config} 文本帧,再发 {@code ssml} 文本帧; + * 4) 服务端以二进制帧返回音频(前 2 字节大端为 header 长度,header 含 {@code X-RequestId}), + * 以 {@code turn.end} 文本帧结束本轮。 + * + * 与参考实现的差异:这里按 {@code X-RequestId} 匹配每个待合成片段(参考实现靠「最近一次连接」 + * 复用,句子并发时会串音),并复用同一条 WebSocket 顺序合成,避免每句重新握手带来的首句延迟。 + * + * 风险:该协议非公开稳定接口,{@code Sec-MS-GEC-Version} 需与 Edge 客户端版本常量同步, + * 上游可能调整。因此本引擎只作为可选音源,失败时由控制器回退/提示,不影响系统音源可用性。 + */ +final class EdgeTtsEngine extends AudioFileTtsEngine { + + private static final String TAG = "TV-tts"; + + private static final String TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"; + private static final String BASE_URL = "wss://speech.platform.bing.com"; + private static final String WSS_PATH = "/consumer/speech/synthesize/readaloud/edge/v1"; + static final String DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural"; + private static final String CHROMIUM_FULL_VERSION = "143.0.3650.75"; + private static final String SEC_MS_GEC_VERSION = "1-" + CHROMIUM_FULL_VERSION; + private static final long WIN_EPOCH_SECONDS = 11644473600L; + private static final String OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3"; + + private static final long CONNECT_TIMEOUT_MS = 12000L; + private static final long TURN_TIMEOUT_MS = 25000L; + + /** 内置中文音色(Neural),id 直接用于 SSML 的 voice name。 */ + static final String[][] VOICES = { + {"zh-CN-XiaoxiaoNeural", "晓晓 · 女声(通用)"}, + {"zh-CN-XiaoyiNeural", "晓伊 · 女声(年轻)"}, + {"zh-CN-YunxiNeural", "云希 · 男声(通用)"}, + {"zh-CN-YunjianNeural", "云健 · 男声(浑厚)"}, + {"zh-CN-YunxiaNeural", "云夏 · 童声"}, + {"zh-CN-YunyangNeural", "云扬 · 男声(播音)"}, + {"zh-CN-liaoning-XiaobeiNeural", "晓北 · 东北口音"}, + {"zh-CN-shaanxi-XiaoniNeural", "晓妮 · 陕西方言"}, + }; + + private static final OkHttpClient CLIENT = new OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .pingInterval(20, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build(); + + private final Object lock = new Object(); + private final Map turns = new LinkedHashMap<>(); + + private WebSocket socket; + private boolean configSent; + private long lastFailureAt; + + private static final class Turn { + final String requestId; + final ByteArrayOutputStream audio = new ByteArrayOutputStream(16384); + final CountDownLatch done = new CountDownLatch(1); + volatile boolean success; + + Turn(String requestId) { + this.requestId = requestId; + } + } + + @Override + public String id() { + return TtsEngines.SOURCE_EDGE; + } + + @Override + public boolean available() { + return true; + } + + @Override + public String detail() { + return "微软 Edge 语音(Neural 音色,依赖网络)"; + } + + @Override + public List voices() { + List list = new ArrayList<>(VOICES.length); + for (String[] v : VOICES) list.add(new TtsVoice(v[0], v[1], "zh-CN")); + return list; + } + + @Override + protected String cacheKey(String text) { + return "edge|" + options.rate + "|" + TtsOptions.pitchOffsetHz(options.pitch) + "|" + voiceId() + "|" + text; + } + + private String voiceId() { + return options.voice == null || options.voice.isEmpty() ? DEFAULT_VOICE : options.voice; + } + + @Override + protected boolean fetchChunk(String text, File target) { + String clean = cleanText(text); + if (clean.isEmpty()) { + lastFetchError = "该片段没有可朗读内容"; + return false; + } + long elapsed = SystemClock.elapsedRealtime(); + if (lastFailureAt > 0 && elapsed - lastFailureAt < 5000L) { + // 刚失败过:等一小会儿再试,避免连接被拒时把重试全部打满 + try { + Thread.sleep(600); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + Turn turn = null; + try { + if (!ensureConnected()) { + lastFetchError = "无法连接微软语音服务(网络或协议变更)"; + return false; + } + String requestId = connectId(); + turn = new Turn(requestId); + synchronized (lock) { + turns.put(requestId, turn); + } + if (!sendSsml(requestId, clean)) { + lastFetchError = "微软语音连接已断开"; + markFailed(); + return false; + } + if (!turn.done.await(TURN_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + lastFetchError = "微软语音响应超时"; + SpiderDebug.log(TAG, "edge tts turn timeout"); + markFailed(); + return false; + } + if (!turn.success || turn.audio.size() == 0) { + lastFetchError = "微软语音没有返回音频"; + return false; + } + boolean stored = storeStream(target, new ByteArrayInputStream(turn.audio.toByteArray())); + if (!stored) { + lastFetchError = "音频写入缓存失败"; + SpiderDebug.log(TAG, "edge tts store failed bytes=%d", turn.audio.size()); + } + return stored; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (Throwable e) { + lastFetchError = e.getClass().getSimpleName() + " " + (e.getMessage() == null ? "" : e.getMessage()); + SpiderDebug.log(TAG, "edge tts failed %s", e.getMessage()); + markFailed(); + return false; + } finally { + if (turn != null) { + synchronized (lock) { + turns.remove(turn.requestId); + } + } + } + } + + private boolean ensureConnected() throws InterruptedException { + WebSocket current; + synchronized (lock) { + current = socket; + } + if (current != null) return true; + CountDownLatch opened = new CountDownLatch(1); + String url = buildUrl(); + Request request = new Request.Builder().url(url) + .header("User-Agent", userAgent()) + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Pragma", "no-cache") + .header("Cache-Control", "no-cache") + .header("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold") + .build(); + WebSocket created = CLIENT.newWebSocket(request, new SocketListener(opened)); + if (!opened.await(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + SpiderDebug.log(TAG, "edge tts connect timeout"); + try { + created.cancel(); + } catch (Throwable ignore) { + } + return false; + } + synchronized (lock) { + return socket != null; + } + } + + private String buildUrl() { + String connectionId = connectId(); + return String.format(Locale.US, "%s%s?ConnectionId=%s&Sec-MS-GEC=%s&Sec-MS-GEC-Version=%s&TrustedClientToken=%s", + BASE_URL, WSS_PATH, connectionId, secMsGec(), SEC_MS_GEC_VERSION, TRUSTED_CLIENT_TOKEN); + } + + private static String userAgent() { + String major = CHROMIUM_FULL_VERSION.split("\\.")[0]; + return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + + major + ".0.0.0 Safari/537.36 Edg/" + major + ".0.0.0"; + } + + /** Sec-MS-GEC:Windows epoch 秒数按 300 秒取整后转 100 纳秒刻度,拼 Token 求 SHA-256 大写十六进制。 */ + static String secMsGec() { + long ticks = System.currentTimeMillis() / 1000L + WIN_EPOCH_SECONDS; + ticks -= ticks % 300L; + ticks *= 10_000_000L; + return sha256(ticks + TRUSTED_CLIENT_TOKEN); + } + + private static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) sb.append(String.format(Locale.US, "%02X", b)); + return sb.toString(); + } catch (Throwable e) { + return ""; + } + } + + private static String connectId() { + return UUID.randomUUID().toString().replace("-", ""); + } + + private boolean sendSsml(String requestId, String text) { + WebSocket current; + synchronized (lock) { + current = socket; + } + if (current == null) return false; + SimpleDateFormat format = new SimpleDateFormat("EEE MMM d yyyy HH:mm:ss 'GMT+0000'", Locale.US); + format.setTimeZone(TimeZone.getTimeZone("UTC")); + String timestamp = format.format(new Date()); + String ssml = "" + + "" + + "" + + escapeXml(text) + + ""; + String message = "X-RequestId:" + requestId + + "\r\nContent-Type:application/ssml+xml" + + "\r\nX-Timestamp:" + timestamp + "Z" + + "\r\nPath:ssml\r\n\r\n" + ssml; + return current.send(message); + } + + /** 朗读面板语速(0.5x–2.0x)→ SSML 百分比:1.0x 对应 +0%。 */ + private String prosodyRate() { + int percent = Math.round((options.rate - 1f) * 100f); + return (percent >= 0 ? "+" : "") + percent + "%"; + } + + private void sendSpeechConfig(WebSocket target) { + SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); + format.setTimeZone(TimeZone.getTimeZone("UTC")); + String config = "{\"context\":{\"synthesis\":{\"audio\":{\"metadataoptions\":{" + + "\"sentenceBoundaryEnabled\":\"false\",\"wordBoundaryEnabled\":\"false\"}," + + "\"outputFormat\":\"" + OUTPUT_FORMAT + "\"}}}}"; + String message = "X-Timestamp:" + format.format(new Date()) + + "\r\nContent-Type:application/json; charset=utf-8" + + "\r\nPath:speech.config\r\n\r\n" + config + "\r\n"; + target.send(message); + } + + private void markFailed() { + lastFailureAt = SystemClock.elapsedRealtime(); + WebSocket current; + synchronized (lock) { + current = socket; + socket = null; + configSent = false; + for (Turn turn : turns.values()) turn.done.countDown(); + } + if (current != null) { + try { + current.cancel(); + } catch (Throwable ignore) { + } + } + } + + @Override + public void release() { + markFailed(); + super.release(); + } + + private final class SocketListener extends WebSocketListener { + + private final CountDownLatch opened; + + SocketListener(CountDownLatch opened) { + this.opened = opened; + } + + @Override + public void onOpen(WebSocket webSocket, Response response) { + synchronized (lock) { + socket = webSocket; + configSent = false; + } + sendSpeechConfig(webSocket); + synchronized (lock) { + configSent = true; + } + opened.countDown(); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + if (text == null || !text.contains("turn.end")) return; + String requestId = headerValue(text, "X-RequestId"); + Turn turn = findTurn(requestId); + if (turn == null) return; + turn.success = turn.audio.size() > 0; + turn.done.countDown(); + } + + @Override + public void onMessage(WebSocket webSocket, ByteString bytes) { + byte[] data = bytes.toByteArray(); + if (data.length < 2) return; + int headerLength = ((data[0] & 0xFF) << 8) | (data[1] & 0xFF); + if (headerLength < 0 || headerLength + 2 > data.length) return; + String header = new String(data, 2, headerLength, StandardCharsets.UTF_8); + String path = headerValue(header, "Path"); + if (path != null && !"audio".equals(path)) return; + Turn turn = findTurn(headerValue(header, "X-RequestId")); + if (turn == null) return; + int start = headerLength + 2; + if (start >= data.length) return; + turn.audio.write(data, start, data.length - start); + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + SpiderDebug.log(TAG, "edge tts closed code=%d", code); + markFailed(); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + SpiderDebug.log(TAG, "edge tts failure %s", t == null ? "unknown" : t.getMessage()); + markFailed(); + opened.countDown(); + } + } + + private Turn findTurn(String requestId) { + if (requestId == null) return null; + synchronized (lock) { + return turns.get(requestId); + } + } + + /** 从协议的 header 段里取值(大小写不敏感,容忍首尾空白)。 */ + private static String headerValue(String raw, String name) { + if (raw == null) return null; + for (String line : raw.split("\r\n")) { + int colon = line.indexOf(':'); + if (colon <= 0) continue; + if (name.equalsIgnoreCase(line.substring(0, colon).trim())) { + return line.substring(colon + 1).trim(); + } + } + return null; + } + + /** 去掉朗读引擎不认识的装饰字符,保留中文与常用标点(对齐参考实现的清洗规则)。 */ + static String cleanText(String text) { + if (text == null) return ""; + StringBuilder sb = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + boolean keep = Character.isLetterOrDigit(c) + || Character.isWhitespace(c) + || ",。!?;:、()《》【】“”‘’…—,.!?;:()[]'\"-".indexOf(c) >= 0; + if (keep) sb.append(c); + } + return sb.toString().trim(); + } + + private static String escapeXml(String text) { + return text.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("'", "'").replace("\"", """); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/HttpTtsEngine.java b/app/src/main/java/com/fongmi/android/tv/tts/HttpTtsEngine.java new file mode 100644 index 0000000000..1be013dd15 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/HttpTtsEngine.java @@ -0,0 +1,177 @@ +package com.fongmi.android.tv.tts; + +import androidx.annotation.Nullable; + +import com.github.catvod.crawler.SpiderDebug; + +import java.io.File; +import java.io.InputStream; +import java.net.URLEncoder; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + * 通用在线朗读引擎:按 legado 兼容的 URL 规则请求音频流。 + * + * 同时用于内置的百度在线语音({@link BaiduTtsEngine})与用户自定义引擎 + * (规则来自 {@link TtsEngineConfig},见 {@link TtsHttpRule} 支持的模板子集)。 + */ +class HttpTtsEngine extends AudioFileTtsEngine { + + private static final String TAG = "TV-tts"; + + private static final OkHttpClient CLIENT = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .callTimeout(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build(); + + private final TtsEngineConfig config; + private final TtsHttpRule rule; + private final String engineId; + + HttpTtsEngine(TtsEngineConfig config) { + this(config, TtsEngines.SOURCE_CUSTOM); + } + + HttpTtsEngine(TtsEngineConfig config, String engineId) { + this.config = config; + this.engineId = engineId; + this.rule = config == null ? null : TtsHttpRule.parse(config.url, config.contentType); + } + + @Override + public String id() { + return engineId; + } + + @Override + public boolean available() { + return rule != null && rule.isUsable(); + } + + @Override + public String detail() { + if (config == null) return "未配置在线朗读引擎"; + if (rule == null || !rule.isUsable()) { + return rule == null ? "引擎规则无法解析" : rule.unsupportedReason(); + } + return config.displayName(); + } + + @Override + protected String cacheKey(String text) { + // 参数(音色/语速)参与键,避免改设置后播到旧音频 + return engineId + "|" + options.rate + "|" + options.voice + "|" + text; + } + + @Override + protected boolean fetchChunk(String text, File target) { + if (!available()) { + lastFetchError = detail(); + return false; + } + int speed = speakSpeed(options.rate); + String url = rule.url(text, speed); + String method = rule.method(); + String body = rule.body(text, speed); + try { + Request.Builder builder = new Request.Builder().url(url); + if (config != null && config.header != null && !config.header.trim().isEmpty()) { + applyHeader(builder, config.header); + } + if (TtsHttpRule.METHOD_POST.equals(method)) { + RequestBody requestBody = RequestBody.create( + body == null ? "" : body, + MediaType.parse("application/x-www-form-urlencoded; charset=utf-8")); + builder.post(requestBody); + } else { + builder.get(); + } + try (Response response = CLIENT.newCall(builder.build()).execute()) { + ResponseBody responseBody = response.body(); + if (!response.isSuccessful() || responseBody == null) { + lastFetchError = "HTTP " + response.code(); + SpiderDebug.log(TAG, "http tts bad response code=%d", response.code()); + return false; + } + String contentType = response.header("Content-Type", ""); + if (!looksLikeAudio(contentType)) { + // 引擎改成返回错误文本时,读一小段便于用户诊断(对齐 legado 的错误体提示) + String preview = peek(responseBody); + lastFetchError = "返回 " + contentType + ":" + preview; + SpiderDebug.log(TAG, "http tts non-audio contentType=%s body=%s", contentType, preview); + return false; + } + try (InputStream in = responseBody.byteStream()) { + boolean stored = storeStream(target, in); + if (!stored) lastFetchError = "音频写入缓存失败"; + return stored; + } + } + } catch (Throwable e) { + lastFetchError = e.getClass().getSimpleName() + " " + (e.getMessage() == null ? "" : e.getMessage()); + SpiderDebug.log(TAG, "http tts failed %s", e.getMessage()); + return false; + } + } + + private static boolean looksLikeAudio(String contentType) { + if (contentType == null) return false; + String ct = contentType.toLowerCase(Locale.ROOT); + return ct.startsWith("audio") || ct.startsWith("application/octet-stream") + || ct.startsWith("application/mpeg") || ct.startsWith("binary/octet-stream"); + } + + private static String peek(ResponseBody body) { + try { + InputStream in = body.byteStream(); + byte[] buf = new byte[256]; + int n = in.read(buf); + return n <= 0 ? "" : new String(buf, 0, n, "UTF-8"); + } catch (Throwable e) { + return ""; + } + } + + private static void applyHeader(Request.Builder builder, String header) { + // legado 的 header 字段允许是 JSON 或直接粘贴的 "Key: Value" 多行文本 + String text = header.trim(); + com.google.gson.JsonObject json = TtsHttpRule.parseObject(text); + if (json != null) { + for (Map.Entry e : json.entrySet()) { + if (e.getValue() == null || e.getValue().isJsonNull()) continue; + builder.header(e.getKey(), e.getValue().getAsString()); + } + return; + } + for (String line : text.split("\n")) { + int colon = line.indexOf(':'); + if (colon <= 0) continue; + builder.header(line.substring(0, colon).trim(), line.substring(colon + 1).trim()); + } + } + + /** 语速映射:朗读面板的 0.5x–2.0x → 引擎自己的速度刻度。 */ + protected int speakSpeed(float rate) { + return Math.max(1, Math.min(7, Math.round(rate * 5f))); + } + + @Nullable + static String urlEncode(String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (Throwable e) { + return null; + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/ReaderTtsController.java b/app/src/main/java/com/fongmi/android/tv/tts/ReaderTtsController.java new file mode 100644 index 0000000000..2eaee823e4 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/ReaderTtsController.java @@ -0,0 +1,387 @@ +package com.fongmi.android.tv.tts; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; + +import com.github.catvod.crawler.SpiderDebug; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 小说朗读编排器(对齐 legado BaseReadAloudService 的职责,去掉与正文分页模型的耦合)。 + * + * 负责:段落 → 朗读片段切分、引擎选择与生命周期、段落游标、上一段/下一段、 + * 定时停止、状态广播。真正的语音合成/播放交给 {@link TtsEngine} 实现。 + * + * 之所以把编排放在原生侧(而不是留在 reader.html 的 JS 里): + * 切后台后 WebView 的 JS 计时器会被系统挂起,而朗读必须继续; + * 同时通知栏的上一段/下一段也要能越过 JS 直接驱动队列。 + */ +public final class ReaderTtsController implements TtsEngine.Listener { + + private static final String TAG = "TV-tts"; + + public static final String STATE_PLAYING = "playing"; + public static final String STATE_PAUSED = "paused"; + public static final String STATE_STOPPED = "stopped"; + public static final String STATE_ERROR = "error"; + + /** 面向 UI(Activity / 通知)的状态回调。 */ + public interface Listener { + /** @param paragraph 当前段落下标(0 基),-1 表示无。 */ + void onTtsState(String state, int paragraph, int total, String message, String title); + + void onTtsParagraph(int paragraph, int total, String text); + + /** 本章朗读完毕,由 UI 决定是否续读下一章。 */ + void onTtsChapterEnd(); + + void onTtsVoices(List voices); + } + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable timerTick = new Runnable() { + @Override + public void run() { + if (timerMinutes <= 0) return; + if (STATE_PLAYING.equals(state)) { + timerMinutes--; + if (timerMinutes <= 0) { + timerMinutes = 0; + stop("定时朗读结束"); + return; + } + notifyState("朗读定时剩余 " + timerMinutes + " 分钟"); + } + handler.postDelayed(this, 60000L); + } + }; + + private final Context context; + private TtsEngine engine; + private Listener listener; + private List chunks = Collections.emptyList(); + private int paragraphCount; + private int currentChunk = -1; + private String state = STATE_STOPPED; + private String title = ""; + private String message = ""; + private int timerMinutes; + private String sourceId = TtsEngines.SOURCE_SYSTEM; + private long startedAt; + + public ReaderTtsController(Context context) { + this.context = context.getApplicationContext(); + } + + public void setListener(Listener l) { + this.listener = l; + } + + public String state() { + return state; + } + + public int currentParagraph() { + if (currentChunk < 0 || currentChunk >= chunks.size()) return -1; + return chunks.get(currentChunk).paragraph; + } + + public int paragraphCount() { + return paragraphCount; + } + + public boolean isPlaying() { + return STATE_PLAYING.equals(state); + } + + public boolean isActive() { + return STATE_PLAYING.equals(state) || STATE_PAUSED.equals(state); + } + + public int timerMinutes() { + return timerMinutes; + } + + public String sourceId() { + return sourceId; + } + + /** 已朗读时长(毫秒),用于通知/状态展示。 */ + public long elapsed() { + return startedAt <= 0 ? 0 : SystemClock.elapsedRealtime() - startedAt; + } + + /** + * 开始朗读。 + * + * @param source 音源 id:{@code system}/{@code edge}/{@code baidu}/{@code custom} + * @param options 语速/音调/音色 + * @param customRule 自定义在线引擎(source=custom 时使用) + * @param paragraphs 段落文本(阅读器正文,按 DOM 顺序) + * @param startIndex 起始段落下标(0 基) + * @param timer 定时分钟数,0 表示不定时 + * @param trackTitle 书名/章节名,用于通知 + */ + public void start(String source, TtsOptions options, TtsEngineConfig customRule, + List paragraphs, int startIndex, int timer, String trackTitle) { + stop(null); + sourceId = source == null || source.isEmpty() ? TtsEngines.SOURCE_SYSTEM : source; + title = trackTitle == null ? "" : trackTitle; + paragraphCount = paragraphs == null ? 0 : paragraphs.size(); + if (paragraphCount == 0) { + setState(STATE_ERROR, "没有可朗读的内容"); + return; + } + engine = TtsEngines.create(context, sourceId, customRule); + if (engine instanceof HttpTtsEngine && (!engine.available())) { + setState(STATE_ERROR, engine.detail()); + releaseEngine(); + return; + } + if (sourceId.equals(TtsEngines.SOURCE_SYSTEM) && !engine.available()) { + setState(STATE_ERROR, engine.detail()); + releaseEngine(); + return; + } + // 系统引擎自己按标点断句更自然;在线引擎按句请求,首句更快出声且便于预取 + int maxChars = TtsEngines.SOURCE_SYSTEM.equals(sourceId) ? 0 : TtsTextSplitter.MAX_CHUNK; + chunks = TtsTextSplitter.split(paragraphs, maxChars); + if (chunks.isEmpty()) { + setState(STATE_ERROR, "没有可朗读的内容"); + releaseEngine(); + return; + } + int start = Math.max(0, Math.min(startIndex, paragraphCount - 1)); + currentChunk = firstChunkOfParagraph(start); + timerMinutes = Math.max(0, Math.min(timer, 600)); + startedAt = SystemClock.elapsedRealtime(); + SpiderDebug.log(TAG, "start source=%s paragraphs=%d chunks=%d start=%d timer=%d", + sourceId, paragraphCount, chunks.size(), start, timerMinutes); + engine.prepare(options, this); + engine.speak(chunks, currentChunk); + setState(STATE_PLAYING, null); + scheduleTimer(); + } + + public void pause() { + if (!STATE_PLAYING.equals(state)) return; + if (engine != null) engine.pause(); + setState(STATE_PAUSED, null); + } + + public void resume() { + if (!STATE_PAUSED.equals(state)) return; + if (engine != null) engine.resume(); + setState(STATE_PLAYING, null); + scheduleTimer(); + } + + public void toggle() { + if (STATE_PLAYING.equals(state)) pause(); + else if (STATE_PAUSED.equals(state)) resume(); + } + + /** 上一段:回到当前段起点;已在段首则退到上一段。 */ + public void previous() { + if (!isActive() || chunks.isEmpty()) return; + int paragraph = currentParagraph(); + int firstOfCurrent = firstChunkOfParagraph(paragraph); + int target = currentChunk > firstOfCurrent ? firstOfCurrent : firstChunkOfParagraph(Math.max(0, paragraph - 1)); + seekChunk(target); + } + + /** 下一段:跳到下一段起点;没有下一段则视为本章读完。 */ + public void next() { + if (!isActive() || chunks.isEmpty()) return; + int paragraph = currentParagraph(); + int target = firstChunkOfParagraph(paragraph + 1); + if (target < 0 || target >= chunks.size() || chunks.get(target).paragraph <= paragraph) { + handleQueueDone(); + return; + } + seekChunk(target); + } + + /** 跳到指定段落。 */ + public void seekParagraph(int paragraph) { + if (!isActive() || chunks.isEmpty()) return; + if (paragraph < 0 || paragraph >= paragraphCount) return; + seekChunk(firstChunkOfParagraph(paragraph)); + } + + public void setRate(float rate) { + if (engine != null) engine.setRate(rate); + } + + public void setPitch(float pitch) { + if (engine != null) engine.setPitch(pitch); + } + + public void setVoice(String voice) { + if (engine == null) return; + engine.setVoice(voice); + // 换音色后从当前段重新合成/朗读 + if (isActive() && !chunks.isEmpty()) seekChunk(firstChunkOfParagraph(Math.max(0, currentParagraph()))); + } + + /** 设置定时的绝对分钟数(0 表示取消)。 */ + public void setTimer(int minutes) { + timerMinutes = Math.max(0, Math.min(minutes, 600)); + notifyState(timerMinutes > 0 ? "朗读定时 " + timerMinutes + " 分钟" : null); + scheduleTimer(); + } + + /** 在现有定时上追加(通知栏「+30 分钟」),上限 3 小时。 */ + public void addTimer(int delta) { + setTimer(Math.min(180, timerMinutes + Math.max(0, delta))); + } + + public void stop(String reason) { + handler.removeCallbacks(timerTick); + if (engine != null) { + engine.stop(); + } + releaseEngine(); + chunks = Collections.emptyList(); + currentChunk = -1; + startedAt = 0; + if (STATE_STOPPED.equals(state) && reason == null) return; + setState(STATE_STOPPED, reason); + } + + /** 用户退出阅读页:停播并释放全部资源。 */ + public void release() { + stop(null); + listener = null; + } + + private void releaseEngine() { + if (engine == null) return; + try { + engine.release(); + } catch (Throwable e) { + SpiderDebug.log(TAG, "engine release failed %s", e.getMessage()); + } + engine = null; + } + + private void seekChunk(int index) { + if (engine == null || chunks.isEmpty()) return; + if (index < 0 || index >= chunks.size()) return; + currentChunk = index; + engine.speak(chunks, index); + setState(STATE_PLAYING, null); + scheduleTimer(); + } + + private int firstChunkOfParagraph(int paragraph) { + for (int i = 0; i < chunks.size(); i++) { + if (chunks.get(i).paragraph >= paragraph) return i; + } + return -1; + } + + private void scheduleTimer() { + handler.removeCallbacks(timerTick); + if (timerMinutes > 0) handler.postDelayed(timerTick, 60000L); + } + + private void setState(String newState, String info) { + state = newState; + message = info == null ? "" : info; + if (STATE_STOPPED.equals(newState)) { + startedAt = 0; + timerMinutes = 0; + } + notifyState(null); + } + + private void notifyState(String info) { + if (listener == null) return; + listener.onTtsState(state, currentParagraph(), paragraphCount, + info != null ? info : message, title); + } + + /** + * 本章读完。 + * + * 必须先发「章末」再落 STOPPED:阅读页靠这个事件决定是否续读下一章, + * 而它判断「当前是否在读」依据的是 state;若先变 stopped,续读会被误判成 + * 「用户已停止」而丢弃,表现为读完一章就彻底停下。 + */ + private void handleQueueDone() { + handler.removeCallbacks(timerTick); + if (engine != null) engine.stop(); + currentChunk = -1; + if (listener != null) listener.onTtsChapterEnd(); + setState(STATE_STOPPED, null); + } + + /* ---------------- TtsEngine.Listener ---------------- */ + + /** + * 引擎回调统一切到主线程。 + * + * 系统 TTS 的 UtteranceProgressListener、MediaPlayer 的完成回调虽然多在主线程, + * 但 HTTP 合成失败/超时等路径可能从工作线程直接回调;而回调链路下游要做 + * startForeground/stopSelf、通知更新与 WebView evaluateJavascript,都要求主线程语义。 + */ + private void onMain(Runnable action) { + if (Looper.myLooper() == Looper.getMainLooper()) action.run(); + else handler.post(action); + } + + @Override + public void onTtsStart(int index, String text) { + onMain(() -> handleStart(index, text)); + } + + private void handleStart(int index, String text) { + currentChunk = index; + if (listener == null) return; + int paragraph = index >= 0 && index < chunks.size() ? chunks.get(index).paragraph : -1; + listener.onTtsParagraph(paragraph, paragraphCount, text); + notifyState(null); + } + + @Override + public void onTtsChunkDone(int index) { + // 进度推进以 onTtsStart 为准(引擎已在下一次 start 里给出新下标) + } + + @Override + public void onTtsQueueDone() { + onMain(this::handleQueueDone); + } + + @Override + public void onTtsError(int index, String errorMessage, boolean fatal) { + onMain(() -> handleError(index, errorMessage, fatal)); + } + + @Override + public void onTtsVoices(List voices) { + onMain(() -> { + if (listener != null) listener.onTtsVoices(new ArrayList<>(voices)); + }); + } + + private void handleError(int index, String errorMessage, boolean fatal) { + SpiderDebug.log(TAG, "engine error index=%d fatal=%b %s", index, fatal, errorMessage); + if (fatal) { + setState(STATE_ERROR, errorMessage); + releaseEngine(); + chunks = Collections.emptyList(); + currentChunk = -1; + } else { + message = errorMessage == null ? "" : errorMessage; + notifyState(null); + } + } + +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/SystemTtsEngine.java b/app/src/main/java/com/fongmi/android/tv/tts/SystemTtsEngine.java new file mode 100644 index 0000000000..69d679b88c --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/SystemTtsEngine.java @@ -0,0 +1,352 @@ +package com.fongmi.android.tv.tts; + +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.speech.tts.TextToSpeech; +import android.speech.tts.UtteranceProgressListener; +import android.speech.tts.Voice; + +import com.github.catvod.crawler.SpiderDebug; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 系统朗读引擎(Android TextToSpeech)。 + * + * 对齐 legado TTSReadAloudService: + * - 首句 SPEC:QUEUE_FLUSH,其余 QUEUE_ADD; + * - 整段文本交给系统引擎(系统引擎自己按标点断句,比应用层切碎更自然); + * - {@code onDone} 推进游标;{@code onError} 跳过当前段继续(单段失败不该中断整章朗读)。 + * + * 与本设备相关的失败路径:设备可能没有安装任何 TTS 引擎(本机测试设备即是如此), + * 此时 {@code onInit} 返回 ERROR,引擎上报 fatal 错误,由上层给出可操作提示。 + */ +final class SystemTtsEngine implements TtsEngine { + + private static final String TAG = "TV-tts"; + private static final String UTTERANCE_PREFIX = "webhtv-tts-"; + + private final Context context; + private final List voices = new ArrayList<>(); + private final AtomicBoolean speaking = new AtomicBoolean(false); + private final AtomicBoolean paused = new AtomicBoolean(false); + + private TextToSpeech textToSpeech; + private TtsEngine.Listener listener; + private List chunks = Collections.emptyList(); + private int current = -1; + private boolean released; + private boolean ready; + private float rate = 1f; + private float pitch = 1f; + private String voiceId = ""; + private boolean lastUtteranceQueued; + /** 队列里已经全部入队,只等最后一段 onDone 触发队列完成。 */ + private boolean queueExhausted; + + SystemTtsEngine(Context context) { + this.context = context; + } + + @Override + public String id() { + return TtsEngines.SOURCE_SYSTEM; + } + + @Override + public boolean available() { + return TtsEngines.systemAvailable(context); + } + + @Override + public String detail() { + return available() ? "设备系统语音" : "设备未安装系统 TTS 引擎"; + } + + @Override + public List voices() { + return new ArrayList<>(voices); + } + + @Override + public void prepare(TtsOptions options, Listener listener) { + this.listener = listener; + if (released) return; + if (!available()) { + listener.onTtsError(-1, "设备未安装系统 TTS 引擎,请在系统设置中安装语音包或改用在线音源", true); + return; + } + applyOptions(options); + // 引擎初始化是异步的;重复 prepare(换音色/改语速)时复用已建好的实例 + if (textToSpeech == null) { + textToSpeech = new TextToSpeech(context.getApplicationContext(), status -> { + if (status != TextToSpeech.SUCCESS) { + SpiderDebug.log(TAG, "system tts init failed status=%d", status); + if (listener != null) listener.onTtsError(-1, "系统 TTS 初始化失败", true); + return; + } + ready = true; + configure(); + collectVoices(); + if (listener != null) listener.onTtsVoices(voices()); + }); + } else if (ready) { + configure(); + } + } + + private void applyOptions(TtsOptions options) { + if (options == null) return; + rate = options.rate; + pitch = options.pitch; + voiceId = options.voice; + } + + private void configure() { + TextToSpeech tts = textToSpeech; + if (tts == null) return; + try { + tts.setSpeechRate(rate); + tts.setPitch(pitch); + if (voiceId != null && !voiceId.isEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Voice target = findVoice(voiceId); + if (target != null) tts.setVoice(target); + } else { + tts.setLanguage(Locale.CHINA); + } + tts.setOnUtteranceProgressListener(new Progress()); + } catch (Throwable e) { + SpiderDebug.log(TAG, "system tts configure failed %s", e.getMessage()); + } + } + + private Voice findVoice(String id) { + TextToSpeech tts = textToSpeech; + if (tts == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null; + try { + Set all = tts.getVoices(); + if (all == null) return null; + for (Voice v : all) if (id.equals(v.getName())) return v; + } catch (Throwable e) { + SpiderDebug.log(TAG, "findVoice failed %s", e.getMessage()); + } + return null; + } + + private void collectVoices() { + voices.clear(); + TextToSpeech tts = textToSpeech; + if (tts == null) return; + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Set all = tts.getVoices(); + if (all != null) { + for (Voice v : all) { + String lang = v.getLocale() == null ? "" : v.getLocale().getLanguage(); + if (!lang.startsWith("zh")) continue; + if (v.isNetworkConnectionRequired() && v.getFeatures() != null + && v.getFeatures().contains(TextToSpeech.Engine.KEY_FEATURE_NOT_INSTALLED)) { + continue; + } + voices.add(new TtsVoice(v.getName(), v.getName(), v.getLocale().toString())); + } + } + } + if (voices.isEmpty()) { + for (android.speech.tts.TextToSpeech.EngineInfo info : tts.getEngines()) { + voices.add(new TtsVoice(info.name, info.label, "")); + } + } + } catch (Throwable e) { + SpiderDebug.log(TAG, "collectVoices failed %s", e.getMessage()); + } + voices.sort((a, b) -> a.name.compareToIgnoreCase(b.name)); + } + + @Override + public void speak(List list, int fromIndex) { + if (textToSpeech == null || !ready) { + // 初始化尚未完成:记下请求,初始化回调里不会自动重放,因此明确报错让 UI 提示重试 + if (listener != null) { + listener.onTtsError(fromIndex, available() ? "系统语音正在初始化,请稍候重试" : "设备未安装系统 TTS 引擎", !available()); + } + return; + } + chunks = list == null ? Collections.emptyList() : list; + queueExhausted = false; + lastUtteranceQueued = false; + paused.set(false); + speaking.set(true); + current = Math.max(0, Math.min(fromIndex, chunks.size() - 1)); + try { + textToSpeech.stop(); + } catch (Throwable ignore) { + } + boolean first = true; + for (int i = current; i < chunks.size(); i++) { + String text = chunks.get(i).text; + int mode = first ? TextToSpeech.QUEUE_FLUSH : TextToSpeech.QUEUE_ADD; + int result = speakChunk(i, text, mode); + if (result == TextToSpeech.ERROR) { + SpiderDebug.log(TAG, "system tts speak error index=%d", i); + if (first && listener != null) { + listener.onTtsError(i, "系统语音播报失败", false); + } + continue; + } + first = false; + } + if (first) { + // 一个片段都没入队 + speaking.set(false); + if (listener != null) listener.onTtsQueueDone(); + return; + } + queueExhausted = true; + // 立即上报起始进度,UI 不必等引擎回调 + if (listener != null) listener.onTtsStart(current, chunks.get(current).text); + } + + private int speakChunk(int index, String text, int mode) { + TextToSpeech tts = textToSpeech; + if (tts == null) return TextToSpeech.ERROR; + Bundle params = new Bundle(); + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + return tts.speak(text, mode, params, UTTERANCE_PREFIX + index); + } + java.util.HashMap legacy = new java.util.HashMap<>(); + legacy.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, UTTERANCE_PREFIX + index); + return tts.speak(text, mode, legacy); + } catch (Throwable e) { + SpiderDebug.log(TAG, "speakChunk failed index=%d %s", index, e.getMessage()); + return TextToSpeech.ERROR; + } + } + + @Override + public void pause() { + if (textToSpeech == null || !speaking.get()) return; + paused.set(true); + try { + textToSpeech.stop(); + } catch (Throwable ignore) { + } + } + + @Override + public void resume() { + if (textToSpeech == null || !speaking.get()) return; + paused.set(false); + // 系统引擎的 stop() 会清空队列,恢复时从当前片段重新入队(复读当前段,语义与 legado 一致) + speak(chunks, Math.max(0, current)); + } + + @Override + public void stop() { + speaking.set(false); + paused.set(false); + queueExhausted = false; + try { + if (textToSpeech != null) textToSpeech.stop(); + } catch (Throwable ignore) { + } + } + + @Override + public void setRate(float value) { + rate = value; + try { + if (textToSpeech != null && ready) textToSpeech.setSpeechRate(value); + } catch (Throwable ignore) { + } + } + + @Override + public void setPitch(float value) { + pitch = value; + try { + if (textToSpeech != null && ready) textToSpeech.setPitch(value); + } catch (Throwable ignore) { + } + } + + @Override + public void setVoice(String voice) { + voiceId = voice == null ? "" : voice; + configure(); + } + + @Override + public void release() { + released = true; + speaking.set(false); + try { + if (textToSpeech != null) { + textToSpeech.stop(); + textToSpeech.shutdown(); + } + } catch (Throwable ignore) { + } + textToSpeech = null; + ready = false; + } + + private final class Progress extends UtteranceProgressListener { + + @Override + public void onStart(String utteranceId) { + int index = indexOf(utteranceId); + if (index < 0 || index >= chunks.size()) return; + current = index; + if (listener != null) listener.onTtsStart(index, chunks.get(index).text); + } + + @Override + public void onDone(String utteranceId) { + int index = indexOf(utteranceId); + if (index < 0) return; + if (listener != null) listener.onTtsChunkDone(index); + if (queueExhausted && index >= chunks.size() - 1) { + speaking.set(false); + if (listener != null) listener.onTtsQueueDone(); + } + } + + @Override + public void onError(String utteranceId, int errorCode) { + handleError(utteranceId, "errorCode=" + errorCode); + } + + @SuppressWarnings("deprecation") + @Override + public void onError(String utteranceId) { + handleError(utteranceId, "engine error"); + } + + private void handleError(String utteranceId, String message) { + int index = indexOf(utteranceId); + // 单段失败只跳过该段,不中断整章(对齐 legado 的 nextParagraph 行为) + if (listener != null) listener.onTtsError(index, "系统语音播报失败(" + message + ")", false); + if (queueExhausted && index >= 0 && index >= chunks.size() - 1) { + speaking.set(false); + if (listener != null) listener.onTtsQueueDone(); + } + } + + private int indexOf(String utteranceId) { + if (utteranceId == null || !utteranceId.startsWith(UTTERANCE_PREFIX)) return -1; + try { + return Integer.parseInt(utteranceId.substring(UTTERANCE_PREFIX.length())); + } catch (NumberFormatException e) { + return -1; + } + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsAudioCache.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsAudioCache.java new file mode 100644 index 0000000000..d2a1ca2eca --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/TtsAudioCache.java @@ -0,0 +1,139 @@ +package com.fongmi.android.tv.tts; + +import com.github.catvod.crawler.SpiderDebug; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * 在线朗读音频缓存(对齐 legado HttpReadAloudService 的「按段落 MD5 落盘」思路)。 + * + * 与 legado 的差异:不引入 media3 的 {@code SimpleCache} / {@code DownloadRequest}, + * 因为这里每次只需复用一小段 mp3,直接用应用缓存目录 + 按最后修改时间淘汰的 + * 容量上限即可,避免为朗读再加一个数据库与索引。 + */ +public final class TtsAudioCache { + + private static final String TAG = "TV-tts"; + private static final long MAX_BYTES = 64L * 1024 * 1024; + private static final long MIN_KEEP_BYTES = 4L * 1024 * 1024; + private static final String EXTENSION = ".mp3"; + + private final File dir; + + public TtsAudioCache(File root, String engineId) { + this.dir = new File(root, sanitize(engineId)); + if (!dir.exists()) dir.mkdirs(); + } + + public File dir() { + return dir; + } + + /** 缓存文件(不保证存在)。 */ + public File file(String engineId, String text) { + return new File(dir, key(engineId, text) + EXTENSION); + } + + public boolean has(File file) { + return file != null && file.isFile() && file.length() > 0; + } + + /** 把输入流写入缓存文件(先写临时文件再原子改名,避免半截文件被当成命中)。 */ + public boolean store(File target, InputStream in) { + if (target == null || in == null) return false; + File tmp = new File(target.getAbsolutePath() + ".part"); + try (InputStream stream = in; OutputStream out = new FileOutputStream(tmp)) { + byte[] buf = new byte[8192]; + long total = 0; + int n; + while ((n = stream.read(buf)) > 0) { + total += n; + out.write(buf, 0, n); + // 单段音频超过 8MB 视为异常响应,避免坏引擎把缓存撑爆 + if (total > 8L * 1024 * 1024) throw new IllegalStateException("audio too large"); + } + out.flush(); + if (total <= 0) throw new IllegalStateException("empty audio"); + } catch (Throwable e) { + SpiderDebug.log(TAG, "store failed %s: %s", target.getName(), e.getMessage()); + //noinspection ResultOfMethodCallIgnored + tmp.delete(); + return false; + } + //noinspection ResultOfMethodCallIgnored + if (target.exists()) target.delete(); + if (!tmp.renameTo(target)) { + //noinspection ResultOfMethodCallIgnored + tmp.delete(); + return false; + } + evictIfNeeded(); + return true; + } + + private void evictIfNeeded() { + File[] files = dir.listFiles(); + if (files == null || files.length < 8) return; + long total = 0; + List audio = new ArrayList<>(files.length); + for (File f : files) { + if (!f.isFile()) continue; + if (!f.getName().endsWith(EXTENSION)) { + //noinspection ResultOfMethodCallIgnored + f.delete(); + continue; + } + total += f.length(); + audio.add(f); + } + if (total <= MAX_BYTES) return; + audio.sort((a, b) -> Long.compare(a.lastModified(), b.lastModified())); + long target = Math.max(MIN_KEEP_BYTES, MAX_BYTES / 2); + for (File f : audio) { + if (total <= target) break; + long len = f.length(); + if (f.delete()) total -= len; + } + SpiderDebug.log(TAG, "evict cache remaining=%d bytes", total); + } + + /** 清空缓存(用户切引擎/清理时调用)。 */ + public void clear() { + File[] files = dir.listFiles(); + if (files == null) return; + for (File f : files) { + //noinspection ResultOfMethodCallIgnored + f.delete(); + } + } + + /** 缓存键:引擎 id + 文本(文本包含语速/音色造成的差异,因此由调用方拼进 text)。 */ + public static String key(String engineId, String text) { + try { + MessageDigest digest = MessageDigest.getInstance("MD5"); + byte[] bytes = digest.digest(((engineId == null ? "" : engineId) + "\u0000" + (text == null ? "" : text)).getBytes("UTF-8")); + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) sb.append(String.format(Locale.US, "%02x", b)); + return sb.toString(); + } catch (Throwable e) { + return Integer.toHexString((engineId + text).hashCode()); + } + } + + private static String sanitize(String value) { + if (value == null || value.isEmpty()) return "default"; + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + sb.append(Character.isLetterOrDigit(c) || c == '_' || c == '-' ? c : '_'); + } + return sb.toString(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsEngine.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsEngine.java new file mode 100644 index 0000000000..85a36c8575 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/TtsEngine.java @@ -0,0 +1,63 @@ +package com.fongmi.android.tv.tts; + +import java.util.List; + +/** + * 朗读引擎抽象。 + * + * 引擎自己持有「朗读片段队列 + 游标」,逐片段回调进度(对齐 legado 让朗读服务 + * 持有 contentList/nowSpeak 的做法):系统朗读靠 TTS 引擎的 QUEUE_ADD 排队, + * 在线朗读靠落盘缓存 + 预取排队,上层只关心片段下标。 + */ +public interface TtsEngine { + + /** 引擎事件回调(可能来自主线程或工作线程)。 */ + interface Listener { + /** 开始朗读某个片段。 */ + void onTtsStart(int index, String text); + + /** 某个片段朗读完成。 */ + void onTtsChunkDone(int index); + + /** 整个队列读完(章节读完)。 */ + void onTtsQueueDone(); + + /** 出错;fatal=true 表示引擎已不可用,需要上层停止朗读。 */ + void onTtsError(int index, String message, boolean fatal); + + /** 音色列表异步就绪(系统引擎初始化完成后才有)。 */ + void onTtsVoices(List voices); + } + + String id(); + + /** 引擎在当前设备/配置下是否可用(不含网络可达性,网络失败在朗读时上报)。 */ + boolean available(); + + /** 不可用的原因或补充说明,用于 UI 提示。 */ + String detail(); + + /** 可用音色;可能为空。 */ + List voices(); + + /** 初始化并准备朗读(系统引擎是异步初始化)。 */ + void prepare(TtsOptions options, Listener listener); + + /** 从 fromIndex 开始朗读整个队列。 */ + void speak(List chunks, int fromIndex); + + void pause(); + + void resume(); + + void stop(); + + void setRate(float rate); + + void setPitch(float pitch); + + void setVoice(String voiceId); + + /** 释放资源,之后不可再用。 */ + void release(); +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsEngineConfig.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsEngineConfig.java new file mode 100644 index 0000000000..bc435b4c45 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/TtsEngineConfig.java @@ -0,0 +1,58 @@ +package com.fongmi.android.tv.tts; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * 自定义在线朗读引擎配置(与 legado 在线朗读引擎字段兼容的最小子集)。 + * + * 只保留真正被使用到的字段:名称、URL 规则、Content-Type、Header。 + * legado 的 {@code loginUrl} / {@code loginUi} / {@code loginCheckJs} / {@code jsLib} + * 需要规则引擎与 JS 运行时支撑,本实现不引入(见任务文档 §3 的取舍决策)。 + */ +public final class TtsEngineConfig { + + public String name = ""; + public String url = ""; + public String contentType = "audio/mpeg"; + public String header = ""; + + public static TtsEngineConfig fromJson(String json) { + if (json == null) return null; + try { + JsonObject o = JsonParser.parseString(json.trim()).getAsJsonObject(); + TtsEngineConfig config = new TtsEngineConfig(); + config.name = optString(o, "name"); + config.url = optString(o, "url"); + config.contentType = optString(o, "contentType"); + config.header = optString(o, "header"); + if (config.contentType.isEmpty()) config.contentType = "audio/mpeg"; + return config.url.isEmpty() ? null : config; + } catch (Throwable e) { + return null; + } + } + + private static String optString(JsonObject o, String key) { + if (o == null || !o.has(key) || o.get(key).isJsonNull()) return ""; + return o.get(key).getAsString(); + } + + public String toJson() { + JsonObject o = new JsonObject(); + o.addProperty("name", name == null ? "" : name); + o.addProperty("url", url == null ? "" : url); + o.addProperty("contentType", contentType == null ? "" : contentType); + o.addProperty("header", header == null ? "" : header); + return new Gson().toJson(o); + } + + public String displayName() { + if (name != null && !name.trim().isEmpty()) return name.trim(); + String u = url == null ? "" : url.trim(); + int cut = u.indexOf(','); + if (cut > 0) u = u.substring(0, cut); + return u.length() > 48 ? u.substring(0, 48) + "…" : u; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsEngines.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsEngines.java new file mode 100644 index 0000000000..b2b87f73e6 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/TtsEngines.java @@ -0,0 +1,152 @@ +package com.fongmi.android.tv.tts; + +import android.content.Context; +import android.speech.tts.TextToSpeech; + +import com.fongmi.android.tv.App; +import com.github.catvod.crawler.SpiderDebug; + +import java.io.File; + +/** + * 朗读引擎注册表:构造内置引擎、保存/读取用户配置的自定义在线引擎。 + * + * 内置音源(均不需要申请 Key): + * - {@code system}:设备自带 TTS 引擎(Android TextToSpeech),离线可用、质量取决于系统语音包; + * - {@code edge}:微软 Edge 大声朗读协议(Neural 音色,质量最好,依赖网络); + * - {@code baidu}:百度翻译发音接口(轻量、低延迟,依赖网络)。 + * + * 另有用户自定义在线引擎(legado 规则子集),配置持久化在 SharedPreferences。 + */ +public final class TtsEngines { + + private static final String TAG = "TV-tts"; + public static final String SOURCE_SYSTEM = "system"; + public static final String SOURCE_EDGE = "edge"; + public static final String SOURCE_BAIDU = "baidu"; + public static final String SOURCE_CUSTOM = "custom"; + + private static final String PREF = "reader_tts"; + private static final String KEY_CUSTOM = "custom_engine"; + + private TtsEngines() { + } + + public static File cacheRoot() { + File dir = new File(App.get().getCacheDir(), "tts"); + if (!dir.exists()) dir.mkdirs(); + return dir; + } + + public static TtsEngine create(Context context, String source, TtsEngineConfig custom) { + String id = source == null ? SOURCE_SYSTEM : source.trim(); + switch (id) { + case SOURCE_EDGE: + return new EdgeTtsEngine(); + case SOURCE_BAIDU: + return new BaiduTtsEngine(); + case SOURCE_CUSTOM: + return new HttpTtsEngine(custom); + case SOURCE_SYSTEM: + default: + return new SystemTtsEngine(context); + } + } + + /** 是否有系统 TTS 引擎可用(同步探测,供 UI 提前给出提示)。 */ + public static boolean systemAvailable(Context context) { + try { + return !context.getPackageManager() + .queryIntentServices(new android.content.Intent(TextToSpeech.Engine.INTENT_ACTION_TTS_SERVICE), 0) + .isEmpty(); + } catch (Throwable e) { + SpiderDebug.log(TAG, "systemAvailable failed %s", e.getMessage()); + return false; + } + } + + /** 内置音源的静态音色表(系统与百度音色需运行时探测,返回空表)。 */ + public static java.util.List staticVoices(String source) { + if (SOURCE_EDGE.equals(source)) { + java.util.List list = new java.util.ArrayList<>(); + for (String[] voice : EdgeTtsEngine.VOICES) { + list.add(new TtsVoice(voice[0], voice[1], "zh-CN")); + } + return list; + } + return java.util.Collections.emptyList(); + } + + /** + * 探测系统引擎的音色列表(异步)。 + * + * TextToSpeech 的音色必须在初始化完成后才能读取,因此这里建一个临时实例, + * 拿到音色后立即释放。仅在设备没有系统引擎时同步回调空表。 + * + * 注意:临时实例与正在朗读的实例并存时,部分设备会互相干扰, + * 因此调用方(朗读面板)只在用户切到系统音源时探测一次。 + */ + public static void probeSystemVoices(Context context, java.util.function.Consumer> callback) { + if (callback == null) return; + if (!systemAvailable(context)) { + callback.accept(java.util.Collections.emptyList()); + return; + } + final SystemTtsEngine engine = new SystemTtsEngine(context.getApplicationContext()); + final java.util.concurrent.atomic.AtomicBoolean done = new java.util.concurrent.atomic.AtomicBoolean(); + final android.os.Handler handler = new android.os.Handler(android.os.Looper.getMainLooper()); + Runnable timeout = () -> finishProbe(done, engine, callback, java.util.Collections.emptyList()); + engine.prepare(new TtsOptions(1f, 1f, ""), new TtsEngine.Listener() { + @Override + public void onTtsStart(int index, String text) { + } + + @Override + public void onTtsChunkDone(int index) { + } + + @Override + public void onTtsQueueDone() { + } + + @Override + public void onTtsError(int index, String message, boolean fatal) { + SpiderDebug.log(TAG, "probeSystemVoices error %s", message); + handler.removeCallbacks(timeout); + finishProbe(done, engine, callback, java.util.Collections.emptyList()); + } + + @Override + public void onTtsVoices(java.util.List voices) { + handler.removeCallbacks(timeout); + finishProbe(done, engine, callback, voices); + } + }); + handler.postDelayed(timeout, 8000L); + } + + private static void finishProbe(java.util.concurrent.atomic.AtomicBoolean done, SystemTtsEngine engine, + java.util.function.Consumer> callback, + java.util.List voices) { + if (!done.compareAndSet(false, true)) return; + try { + callback.accept(voices == null ? java.util.Collections.emptyList() : voices); + } finally { + try { + engine.release(); + } catch (Throwable ignore) { + } + } + } + + public static TtsEngineConfig loadCustomConfig() { + String json = App.get().getSharedPreferences(PREF, Context.MODE_PRIVATE).getString(KEY_CUSTOM, ""); + if (json == null || json.isEmpty()) return null; + return TtsEngineConfig.fromJson(json); + } + + public static void saveCustomConfig(TtsEngineConfig config) { + App.get().getSharedPreferences(PREF, Context.MODE_PRIVATE) + .edit().putString(KEY_CUSTOM, config == null ? "" : config.toJson()).apply(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsHttpRule.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsHttpRule.java new file mode 100644 index 0000000000..dafb03d644 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/TtsHttpRule.java @@ -0,0 +1,331 @@ +package com.fongmi.android.tv.tts; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +/** + * 在线朗读引擎 URL 规则(legado 兼容子集)。 + * + * 支持 legado 在线朗读规则的常用形态: + *

+ * https://host/path?text={{java.encodeURI(speakText)}}&spd={{speakSpeed}}
+ * http://tts.baidu.com/text2audio,{
+ *     "method": "POST",
+ *     "body": "tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{(speakSpeed + 5) / 10 + 4}}",
+ *     "headers": {"User-Agent": "..."}
+ * }
+ * 
+ * + * 支持的模板表达式: + * - 变量:{@code speakText}、{@code speakSpeed} + * - 函数:{@code java.encodeURI()}(等价 JS encodeURI,整体转义后保留保留字符) + * - 包装:{@code String()} + * - 算术:{@code + - * / ( )} 与数字常量 + * + * 不支持(构造时即判定不可用,避免运行期静默出错):{@code @js:} 规则、 + * {@code loginUrl} / {@code loginCheckJs} / {@code jsLib}。 + */ +public final class TtsHttpRule { + + public static final String METHOD_GET = "GET"; + public static final String METHOD_POST = "POST"; + + private final String raw; + private final String url; + private final String method; + private final String body; + private final Map headers; + private final String contentType; + private final String unsupported; + + private TtsHttpRule(String raw, String url, String method, String body, + Map headers, String contentType, String unsupported) { + this.raw = raw == null ? "" : raw; + this.url = url == null ? "" : url; + this.method = method; + this.body = body; + this.headers = headers; + this.contentType = contentType; + this.unsupported = unsupported; + } + + /** 解析引擎规则;参数非法时构造出的对象 {@link #isUsable()} 为 false。 */ + public static TtsHttpRule parse(String rule, String contentType) { + String text = rule == null ? "" : rule.trim(); + if (text.isEmpty()) return unusable(rule, contentType, "引擎规则为空"); + if (text.startsWith("@js:")) return unusable(rule, contentType, "暂不支持 @js: 规则引擎"); + String url = text; + String method = METHOD_GET; + String body = null; + Map headers = new LinkedHashMap<>(); + int split = indexOfOptions(text); + if (split > 0) { + url = text.substring(0, split).trim(); + JsonObject options = parseObject(text.substring(split + 1)); + if (options == null) return unusable(rule, contentType, "规则 JSON 解析失败"); + if (options.has("method") && !options.get("method").isJsonNull()) { + method = options.get("method").getAsString().trim().toUpperCase(Locale.ROOT); + } + if (options.has("body") && !options.get("body").isJsonNull()) { + JsonObject bodyObject = options.get("body").isJsonObject() ? options.getAsJsonObject("body") : null; + body = bodyObject != null ? new Gson().toJson(bodyObject) : options.get("body").getAsString(); + } + if (options.has("headers") && options.get("headers").isJsonObject()) { + for (Map.Entry e : options.getAsJsonObject("headers").entrySet()) { + if (e.getValue() == null || e.getValue().isJsonNull()) continue; + headers.put(e.getKey(), e.getValue().getAsString()); + } + } + } + if (url.isEmpty()) return unusable(rule, contentType, "引擎地址为空"); + if (METHOD_POST.equals(method) && (body == null || body.isEmpty())) { + return unusable(rule, contentType, "POST 规则缺少 body"); + } + return new TtsHttpRule(rule, url, method, body, headers, contentType, null); + } + + /** + * 找到 URL 与 JSON 选项的分界点(legado 的 {@code url,{...}} 形式)。 + * 只有当 URL 部分不含 {@code {}} 且逗号后紧跟 JSON 对象时才算选项分界, + * 否则把整串当作 URL(URL 自身可能带逗号,如 gettts 的查询串)。 + */ + private static int indexOfOptions(String text) { + // 从右往左找:URL 模板里也可能出现逗号({java.encodeURI(...)}), + // 只有「逗号后面的整段能解析成 JSON 对象」才是选项分界。 + int comma = text.lastIndexOf(','); + while (comma >= 0) { + String rest = text.substring(comma + 1).trim(); + if (rest.startsWith("{") && parseObject(rest) != null) return comma; + comma = text.lastIndexOf(',', comma - 1); + } + return -1; + } + + /** 解析 JSON 对象;失败返回 null(不抛出,交给调用方给出可读原因)。 */ + static JsonObject parseObject(String json) { + if (json == null) return null; + try { + com.google.gson.JsonElement element = JsonParser.parseString(json.trim()); + return element != null && element.isJsonObject() ? element.getAsJsonObject() : null; + } catch (Throwable e) { + return null; + } + } + + private static TtsHttpRule unusable(String rule, String contentType, String reason) { + return new TtsHttpRule(rule, "", METHOD_GET, null, new LinkedHashMap<>(), contentType, reason); + } + + public boolean isUsable() { + return unsupported == null; + } + + public String unsupportedReason() { + return unsupported; + } + + public String raw() { + return raw; + } + + public String contentType() { + return contentType; + } + + public String method() { + return method; + } + + public Map headers() { + return headers; + } + + public String url(String speakText, int speakSpeed) { + return render(url, speakText, speakSpeed); + } + + public String body(String speakText, int speakSpeed) { + return body == null ? null : render(body, speakText, speakSpeed); + } + + /** 渲染模板:把 {{表达式}} 求值后替换。 */ + public static String render(String template, String speakText, int speakSpeed) { + if (template == null) return null; + StringBuilder out = new StringBuilder(template.length() + 32); + int i = 0; + while (i < template.length()) { + int start = template.indexOf("{{", i); + if (start < 0) { + out.append(template, i, template.length()); + break; + } + int end = template.indexOf("}}", start + 2); + if (end < 0) { + out.append(template, i, template.length()); + break; + } + out.append(template, i, start); + String expr = template.substring(start + 2, end); + out.append(evaluateExpression(expr, speakText, speakSpeed)); + i = end + 2; + } + return out.toString(); + } + + private static String evaluateExpression(String expr, String speakText, int speakSpeed) { + String e = expr == null ? "" : expr.trim(); + if (e.isEmpty()) return ""; + if (e.equals("speakText")) return speakText == null ? "" : speakText; + if (e.equals("speakSpeed")) return String.valueOf(speakSpeed); + if (e.startsWith("String(") && e.endsWith(")")) { + String inner = e.substring("String(".length(), e.length() - 1).trim(); + Double value = tryEvaluateNumber(inner, speakText, speakSpeed); + return value != null ? numberToString(value) : evaluateExpression(inner, speakText, speakSpeed); + } + if (e.startsWith("java.encodeURI(") && e.endsWith(")")) { + String inner = e.substring("java.encodeURI(".length(), e.length() - 1).trim(); + return encodeUri(evaluateExpression(inner, speakText, speakSpeed)); + } + Double value = tryEvaluateNumber(e, speakText, speakSpeed); + if (value != null) return numberToString(value); + return "{{" + e + "}}"; + } + + /** 简单递归下降算术求值;含未知标识符时返回 null(保持原文)。 */ + private static Double tryEvaluateNumber(String expr, String speakText, int speakSpeed) { + try { + ArithmeticParser parser = new ArithmeticParser(expr, speakSpeed); + double v = parser.parse(); + if (!parser.isConsumed()) return null; + return v; + } catch (RuntimeException e) { + return null; + } + } + + private static String numberToString(double value) { + if (value == Math.rint(value) && !Double.isInfinite(value) && Math.abs(value) < 1e15) { + return String.valueOf((long) value); + } + String s = String.format(Locale.US, "%.4f", value); + while (s.contains(".") && (s.endsWith("0") || s.endsWith("."))) s = s.substring(0, s.length() - 1); + return s; + } + + /** 等价 JS encodeURI:保留 URI 保留字与未保留字,其余按 UTF-8 转义为大写十六进制。 */ + public static String encodeUri(String value) { + if (value == null) return ""; + // URLEncoder 把空格编成 '+',而 JS encodeURI 是 %20:先对齐空格, + // 否则模板拼出的查询串在按 JS 语义解析的服务端会得到字面加号。 + String encoded = urlEncode(value).replace("+", "%20"); + StringBuilder sb = new StringBuilder(encoded.length()); + for (int i = 0; i < encoded.length(); i++) { + char c = encoded.charAt(i); + if (c == '%' && i + 2 < encoded.length()) { + sb.append('%').append(Character.toUpperCase(encoded.charAt(i + 1))) + .append(Character.toUpperCase(encoded.charAt(i + 2))); + i += 2; + } else { + sb.append(c); + } + } + // URLEncoder 把 JS 视为保留的字符也转义了,这里还原,避免百度等接口解析差异 + return sb.toString() + .replace("%2A", "*").replace("%2D", "-").replace("%2E", ".") + .replace("%5F", "_").replace("%21", "!").replace("%7E", "~") + .replace("%27", "'").replace("%28", "(").replace("%29", ")") + .replace("%3B", ";").replace("%2C", ",").replace("%2F", "/") + .replace("%3F", "?").replace("%3A", ":").replace("%40", "@") + .replace("%26", "&").replace("%3D", "=").replace("%2B", "+") + .replace("%24", "$").replace("%23", "#"); + } + + private static String urlEncode(String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return value; + } + } + + /** 只支持 speakSpeed 变量的整数算术,够用且能挡住任意表达式注入。 */ + private static final class ArithmeticParser { + private final String s; + private final int speakSpeed; + private int pos; + + ArithmeticParser(String s, int speakSpeed) { + this.s = s; + this.speakSpeed = speakSpeed; + } + + boolean isConsumed() { + skipSpace(); + return pos >= s.length(); + } + + double parse() { + double v = expression(); + return v; + } + + private double expression() { + double v = term(); + while (true) { + skipSpace(); + if (accept('+')) v += term(); + else if (accept('-')) v -= term(); + else return v; + } + } + + private double term() { + double v = factor(); + while (true) { + skipSpace(); + if (accept('*')) v *= factor(); + else if (accept('/')) v /= factor(); + else return v; + } + } + + private double factor() { + skipSpace(); + if (accept('(')) { + double v = expression(); + skipSpace(); + if (!accept(')')) throw new IllegalArgumentException("missing )"); + return v; + } + if (accept('-')) return -factor(); + if (accept('+')) return factor(); + if (pos < s.length() && s.startsWith("speakSpeed", pos)) { + pos += "speakSpeed".length(); + return speakSpeed; + } + int start = pos; + while (pos < s.length() && (Character.isDigit(s.charAt(pos)) || s.charAt(pos) == '.')) pos++; + if (start == pos) throw new IllegalArgumentException("unexpected token"); + return Double.parseDouble(s.substring(start, pos)); + } + + private void skipSpace() { + while (pos < s.length() && Character.isWhitespace(s.charAt(pos))) pos++; + } + + private boolean accept(char c) { + if (pos < s.length() && s.charAt(pos) == c) { + pos++; + return true; + } + return false; + } + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsOptions.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsOptions.java new file mode 100644 index 0000000000..50b9ca836e --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/TtsOptions.java @@ -0,0 +1,47 @@ +package com.fongmi.android.tv.tts; + +/** 朗读参数(来自阅读器面板,均由 JSON 传入)。 */ +public final class TtsOptions { + + public static final float RATE_MIN = 0.5f; + public static final float RATE_MAX = 2.0f; + public static final float PITCH_MIN = 0.5f; + public static final float PITCH_MAX = 1.5f; + + public final float rate; + public final float pitch; + public final String voice; + + public TtsOptions(float rate, float pitch, String voice) { + this.rate = clamp(rate <= 0f ? 1f : rate, RATE_MIN, RATE_MAX); + this.pitch = clamp(pitch <= 0f ? 1f : pitch, PITCH_MIN, PITCH_MAX); + this.voice = voice == null ? "" : voice; + } + + public TtsOptions withRate(float value) { + return new TtsOptions(value, pitch, voice); + } + + public TtsOptions withPitch(float value) { + return new TtsOptions(rate, value, voice); + } + + public TtsOptions withVoice(String value) { + return new TtsOptions(rate, pitch, value); + } + + private static float clamp(float v, float min, float max) { + return v < min ? min : (v > max ? max : v); + } + + /** + * 面板音调(0.5x–1.5x)→ SSML prosody pitch 的 Hz 偏移。 + * + * 仅微软 Edge 音源支持音调;百度发音接口没有音调参数, + * 因此朗读面板只在支持的音源上显示音调控件(见 reader.html 的 ttsRefreshSource)。 + */ + public static String pitchOffsetHz(float pitch) { + int offset = Math.round((clamp(pitch, PITCH_MIN, PITCH_MAX) - 1f) * 100f); + return (offset >= 0 ? "+" : "") + offset + "Hz"; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsTextSplitter.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsTextSplitter.java new file mode 100644 index 0000000000..49f0ac9711 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/TtsTextSplitter.java @@ -0,0 +1,140 @@ +package com.fongmi.android.tv.tts; + +import java.util.ArrayList; +import java.util.List; + +/** + * 朗读文本切分(纯逻辑,便于单测)。 + * + * 对齐 legado 的朗读分句策略: + * - 系统朗读按「段落」入队,交给 TTS 引擎处理长文本韵律; + * - 在线朗读按「分句」入队,因为每个分句是一次 HTTP/WS 请求, + * 分句越短首句越快出声、预取也越平滑。 + * + * 两种模式都丢弃纯标点/空白片段(legado 的 notReadAloudRegex 语义), + * 避免朗读引擎对「……」「——」之类片段报错或产生长时间静音。 + */ +public final class TtsTextSplitter { + + /** 单次在线合成的文本上限(URL 长度与首句时延的折中)。 */ + public static final int MAX_CHUNK = 160; + + private static final String SENTENCE_END = "。!?!?;;…\n"; + private static final String SOFT_BREAK = ",,、::"; + + private TtsTextSplitter() { + } + + /** 一个朗读片段:文本 + 它所属的段落下标(0 基)。 */ + public static final class Chunk { + public final String text; + public final int paragraph; + + public Chunk(String text, int paragraph) { + this.text = text; + this.paragraph = paragraph; + } + + @Override + public String toString() { + return paragraph + ":" + text; + } + } + + /** + * @param paragraphs 段落文本(来自阅读器正文) + * @param maxChars 单片段最大字数;<= 0 表示按段落(系统朗读) + */ + public static List split(List paragraphs, int maxChars) { + List out = new ArrayList<>(); + if (paragraphs == null) return out; + for (int i = 0; i < paragraphs.size(); i++) { + String paragraph = normalize(paragraphs.get(i)); + if (paragraph.isEmpty() || isSilent(paragraph)) continue; + if (maxChars <= 0) { + out.add(new Chunk(paragraph, i)); + continue; + } + for (String piece : splitParagraph(paragraph, maxChars)) { + if (piece.isEmpty() || isSilent(piece)) continue; + out.add(new Chunk(piece, i)); + } + } + return out; + } + + /** 规范化:去掉控制字符与多余空白,保留正文标点。 */ + public static String normalize(String text) { + if (text == null) return ""; + StringBuilder sb = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\r' || c == '\t' || c == '\u00a0' || c == '\u3000') { + sb.append(' '); + } else if (c < 0x20 && c != '\n') { + // 控制字符直接丢弃(朗读引擎遇到会报错) + } else { + sb.append(c); + } + } + return sb.toString().trim(); + } + + /** 是否没有可朗读内容(只有标点/空白/装饰符)。 */ + public static boolean isSilent(String text) { + if (text == null) return true; + for (int i = 0; i < text.length(); i++) { + if (isSpeakable(text.charAt(i))) return false; + } + return true; + } + + private static boolean isSpeakable(char c) { + // 中日韩统一表意文字在 Unicode 里就是 Letter,isLetterOrDigit 已覆盖; + // 这里只额外保留需要的边界判断,其余一律视为标点/装饰符。 + return Character.isLetterOrDigit(c); + } + + private static List splitParagraph(String paragraph, int maxChars) { + List sentences = new ArrayList<>(); + StringBuilder cur = new StringBuilder(); + for (int i = 0; i < paragraph.length(); i++) { + char c = paragraph.charAt(i); + cur.append(c); + if (SENTENCE_END.indexOf(c) >= 0) { + sentences.add(cur.toString().trim()); + cur.setLength(0); + } + } + if (cur.length() > 0) sentences.add(cur.toString().trim()); + List out = new ArrayList<>(); + for (String s : sentences) { + if (s.isEmpty()) continue; + if (s.length() <= maxChars) { + out.add(s); + } else { + out.addAll(hardSplit(s, maxChars)); + } + } + return out; + } + + /** 超长句:优先在逗号等软停顿处切,实在没有就按长度硬切。 */ + private static List hardSplit(String sentence, int maxChars) { + List out = new ArrayList<>(); + StringBuilder cur = new StringBuilder(); + for (int i = 0; i < sentence.length(); i++) { + char c = sentence.charAt(i); + cur.append(c); + boolean soft = SOFT_BREAK.indexOf(c) >= 0; + if (cur.length() >= maxChars || (soft && cur.length() >= maxChars / 2)) { + out.add(cur.toString().trim()); + cur.setLength(0); + } + } + if (cur.length() > 0) out.add(cur.toString().trim()); + List trimmed = new ArrayList<>(out.size()); + for (String s : out) if (!s.isEmpty()) trimmed.add(s); + return trimmed; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/tts/TtsVoice.java b/app/src/main/java/com/fongmi/android/tv/tts/TtsVoice.java new file mode 100644 index 0000000000..58a5a31bca --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/tts/TtsVoice.java @@ -0,0 +1,20 @@ +package com.fongmi.android.tv.tts; + +/** 朗读音色。 */ +public final class TtsVoice { + + public final String id; + public final String name; + public final String lang; + + public TtsVoice(String id, String name, String lang) { + this.id = id == null ? "" : id; + this.name = name == null ? "" : name; + this.lang = lang == null ? "" : lang; + } + + @Override + public String toString() { + return id; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/AudioActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/AudioActivity.java index 5d854489b2..aaddea21ee 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/activity/AudioActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/AudioActivity.java @@ -1,7 +1,6 @@ package com.fongmi.android.tv.ui.activity; import android.app.Activity; -import android.app.AlertDialog; import android.content.Intent; import android.graphics.drawable.ColorDrawable; import android.os.Bundle; @@ -42,6 +41,7 @@ import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.audio.AudioHistory; import com.fongmi.android.tv.ui.audio.AudioMiniPlayer; import com.fongmi.android.tv.ui.audio.AudioPlaybackReconciliation; @@ -751,7 +751,7 @@ public class AudioActivity extends PlaybackActivity { } String[] labels = new String[options.size()]; for (int i = 0; i < options.size(); i++) labels[i] = options.get(i).getLabel(); - new AlertDialog.Builder(this) + new WebHtvAlertDialogBuilder(this) .setTitle("选择歌词") .setItems(labels, (dialog, which) -> applyManualLyric(request, options.get(which), title, subtitle, duration)) .show(); 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..cbcb9f5a8b 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,14 +1,27 @@ package com.fongmi.android.tv.ui.activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; import android.os.Bundle; +import android.view.WindowManager; + import androidx.appcompat.app.AlertDialog; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.ActivityCrashBinding; +import com.fongmi.android.tv.server.Server; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.utils.AppVersion; +import com.fongmi.android.tv.utils.CrashLogSession; +import com.fongmi.android.tv.utils.CrashLogText; import com.fongmi.android.tv.utils.CrashRestartMode; +import com.fongmi.android.tv.utils.Notify; +import com.fongmi.android.tv.utils.Task; +import com.github.catvod.utils.Util; import java.util.Objects; @@ -17,6 +30,8 @@ import cat.ereza.customactivityoncrash.CustomActivityOnCrash; public class CrashActivity extends BaseActivity { private ActivityCrashBinding mBinding; + private String logUrl; + private String localUrl; @Override protected boolean customWall() { @@ -31,6 +46,8 @@ public class CrashActivity extends BaseActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + // The address is meant to be read or copied from the screen; keep it visible. + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); } @Override @@ -41,6 +58,107 @@ public class CrashActivity extends BaseActivity { CrashRestartMode.arm(); restartApplication(); }); + mBinding.copyLogAddress.setOnClickListener(v -> copyLogAddress()); + mBinding.openLog.setOnClickListener(v -> openLog()); + mBinding.logAddress.setText(R.string.crash_log_preparing); + setLogActionsEnabled(false); + Task.execute(this::startLogService); + } + + /** The crash screen is a dead end; leaving it would keep the LAN log service alive unseen. */ + @Override + protected void onBackInvoked() { + } + + @Override + protected void onDestroy() { + if (isFinishing()) { + try { + Server.get().shutdown(); + } catch (Throwable ignored) { + } + } + super.onDestroy(); + } + + private void startLogService() { + try { + CrashLogSession.prepare(this); + } catch (Throwable ignored) { + // The address can still be offered; the page will report its own coverage. + } + boolean running = false; + try { + Server.get().start(); + running = Server.get().isRunning(); + } catch (Throwable ignored) { + } + if (!running) { + post(this::showLogFailure); + return; + } + String host; + try { + host = Util.getIp(); + } catch (Throwable ignored) { + host = ""; + } + String lan = Server.get().getAddress(false) + "/debug/logs"; + String local = Server.get().getAddress("/debug/logs"); + String lanHost = host; + post(() -> bindLogAddress(lanHost, lan, local)); + } + + private void bindLogAddress(String host, String lan, String local) { + localUrl = local; + if (host == null || host.isEmpty()) { + logUrl = local; + mBinding.logAddress.setText(getString(R.string.crash_log_no_lan, CrashLogText.wrapUrlForDisplay(local))); + } else { + logUrl = lan; + mBinding.logAddress.setText(CrashLogText.wrapUrlForDisplay(lan)); + } + mBinding.logHint.setText(R.string.crash_log_hint); + setLogActionsEnabled(true); + } + + private void showLogFailure() { + logUrl = null; + localUrl = null; + mBinding.logAddress.setText(R.string.crash_log_service_failed); + mBinding.logHint.setText(R.string.crash_log_unavailable); + setLogActionsEnabled(false); + } + + private void setLogActionsEnabled(boolean enabled) { + mBinding.copyLogAddress.setEnabled(enabled); + mBinding.openLog.setEnabled(enabled); + } + + private void copyLogAddress() { + if (logUrl == null) return; + ClipboardManager manager = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + if (manager == null) return; + manager.setPrimaryClip(ClipData.newPlainText(getString(R.string.crash_log_title), logUrl)); + Notify.show(R.string.crash_log_copied); + } + + private void openLog() { + if (localUrl == null) return; + try { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(localUrl))); + } catch (RuntimeException error) { + Notify.show(R.string.debug_log_no_browser); + } + } + + private void post(Runnable action) { + runOnUiThread(() -> { + try { + action.run(); + } catch (Throwable ignored) { + } + }); } private void restartApplication() { 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 index 8e79710d2a..176277eaaf 100644 --- 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 @@ -9,6 +9,7 @@ import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import android.view.View; +import android.view.ViewGroup; import android.widget.EditText; import android.widget.LinearLayout; @@ -19,7 +20,6 @@ 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; @@ -39,19 +39,19 @@ 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.theme.ThemeController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.FollowingAdapter; +import com.fongmi.android.tv.ui.custom.CustomWallView; 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 { @@ -62,7 +62,6 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda 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()) @@ -73,8 +72,6 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda 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 -> { @@ -108,6 +105,10 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda protected void onCreate(@Nullable Bundle savedInstanceState) { setTheme(R.style.Theme_App); super.onCreate(savedInstanceState); + // This page predates the shared BaseActivity, so it must opt into the same + // appearance contract by hand: resolve the persisted tokens before the first + // content view, then bind the tree once it exists and again after initView(). + ThemeController.applyFromPreferences(this); if (!FollowingSettings.isEnabled()) { Notify.show(R.string.following_enabled_hint); finish(); @@ -115,24 +116,33 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda } binding = ActivityFollowingBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); + ThemeController.bindTheme(binding.getRoot()); + addWallpaper(); focusIdentity = getIntent().getStringExtra(EXTRA_IDENTITY_KEY); FollowingNotifier.createChannel(); FollowingScheduler.ensurePeriodic(this); initView(); + ThemeController.bindTheme(binding.getRoot()); FollowingScheduler.enqueueDueNow(this); } + /** + * 与其它内容页保持一致:全局壁纸作为页面背景,壁纸视图插在内容根视图之下。 + * 页面根布局保持透明,顶栏与卡片用半透明面板承载文字,动效跟随全局壁纸设置。 + */ + private void addWallpaper() { + ViewGroup content = findViewById(android.R.id.content); + if (content == null || content.getChildCount() == 0) return; + content.addView(new CustomWallView(this, null), 0, + new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + } + 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.readAll.setOnClickListener(view -> markAllRead()); binding.filter.setOnClickListener(view -> { filterIndex = (filterIndex + 1) % FILTER_COUNT; load(); @@ -177,7 +187,6 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda 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(() -> { @@ -187,35 +196,18 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda } } - 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(); + private void markAllRead() { 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); + for (FollowingAdapter.Row row : adapter.getItems()) { + if (row.following.hasUpdate && !TextUtils.isEmpty(row.following.identityKey)) 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); + adapter.markReadLocally(keys); + FollowingPlaybackBridge.markReadAllAsync(keys, error -> { if (error != null) load(); else refreshUnreadSummary(); }); @@ -263,9 +255,8 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda 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); + for (Following item : items) success |= coordinator.checkNow(item.identityKey, true); boolean finalSuccess = success; App.post(() -> { if (binding == null) return; @@ -286,7 +277,7 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda 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) + new WebHtvAlertDialogBuilder(this) .setTitle(R.string.following_server_import) .setView(form) .setNegativeButton(R.string.dialog_negative, null) @@ -352,7 +343,7 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda if (candidate.currentEpisodes > 0) message.append(" · E").append(candidate.currentEpisodes); } if (candidates.size() > limit) message.append("\n… +").append(candidates.size() - limit); - new MaterialAlertDialogBuilder(this) + new WebHtvAlertDialogBuilder(this) .setTitle(R.string.following_server_preview_title) .setMessage(message) .setNegativeButton(R.string.dialog_negative, null) @@ -375,7 +366,6 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda @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)); @@ -395,7 +385,7 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda getString(R.string.following_change_source), getString(R.string.following_cancel) }; - new MaterialAlertDialogBuilder(this) + new WebHtvAlertDialogBuilder(this) .setTitle(item.vodName) .setMessage(message) .setNegativeButton(R.string.dialog_close, null) @@ -417,7 +407,7 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda App.post(() -> { if (isFinishing() || isDestroyed()) return; if (history != null) { - TmdbDetailActivity.startFromHistory(this, history); + VideoActivity.startFromFollowingHistory(this, history); return; } FollowingSource target = preferred(item, source); @@ -516,7 +506,8 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda @Override public void onRead(Following item) { - if (item != null) markReadNow(List.of(item.identityKey)); + if (item == null || TextUtils.isEmpty(item.identityKey)) return; + markReadNow(List.of(item.identityKey)); } @Override @@ -544,7 +535,7 @@ public class FollowingActivity extends AppCompatActivity implements FollowingAda @Override public void onDelete(Following item) { - new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_WebHTV_FollowingConfirmDialog) + new WebHtvAlertDialogBuilder(this, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.following_delete_title) .setMessage(R.string.following_delete_message) .setNegativeButton(R.string.dialog_negative, null) 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..c7bb9e3f5d 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 @@ -19,12 +19,12 @@ import com.fongmi.android.tv.bean.Vod; 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.theme.WebHtvAlertDialogBuilder; 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; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.Objects; import java.util.concurrent.atomic.AtomicInteger; @@ -166,7 +166,7 @@ public final class HistoryResumeCoordinator { messageParams.leftMargin = ResUtil.dp2px(16); content.addView(message, messageParams); - AlertDialog loading = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog loading = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setView(content) .setCancelable(false) .create(); @@ -177,7 +177,7 @@ public final class HistoryResumeCoordinator { } private static void showSearchFallback(Activity activity, History history, int targetCid) { - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setMessage(R.string.history_source_not_found) .setNegativeButton(R.string.dialog_negative, null) .setPositiveButton(R.string.history_source_open_search, (ignored, which) -> openSearch(activity, history.getCid(), HistoryResumePayload.encode(history), targetCid, history.getVodName())) 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..a95f44c7bc 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 @@ -21,6 +21,7 @@ import android.view.ViewConfiguration; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; +import androidx.lifecycle.Lifecycle; import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; @@ -41,6 +42,7 @@ 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.PlaybackOwnershipKeys; import com.fongmi.android.tv.player.PlaybackServiceReleasePolicy; import com.fongmi.android.tv.player.PlaybackTelemetry; import com.fongmi.android.tv.player.PlayerManager; @@ -56,7 +58,6 @@ 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; @@ -87,7 +88,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont private int render = -1; 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 = @@ -109,16 +109,6 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont return mService != null && mService.player() != null && !mService.player().isReleased(); } - private void bindAdAudioPrompt() { - if (!isServiceReady() || !isOwner()) return; - if (adSkipPromptPresenter == null) adSkipPromptPresenter = new AdSkipPromptPresenter(this); - player().bindAdAudioUi(adSkipPromptPresenter); - } - - private void unbindAdAudioPrompt() { - if (isServiceReady() && isOwner()) player().unbindAdAudioUi(); - } - protected View.OnClickListener guarded(Runnable action) { return v -> { if (isServiceReady()) action.run(); @@ -156,12 +146,25 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont finish(); } + /** + * 退出播放页时的确定性停播。 + * + *

归属令牌({@link #activePlaybackKey()})在一次会话里可能失配:换集、onNewIntent 换条目, + * 或 TMDB 富集改写 intent 的 id 之后 {@link #isOwner()} 会落到 false。旧实现此时退化成 + * {@code mController.stop()},而控制器是异步连上的、播放又是由服务侧直接起播的,于是留下两个 + * 漏播窗口:控制器还没连上时整次调用是空操作;播放器处于 IDLE(取流中退出)时会被会话里的 + * stop 直接忽略。两者都不撤销前台通知,用户看到的就是「退回详情页后还有声音、通知栏还显示播放中」。 + * + *

所以只要服务还在,退出就必须由服务自己停播:归属方关停服务,非归属方也让服务停播并撤掉 + * 前台通知,避免退出被迟到的会话状态吞掉。控制器只保留为「服务不可用」时的兜底。 + */ protected void stopPlayback() { - if (mService != null && isOwner()) { - mService.shutdown(); - } else if (mController != null) { - mController.stop(); + if (mService == null) { + if (mController != null) mController.stop(); + return; } + if (isOwner()) mService.shutdown(); + else mService.suspend(); } protected void updateNavigationKey() { @@ -228,10 +231,19 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont pinnedPlaybackKey = null; } + /** + * 本次播放会话是否由本页面拥有。 + * + *

比较必须走 {@link PlaybackOwnershipKeys#sameSession(String, String)} 而不是字符串相等: + * 自动连播时服务侧用「浏览下一集」的 {@code VE:|<线路>|<集>} 键起播,与本页面现算的 + * 记录键不是同一个字符串,但仍是同一次播放会话。旧实现在这里判假,导致回调里的 + * {@code onPlayerRebuild(...)} 整段跳过、Surface 永不重绑 —— 设备日志里那次「有声音没画面、 + * 重开才恢复」正是这条路径。 + */ protected boolean isOwner() { String key = activePlaybackKey(); PlayerManager manager = player(); - return key == null || (manager != null && key.equals(manager.getKey())); + return PlaybackOwnershipKeys.sameSession(key, manager == null ? null : manager.getKey()); } public final boolean pauseForOverlayPlayback() { @@ -356,6 +368,42 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont protected void onPlayerRebuilt() { } + /** + * 保证「当前引擎」此刻确实挂在 PlayerView 上,必要时补一次绑定。 + * + *

这是设备日志里那次「有声音没画面、重开才恢复」的收口点:自动连播由服务侧 + * {@code PlaybackService#startBrowse} 用 {@code VE:} 选集键起播,页面一度判不出归属, + * 于是既没有重建回调去重绑,也没有其它路径补绑;解码器与音频照常工作,视频帧却全部被丢弃。 + * + *

与 {@link #attachSurface(boolean)} 的区别:它不重设 render、不接 ASS/字幕会话,也不解绑 + * (引擎未就绪时直接返回),只把「当前引擎」补到视图上并重放当前快门策略;绑定已正确时立刻 + * 返回。且只在页面 RESUMED 时执行——后台的旧播放页同样会收到服务侧回调,在那里补绑会把引擎 + * 从用户正在看的页面上抢走。 + */ + private void ensureVideoSurfaceBound(String reason) { + if (mService == null || isFinishing() || isDestroyed()) return; + // 只有正处在 RESUMED 的页面可能是这条会话的显示方。后台/返回栈里的旧播放页同样会收到 + // 服务侧回调;在那里补绑会把同一个引擎从用户正在看的页面上抢走,结果还是黑屏。 + if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) return; + PlayerManager manager = player(); + if (manager == null || manager.isReleased()) return; + PlayerView view = getExoView(); + if (view == null) return; + Player currentPlayer = view.getPlayer(); + Player nextPlayer = manager.getPlayer(); + // 引擎尚未就绪时保持现状:这个兜底路径只负责「绑上当前引擎」,绝不在这里解绑。 + if (nextPlayer == null) return; + VideoSurfaceBinding.Result binding = VideoSurfaceBinding.resolve(currentPlayer, nextPlayer); + if (!binding.applied()) return; + if (binding.detachedFirst()) view.setPlayer(null); + view.setPlayer(binding.player()); + surfaceDiagnostics.rebind(reason, binding.detachedFirst(), view.getVideoSurfaceView()); + logSurfaceState("ensure bound reason=" + reason); + syncVideoSurfaceSize(null); + syncShutter(); + if (manager.isNativePlayer()) view.post(this::syncShutter); + } + /** * The rendered frame is revealed by clearing the shutter in the caller. The * loading spinner deliberately stays up until STATE_READY: a first frame does @@ -501,6 +549,13 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont protected void startPlayer(String key, Result result, boolean useParse, long timeout, MediaMetadata metadata, long startPositionMs) { + // 已经决定退出的播放页不能再起播。自动换源、换线路、取流结果这些链路最终都回到这里, + // 迟到的一次起播会在服务里正常播放并挂上前台通知,而页面已经退出,没有人再停它—— + // 用户看到的就是「退回详情页后还有声音、通知栏还显示播放中」。 + if (isPlaybackExiting() || isFinishing() || isDestroyed()) { + if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-flow", "startPlayer skipped exiting=%s finishing=%s destroyed=%s key=%s", isPlaybackExiting(), isFinishing(), isDestroyed(), key); + return; + } // 小说/漫画阅读器路由(播放内核前的最后一道拦截) // novel:// / pics:// / manga:// 是「阅读内容协议」而非播放地址。 // 正常情况下 ContentDispatcher 已在更早的汇聚点分流;这里兜底处理漏网的解析结果。 @@ -673,8 +728,20 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont logSurfaceState("attach after setRender target=" + targetRender); } surfaceDiagnostics.bind(getExoView(), player().getPlaybackTraceId()); - if (getExoView().getPlayer() == null) { - getExoView().setPlayer(player().getPlayer()); + // 切集换新媒体前 PlayerManager.clear() 会把 spec 置空,preparePlayer() 再重建引擎。 + // 此时 render 值通常不变,旧的 attachSurface 只按“PlayerView 是否为空”判断, + // 会把已释放的旧引擎继续留在 PlayerView 上,新引擎从未绑定 Surface。 + // 以引擎实例身份而不是“是否为空”判断,自动下一集的黑屏路径才能强制重绑。 + // + // 判定本身交给 VideoSurfaceBinding:口径是「PlayerView 上挂着的引擎实例是否就是当前引擎」, + // 与 Activity 是否自认归属无关。设备日志里的那次「有声音没画面」正因归属判假而漏绑。 + Player currentPlayer = getExoView().getPlayer(); + Player nextPlayer = player().getPlayer(); + VideoSurfaceBinding.Result binding = VideoSurfaceBinding.resolve(currentPlayer, nextPlayer); + if (binding.applied()) { + if (binding.detachedFirst()) getExoView().setPlayer(null); + getExoView().setPlayer(binding.player()); + surfaceDiagnostics.rebind("attach", binding.detachedFirst(), getExoView().getVideoSurfaceView()); logSurfaceState("attach after setPlayer"); syncVideoSurfaceSize(null); if (restoreExoShutter) syncShutter(); @@ -1012,6 +1079,9 @@ public abstract class PlaybackActivity extends BaseActivity implements MediaCont @Override public void onPlayerRebuild(Player player, boolean resetVideoSurface) { + // 归属必须与「当前引擎是否属于本页面」一致:AUTONEXT-02 之前这里判假的原因是 + // 服务侧用 VE: 选集键起播而页面拿记录键比对(见 PlaybackOwnershipKeys)。判假时 + // 整段被跳过,新引擎永远拿不到 Surface,画面全黑而声音正常。 if (isOwner()) { nativeOutputPending = player().shouldKeepVideoShutterClosed(); getSeekView().setProgressPlayer(player); @@ -1072,14 +1142,15 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { @Override public void onPlaybackStateChanged(int state) { + // 绑定视频 Surface 是所有权无关的必经动作:服务侧「浏览下一集」(VE: 选集键)起播时 + // isOwner() 为假,旧实现让下面的早退把 Surface 重绑一起吞掉,新引擎于是永远拿不到 + // Surface —— 解码器/音频正常、画面全黑,只有重开 App 才好。每次 BUFFERING/READY 都是 + // 新媒体起播的必经点,在这里补一次「当前引擎是否已挂在视图上」的校验。 + if (state == Player.STATE_BUFFERING || state == Player.STATE_READY) ensureVideoSurfaceBound("state=" + 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; - // Ownership is established after onServiceConnected/onResume have already run, so - // the earlier bind attempts were rejected by the isOwner() guard. Bind here too or - // the ad-audio runtime never gets a UI and stays deactivated for the whole session. - bindAdAudioPrompt(); syncShutter(); onStateChanged(state); } @@ -1203,7 +1274,6 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { mService.addPlayerCallback(mPlayerCallback); getSeekView().setProgressPlayer(player().getPlayer()); player().setLutAllowed(isLutAllowed()); - bindAdAudioPrompt(); syncKeepScreenOn(); player().setDanmakuForeground(true); publishRenderTarget(getExoView().getVideoSurfaceView()); @@ -1216,9 +1286,6 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { @Override public void onServiceDisconnected(ComponentName name) { if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-lifecycle", "service disconnected name=%s %s", name, lifecycleState()); - unbindAdAudioPrompt(); - if (adSkipPromptPresenter != null) adSkipPromptPresenter.close(); - adSkipPromptPresenter = null; releaseController(); getSeekView().setProgressPlayer(null); mService = null; @@ -1235,7 +1302,6 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-lifecycle", "activity resume %s", lifecycleState()); playbackExiting = false; setRedirect(false); - bindAdAudioPrompt(); applyExoOutputMode(); if (shouldReclaim()) { detachSurface(); @@ -1253,7 +1319,6 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { @Override protected void onStop() { - unbindAdAudioPrompt(); if (mService != null) { mService.setPlaybackForeground(false); if (isOwner()) player().setDanmakuForeground(false); @@ -1275,9 +1340,6 @@ public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) { surfaceDiagnostics.unbind(); detachAssSurface(); if (SpiderDebug.isEnabled()) SpiderDebug.log("playback-lifecycle", "activity destroy beforeRelease %s", lifecycleState()); - unbindAdAudioPrompt(); - if (adSkipPromptPresenter != null) adSkipPromptPresenter.close(); - adSkipPromptPresenter = null; RealtimeSubtitleController.get().unbind(getExoView()); restoreExoOutputMode(); 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..56457cd012 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 @@ -27,6 +27,7 @@ import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; +import android.view.ViewTreeObserver; import android.view.Window; import android.view.WindowManager; import android.widget.FrameLayout; @@ -112,6 +113,7 @@ 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.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.detail.DetailModeHost; import com.fongmi.android.tv.ui.detail.EnhancedDetailController; import com.fongmi.android.tv.ui.detail.FusionDetailController; @@ -145,6 +147,7 @@ import com.fongmi.android.tv.setting.MultiThreadProxySetting; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.setting.TmdbSitePolicy; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.title.MediaTitleLearningExample; import com.fongmi.android.tv.title.MediaTitleLearningStore; import com.fongmi.android.tv.title.MediaTitleParser; @@ -162,6 +165,7 @@ import com.fongmi.android.tv.ui.adapter.TmdbRailAdapter; import com.fongmi.android.tv.ui.adapter.TmdbVideoAdapter; import com.fongmi.android.tv.ui.controller.VodPlayerControlController; import com.fongmi.android.tv.ui.custom.CustomSeekView; +import com.fongmi.android.tv.ui.helper.TmdbDetailPrefetch; import com.fongmi.android.tv.ui.helper.TmdbVideoPlayback; import com.fongmi.android.tv.ui.custom.EpisodeTitlePopup; import com.fongmi.android.tv.ui.custom.PlayerGesture; @@ -180,6 +184,7 @@ import com.fongmi.android.tv.ui.dialog.ChoiceDialog; import com.fongmi.android.tv.ui.dialog.TmdbSearchDialog; import com.fongmi.android.tv.ui.dialog.TrackDialog; import com.fongmi.android.tv.ui.novel.NovelRouter; +import com.fongmi.android.tv.ui.web.CatWebActivity; import com.fongmi.android.tv.ui.helper.DetailThemeVisibility; import com.fongmi.android.tv.ui.helper.EpisodeRangePolicy; import com.fongmi.android.tv.ui.helper.EpisodeCardImagePolicy; @@ -219,21 +224,21 @@ import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.ui.utils.TmdbDetailLayoutUtils; import com.fongmi.android.tv.utils.TmdbDetailCache; +import com.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.fongmi.android.tv.utils.TmdbEpisodeSorter; import com.fongmi.android.tv.utils.TmdbImageSelector; import com.fongmi.android.tv.utils.TmdbImageSaver; import com.fongmi.android.tv.utils.Traffic; import com.fongmi.android.tv.utils.Util; +import com.fongmi.android.tv.utils.WebViewUtil; import com.fongmi.android.tv.utils.Clock; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.player.lut.LutPreset; import com.fongmi.android.tv.player.lut.LutStore; -import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.web.WebHomeInlineVodStore; import com.google.android.flexbox.FlexboxLayout; import com.google.android.material.button.MaterialButton; import com.google.android.material.card.MaterialCardView; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.bumptech.glide.Glide; import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.engine.GlideException; @@ -243,6 +248,7 @@ import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.request.target.Target; import com.bumptech.glide.request.transition.Transition; import com.github.catvod.crawler.SpiderDebug; +import com.google.common.util.concurrent.ListenableFuture; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -276,16 +282,20 @@ import java.util.regex.Pattern; public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog.Listener, Clock.Callback, PlayerGesture.Listener, SubtitlePlaybackSession.Host, TmdbDetailHost { private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss", Locale.getDefault()); - private static final int FOCUS_STROKE = 0xFFFFD166; - private static final int FOCUS_STROKE_DP = 3; private static final int CHIP_STROKE_DP = 1; private static final int CHIP_MAX_WIDTH_DP = 240; private static final int PHOTO_PRELOAD_RADIUS = 2; + /** 自动匹配消歧时同时最多在途的候选详情请求数。 */ + private static final int DISAMBIGUATION_CONCURRENCY = 3; private static final long BACKDROP_SLIDE_DELAY_MS = 10_000L; private static final long BACKDROP_SLIDE_RETRY_MS = 2_500L; private static final int SHORT_DRAMA_SCALE = 0; private static final int SHORT_DRAMA_FRAME_WIDTH = 9; private static final int SHORT_DRAMA_FRAME_HEIGHT = 16; + /** 卡片行获得焦点时,分区标题上方保留的留白(标题需完整可见)。 */ + private static final int CARD_ROW_TOP_MARGIN_DP = 12; + /** 卡片行获得焦点时,行底与可视区底部之间保留的留白。 */ + private static final int CARD_ROW_BOTTOM_MARGIN_DP = 16; private static final int INLINE_SIDE_CONTROL_MARGIN_DP = 4; private static final int INLINE_SIDE_CONTROL_FULLSCREEN_MARGIN_DP = 48; private static final long INLINE_CONTROLS_HIDE_DELAY_MS = TimeUnit.SECONDS.toMillis(10); @@ -298,6 +308,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private final TmdbService tmdbService = new TmdbService(); private final Task.Scope detailTasks = new Task.Scope(Task.executor()); + /** identity 已知时与站点详情并行取回 TMDB 详情;单槽、按 tmdbId+mediaType 匹配。 */ + private final TmdbDetailPrefetch detailPrefetch = new TmdbDetailPrefetch(Task.largeExecutor()); private final IntroSkipPlayback introSkipPlayback = new IntroSkipPlayback(); private androidx.appcompat.app.AlertDialog introSkipConfirmDialog; private boolean introSkipListenersReady; @@ -343,6 +355,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private ActivityTmdbDetailBinding binding; @androidx.annotation.Keep private ActivityTmdbDetailBinding mBinding; + private ViewTreeObserver.OnGlobalFocusChangeListener cardRowFocusMarginListener; + private final List cardRowOrder = new ArrayList<>(); + private final List cardRowTitleOrder = new ArrayList<>(); private TmdbDetailModeController modeController; private Vod vod; private String sourceVodName; @@ -384,6 +399,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private boolean useParse; private boolean inlineStarted; private boolean inlinePlaybackPending; + /** 置位 {@link #inlinePlaybackPending} 的请求代际;0 表示当前没有待定请求。 */ + private int inlinePlaybackPendingGeneration; private final Runnable inlineLoadingSpeedRefresh = new Runnable() { @Override public void run() { @@ -427,6 +444,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private boolean inlineKeySpeedChanging; private float inlineGestureSpeed = 1.0f; private boolean inlineStartPositionApplied; + /** 切集异步解析窗口期内为 false:此时播放器仍在旧集上,进度禁止写入已指向新集的 history。 */ + private boolean inlinePlaybackSettled = true; + /** 播放器当前媒体是否已就绪(STATE_READY)。stop 后到新集 READY 前,Exo getPosition() + * 仍返回旧集残留位置,这段加载期禁止把播放器读数写回 history。 */ + private boolean inlinePlayerMediaReady = true; private boolean inlineFirstReady; private boolean inlinePlayHealthRecorded; private String inlinePlayHealthKey = ""; @@ -851,7 +873,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. setupOverviewInteraction(); if (Util.isMobile()) binding.headerTitle.setText(""); else binding.headerTitle.setText(detailModeTitle()); - binding.headerTitle.setVisibility(Util.isMobile() || !isCinemaMode() ? View.VISIBLE : View.INVISIBLE); + binding.headerTitle.setVisibility(Util.isMobile() || !modeController.isCinemaStyle() ? View.VISIBLE : View.INVISIBLE); binding.title.setText(getNameText()); binding.subtitle.setText(""); binding.sourceValue.setText(getString(R.string.detail_source_current, getKeyText())); @@ -867,6 +889,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.headerBar.setVisibility(Util.isMobile() ? View.VISIBLE : View.GONE); updateDetailThemeButtonVisibility(); applyDetailTemplate(); + installCardRowFocusMargin(); initFusionPlayer(); binding.episodeEmpty.setText(R.string.detail_source_episode_empty); bindInitialArtwork(); @@ -900,12 +923,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. personalDoubanAdapter.setOnItemLongClickListener(item -> onRecommendationLongClick(item, "douban")); personalAiAdapter.setOnItemLongClickListener(item -> onRecommendationLongClick(item, "ai")); personalAiAdapter.setOnItemFocusListener(this::showAiRecommendationReason); - castAdapter.setCinema(isCinemaMode()); - creatorAdapter.setCinema(isCinemaMode()); - relatedAdapter.setCinema(isCinemaMode()); - personalTmdbAdapter.setCinema(isCinemaMode()); - personalDoubanAdapter.setCinema(isCinemaMode()); - personalAiAdapter.setCinema(isCinemaMode()); + castAdapter.setCinema(modeController.isCinemaStyle()); + creatorAdapter.setCinema(modeController.isCinemaStyle()); + relatedAdapter.setCinema(modeController.isCinemaStyle()); + personalTmdbAdapter.setCinema(modeController.isCinemaStyle()); + personalDoubanAdapter.setCinema(modeController.isCinemaStyle()); + personalAiAdapter.setCinema(modeController.isCinemaStyle()); setDetailAdaptersLight(resolveLightTheme()); updateEpisodeLayoutManager(); binding.episodeContainer.setItemAnimator(null); @@ -929,6 +952,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.relatedList.setAdapter(relatedAdapter); binding.relatedVideoList.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)); binding.relatedVideoList.setNestedScrollingEnabled(false); + // 相关视频卡片本体 276x156dp,焦点放大 1.04 倍后每侧横向多出约 5.5dp、纵向约 3.1dp。 + // 与 posterList 相同的既有做法:关闭自身裁剪并按四周预留内边距,保证放大后描边完整。 + binding.relatedVideoList.setClipToOutline(false); + binding.relatedVideoList.setClipChildren(false); + binding.relatedVideoList.setPaddingRelative(ResUtil.dp2px(8), ResUtil.dp2px(6), ResUtil.dp2px(8), ResUtil.dp2px(6)); binding.relatedVideoList.setAdapter(relatedVideoAdapter); binding.personalTmdbList.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)); binding.personalTmdbList.setNestedScrollingEnabled(false); @@ -940,6 +968,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.personalAiList.setNestedScrollingEnabled(false); binding.personalAiList.setAdapter(personalAiAdapter); applyDetailTheme(); + // BaseActivity applies the global Material theme after initView/initEvent. Profile + // detail owns translucent cards and backdrop surfaces, so reapply that contract + // after the global pass instead of letting tokens.surface() force opaque cards. + binding.root.post(this::applyDetailTheme); } private void initModeController() { @@ -955,6 +987,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return binding; } + @Override + public boolean isCinemaStyle() { + return rawCinemaMode(); + } + @Override public void closeDetailFullscreenPlayer() { TmdbDetailActivity.this.closeDetailFullscreenPlayer(); @@ -976,9 +1013,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } }; - if (isFusionMode()) { + if (rawFusionMode()) { modeController = new FusionDetailController(host); - } else if (isPlayerMode()) { + } else if (rawPlayerMode()) { modeController = new PlayerDetailController(host); } else { modeController = new EnhancedDetailController(host); @@ -1018,7 +1055,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.headerBar.getPaddingRight(), binding.headerBar.getPaddingBottom() ); - if (!isCinemaMode()) TmdbDetailLayoutUtils.setHeightDp(binding.heroSpacer, defaultHeroSpacerHeightDp()); + if (!modeController.isCinemaStyle()) TmdbDetailLayoutUtils.setHeightDp(binding.heroSpacer, defaultHeroSpacerHeightDp()); return insets; }); ViewCompat.requestApplyInsets(binding.root); @@ -1035,7 +1072,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. window.setNavigationBarContrastEnforced(false); } WindowInsetsControllerCompat insets = WindowCompat.getInsetsController(window, window.getDecorView()); - boolean lightBars = lightTheme && !isFusionMode(); + boolean lightBars = lightTheme && !modeController.shouldShowInlinePlayer(); insets.setAppearanceLightStatusBars(lightBars); insets.setAppearanceLightNavigationBars(lightBars); } @@ -1626,6 +1663,15 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (focused) inlineControlFocus = control; updatePlayerPanelFocus(); }); + // 遥控以外的输入(鼠标/触摸点击)不会移动焦点:只靠上面的焦点监听时, + // 用户点过的按钮不会被记住,再唤出控制栏就只能落到默认候选按钮上。 + // 返回 false 保证不吞事件,原有点击链路不受影响。 + if (!Util.isMobile()) { + view.setOnTouchListener((control, event) -> { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) inlineControlFocus = control; + return false; + }); + } } private boolean hasFocusedChild(View view) { @@ -1896,7 +1942,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. applyBackdropSurface(colors); binding.backdropFill.setAlpha(backdropSlideAlpha()); binding.backdrop.setAlpha(backdropSlideAlpha()); - binding.backdropShade.setBackground(isCinemaMode() ? cinemaBackdropShade() : TmdbDetailLayoutUtils.colorDrawable(colors.backdropShade)); + binding.backdropShade.setBackground(modeController.isCinemaStyle() ? cinemaBackdropShade() : TmdbDetailLayoutUtils.colorDrawable(colors.backdropShade)); setCard(binding.contentPanel, colors.panel, colors.line); setPlayerCard(colors); setCard(binding.tmdbPanel, colors.panel, colors.line); @@ -1929,8 +1975,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.overviewToggle.setTextColor(colors.accent); binding.episodeEmpty.setTextColor(colors.secondary); binding.tmdbStatus.setTextColor(colors.secondary); - binding.personalAiReason.setTextColor(isCinemaMode() ? 0xE6FFFFFF : colors.secondary); - if (isCinemaMode()) binding.personalAiReason.setShadowLayer(3f, 0f, 1.5f, 0xCC000000); + boolean lightCinemaText = modeController.isCinemaStyle() && !lightTheme; + binding.personalAiReason.setTextColor(lightCinemaText ? 0xE6FFFFFF : colors.secondary); + if (lightCinemaText) binding.personalAiReason.setShadowLayer(3f, 0f, 1.5f, 0xCC000000); else binding.personalAiReason.setShadowLayer(0f, 0f, 0f, 0x00000000); tintTmdbSectionTitles(colors); styleSourceValue(); @@ -1948,7 +1995,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (episodePhotoAdapter != null) episodePhotoAdapter.setLight(lightTheme); if (posterAdapter != null) posterAdapter.setLight(lightTheme); setDetailAdaptersLight(lightTheme); - if (isCinemaMode()) scheduleBackdropSlide(BACKDROP_SLIDE_DELAY_MS); + if (modeController.isCinemaStyle()) scheduleBackdropSlide(BACKDROP_SLIDE_DELAY_MS); } private void styleSourceValue() { @@ -1984,10 +2031,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.personalAiTitle, binding.externalLinksTitle }; - int color = isCinemaMode() ? 0xFFFFFFFF : colors.primary; + // 光影剧幕的文字色跟随其调色板:深色剧幕是浅字(靠暗投影压住背景图),浅色剧幕是深字。 + // 此前不分深浅一律强制白字,浅色剧幕恢复白色中间层后白字压白层会直接看不清。 + boolean lightText = modeController.isCinemaStyle() && !lightTheme; for (TextView title : titles) { - title.setTextColor(color); - if (isCinemaMode()) title.setShadowLayer(3f, 0f, 1.5f, 0xCC000000); + title.setTextColor(colors.primary); + if (lightText) title.setShadowLayer(3f, 0f, 1.5f, 0xCC000000); else title.setShadowLayer(0f, 0f, 0f, 0x00000000); } updateTmdbSeasonActionVisibility(); @@ -1999,7 +2048,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. boolean pictureInPicture = isInPictureInPictureMode(); boolean showMobileButton = DetailThemeVisibility.showMobileThemeButton(mobile, inlineFullscreen, inlinePiPLayout, pictureInPicture); boolean showLargeScreenButton = DetailThemeVisibility.showLargeScreenThemeButton(mobile, inlineFullscreen, inlinePiPLayout, pictureInPicture); - boolean fusionMode = isFusionMode(); + boolean fusionMode = modeController.isFusionMode(); boolean playbackPage = isAutoPlayMode() || detailPlayerActive; // 手机版也使用底部一排的主题按钮(themeModeDetail),不再使用右上角浮动按钮(themeModeTop) binding.themeModeTop.setVisibility(View.GONE); @@ -2009,7 +2058,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void applyTemplateCardChrome(ThemeColors colors) { - if (isCinemaMode()) { + if (modeController.isCinemaStyle()) { binding.contentPanel.setCardBackgroundColor(0x00000000); binding.contentPanel.setStrokeWidth(0); binding.tmdbPanel.setCardBackgroundColor(0x00000000); @@ -2025,7 +2074,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void applyDetailTemplate() { - if (isCinemaMode()) applyCinemaDetailTemplate(); + if (modeController.isCinemaStyle()) applyCinemaDetailTemplate(); else applyDefaultDetailTemplate(); } @@ -2035,7 +2084,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. TmdbDetailLayoutUtils.setWidthMatch(binding.contentPanel); TmdbDetailLayoutUtils.setWidthMatch(binding.tmdbSection); TmdbDetailLayoutUtils.setMarginsDp(binding.contentPanel, 16, 0, 16, 0); - TmdbDetailLayoutUtils.setMarginsDp(binding.playerPanel, 16, isFusionMode() ? 22 : 14, 16, isFusionMode() ? 20 : 16); + TmdbDetailLayoutUtils.setMarginsDp(binding.playerPanel, 16, modeController.isFusionMode() ? 22 : 14, 16, modeController.isFusionMode() ? 20 : 16); TmdbDetailLayoutUtils.setMarginsDp(binding.tmdbSection, 16, 16, 16, 0); binding.contentPanel.setRadius(ResUtil.dp2px(20)); binding.tmdbPanel.setRadius(ResUtil.dp2px(20)); @@ -2069,7 +2118,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private int defaultHeroSpacerHeightDp() { - if (isFusionMode()) return 0; + if (modeController.isFusionMode()) return 0; if (!Util.isMobile()) return 102; int insetDp = Math.round(statusBarInsetTop / getResources().getDisplayMetrics().density); return Math.max(72, 102 - Math.min(insetDp, 30)); @@ -2119,7 +2168,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. TmdbDetailLayoutUtils.setHeightDp(binding.castList, compact ? 90 : 90); TmdbDetailLayoutUtils.setHeightDp(binding.creatorList, compact ? 90 : 90); TmdbDetailLayoutUtils.setHeightDp(binding.relatedList, compact ? 160 : 160); - TmdbDetailLayoutUtils.setHeightDp(binding.relatedVideoList, compact ? 128 : 160); + TmdbDetailLayoutUtils.setHeightDp(binding.relatedVideoList, compact ? 128 : 168); TmdbDetailLayoutUtils.setHeightDp(binding.personalTmdbList, compact ? 160 : 160); TmdbDetailLayoutUtils.setHeightDp(binding.personalDoubanList, compact ? 160 : 160); TmdbDetailLayoutUtils.setHeightDp(binding.personalAiList, compact ? 160 : 160); @@ -2158,6 +2207,13 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return new LayerDrawable(new Drawable[]{horizontal, vertical}); } + /** + * 浅色光影剧幕的白色中间层。 + * + *

它与深色模式的黑幕同构:都是压在背景图之上、内容之下的全屏渐变层。 + * 深色剧幕一直是「暗幕 + 浅字」,浅色剧幕此前该层被改成全透明,内容直接浮在 + * 剧照原图上,文字与 chip 都没有稳定底板,所以既看不清也不像深色模式。 + */ private Drawable cinemaLightBackdropShade() { boolean compact = isCompactWidth(); GradientDrawable horizontal = new GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, compact ? new int[]{ @@ -2239,12 +2295,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } boolean focused = binding.playerPanel.hasFocus() && !hasFocusedChild(inlineControlsView()); - binding.playerPanel.setStrokeColor(focused ? FOCUS_STROKE : colors.line); - binding.playerPanel.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : CHIP_STROKE_DP)); + binding.playerPanel.setStrokeColor(focused ? focusStroke() : colors.line); + binding.playerPanel.setStrokeWidth(focused ? focusRingWidthPx() : ResUtil.dp2px(CHIP_STROKE_DP)); } private boolean isLeanbackInlinePlayerPanel() { - return Util.isLeanback() && (isFusionMode() || isPlayerMode()); + return Util.isLeanback() && (modeController.isFusionMode() || modeController.isPlayerMode()); } private void setupPlayerPanelFocusLayer() { @@ -2288,8 +2344,24 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void applyButtonFocus(MaterialButton button, int stroke, boolean focused) { - button.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : CHIP_STROKE_DP)); - button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : stroke)); + button.setStrokeWidth(focused ? focusRingWidthPx() : ResUtil.dp2px(CHIP_STROKE_DP)); + button.setStrokeColor(ColorStateList.valueOf(focused ? focusStroke() : stroke)); + } + + /** + * 焦点环宽度(px):唯一来源 {@code @dimen/webhtv_focus_ring_width}。 + * + *

详情页有一批控件是代码挂环({@code setStrokeWidth}/{@code GradientDrawable.setStroke}), + * 不走 drawable 层。此前它们写死 {@code FOCUS_STROKE_DP = 3},于是前几轮统一 drawable + * 宽度时漏掉了这批控件(用户报告「个性推荐等卡片的边框粗细没有改小没有统一」)。 + */ + private int focusRingWidthPx() { + return getResources().getDimensionPixelSize(R.dimen.webhtv_focus_ring_width); + } + + /** TV 焦点环的唯一代码来源:与 {@code ?attr/tvFocusRing} 同一取值,跟随主题 FOCUS 槽。 */ + private int focusStroke() { + return ThemeController.focusRingColor(this); } private void setEpisodeToolButton(MaterialButton button, ThemeColors colors) { @@ -2325,11 +2397,17 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void applyEpisodeTitleButtonFocus(MaterialButton button, ThemeColors colors) { boolean actionable = button.isFocusable() && button.isEnabled(); boolean focused = actionable && button.hasFocus(); - button.setBackgroundTintList(ColorStateList.valueOf(focused ? colors.control : Color.TRANSPARENT)); + boolean lightCinemaPlate = lightTheme && isCinemaStyle(); + button.setCornerRadius(ResUtil.dp2px(18)); + button.setBackgroundTintList(ColorStateList.valueOf(focused ? colors.control : episodeTitleRestingColor(lightCinemaPlate, colors))); button.setTextColor(colors.primary); button.setIconTint(ColorStateList.valueOf(colors.primary)); - button.setStrokeWidth(focused ? ResUtil.dp2px(FOCUS_STROKE_DP) : 0); - button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : Color.TRANSPARENT)); + button.setStrokeWidth(focused ? focusRingWidthPx() : (lightCinemaPlate ? ResUtil.dp2px(CHIP_STROKE_DP) : 0)); + button.setStrokeColor(ColorStateList.valueOf(focused ? focusStroke() : (lightCinemaPlate ? colors.line : Color.TRANSPARENT))); + } + + private int episodeTitleRestingColor(boolean lightCinemaPlate, ThemeColors colors) { + return lightCinemaPlate ? colors.chip : Color.TRANSPARENT; } private void applyEpisodeToolButtonFocus(MaterialButton button, ThemeColors colors) { @@ -2339,8 +2417,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. button.setBackgroundTintList(ColorStateList.valueOf(colors.control)); button.setTextColor(colors.primary); button.setIconTint(ColorStateList.valueOf(colors.primary)); - button.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : CHIP_STROKE_DP)); - button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : colors.lineStrong)); + button.setStrokeWidth(focused ? focusRingWidthPx() : ResUtil.dp2px(CHIP_STROKE_DP)); + button.setStrokeColor(ColorStateList.valueOf(focused ? focusStroke() : colors.lineStrong)); } private void tintTextTree(View view, ThemeColors colors) { @@ -2357,6 +2435,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. int generation = ++loadGeneration; tmdbCredentialScope = SubscriptionTmdbCredentialStore.currentScope(); detailTasks.cancelAll(); + detailPrefetch.cancel(); + startTmdbDetailPrefetch(initialTmdbItem, reusableBundle); int mode = getDetailMode(); String key = getKeyText(); String id = getIdText(); @@ -2412,6 +2492,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. 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) { + detailPrefetch.cancel(); String reason = !tmdbAllowed ? "site_tmdb_disabled" : sourceState == TmdbSourceState.IDENTITY_ONLY ? "source_identity_only_without_key" : "source_absent_or_invalid"; @@ -2423,7 +2504,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return; } if (sourceBundle != null) { - if (reusableBundle != null) sourceBundle = TmdbSourceMerger.merge(sourceBundle, sourcePayload, reusableBundle, null).bundle(); + // 源内嵌 payload 路径不消费预取的完整详情:结果丢弃,同时释放单槽与在途线程。 + detailPrefetch.cancel(); + if (reusableBundle != null) sourceBundle = TmdbSourceMerger.merge(sourceBundle, sourcePayload, reusableBundle, null, TmdbLanguagePolicy.requestLanguage(tmdbConfig)).bundle(); TmdbBundle initialBundle = sourceBundle; runOnAliveUi(() -> { if (generation != loadGeneration) return; @@ -2433,14 +2516,14 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. applyLoaded(finalVod, initialBundle, new ArrayList<>(), finalError, false); }); if (!decision.networkAllowed()) return; - TmdbSourceCapabilityPlanner.Plan plan = TmdbSourceCapabilityPlanner.plan(initialBundle, sourcePayload, TmdbSourceCapabilityPlanner.UiState.initialScreen()); + TmdbSourceCapabilityPlanner.Plan plan = TmdbSourceCapabilityPlanner.plan(initialBundle, sourcePayload, TmdbSourceCapabilityPlanner.UiState.initialScreen(), TmdbLanguagePolicy.requestLanguage(tmdbConfig)); 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); + TmdbBundle mergedBundle = TmdbSourceMerger.fillOnly(initialBundle, sourcePayload, networkBundle, TmdbLanguagePolicy.requestLanguage(tmdbConfig)); 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(() -> { @@ -2454,6 +2537,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } if (reusableBundle != null) { + detailPrefetch.cancel(); runOnAliveUi(() -> { if (generation != loadGeneration) return; activeRuntimeDecision = decision; @@ -2476,68 +2560,79 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } if (finalVod == null || tmdbFuture == null) { if (tmdbFuture != null) tmdbFuture.cancel(true); + detailPrefetch.cancel(); return; } - try { - long tmdbWaitStart = System.currentTimeMillis(); - TmdbLoadResult result = tmdbFuture.get(); - if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; - SpiderDebug.log("tmdb-detail-flow", "tmdb wait cost=%dms mode=%d bundle=%s search=%d total=%dms", System.currentTimeMillis() - tmdbWaitStart, mode, result != null && result.bundle() != null, result == null ? 0 : result.searchItems().size(), System.currentTimeMillis() - loadStart); - if (result != null && result.bundle() == null && finalVod != null) { - String detailTitle = finalVod.getName(); - String initialTitle = getTmdbRawTitle(); - if (!TextUtils.isEmpty(detailTitle) && !normalize(detailTitle).equals(normalize(initialTitle))) { - logTmdbMatch("基础匹配未命中,使用站源标题重试:初始标题=%s,站源标题=%s", initialTitle, detailTitle); - AutoTmdbMatch detailRetry = searchResolvedTmdbMatch(detailTitle, finalVod); - if (detailRetry.item() != null) { - result = new TmdbLoadResult(loadTmdbBundle(detailRetry.item()), detailRetry.items()); - } else if (!detailRetry.items().isEmpty()) { - result = new TmdbLoadResult(null, detailRetry.items()); - } + // 收尾阶段要阻塞等待 TMDB 结果,转投大线程池执行,释放共享 5 线程池的线程。 + detailTasks.submit(Task.largeExecutor(), + () -> completeTmdbLoad(generation, mode, finalVod, finalError, singlePassStandaloneTmdb, tmdbFuture, decision, loadStart)); + }); + } + + private void completeTmdbLoad(int generation, int mode, Vod loadedVod, String error, + boolean singlePassStandaloneTmdb, Future tmdbFuture, + DetailRuntimeModePolicy.Decision decision, long loadStart) { + final Vod finalVod = loadedVod; + final String finalError = error; + try { + long tmdbWaitStart = System.currentTimeMillis(); + TmdbLoadResult result = tmdbFuture.get(); + if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; + SpiderDebug.log("tmdb-detail-flow", "tmdb wait cost=%dms mode=%d bundle=%s search=%d total=%dms", System.currentTimeMillis() - tmdbWaitStart, mode, result != null && result.bundle() != null, result == null ? 0 : result.searchItems().size(), System.currentTimeMillis() - loadStart); + if (result != null && result.bundle() == null && finalVod != null) { + String detailTitle = finalVod.getName(); + String initialTitle = getTmdbRawTitle(); + if (!TextUtils.isEmpty(detailTitle) && !normalize(detailTitle).equals(normalize(initialTitle))) { + logTmdbMatch("基础匹配未命中,使用站源标题重试:初始标题=%s,站源标题=%s", initialTitle, detailTitle); + AutoTmdbMatch detailRetry = searchResolvedTmdbMatch(detailTitle, finalVod); + if (detailRetry.item() != null) { + result = new TmdbLoadResult(loadTmdbBundle(detailRetry.item()), detailRetry.items()); + } else if (!detailRetry.items().isEmpty()) { + result = new TmdbLoadResult(null, detailRetry.items()); } } - if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; - if (result != null && result.bundle() == null && finalVod != null) { - String query = cleanTmdbSearchQuery(finalVod.getName()); - logTmdbMatch("基础匹配未命中,使用站源详情继续消歧:片名=%s,清洗后=%s,年份=%s,演员=%s,导演=%s,简介长度=%d", - finalVod.getName(), query, finalVod.getYear(), finalVod.getActor(), finalVod.getDirector(), finalVod.getContent().length()); - TmdbBundle bundle = chooseTmdbBundle(result.searchItems(), query, finalVod); - List matchedSearchItems = result.searchItems(); - if (bundle == null) { - SplitYearSearch split = searchSplitYearTmdbItems(query, finalVod); - if (split != null) { - bundle = chooseTmdbBundle(split.items(), split.query(), finalVod); - matchedSearchItems = split.items(); - } - } - result = new TmdbLoadResult(bundle, matchedSearchItems); - } - if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; - TmdbLoadResult loadedResult = result; - if (singlePassStandaloneTmdb) loadedResult = preloadInitialStandaloneSeason(loadedResult, finalVod); - if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; - 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 { - applyTmdbResult(finalResult); - } - }); - } catch (Throwable ignored) { - if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; - SpiderDebug.log("tmdb-detail-flow", "tmdb wait failed mode=%d total=%dms", mode, System.currentTimeMillis() - loadStart); - if (singlePassStandaloneTmdb) { - runOnAliveUi(() -> { - if (generation != loadGeneration) return; - activeRuntimeDecision = decision; - applyLoaded(finalVod, null, new ArrayList<>(), finalError, false); - }); - } } - }); + if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; + if (result != null && result.bundle() == null && finalVod != null) { + String query = cleanTmdbSearchQuery(finalVod.getName()); + logTmdbMatch("基础匹配未命中,使用站源详情继续消歧:片名=%s,清洗后=%s,年份=%s,演员=%s,导演=%s,简介长度=%d", + finalVod.getName(), query, finalVod.getYear(), finalVod.getActor(), finalVod.getDirector(), finalVod.getContent().length()); + TmdbBundle bundle = chooseTmdbBundle(result.searchItems(), query, finalVod); + List matchedSearchItems = result.searchItems(); + if (bundle == null) { + SplitYearSearch split = searchSplitYearTmdbItems(query, finalVod); + if (split != null) { + bundle = chooseTmdbBundle(split.items(), split.query(), finalVod); + matchedSearchItems = split.items(); + } + } + result = new TmdbLoadResult(bundle, matchedSearchItems); + } + if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; + TmdbLoadResult loadedResult = result; + if (singlePassStandaloneTmdb) loadedResult = preloadInitialStandaloneSeason(loadedResult, finalVod); + if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; + 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 { + applyTmdbResult(finalResult); + } + }); + } catch (Throwable ignored) { + if (generation != loadGeneration || Thread.currentThread().isInterrupted()) return; + SpiderDebug.log("tmdb-detail-flow", "tmdb wait failed mode=%d total=%dms", mode, System.currentTimeMillis() - loadStart); + if (singlePassStandaloneTmdb) { + runOnAliveUi(() -> { + if (generation != loadGeneration) return; + activeRuntimeDecision = decision; + applyLoaded(finalVod, null, new ArrayList<>(), finalError, false); + }); + } + } } private boolean shouldLoadInitialStandaloneTmdbDetailInSinglePass(@Nullable TmdbBundle reusableBundle, @Nullable Future tmdbFuture) { @@ -2767,7 +2862,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. List searchItems = new ArrayList<>(); try { if (initialTmdbItem != null) { - tmdbBundle = loadTmdbBundle(initialTmdbItem); + tmdbBundle = loadPrefetchedTmdbBundle(initialTmdbItem); } else { boolean manual = isManualTmdbMatch(); TmdbItem match = getCachedTmdbMatch(); @@ -2831,9 +2926,51 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. 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()); } + /** + * identity 已知时在站点详情请求发出前启动 TMDB 详情预取。 + *

+ * 站点详情与 TMDB 详情互不依赖,并行可省掉一个网络往返。代价(用户已确认): + * 源内嵌完整 TMDB payload(T3/T4)的页面也会多发 1 次请求,但结果只被 + * {@link #loadPrefetchedTmdbBundle} 消费,源数据路径会直接丢弃,不改变渲染。 + * 预取只在 TMDB 可用且站点允许时启动;已复用 bundle 或自动匹配(identity 未知)不预取。 + */ + private void startTmdbDetailPrefetch(@Nullable TmdbItem item, @Nullable TmdbBundle reusableBundle) { + if (item == null || reusableBundle != null) return; + TmdbConfig config = tmdbConfig; + if (config == null || !config.isReady()) return; + if (!isTmdbAllowedForCurrentSite()) return; + detailPrefetch.start(item, () -> { + long start = System.currentTimeMillis(); + JsonObject detail = tmdbService.detail(item, config); + SpiderDebug.log("tmdb-detail-flow", "tmdb prefetch cost=%dms id=%d media=%s", System.currentTimeMillis() - start, item.getTmdbId(), item.getMediaType()); + return new TmdbDetailPrefetch.Result(item, detail, List.of()); + }); + } + + /** + * 消费站点详情期间并行取回的 TMDB 详情,避免第二次网络请求;预取缺失、被取消或失败时 + * 回落到常规 {@link #loadTmdbBundle}(此时文件缓存可能已被预取写入,通常只剩解析成本)。 + */ + private TmdbBundle loadPrefetchedTmdbBundle(TmdbItem item) throws Exception { + ListenableFuture future = detailPrefetch.take(item); + if (future == null) return loadTmdbBundle(item); + try { + TmdbDetailPrefetch.Result prefetched = future.get(); + if (prefetched == null || prefetched.getDetail() == null) return loadTmdbBundle(item); + SpiderDebug.log("tmdb-detail-flow", "tmdb prefetch hit id=%d media=%s", item.getTmdbId(), item.getMediaType()); + return toTmdbBundle(prefetched.getItem() == null ? item : prefetched.getItem(), prefetched.getDetail()); + } catch (Throwable e) { + SpiderDebug.log("tmdb-detail-flow", "tmdb prefetch unusable id=%d media=%s error=%s", item.getTmdbId(), item.getMediaType(), e.getMessage()); + return loadTmdbBundle(item); + } + } + private TmdbBundle loadTmdbBundle(TmdbItem item) throws Exception { + return toTmdbBundle(item, tmdbService.detail(item, tmdbConfig)); + } + + private TmdbBundle toTmdbBundle(TmdbItem item, JsonObject detail) { long start = System.currentTimeMillis(); - JsonObject detail = tmdbService.detail(item, tmdbConfig); item = normalizeTmdbItemTitle(item, detail); List seasons = new ArrayList<>(); Map seasonCounts = new HashMap<>(); @@ -2873,10 +3010,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private String tmdbDetailTitle(TmdbItem item, JsonObject detail) { - if (item == null || detail == null) return ""; - String primary = "movie".equalsIgnoreCase(item.getMediaType()) ? string(detail, "title") : string(detail, "name"); - if (!TextUtils.isEmpty(primary)) return primary; - return "movie".equalsIgnoreCase(item.getMediaType()) ? string(detail, "name") : string(detail, "title"); + return tmdbService.preferredTitle(item, detail, tmdbConfig); } private void loadTmdbMediaBlocks(TmdbBundle bundle) { @@ -2963,10 +3097,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. personalTmdbItems.addAll(finalPersonalTmdb); personalDoubanItems.clear(); personalDoubanItems.addAll(finalPersonalDouban); - detailTmdbPhotos.clear(); - detailTmdbPhotos.addAll(finalBackdrops); - detailTmdbPosters.clear(); - detailTmdbPosters.addAll(finalPosters); + setRailPhotos(finalBackdrops); + setRailPosters(finalPosters); detailBackdropSlides.clear(); detailBackdropSlides.addAll(finalBackgroundSlides); refreshBackdropSlideshow(); @@ -3074,8 +3206,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. detailTmdbPosters.clear(); detailBackdropSlides.clear(); if (bundle != null && bundle.detail() != null) { - detailTmdbPhotos.addAll(tmdbService.backdrops(bundle.detail(), tmdbConfig)); - detailTmdbPosters.addAll(tmdbService.posters(bundle.detail(), tmdbConfig)); + setRailPhotos(tmdbService.backdrops(bundle.detail(), tmdbConfig)); + setRailPosters(tmdbService.posters(bundle.detail(), tmdbConfig)); detailBackdropSlides.addAll(bundle.photos()); if (detailBackdropSlides.isEmpty()) { detailBackdropSlides.addAll(tmdbService.photos(bundle.detail(), tmdbConfig, preferLandscapeBackground())); @@ -3417,11 +3549,14 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. binding.episodeTitle.setContentDescription(visible ? getString(R.string.tmdb_season_match_current, binding.episodeTitle.getText()) : binding.episodeTitle.getText()); + setEpisodeTitleButton(binding.episodeTitle, currentThemeColors()); Drawable icon = visible ? getDrawable(R.drawable.ic_expand_more) : null; - if (icon != null) icon.setTint(binding.episodeTitle.getCurrentTextColor()); + if (icon != null) { + icon = icon.mutate(); + icon.setTint(binding.episodeTitle.getCurrentTextColor()); + } binding.episodeTitle.setCompoundDrawablePadding(visible ? ResUtil.dp2px(4) : 0); binding.episodeTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, icon, null); - setEpisodeTitleButton(binding.episodeTitle, currentThemeColors()); } private boolean canApplyValidatedFlatSeasonMapping(List episodes) { @@ -3544,9 +3679,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (!isTmdbAllowedForCurrentSite()) return null; TmdbMatchCache cache = Setting.getTmdbMatchCache(); // 手动选择优先,且不做标题兼容性校验:用户之所以手动选,正是因为标题解析结果不对。 - TmdbItem manual = cache.findManual(getKeyText(), getIdText(), getTmdbRawTitle()); + TmdbItem manual = cache.findManual(getKeyText(), getIdText(), getTmdbRawTitle(), TmdbLanguagePolicy.requestLanguage(tmdbConfig)); if (manual != null) return manual; - TmdbItem item = cache.find(getKeyText(), getIdText(), getTmdbRawTitle()); + TmdbItem item = cache.find(getKeyText(), getIdText(), getTmdbRawTitle(), TmdbLanguagePolicy.requestLanguage(tmdbConfig)); if (!isCachedTmdbMatchCompatible(item)) return null; return item; } @@ -3583,7 +3718,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. // 不加锁会让后到的自动结果基于旧快照覆盖掉刚落盘的手动选择。 synchronized (Setting.class) { TmdbMatchCache cache = Setting.getTmdbMatchCache(); - cache.put(getKeyText(), getIdText(), getTmdbRawTitle(), item); + cache.put(getKeyText(), getIdText(), getTmdbRawTitle(), item, TmdbLanguagePolicy.requestLanguage(tmdbConfig)); Setting.putTmdbMatchCache(cache); } } @@ -3594,7 +3729,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. List aliases = tmdbSourceTitleAliases(); synchronized (Setting.class) { TmdbMatchCache cache = Setting.getTmdbMatchCache(); - cache.putManual(getKeyText(), getIdText(), aliases, item); + cache.putManual(getKeyText(), getIdText(), aliases, item, TmdbLanguagePolicy.requestLanguage(tmdbConfig)); Setting.putTmdbMatchCache(cache); } } @@ -3912,10 +4047,32 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void bindInitialArtwork() { String initialTmdbPoster = getTmdbPosterText(); - ImgUtil.load(getNameText(), TextUtils.isEmpty(initialTmdbPoster) ? getPicText() : TmdbImageSelector.originalUrl(initialTmdbPoster), binding.poster); + ImgUtil.load(getNameText(), TextUtils.isEmpty(initialTmdbPoster) ? getPicText() : TmdbImageSelector.heroPosterTier(initialTmdbPoster), binding.poster); bindBackdropImage(getNameText(), getBackdropText(), getPicText()); } + /** + * 图集/剧照 rail 统一降到官方 backdrop 档位 {@code w780}。 + *

+ * rail 只显示缩略图,原图会白下 8 倍以上字节(实测 backdrop original 982,744B / w780 48,585B)。 + * 全屏查看与保存图片走 {@link #highResTmdbImage} 升级回 original,观感不变。 + */ + private void setRailPhotos(@Nullable List photos) { + detailTmdbPhotos.clear(); + if (photos == null) return; + for (String url : photos) detailTmdbPhotos.add(TmdbImageSelector.photoTier(url)); + } + + /** + * 海报 rail 统一降到官方 poster 档位 {@code w500}(实测 original 1,187,694B / w500 85,680B), + * 全屏查看与保存仍升级回 original。 + */ + private void setRailPosters(@Nullable List posters) { + detailTmdbPosters.clear(); + if (posters == null) return; + for (String url : posters) detailTmdbPosters.add(TmdbImageSelector.posterTier(url)); + } + private void bindBackdrop() { boolean wallpaperBackdrop = useAppWallpaperBackdrop(); bindBackdropImage(vod.getName(), wallpaperBackdrop ? "" : tmdbBackdropUrl(), wallpaperBackdrop ? "" : vod.getPic()); @@ -3929,7 +4086,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. getTmdbPosterText(), vod == null ? "" : vod.getPic(), getPicText()); - return TmdbImageSelector.poster(matchedTmdbDetail, tmdbConfig == null ? "" : tmdbConfig.getImageBase(), fallback); + return TmdbImageSelector.heroPosterTier(TmdbImageSelector.poster(matchedTmdbDetail, tmdbConfig == null ? "" : tmdbConfig.getImageBase(), fallback)); } private void bindBackdropImage(String title, String backdrop, String fallback) { @@ -4104,7 +4261,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private float backdropSlideAlpha() { - return isCinemaMode() && !lightTheme ? 0.9f : 1f; + if (modeController.isCinemaStyle()) return lightTheme ? 1f : 0.9f; + return lightTheme ? 1f : 0.5f; } private int nextBackdropSlideIndex() { @@ -4167,9 +4325,17 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return true; } + private boolean isCinemaStyle() { + return modeController != null ? modeController.isCinemaStyle() : rawCinemaMode(); + } + + private boolean rawCinemaMode() { + return getIntent().getIntExtra("detail_mode", Setting.getDetailOpenMode()) == Setting.DETAIL_OPEN_CINEMA || Setting.isTmdbCinemaStyle(); + } + private ThemeColors currentThemeColors() { ThemeColors colors = lightTheme ? ThemeColors.light() : ThemeColors.dark(); - return isCinemaMode() ? ThemeColors.cinema(lightTheme) : colors; + return isCinemaStyle() ? ThemeColors.cinema(lightTheme) : colors; } private void refreshBackdropSurface() { @@ -4189,7 +4355,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private int backdropFallbackBackground(ThemeColors colors) { - return isPlayerMode() ? 0xFF0F141A : colors.background; + return modeController.isPlayerMode() ? 0xFF0F141A : colors.background; } private String episodeFallbackStillUrl() { @@ -4367,9 +4533,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (!(view instanceof LinearLayout row)) return; boolean focused = row.hasFocus(); GradientDrawable background = new GradientDrawable(); - background.setColor(isCinemaMode() ? TmdbCinemaTheme.palette(lightTheme).ratingChip() : colors.chip); + background.setColor(modeController.isCinemaStyle() ? TmdbCinemaTheme.palette(lightTheme).ratingChip() : colors.chip); background.setCornerRadius(ResUtil.dp2px(10)); - background.setStroke(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : CHIP_STROKE_DP), focused ? FOCUS_STROKE : colors.line); + background.setStroke(focused ? focusRingWidthPx() : ResUtil.dp2px(CHIP_STROKE_DP), focused ? focusStroke() : colors.line); row.setBackground(background); for (int i = 0; i < row.getChildCount(); i++) { View child = row.getChildAt(i); @@ -4379,10 +4545,39 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void openExternalLink(String url) { + if (TextUtils.isEmpty(url)) return; + if (Util.isLeanback()) { + ChoiceDialog.showSingle(this, R.string.detail_external_open_title, + new CharSequence[]{getString(R.string.detail_external_open_builtin), getString(R.string.detail_external_open_external)}, + -1, which -> { + if (which == 0) openExternalLinkBuiltIn(url); + else openExternalLinkExternal(url); + }); + return; + } + openExternalLinkExternal(url); + } + + private void openExternalLinkBuiltIn(String url) { + if (!WebViewUtil.support()) { + openExternalLinkExternal(url); + return; + } try { - startActivity(new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url))); + startActivity(CatWebActivity.browserIntent(this, url, + getString(R.string.tmdb_external_links_label), + getString(R.string.detail_external_opening))); + } catch (Throwable ignored) { + // WebView 容器临时起不来时仍回退到系统浏览器,避免链接完全不可达。 + openExternalLinkExternal(url); + } + } + + private void openExternalLinkExternal(String url) { + try { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); } catch (Throwable e) { - Notify.show("无法打开链接"); + Notify.show(R.string.detail_external_open_failed); } } @@ -4432,7 +4627,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private int ratingChipBackground(ThemeColors colors) { - return isCinemaMode() ? TmdbCinemaTheme.palette(true).ratingChip() : colors.chip; + return modeController.isCinemaStyle() ? TmdbCinemaTheme.palette(true).ratingChip() : colors.chip; } private int readableDetailRatingColor(int color) { @@ -4669,7 +4864,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. renderFlagSelection(); renderEpisodes(); refreshSeasonSourceRoutes(); - if (isFusionMode()) onPlay(); + if (modeController.isFusionMode()) onPlay(); }); binding.flagContainer.addView(button); } @@ -5187,11 +5382,13 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. || focusTmdbRecycler(binding.relatedList) || focusTmdbRecycler(binding.creatorList) || focusTmdbRecycler(binding.castList) + || focusTmdbRecycler(binding.relatedVideoList) || focusTmdbRecycler(binding.episodePhotoList); } private boolean focusFirstVisibleTmdbRow() { return focusTmdbRecycler(binding.episodePhotoList) + || focusTmdbRecycler(binding.relatedVideoList) || focusTmdbRecycler(binding.castList) || focusTmdbRecycler(binding.creatorList) || focusTmdbRecycler(binding.relatedList) @@ -5224,6 +5421,187 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return true; } + /** + * 安装焦点滚动校正。 + * + * 系统默认的焦点滚动会把内容贴到可视区边缘:卡片行会把行上方的分区标题切掉一半、 + * 让下一个分区标题在底部露出一小条;按钮则整块贴住屏幕顶端,缺少呼吸空间。 + * 这里在每次焦点变化后统一校正一次。 + */ + private void installCardRowFocusMargin() { + if (binding == null) return; + buildCardRowIndex(); + cardRowFocusMarginListener = (previousFocus, newFocus) -> { + if (binding == null || newFocus == null) return; + // 系统默认的焦点滚动可能在下一帧完成;使用动画帧而非固定延时,避免视觉闪烁。 + binding.scroll.postOnAnimation(() -> ensureFocusVisibleWithMargin(newFocus)); + binding.scroll.postOnAnimationDelayed(() -> ensureFocusVisibleWithMargin(newFocus), 260); + }; + binding.scroll.getViewTreeObserver().addOnGlobalFocusChangeListener(cardRowFocusMarginListener); + } + + private void removeCardRowFocusMargin() { + if (binding == null || cardRowFocusMarginListener == null) return; + ViewTreeObserver observer = binding.scroll.getViewTreeObserver(); + if (observer.isAlive()) observer.removeOnGlobalFocusChangeListener(cardRowFocusMarginListener); + cardRowFocusMarginListener = null; + } + + /** 按页面自上而下的顺序登记卡片行及其分区标题。 */ + private void buildCardRowIndex() { + cardRowOrder.clear(); + cardRowTitleOrder.clear(); + addCardRow(binding.episodePhotoList, binding.episodePhotoTitle); + addCardRow(binding.posterList, binding.posterTitle); + addCardRow(binding.relatedVideoList, binding.relatedVideoTitle); + addCardRow(binding.castList, binding.castTitle); + addCardRow(binding.creatorList, binding.creatorTitle); + addCardRow(binding.relatedList, binding.relatedTitle); + addCardRow(binding.personalTmdbList, binding.personalTmdbTitle); + addCardRow(binding.personalDoubanList, binding.personalDoubanTitle); + addCardRow(binding.personalAiList, binding.personalAiTitle); + } + + private void addCardRow(View row, View title) { + if (row == null) return; + cardRowOrder.add(row); + cardRowTitleOrder.add(title); + } + + private boolean isLaidOutVisible(View view) { + return view != null && view.getVisibility() == View.VISIBLE && view.getHeight() > 0 && view.isShown(); + } + + /** 返回焦点所在卡片行在 {@link #cardRowOrder} 中的下标,找不到返回 -1。 */ + private int focusedCardRowIndex(View focused) { + for (View current = focused; current != null; ) { + int index = cardRowOrder.indexOf(current); + if (index >= 0) return index; + Object parent = current.getParent(); + current = parent instanceof View ? (View) parent : null; + } + return -1; + } + + /** 该行之后第一个可见的分区标题(用于避免其只露出一部分)。 */ + private View nextVisibleSectionTitle(int rowIndex) { + for (int i = rowIndex + 1; i < cardRowTitleOrder.size(); i++) { + View title = cardRowTitleOrder.get(i); + if (isLaidOutVisible(title)) return title; + } + return isLaidOutVisible(binding.externalLinksTitle) ? binding.externalLinksTitle : null; + } + + /** 焦点变化后的统一校正入口:卡片行走专用逻辑,其余按钮/控件走通用留白。 */ + private void ensureFocusVisibleWithMargin(View focused) { + if (binding == null || focused == null || !focused.isShown()) return; + if (focusedCardRowIndex(focused) >= 0) { + ensureCardRowVisibleWithMargin(focused); + return; + } + ensureButtonVisibleWithMargin(focused); + } + + /** 该 View 是否位于 binding.scroll 之内。 */ + private boolean isInsideDetailScroll(View view) { + for (View current = view; current != null; ) { + if (current == binding.scroll) return true; + Object parent = current.getParent(); + current = parent instanceof View ? (View) parent : null; + } + return false; + } + + /** + * 该 View 是否是横向列表(RecyclerView)里的条目。 + * 这类条目有各自的滚动/对齐逻辑,这里不再叠加通用留白,避免相互打架。 + */ + private boolean isInsideRecyclerRow(View view) { + for (View current = view; current != null; ) { + if (current == binding.scroll) return false; + if (current instanceof RecyclerView) return true; + Object parent = current.getParent(); + current = parent instanceof View ? (View) parent : null; + } + return false; + } + + /** + * 按钮/控件获得焦点时保留上下间距。 + * + * 与卡片行同理:系统默认的焦点滚动会把按钮贴到屏幕顶端(实测「继续播放」的 y=0), + * 视觉上过于拥挤。这里保证按钮至少离可视区上下边缘各留一段间距。 + */ + private void ensureButtonVisibleWithMargin(View focused) { + if (binding == null || focused == null || !focused.isShown()) return; + if (focused == binding.scroll) return; + if (!isInsideDetailScroll(focused) || isInsideRecyclerRow(focused)) return; + if (focused.getHeight() == 0 || binding.scroll.getHeight() == 0) return; + int[] loc = new int[2]; + binding.scroll.getLocationOnScreen(loc); + int viewTop = loc[1]; + int viewBottom = viewTop + binding.scroll.getHeight(); + focused.getLocationOnScreen(loc); + int top = loc[1]; + int bottom = top + focused.getHeight(); + int topMargin = ResUtil.dp2px(CARD_ROW_TOP_MARGIN_DP); + int bottomMargin = ResUtil.dp2px(CARD_ROW_BOTTOM_MARGIN_DP); + if (top < viewTop + topMargin) binding.scroll.scrollBy(0, top - (viewTop + topMargin)); + else if (bottom > viewBottom - bottomMargin) binding.scroll.scrollBy(0, bottom - (viewBottom - bottomMargin)); + } + + /** + * 让获得焦点的卡片行连同它的分区标题一起舒服地落在可视区内。 + * + * 1) 顶部:锚定到分区标题(而不是行本身),标题完整显示,不再被切掉一半; + * 同时兼容卡片焦点放大 1.04 倍后描边外溢的情况。 + * 2) 底部:行底留出间距,并且不让下一个分区标题只露出一小条—— + * 要么完整显示,要么完全移出可视区。 + */ + private void ensureCardRowVisibleWithMargin(View focused) { + if (binding == null || focused == null || !focused.isShown()) return; + if (cardRowOrder.isEmpty()) buildCardRowIndex(); + int index = focusedCardRowIndex(focused); + if (index < 0) return; + View row = cardRowOrder.get(index); + if (row.getHeight() == 0 || binding.scroll.getHeight() == 0) return; + + int[] loc = new int[2]; + binding.scroll.getLocationOnScreen(loc); + int viewTop = loc[1]; + int viewBottom = viewTop + binding.scroll.getHeight(); + + row.getLocationOnScreen(loc); + int rowTop = loc[1]; + int rowBottom = rowTop + row.getHeight(); + + int topMargin = ResUtil.dp2px(CARD_ROW_TOP_MARGIN_DP); + int bottomMargin = ResUtil.dp2px(CARD_ROW_BOTTOM_MARGIN_DP); + + int anchorTop = rowTop; + View title = index < cardRowTitleOrder.size() ? cardRowTitleOrder.get(index) : null; + if (isLaidOutVisible(title)) { + title.getLocationOnScreen(loc); + anchorTop = Math.min(anchorTop, loc[1]); + } + + if (anchorTop < viewTop + topMargin) { + binding.scroll.scrollBy(0, anchorTop - (viewTop + topMargin)); + } else if (rowBottom > viewBottom - bottomMargin) { + binding.scroll.scrollBy(0, rowBottom - (viewBottom - bottomMargin)); + } + + View next = nextVisibleSectionTitle(index); + if (!isLaidOutVisible(next)) return; + next.getLocationOnScreen(loc); + int nextTop = loc[1]; + if (nextTop >= viewBottom || nextTop + next.getHeight() <= viewBottom) return; + // 下一个标题正卡在底边:把内容整体下移,让它完全移出可视区; + // 但保证本行标题不会被顶出屏幕顶部。 + int delta = Math.min(nextTop - viewBottom, anchorTop - (viewTop + topMargin)); + binding.scroll.scrollBy(0, delta); + } + private void scrollDetailChildIntoViewNow(View child, int topPaddingDp) { if (binding == null || child == null) return; Rect rect = new Rect(); @@ -5817,7 +6195,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. tmdbEpisodePhotos.clear(); List seasonPhotos = tmdbSeasonPhotos.get(seasonNumber); - tmdbEpisodePhotos.addAll(seasonPhotos == null || seasonPhotos.isEmpty() ? detailTmdbPhotos : seasonPhotos); + if (seasonPhotos == null || seasonPhotos.isEmpty()) { + tmdbEpisodePhotos.addAll(detailTmdbPhotos); + } else { + for (String url : seasonPhotos) tmdbEpisodePhotos.add(TmdbImageSelector.photoTier(url)); + } } private boolean hasExplicitSeasonNumbers(List episodes) { @@ -6152,7 +6534,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. episodePhotoAdapter.setItems(tmdbEpisodePhotos); episodePhotoAdapter.rebindAttached(binding.episodePhotoList); - int sectionGapDp = isPlayerMode() && !isCinemaMode() ? 12 : 20; + int sectionGapDp = modeController.isPlayerMode() && !modeController.isCinemaStyle() ? 12 : 20; setTopMargin(binding.posterTitle, hasPhotos ? sectionGapDp : 0); binding.posterTitle.setVisibility(hasPosters ? View.VISIBLE : View.GONE); binding.posterList.setVisibility(hasPosters ? View.VISIBLE : View.GONE); @@ -6326,7 +6708,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. logTmdbMatch("原生增强播放标题:raw=%s,缓存标题=%s,详情标题=%s,播放标题=%s", getTmdbRawTitle(), matchedTmdbItem == null ? "" : matchedTmdbItem.getTitle(), tmdbDetailTitle(matchedTmdbItem, matchedTmdbDetail), playbackHistoryName()); TmdbItem item = playbackTmdbItem(); EpisodePosition position = historyEpisodePosition(selectedEpisode); - String tmdbDetailCacheKey = TmdbDetailCache.put(item, matchedTmdbDetail, detailCastItems); + String tmdbDetailCacheKey = TmdbDetailCache.put(item, matchedTmdbDetail, detailCastItems, TmdbLanguagePolicy.requestLanguage(tmdbConfig), tmdbSeasonEpisodes); SpiderDebug.log("tmdb-tv", "play launch prep cost=%dms title=%s", System.currentTimeMillis() - start, playbackHistoryName()); VideoActivity.startDirectTmdb(this, getKeyText(), getIdText(), playbackHistoryName(), playbackHistoryPic(), playbackMark(), fastPlaybackEpisodeTitles(), item, playbackTmdbVod(), vod, tmdbDetailCacheKey, playbackFlag(), selectedSeasonFlagKey(), playbackEpisodeName(), playbackEpisodeUrl(), position.season(), position.number(), isResumeFromHistory() ? getIntentResumeHistory() : null); }); @@ -6476,7 +6858,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. */ private void showMovieDialog(Episode episode) { DialogTmdbEpisodeBinding dialogBinding = DialogTmdbEpisodeBinding.inflate(getLayoutInflater()); - AlertDialog dialog = new MaterialAlertDialogBuilder(this).setView(dialogBinding.getRoot()).create(); + AlertDialog dialog = new WebHtvAlertDialogBuilder(this).setView(dialogBinding.getRoot()).create(); ThemeColors colors = lightTheme ? ThemeColors.light() : ThemeColors.dark(); dialogBinding.panel.setCardBackgroundColor(colors.panel); dialogBinding.panel.setStrokeColor(colors.line); @@ -6591,7 +6973,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void showTmdbEpisodeDialog(Episode episode, int episodeNumber, JsonObject detail, List photos, List guests) { DialogTmdbEpisodeBinding dialogBinding = DialogTmdbEpisodeBinding.inflate(getLayoutInflater()); - AlertDialog dialog = new MaterialAlertDialogBuilder(this).setView(dialogBinding.getRoot()).create(); + AlertDialog dialog = new WebHtvAlertDialogBuilder(this).setView(dialogBinding.getRoot()).create(); ThemeColors colors = lightTheme ? ThemeColors.light() : ThemeColors.dark(); dialogBinding.panel.setCardBackgroundColor(colors.panel); dialogBinding.panel.setStrokeColor(colors.line); @@ -6608,7 +6990,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } dialogBinding.meta.setText(episodeMeta(detail)); dialogBinding.meta.setVisibility(TextUtils.isEmpty(dialogBinding.meta.getText()) ? View.GONE : View.VISIBLE); - String overview = string(detail, "overview"); + String overview = tmdbService.translatedOverview(detail, tmdbConfig); if (TextUtils.isEmpty(overview)) overview = episode == null ? "" : episode.getDesc(); dialogBinding.overview.setText(TextUtils.isEmpty(overview) ? getString(R.string.detail_tmdb_empty) : overview); String crew = episodeCrew(detail); @@ -6879,12 +7261,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void applyPhotoButtonFocus(MaterialButton button, boolean focused) { button.setBackgroundTintList(ColorStateList.valueOf(focused ? 0x33FFFFFF : 0x18FFFFFF)); - button.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : CHIP_STROKE_DP)); - button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : 0x4DFFFFFF)); + button.setStrokeWidth(focused ? focusRingWidthPx() : ResUtil.dp2px(CHIP_STROKE_DP)); + button.setStrokeColor(ColorStateList.valueOf(focused ? focusStroke() : 0x4DFFFFFF)); } private void showPhotoActionDialog(String url) { - new MaterialAlertDialogBuilder(this) + new WebHtvAlertDialogBuilder(this) .setItems(new CharSequence[]{getString(R.string.detail_image_save)}, (dialog, which) -> savePhoto(url, null)) .show(); } @@ -6992,7 +7374,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private String episodeDetailTitle(Episode episode, int episodeNumber, JsonObject detail) { - String name = string(detail, "name"); + String name = TmdbLanguagePolicy.bestDisplayValue(string(detail, "name"), array(detail, "translations", "translations"), "name", TmdbLanguagePolicy.requestLanguage(tmdbConfig)); if (TextUtils.isEmpty(name)) { TmdbEpisode tmdbEpisode = tmdbEpisodes.get(episodeNumber); name = tmdbEpisode == null ? "" : tmdbEpisode.getTitle(); @@ -7082,8 +7464,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private boolean isHistoryEpisode(Episode episode, History item) { if (episode == null || item == null) return false; Episode saved = item.getEpisode(); + // 历史集 URL 仍能定位到当前线路条目(同集多版本并存)时启用版本消歧, + // 避免“点第二版本却被当作第一版本”;换线路/换源/源站刷新后 URL 必然失配, + // 保留集号容错,保住跨线路续播与刷新后的“继续播放”。 + boolean versionAware = selectedFlag != null && selectedFlag.containsEpisodeUrl(saved); if (item.getTmdbEpisodeNumber() > 0 && episode.getTmdbEpisode() != null && episode.getTmdbEpisode().getNumber() > 0) { - return episode.matchesPlayback(saved); + return episode.matchesPlayback(saved, versionAware); } if (!TextUtils.isEmpty(item.getEpisodeUrl()) && item.getEpisodeUrl().equals(episode.getUrl())) return true; return episode.matchesName(saved) || episode.getDisplayName().equals(item.getVodRemarks()) || historyEpisodeTitle(episode).equals(item.getVodRemarks()); @@ -7214,20 +7600,16 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return coalesce(matchedTmdbItem == null ? "" : matchedTmdbItem.getPosterUrl(), matchedTmdbItem == null ? "" : matchedTmdbItem.getBackdropUrl(), vod == null ? "" : vod.getPic(), getPicText()); } - private boolean isFusionMode() { + private boolean rawFusionMode() { return getDetailMode() == Setting.DETAIL_OPEN_FUSION || getIntent().getBooleanExtra("fusion", false); } - private boolean isPlayerMode() { + private boolean rawPlayerMode() { return getDetailMode() == Setting.DETAIL_OPEN_PLAYER; } - private boolean isCinemaMode() { - return getIntent().getIntExtra("detail_mode", Setting.getDetailOpenMode()) == Setting.DETAIL_OPEN_CINEMA || Setting.isTmdbCinemaStyle(); - } - private int getDetailMode() { - // 返回原始模式,不做 normalize,否则 isPlayerMode() 永远返回 false + // 返回原始模式,不做 normalize,否则 modeController.isPlayerMode() 永远返回 false if (getIntent().hasExtra("detail_mode")) return getIntent().getIntExtra("detail_mode", Setting.DETAIL_OPEN_ENHANCED); // 详情直放没有内嵌播放界面;若既无 detail_mode 也无 fusion 标记,只能按当前设置还原, // 不能把无标记默认成炫彩详情,否则点击播放会误走融合内嵌播放。 @@ -7235,8 +7617,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private int detailModeTitle() { - if (isFusionMode()) return R.string.setting_detail_open_fusion; - if (isPlayerMode()) return R.string.setting_detail_open_player; + if (modeController.isFusionMode()) return R.string.setting_detail_open_fusion; + if (modeController.isPlayerMode()) return R.string.setting_detail_open_player; return R.string.setting_detail_open_enhanced; } @@ -7245,7 +7627,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean shouldUseLoadingOnlyBeforeDefaultPlayback() { - return isAutoPlayMode() && !isFusionMode() && !isPlayerMode(); + return isAutoPlayMode() && !modeController.isFusionMode() && !modeController.isPlayerMode(); } private void setLoadingOnlyBeforeDefaultPlayback(boolean loadingOnly) { @@ -7258,7 +7640,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean isInlinePlayerMode() { - return isFusionMode() || detailPlayerActive; + return modeController.isFusionMode() || detailPlayerActive; } private boolean isCurrentInlinePlayback(Episode episode) { @@ -7319,6 +7701,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. inlinePlaybackPending = true; updateInlineLoading(); int generation = ++inlinePlaybackGeneration; + inlinePlaybackPendingGeneration = generation; String key = getKeyText(); String flag = selectedFlag.getFlag(); String episodeUrl = selectedEpisode.getUrl(); @@ -7328,7 +7711,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. try { Result result = SiteApi.playerContent(key, flag, episodeUrl, playerKernel); runOnAliveUi(() -> { - if (!isInlinePlaybackRequestCurrent(generation, key, flag, episodeUrl)) return; + if (!isInlinePlaybackRequestCurrent(generation, key, flag, episodeUrl)) { + // 请求已被作废(换集/换源/详情重载/外部播放等):不会再有结果来收圈, + // 由置位它的这个代际负责释放。 + releaseInlinePlaybackPending(generation); + return; + } inlinePlaybackPending = false; String resolvedUrl = result.getUrl() == null ? "" : result.getUrl().v(); if (!TextUtils.isEmpty(failedUrl) && TextUtils.equals(failedUrl, resolvedUrl)) { @@ -7341,7 +7729,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } catch (Throwable e) { String message = e.getMessage(); runOnAliveUi(() -> { - if (!isInlinePlaybackRequestCurrent(generation, key, flag, episodeUrl)) return; + if (!isInlinePlaybackRequestCurrent(generation, key, flag, episodeUrl)) { + releaseInlinePlaybackPending(generation); + return; + } inlinePlaybackPending = false; String fallback = TextUtils.isEmpty(failureMessage) ? getString(R.string.error_play_url) : failureMessage; showInlineError(TextUtils.isEmpty(message) ? fallback : message); @@ -7355,6 +7746,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. subtitlePlaybackSession.stop(this); inlineStartPosition = C.TIME_UNSET; inlineStartPositionApplied = false; + // 播放器已清空:窗口态回安全默认,避免残留的 false 永久禁用后续同集进度更新。 + inlinePlaybackSettled = true; + inlinePlayerMediaReady = true; pendingInlineResult = null; currentInlineResult = null; inlinePlaybackEpisode = null; @@ -7376,6 +7770,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void startInlinePlayer(Result result, long resumePosition) { inlinePlaybackPending = false; + // 切集窗口在此关闭:本次解析结果就属于当前选中集,此后实时进度重新允许写回 history。 + // 放在 NovelRouter 拦截之前——阅读器接管后播放器会被清空,进度写入自然早退; + // 若放在拦截之后,窗口态会泄漏到阅读器返回后的同集播放,永远禁用进度更新。 + inlinePlaybackSettled = true; // 决定性拦截:play_url 协议为 novel:// / pics:// / manga:// → 直启阅读器,不再喂给内联 player // (选集 / quality 切换 / 重连 / resume 全走此汇聚点。) if (NovelRouter.guardInlinePlay(this, result, @@ -7413,6 +7811,12 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. hideInlineControls(); resetInlineShortDramaMode(); updateInlineButtons(false); + // 媒体就绪态在此失效:stop 之后、新集 READY 之前,getPosition() 仍返回旧集的残留 + // 位置(Exo stop 不归零,直到新 media prepare 完成)。这段加载期里每秒 tick 若把 + // 播放器读数写回 history,旧集的末尾位置就会记到新集名下——新集 READY 后 + // applyInlineStartPosition 会顺着它 seekTo 到上一集看过的位置,表现为「下一集从 + // 上一集的进度开始」。短剧快切尤其必现(解析快,tick 大概率落在加载窗口内)。 + inlinePlayerMediaReady = false; player().stop(); player().clear(); if (resumePosition == C.TIME_UNSET) resetInlineHistoryIfNearEnding(); @@ -7550,7 +7954,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private boolean suppressInlineDisplayForLeanbackFusionExit() { - if (!Util.isLeanback() || !isFusionMode()) return false; + if (!Util.isLeanback() || !modeController.isFusionMode()) return false; inlineDisplaySuppressUntil = Math.max(inlineDisplaySuppressUntil, SystemClock.uptimeMillis() + LEANBACK_FUSION_EXIT_DISPLAY_SUPPRESS_MS); hideInlineDisplayPanel(); return true; @@ -7590,13 +7994,28 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. }); } + /** + * 遥控端唤出控制栏时,只有「用户真实选中过的普通控件」才算有效记忆焦点。 + * 片头/片尾是区间快捷入口,一旦被记成「上次焦点」,每次唤出控制栏都会被还原 + * 到片头/片尾,覆盖用户真正选中的那个按钮。与影视原生模式 hasRememberedFocus() + * 保持同一判定。 + */ + private boolean hasRememberedInlineControlFocus() { + return inlineControlFocus != null + && isVisibleInHierarchy(inlineControlFocus) + && inlineControlFocus.isEnabled() + && inlineControlFocus != binding.playerOpening + && inlineControlFocus != binding.playerEnding; + } + private View getInlineControlFocus() { if (Util.isMobile()) { + // 手机端以触控导航为主,保持原有「记住上次控件」语义,不套用遥控端的片头/片尾排除。 if (inlineControlFocus != null && isVisibleInHierarchy(inlineControlFocus) && inlineControlFocus.isEnabled()) return inlineControlFocus; return detailControlView(R.id.play, View.class); } // TV模式:按顺序查找第一个可见且启用的按钮 - if (inlineControlFocus != null && isVisibleInHierarchy(inlineControlFocus) && inlineControlFocus.isEnabled()) return inlineControlFocus; + if (hasRememberedInlineControlFocus()) return inlineControlFocus; View[] candidates = { binding.playerNext, binding.playerPrev, binding.playerEpisodes, binding.playerRefresh, binding.playerChangeSource, binding.playerFullscreenAction @@ -8118,7 +8537,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. protected void onInlineInfo() { if (!hasInlineInfo()) return; - new MaterialAlertDialogBuilder(this) + new WebHtvAlertDialogBuilder(this) .setTitle(inlineTitleText()) .setMessage(buildInlineInfoText()) .setPositiveButton(R.string.dialog_positive, null) @@ -8246,7 +8665,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. int count = currentInlineResult.getUrl().getValues().size(); String[] labels = new String[count]; for (int i = 0; i < count; i++) labels[i] = inlineQualityName(i); - new MaterialAlertDialogBuilder(this) + new WebHtvAlertDialogBuilder(this) .setTitle(R.string.detail_quality) .setSingleChoiceItems(labels, currentInlineResult.getUrl().getPosition(), (dialog, which) -> { dialog.dismiss(); @@ -8352,6 +8771,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void refreshInlinePlayback() { if (selectedFlag == null || selectedEpisode == null) return; if (history != null) history.setPosition(C.TIME_UNSET); + // 与 onReplay 同理:清位后若不开窗,重播前的实时进度会立刻回填,重播失效。 + inlinePlaybackSettled = false; playInline(); } @@ -8668,7 +9089,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. String[] kernels = PlayerKernelDialog.kernels(getResources()); String[] items = Arrays.copyOf(kernels, kernels.length + 1); items[kernels.length] = getString(R.string.player_kernel_external); - new MaterialAlertDialogBuilder(this).setItems(items, (dialog, which) -> onInlinePlayerChoice(kernels, which)).show(); + new WebHtvAlertDialogBuilder(this).setItems(items, (dialog, which) -> onInlinePlayerChoice(kernels, which)).show(); return true; } @@ -8693,6 +9114,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. String episodeUrl = selectedEpisode.getUrl(); if (TextUtils.isEmpty(flag) || TextUtils.isEmpty(episodeUrl)) return false; int generation = ++inlinePlaybackGeneration; + // 只自增代际、不接替请求:在途的取址回调会因“不再是最新请求”直接返回, + // 它留下的加载标记必须在这里释放,否则圈永久留在屏上,且同一集会因 + // isSamePendingInlinePlayback 恒真而无法再次起播。切内核自身的缓冲显示由 + // updateInlineLoading 的引擎条件兜住,不依赖这个标记。 + releaseInlinePlaybackPending(); long position = player().getPosition(); float speed = player().getSpeed(); boolean repeat = player().isRepeatOne(); @@ -8729,7 +9155,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void cancelPendingInlinePlayerSwitch() { inlinePlaybackGeneration++; - updateInlineDisplayPanel(); + // 同 refreshAndSwitchInlinePlayer:作废在途取址请求时必须一并释放它留下的加载标记。 + releaseInlinePlaybackPending(); } private boolean isInlinePlayerSwitchRequestCurrent(int generation, String key, String flag, String episodeUrl) { @@ -9114,7 +9541,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. showPage.accept(pageIndex[0], true); }); - AlertDialog dialog = new MaterialAlertDialogBuilder(this) + AlertDialog dialog = new WebHtvAlertDialogBuilder(this) .setView(content) .create(); holder[0] = dialog; @@ -9296,7 +9723,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. showPage.accept(selectedPage, true); }; - AlertDialog dialog = new MaterialAlertDialogBuilder(this).setView(scroll).create(); + AlertDialog dialog = new WebHtvAlertDialogBuilder(this).setView(scroll).create(); panel.setTag(dialog); dialog.setOnShowListener(value -> render[0].run()); if (!canTouchUi()) return; @@ -9345,7 +9772,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. GradientDrawable background = new GradientDrawable(); background.setCornerRadius(ResUtil.dp2px(4)); background.setColor(focused ? colors.control : selected ? colors.chipActive : colors.chip); - background.setStroke(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : selected ? 2 : CHIP_STROKE_DP), focused ? FOCUS_STROKE : selected ? colors.accent : colors.line); + background.setStroke(focused ? focusRingWidthPx() : ResUtil.dp2px(selected ? 2 : CHIP_STROKE_DP), focused ? focusStroke() : selected ? colors.accent : colors.line); button.setSelected(selected); button.setActivated(selected); button.setTextColor(colors.primary); @@ -9527,8 +9954,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. GradientDrawable background = new GradientDrawable(); background.setCornerRadius(ResUtil.dp2px(6)); if (focused) { - background.setColor(lightTheme ? 0x1AFFD166 : 0x55FFD166); - background.setStroke(ResUtil.dp2px(FOCUS_STROKE_DP), FOCUS_STROKE); + background.setColor(ThemeController.focusRingColor(this, lightTheme ? 0.10f : 0.33f)); + background.setStroke(focusRingWidthPx(), focusStroke()); button.setTextColor(lightTheme ? colors.primary : 0xFFFFFFFF); } else if (selected) { background.setColor(lightTheme ? 0x1F20B866 : 0x332CC56F); @@ -9546,8 +9973,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. GradientDrawable background = new GradientDrawable(); background.setCornerRadius(ResUtil.dp2px(6)); if (focused) { - background.setColor(0x55FFD166); - background.setStroke(ResUtil.dp2px(FOCUS_STROKE_DP), FOCUS_STROKE); + background.setColor(ThemeController.focusRingColor(this, 0.33f)); + background.setStroke(focusRingWidthPx(), focusStroke()); } else { background.setColor(0x00000000); } @@ -9590,9 +10017,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. int text = focused ? (lightTheme ? colors.primary : 0xFFFFFFFF) : colors.primary; button.setTextColor(text); button.setIconTint(ColorStateList.valueOf(text)); - button.setBackgroundTintList(ColorStateList.valueOf(focused ? (lightTheme ? 0x1AFFD166 : 0x55FFD166) : colors.control)); - button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : colors.lineStrong)); - button.setStrokeWidth(ResUtil.dp2px(focused ? 2 : 1)); + button.setBackgroundTintList(ColorStateList.valueOf(focused ? ThemeController.focusRingColor(this, lightTheme ? 0.10f : 0.33f) : colors.control)); + button.setStrokeColor(ColorStateList.valueOf(focused ? focusStroke() : colors.lineStrong)); + button.setStrokeWidth(focused ? focusRingWidthPx() : ResUtil.dp2px(1)); } private void updateInlineEpisodeModeIcon(ImageView icon) { @@ -9681,6 +10108,26 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. return 3; } + /** + * 释放被作废的取流请求留下的加载标记。 + * + *

没有结果回调会再来收圈(回调要么随任务被取消而不执行,要么因请求失效直接返回), + * 标记残留会让加载圈永久留在屏上,并让 {@link #isSamePendingInlinePlayback} 恒真、 + * 同一集无法再次起播。 + */ + private void releaseInlinePlaybackPending() { + if (!inlinePlaybackPending) return; + inlinePlaybackPending = false; + inlinePlaybackPendingGeneration = 0; + updateInlineDisplayPanel(); + } + + /** 只有置位该标记的那个请求才有权释放它,避免旧回调清掉新请求的标记。 */ + private void releaseInlinePlaybackPending(int generation) { + if (inlinePlaybackPendingGeneration != generation) return; + releaseInlinePlaybackPending(); + } + private boolean isSamePendingInlinePlayback(Episode episode) { return inlinePlaybackPending && selectedEpisode != null && selectedEpisode.equals(episode); } @@ -9786,7 +10233,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private boolean shouldShowDetailFullscreenControlsOnReady() { // 详情直放模式:只在首次准备完成时显示控制栏,快进/后退导致的 STATE_READY 不显示 - return detailPlayerActive && !isFusionMode() && inlineFullscreen && !isLock() && !inlineFirstReady; + return detailPlayerActive && !modeController.isFusionMode() && inlineFullscreen && !isLock() && !inlineFirstReady; } private void applyInlineShortDramaMode() { @@ -9911,8 +10358,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void applyInlinePlayerEmbeddedLayout() { if (binding == null) return; // 融合模式上下 margin (22dp/20dp) 比普通模式 (14dp/16dp) 略大 - int topMarginDp = isFusionMode() ? 22 : 14; - int bottomMarginDp = isFusionMode() ? 20 : 16; + int topMarginDp = modeController.isFusionMode() ? 22 : 14; + int bottomMarginDp = modeController.isFusionMode() ? 20 : 16; // TV 版左右贴边(margin=0),mobile 版左右留白 16dp int horizontalMarginDp = Util.isLeanback() ? 0 : 16; FrameLayout.LayoutParams params; @@ -9954,8 +10401,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. ViewGroup.LayoutParams sp = binding.playerPanelSpacer.getLayoutParams(); if (sp == null) return; int target = ResUtil.dp2px(252); - int topMargin = ResUtil.dp2px(isFusionMode() ? 22 : 14); - int bottomMargin = ResUtil.dp2px(isFusionMode() ? 20 : 16); + int topMargin = ResUtil.dp2px(modeController.isFusionMode() ? 22 : 14); + int bottomMargin = ResUtil.dp2px(modeController.isFusionMode() ? 20 : 16); boolean changed = sp.height != target; if (sp instanceof ViewGroup.MarginLayoutParams marginParams) { if (marginParams.topMargin != topMargin || marginParams.bottomMargin != bottomMargin) { @@ -10125,6 +10572,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. saveInlineHistory(); stopInlinePlaybackSync(); inlinePlaybackGeneration++; + inlinePlaybackSettled = true; introSkipPlayback.reset(); subtitlePlaybackSession.stop(this); hideInlineControls(); @@ -10476,8 +10924,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } if (handleInlineSeekKey(event)) return true; if (KeyUtil.isMenuKey(event) && (inlineFullscreen || isInlineControlsVisible())) { + int menuKey = Setting.getFullscreenMenuKey(); if (isInlineControlsVisible()) hideInlineControls(); - else if (Setting.getFullscreenMenuKey() == 1) showInlineEpisodes(); + else if (menuKey == Setting.FULLSCREEN_MENU_KEY_EPISODES) showInlineEpisodes(); + else if (menuKey == Setting.FULLSCREEN_MENU_KEY_NEXT_EPISODE) checkInlineNext(); else showInlineControls(true); return true; } @@ -10701,7 +11151,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override protected void onFirstFrameRendered() { recordInlinePlayHealth(true, ""); - if (!detailPlayerFullscreenPending || !isPlayerMode() || !inlineStarted || !isOwner()) return; + if (!detailPlayerFullscreenPending || !modeController.isPlayerMode() || !inlineStarted || !isOwner()) return; revealDetailPlayerFullscreen(); } @@ -10735,6 +11185,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. protected void onStateChanged(int state) { if (!isInlinePlayerMode()) return; if (state == Player.STATE_READY) { + // 新集媒体真正就绪:此后播放器读数才属于本集,进度写入重新放行。 + inlinePlayerMediaReady = true; hideInlineControls(); player().reset(); boolean pendingResumeSeekApplied = applyInlineStartPosition(); @@ -10928,6 +11380,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. introSkipPlayback.reset(); loadGeneration++; cancelAiSeasonAnalysis(false); + detailPrefetch.cancel(); detailTasks.close(); subtitlePlaybackSession.stop(this); if (inlinePiPLayout) exitInlinePiPLayout(); @@ -10936,6 +11389,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. App.removeCallbacks(inlineHideControls); App.removeCallbacks(inlineKeySeekEnd); EpisodeTitlePopup.dismiss(); + removeCardRowFocusMargin(); saveInlineHistory(); stopInlinePlaybackSync(); // 确保内嵌播放退出时停止播放,避免声音继续(与 VideoActivity 保持一致) @@ -11017,7 +11471,11 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. if (!isInlinePlayerMode() || !inlineStarted || !isOwner() || history == null) return; // 保存当前集的播放位置到缓存 - if (!TextUtils.isEmpty(history.getVodRemarks()) && !skipEpisodePositionCache() && service() != null && player() != null && !player().isReleased()) { + if (!isInlinePlayerSettledOnSelection()) { + // 切集解析窗口期:history 已被 updateInlineHistory 重指向新集,而播放器仍停留在 + // 旧集。此刻写入会把旧集的实时位置记到新集名下,起播时直接跳到上一集看过的位置。 + // onPlay() 在窗口开始前已按旧集完整保存过一次,这里跳过不会丢任何进度。 + } else if (!TextUtils.isEmpty(history.getVodRemarks()) && !skipEpisodePositionCache() && service() != null && player() != null && !player().isReleased()) { EpisodePositionCache.get().put( getKeyText(), getIdText(), @@ -11074,11 +11532,39 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void updateInlineHistoryProgress(long time, long position, long duration) { if (history == null) return; + if (!inlinePlaybackSettled || !isInlinePlayerCurrentMediaReady()) { + // 切集解析窗口期:position/duration 仍来自上一集,写进已指向新集的 history + // 就是「下一集从上一集的位置开播」的直接来源。加载期同理:stop 后到新集 READY + // 前,播放器读数仍是旧集残留。 + history.setCreateTime(time); + return; + } history.setCreateTime(time); if (position > 0) history.setPosition(position); if (duration > 0) history.setDuration(duration); } + /** 播放器已实际承载本集媒体且进入 READY;期间任何进度写入都可能是旧集残留。 */ + private boolean isInlinePlayerCurrentMediaReady() { + return inlinePlayerMediaReady; + } + + /** + * 内联播放器当前实际承载的媒体是否就是选中的那一集。 + *

+ * 沉浸融合/详情直放切集走异步解析:{@code updateInlineHistory} 已把 history 重指向新集, + * 但在解析结果返回并 {@code startInlinePlayer} 之前,播放器仍停留在旧集上继续走秒。 + * 这个窗口内任何把播放器实时进度写回 history 的操作都会把旧集位置记到新集名下, + * 新集起播时会顺着 getInlineResumePosition() 续播到上一集看过的位置。 + *

+ * 与 {@link #isCurrentInlinePlayback(Episode)} 的区别:那个还要求「已起播」 + * (inlineStarted 且 currentInlineResult 就绪),本守卫只关心「播放器身份与选中集 + * 一致」,因此同样覆盖 startInlinePlayer 之后、首个 READY 之前的加载段。 + */ + private boolean isInlinePlayerSettledOnSelection() { + return inlinePlaybackSettled && isInlinePlayerCurrentMediaReady(); + } + /** * 用户显式换内核后记住选定值。 * 记的是用户选的值而不是引擎状态:引擎会被播放失败后的自动回退改掉, @@ -11158,7 +11644,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. introSkipPlayback.setSkipConfirmListener((segment, action) -> { if (isFinishing() || isDestroyed()) return false; if (introSkipConfirmDialog != null && introSkipConfirmDialog.isShowing()) return false; - introSkipConfirmDialog = new MaterialAlertDialogBuilder(this) + introSkipConfirmDialog = new WebHtvAlertDialogBuilder(this) .setTitle(R.string.intro_skip_confirm_title) .setMessage(IntroSkipKinds.confirmMessage(segment)) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) @@ -11210,6 +11696,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. long position = player().getPosition(); long duration = player().getDuration(); boolean canUpdateProgress = inlineStartPositionApplied || getInlineStartPosition() <= 0; + if (canUpdateProgress) canUpdateProgress = isInlinePlayerSettledOnSelection(); if (canUpdateProgress) { updateInlineHistoryProgress(time, position, duration); } else { @@ -11242,6 +11729,9 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. @Override public void onReplay() { if (history != null) history.setPosition(C.TIME_UNSET); + // 同集从头重播也必须开窗:否则窗口期 tick 会把当前实时位置写回 history, + // 起播时 getInlineResumePosition() 又读回同一位置,「重播」退化为「续播」。 + inlinePlaybackSettled = false; playInline(); } }; @@ -11257,8 +11747,14 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. boolean sameFlag = TextUtils.equals(history.getVodFlag(), selectedFlag.getFlag()); if (inlineStarted && (!sameEpisode || !sameFlag)) stopInlinePlaybackSync(); + // 同集换线路也要开窗:播放器仍承载旧线路的解析结果,窗口期写回会把旧位置 + // 续到新线路的同一集上;开窗后由 startInlinePlayer 或 stopInlinePlayerForReload 关闭。 + if (sameEpisode && !sameFlag) inlinePlaybackSettled = false; if (!sameEpisode) { - // 保存当前集的播放位置到缓存 + // 从这里起到 startInlinePlayer 真正接管播放器为止,播放器仍停留在旧集, + // 而 history 已指向新集:禁止把播放器实时进度写回(见 inlinePlaybackSettled)。 + inlinePlaybackSettled = false; + // 保存当前集的播放位置到缓存(此时 history 仍是旧集身份,写入是安全的) boolean skipCache = skipEpisodePositionCache(); if (!TextUtils.isEmpty(history.getVodRemarks()) && !skipCache && service() != null && player() != null && !player().isReleased()) { EpisodePositionCache.get().put( @@ -11329,16 +11825,50 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. 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); + if (isFollowed(existing)) { + // 详情页与播放页统一为就地开关:再次点击「已追更」立即取消,绝不跳转追更页。 + cancelFollowing(identityKey); return; } addFollowing(tmdb, siteKey, vodId, season, identityKey); }); } + /** + * 是否处于「已追更」状态。 + *

+ * `resolveFollowing` 走 TMDB 身份迁移路径时,为防复活会**故意返回墓碑行** + * (`FollowingStore.resolveTmdb` 的墓碑守卫,C9 语义),因此这里不能只用 `!= null` + * 判断:否则取消追更后按钮仍显示「已追更」,内联播放中再点只会重复写墓碑、 + * 永远无法重新追更。墓碑行必须视为未追更,交给 `addFollowing` 走复活路径。 + */ + private boolean isFollowed(Following item) { + return item != null && !item.isDeleted(); + } + + /** + * 详情页取消追更:写墓碑、取消该条 one-shot 检查,然后立即刷新按钮状态。 + *

+ * 无论是否处于内联播放,本页都必须就地生效而不是跳转追更页:跳页既会打断播放, + * 也会把「取消」这种一步操作变成两步,与播放页(mobile/leanback `VideoActivity`)的 + * 就地开关语义保持一致(见 `docs/FOLLOW-1-following-updates-design.md` 8.3.1)。 + */ + private void cancelFollowing(String identityKey) { + FollowingScheduler.cancelNext(this, identityKey); + FollowingPlaybackBridge.deleteAsync(identityKey, error -> { + followingActionPending = false; + if (isFinishing() || isDestroyed()) return; + if (error != null) { + setFollowingButtonsEnabled(true); + Notify.show(error.getMessage()); + return; + } + updateFollowingState(); + FollowingPlaybackBridge.refreshUnreadCountAsync(null); + Notify.show(R.string.following_canceled); + }); + } + private void addFollowing(TmdbItem tmdb, String siteKey, String vodId, int season, String identityKey) { long now = System.currentTimeMillis(); Following item = new Following(); @@ -11458,7 +11988,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. applyFollowingButtonState(true, false); resolveFollowing(tmdb, identityKey, getKeyText(), getIdText(), season, item -> { if (generation != followingUiGeneration || isFinishing() || isDestroyed()) return; - applyFollowingButtonState(true, item != null); + applyFollowingButtonState(true, isFollowed(item)); }); } @@ -11678,6 +12208,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void wireInlineQuickSearchExtras(Class dialogClass, Object dialog) { invokeQuiet(dialog, "title", new Class[]{String.class}, getString(R.string.play_search) + " " + inlineSearchKeyword); invokeQuiet(dialog, "keyword", new Class[]{String.class}, inlineSearchKeyword); + invokeQuiet(dialog, "currentSiteKey", new Class[]{String.class}, getKeyText()); // 手机版:弹层内改关键词重搜 + 自有 dismiss 接口 bindProxyListener(dialogClass, dialog, "searchListener", dialogClass.getName() + "$OnSearchListener", args -> restartInlineSourceSearch(args == null || args.length != 1 ? "" : String.valueOf(args[0]))); @@ -11787,7 +12318,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } private void openMatchedDetail(Site site, Vod match, TmdbItem item) { - if (isFusionMode()) { + if (modeController.isFusionMode()) { switchSourceDetail(site, match, item, ""); return; } @@ -11834,7 +12365,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. TmdbBundle reusableBundle = canReuseTmdbBundle(item) ? activeTmdbBundle : null; Intent intent = new Intent(getIntent()); intent.putExtra("detail_mode", getDetailMode()); - intent.putExtra("fusion", isFusionMode()); + intent.putExtra("fusion", modeController.isFusionMode()); intent.putExtra("key", site.getKey()); intent.putExtra("id", match.getId()); intent.putExtra("name", match.getName()); @@ -12096,16 +12627,18 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. TmdbCandidate second = null; int count = Math.min(6, candidates.size()); logTmdbMatch("详情消歧开始拉取候选详情:候选数量=%d,拉取上限=%d", candidates.size(), count); + JsonObject[] details = fetchDisambiguationDetails(candidates, count); for (int i = 0; i < count; i++) { TmdbCandidate candidate = candidates.get(i); + JsonObject detail = details[i]; + if (detail == null) continue; try { - TmdbBundle bundle = loadTmdbBundle(candidate.item); - int peopleScore = hasSourcePeople(sourceVod) ? scoreTmdbPeople(bundle.detail(), sourceVod) : 0; - int splitSeasonScore = TmdbMatchPolicy.splitSeasonDetailScore(tmdbMatchSourceText(keyword, sourceVod), bundle.detail()); + int peopleScore = hasSourcePeople(sourceVod) ? scoreTmdbPeople(detail, sourceVod) : 0; + int splitSeasonScore = TmdbMatchPolicy.splitSeasonDetailScore(tmdbMatchSourceText(keyword, sourceVod), detail); int score = candidate.score + peopleScore + splitSeasonScore; logTmdbMatch("详情消歧候选:标题=%s,年份=%d,基础分=%d,演员主创分=%d,分季分=%d,总分=%d", candidate.item.getTitle(), tmdbItemYear(candidate.item), candidate.score, peopleScore, splitSeasonScore, score); - TmdbCandidate scored = new TmdbCandidate(candidate.item, candidate.titleScore, score, bundle); + TmdbCandidate scored = new TmdbCandidate(candidate.item, candidate.titleScore, score, detail); if (best == null || scored.score > best.score) { second = best; best = scored; @@ -12113,10 +12646,10 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. second = scored; } } catch (Throwable ignored) { - logTmdbMatch("详情消歧候选加载失败:标题=%s,错误=%s", candidate.item.getTitle(), ignored.getMessage()); + logTmdbMatch("详情消歧候选打分失败:标题=%s,错误=%s", candidate.item.getTitle(), ignored.getMessage()); } } - if (best == null || best.bundle == null) { + if (best == null || best.detail == null) { logTmdbMatch("详情消歧结束:没有成功加载的候选详情"); return Setting.isTmdbSmartMatch() ? chooseSmartTmdbBundle(items, keyword, sourceVod) : null; } @@ -12124,10 +12657,53 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. boolean accepted = best.score >= 420 && gap >= 50; logTmdbMatch("详情消歧判定:最佳=%s(%d年, 分=%d),第二=%s,分差=%d,结果=%s", best.item.getTitle(), tmdbItemYear(best.item), best.score, second == null ? "无" : second.item.getTitle() + "(" + second.score + ")", gap, accepted ? "通过" : "不通过"); - if (accepted) return best.bundle; + if (accepted) return loadDisambiguationWinner(best.item); return Setting.isTmdbSmartMatch() ? chooseSmartTmdbBundle(items, keyword, sourceVod) : null; } + /** + * 并行拉取消歧候选的 lite 详情,按候选原顺序返回(失败为 {@code null})。 + *

+ * 每次最多 {@link #DISAMBIGUATION_CONCURRENCY} 个在途请求;打分仍按候选原顺序串行执行, + * 因此排序、分差判定与改造前完全一致。lite 详情只含 {@code credits}/{@code aggregate_credits}, + * 人物打分输入不变。 + */ + private JsonObject[] fetchDisambiguationDetails(List candidates, int count) { + JsonObject[] details = new JsonObject[count]; + for (int start = 0; start < count; start += DISAMBIGUATION_CONCURRENCY) { + int end = Math.min(count, start + DISAMBIGUATION_CONCURRENCY); + List> futures = new ArrayList<>(end - start); + for (int i = start; i < end; i++) { + TmdbItem item = candidates.get(i).item; + futures.add(detailTasks.submitCallable(Task.largeExecutor(), + () -> tmdbService.detailForDisambiguation(item, tmdbConfig))); + } + for (int i = start; i < end; i++) { + TmdbItem item = candidates.get(i).item; + try { + details[i] = futures.get(i - start).get(); + } catch (Throwable e) { + logTmdbMatch("详情消歧候选加载失败:标题=%s,错误=%s", item.getTitle(), e.getMessage()); + } + } + } + return details; + } + + /** + * 消歧阶段只拿到 lite 详情;最终赢家必须回到完整详情(images/translations/recommendations 等) + * 才能渲染详情页。完整详情多命中一次 7 天文件缓存时不会产生网络请求。 + */ + @Nullable + private TmdbBundle loadDisambiguationWinner(TmdbItem item) { + try { + return loadTmdbBundle(item); + } catch (Throwable e) { + logTmdbMatch("详情消歧赢家完整详情加载失败:标题=%s,错误=%s", item.getTitle(), e.getMessage()); + return null; + } + } + private List scoreTmdbCandidates(List items, String keyword, @Nullable Vod sourceVod) { List candidates = new ArrayList<>(); if (items == null || items.isEmpty()) return candidates; @@ -12175,19 +12751,21 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. List scored = new ArrayList<>(); int count = Math.min(6, candidates.size()); logTmdbMatch("智能匹配开始拉取候选详情:关键词=%s,候选数量=%d,拉取上限=%d", keyword, candidates.size(), count); + JsonObject[] details = fetchDisambiguationDetails(candidates, count); for (int i = 0; i < count; i++) { TmdbCandidate candidate = candidates.get(i); + JsonObject detail = details[i]; + if (detail == null) continue; try { - TmdbBundle bundle = loadTmdbBundle(candidate.item); - int detailScore = scoreTmdbCountry(bundle.detail(), sourceVod) - + scoreTmdbPeople(bundle.detail(), sourceVod) - + TmdbMatchPolicy.splitSeasonDetailScore(tmdbMatchSourceText(keyword, sourceVod), bundle.detail()); + int detailScore = scoreTmdbCountry(detail, sourceVod) + + scoreTmdbPeople(detail, sourceVod) + + TmdbMatchPolicy.splitSeasonDetailScore(tmdbMatchSourceText(keyword, sourceVod), detail); int score = candidate.score + detailScore; logTmdbMatch("智能匹配候选:标题=%s,媒体=%s,年份=%d,基础分=%d,详情分=%d,总分=%d", candidate.item.getTitle(), candidate.item.getMediaType(), tmdbItemYear(candidate.item), candidate.score, detailScore, score); - scored.add(new TmdbCandidate(candidate.item, candidate.titleScore, score, bundle)); + scored.add(new TmdbCandidate(candidate.item, candidate.titleScore, score, detail)); } catch (Throwable ignored) { - logTmdbMatch("智能匹配候选加载失败:标题=%s,错误=%s", candidate.item.getTitle(), ignored.getMessage()); + logTmdbMatch("智能匹配候选打分失败:标题=%s,错误=%s", candidate.item.getTitle(), ignored.getMessage()); } } if (scored.isEmpty()) { @@ -12202,7 +12780,7 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. boolean accepted = best.score >= smartAcceptScore(sourceYear) && (gap >= 25 || isUniqueNewest(best, scored, sourceYear)); logTmdbMatch("智能匹配判定:最佳=%s(%d年, 分=%d),第二=%s,分差=%d,结果=%s", best.item.getTitle(), tmdbItemYear(best.item), best.score, second == null ? "无" : second.item.getTitle() + "(" + second.score + ")", gap, accepted ? "通过" : "不通过"); - return accepted ? best.bundle : null; + return accepted ? loadDisambiguationWinner(best.item) : null; } private List scoreSmartTmdbCandidates(List items, String keyword, @Nullable Vod sourceVod) { @@ -12723,7 +13301,14 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void setChipState(MaterialButton button, boolean selected) { ThemeColors colors = lightTheme ? ThemeColors.light() : ThemeColors.dark(); button.setTextColor(colors.primary); - button.setBackgroundColor(selected ? colors.chipActive : colors.chip); + // 必须走 backgroundTint 通道,不能用 setBackgroundColor。 + // setBackgroundColor 只把颜色写进当前那个 MaterialShapeDrawable 实例, + // 而 Material 之后会因 inset/圆角/测量变化重建背景,重建时用的是 + // backgroundTint 字段(这些代码新建的按钮该字段仍是透明),于是填充被 + // 悄悄丢回全透明——线路与选集 chip 因此只剩描边、没有底色,压在亮色剧照 + // 上就看不清。setBackgroundTintList 写的是那个持久字段,重建后依然生效, + // 这也是同一页里选集「第 N 季」按钮一直有底色的原因。 + button.setBackgroundTintList(ColorStateList.valueOf(selected ? colors.chipActive : colors.chip)); button.setOnFocusChangeListener(null); applyChipFocus(button, selected, button.hasFocus(), colors); button.setOnFocusChangeListener((view, focused) -> applyChipFocus(button, selected, focused, colors)); @@ -12731,8 +13316,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. private void applyChipFocus(MaterialButton button, boolean selected, boolean focused, ThemeColors colors) { button.setSelected(!Util.isLeanback() || selected || focused); - button.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : (selected ? 2 : CHIP_STROKE_DP))); - button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : (selected ? colors.accent : colors.line))); + button.setStrokeWidth(focused ? focusRingWidthPx() : ResUtil.dp2px(selected ? 2 : CHIP_STROKE_DP)); + button.setStrokeColor(ColorStateList.valueOf(focused ? focusStroke() : (selected ? colors.accent : colors.line))); } private void styleMetaChips() { @@ -13199,7 +13784,8 @@ public class TmdbDetailActivity extends PlaybackActivity implements TrackDialog. } } - private record TmdbCandidate(TmdbItem item, int titleScore, int score, @Nullable TmdbBundle bundle) { + /** 消歧候选的 lite 详情(只用于打分);赢家再取完整详情渲染。 */ + private record TmdbCandidate(TmdbItem item, int titleScore, int score, @Nullable JsonObject detail) { } private record RatingChipTag(String platform, int color) { 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..84c20de187 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 @@ -46,6 +46,8 @@ import com.fongmi.android.tv.bean.Vod; import com.fongmi.android.tv.databinding.ActivityTmdbPersonBinding; import com.fongmi.android.tv.service.TmdbService; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.TmdbPersonPhotoAdapter; import com.fongmi.android.tv.ui.adapter.TmdbWorkAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; @@ -57,7 +59,6 @@ import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.utils.TmdbImageSaver; import com.fongmi.android.tv.utils.Util; import com.google.android.material.button.MaterialButton; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -71,7 +72,6 @@ import java.util.Set; public class TmdbPersonActivity extends BaseActivity { - private static final int FOCUS_STROKE = 0xFFFFD166; private static final int FOCUS_STROKE_DP = 3; private static final int PHOTO_STROKE_DP = 1; @@ -595,7 +595,7 @@ public class TmdbPersonActivity extends BaseActivity { 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 = focused ? ThemeController.focusRingColor(this) : selected ? 0xFF6DA8E8 : (light ? 0x33424B57 : 0x33FFFFFF); button.setTextColor(fg); button.setBackgroundTintList(ColorStateList.valueOf(bg)); button.setStrokeColor(ColorStateList.valueOf(stroke)); @@ -834,11 +834,11 @@ public class TmdbPersonActivity extends BaseActivity { private void applyPhotoButtonFocus(MaterialButton button, boolean focused) { button.setBackgroundTintList(ColorStateList.valueOf(focused ? 0x33FFFFFF : 0x18FFFFFF)); button.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : PHOTO_STROKE_DP)); - button.setStrokeColor(ColorStateList.valueOf(focused ? FOCUS_STROKE : 0x4DFFFFFF)); + button.setStrokeColor(ColorStateList.valueOf(focused ? ThemeController.focusRingColor(this) : 0x4DFFFFFF)); } private void showPhotoActionDialog(String url) { - new MaterialAlertDialogBuilder(this) + new WebHtvAlertDialogBuilder(this) .setItems(new CharSequence[]{getString(R.string.detail_image_save)}, (dialog, which) -> savePhoto(url, null)) .show(); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/activity/VideoSurfaceBinding.java b/app/src/main/java/com/fongmi/android/tv/ui/activity/VideoSurfaceBinding.java new file mode 100644 index 0000000000..885b3ad636 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ui/activity/VideoSurfaceBinding.java @@ -0,0 +1,54 @@ +package com.fongmi.android.tv.ui.activity; + +import androidx.annotation.Nullable; + +/** + * 把「当前这个引擎该不该挂到 PlayerView 上」从 {@code PlaybackActivity#attachSurface(boolean)} 里 + * 拆出来,变成一个可单测的纯判定。 + * + *

背景(设备日志 {@code webhtv-debug-log (27).txt},2026-10-21 21:50):自动连播时服务侧 + * {@code PlaybackService#startBrowse} 用「浏览下一集」的 {@code VE:...} 键起播,Activity 侧 + * {@code isOwner()} 随之为假,重建回调 {@code onPlayerRebuild()} 整段被跳过;于是 + * {@code attachSurface()} 既没走 {@code render != targetRender} 的重绑分支,也没走「引擎实例已不同」 + * 的强制重绑分支,新引擎从未拿到 Surface —— 解码器可初始化、能到 READY、音频正常推进,但视频帧 + * 零提交(全黑有声)。手工重开 App 会重建整页与整条绑定链,所以「重开就正常」。 + * + *

结论:重绑条件不能依赖「Activity 是否自认归属」,只能依赖「PlayerView 上挂着的引擎实例是否 + * 就是当前引擎」。本类只用实例身份回答这一个问题,不关心引擎类型,因此可被单元测试直接证伪。 + */ +final class VideoSurfaceBinding { + + private VideoSurfaceBinding() { + } + + /** + * 一次绑定判定的结果。 + * + * @param applied 是否需要在视图上执行绑定/解绑 + * @param detachedFirst 是否需要先把旧引擎解绑再绑新引擎(旧引擎非空时为真,保证顺序确定) + * @param player 判定后视图应当挂着的引擎实例 + */ + record Result(boolean applied, boolean detachedFirst, @Nullable T player) { + + static Result skipped(@Nullable T player) { + return new Result<>(false, false, player); + } + + static Result rebound(@Nullable T player, boolean detachedFirst) { + return new Result<>(true, detachedFirst, player); + } + } + + /** + * 判定是否把 {@code next} 绑到视图上。 + * + * @param current 视图当前挂着的引擎实例(可能已被 {@code release()},但仍非 null) + * @param next 当前会话应当挂着的引擎实例 + */ + static Result resolve(@Nullable T current, @Nullable T next) { + // 实例身份比较:口径是「视图上挂着的引擎是不是当前引擎」。这里刻意不用 equals, + // 免得将来某个 Player 实现自定义相等语义,把两个不同实例误判为同一个而漏绑。 + if (current == next) return Result.skipped(next); + return Result.rebound(next, current != null); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/adapter/BaseDiffAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/adapter/BaseDiffAdapter.java index 0c535dffe8..fe473b8b91 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/adapter/BaseDiffAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/adapter/BaseDiffAdapter.java @@ -17,10 +17,38 @@ public abstract class BaseDiffAdapter, VH extends Recycler protected final AsyncListDiffer differ; + /** + * 列表数量变化后的回调。 + * + *

为什么放在这里。本仓库反复出现过同一类缺陷(共 5 处):删除/清空之后宿主忘了 + * 驱动状态机,于是列表空了但页面留白——连空态都不出现。根因是"驱动状态机"这件事被留给了 + * 每个调用点,而调用点很容易漏(单条删除、删除全部、删除后又刷新,各是一个入口)。 + * + *

所有变更最终都经过 {@code setItems(...)},而它的回调在 diff **应用完成**后才触发 + * ({@code AsyncListDiffer} 是异步的,只有那时 {@code getItemCount()} 才是新值)。 + * 因此把回调挂在这里:宿主只需注册一次,之后无论从哪条路径改动列表, + * 状态都会跟着列表走——不可能再忘。 + */ + public interface OnCountChanged { + void onCountChanged(int count); + } + + private OnCountChanged onCountChanged; + public BaseDiffAdapter() { this.differ = new AsyncListDiffer<>(this, new BaseItemCallback()); } + /** 注册列表数量变化回调;传 {@code null} 取消注册。 */ + public void setOnCountChanged(OnCountChanged listener) { + this.onCountChanged = listener; + } + + private void afterUpdate(Runnable runnable) { + if (runnable != null) runnable.run(); + if (onCountChanged != null) onCountChanged.onCountChanged(getItemCount()); + } + private boolean listsAreSame(List oldList, List newList) { if (oldList.size() != newList.size()) return false; for (int i = 0; i < oldList.size(); i++) { @@ -44,15 +72,20 @@ public abstract class BaseDiffAdapter, VH extends Recycler } public void setItems(List items, Runnable runnable) { - differ.submitList(Objects.requireNonNullElseGet(items, ArrayList::new), runnable); + differ.submitList(Objects.requireNonNullElseGet(items, ArrayList::new), () -> afterUpdate(runnable)); } public void setItems(List items, Callback callback) { List oldItems = getItems(); List newItems = Objects.requireNonNullElseGet(items, ArrayList::new); boolean hasChange = !listsAreSame(oldItems, newItems); - if (!hasChange) callback.onUpdateFinished(false); - else differ.submitList(newItems, () -> callback.onUpdateFinished(true)); + if (!hasChange) { + callback.onUpdateFinished(false); + afterUpdate(null); + } else differ.submitList(newItems, () -> { + callback.onUpdateFinished(true); + afterUpdate(null); + }); } public void add(T item) { 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 index 869d5b6266..9a016f0491 100644 --- 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 @@ -90,6 +90,7 @@ public class FollowingAdapter extends RecyclerView.Adapter 0) holder.binding.official.setText(holder.itemView.getContext().getString(R.string.following_official, released)); else holder.binding.official.setText(R.string.following_official_unknown); + bindMetadata(holder, item); if (source != null && source.playableEpisode > 0) { holder.binding.source.setText(holder.itemView.getContext().getString(R.string.following_source, source.playableEpisode)); } else { @@ -129,6 +130,25 @@ public class FollowingAdapter extends RecyclerView.Adapter listener.onDelete(item)); } + private void bindMetadata(Holder holder, Following item) { + List parts = new ArrayList<>(); + if (item.seasonTotalEpisodes > 0) { + parts.add(holder.itemView.getContext().getString(R.string.following_season_total, item.seasonTotalEpisodes)); + } + if (item.seriesTotalEpisodes > 0) { + parts.add(holder.itemView.getContext().getString(R.string.following_series_total, item.seriesTotalEpisodes)); + } + if (item.nextAirWeekday > 0) { + parts.add(holder.itemView.getContext().getString(R.string.following_weekday, + holder.itemView.getResources().getStringArray(R.array.following_weekdays)[item.nextAirWeekday - 1])); + } + if (parts.isEmpty()) holder.binding.metadata.setVisibility(View.GONE); + else { + holder.binding.metadata.setVisibility(View.VISIBLE); + holder.binding.metadata.setText(String.join(" · ", parts)); + } + } + @Override public int getItemCount() { return items.size(); @@ -138,6 +158,10 @@ public class FollowingAdapter extends RecyclerView.Adapter= 0 && position < items.size() ? items.get(position) : null; } + public List getItems() { + return new ArrayList<>(items); + } + public void markReadLocally(Collection identityKeys) { if (identityKeys == null || identityKeys.isEmpty()) return; for (int i = 0; i < items.size(); i++) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/adapter/InlineEpisodeAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/adapter/InlineEpisodeAdapter.java index 1d57d52196..7cf34491ba 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/adapter/InlineEpisodeAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/adapter/InlineEpisodeAdapter.java @@ -105,7 +105,10 @@ public class InlineEpisodeAdapter extends RecyclerView.Adapter此前这里写死 {@code FOCUS_STROKE_DP = 3},而详情页/播放页的卡片是**代码挂环** + * (不走 drawable 层),所以前几轮统一 drawable 宽度时漏掉了这批卡片, + * 用户报告「个性推荐等卡片的边框粗细没有改小没有统一」。 + */ + private static int focusStrokePx(MaterialCardView card) { + return card.getContext().getResources() + .getDimensionPixelSize(com.fongmi.android.tv.R.dimen.webhtv_focus_ring_width); } interface FocusCallback { @@ -50,23 +58,26 @@ final class TmdbCardFocusHelper { } private static void apply(MaterialCardView card, boolean focused, int backgroundColor, int strokeColor, int strokeWidthDp) { + int focus = ThemeController.focusRingColor(card.getContext()); + int focusWidth = focusStrokePx(card); card.setCardBackgroundColor(backgroundColor); - card.setStrokeColor(focused ? FOCUS_STROKE : strokeColor); - card.setStrokeWidth(ResUtil.dp2px(focused ? FOCUS_STROKE_DP : strokeWidthDp)); + card.setStrokeColor(focused ? focus : strokeColor); + card.setStrokeWidth(focused ? focusWidth : ResUtil.dp2px(strokeWidthDp)); card.setCardElevation(ResUtil.dp2px(focused ? FOCUS_ELEVATION_DP : 0)); card.setTranslationZ(ResUtil.dp2px(focused ? FOCUS_ELEVATION_DP : 0)); - card.setForeground(focused ? foregroundBorder(card, FOCUS_STROKE, FOCUS_STROKE_DP) : null); + card.setForeground(focused ? foregroundBorder(card, focus, focusWidth) : null); card.animate().cancel(); card.setScaleX(1f); card.setScaleY(1f); } - static GradientDrawable foregroundBorder(MaterialCardView card, int strokeColor, int strokeWidthDp) { + /** 描边宽度接受**像素**值(调用方自己从 token 或 dp 换算),避免再一次写死 dp。 */ + static GradientDrawable foregroundBorder(MaterialCardView card, int strokeColor, int strokeWidthPx) { GradientDrawable drawable = new GradientDrawable(); drawable.setShape(GradientDrawable.RECTANGLE); drawable.setColor(Color.TRANSPARENT); drawable.setCornerRadius(card.getRadius()); - drawable.setStroke(ResUtil.dp2px(strokeWidthDp), strokeColor); + drawable.setStroke(strokeWidthPx, strokeColor); return drawable; } } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapter.java b/app/src/main/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapter.java index 4dd4fb1c05..6be7e1843f 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapter.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/adapter/TmdbEpisodeAdapter.java @@ -17,6 +17,7 @@ import com.fongmi.android.tv.bean.Episode; import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.databinding.AdapterTmdbEpisodeBinding; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.ui.helper.TmdbEpisodeGridPolicy; import com.fongmi.android.tv.ui.helper.TmdbEpisodeMatcher; import com.fongmi.android.tv.utils.EpisodeTitleFormatter; @@ -33,8 +34,6 @@ import java.util.Objects; public class TmdbEpisodeAdapter extends RecyclerView.Adapter { - private static final int FOCUS_STROKE = 0xFFFFD166; - private static final int FOCUS_STROKE_DP = 3; private static final int ACTIVE_STROKE_DP = 2; private static final int FOCUS_ELEVATION_DP = 8; @@ -546,19 +545,27 @@ public class TmdbEpisodeAdapter extends RecyclerView.Adapter { - float scale = focused ? 1.04f : 1.0f; - view.animate().scaleX(scale).scaleY(scale).setDuration(120).start(); - card.setStrokeWidth(ResUtil.dp2px(focused ? 2 : 1)); - card.setStrokeColor(focused ? 0xFFFFD166 : 0x33FFFFFF); + TvFocusScale.applyCompact(view, focused); + // 焦点环宽度必须走唯一 token(此前写死 2dp,前几轮统一时漏掉)。 + card.setStrokeWidth(focused + ? view.getContext().getResources().getDimensionPixelSize(com.fongmi.android.tv.R.dimen.webhtv_focus_ring_width) + : ResUtil.dp2px(1)); + card.setStrokeColor(focused ? ThemeController.focusRingColor(view.getContext()) : 0x33FFFFFF); }); } } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/audio/AudioMiniPlayer.java b/app/src/main/java/com/fongmi/android/tv/ui/audio/AudioMiniPlayer.java index 936dd8f9b0..865c57b77b 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/audio/AudioMiniPlayer.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/audio/AudioMiniPlayer.java @@ -179,6 +179,7 @@ public final class AudioMiniPlayer implements ServiceConnection { } private void applyInsets() { + if (root == null) return; root.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> updateWindowBounds()); if (root.getParent() instanceof View) { ((View) root.getParent()).addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> updateWindowBounds()); @@ -189,7 +190,13 @@ public final class AudioMiniPlayer implements ServiceConnection { return insets; }); ViewCompat.requestApplyInsets(root); - root.post(() -> ViewCompat.requestApplyInsets(root)); + // The host activity can be recreated (theme/language change) before this + // posted runnable executes; capture the view and bail out once detached. + View target = root; + target.post(() -> { + if (root != target) return; + ViewCompat.requestApplyInsets(target); + }); } private void updateWindowBounds() { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomRecyclerView.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomRecyclerView.java index 82fdd58d90..5d71b035c2 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomRecyclerView.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomRecyclerView.java @@ -95,15 +95,38 @@ public class CustomRecyclerView extends RecyclerView { setMeasuredDimension(finalWidth, finalHeight); } + private static final int FOCUS_ATTEMPTS = 5; + private static final long FOCUS_RETRY_DELAY_MS = 50; + private void focus(int position) { + focus(position, FOCUS_ATTEMPTS); + } + + /** + * 把焦点落到指定行,但在布局完成前不放弃。 + * + *

弹层刚 show 时 RecyclerView 往往还没走到 onLayout,{@code findViewHolderForLayoutPosition} + * 会返回 null,原先的实现就直接丢掉这次请求——结果是整张列表没有任何行处于 focus, + * 遥控器上下键既看不到焦点环,也看不出光标停在哪(用户报告「TV 版本视轨/音轨没有焦点显示」)。 + * 电视盒子冷启动时更容易命中这个时序。 + * + *

失败时按 50ms 重试,总时长上限 {@code FOCUS_ATTEMPTS * FOCUS_RETRY_DELAY_MS} = 250ms。 + * 只要列表中已经有子 View 拿到焦点(用户已经开始按键)就立刻停止,不去抢焦点。 + */ + private void focus(int position, int attempts) { ViewHolder holder = findViewHolderForLayoutPosition(position); - if (holder != null) holder.itemView.requestFocus(); + if (holder != null) { + holder.itemView.requestFocus(); + return; + } + if (getFocusedChild() != null || attempts <= 1) return; + postDelayed(() -> focus(position, attempts - 1), FOCUS_RETRY_DELAY_MS); } @Override public void scrollToPosition(int position) { super.scrollToPosition(position); - postDelayed(() -> focus(position), 50); + postDelayed(() -> focus(position), FOCUS_RETRY_DELAY_MS); } @Override diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWallView.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWallView.java index 52e6a56340..70cfcb6e69 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWallView.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWallView.java @@ -28,6 +28,7 @@ import com.fongmi.android.tv.databinding.ViewWallBinding; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.utils.FileUtil; import com.github.catvod.crawler.SpiderDebug; diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWebView.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWebView.java index d990032934..4d86985974 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWebView.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/CustomWebView.java @@ -41,6 +41,7 @@ import com.google.common.net.HttpHeaders; import java.io.ByteArrayInputStream; import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -68,6 +69,8 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL private String from; private String key; private String url; + private Map pageHeaders; + private int mediaProbeCount; public static CustomWebView create(@NonNull Context context) { return new CustomWebView(context); @@ -110,6 +113,7 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL this.from = from; this.key = key; this.url = url; + this.mediaProbeCount = 0; start(headers); return this; } @@ -117,6 +121,8 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL private void start(Map headers) { CookieManager.getInstance().setAcceptThirdPartyCookies(this, true); Map pageHeaders = WebSniffHeaders.forPage(headers, getSettings().getUserAgentString()); + this.pageHeaders = new HashMap<>(pageHeaders); + if (!this.pageHeaders.containsKey(HttpHeaders.REFERER)) this.pageHeaders.put(HttpHeaders.REFERER, url); checkHeader(url, pageHeaders); SpiderDebug.log("webview-parse", "page headers input=%s applied=%s ua=%s", headers == null ? "[]" : headers.keySet(), pageHeaders.keySet(), getSettings().getUserAgentString()); loadUrl(url, pageHeaders); @@ -138,11 +144,22 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL if (TextUtils.isEmpty(host) || isAd(host)) return empty; Map headers = request.getRequestHeaders(); if (url.contains("/cdn-cgi/challenge-platform/")) post(() -> showDialog()); - if (detect && PLAYER.matcher(url).find() && addUrl(url)) onParseAdd(headers, url); - else if (isVideoFormat(url)) onParseSuccess(headers, url); + boolean player = PLAYER.matcher(url).find(); + boolean video = isVideoFormat(url) && !isPlaceholderVideoUrl(url); + if (SpiderDebug.isEnabled() && (player || video || host.contains("hls") || url.contains("m3u8") || url.contains("mp4") || url.contains("video"))) { + SpiderDebug.log("webview-sniff", "from=%s host=%s path=%s player=%s video=%s", from, host, request.getUrl().getPath(), player, video); + } + if (detect && player && addUrl(url)) onParseAdd(headers, url); + else if (video) onParseSuccess(headers, url); return super.shouldInterceptRequest(view, request); } + @Override + public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) { + super.onPageStarted(view, url, favicon); + if (needsWasmSignShim(url)) view.evaluateJavascript(WASM_SIGN_SHIM, null); + } + @Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); @@ -180,6 +197,147 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL }; } + + /** + * 部分解析站(虾米/jx.m3u8.tv/jx.hls.one 等同源引擎)的签名脚本依赖 + * WebAssembly reference-types 与多值返回,只有 Chrome 96+ 的 WebView 才能实例化。 + * 老模拟器 WebView(如 91)实例化失败后签名不会生成,解析接口根本不会被调用, + * 表现为“打开解析页后一直嗅探不到地址”。 + * + * 这里在页面脚本执行前注入同样的签名兜底实现: + * 先按原样调用 WebAssembly.instantiate,成功则完全走原逻辑;只有失败时才回退到 + * 等价的 JS HMAC-SHA256(sign = HMAC(key, be64(tm) || url)), + * 让解析页自己完成后续的请求、解密与播放器构建。 + */ + private static final String WASM_SIGN_SHIM = """ +(function () { + if (window.__whWasmShim) return; + window.__whWasmShim = 1; + if (typeof WebAssembly === 'undefined' || typeof WebAssembly.instantiate !== 'function') return; + var KEY = ']QAK[I]DGUHp4$%,145"0$3&3%4'; + var K0 = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]; + var KT = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]; + + function rotr(x, n) { return ((x >>> n) | (x << (32 - n))) >>> 0; } + + function sha256(bytes) { + var len = bytes.length; + var withPad = new Uint8Array(((len + 9 + 63) >> 6) << 6); + withPad.set(bytes, 0); + withPad[len] = 0x80; + var bitLenHi = Math.floor(len / 0x20000000); + var bitLenLo = (len << 3) >>> 0; + var dv = new DataView(withPad.buffer); + dv.setUint32(withPad.length - 8, bitLenHi); + dv.setUint32(withPad.length - 4, bitLenLo); + var h = K0.slice(0), w = new Array(64); + for (var off = 0; off < withPad.length; off += 64) { + for (var i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4); + for (i = 16; i < 64; i++) { + var s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + var s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + var a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7]; + for (i = 0; i < 64; i++) { + var S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + var ch = (e & f) ^ (~e & g); + var t1 = (hh + S1 + ch + KT[i] + w[i]) >>> 0; + var S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + var mj = (a & b) ^ (a & c) ^ (b & c); + var t2 = (S0 + mj) >>> 0; + hh = g; g = f; f = e; e = (d + t1) >>> 0; + d = c; c = b; b = a; a = (t1 + t2) >>> 0; + } + h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0; + h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0; h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + hh) >>> 0; + } + var out = new Uint8Array(32); + var odv = new DataView(out.buffer); + for (i = 0; i < 8; i++) odv.setUint32(i * 4, h[i]); + return out; + } + + function hmacSha256(keyBytes, msgBytes) { + var block = new Uint8Array(64); + var k = keyBytes.length > 64 ? sha256(keyBytes) : keyBytes; + block.set(k, 0); + var ipad = new Uint8Array(64), opad = new Uint8Array(64); + for (var i = 0; i < 64; i++) { ipad[i] = block[i] ^ 0x36; opad[i] = block[i] ^ 0x5c; } + var inner = new Uint8Array(64 + msgBytes.length); + inner.set(ipad, 0); inner.set(msgBytes, 64); + var ih = sha256(inner); + var outer = new Uint8Array(96); + outer.set(opad, 0); outer.set(ih, 64); + return sha256(outer); + } + + var encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; + function utf8Bytes(str) { + if (encoder) return encoder.encode(str); + var s = unescape(encodeURIComponent(str)), b = new Uint8Array(s.length); + for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i); + return b; + } + + function hex64(tm, url) { + var msg = new Uint8Array(8 + utf8Bytes(url).length); + var v = tm; + for (var i = 7; i >= 0; i--) { msg[i] = Number(v & BigInt(0xff)); v = v >> BigInt(8); } + msg.set(utf8Bytes(url), 8); + return hmacSha256(Uint8Array.from(KEY.split('').map(function (c) { return c.charCodeAt(0); })), msg); + } + + function fallbackInstance() { + var buffer = new ArrayBuffer(1 << 20); + var heap = new Uint8Array(buffer); + var next = 4096; + return { + instance: { + exports: { + memory: { buffer: buffer }, + alloc: function (size) { var p = next; next = (next + size + 15) & ~15; return p; }, + free: function () { }, + gen_sign: function (tm, ptr, len, out) { + var url = ''; + for (var i = 0; i < len; i++) url += String.fromCharCode(heap[ptr + i]); + url = decodeURIComponent(escape(url)); + var sig = hex64(tm, url); + heap.set(sig, out); + return 0; + } + } + } + }; + } + + var real = WebAssembly.instantiate; + WebAssembly.instantiate = function () { + var args = arguments; + var p; + try { p = real.apply(WebAssembly, args); } catch (e) { p = Promise.reject(e); } + return Promise.resolve(p).then(function (r) { return r; }, function () { + try { console.log('[wh-wasm] wasm unavailable, using JS HMAC shim'); } catch (e) { } + return fallbackInstance(); + }); + }; +})(); +"""; + + /** 只有这几个解析站需要 WASM 签名兜底,避免影响其它解析/嗅探站点。 */ + private static boolean needsWasmSignShim(String url) { + if (TextUtils.isEmpty(url)) return false; + String lower = url.toLowerCase(java.util.Locale.ROOT); + return lower.contains("xmflv.com") || lower.contains("m3u8.tv") || lower.contains("hls.one"); + } + private boolean addUrl(String url) { if (urls.size() > MAX_URLS) urls.clear(); return urls.add(url); @@ -210,7 +368,10 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL } private void evaluate(List script, int index) { - if (index >= script.size()) return; + if (index >= script.size()) { + probeDomMedia(0); + return; + } String js = script.get(index); if (TextUtils.isEmpty(js)) { evaluate(script, index + 1); @@ -219,6 +380,45 @@ public class CustomWebView extends WebView implements DialogInterface.OnDismissL } } + /** + * 部分解析页给 video/src 分配的是没有 .m3u8/.mp4 后缀的签名地址, + * 这类地址不会经过 URL 正则嗅探;从 DOM/performance 资源中补采一次。 + */ + private void probeDomMedia(int attempt) { + if (stopped || attempt > 6 || mediaProbeCount++ > 12) return; + String js = "(function(){var a=[];var seen=[];function walk(d){if(!d||seen.indexOf(d)>=0)return;seen.push(d);try{d.querySelectorAll('video,audio,video source,audio source').forEach(function(v){var u=v.currentSrc||v.src;if(u)a.push({u:u,k:'video'});});d.querySelectorAll('iframe,frame').forEach(function(f){try{walk(f.contentDocument);}catch(e){}});try{d.defaultView.performance.getEntriesByType('resource').forEach(function(e){if(e.initiatorType==='video'||e.initiatorType==='audio')a.push({u:e.name,k:'resource'});});}catch(e){}}walk(document);return JSON.stringify(a);})()"; + evaluateJavascript(js, value -> { + try { + String payload = value == null ? "" : new org.json.JSONTokener(value).nextValue().toString(); + org.json.JSONArray array = new org.json.JSONArray(payload); + if (SpiderDebug.isEnabled() && array.length() > 0) SpiderDebug.log("webview-probe", "from=%s attempt=%d candidates=%s", from, attempt, payload); + for (int i = 0; i < array.length(); i++) { + org.json.JSONObject item = array.optJSONObject(i); + if (item == null) continue; + String candidate = item.optString("u", ""); + String kind = item.optString("k", ""); + if (("video".equals(kind) || "resource".equals(kind)) && isHttpMediaCandidate(candidate)) { + onParseSuccess(pageHeaders == null ? new HashMap<>() : new HashMap<>(pageHeaders), candidate); + return; + } + } + } catch (Throwable ignored) { + } + if (!stopped) postDelayed(() -> probeDomMedia(attempt + 1), 1000); + }); + } + + private boolean isHttpMediaCandidate(String candidate) { + if (TextUtils.isEmpty(candidate) || !candidate.regionMatches(true, 0, "http", 0, 4)) return false; + if (candidate.equals(url) || candidate.contains("/favicon")) return false; + return !isPlaceholderVideoUrl(candidate) && (Sniffer.isVideoFormat(candidate) || !candidate.contains(".js") && !candidate.contains(".css") && !candidate.contains(".html")); + } + + private boolean isPlaceholderVideoUrl(String candidate) { + String lower = candidate == null ? "" : candidate.toLowerCase(java.util.Locale.ROOT); + return lower.contains("/404_") || lower.contains("/404.mp4") || lower.contains("/error.mp4"); + } + private boolean isAd(String host) { for (String ad : RuleConfig.get().getAds()) { if (Util.containOrMatch(host, ad)) { 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..20710bcfc6 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 @@ -59,6 +59,16 @@ public class PlayerOsdController { private static volatile String cachedSystemText; private static volatile String cachedWebViewText; private static volatile String cachedHevcDecoderText; + /** + * The verdict threshold for "掉帧较多". It used to be a bare raw count of 60, but media3 + * settles its dropped-frame accumulator whenever the renderer stops, so the count also + * jumps at every pause. The rate is the comparable measure; it matches + * ExoUtil.ENHANCED_DROPPED_FRAMES_PER_SECOND_THRESHOLD, the local downgrade trigger. + */ + private static final double DROPPED_FRAMES_PER_SECOND_DIAGNOSIS = 4d; + /** Below this played time a rate is not yet meaningful; the raw count is used instead. */ + private static final long DROPPED_FRAMES_RATE_MIN_PLAY_TIME_MS = 30_000L; + private static final long DROPPED_FRAMES_RAW_COUNT_DIAGNOSIS = 60; private final SimpleDateFormat timeFormat; private final TextView topLeft; @@ -79,6 +89,7 @@ public class PlayerOsdController { private final DecimalFormat frameFormat; private final DecimalFormat refreshFormat; private final DecimalFormat bitrateFormat; + private final DecimalFormat frameDropRateFormat; private final PlaybackSpeedMeter speedMeter = new PlaybackSpeedMeter(); private long lastSpeedKBps; private String lastSpeedText; @@ -91,6 +102,8 @@ public class PlayerOsdController { public PlayerOsdController(View root, TextView topLeft, TextView topRight, TextView bottomLeft, TextView bottomRight, TextView diagnostics, MiniProgressView miniProgress, Source source, float miniSp) { this.timeFormat = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); this.bitrateFormat = new DecimalFormat("#.0"); + // "#.0" prints 0.5 as ".5"; a rate must keep its leading digit. + this.frameDropRateFormat = new DecimalFormat("0.0"); this.refreshFormat = new DecimalFormat("#.##"); this.frameFormat = new DecimalFormat("#.###"); this.miniProgress = miniProgress; @@ -408,9 +421,10 @@ 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(); + // 只有 Exo 有 analytics 快照;IJK/MPV 走的是 PlayerManager 的引擎无关缓冲追踪器。 + // 原先只对 MPV 分支,IJK 因此落到 Exo 分支读到 Snapshot.empty(),重缓冲计数恒为 0。 + int rebufferCount = player.isExo() ? snapshot.rebufferCount() : player.getRebufferCount(); + long rebufferTotalMs = player.isExo() ? snapshot.rebufferTotalMs() : player.getRebufferTotalMs(); String rebuffer = rebufferCount <= 0 ? "0 次" : rebufferCount + " 次 / " + formatDuration(rebufferTotalMs); long stableThroughput = player.getNetworkProtectionStableThroughput(); long consumption = player.getNetworkProtectionConsumption(); @@ -439,8 +453,9 @@ public class PlayerOsdController { String frameTiming = player.isExo() ? summarizeFrameTiming() : ""; PlayerEngine.VideoPlaybackDetails videoDetails = player.getVideoPlaybackDetails(); + VideoTrackState videoTrack = getVideoTrackState(player); String videoText = summarizeVideo(video, player, - snapshot.videoDecoderName(), getVideoTrackState(player), + snapshot.videoDecoderName(), videoTrack, videoDetails); AudioTrackState audioTrack = getAudioTrackState(player); AudioPlaybackDiagnostics.Snapshot audioDetails = player.getAudioPlaybackDiagnostics(); @@ -456,7 +471,7 @@ public class PlayerOsdController { String frameRateMatch = player.isExo() ? "帧率匹配 开" : ""; String softTune = getSoftDecodeTuneText(player); String playerText = join(" / ", player.getPlayerText(), player.getDecodeText(), render, "隧道" + tunnel, "性能" + performance, frameRateMatch, preload, softTune, player.isExo() ? "兜底开" : ""); - String playback = join(" / ", state, buffer, "重缓冲 " + rebuffer, "掉帧 " + player.getDroppedFrames()); + String playback = join(" / ", state, buffer, "重缓冲 " + rebuffer, droppedFrameText(player)); String startup = getStartupText(player); String error = getErrorText(player, snapshot); String main = join("\n", @@ -474,7 +489,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, videoTrack, audioTrack, audioDetails, localSource))); String extra = join("\n", row("设备", getDeviceText()), row("系统", getSystemText()), @@ -496,12 +511,46 @@ 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) { + /** + * "掉帧 12(1.4/秒)" for EXO. The rate is what the number should be read against: media3 + * settles a renderer's dropped-frame accumulator from onStopped(), so a pause adds + * whatever tail accumulated while playing and a bare total looks like pause damage. + * MPV and the other engines keep reporting their own raw counter unchanged. + */ + private String droppedFrameText(PlayerManager player) { + long dropped = player.getDroppedFrames(); + if (!player.isExo()) return "掉帧 " + dropped; + double rate = PlaybackAnalyticsListener.droppedFramesPerSecond(); + return "掉帧 " + dropped + "(" + frameDropRateFormat.format(rate) + "/秒)"; + } + + /** + * Whether the drop count is worth a verdict. The raw total is only comparable to a fixed + * budget while there is no measured play time to divide by; after that the rate decides, + * so a pause that settles the accumulator no longer reads as decoder pressure. + */ + private boolean excessiveDroppedFrames(PlayerManager player) { + if (!player.isExo()) return player.getDroppedFrames() >= DROPPED_FRAMES_RAW_COUNT_DIAGNOSIS; + long playTimeMs = PlaybackAnalyticsListener.playTimeMs(); + if (playTimeMs < DROPPED_FRAMES_RATE_MIN_PLAY_TIME_MS) { + return player.getDroppedFrames() >= DROPPED_FRAMES_RAW_COUNT_DIAGNOSIS; + } + return PlaybackAnalyticsListener.droppedFramesPerSecond() >= DROPPED_FRAMES_PER_SECOND_DIAGNOSIS; + } + + private String getDiagnosis(PlayerManager player, PlaybackAnalyticsListener.Snapshot snapshot, Format video, VideoTrackState videoTrack, AudioTrackState audioTrack, AudioPlaybackDiagnostics.Snapshot audioDetails, boolean localSource) { if (audioDetails.runtimeState() == AudioPlaybackDiagnostics.RuntimeState.FAILED) return "音频链路失败,先看音频和错误行"; 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 "已发现音轨但未选中,可能无声"; + // A video track without any usable decoder is dropped silently: playback + // keeps advancing with audio and subtitles only, so nothing else in this + // panel would flag it and the verdict would read as healthy. "Handled" is + // the test rather than the unsupported codes alone: EXCEEDS_CAPABILITIES + // drops the track the same way, and it is one of the two states + // PlayerManager.needsSoftDecode reacts to. + if (videoTrack.hasTracks() && !videoTrack.selected() && !videoTrack.isHandled()) return videoDiagnosis(player, videoTrack); if (!localSource) { long mediaBitrate = getMediaBitrate(video, snapshot.audioFormat() != null ? snapshot.audioFormat() : audioTrack.format()); long availableBitrate = snapshot.bandwidthEstimate() > 0 ? snapshot.bandwidthEstimate() : lastSpeedKBps * 1024 * 8; @@ -510,7 +559,7 @@ public class PlayerOsdController { // disk ranges, which reads high enough that this hint could never fire. if (player.isLoading() && player.getNativeBufferedDuration() < 3000) return "缓冲偏少,可能是网络或源响应慢"; } - if (player.getDroppedFrames() >= 60) return "掉帧较多,可能是解码或渲染压力"; + if (excessiveDroppedFrames(player)) 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. @@ -529,6 +578,19 @@ public class PlayerOsdController { return "正常"; } + /** + * The verdict for a video track no renderer can decode. The support code is spelled out the + * way the audio rows and the video summary row already do it, and an encrypted track does not + * get the "switch to software decode" advice: no decoder choice fixes a DRM rejection. + */ + private String videoDiagnosis(PlayerManager player, VideoTrackState videoTrack) { + String support = supportText(videoTrack.support()); + if (videoTrack.support() == C.FORMAT_UNSUPPORTED_DRM) return "视频轨" + support + ",加密轨请更换资源"; + return player.isHardDecode() + ? "视频轨无可用解码器:" + support + ",请切软解或更换资源" + : "视频轨无可用解码器:" + support + ",请更换资源"; + } + private String getSystemGpuText() { GpuLoadMonitor.Snapshot system = GpuLoadMonitor.process().snapshot(); if (!system.available()) return ""; diff --git a/app/src/main/java/com/fongmi/android/tv/ui/custom/ProgressLayout.java b/app/src/main/java/com/fongmi/android/tv/ui/custom/ProgressLayout.java index 8958aab80a..58335c87fc 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/custom/ProgressLayout.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/custom/ProgressLayout.java @@ -6,7 +6,9 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.RelativeLayout; +import android.widget.TextView; +import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.ViewEmptyBinding; import com.fongmi.android.tv.databinding.ViewProgressBinding; @@ -18,12 +20,20 @@ public class ProgressLayout extends RelativeLayout { private static final String TAG_PROGRESS = "ProgressLayout.TAG_PROGRESS"; public enum State { - CONTENT, PROGRESS, EMPTY + CONTENT, PROGRESS, EMPTY, ERROR } private List mContentViews; private View mProgressView; private View mEmptyView; + private int mEmptyTitleRes; + private int mEmptyDescriptionRes; + private int mEmptyActionRes; + private OnClickListener mEmptyActionListener; + private int mErrorTitleRes; + private CharSequence mErrorReason; + private int mErrorRetryRes; + private OnClickListener mErrorRetryListener; private State mState; public ProgressLayout(Context context) { @@ -51,6 +61,140 @@ public class ProgressLayout extends RelativeLayout { mEmptyView.setTag(TAG_PROGRESS); mEmptyView.setVisibility(GONE); addView(mEmptyView, centerParams()); + applyEmptyContent(); + } + /** + * 配置空态内容。 + * + *

设计文档 §9.9 要求空态是「标题 + 解释 + 一个主动作」,并明确「不能只显示一句 + * '暂无数据'」。本方法让宿主按需接入:不调用时渲染结果与改动前完全一致 + * (只有标题、解释与动作保持 GONE),因此既有调用点无需改动。 + * + *

可在任意时刻调用(早于首次显示也可以):空态视图尚未创建时会先记住参数, + * 在创建时应用。 + * + * @param titleRes 标题;传 0 表示沿用布局里的默认文案 + * @param descriptionRes 解释;传 0 表示不显示 + * @param actionRes 主按钮文案;传 0 或不传监听器表示不显示按钮 + * @param listener 主按钮动作;为空则不显示按钮(避免出现点了没反应的死按钮) + */ + public void setEmptyContent(int titleRes, int descriptionRes, int actionRes, OnClickListener listener) { + mEmptyTitleRes = titleRes; + mEmptyDescriptionRes = descriptionRes; + mEmptyActionRes = actionRes; + mEmptyActionListener = listener; + applyEmptyContent(); + } + + private void applyEmptyContent() { + if (mEmptyView == null) return; + // 用 findViewById 而不是 ViewEmptyBinding 的字段:view_empty.xml 是**分 flavor** 的, + // mobile 版没有 description/action 两个控件,而本类在 main 源集。直接引用 binding + // 字段会让 mobile 编译失败;这里按 id 查找并对缺失做空值守卫,于是 TV 端拿到完整空态、 + // 手机端保持原样(传进来的内容会被静默忽略)。 + View illustration = mEmptyView.findViewById(R.id.illustration); + TextView title = mEmptyView.findViewById(R.id.text); + TextView description = mEmptyView.findViewById(R.id.description); + TextView action = mEmptyView.findViewById(R.id.action); + // 从错误态回到空态时要把外观复原:插画显示、标题回到常规前景色。 + if (illustration != null) illustration.setVisibility(VISIBLE); + if (title != null) { + title.setTextColor(themeColor(R.attr.webhtvColorOnWallpaper)); + if (mEmptyTitleRes != 0) title.setText(mEmptyTitleRes); + } + if (description != null) { + if (mEmptyDescriptionRes != 0) { + description.setText(mEmptyDescriptionRes); + description.setVisibility(VISIBLE); + } else { + description.setVisibility(GONE); + } + } + if (action != null) { + // 没有动作就不显示按钮:避免出现点了没反应的死按钮。 + if (mEmptyActionRes != 0 && mEmptyActionListener != null) { + action.setText(mEmptyActionRes); + action.setOnClickListener(mEmptyActionListener); + action.setVisibility(VISIBLE); + } else { + action.setOnClickListener(null); + action.setVisibility(GONE); + } + } + } + + /** + * 进入错误态:设计文档 §9.9「错误态:错误色 + 原因 + 重试动作」。 + * + *

复用空态的三个槽位(标题 / 解释 / 主动作),只做两处区分:标题改用 + * {@code ?attr/webhtvColorOnWallpaperError},并隐藏插画——空态那张"空箱子"表达的是 + * "这里没有内容",用在错误上会误导。 + * + *

原因正文刻意**不**用错误色:错误前景色在壁纸上是 4.11:1(只够 Display 档大字号), + * 而原因是 16sp 正文,需要 4.5:1;它沿用空态的次级壁纸前景色。 + * + * @param titleRes 错误标题;传 0 沿用当前标题 + * @param reasonRes 失败原因;传 0 不显示 + * @param retryRes 重试按钮文案;传 0 或不传监听器则不显示按钮 + * @param retry 重试动作 + */ + public void showError(int titleRes, int reasonRes, int retryRes, OnClickListener retry) { + showError(titleRes, reasonRes == 0 ? null : getContext().getString(reasonRes), retryRes, retry); + } + + /** + * 同上,但原因来自运行时字符串(例如服务端返回的 message)。 + */ + public void showError(int titleRes, CharSequence reason, int retryRes, OnClickListener retry) { + mErrorTitleRes = titleRes; + mErrorReason = reason; + mErrorRetryRes = retryRes; + mErrorRetryListener = retry; + // 已经在错误态时 switchState 会提前返回,但重试再次失败需要刷新原因文案, + // 所以这里显式重放一次。 + if (mState == State.ERROR) applyErrorContent(); + else switchState(State.ERROR); + } + + public boolean isError() { + return mState == State.ERROR; + } + + private void applyErrorContent() { + if (mEmptyView == null) return; + View illustration = mEmptyView.findViewById(R.id.illustration); + TextView title = mEmptyView.findViewById(R.id.text); + TextView description = mEmptyView.findViewById(R.id.description); + TextView action = mEmptyView.findViewById(R.id.action); + if (illustration != null) illustration.setVisibility(GONE); + if (title != null) { + title.setTextColor(themeColor(R.attr.webhtvColorOnWallpaperError)); + if (mErrorTitleRes != 0) title.setText(mErrorTitleRes); + } + if (description != null) { + if (mErrorReason != null && mErrorReason.length() > 0) { + description.setText(mErrorReason); + description.setVisibility(VISIBLE); + } else { + description.setVisibility(GONE); + } + } + if (action != null) { + if (mErrorRetryRes != 0 && mErrorRetryListener != null) { + action.setText(mErrorRetryRes); + action.setOnClickListener(mErrorRetryListener); + action.setVisibility(VISIBLE); + } else { + action.setOnClickListener(null); + action.setVisibility(GONE); + } + } + } + + /** 解析主题属性里的颜色值;未赋值时返回 {@code 0}(透明),调用方自行决定是否使用。 */ + private int themeColor(int attr) { + android.util.TypedValue value = new android.util.TypedValue(); + return getContext().getTheme().resolveAttribute(attr, value, true) ? value.data : 0; } private void ensureProgressView() { @@ -120,6 +264,7 @@ public class ProgressLayout extends RelativeLayout { public void switchState(State state) { if (mState == state) return; mState = state; + applyScrim(state); switch (state) { case CONTENT: if (mEmptyView != null) mEmptyView.setVisibility(GONE); @@ -134,10 +279,45 @@ public class ProgressLayout extends RelativeLayout { break; case EMPTY: ensureEmptyView(); + // 从错误态过来时要把外观复原(插画显示、标题回到常规前景色), + // 所以进 EMPTY 时重新应用一次空态内容。 + applyEmptyContent(); mEmptyView.setVisibility(VISIBLE); if (mProgressView != null) mProgressView.setVisibility(GONE); setContentVisibility(false); break; + case ERROR: + ensureEmptyView(); + applyErrorContent(); + mEmptyView.setVisibility(VISIBLE); + if (mProgressView != null) mProgressView.setVisibility(GONE); + setContentVisibility(false); + break; + } + } + + /** + * 空态/错误态给浮层加 scrim,其余状态清掉。 + * + *

为什么必须加。设计文档 §5.3 要求「图片上的文字必须有 scrim/gradient」、 + * §5.4 要求「所有内容表面必须有不透明或可控的 surface 层」。而空态/错误态的文字此前 + * **直接压在壁纸(一张图)上、没有任何 scrim**——这违反那两条,不只是对比度差一点。 + * + *

而且靠调色无解:实测壁纸合成亮度 L=0.1217/0.1476/0.2019,正文(16sp)门槛 4.5:1, + * 80% 白文字只有 4.61/4.08/3.30:1;**即使换成纯白,最亮壁纸上限也只有 4.17:1**, + * 即 4.5:1 物理上不可达。加 scrim 后(overlay_dark ≈32% 黑)为 6.02/5.43/4.53:1,全部达标。 + * + *

加在 {@code ProgressLayout} 自身而不是 {@code view_empty.xml} 的内容块上: + * 空态视图是**居中摆放的 wrap_content 内容块**({@code centerParams()}), + * 给它加背景会变成"文字后面一块小方框";本类是 match_parent 的宿主层,盖的是整屏。 + * + *

PROGRESS 态刻意**不加**:它是短暂的加载瞬间,加 scrim 会让页面闪一下暗。 + */ + private void applyScrim(State state) { + if (state == State.EMPTY || state == State.ERROR) { + setBackgroundColor(themeColor(R.attr.webhtvColorOverlayDark)); + } else { + setBackground(null); } } 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..b6ffd0e16c 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 @@ -1470,6 +1470,11 @@ public class TmdbHeaderView { chip.setOrientation(androidx.appcompat.widget.LinearLayoutCompat.VERTICAL); chip.setGravity(android.view.Gravity.CENTER); chip.setPadding(28, 14, 28, 14); + // “评分与数据”卡片必须可被遥控/键盘选中:此前是纯装饰 LinearLayout, + // 上下键会整行跳过,与 TV 播放页保持同一行为。 + chip.setFocusable(true); + chip.setFocusableInTouchMode(false); + chip.setClickable(true); android.graphics.drawable.GradientDrawable background = new android.graphics.drawable.GradientDrawable(); background.setCornerRadius(ResUtil.dp2px(6)); @@ -1501,9 +1506,28 @@ public class TmdbHeaderView { boolean lightChrome = isLightDetailChrome() || (fusion && !isDarkDetailTheme()); boolean lightSurface = !backdropSurfaceMode && lightChrome; styleSourceRatingChip(chip, fusion, lightChrome, lightSurface); + chip.setOnFocusChangeListener((view, focused) -> { + if (focused) applyUnifiedRatingChipFocus(view); + else styleSourceRatingChip((ViewGroup) view, Setting.isFusionDetailPage(), + isLightDetailChrome() || (Setting.isFusionDetailPage() && !isDarkDetailTheme()), + !backdropSurfaceMode && (isLightDetailChrome() || (Setting.isFusionDetailPage() && !isDarkDetailTheme()))); + }); return chip; } + /** + * 统一焦点环:3dp @color/tv_item_focus_ring,与 selector_video_item.xml 同一套取值。 + * 失焦时由 styleSourceRatingChip 恢复当前主题的常态外观,不改变既有亮/暗/融合配色。 + */ + private void applyUnifiedRatingChipFocus(View chip) { + if (!(chip instanceof ViewGroup group)) return; + android.graphics.drawable.GradientDrawable background = new android.graphics.drawable.GradientDrawable(); + background.setColor(backdropSurfaceMode ? COLOR_BACKDROP_SURFACE_CONTROL_BG : 0x26FFFFFF); + background.setCornerRadius(ResUtil.dp2px(6)); + background.setStroke(ResUtil.dp2px(3), activity.getColor(R.color.tv_item_focus_ring)); + group.setBackground(background); + } + private void styleRatingChipContainer(ViewGroup container) { if (container == null) return; boolean fusion = Setting.isFusionDetailPage(); 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..394e32dd12 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 @@ -23,6 +23,9 @@ public interface DetailModeHost { */ ViewBinding binding(); + /** Whether the selected detail theme should use the cinema presentation. */ + boolean isCinemaStyle(); + void closeDetailFullscreenPlayer(); void playInline(); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/detail/EnhancedDetailController.java b/app/src/main/java/com/fongmi/android/tv/ui/detail/EnhancedDetailController.java index 50875219fc..1ef516411f 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/detail/EnhancedDetailController.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/detail/EnhancedDetailController.java @@ -19,6 +19,11 @@ public class EnhancedDetailController extends BaseTmdbDetailModeController { super(host); } + @Override + public boolean isCinemaStyle() { + return host.isCinemaStyle(); + } + @Override protected boolean showInlinePlayer() { return false; 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..fbd70f2a08 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 @@ -20,6 +20,11 @@ public class FusionDetailController extends BaseTmdbDetailModeController { super(host); } + @Override + public boolean isFusionMode() { + return true; + } + @Override protected boolean showInlinePlayer() { return true; 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..e4bb6a2fae 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 @@ -19,6 +19,16 @@ public class PlayerDetailController extends BaseTmdbDetailModeController { super(host); } + @Override + public boolean isCinemaStyle() { + return host.isCinemaStyle(); + } + + @Override + public boolean isPlayerMode() { + return true; + } + @Override protected boolean showInlinePlayer() { return false; 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..e866ce7656 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 @@ -63,6 +63,21 @@ public interface TmdbDetailModeController { */ boolean shouldAutoPlay(); + /** Whether this mode uses the cinema presentation style. */ + default boolean isCinemaStyle() { + return false; + } + + /** Whether this is the immersive fusion mode. */ + default boolean isFusionMode() { + return false; + } + + /** Whether this is the direct-play detail mode. */ + default boolean isPlayerMode() { + return false; + } + /** Whether this mode owns an in-process playback service connection. */ boolean shouldBindPlaybackService(); 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..7913dea35b 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 @@ -21,6 +21,7 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogAboutBinding; import com.fongmi.android.tv.databinding.DialogGithubProxyBinding; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.GithubProxyAdapter; import com.fongmi.android.tv.utils.AppVersion; import com.fongmi.android.tv.utils.GithubProxy; @@ -68,7 +69,7 @@ public final class AboutDialog { } private static void showGithubProxy(FragmentActivity activity) { - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog); + MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog); Context context = builder.getContext(); DialogGithubProxyBinding binding = DialogGithubProxyBinding.inflate(LayoutInflater.from(context)); GithubProxyAdapter adapter = new GithubProxyAdapter(new GithubProxyAdapter.OnClickListener() { 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 index 0fdcf82b73..c38b795bb8 100644 --- 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 @@ -21,8 +21,8 @@ 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.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.google.android.material.button.MaterialButton; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; import java.util.Collections; @@ -122,7 +122,7 @@ public final class AdBlockLogFilterDialog { selectAll.setOnClickListener(v -> adapter.selectAll()); clear.setOnClickListener(v -> adapter.clearSelection()); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) .setTitle(title) .setView(root) .setNegativeButton(R.string.ad_log_filter_cancel, null) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdSkipPromptPresenter.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdSkipPromptPresenter.java deleted file mode 100644 index 82ebb7773c..0000000000 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AdSkipPromptPresenter.java +++ /dev/null @@ -1,141 +0,0 @@ -package com.fongmi.android.tv.ui.dialog; - -import android.os.Handler; -import android.os.Looper; - -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.FragmentActivity; - -import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ad.audio.AdSkipCoordinator; -import com.fongmi.android.tv.ad.audio.SpeechAdSignalProvider; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import java.lang.ref.WeakReference; -import java.util.concurrent.atomic.AtomicBoolean; - -public final class AdSkipPromptPresenter implements AdSkipCoordinator.UiPort, AutoCloseable { - - private final WeakReference activity; - private final Handler main = new Handler(Looper.getMainLooper()); - private AlertDialog dialog; - private Runnable timeout; - private long presentationToken; - private boolean closed; - - public AdSkipPromptPresenter(FragmentActivity activity) { - this.activity = new WeakReference<>(activity); - } - - @Override - public void showCandidate(AdSkipCoordinator.Prompt prompt, AdSkipCoordinator.Actions actions) { - runOnMain(() -> showCandidateInternal(prompt, actions)); - } - - @Override - public void showUndo(AdSkipCoordinator.UndoPrompt prompt, AdSkipCoordinator.Actions actions) { - runOnMain(() -> showUndoInternal(prompt, actions)); - } - - @Override - public void dismiss(long sessionId) { - runOnMain(this::dismissInternal); - } - - @Override - public void close() { - runOnMain(() -> { - if (closed) return; - closed = true; - dismissInternal(); - activity.clear(); - }); - } - - private void showCandidateInternal(AdSkipCoordinator.Prompt prompt, - AdSkipCoordinator.Actions actions) { - FragmentActivity owner = usableActivity(); - if (owner == null) return; - dismissInternal(); - long token = ++presentationToken; - AtomicBoolean handled = new AtomicBoolean(); - boolean speech = SpeechAdSignalProvider.RULE_ID.equals(prompt.ruleId()); - int title = speech - ? R.string.ad_audio_speech_candidate_title - : (prompt.fullMatch() ? R.string.ad_audio_match_title : R.string.ad_audio_candidate_title); - long duration = prompt.skipDurationSeconds(); - String message = speech - ? owner.getString(R.string.ad_audio_speech_candidate_message, duration) - : owner.getString(R.string.ad_audio_candidate_message, prompt.ruleId(), duration); - dialog = new MaterialAlertDialogBuilder(owner, R.style.ThemeOverlay_WebHTV_LightDialog) - .setTitle(title) - .setMessage(message) - .setPositiveButton(R.string.ad_audio_skip, (ignored, which) -> - act(token, handled, actions::confirm)) - .setNegativeButton(R.string.ad_audio_ignore, (ignored, which) -> - act(token, handled, actions::ignore)) - .setOnCancelListener(ignored -> act(token, handled, actions::ignore)) - .create(); - dialog.show(); - if (dialog.getButton(AlertDialog.BUTTON_POSITIVE) != null) { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).requestFocus(); - } - } - - private void showUndoInternal(AdSkipCoordinator.UndoPrompt prompt, - AdSkipCoordinator.Actions actions) { - FragmentActivity owner = usableActivity(); - if (owner == null) return; - dismissInternal(); - long token = ++presentationToken; - AtomicBoolean handled = new AtomicBoolean(); - dialog = new MaterialAlertDialogBuilder(owner, R.style.ThemeOverlay_WebHTV_LightDialog) - .setTitle(R.string.ad_audio_skipped_title) - .setMessage(owner.getString(R.string.ad_audio_skipped_message, - Math.max(1L, prompt.expiresAfterMs() / 1_000L))) - .setPositiveButton(R.string.ad_audio_undo, (ignored, which) -> - act(token, handled, actions::undo)) - .setNegativeButton(R.string.dialog_negative, (ignored, which) -> - act(token, handled, actions::ignore)) - .setOnCancelListener(ignored -> act(token, handled, actions::ignore)) - .create(); - dialog.show(); - if (dialog.getButton(AlertDialog.BUTTON_POSITIVE) != null) { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).requestFocus(); - } - timeout = () -> { - if (!isCurrent(token) || !handled.compareAndSet(false, true)) return; - actions.ignore(); - dismissInternal(); - }; - main.postDelayed(timeout, prompt.expiresAfterMs()); - } - - private void act(long token, AtomicBoolean handled, Runnable action) { - if (!isCurrent(token) || !handled.compareAndSet(false, true)) return; - action.run(); - } - - private boolean isCurrent(long token) { - return !closed && token == presentationToken && usableActivity() != null; - } - - private FragmentActivity usableActivity() { - FragmentActivity owner = activity.get(); - if (owner == null || owner.isFinishing() || owner.isDestroyed()) return null; - return owner; - } - - private void dismissInternal() { - presentationToken++; - if (timeout != null) main.removeCallbacks(timeout); - timeout = null; - if (dialog != null) dialog.dismiss(); - dialog = null; - } - - private void runOnMain(Runnable action) { - if (Looper.myLooper() == Looper.getMainLooper()) action.run(); - else main.post(action); - } -} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiAnalysisDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiAnalysisDialog.java index 765dd59e8c..4de1aa1aaf 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiAnalysisDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiAnalysisDialog.java @@ -8,8 +8,8 @@ import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; public final class AiAnalysisDialog { @@ -26,7 +26,7 @@ public final class AiAnalysisDialog { ResUtil.dp2px(40), ResUtil.dp2px(40), Gravity.CENTER); container.addView(progress, params); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.tmdb_season_ai_loading_title) .setMessage(R.string.tmdb_season_ai_analyzing) .setView(container) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiConfigDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiConfigDialog.java index 3ffa15c9e6..ea9e003e9e 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiConfigDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AiConfigDialog.java @@ -17,6 +17,7 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.AiConfig; import com.fongmi.android.tv.service.AiCompletionClient; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.Task; import com.google.android.material.button.MaterialButton; @@ -105,7 +106,7 @@ public class AiConfigDialog { } private MaterialAlertDialogBuilder builder() { - return new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog); + return new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog); } private void showPromptConfig() { @@ -163,7 +164,7 @@ public class AiConfigDialog { dialogContext.getString(R.string.dialog_ai_group_rule_prompt_label) }; boolean[] checked = {true, true, true, true, true}; - AlertDialog picker = new MaterialAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_LightDialog) + AlertDialog picker = new WebHtvAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.dialog_ai_prompt_reset) .setMultiChoiceItems(labels, checked, (d, which, isChecked) -> checked[which] = isChecked) .setPositiveButton(R.string.dialog_positive, (d, w) -> { @@ -295,7 +296,7 @@ public class AiConfigDialog { break; } } - new MaterialAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_LightDialog) + new WebHtvAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.dialog_ai_protocol_label) .setSingleChoiceItems(labels, currentIndex, (dialog, which) -> { String oldProtocol = config.getProtocol(); 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..a308d3c7f7 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 @@ -93,7 +93,7 @@ public class AiRecommendationInfoDialog { ImgUtil.load(item.getTitle(), image, fallbackImage, poster, true, 300, 450); int dialogHeightPx = calculateDialogHeightPx( - ResUtil.getScreenHeight(activity), + DialogSafeArea.height(activity), dp(activity, DIALOG_VERTICAL_MARGIN_DP)); Dialog dialog = LightDialog.create( activity, diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ApkPushProgressDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ApkPushProgressDialog.java index b79964dbe3..99562d4a26 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ApkPushProgressDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ApkPushProgressDialog.java @@ -13,6 +13,7 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogApkPushProgressBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; @@ -59,7 +60,7 @@ public final class ApkPushProgressDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()).setCancelable(false); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()).setCancelable(false); } @Override diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AudioSourceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AudioSourceDialog.java index c1236d61d4..28093fd8be 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/AudioSourceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/AudioSourceDialog.java @@ -16,9 +16,9 @@ import com.fongmi.android.tv.bean.AudioConfig; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; import java.util.List; @@ -76,7 +76,7 @@ public class AudioSourceDialog { manageBtn.setOnClickListener(v -> showSiteManage()); resetBtn.setOnClickListener(v -> resetToDefault()); - dialog = new MaterialAlertDialogBuilder(activity) + dialog = new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.setting_audio_source) .setView(view) .setPositiveButton(R.string.dialog_positive, (d, w) -> onSave()) @@ -111,7 +111,7 @@ public class AudioSourceDialog { checked[i] = matchesRule(enabledRules, site); } - new MaterialAlertDialogBuilder(activity) + new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.dialog_audio_site_manage) .setMultiChoiceItems(labels, checked, (d, which, isChecked) -> checked[which] = isChecked) .setPositiveButton(R.string.dialog_positive, (d, w) -> applySiteManage(sites, enabledRules, checked)) 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..756ce8f87d 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 @@ -10,6 +10,8 @@ import androidx.fragment.app.DialogFragment; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -25,11 +27,17 @@ public abstract class BaseAlertDialog extends DialogFragment { Dialog dialog = getBuilder().create(); initView(); initEvent(); + // AlertController installs the custom view and the action buttons during + // show(). Binding here only registers the binder root; the descendant-count + // watcher re-walks the tree once those children appear. The dialog's own + // OnShowListener is deliberately left untouched because + // MaterialAlertDialogBuilder already owns it for its background insets. + ThemeController.bindDialog(dialog); return dialog; } protected MaterialAlertDialogBuilder builder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.MaterialAlertDialog_WebHTV_Rounded); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog); } protected void initView() { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseBottomSheetDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseBottomSheetDialog.java index 6da7798546..f8bdc17f43 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseBottomSheetDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/BaseBottomSheetDialog.java @@ -15,6 +15,7 @@ import androidx.core.view.WindowCompat; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.bottomsheet.BottomSheetBehavior; @@ -57,9 +58,16 @@ public abstract class BaseBottomSheetDialog extends BottomSheetDialogFragment { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); initView(); + bindDialogTheme(); initEvent(); } + private void bindDialogTheme() { + Dialog dialog = getDialog(); + if (dialog == null) return; + ThemeController.bindDialog(dialog); + } + protected void initView() { } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialog.java new file mode 100644 index 0000000000..66d503ca22 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/CacheManagementDialog.java @@ -0,0 +1,714 @@ +package com.fongmi.android.tv.ui.dialog; + +import android.app.Dialog; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.GradientDrawable; +import android.os.Bundle; +import android.view.KeyEvent; +import android.util.TypedValue; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.DialogFragment; +import androidx.viewbinding.ViewBinding; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.cache.CacheCenter; +import com.fongmi.android.tv.cache.CacheCleanupManager; +import com.fongmi.android.tv.cache.CacheCleanupMode; +import com.fongmi.android.tv.cache.CacheCleanupPlan; +import com.fongmi.android.tv.cache.CacheCleanupProgress; +import com.fongmi.android.tv.cache.CacheCleanupResult; +import com.fongmi.android.tv.cache.CacheCleanupStatus; +import com.fongmi.android.tv.cache.CacheFormat; +import com.fongmi.android.tv.cache.CacheLimitOptions; +import com.fongmi.android.tv.cache.CacheMeasurement; +import com.fongmi.android.tv.cache.CacheModuleId; +import com.fongmi.android.tv.cache.CachePolicyEngine; +import com.fongmi.android.tv.cache.CachePolicyStore; +import com.fongmi.android.tv.cache.CacheScheduler; +import com.fongmi.android.tv.cache.CacheSnapshot; +import com.fongmi.android.tv.databinding.DialogCacheManagementBinding; +import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; +import com.fongmi.android.tv.utils.FileUtil; +import com.fongmi.android.tv.utils.Notify; +import com.fongmi.android.tv.utils.ResUtil; +import com.fongmi.android.tv.utils.Util; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import com.google.android.material.button.MaterialButton; + +public class CacheManagementDialog extends DialogFragment { + + private static final CacheModuleId[] MODULE_ORDER = CacheModuleId.values(); + private static final float SCREEN_FRACTION = 0.9f; + private DialogCacheManagementBinding binding; + private boolean loading; + private CharSequence resultLine; + private final java.util.ArrayList moduleButtons = new java.util.ArrayList<>(); + + public static void show(Fragment fragment) { + new CacheManagementDialog().show(fragment.getChildFragmentManager(), null); + } + + public static void show(FragmentActivity activity) { + new CacheManagementDialog().show(activity.getSupportFragmentManager(), null); + } + + /** + * Runs the settings row's plain click: every cache the panel can name is cleared the way the + * single-key clear of the pre-split settings row left them, and the outcome is reported as a + * notification. + * + *

The panel deliberately stays closed. This entry re-creates the action from before the cache + * management split, and that one-key clear never opened a screen either: the click is the + * deliberate act, the cleanup runs in the background, and the settings row re-reads itself once + * the cleanup publishes its change. The panel - graded cleanup, module limits, auto cleanup - + * moved to the row's long press, which is now its only entry: the row stays single-line by user + * request, so no gesture hint is printed under the title.

+ */ + public static void cleanEverything() { + if (CacheCleanupManager.isRunning()) { + Resources resources = rowResources(); + Notify.show(resources.getString(R.string.cache_cleanup_busy)); + return; + } + Resources resources = rowResources(); + Notify.show(resources.getString(R.string.cache_cleanup_full_started)); + CacheCleanupManager.execute(CachePolicyEngine.plan(CacheCleanupMode.FULL), "settings_row", + result -> Notify.show(describe(result, resources))); + } + + /** + * The resources the row's toasts resolve their text from. + * + *

{@link Notify} renders through the application context, whose resources carry the system + * locale instead of the in-app language the settings screens apply, so a row toast would + * otherwise disagree with the panel that reports the identical outcome. Resolving through the + * same helper the settings screens use keeps both surfaces in one language.

+ */ + private static Resources rowResources() { + return Setting.wrapLanguage(App.get()).getResources(); + } + + /** + * Words a cleanup outcome exactly as the panel's status line does, so the same action reads the + * same way whether it was started from the panel or from the settings row. + * + *

The resources are passed in rather than read globally: the panel renders - and notifies - + * through its host context, which carries the in-app language choice, while the row has to + * ask for that same choice explicitly.

+ */ + static String describe(CacheCleanupResult result, Resources resources) { + int message; + if (result.status() == CacheCleanupStatus.COMPLETED) { + message = R.string.cache_cleanup_done; + } else if (result.status() == CacheCleanupStatus.CANCELLED) { + message = R.string.cache_cleanup_cancelled; + } else if (result.status() == CacheCleanupStatus.FAILED) { + message = R.string.cache_cleanup_failed; + } else if (result.status() == CacheCleanupStatus.NOT_ALLOWED) { + message = R.string.cache_cleanup_not_allowed; + } else if (result.status() == CacheCleanupStatus.DEFERRED) { + message = R.string.cache_cleanup_deferred; + } else { + message = R.string.cache_cleanup_partial; + } + if (result.status() == CacheCleanupStatus.COMPLETED) { + return resources.getString(message, FileUtil.byteCountToDisplaySize(result.releasedBytes()), + result.deletedFiles()); + } + if (result.status() == CacheCleanupStatus.PARTIAL) { + return resources.getString(message, FileUtil.byteCountToDisplaySize(result.releasedBytes()), + result.deletedFiles(), result.skippedFiles()); + } + return resources.getString(message); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + Dialog dialog = new Dialog(requireContext()); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + dialog.setContentView(getBinding().getRoot()); + dialog.setCanceledOnTouchOutside(true); + // Design §15.2: while a cleanup is running the BACK key must request cancellation + // instead of silently closing the panel and leaving the job running invisibly. + dialog.setOnKeyListener((ignored, keyCode, event) -> { + if (keyCode != KeyEvent.KEYCODE_BACK || event.getAction() != KeyEvent.ACTION_UP) return false; + if (!CacheCleanupManager.isRunning()) return false; + CacheCleanupManager.cancel(); + return true; + }); + initView(); + initEvent(); + return dialog; + } + + protected ViewBinding getBinding() { + return binding = DialogCacheManagementBinding.inflate(getLayoutInflater()); + } + + protected void initView() { + renderModules(null); + refresh(false); + } + + protected void initEvent() { + binding.refresh.setOnClickListener(view -> refresh(true)); + binding.cancel.setOnClickListener(view -> CacheCleanupManager.cancel()); + binding.cleanupLight.setOnClickListener(view -> { + if (!CacheCleanupManager.isRunning()) confirm(CacheCleanupMode.LIGHT, view); + }); + binding.cleanupStandard.setOnClickListener(view -> { + if (!CacheCleanupManager.isRunning()) confirm(CacheCleanupMode.STANDARD, view); + }); + binding.cleanupDeep.setOnClickListener(view -> { + if (!CacheCleanupManager.isRunning()) confirmDeep(view); + }); + binding.autoCleanup.setOnClickListener(view -> toggleAutoCleanup()); + binding.retention.setOnClickListener(view -> chooseRetention()); + binding.totalLimit.setOnClickListener(view -> chooseTotalLimit()); + // The close button must follow the same rule as BACK: during a cleanup it requests cancellation instead of hiding a still-running job. + binding.close.setOnClickListener(view -> { + if (CacheCleanupManager.isRunning()) { + CacheCleanupManager.cancel(); + return; + } + dismiss(); + }); + updatePolicyButtons(); + } + + @Override + public void onStart() { + super.onStart(); + applyWindowSize(); + DialogSafeArea.follow(requireActivity(), getDialog() == null ? null : getDialog().getWindow(), this::applyWindowSize); + } + + private void applyWindowSize() { + Dialog dialog = getDialog(); + Window window = dialog == null ? null : dialog.getWindow(); + if (window == null) return; + int width = Math.round(DialogSafeArea.width(requireActivity(), window) * SCREEN_FRACTION); + int height = Math.round(DialogSafeArea.height(requireActivity(), window) * SCREEN_FRACTION); + WindowManager.LayoutParams params = window.getAttributes(); + params.width = width; + params.height = height; + params.dimAmount = 0.6f; + window.setAttributes(params); + window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + window.setLayout(width, height); + // Custom Dialog: the window IS the panel, so it must paint its own themed surface. + // MaterialAlertDialog would keep a wrap_content inner panel (~60% height) regardless of + // the window size, which is what made the earlier TV dialog look too small. + GradientDrawable surface = new GradientDrawable(); + surface.setShape(GradientDrawable.RECTANGLE); + surface.setColor(themeColor(android.R.attr.colorBackground, 0xFF1E1B22)); + surface.setCornerRadius(ResUtil.dp2px(20)); + surface.setStroke(ResUtil.dp2px(1), 0x33FFFFFF); + binding.getRoot().setBackground(surface); + } + + private void toggleAutoCleanup() { + boolean enabled = !CachePolicyStore.isAutoCleanupEnabled(); + CachePolicyStore.putAutoCleanupEnabled(enabled); + if (enabled) CacheScheduler.get().start(); + else CacheScheduler.get().cancelPersistent(requireContext()); + updatePolicyButtons(); + } + + private void chooseRetention() { + String[] labels = {getString(R.string.cache_retention_7), getString(R.string.cache_retention_30), + getString(R.string.cache_retention_90), getString(R.string.cache_retention_forever)}; + int[] days = {7, 30, 90, 3650}; + int checked = 0; + int current = CachePolicyStore.getRetentionDays(); + for (int index = 0; index < days.length; index++) if (days[index] == current) checked = index; + ChoiceDialog.showSingle(this, R.string.cache_retention_title, labels, checked, which -> { + CachePolicyStore.putRetentionDays(days[which]); + updatePolicyButtons(); + }); + } + + private void chooseTotalLimit() { + String[] labels = new String[CacheLimitOptions.BYTES.length]; + for (int index = 0; index < labels.length; index++) { + labels[index] = CacheLimitOptions.BYTES[index] <= 0 + ? getString(R.string.cache_limit_unlimited) + : FileUtil.byteCountToDisplaySize(CacheLimitOptions.BYTES[index]); + } + ChoiceDialog.showSingle(this, R.string.cache_total_limit_title, labels, + CacheLimitOptions.indexOf(CachePolicyStore.getTotalLimitBytes()), which -> { + CachePolicyStore.putTotalLimitBytes(CacheLimitOptions.BYTES[which]); + updatePolicyButtons(); + }); + } + + private void updatePolicyButtons() { + binding.autoCleanup.setText(CachePolicyStore.isAutoCleanupEnabled() + ? R.string.cache_auto_on : R.string.cache_auto_off); + int days = CachePolicyStore.getRetentionDays(); + binding.retention.setText(getString(R.string.cache_retention_summary, + days >= 3650 ? getString(R.string.cache_retention_forever) : days + "d")); + long totalLimit = CachePolicyStore.getTotalLimitBytes(); + binding.totalLimit.setText(getString(R.string.cache_total_limit_summary, + totalLimit <= 0 ? getString(R.string.cache_limit_unlimited) + : FileUtil.byteCountToDisplaySize(totalLimit))); + } + + private void confirm(CacheCleanupMode mode, View returnFocus) { + int message = switch (mode) { + case LIGHT -> R.string.cache_cleanup_confirm_light; + case STANDARD -> R.string.cache_cleanup_confirm_standard; + default -> R.string.cache_cleanup_confirm_deep; + }; + AlertDialog dialog = new WebHtvAlertDialogBuilder(requireContext()) + .setTitle(R.string.cache_cleanup_confirm_title) + .setMessage(message) + .setNegativeButton(R.string.dialog_negative, null) + .setPositiveButton(R.string.dialog_positive, (ignored, which) -> startCleanup(mode)) + .create(); + focusNegativeOnShow(dialog); + restoreFocusOnDismiss(dialog, returnFocus); + dialog.show(); + } + + private void confirmModule(CacheModuleId id, View returnFocus) { + AlertDialog dialog = new WebHtvAlertDialogBuilder(requireContext()) + .setTitle(R.string.cache_cleanup_confirm_title) + .setMessage(getString(R.string.cache_cleanup_confirm_module, getModuleName(id))) + .setNegativeButton(R.string.dialog_negative, null) + .setPositiveButton(R.string.dialog_positive, (ignored, which) -> startCleanup( + com.fongmi.android.tv.cache.CachePolicyEngine.module(id))) + .create(); + focusNegativeOnShow(dialog); + restoreFocusOnDismiss(dialog, returnFocus); + dialog.show(); + } + + /** Restore the trigger after a confirmation closes, instead of letting geometry choose again. */ + private void restoreFocusOnDismiss(AlertDialog dialog, @Nullable View returnFocus) { + if (returnFocus == null) return; + dialog.setOnDismissListener(ignored -> returnFocus.post(() -> { + if (!returnFocus.isAttachedToWindow() || !returnFocus.isShown() + || !returnFocus.isEnabled() || !returnFocus.isFocusable()) return; + returnFocus.requestFocus(); + })); + } + + /** + * Design §15.1: cleanup confirmation dialogs must open with the focus on "cancel" so a + * single remote OK press can never delete cache data by accident. + */ + private void focusNegativeOnShow(AlertDialog dialog) { + dialog.setOnShowListener(ignored -> { + Button negative = dialog.getButton(AlertDialog.BUTTON_NEGATIVE); + if (negative == null) return; + negative.setFocusable(true); + negative.setFocusableInTouchMode(true); + // requestFocus() during onShow is too early: the dialog lays out afterwards and + // drops the focus again, leaving no button focused (a remote OK press then does + // nothing). Posting defers it until after layout. + negative.post(negative::requestFocus); + }); + } + + private void confirmDeep(View returnFocus) { + AlertDialog dialog = new WebHtvAlertDialogBuilder(requireContext()) + .setTitle(R.string.cache_cleanup_confirm_title) + .setMessage(R.string.cache_cleanup_confirm_deep) + .setNegativeButton(R.string.dialog_negative, null) + .setPositiveButton(R.string.cache_cleanup_continue, null) + .create(); + dialog.setOnShowListener(ignored -> { + Button negative = dialog.getButton(AlertDialog.BUTTON_NEGATIVE); + if (negative != null) { + negative.setFocusable(true); + negative.setFocusableInTouchMode(true); + negative.post(negative::requestFocus); + } + Button positive = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + positive.setOnClickListener(view -> { + // Keep the second confirmation in the same window. Creating a second alert here + // made one remote confirmation look like multiple stacked dialogs. + positive.setText(R.string.cache_cleanup_deep_start); + dialog.setMessage(getString(R.string.cache_cleanup_confirm_deep_final)); + positive.setOnClickListener(confirmed -> { + dialog.dismiss(); + startCleanup(CacheCleanupMode.DEEP); + }); + // The step-2 text replaces step-1, so re-seat focus on cancel to keep the same + // "safe default" rule for the irreversible action. + if (negative != null) negative.post(negative::requestFocus); + }); + }); + restoreFocusOnDismiss(dialog, returnFocus); + dialog.show(); + } + + private void startCleanup(CacheCleanupMode mode) { + if (CacheCleanupManager.isRunning()) return; + startCleanup(buildPlan(mode)); + } + + private void startCleanup(CacheCleanupPlan plan) { + if (CacheCleanupManager.isRunning()) return; + setCleanupInteractive(false); + setDismissableWhileIdle(false); + binding.cancel.setVisibility(android.view.View.VISIBLE); + CacheCleanupManager.execute(plan, this::renderProgress, this::renderResult); + } + + /** + * Keeps the panel on screen for the whole cleanup so the user always sees progress and the + * final outcome; BACK is still handled separately as a cancellation request. + */ + private void setDismissableWhileIdle(boolean dismissable) { + Dialog dialog = getDialog(); + if (dialog != null) dialog.setCanceledOnTouchOutside(dismissable); + } + + private CacheCleanupPlan buildPlan(CacheCleanupMode mode) { + return com.fongmi.android.tv.cache.CachePolicyEngine.plan(mode); + } + + private void renderProgress(CacheCleanupProgress progress) { + if (binding == null || !isAdded()) return; + binding.status.setText(getString(R.string.cache_cleanup_progress, + getModuleName(progress.moduleId()), progress.completedModules() + 1, progress.totalModules())); + } + + private void renderResult(CacheCleanupResult result) { + if (binding == null || !isAdded()) return; + setCleanupInteractive(true); + setDismissableWhileIdle(true); + binding.cancel.setVisibility(android.view.View.GONE); + String text = describe(result, getResources()); + // The cleanup already finished, so a modal confirmation here would force the user to + // dismiss an extra dialog for an action that is already done. Notify passively instead + // and keep the outcome visible in the panel's own status line. + Notify.show(text); + resultLine = text; + refresh(true); + } + + private void setCleanupInteractive(boolean enabled) { + setCleanupButtonInteractive(binding.cleanupLight, enabled); + setCleanupButtonInteractive(binding.cleanupStandard, enabled); + setCleanupButtonInteractive(binding.cleanupDeep, enabled); + } + + /** + * Keep cleanup buttons focusable while a cleanup runs, but prevent a second click. Disabling + * the trigger before the confirmation dialog dismisses removes the only safe focus target and + * makes Android TV move focus to an arbitrary neighbouring button. + */ + private void setCleanupButtonInteractive(MaterialButton button, boolean enabled) { + button.setClickable(enabled); + button.setAlpha(enabled ? 1f : 0.55f); + } + + private void refresh(boolean force) { + if (loading) return; + loading = true; + binding.status.setText(R.string.cache_management_scanning); + CacheCenter.get().requestSnapshot(force, this::render); + } + + private void render(CacheSnapshot snapshot) { + loading = false; + if (binding == null || !isAdded()) return; + long quota = snapshot.systemQuotaBytes(); + String total = FileUtil.byteCountToDisplaySize(snapshot.totalBytes()); + binding.summary.setText(quota > 0 + ? getString(R.string.cache_management_summary_with_quota, total, FileUtil.byteCountToDisplaySize(quota)) + : getString(R.string.cache_management_summary, total)); + if (resultLine != null) { + binding.status.setText(resultLine); + resultLine = null; + } else binding.status.setText(snapshot.warnings().isEmpty() + ? getString(R.string.cache_management_scanned_at, new SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(new Date())) + : getString(R.string.cache_management_partial, snapshot.warnings().size())); + renderModules(snapshot); + } + + private void renderModules(@Nullable CacheSnapshot snapshot) { + ModuleFocusTag focusedModule = focusedModuleTag(binding.getRoot().findFocus()); + binding.modules.removeAllViews(); + moduleButtons.clear(); + long totalBytes = snapshot == null ? 0 : snapshot.totalBytes(); + java.util.ArrayList ordered = snapshot == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(snapshot.modules()); + ordered.sort((left, right) -> { + int byBytes = Long.compare(right.bytes(), left.bytes()); + if (byBytes != 0) return byBytes; + return Integer.compare(left.id().ordinal(), right.id().ordinal()); + }); + if (snapshot == null) for (CacheModuleId id : MODULE_ORDER) addRow(id, null, 0); + else for (CacheMeasurement measurement : ordered) addRow(measurement.id(), measurement, totalBytes); + wireFocusOrder(); + restoreModuleFocus(focusedModule); + } + + @Nullable + private ModuleFocusTag focusedModuleTag(@Nullable View focused) { + Object tag = focused == null ? null : focused.getTag(); + return tag instanceof ModuleFocusTag moduleTag ? moduleTag : null; + } + + private void restoreModuleFocus(@Nullable ModuleFocusTag target) { + if (target == null) return; + for (MaterialButton button : moduleButtons) { + if (!target.equals(button.getTag())) continue; + button.post(() -> { + if (!button.isAttachedToWindow() || !button.isShown() || !button.isEnabled()) return; + button.requestFocus(); + }); + return; + } + } + + private void addRow(CacheModuleId id, @Nullable CacheMeasurement measurement, long totalBytes) { + boolean mobile = Util.isMobile(); + LinearLayout row = new LinearLayout(requireContext()); + row.setOrientation(mobile ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL); + row.setGravity(android.view.Gravity.CENTER_VERTICAL); + int padding = mobile ? ResUtil.dp2px(8) : 10; + row.setPadding(0, padding, 0, padding); + LinearLayout detailColumn = new LinearLayout(requireContext()); + detailColumn.setOrientation(LinearLayout.VERTICAL); + String title = getModuleName(id); + if (measurement != null) title += " " + CacheFormat.percent(measurement.bytes(), totalBytes); + detailColumn.addView(text(title, 18, themeColor(android.R.attr.textColorPrimary, 0xFF1F1F1F))); + String detail; + if (measurement == null) detail = getString(R.string.cache_management_scanning); + else detail = getString(R.string.cache_management_module_detail, + FileUtil.byteCountToDisplaySize(measurement.bytes()), + measurement.fileCount(), + formatTime(measurement.newestModifiedMs())); + detailColumn.addView(text(detail, 14, themeColor(android.R.attr.textColorSecondary, 0xFF5F6368))); + if (mobile) { + // Phone text must not compete with two minimum-width action buttons. + row.addView(detailColumn, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + LinearLayout actions = new LinearLayout(requireContext()); + actions.setOrientation(LinearLayout.HORIZONTAL); + MaterialButton[] buttons = {moduleButton(id), limitButton(id)}; + for (int index = 0; index < buttons.length; index++) { + MaterialButton button = buttons[index]; + button.setMinWidth(0); + button.setMinimumWidth(0); + button.setMinHeight(ResUtil.dp2px(48)); + button.setMinimumHeight(ResUtil.dp2px(48)); + button.setPaddingRelative(ResUtil.dp2px(8), button.getPaddingTop(), + ResUtil.dp2px(8), button.getPaddingBottom()); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); + if (index > 0) params.setMarginStart(ResUtil.dp2px(8)); + actions.addView(button, params); + } + row.addView(actions, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + } else { + row.addView(detailColumn, new LinearLayout.LayoutParams(0, + LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); + row.addView(moduleButton(id)); + row.addView(limitButton(id)); + } + binding.modules.addView(row); + } + + private MaterialButton limitButton(CacheModuleId id) { + MaterialButton button = new MaterialButton(requireContext(), null, + com.google.android.material.R.attr.materialButtonOutlinedStyle); + button.setMinHeight(ResUtil.dp2px(44)); + button.setMinWidth(ResUtil.dp2px(96)); + button.setMinimumHeight(ResUtil.dp2px(44)); + button.setTextSize(14); + applyFocusRing(button, R.color.focus_ring_primary); + button.setText(R.string.cache_limit_button); + button.setEnabled(supportsLimit(id)); + if (supportsLimit(id)) { + button.setTag(new ModuleFocusTag(id, true)); + button.setOnClickListener(view -> chooseLimit(id)); + } + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + params.setMarginStart(8); + button.setLayoutParams(params); + moduleButtons.add(button); + return button; + } + + private boolean supportsLimit(CacheModuleId id) { + return switch (id) { + case GLIDE, LYRICS, KARAOKE, WEBHOME_EXT, EPG, PLUGIN_SCRIPTS, TEMP_FILES -> true; + default -> false; + }; + } + + private void chooseLimit(CacheModuleId id) { + String[] labels = new String[CacheLimitOptions.BYTES.length]; + for (int index = 0; index < labels.length; index++) { + labels[index] = CacheLimitOptions.BYTES[index] <= 0 + ? getString(R.string.cache_limit_unlimited) + : FileUtil.byteCountToDisplaySize(CacheLimitOptions.BYTES[index]); + } + ChoiceDialog.showSingle(this, R.string.cache_limit_title, + labels, CacheLimitOptions.indexOf(CachePolicyStore.getLimit(id)), which -> { + CachePolicyStore.putLimit(id, CacheLimitOptions.BYTES[which]); + refresh(true); + }); + } + + /** + * 焦点环走 MaterialButton 自带的描边通道,而不是 foreground ``。 + * + *

原因(2026-10-10 实测):按钮的圆角由 {@code ShapeAppearance.M3.Comp.Button.Small. + * Container.Shape.Round} → {@code Corner.Full} 决定,是 50%(52dp 高按钮 = 26dp 胶囊), + * 而 foreground 环写的是固定 dp,两者在几何上无法对齐:实测 foreground 环的外边界 + * (声明 28dp + w/2)画在按钮 padding 盒(72/30/72/30)上,而按钮填充又被 4dp inset, + * 圆角与盒位都不一致。改用 {@code app:strokeColor}/{@code app:strokeWidth} 后,Material + * 用同一个 {@code ShapeAppearanceModel} 同时画填充与描边,几何天然一致。 + * + *

环色按仓库既有规则取「焦点态实际填充色的配对 on-色」:tonal(secondaryContainer) + * 用 {@code focus_ring_secondary},outlined/text(无容器填充)用 {@code focus_ring_primary}。 + * 两者都由主题解析器从用户 seed 生成并做过对比度校验(浅色表 primary 对 secondaryContainer + * 4.91:1、on_secondary_container 对 secondaryContainer 13.18:1;夜间表 5.44:1 / 7.24:1)。 + */ + private void applyFocusRing(MaterialButton button, int strokeColorRes) { + button.setStrokeColor(androidx.core.content.ContextCompat.getColorStateList(requireContext(), strokeColorRes)); + button.setStrokeWidth(requireContext().getResources().getDimensionPixelSize(R.dimen.webhtv_focus_ring_width)); + } + + private MaterialButton moduleButton(CacheModuleId id) { + MaterialButton button = new MaterialButton(requireContext(), null, + com.google.android.material.R.attr.materialButtonTonalStyle); + button.setMinHeight(ResUtil.dp2px(44)); + button.setMinWidth(ResUtil.dp2px(112)); + button.setMinimumHeight(ResUtil.dp2px(44)); + button.setTextSize(14); + applyFocusRing(button, R.color.focus_ring_secondary); + // Read the restriction from the registry instead of hardcoding one module: an entry that + // declares allowManualCleanup=false must not be offered as directly cleanable. + boolean restricted = !CachePolicyEngine.manualCleanupAllowed(id, requireContext().getCacheDir()); + button.setEnabled(!restricted); + button.setText(restricted ? R.string.cache_cleanup_owner_managed : R.string.cache_cleanup_module); + if (!restricted) { + button.setTag(new ModuleFocusTag(id, false)); + button.setOnClickListener(view -> confirmModule(id, view)); + } + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + params.setMarginStart(8); + button.setLayoutParams(params); + moduleButtons.add(button); + return button; + } + + private record ModuleFocusTag(CacheModuleId id, boolean limitButton) { + } + + /** + * The default geometric focus search skipped whole rows (for example UP from "refresh" landed + * on the policy row instead of the cleanup row). Link the action area explicitly so remote + * focus always moves through policy row -> cleanup row -> footer without skipping a row. + */ + private void wireFocusOrder() { + // Mobile actions wrap; focus should follow their actual positions, not TV columns. + if (Util.isMobile()) return; + linkVertical(binding.autoCleanup, null, binding.cleanupLight); + linkVertical(binding.retention, null, binding.cleanupStandard); + linkVertical(binding.totalLimit, null, binding.cleanupDeep); + linkVertical(binding.cleanupLight, binding.autoCleanup, binding.refresh); + linkVertical(binding.cleanupStandard, binding.retention, binding.refresh); + linkVertical(binding.cleanupDeep, binding.totalLimit, binding.close); + linkVertical(binding.refresh, binding.cleanupStandard, null); + linkVertical(binding.close, binding.cleanupDeep, null); + binding.refresh.setNextFocusLeftId(binding.cancel.getVisibility() == View.VISIBLE + ? R.id.cancel : View.NO_ID); + binding.refresh.setNextFocusRightId(R.id.close); + binding.close.setNextFocusLeftId(R.id.refresh); + if (moduleButtons.size() >= 2) { + MaterialButton lastClean = moduleButtons.get(moduleButtons.size() - 2); + MaterialButton lastLimit = moduleButtons.get(moduleButtons.size() - 1); + lastClean.setNextFocusDownId(R.id.autoCleanup); + lastLimit.setNextFocusDownId(R.id.totalLimit); + // Close the loop upwards as well, so UP from the policy row returns to the module list + // instead of jumping to an unrelated neighbour chosen by geometry. + binding.autoCleanup.setNextFocusUpId(lastClean.getId()); + binding.retention.setNextFocusUpId(lastLimit.getId()); + binding.totalLimit.setNextFocusUpId(lastLimit.getId()); + } + } + + private void linkVertical(View view, @Nullable View up, @Nullable View down) { + if (view == null) return; + view.setNextFocusUpId(up == null ? View.NO_ID : up.getId()); + view.setNextFocusDownId(down == null ? View.NO_ID : down.getId()); + } + + private int themeColor(int attribute, int fallback) { + TypedValue value = new TypedValue(); + if (requireContext().getTheme().resolveAttribute(attribute, value, true)) { + if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT) { + return value.data; + } + if (value.resourceId != 0) { + return androidx.core.content.ContextCompat.getColor(requireContext(), value.resourceId); + } + } + return fallback; + } + + private android.widget.TextView text(CharSequence value, int size, int color) { + android.widget.TextView view = new android.widget.TextView(requireContext()); + view.setText(value); + view.setTextSize(size); + view.setTextColor(color); + return view; + } + + private String getModuleName(CacheModuleId id) { + return getString(switch (id) { + case EXO -> R.string.cache_module_exo; + case MPV_HLS -> R.string.cache_module_mpv_hls; + case MPV_DEMUXER -> R.string.cache_module_mpv_demuxer; + case MPV_RUNTIME -> R.string.cache_module_mpv_runtime; + case LYRICS -> R.string.cache_module_lyrics; + case KARAOKE -> R.string.cache_module_karaoke; + case WEBHOME_EXT -> R.string.cache_module_webhome_ext; + case WEBHOME_RAW -> R.string.cache_module_webhome_raw; + case EPG -> R.string.cache_module_epg; + case GLIDE -> R.string.cache_module_glide; + case PLUGIN_SCRIPTS -> R.string.cache_module_plugin_scripts; + case TEMP_FILES -> R.string.cache_module_temp_files; + case DIAGNOSTIC_LOGS -> R.string.cache_module_diagnostic_logs; + case LEGACY_FILES -> R.string.cache_module_legacy_files; + case UNCLASSIFIED -> R.string.cache_module_unclassified; + }); + } + + private String formatTime(long value) { + if (value <= 0) return getString(R.string.none); + return new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(new Date(value)); + } + +} 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..44a9e81937 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 @@ -29,6 +29,9 @@ import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import com.fongmi.android.tv.R; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeEditorUi; +import com.fongmi.android.tv.theme.ThemeTokens; import com.fongmi.android.tv.ui.helper.TmdbSeasonResolver; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; @@ -239,6 +242,7 @@ public final class ChoiceDialog extends DialogFragment { Dialog dialog = new Dialog(requireContext()); dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); dialog.setContentView(createView(LayoutInflater.from(requireContext()))); + ThemeController.bindDialog(dialog); dialog.setCanceledOnTouchOutside(true); return dialog; } @@ -384,14 +388,19 @@ public final class ChoiceDialog extends DialogFragment { if (showCancel && !multi && items != null && items.length > 0 && negative == null) negative = getString(R.string.dialog_negative); LinearLayout root = new LinearLayout(requireContext()); root.setOrientation(LinearLayout.VERTICAL); - root.setBackgroundResource(R.drawable.shape_shell_proxy_dialog); + ThemeTokens tokens = ThemeController.current(); + root.setBackground(ThemeEditorUi.shape(requireContext(), tokens.colorSurfaceContainerHigh(), 0, 0, 22)); int vertical = dp(24); int horizontal = dp(actionCount() >= 3 ? 18 : 24); root.setPadding(horizontal, vertical, horizontal, vertical); MaterialTextView titleView = new MaterialTextView(requireContext()); titleView.setText(title); - titleView.setTextColor(Color.parseColor("#202124")); + // The panel is shape_shell_proxy_dialog (?attr/colorSurfaceContainerHigh), which on + // the TV flavour compiles to the dark table (#2A2F34). A fixed #202124 title was + // therefore invisible there - measured 1.00:1 on device - while the very same + // AppearanceDialog rows were being opened from this dialog. Use the active palette. + titleView.setTextColor(ThemeController.current().colorOnSurface()); titleView.setTextSize(18); titleView.setGravity(Gravity.CENTER_VERTICAL); titleView.setSingleLine(false); @@ -406,7 +415,8 @@ public final class ChoiceDialog extends DialogFragment { private void addMessage(LinearLayout root) { MaterialTextView messageView = new MaterialTextView(requireContext()); messageView.setText(message); - messageView.setTextColor(Color.parseColor("#5F6368")); + // Same panel, same reason as the title above. + messageView.setTextColor(ThemeController.current().colorOnSurfaceVariant()); messageView.setTextSize(14); messageView.setLineSpacing(dp(2), 1f); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -480,17 +490,18 @@ public final class ChoiceDialog extends DialogFragment { } private void styleItem(MaterialButton button, int position) { + ThemeTokens tokens = ThemeController.current(); if (!itemEnabled(position)) { - button.setTextColor(ColorStateList.valueOf(Color.parseColor("#9AA0A6"))); - button.setBackgroundTintList(ColorStateList.valueOf(Color.parseColor("#F1F3F4"))); - button.setStrokeColor(ColorStateList.valueOf(Color.parseColor("#E0E0E0"))); + button.setTextColor(ColorStateList.valueOf(ThemeEditorUi.withAlpha(tokens.colorOnSurfaceVariant(), 0.45f))); + button.setBackgroundTintList(ColorStateList.valueOf(tokens.colorSurfaceContainerHighest())); + button.setStrokeColor(ColorStateList.valueOf(tokens.colorOutlineVariant())); return; } boolean on = itemSelected(position); boolean focused = button.isFocused(); - int text = focused ? Color.WHITE : on ? Color.parseColor("#174EA6") : Color.parseColor("#202124"); - int bg = focused ? Color.parseColor("#1A73E8") : on ? Color.parseColor("#E8F0FE") : Color.WHITE; - int stroke = focused ? Color.parseColor("#174EA6") : on ? Color.parseColor("#8AB4F8") : Color.parseColor("#DADCE0"); + int text = focused ? tokens.colorOnPrimary() : on ? tokens.colorOnPrimaryContainer() : tokens.colorOnSurface(); + int bg = focused ? tokens.colorPrimary() : on ? tokens.colorPrimaryContainer() : tokens.colorSurfaceContainer(); + int stroke = focused ? tokens.colorOnPrimary() : on ? tokens.colorPrimary() : tokens.colorOutline(); button.setTextColor(ColorStateList.valueOf(text)); button.setBackgroundTintList(ColorStateList.valueOf(bg)); button.setStrokeColor(ColorStateList.valueOf(stroke)); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/CodecCapabilityDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/CodecCapabilityDialog.java index 51e774696b..4d4bb8ddb4 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/CodecCapabilityDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/CodecCapabilityDialog.java @@ -189,7 +189,7 @@ public final class CodecCapabilityDialog { } private int getReportHeight() { - int screen = ResUtil.getScreenHeight(activity); + int screen = DialogSafeArea.height(activity); if (Util.isLeanback()) return Math.min(ResUtil.dp2px(430), Math.round(screen * 0.58f)); return Math.min(ResUtil.dp2px(330), Math.round(screen * (ResUtil.isLand(activity) ? 0.44f : 0.36f))); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ComicSourceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ComicSourceDialog.java index 7b0435014f..a238a3f1c7 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ComicSourceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ComicSourceDialog.java @@ -13,9 +13,9 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.ComicSourceConfig; import com.fongmi.android.tv.bean.Site; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; import java.util.List; @@ -70,7 +70,7 @@ public class ComicSourceDialog { manageBtn.setOnClickListener(v -> showSiteManage()); resetBtn.setOnClickListener(v -> resetToDefault()); - dialog = new MaterialAlertDialogBuilder(activity) + dialog = new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.setting_comic_source) .setView(view) .setPositiveButton(R.string.dialog_positive, (d, w) -> onSave()) @@ -103,7 +103,7 @@ public class ComicSourceDialog { checked[i] = matchesRule(enabledRules, site); } - new MaterialAlertDialogBuilder(activity) + new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.dialog_comic_site_manage) .setMultiChoiceItems(labels, checked, (d, which, isChecked) -> checked[which] = isChecked) .setPositiveButton(R.string.dialog_positive, (d, w) -> applySiteManage(sites, enabledRules, checked)) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/CustomCspDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/CustomCspDialog.java index 78c6f8c793..32fd33545c 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/CustomCspDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/CustomCspDialog.java @@ -49,6 +49,11 @@ import com.fongmi.android.tv.databinding.AdapterCustomCspBinding; import com.fongmi.android.tv.databinding.DialogCustomCspBinding; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.setting.CustomCspSetting; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeEditorUi; +import com.fongmi.android.tv.theme.ThemeTokens; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; +import com.fongmi.android.tv.ui.custom.CustomNestedScrollView; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.ui.custom.SafeScrollEditText; import com.fongmi.android.tv.ui.custom.SettingClipboardOverlay; @@ -95,6 +100,9 @@ public class CustomCspDialog extends BaseAlertDialog { private boolean pendingFilesImport; private TextInputEditText pendingFileTarget; private Set initialItemIds = new HashSet<>(); + private ThemeTokens themeTokens() { + return ThemeController.current(); + } private final Set pendingDeleteIds = new HashSet<>(); private CspEditor editor; private TextInputEditText recognizeInput; @@ -135,7 +143,7 @@ public class CustomCspDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -146,24 +154,9 @@ public class CustomCspDialog extends BaseAlertDialog { getDialog().setCanceledOnTouchOutside(false); Window window = getDialog().getWindow(); if (window == null) return; - WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); - boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); - params.width = (int) (screenWidth * (land ? 0.76f : 0.94f)); - params.height = land ? (int) (screenHeight * 0.98f) : WindowManager.LayoutParams.WRAP_CONTENT; - window.setAttributes(params); - window.setLayout(params.width, params.height); - ViewGroup.LayoutParams rootParams = binding.root.getLayoutParams(); - rootParams.height = land ? params.height : ViewGroup.LayoutParams.WRAP_CONTENT; - binding.root.setLayoutParams(rootParams); - LinearLayoutCompat.LayoutParams scrollParams = (LinearLayoutCompat.LayoutParams) binding.contentScroll.getLayoutParams(); - scrollParams.height = land ? 0 : ViewGroup.LayoutParams.WRAP_CONTENT; - scrollParams.weight = land ? 1 : 0; - binding.contentScroll.setLayoutParams(scrollParams); - binding.contentScroll.setMaxHeight(land ? 0 : (int) (screenHeight * 0.58f)); + applyPageSizing(window, binding.root, binding.contentScroll); binding.enabled.requestFocus(); if (clipboardOverlay == null) clipboardOverlay = SettingClipboardOverlay.attach(this, binding.getRoot()); getDialog().setOnKeyListener((dialog, keyCode, event) -> { @@ -175,6 +168,60 @@ public class CustomCspDialog extends BaseAlertDialog { }); } + /** + * 站点注入页面的窗口/内容尺寸策略:**两种朝向都铺满整页**,拆成静态方法便于 JVM 测量测试 + * 锁定“底部按钮区不被压扁”与“页面确实铺满”。 + * + *

旧策略两种朝向都不铺满:竖屏按比例手算高度({@code WRAP_CONTENT} 窗口 + 滚动区 + * {@code wrap_content} + {@code 0.58H} 上限 + 无 weight),横屏是 {@code 0.76×0.98} 居中 + * 比例弹窗。前者在条目多时把底部按钮区挤出窗口下沿裁掉(设备实测取消/确定 从 40dp 变 + * 34.9dp、末张卡片操作行只剩 9.1dp);后者在 1080×1920/1920×1080 设备上左右各空出 + * 一大块背景,用户实测反馈“没有全屏”。这个页面内容多,应按用户要求统一改成全屏整页。 + * + *

铺满后窗口高度交给系统,固定高的按钮区永远保住自然高度,剩余空间全部由权重滚动区 + * 吃掉;页面左右边缘由 XML 的 20dp padding 保证正文不贴边。 + * + *

铺满后窗口自身没有多余空间躲屏幕键盘,必须显式 ADJUST_RESIZE,否则底部按钮和列表会被 + * 键盘盖住(与 AboutDialog 全屏弹窗同一处理)。 + */ + static void applyPageSizing(Window window, LinearLayoutCompat root, CustomNestedScrollView scroll) { + WindowManager.LayoutParams params = window.getAttributes(); + params.width = WindowManager.LayoutParams.MATCH_PARENT; + params.height = WindowManager.LayoutParams.MATCH_PARENT; + params.gravity = Gravity.CENTER; + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + window.setAttributes(params); + window.setLayout(params.width, params.height); + ViewGroup.LayoutParams rootParams = root.getLayoutParams(); + rootParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + root.setLayoutParams(rootParams); + expandToWindow(root); + // 滚动区吃权重、不再用 maxHeight 手算上限,剩余空间全部由它吃掉。 + LinearLayoutCompat.LayoutParams scrollParams = (LinearLayoutCompat.LayoutParams) scroll.getLayoutParams(); + scrollParams.height = 0; + scrollParams.weight = 1; + scroll.setLayoutParams(scrollParams); + scroll.setMaxHeight(0); + } + + /** + * 铺满整页:把 root 之上的 Material 弹窗面板({@code @id/custom} → customPanel → parentPanel) + * 一并改成 match_parent。 + * + *

它们默认 wrap_content,内容比窗口短时会让 root 的 match_parent 退化成“按内容高度”, + * 页面底部留出空隙、底部按钮区不贴底。改成 match_parent 后窗口高度一路直达 root, + * 剩余空间全部由权重滚动区吃掉;DecorView 的参数不是 MarginLayoutParams,循环到那里自然结束。 + */ + private static void expandToWindow(View root) { + for (ViewParent parent = root.getParent(); parent instanceof ViewGroup; parent = parent.getParent()) { + ViewGroup group = (ViewGroup) parent; + ViewGroup.LayoutParams params = group.getLayoutParams(); + if (!(params instanceof ViewGroup.MarginLayoutParams)) break; + params.height = ViewGroup.LayoutParams.MATCH_PARENT; + group.setLayoutParams(params); + } + } + @Override protected void initView() { registry = CustomCspSetting.load(); @@ -215,6 +262,13 @@ public class CustomCspDialog extends BaseAlertDialog { if (checkedId == R.id.uiMode && !showTextMode(false)) binding.modeGroup.check(R.id.textMode); }); setupScrollableText(binding.jsonText); + binding.siteSearch.addTextChangedListener(new CustomTextListener() { + @Override + public void afterTextChanged(Editable editable) { + adapter.setSearchQuery(editable == null ? "" : editable.toString()); + updateModeVisibility(); + } + }); binding.add.setOnClickListener(view -> addItem()); binding.recognize.setOnClickListener(view -> showRecognizePanel()); binding.sort.setOnClickListener(view -> setSortMode(!sortMode)); @@ -308,8 +362,11 @@ public class CustomCspDialog extends BaseAlertDialog { private void updateModeVisibility() { boolean listMode = !textMode && !editMode; - boolean mobileSort = Util.isMobile() && listMode; + boolean searchMode = listMode && !sortMode; + boolean mobileSort = Util.isMobile() && listMode && !adapter.hasSearchQuery(); binding.recycler.setVisibility(listMode ? View.VISIBLE : View.GONE); + binding.searchLayout.setVisibility(searchMode ? View.VISIBLE : View.GONE); + binding.searchEmpty.setVisibility(searchMode && adapter.hasSearchQuery() && adapter.getItemCount() == 0 ? View.VISIBLE : View.GONE); binding.jsonLayout.setVisibility(textMode && !editMode ? View.VISIBLE : View.GONE); binding.editPanel.setVisibility(editMode ? View.VISIBLE : View.GONE); binding.add.setVisibility(listMode && !sortMode ? View.VISIBLE : View.GONE); @@ -324,7 +381,7 @@ public class CustomCspDialog extends BaseAlertDialog { } private void setSortMode(boolean sort) { - if (sort && (!Util.isMobile() || textMode || editMode)) return; + if (sort && (!Util.isMobile() || textMode || editMode || adapter.hasSearchQuery())) return; if (sortMode == sort) { updateModeVisibility(); return; @@ -628,7 +685,7 @@ public class CustomCspDialog extends BaseAlertDialog { LinearLayoutCompat container = new LinearLayoutCompat(requireContext()); container.setOrientation(LinearLayoutCompat.VERTICAL); container.setPadding(0, dp(4), 0, 0); - MaterialTextView title = text(getString(R.string.setting_custom_csp_recognize_title), 18, Color.BLACK, true); + MaterialTextView title = text(getString(R.string.setting_custom_csp_recognize_title), 18, themeTokens().colorOnSurface(), true); container.addView(title, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); params.topMargin = dp(10); @@ -949,8 +1006,8 @@ public class CustomCspDialog extends BaseAlertDialog { TextInputEditText input = new TextInputEditText(requireContext()); input.setSelectAllOnFocus(false); input.setSingleLine(!multiline); - input.setTextColor(Color.BLACK); - input.setHintTextColor(Color.parseColor("#666666")); + input.setTextColor(themeTokens().colorOnSurface()); + input.setHintTextColor(themeTokens().colorOnSurfaceVariant()); input.setInputType(multiline ? InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS : InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); input.setGravity(multiline ? Gravity.START | Gravity.TOP : Gravity.CENTER_VERTICAL); return input; @@ -966,10 +1023,11 @@ public class CustomCspDialog extends BaseAlertDialog { container.setPadding(ResUtil.dp2px(20), ResUtil.dp2px(8), ResUtil.dp2px(20), 0); TextInputLayout layout = new TextInputLayout(requireContext()); layout.setHint(hint); + ThemeTokens tokens = themeTokens(); layout.setBoxBackgroundMode(TextInputLayout.BOX_BACKGROUND_OUTLINE); - layout.setBoxBackgroundColor(Color.WHITE); - layout.setBoxStrokeColor(ResUtil.getColor(R.color.dialog_outlined_button_stroke)); - layout.setHintTextColor(ColorStateList.valueOf(Color.parseColor("#5F6368"))); + layout.setBoxBackgroundColor(tokens.colorSurfaceContainerHighest()); + layout.setBoxStrokeColor(tokens.colorOutline()); + layout.setHintTextColor(ColorStateList.valueOf(tokens.colorOnSurfaceVariant())); layout.addView(input, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); container.addView(layout, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); return container; @@ -979,7 +1037,7 @@ public class CustomCspDialog extends BaseAlertDialog { LinearLayoutCompat container = new LinearLayoutCompat(requireContext()); container.setOrientation(LinearLayoutCompat.VERTICAL); container.setPadding(ResUtil.dp2px(20), ResUtil.dp2px(8), ResUtil.dp2px(20), 0); - MaterialTextView label = text(getString(R.string.setting_custom_csp_other_fields), 12, Color.parseColor("#5F6368"), true); + MaterialTextView label = text(getString(R.string.setting_custom_csp_other_fields), 12, themeTokens().colorOnSurfaceVariant(), true); container.addView(label, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); LinearLayoutCompat.LayoutParams buttonParams = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(40)); buttonParams.topMargin = dp(4); @@ -1003,10 +1061,11 @@ public class CustomCspDialog extends BaseAlertDialog { private TextInputLayout createOtherValueLayout(TextInputEditText input) { TextInputLayout layout = new TextInputLayout(requireContext()); + ThemeTokens tokens = themeTokens(); layout.setBoxBackgroundMode(TextInputLayout.BOX_BACKGROUND_OUTLINE); - layout.setBoxBackgroundColor(Color.WHITE); - layout.setBoxStrokeColor(ResUtil.getColor(R.color.dialog_outlined_button_stroke)); - layout.setHintTextColor(ColorStateList.valueOf(Color.parseColor("#5F6368"))); + layout.setBoxBackgroundColor(tokens.colorSurfaceContainerHighest()); + layout.setBoxStrokeColor(tokens.colorOutline()); + layout.setHintTextColor(ColorStateList.valueOf(tokens.colorOnSurfaceVariant())); layout.addView(input, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); return layout; } @@ -1206,24 +1265,32 @@ public class CustomCspDialog extends BaseAlertDialog { } private int statusColor(CustomCspSetting.Item item) { - if (!effectiveEnabled(item)) return Color.parseColor("#6F7378"); - return item.isValid() && !item.hasInvalidExtensions() ? Color.parseColor("#137333") : Color.parseColor("#B3261E"); + ThemeTokens tokens = themeTokens(); + if (!effectiveEnabled(item)) return tokens.colorOnSurfaceVariant(); + return item.isValid() && !item.hasInvalidExtensions() ? tokens.colorSuccess() : tokens.colorError(); } private Drawable rowBackground(CustomCspSetting.Item item) { + ThemeTokens tokens = themeTokens(); boolean disabled = !effectiveEnabled(item); + int focusFill = tokens.colorPrimaryContainer(); + int focusStroke = ThemeEditorUi.readable(tokens.colorPrimary(), focusFill); + int normalFill = disabled ? tokens.colorSurfaceContainerHighest() + : item.isValid() ? tokens.colorSurfaceContainerHigh() : tokens.colorErrorContainer(); + int normalStroke = disabled ? tokens.colorOutlineVariant() + : item.isValid() ? tokens.colorOutlineVariant() : tokens.colorError(); StateListDrawable drawable = new StateListDrawable(); - drawable.addState(new int[]{android.R.attr.state_focused}, rowShape("#E8F0FE", "#1A73E8", 2, 8)); - drawable.addState(new int[]{android.R.attr.state_pressed}, rowShape("#E8F0FE", "#1A73E8", 2, 8)); - drawable.addState(new int[]{android.R.attr.state_activated}, rowShape("#E8F0FE", "#1A73E8", 2, 8)); - drawable.addState(new int[]{}, rowShape(disabled ? "#ECEFF1" : item.isValid() ? "#F5F6F7" : "#FFF7F7", disabled ? "#D5D9DE" : item.isValid() ? "#DADCE0" : "#F1C9C6", 1, 6)); + drawable.addState(new int[]{android.R.attr.state_focused}, rowShape(focusFill, focusStroke, 2, 8)); + drawable.addState(new int[]{android.R.attr.state_pressed}, rowShape(focusFill, focusStroke, 2, 8)); + drawable.addState(new int[]{android.R.attr.state_activated}, rowShape(focusFill, focusStroke, 2, 8)); + drawable.addState(new int[]{}, rowShape(normalFill, normalStroke, 1, 6)); return drawable; } - private GradientDrawable rowShape(String color, String stroke, int strokeDp, int radiusDp) { + private GradientDrawable rowShape(int color, int stroke, int strokeDp, int radiusDp) { GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(Color.parseColor(color)); - drawable.setStroke(dp(strokeDp), Color.parseColor(stroke)); + drawable.setColor(color); + drawable.setStroke(dp(strokeDp), stroke); drawable.setCornerRadius(dp(radiusDp)); return drawable; } @@ -1244,7 +1311,7 @@ public class CustomCspDialog extends BaseAlertDialog { view.setSingleLine(true); view.setPadding(dp(8), dp(3), dp(8), dp(3)); GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(Color.WHITE); + drawable.setColor(themeTokens().colorSurfaceContainerHighest()); drawable.setStroke(dp(1), color); drawable.setCornerRadius(dp(6)); view.setBackground(drawable); @@ -1256,11 +1323,12 @@ public class CustomCspDialog extends BaseAlertDialog { } private int titleColor(boolean enabled) { - return Color.parseColor(enabled ? "#202124" : "#8A8D91"); + ThemeTokens tokens = themeTokens(); + return enabled ? tokens.colorOnSurface() : tokens.colorOnSurfaceVariant(); } private int detailColor(boolean disabled) { - return Color.parseColor(disabled ? "#9AA0A6" : "#5F6368"); + return themeTokens().colorOnSurfaceVariant(); } private void addDetail(LinearLayoutCompat root, String value) { @@ -1283,7 +1351,7 @@ public class CustomCspDialog extends BaseAlertDialog { button.setMinimumHeight(dp(36)); button.setPadding(dp(6), 0, dp(6), 0); ColorStateList bg = ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_bg : R.color.dialog_outlined_button_bg); - ColorStateList fg = danger ? ColorStateList.valueOf(Color.parseColor("#B3261E")) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); + ColorStateList fg = danger ? ColorStateList.valueOf(themeTokens().colorError()) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); button.setBackgroundTintList(bg); button.setTextColor(fg); if (!tonal) { @@ -1303,7 +1371,7 @@ public class CustomCspDialog extends BaseAlertDialog { AppCompatImageButton button = new AppCompatImageButton(requireContext()); button.setBackgroundResource(R.drawable.selector_dialog_switch); button.setImageResource(icon); - button.setColorFilter(Color.parseColor("#5F6368")); + button.setColorFilter(themeTokens().colorOnSurfaceVariant()); button.setContentDescription(getString(contentDescription)); button.setFocusable(true); button.setPadding(dp(8), dp(8), dp(8), dp(8)); @@ -1328,36 +1396,60 @@ public class CustomCspDialog extends BaseAlertDialog { private class CspAdapter extends RecyclerView.Adapter { private final List items; + private final List visibleIndices = new ArrayList<>(); private boolean reverseOrder; private boolean sortMode; + private String searchQuery = ""; CspAdapter(List items) { this.items = items; + refreshVisibleIndices(); } List getItems() { return items; } + boolean hasSearchQuery() { + return !TextUtils.isEmpty(searchQuery.trim()); + } + + void setSearchQuery(String query) { + String value = query == null ? "" : query; + if (TextUtils.equals(searchQuery, value)) return; + searchQuery = value; + refreshVisibleIndices(); + notifyDataSetChanged(); + } + + private void refreshVisibleIndices() { + visibleIndices.clear(); + for (int i = 0; i < items.size(); i++) { + if (!hasSearchQuery() || CustomCspSetting.matchesSearch(items.get(i), searchQuery)) visibleIndices.add(i); + } + } + int add(CustomCspSetting.Item item) { items.add(item); + refreshVisibleIndices(); markJsonDirty(); - int position = displayPosition(items.size() - 1); - notifyItemInserted(position); - return position; + notifyDataSetChanged(); + return displayPosition(items.size() - 1); } void replace(int position, CustomCspSetting.Item item) { if (position < 0 || position >= items.size()) return; CustomCspSetting.Item old = items.set(position, item); if (!old.isLive() && old.site().getKey().equals(registry.getHomeKey())) registry.setHomeKey(item.isLive() ? "" : item.site().getKey()); + refreshVisibleIndices(); markJsonDirty(); - notifyItemChanged(displayPosition(position)); + notifyDataSetChanged(); } void setItems(List items) { this.items.clear(); this.items.addAll(items); + refreshVisibleIndices(); markJsonDirty(); notifyDataSetChanged(); } @@ -1384,17 +1476,18 @@ public class CustomCspDialog extends BaseAlertDialog { } int moveDisplay(int fromPosition, int toPosition) { - if (fromPosition < 0 || toPosition < 0 || fromPosition >= items.size() || toPosition >= items.size()) return -1; + if (hasSearchQuery() || fromPosition < 0 || toPosition < 0 || fromPosition >= getItemCount() || toPosition >= getItemCount()) return -1; return moveItemToIndex(itemIndex(fromPosition), itemIndex(toPosition)); } int moveItemToIndex(int fromIndex, int toIndex) { - if (fromIndex < 0 || toIndex < 0 || fromIndex >= items.size() || toIndex >= items.size()) return -1; + if (hasSearchQuery() || fromIndex < 0 || toIndex < 0 || fromIndex >= items.size() || toIndex >= items.size()) return -1; if (fromIndex == toIndex) return displayPosition(toIndex); int fromPosition = displayPosition(fromIndex); int toPosition = displayPosition(toIndex); CustomCspSetting.Item item = items.remove(fromIndex); items.add(toIndex, item); + refreshVisibleIndices(); markJsonDirty(); notifyItemMoved(fromPosition, toPosition); notifyItemRangeChanged(Math.min(fromPosition, toPosition), Math.abs(fromPosition - toPosition) + 1); @@ -1402,23 +1495,30 @@ public class CustomCspDialog extends BaseAlertDialog { } void remove(int position, View removed) { - if (position < 0 || position >= items.size()) return; + if (position < 0 || position >= getItemCount()) return; int index = itemIndex(position); + if (index < 0 || index >= items.size()) return; focusBeforeRemove(removed); CustomCspSetting.Item item = items.remove(index); if (!item.isLive() && item.site().getKey().equals(registry.getHomeKey())) registry.setHomeKey(""); if (!TextUtils.isEmpty(item.getId())) pendingDeleteIds.add(item.getId()); + refreshVisibleIndices(); markJsonDirty(); notifyDataSetChanged(); + updateModeVisibility(); } int itemIndex(int position) { - return reverseOrder ? items.size() - 1 - position : position; + if (position < 0 || position >= visibleIndices.size()) return -1; + int visiblePosition = reverseOrder ? visibleIndices.size() - 1 - position : position; + return visibleIndices.get(visiblePosition); } int displayPosition(int index) { if (index < 0 || index >= items.size()) return -1; - return reverseOrder ? items.size() - 1 - index : index; + int visiblePosition = visibleIndices.indexOf(index); + if (visiblePosition < 0) return -1; + return reverseOrder ? visibleIndices.size() - 1 - visiblePosition : visiblePosition; } void setHome(CustomCspSetting.Item item) { @@ -1431,7 +1531,7 @@ public class CustomCspDialog extends BaseAlertDialog { @Override public int getItemCount() { - return items.size(); + return visibleIndices.size(); } @NonNull @@ -1449,6 +1549,7 @@ public class CustomCspDialog extends BaseAlertDialog { @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { int index = itemIndex(position); + if (index < 0 || index >= items.size()) return; holder.bind(items.get(index), index); } @@ -1498,6 +1599,11 @@ public class CustomCspDialog extends BaseAlertDialog { } else { AppCompatImageButton up = iconButton(R.drawable.ic_subtitle_up, R.string.setting_custom_csp_up, view -> move(getBindingAdapterPosition(), getBindingAdapterPosition() - 1)); AppCompatImageButton down = iconButton(R.drawable.ic_subtitle_down, R.string.setting_custom_csp_down, view -> move(getBindingAdapterPosition(), getBindingAdapterPosition() + 1)); + boolean canMove = !hasSearchQuery(); + up.setEnabled(canMove); + down.setEnabled(canMove); + up.setAlpha(canMove ? 1.0f : 0.45f); + down.setAlpha(canMove ? 1.0f : 0.45f); linkCardFocus(root, up); linkCardFocus(root, down); header.addView(up, iconLayout(8)); 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..984037f3a2 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 @@ -6,13 +6,14 @@ import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.net.Uri; -import android.graphics.Color; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; import com.fongmi.android.tv.server.Server; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Notify; import com.github.catvod.crawler.SpiderDebug; @@ -35,7 +36,10 @@ public final class DebugLogDialog { String message = activity.getString(R.string.debug_log_dialog_message, lanUrl, localUrl); MaterialTextView content = new MaterialTextView(activity); content.setText(message); - content.setTextColor(Color.parseColor("#5F6368")); + // 说明文字是次要前景:跟随调色板的 onSurfaceVariant。原先硬编码 #5F6368, + // 而承载它的 shape_shell_proxy_dialog 面板是 ?attr/colorSurfaceContainerHigh + // (日间浅色、夜间深色),于是夜间变成深底深字(实测 2.23:1)。 + content.setTextColor(ThemeController.current().colorOnSurfaceVariant()); content.setTextSize(14); content.setLineSpacing(ResUtil.dp2px(2), 1f); android.widget.LinearLayout panel = new android.widget.LinearLayout(activity); panel.setOrientation(android.widget.LinearLayout.VERTICAL); @@ -43,7 +47,8 @@ public final class DebugLogDialog { 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")); + // 开关标签是主要前景:跟随调色板的 onSurface(原先硬编码 #202124,夜间仅 1.19:1)。 + toggle.setTextColor(ThemeController.current().colorOnSurface()); 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)); @@ -54,13 +59,13 @@ public final class DebugLogDialog { 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("选择当前现象") + mark.setOnClickListener(v -> new WebHtvAlertDialogBuilder(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("限时深度统计") + depth.setOnClickListener(v -> new WebHtvAlertDialogBuilder(activity).setTitle("限时深度统计") .setMessage("对当前播放做少量低分辨率画面和 PCM 数值统计,不保存图像或声音。到期、切换播放或关闭诊断自动停止。") .setNegativeButton("取消", null).setPositiveButton("开启 60 秒", (d, which) -> { try { com.fongmi.android.tv.player.DiagnosticControls.startDepth(60); Notify.show("限时统计已开启"); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/DialogSafeArea.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DialogSafeArea.java new file mode 100644 index 0000000000..870c82f718 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/DialogSafeArea.java @@ -0,0 +1,142 @@ +package com.fongmi.android.tv.ui.dialog; + +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.view.View; +import android.view.Window; + +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +import com.fongmi.android.tv.utils.ResUtil; + +import java.util.WeakHashMap; + +/** + * The screen area a floating dialog may occupy without colliding with the system bars. + * + *

{@code ResUtil.getScreenWidth/Height} return the whole display, not the area the window + * manager actually offers a floating dialog: this app draws edge to edge, so on phones the value + * still includes the status bar and the navigation/gesture bar. A dialog window sized (or capped) + * from that value is taller than the bar-safe area, and its bottom row - a fixed-height action + * button - ends up painted under the navigation bar. The theme colour editor and the playback + * health report were both clipped that way; every dialog that sizes its window or an inner + * scroll/list cap from the screen now goes through this class instead. + * + *

The bar-safe area is the display minus the dialog window's own insets, falling back to the + * host activity's root insets and then to the space the host content view already gave up. An + * all-zero inset report is treated as unknown rather than as "no bars", because a floating dialog + * window on API 28 keeps reporting zeros while the navigation bar is on screen. The math itself + * stays in {@link ThemeDialogLayout} so every dialog shares one implementation. + * + *

On a television, or in any fullscreen/immersive host, the resolved insets are zero and this + * class returns exactly the old display-based numbers, so no television geometry changes. + */ +final class DialogSafeArea { + + private static final WeakHashMap OBSERVED = new WeakHashMap<>(); + + private DialogSafeArea() { + } + + /** Normalises one platform inset report into the four edges a dialog must keep clear of. */ + static ThemeDialogLayout.Insets of(WindowInsetsCompat insets) { + Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); + Insets gestures = insets.getInsets(WindowInsetsCompat.Type.systemGestures()); + // Only the bottom edge takes the gesture area into account: some OEM gesture-navigation + // builds under-report systemBars().bottom while the handle still covers the window, but + // letting the left/right gesture regions into the max would needlessly narrow dialogs on + // three-button-navigation phones. + return new ThemeDialogLayout.Insets(bars.left, bars.top, bars.right, Math.max(bars.bottom, gestures.bottom)); + } + + /** The bar-safe area for a dialog window, or for its host when the window is unknown. */ + static ThemeDialogLayout.Area area(Activity activity, Window window) { + ThemeDialogLayout.Insets insets = window == null ? null : OBSERVED.get(window.getDecorView()); + if (insets == null || insets.isEmpty()) insets = hostInsets(activity); + int displayWidth = ResUtil.getScreenWidth(activity); + int displayHeight = ResUtil.getScreenHeight(activity); + if (insets.isEmpty()) { + insets = ThemeDialogLayout.Insets.ofContent(displayWidth, displayHeight, contentWidth(activity), contentHeight(activity)); + } + return ThemeDialogLayout.safeArea(displayWidth, displayHeight, insets); + } + + static ThemeDialogLayout.Area area(Activity activity) { + return area(activity, null); + } + + /** Bar-safe width for the given dialog window (never below one pixel). */ + static int width(Activity activity, Window window) { + return Math.max(1, area(activity, window).width()); + } + + /** Bar-safe height for the given dialog window (never below one pixel). */ + static int height(Activity activity, Window window) { + return Math.max(1, area(activity, window).height()); + } + + /** Bar-safe width for a context that may not be an Activity; falls back to the display. */ + static int width(Context context) { + Activity activity = activityOf(context); + return activity == null ? ResUtil.getScreenWidth(context) : width(activity, null); + } + + /** Bar-safe height for a context that may not be an Activity; falls back to the display. */ + static int height(Context context) { + Activity activity = activityOf(context); + return activity == null ? ResUtil.getScreenHeight(context) : height(activity, null); + } + + /** + * Re-runs {@code resize} whenever the system-bar insets change, so a window sized before the + * platform reported its insets - or before a rotation - converges on the bar-safe area instead + * of staying clipped. The listener only fires when the reported insets really change, so it + * cannot loop with the resize that {@code window.setLayout} triggers. + */ + static void follow(Activity activity, Window window, Runnable resize) { + if (activity == null || window == null) return; + View decorView = window.getDecorView(); + ViewCompat.setOnApplyWindowInsetsListener(decorView, (view, insets) -> { + ThemeDialogLayout.Insets next = of(insets); + ThemeDialogLayout.Insets cached = OBSERVED.get(view); + if (!next.equals(cached)) { + OBSERVED.put(view, next); + resize.run(); + } + return insets; + }); + ViewCompat.requestApplyInsets(decorView); + } + + private static ThemeDialogLayout.Insets hostInsets(Activity activity) { + if (activity == null) return ThemeDialogLayout.Insets.none(); + try { + WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(activity.getWindow().getDecorView()); + return insets == null ? ThemeDialogLayout.Insets.none() : of(insets); + } catch (RuntimeException error) { + return ThemeDialogLayout.Insets.none(); + } + } + + private static int contentWidth(Activity activity) { + View content = activity == null ? null : activity.findViewById(android.R.id.content); + return content == null ? 0 : content.getWidth(); + } + + private static int contentHeight(Activity activity) { + View content = activity == null ? null : activity.findViewById(android.R.id.content); + return content == null ? 0 : content.getHeight(); + } + + private static Activity activityOf(Context context) { + Context current = context; + while (current instanceof ContextWrapper) { + if (current instanceof Activity activity) return activity; + current = ((ContextWrapper) current).getBaseContext(); + } + return current instanceof Activity activity ? activity : null; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/GitCloudDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/GitCloudDialog.java index 2189daf4cc..4ba9cd2517 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/GitCloudDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/GitCloudDialog.java @@ -36,6 +36,7 @@ import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.appcompat.widget.LinearLayoutCompat; import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; import androidx.core.widget.NestedScrollView; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; @@ -62,6 +63,7 @@ import com.fongmi.android.tv.gitcloud.drive.JGitDriveEngine; import com.fongmi.android.tv.gitcloud.provider.GitCloudProvider; import com.fongmi.android.tv.gitcloud.provider.GitCloudProviders; import com.fongmi.android.tv.gitcloud.secure.GitCloudTokenStore; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.SafeScrollEditText; import com.fongmi.android.tv.ui.custom.SettingClipboardOverlay; import com.fongmi.android.tv.utils.AppBackup; @@ -168,7 +170,7 @@ public class GitCloudDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -178,8 +180,8 @@ public class GitCloudDialog extends BaseAlertDialog { if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); boolean land = ResUtil.isLand(requireContext()); - params.width = (int) (ResUtil.getScreenWidth(requireContext()) * (land ? 0.72f : 0.94f)); - params.height = (int) (ResUtil.getScreenHeight(requireContext()) * 0.86f); + params.width = (int) (DialogSafeArea.width(requireActivity(), window) * (land ? 0.72f : 0.94f)); + params.height = (int) (DialogSafeArea.height(requireActivity(), window) * 0.86f); params.gravity = Gravity.TOP | Gravity.CENTER_HORIZONTAL; params.y = dp(18); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); @@ -2159,7 +2161,7 @@ public class GitCloudDialog extends BaseAlertDialog { edit.setTextColor(Color.parseColor("#202124")); edit.setHintTextColor(Color.parseColor("#5F6368")); edit.setSelectAllOnFocus(false); - edit.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO); + ViewCompat.setImportantForAutofill(edit, View.IMPORTANT_FOR_AUTOFILL_NO); if (password) edit.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); layout.addView(edit, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); return new TextInput(layout, edit); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/GroupRuleDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/GroupRuleDialog.java index 64457437dc..cc89a005ee 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/GroupRuleDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/GroupRuleDialog.java @@ -33,6 +33,7 @@ import com.fongmi.android.tv.setting.GroupRuleConfig; import com.fongmi.android.tv.setting.GroupRuleStore; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.setting.SiteNameStore; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Task; @@ -510,8 +511,8 @@ public final class GroupRuleDialog { private void configureAiPreviewWindow(AlertDialog dialog, NestedScrollView contentScroll) { Window window = dialog.getWindow(); if (window == null) return; - int screenWidth = ResUtil.getScreenWidth(activity); - int screenHeight = ResUtil.getScreenHeight(activity); + int screenWidth = DialogSafeArea.width(activity, window); + int screenHeight = DialogSafeArea.height(activity, window); boolean landscape = screenWidth >= screenHeight; AiGroupRulePreviewSizing.Size size = AiGroupRulePreviewSizing.calculate( screenWidth, screenHeight, dp(landscape ? 24 : 12), dp(landscape ? 170 : 190)); @@ -592,7 +593,7 @@ public final class GroupRuleDialog { } private MaterialAlertDialogBuilder builder() { - return new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog); + return new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog); } 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/LightDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LightDialog.java index be0b5c43ce..4101401c4f 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,6 +21,8 @@ import androidx.core.content.ContextCompat; import androidx.core.widget.TextViewCompat; import com.fongmi.android.tv.R; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeEditorUi; import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; @@ -37,11 +39,31 @@ public final class LightDialog { 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; + // The shell keeps the opaque semantic fill on purpose: dialogOpacity is applied by + // bindWindowBackground below, which is the one documented owner of that slot. Baking + // the alpha in here scaled it twice (0.70 -> 0.49) and, because a translucent fill no + // longer matches its baseline role, also stopped the shell from following a user + // surfaceContainerHigh override. + Drawable background = shell(dialog.getContext()); int verticalInset = (int) (dialog.getContext().getResources().getDisplayMetrics().density * 24); window.setBackgroundDrawable(new InsetDrawable(background, 0, verticalInset, 0, verticalInset)); if (Util.isLeanback()) applyAlertWindow(dialog, window); + // AlertController installs the message and button views during show(), so the + // binder root is registered here and re-walked by descendant-count changes. + ThemeController.bindDialog(dialog); + ThemeController.bindWindowBackground(background); + } + + /** + * The one dialog shell shape, shared by the AlertDialog and the custom-Dialog paths. + * + *

Public so a test can assert the two halves of the shell contract on the real + * drawable: it ships opaque (so {@code dialogOpacity} has a single owner), and its fill + * is the semantic {@code colorSurfaceContainerHigh} value (so the binder's exact-match + * rewrite can still find the role). + */ + public static Drawable shell(Context context) { + return ThemeEditorUi.shape(context, ThemeController.current().colorSurfaceContainerHigh(), 0, 0, 22); } static int resolveAlertWidth(int screenWidth, int screenHeight) { @@ -129,6 +151,7 @@ public final class LightDialog { dialog.setOnShowListener(d -> { applyWindow(dialog, context, landFactor, portFactor, maxDp, heightPx); TouchOptimizationHelper.sync(dialog); + ThemeController.bindDialog(dialog); }); return dialog; } @@ -143,7 +166,7 @@ public final class LightDialog { private static View root(Context context, CharSequence title, View content, String positive, View.OnClickListener onPositive, String negative, View.OnClickListener onNegative, String neutral, View.OnClickListener onNeutral, boolean fillHeight) { LinearLayout root = new LinearLayout(context); root.setOrientation(LinearLayout.VERTICAL); - root.setBackgroundResource(R.drawable.shape_shell_proxy_dialog); + root.setBackground(shell(context)); int actionCount = (positive == null ? 0 : 1) + (negative == null ? 0 : 1) + (neutral == null ? 0 : 1); int vertical = ResUtil.dp2px(24); int horizontal = ResUtil.dp2px(actionCount >= 3 ? 18 : 24); @@ -152,7 +175,7 @@ public final class LightDialog { if (title != null) { MaterialTextView titleView = new MaterialTextView(context); titleView.setText(title); - titleView.setTextColor(Color.parseColor("#202124")); + titleView.setTextColor(ThemeController.current().colorOnSurface()); titleView.setTextSize(18); titleView.setGravity(Gravity.CENTER_VERTICAL); root.addView(titleView, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStateLearnDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStateLearnDialog.java index 2d3d684856..2ed9114b5e 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStateLearnDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStateLearnDialog.java @@ -19,6 +19,7 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogLoginStateLearnBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.LoginStateSync; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; @@ -57,7 +58,7 @@ public final class LoginStateLearnDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -104,7 +105,7 @@ public final class LoginStateLearnDialog extends BaseAlertDialog { private void setScrollHeight(FragmentActivity activity) { ViewGroup.LayoutParams params = binding.contentScroll.getLayoutParams(); - params.height = Math.max(ResUtil.dp2px(190), Math.min(ResUtil.dp2px(300), (int) (ResUtil.getScreenHeight(activity) * 0.34f))); + params.height = Math.max(ResUtil.dp2px(190), Math.min(ResUtil.dp2px(300), (int) (DialogSafeArea.height(activity) * 0.34f))); binding.contentScroll.setLayoutParams(params); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStatePathDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStatePathDialog.java index 6bbd694bfb..22aa84b23f 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStatePathDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/LoginStatePathDialog.java @@ -29,6 +29,8 @@ import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterLoginStateTreeBinding; import com.fongmi.android.tv.databinding.DialogLoginStatePathBinding; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.ui.custom.SafeScrollEditText; import com.fongmi.android.tv.utils.FileUtil; @@ -100,7 +102,7 @@ public class LoginStatePathDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -136,8 +138,8 @@ public class LoginStatePathDialog extends BaseAlertDialog { Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); + int screenWidth = DialogSafeArea.width(requireActivity(), window); + int screenHeight = DialogSafeArea.height(requireActivity(), window); boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); @@ -447,7 +449,7 @@ public class LoginStatePathDialog extends BaseAlertDialog { container.setPadding(ResUtil.dp2px(20), ResUtil.dp2px(8), ResUtil.dp2px(20), 0); TextView label = new TextView(requireContext()); label.setText(preview.getPath()); - label.setTextColor(Color.parseColor("#5F6368")); + label.setTextColor(ThemeController.current().colorOnSurfaceVariant()); label.setTextSize(12); label.setSingleLine(true); label.setEllipsize(TextUtils.TruncateAt.MIDDLE); @@ -455,12 +457,12 @@ public class LoginStatePathDialog extends BaseAlertDialog { if (preview.isTruncated() || !preview.isEditable()) { TextView meta = new TextView(requireContext()); meta.setText(preview.isTruncated() ? R.string.login_state_preview_truncated : R.string.login_state_preview_readonly); - meta.setTextColor(Color.parseColor("#8A4B00")); + meta.setTextColor(ThemeController.current().colorWarning()); meta.setTextSize(12); meta.setPadding(0, ResUtil.dp2px(6), 0, 0); container.addView(meta, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); } - LinearLayoutCompat.LayoutParams inputParams = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (int) (ResUtil.getScreenHeight(requireContext()) * 0.52f)); + LinearLayoutCompat.LayoutParams inputParams = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (int) (DialogSafeArea.height(requireActivity()) * 0.52f)); inputParams.topMargin = ResUtil.dp2px(8); SafeScrollEditText input = null; if (payload.listPreview) { @@ -486,6 +488,7 @@ public class LoginStatePathDialog extends BaseAlertDialog { private SafeScrollEditText editText(LoginStateSync.TextPreview preview) { SafeScrollEditText input = new SafeScrollEditText(requireContext()); input.setText(preview.getContent(), TextView.BufferType.EDITABLE); + input.setTextColor(ThemeController.current().colorOnSurface()); input.setSelectAllOnFocus(false); input.setSingleLine(false); input.setHorizontallyScrolling(true); @@ -509,9 +512,9 @@ public class LoginStatePathDialog extends BaseAlertDialog { private GradientDrawable editorBackground() { GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(Color.parseColor("#F8FAFD")); + drawable.setColor(ThemeController.current().colorSurfaceContainerHigh()); drawable.setCornerRadius(ResUtil.dp2px(8)); - drawable.setStroke(1, Color.parseColor("#DADCE0")); + drawable.setStroke(1, ThemeController.current().colorOutlineVariant()); return drawable; } @@ -576,15 +579,15 @@ public class LoginStatePathDialog extends BaseAlertDialog { private int stateColor(Row row) { String path = row.item.getPath(); - if (isMissing(path)) return Color.parseColor("#B3261E"); - if (isPending(path) || (row.item.isDir() && hasPendingChild(path))) return Color.parseColor("#B06000"); - return Color.parseColor("#174EA6"); + if (isMissing(path)) return ThemeController.current().colorError(); + if (isPending(path) || (row.item.isDir() && hasPendingChild(path))) return ThemeController.current().colorWarning(); + return ThemeController.current().colorPrimary(); } private int nameColor(Row row) { - if (isPending(row.item.getPath()) || (row.item.isDir() && hasPendingChild(row.item.getPath()))) return Color.parseColor("#8A4B00"); - if (isMissing(row.item.getPath())) return Color.parseColor("#B3261E"); - return Color.parseColor("#202124"); + if (isPending(row.item.getPath()) || (row.item.isDir() && hasPendingChild(row.item.getPath()))) return ThemeController.current().colorWarning(); + if (isMissing(row.item.getPath())) return ThemeController.current().colorError(); + return ThemeController.current().colorOnSurface(); } private int iconFor(LoginStateSync.TreeItem item) { @@ -755,7 +758,7 @@ public class LoginStatePathDialog extends BaseAlertDialog { @Override public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { TextView text = new TextView(parent.getContext()); - text.setTextColor(Color.parseColor("#202124")); + text.setTextColor(ThemeController.current().colorOnSurface()); text.setTextSize(12); text.setTypeface(Typeface.MONOSPACE); text.setLineSpacing(0, 1.08f); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ManagePageDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ManagePageDialog.java index d62b714490..dcc51dc7eb 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ManagePageDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ManagePageDialog.java @@ -89,7 +89,7 @@ public final class ManagePageDialog { private static void setScrollHeight(FragmentActivity activity, View view, float screenFactor, int maxDp) { ViewGroup.LayoutParams params = view.getLayoutParams(); - params.height = Math.min(ResUtil.dp2px(maxDp), Math.round(ResUtil.getScreenHeight(activity) * screenFactor)); + params.height = Math.min(ResUtil.dp2px(maxDp), Math.round(DialogSafeArea.height(activity) * screenFactor)); view.setLayoutParams(params); } 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..5c74f6daeb 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 @@ -22,6 +22,7 @@ import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogMpvConfigCreateBinding; import com.fongmi.android.tv.player.mpv.MpvConfigStore; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; @@ -89,7 +90,7 @@ public class MpvConfigCreateDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override 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..6afc51bb63 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 @@ -25,6 +25,8 @@ import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogMpvConfigManagerBinding; import com.fongmi.android.tv.player.mpv.MpvConfigStore; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.MpvConfigProfileAdapter; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; @@ -62,7 +64,7 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -84,8 +86,12 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile private void setupTabs() { binding.tabs.setTabMode(TabLayout.MODE_FIXED); binding.tabs.setTabGravity(TabLayout.GRAVITY_FILL); - binding.tabs.setSelectedTabIndicatorColor(Color.parseColor("#1A73E8")); - binding.tabs.setTabTextColors(Color.parseColor("#5F6368"), Color.parseColor("#1A73E8")); + // Tab 是对话框面板上的前景:未选中用次要前景,选中用强调色。 + // 原先硬编码 #5F6368 / #1A73E8,夜间在深色面板上只有 2.23:1。 + binding.tabs.setSelectedTabIndicatorColor(ThemeController.current().colorPrimary()); + binding.tabs.setTabTextColors( + ThemeController.current().colorOnSurfaceVariant(), + ThemeController.current().colorPrimary()); binding.tabs.setTabRippleColor(android.content.res.ColorStateList.valueOf(Color.TRANSPARENT)); binding.tabs.setUnboundedRipple(false); for (String label : targets()) binding.tabs.addTab(binding.tabs.newTab().setText(label)); @@ -239,7 +245,9 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile item.setPadding(ResUtil.dp2px(14), 0, ResUtil.dp2px(14), 0); item.setCompoundDrawablePadding(ResUtil.dp2px(12)); item.setCompoundDrawablesRelativeWithIntrinsicBounds(iconRes, 0, 0, 0); - int color = Color.parseColor(danger ? "#C5221F" : "#202124"); + // 菜单底色是 ?attr/colorSurfaceContainerLowest(日间白 / 夜间近黑), + // 所以文字必须跟着调色板走:普通项用 onSurface,删除项用 error。 + int color = danger ? ThemeController.current().colorError() : ThemeController.current().colorOnSurface(); item.setTextColor(color); TextViewCompat.setCompoundDrawableTintList(item, android.content.res.ColorStateList.valueOf(color)); TypedValue value = new TypedValue(); @@ -384,8 +392,8 @@ public class MpvConfigDialog extends BaseAlertDialog implements MpvConfigProfile boolean land = ResUtil.isLand(requireContext()); WindowManager.LayoutParams params = window.getAttributes(); float width = Util.isLeanback() ? 0.68f : land ? 0.62f : 0.94f; - params.width = Math.min((int) (ResUtil.getScreenWidth(requireContext()) * width), ResUtil.dp2px(760)); - params.height = (int) (ResUtil.getScreenHeight(requireContext()) * (land ? 0.88f : 0.82f)); + params.width = Math.min((int) (DialogSafeArea.width(requireActivity(), window) * width), ResUtil.dp2px(760)); + params.height = (int) (DialogSafeArea.height(requireActivity(), window) * (land ? 0.88f : 0.82f)); params.dimAmount = 0.58f; window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigEditorDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigEditorDialog.java index 0570802100..2f241d19b3 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigEditorDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigEditorDialog.java @@ -14,6 +14,7 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogMpvConfigEditorBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -46,7 +47,7 @@ public class MpvConfigEditorDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -105,8 +106,8 @@ public class MpvConfigEditorDialog extends BaseAlertDialog { if (window == null) return; boolean land = ResUtil.isLand(requireContext()); WindowManager.LayoutParams params = window.getAttributes(); - params.width = (int) (ResUtil.getScreenWidth(requireContext()) * (land ? 0.72f : 0.96f)); - params.height = (int) (ResUtil.getScreenHeight(requireContext()) * (land ? 0.9f : 0.88f)); + params.width = (int) (DialogSafeArea.width(requireActivity(), window) * (land ? 0.72f : 0.96f)); + params.height = (int) (DialogSafeArea.height(requireActivity(), window) * (land ? 0.9f : 0.88f)); params.dimAmount = 0.58f; window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigHistoryDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigHistoryDialog.java index 63c14c500b..94f0d9adb3 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigHistoryDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigHistoryDialog.java @@ -12,6 +12,7 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogHistoryBinding; import com.fongmi.android.tv.player.mpv.MpvConfigStore; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.MpvConfigHistoryAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.utils.ResUtil; @@ -46,7 +47,7 @@ public class MpvConfigHistoryDialog extends BaseAlertDialog implements MpvConfig @Override protected MaterialAlertDialogBuilder getBuilder() { - MaterialAlertDialogBuilder builder = Util.isLeanback() ? builder() : new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog); + MaterialAlertDialogBuilder builder = Util.isLeanback() ? builder() : new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog); return builder.setView(getBinding().getRoot()); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigRenameDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigRenameDialog.java index 9541542a9d..3f68930749 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigRenameDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MpvConfigRenameDialog.java @@ -12,6 +12,7 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogMpvConfigRenameBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -40,7 +41,7 @@ public class MpvConfigRenameDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override 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 index d6581da9b7..07f15d3189 100644 --- 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 @@ -30,11 +30,11 @@ import androidx.fragment.app.FragmentManager; import com.fongmi.android.tv.R; import com.fongmi.android.tv.player.mpv.MpvConfigStore; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; 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; @@ -83,7 +83,7 @@ public class MpvCustomButtonDialog extends DialogFragment { 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(); + return new WebHtvAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_WebHTV_Dialog).setView(root).create(); } private void refresh() { @@ -157,7 +157,7 @@ public class MpvCustomButtonDialog extends DialogFragment { } private void confirmDelete(MpvConfigStore.CustomButton button) { - new MaterialAlertDialogBuilder(requireContext()) + new WebHtvAlertDialogBuilder(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) @@ -265,8 +265,8 @@ public class MpvCustomButtonDialog extends DialogFragment { 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.width = Math.min((int) (DialogSafeArea.width(requireActivity(), window) * (Util.isLeanback() ? 0.68f : 0.94f)), ResUtil.dp2px(760)); + params.height = (int) (DialogSafeArea.height(requireActivity(), window) * (Util.isLeanback() ? 0.88f : 0.9f)); params.dimAmount = 0.58f; window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); @@ -391,7 +391,7 @@ public class MpvCustomButtonDialog extends DialogFragment { 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(); + return new WebHtvAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_WebHTV_Dialog).setView(scroll).create(); } private void save() { @@ -439,8 +439,8 @@ public class MpvCustomButtonDialog extends DialogFragment { 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.width = Math.min((int) (DialogSafeArea.width(requireActivity(), window) * (Util.isLeanback() ? 0.68f : 0.94f)), ResUtil.dp2px(760)); + params.height = (int) (DialogSafeArea.height(requireActivity(), window) * (Util.isLeanback() ? 0.9f : 0.92f)); params.dimAmount = 0.58f; window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MultiThreadProxyDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MultiThreadProxyDialog.java index 426dc2030d..3d9265dd8c 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/MultiThreadProxyDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/MultiThreadProxyDialog.java @@ -22,6 +22,7 @@ import com.fongmi.android.tv.server.proxy.ProxyDomainRuleSet; import com.fongmi.android.tv.server.proxy.ProxyRuntimeConfig; import com.fongmi.android.tv.server.proxy.ProxyRuntimeConfigValidator; import com.fongmi.android.tv.setting.MultiThreadProxySetting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -72,7 +73,7 @@ public class MultiThreadProxyDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog) + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog) .setView(getBinding().getRoot()); } @@ -83,8 +84,8 @@ public class MultiThreadProxyDialog extends BaseAlertDialog { if (dialog == null) return; Window window = dialog.getWindow(); if (window == null) return; - int width = ResUtil.getScreenWidth(requireContext()); - int height = ResUtil.getScreenHeight(requireContext()); + int width = DialogSafeArea.width(requireActivity(), window); + int height = DialogSafeArea.height(requireActivity(), window); boolean land = ResUtil.isLand(requireContext()); WindowManager.LayoutParams params = window.getAttributes(); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); @@ -222,7 +223,7 @@ public class MultiThreadProxyDialog extends BaseAlertDialog { private void showApplyChoice(ProxyRuntimeConfig next, ProxyDomainRuleSet rules) { FragmentActivity activity = requireActivity(); dismiss(); - new MaterialAlertDialogBuilder(activity) + new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.multi_thread_proxy_apply_title) .setMessage(R.string.multi_thread_proxy_apply_message) .setNegativeButton(R.string.multi_thread_proxy_apply_later, diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/NovelSourceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/NovelSourceDialog.java index b0e96778df..1526cc63e5 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/NovelSourceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/NovelSourceDialog.java @@ -13,9 +13,9 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.NovelSourceConfig; import com.fongmi.android.tv.bean.Site; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; import java.util.List; @@ -70,7 +70,7 @@ public class NovelSourceDialog { manageBtn.setOnClickListener(v -> showSiteManage()); resetBtn.setOnClickListener(v -> resetToDefault()); - dialog = new MaterialAlertDialogBuilder(activity) + dialog = new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.setting_novel_source) .setView(view) .setPositiveButton(R.string.dialog_positive, (d, w) -> onSave()) @@ -103,7 +103,7 @@ public class NovelSourceDialog { checked[i] = matchesRule(enabledRules, site); } - new MaterialAlertDialogBuilder(activity) + new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.dialog_novel_site_manage) .setMultiChoiceItems(labels, checked, (d, which, isChecked) -> checked[which] = isChecked) .setPositiveButton(R.string.dialog_positive, (d, w) -> applySiteManage(sites, enabledRules, checked)) 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..9dd8e2afa7 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 @@ -23,6 +23,8 @@ import com.fongmi.android.tv.databinding.DialogOneKeySyncBinding; import com.fongmi.android.tv.remote.RemoteStore; import com.fongmi.android.tv.server.Server; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeTokens; import com.fongmi.android.tv.ui.adapter.SyncDeviceAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.utils.NsdDeviceDiscovery; @@ -38,6 +40,7 @@ import com.fongmi.android.tv.utils.Task; import com.github.catvod.net.OkHttp; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.bottomsheet.BottomSheetDialog; +import com.google.android.material.card.MaterialCardView; import com.google.android.material.checkbox.MaterialCheckBox; import java.io.File; @@ -69,6 +72,18 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic private NsdDeviceDiscovery discovery; private Device selected; private Call syncCall; + /** + * 同步代际:每次 {@link #startSync()} 自增,回调执行前比对。 + * + *

取消/失败的回调都是异步投递的(OkHttp 线程 → {@code App.post})。若用户在上一条 + * post 尚未执行时就重新点了「开始同步」,旧回调会把**新一次同步**的界面复位:进度清零、 + * 按钮文案回「开始同步」、方向按钮被重新启用。代际不相等即丢弃陈旧回调。 + * + *

写入只在主线程({@code startSync()} 由点击回调触发),但读取发生在回调线程: + * {@code Task.execute} 的固定线程池、OkHttp 的 Dispatcher 线程。因此必须 volatile—— + * 同文件的 {@code syncing} 出于同样的原因已经是 volatile。 + */ + private volatile long syncGeneration; private long progressStart; private long prepareStart; private long prepareUpdate; @@ -111,7 +126,10 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic @Override protected void initEvent() { - binding.modeButton.setOnClickListener(v -> toggleMode()); + binding.directionGroup.addOnButtonCheckedListener((group, checkedId, isChecked) -> { + if (!isChecked) return; + setToRemote(checkedId == R.id.directionPush); + }); binding.refresh.setOnClickListener(v -> refresh()); binding.changeDevice.setOnClickListener(v -> changeDevice()); binding.selectAll.setOnClickListener(v -> toggleSelection()); @@ -166,11 +184,17 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic setScanning(false); } - private void toggleMode() { - toRemote = !toRemote; + private void setToRemote(boolean value) { + if (toRemote == value) return; + toRemote = value; updateDirection(); } + private void updateDirectionGroup() { + int checked = toRemote ? R.id.directionPush : R.id.directionPull; + if (binding.directionGroup.getCheckedButtonId() != checked) binding.directionGroup.check(checked); + } + private void toggleSelection() { select(!allSelected()); } @@ -258,7 +282,7 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic binding.selected.setText(toRemote ? R.string.sync_mode_to_remote : R.string.sync_mode_from_remote); binding.mode.setText(toRemote ? R.string.sync_push : R.string.sync_pull); binding.mode.setCompoundDrawablesWithIntrinsicBounds(0, toRemote ? R.drawable.ic_sync_arrow_right_blue : R.drawable.ic_sync_arrow_left_blue, 0, 0); - binding.modeButton.setText(toRemote ? R.string.sync_push : R.string.sync_pull); + updateDirectionGroup(); updateDirectionCard(binding.localCard, binding.localRole, toRemote); updateDirectionCard(binding.remoteCard, binding.remoteRole, !toRemote); binding.localRole.setText(toRemote ? R.string.sync_sender : R.string.sync_receiver); @@ -269,11 +293,20 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic binding.selectAll.setText(allSelected() ? R.string.sync_select_none : R.string.sync_select_all); } - private void updateDirectionCard(com.google.android.material.card.MaterialCardView card, TextView role, boolean sender) { - card.setCardBackgroundColor(Color.parseColor(sender ? "#E8F0FE" : "#F8FAFD")); - card.setStrokeColor(Color.TRANSPARENT); - card.setStrokeWidth(0); - role.setTextColor(Color.parseColor(sender ? "#174EA6" : "#5F6368")); + /** + * 高亮"发送方"卡片,另一张退回布局的静态样式。 + * + *

颜色必须走主题 token,不能用裸 hex:布局里两张卡片本来就是 + * {@code ?attr/colorSurfaceContainerLow} + {@code ?attr/colorOutlineVariant}, + * 用固定浅色 hex 覆盖会让深色/自定义主题下的方向卡仍是浅底深字。 + * 这里用 M3 的成对语义色(容器色 + 其 on 色),对比度由主题契约保证。 + */ + private void updateDirectionCard(MaterialCardView card, TextView role, boolean sender) { + ThemeTokens tokens = ThemeController.current(); + card.setCardBackgroundColor(sender ? tokens.colorPrimaryContainer() : tokens.colorSurfaceContainerLow()); + card.setStrokeColor(sender ? Color.TRANSPARENT : tokens.colorOutlineVariant()); + card.setStrokeWidth(sender ? 0 : ResUtil.dp2px(1)); + role.setTextColor(sender ? tokens.colorOnPrimaryContainer() : tokens.colorOnSurfaceVariant()); } @Override @@ -328,6 +361,12 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic return; } SyncOptions options = options(); + // 方向必须在入口快照:toRemote 由主线程的方向选择器改写,而下面的工作线程要读它 + // 决定请求体。若不快照,同步中切换方向会让 URL 的 mode 与请求体(backup/device) + // 指向相反方向。 + final boolean toRemote = this.toRemote; + // 代际同样在入口快照:陈旧回调靠它在执行前丢弃,不会复位新一次同步的界面。 + final long generation = ++syncGeneration; String mode = toRemote ? "1" : "2"; String url = String.format(Locale.getDefault(), "%s/action?do=sync&mode=%s&type=backup", selected.getIp(), mode); binding.start.setEnabled(false); @@ -337,21 +376,25 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic prepareUpdate = 0; Task.execute(() -> { try { - SyncFiles.Archive archive = toRemote && SyncFiles.hasPaths(options) ? SyncFiles.createArchive(SyncFiles.getPaths(options), () -> syncing, this::onPrepareProgress) : null; + SyncFiles.Archive archive = toRemote && SyncFiles.hasPaths(options) ? SyncFiles.createArchive(SyncFiles.getPaths(options), () -> syncing && generation == syncGeneration, (count, size) -> onPrepareProgress(generation, count, size)) : 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); - if (!syncing) { + RequestBody body = buildBody(options, toRemote, archive, mpvArchive, loginArchive, generation); + // 代际必须一起判:取消后立刻重开时 syncing 已是新一次同步的 true, + // 只判 syncing 会让这条陈旧的准备线程继续把上一轮的 body 真发出去, + // 并在 request() 里覆盖 syncCall(此后点「取消」取消的是错的 Call)。 + if (!syncing || generation != syncGeneration) { 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, mpvArchive, loginArchive, 0, generation); } catch (Exception e) { - if (!syncing) return; + if (!syncing || generation != syncGeneration) return; App.post(() -> { + if (generation != syncGeneration) return; setSyncing(false); Notify.show(getString(R.string.sync_failed_with_reason, e.getMessage())); }); @@ -359,7 +402,7 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic }); } - private RequestBody buildBody(SyncOptions options, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive) { + private RequestBody buildBody(SyncOptions options, boolean toRemote, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive, long generation) { if (archive == null && mpvArchive == null && loginArchive == null) { FormBody.Builder body = new FormBody.Builder(); body.add("options", options.toString()); @@ -369,13 +412,13 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic addRemoteRelay(body, options, toRemote); return body.build(); } - App.post(() -> updatePrepare(archive, loginArchive)); + App.post(() -> updatePrepare(generation, archive, loginArchive)); MultipartBody.Builder body = new MultipartBody.Builder().setType(MultipartBody.FORM); body.addFormDataPart("options", options.toString()); body.addFormDataPart("force", "false"); 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 (archive != null) body.addFormDataPart(SyncFiles.PART_NAME, archive.getFile().getName(), new ProgressRequestBody(archive.getFile(), ZIP, (written, total) -> App.post(() -> updateUpload(generation, 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,13 +428,13 @@ 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, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive, int retry, long generation) { syncCall = OkHttp.newCall(client, url, body); - syncCall.enqueue(callback(url, body, archive, mpvArchive, loginArchive, retry)); + syncCall.enqueue(callback(url, body, archive, mpvArchive, loginArchive, retry, generation)); } - private void retry(String url, RequestBody body, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive, int retry, String msg) { - if (!syncing) { + private void retry(String url, RequestBody body, SyncFiles.Archive archive, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive, int retry, long generation, String msg) { + if (generation != syncGeneration || !syncing) { if (archive != null) archive.delete(); if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); @@ -402,15 +445,26 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); App.post(() -> { + if (generation != syncGeneration) return; 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(() -> { + // 延迟重试也要复核代际:这段时间里用户可能已取消并重新开始同步, + // 此时重发上一轮的请求会与新同步并发,并劫持 syncCall。 + if (generation != syncGeneration || !syncing) { + if (archive != null) archive.delete(); + if (mpvArchive != null) mpvArchive.delete(); + if (loginArchive != null) loginArchive.delete(); + return; + } + request(url, body, archive, mpvArchive, loginArchive, retry + 1, generation); + }, 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, MpvConfigSync.Archive mpvArchive, LoginStateSync.Archive loginArchive, int retry, long generation) { return new Callback() { @Override public void onFailure(@NonNull Call call, @NonNull IOException e) { @@ -419,26 +473,31 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic if (mpvArchive != null) mpvArchive.delete(); if (loginArchive != null) loginArchive.delete(); App.post(() -> { + if (generation != syncGeneration) return; setSyncing(false); Notify.show(R.string.sync_canceled); }); return; } - retry(url, body, archive, mpvArchive, loginArchive, retry, e.getMessage()); + retry(url, body, archive, mpvArchive, loginArchive, retry, generation, e.getMessage()); } @Override public void onResponse(@NonNull Call call, @NonNull Response response) { try (Response res = response) { - if (res.isSuccessful()) App.post(() -> { + if (res.isSuccessful()) { + // 临时归档必须无条件清理:陈旧回调也要删,否则会留下本次同步的文件。 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()); + App.post(() -> { + if (generation != syncGeneration) return; + setSyncing(false); + Notify.show(R.string.sync_success); + dismiss(); + }); + } + else retry(url, body, archive, mpvArchive, loginArchive, retry, generation, res.message()); } } }; @@ -450,6 +509,11 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic binding.progressBox.setVisibility(syncing ? View.VISIBLE : View.GONE); binding.start.setText(syncing ? R.string.sync_cancel : R.string.sync_start); binding.start.setEnabled(true); + // 同步中锁住方向:请求的方向已在 startSync 入口快照,此时翻方向不会改变 + // 正在跑的这次同步,只会让界面显示的方向与实际请求相反。 + // 注意 ViewGroup.setEnabled 不会传播到子 View,必须逐个按钮设。 + binding.directionPush.setEnabled(!syncing); + binding.directionPull.setEnabled(!syncing); if (!syncing) { syncCall = null; binding.progress.setProgress(0); @@ -466,8 +530,8 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic } } - private void updatePrepare(SyncFiles.Archive archive, LoginStateSync.Archive loginArchive) { - if (!syncing || binding == null) return; + private void updatePrepare(long generation, SyncFiles.Archive archive, LoginStateSync.Archive loginArchive) { + if (!syncing || generation != syncGeneration || binding == null) return; progressStart = System.currentTimeMillis(); binding.progress.setProgress(100); int count = (archive == null ? 0 : archive.getCount()) + (loginArchive == null ? 0 : loginArchive.getCount()); @@ -476,23 +540,23 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic binding.progressText.setText(getString(R.string.sync_upload_prepare, count, FileUtil.byteCountToDisplaySize(rawSize), FileUtil.byteCountToDisplaySize(zipSize))); } - private void onPrepareProgress(int count, long size) { + private void onPrepareProgress(long generation, int count, long size) { long now = System.currentTimeMillis(); if (now - prepareUpdate < 350) return; prepareUpdate = now; long elapsed = Math.max(now - prepareStart, 1); long speed = size * 1000 / elapsed; - App.post(() -> updatePrepareProgress(count, size, speed)); + App.post(() -> updatePrepareProgress(generation, count, size, speed)); } - private void updatePrepareProgress(int count, long size, long speed) { - if (!syncing || binding == null) return; + private void updatePrepareProgress(long generation, int count, long size, long speed) { + if (!syncing || generation != syncGeneration || binding == null) return; binding.progress.setProgress(0); binding.progressText.setText(getString(R.string.sync_prepare_progress, count, FileUtil.byteCountToDisplaySize(size), FileUtil.byteCountToDisplaySize(speed))); } - private void updateUpload(SyncFiles.Archive archive, long written, long total) { - if (!syncing || binding == null) return; + private void updateUpload(long generation, SyncFiles.Archive archive, long written, long total) { + if (!syncing || generation != syncGeneration || binding == null) return; long elapsed = Math.max(System.currentTimeMillis() - progressStart, 1); long speed = written * 1000 / elapsed; int percent = total <= 0 ? 0 : (int) Math.min(100, written * 100 / total); @@ -523,6 +587,9 @@ public class OneKeySyncDialog extends BaseBottomSheetDialog implements SyncDevic @Override public void onDestroyView() { + // 关掉对话框同样作废代际:在途回调随后会走到 App.post 里,而那时 binding 已经销毁, + // 继续 setSyncing(...) 会碰已解绑的视图。代际不等即丢弃,归档仍照常清理。 + ++syncGeneration; if (syncCall != null) syncCall.cancel(); syncing = false; super.onDestroyView(); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PanNetworkDiagnosticDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PanNetworkDiagnosticDialog.java index dba341109a..48b8cdf02e 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PanNetworkDiagnosticDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PanNetworkDiagnosticDialog.java @@ -39,13 +39,14 @@ import com.fongmi.android.tv.player.diagnostic.PanDiagnosticVerdict; import com.fongmi.android.tv.player.diagnostic.PanEndpoint; import com.fongmi.android.tv.player.diagnostic.PanEndpointParser; import com.fongmi.android.tv.player.diagnostic.PanNetworkDiagnosticRunner; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; +import com.fongmi.android.tv.ui.helper.TvFocusScale; 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.chip.Chip; import com.google.android.material.chip.ChipGroup; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.progressindicator.LinearProgressIndicator; import com.google.android.material.tabs.TabLayout; @@ -122,7 +123,7 @@ public final class PanNetworkDiagnosticDialog extends DialogFragment implements contentScroll.setOverScrollMode(View.OVER_SCROLL_NEVER); content = vertical(0); contentScroll.addView(content, new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - int screenHeight = ResUtil.getScreenHeight(requireContext()); + int screenHeight = DialogSafeArea.height(requireContext()); int contentHeight = Util.isLeanback() ? Math.min(dp(460), Math.max(dp(400), screenHeight - dp(300))) : Math.min(dp(560), Math.max(dp(350), screenHeight * 5 / 8)); @@ -135,7 +136,7 @@ public final class PanNetworkDiagnosticDialog extends DialogFragment implements root.addView(resultFooter, footerParams); resolveEndpoint(); showConfig(); - Dialog dialog = new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(root).create(); + Dialog dialog = new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(root).create(); dialog.setCanceledOnTouchOutside(false); return dialog; } @@ -442,7 +443,7 @@ public final class PanNetworkDiagnosticDialog extends DialogFragment implements private void confirmHighThread(List threads, long bytes) { int maxThreads = threads.get(threads.size() - 1); - new MaterialAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_WebHTV_LightDialog) + new WebHtvAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_WebHTV_Dialog) .setTitle("确认高流量诊断") .setMessage("将测试到 " + maxThreads + " 线程,预计流量上限约 " + PanNetworkDiagnosticRunner.formatBytes(bytes) + ",已计入探测、预热、重复、直链对照、App DataSource、配对验证和各可重试阶段的一次瞬态断流重试。高线程可能增加耗电、内存占用,并触发源站或网盘限流、风控。") @@ -914,7 +915,7 @@ public final class PanNetworkDiagnosticDialog extends DialogFragment implements button.setStrokeColor(ColorStateList.valueOf(primary ? BLUE : Color.rgb(138, 180, 248))); button.setStrokeWidth(dp(1)); button.setOnFocusChangeListener((v, focused) -> { - v.animate().scaleX(focused ? 1.035f : 1f).scaleY(focused ? 1.035f : 1f).setDuration(100).start(); + TvFocusScale.applyCompact(v, focused); if (!primary) { button.setTextColor(ColorStateList.valueOf(focused ? Color.WHITE : BLUE_DARK)); button.setBackgroundTintList(ColorStateList.valueOf(focused ? BLUE : Color.WHITE)); 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..233e350810 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 @@ -26,6 +26,8 @@ import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeTokens; import com.fongmi.android.tv.App; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.setting.PlaybackPerformanceCatalog; @@ -72,7 +74,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { PlaybackPerformanceSetting.ensureInitialized(); - Dialog dialog = new Dialog(requireActivity(), R.style.Theme_WebHTV_LightDialog); + Dialog dialog = new Dialog(requireActivity(), R.style.Theme_WebHTV_Dialog); dialog.setContentView(createView(LayoutInflater.from(requireContext()))); return dialog; } @@ -104,7 +106,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { MaterialTextView title = new MaterialTextView(requireContext()); title.setText(getString(R.string.player_performance) + " · " + playerName()); - title.setTextColor(Color.parseColor("#202124")); + title.setTextColor(ThemeController.current().colorOnSurface()); title.setTextSize(18); title.setGravity(Gravity.CENTER_VERTICAL); titleBar.addView(title, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); @@ -134,7 +136,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { list = new LinearLayout(requireContext()); list.setOrientation(LinearLayout.VERTICAL); scroll.addView(list, new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Math.min(dp(460), Math.max(dp(300), ResUtil.getScreenHeight(requireContext()) * 2 / 3))); + LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Math.min(dp(460), Math.max(dp(300), DialogSafeArea.height(requireContext()) * 2 / 3))); scrollParams.topMargin = dp(16); root.addView(scroll, scrollParams); refreshRows(); @@ -155,7 +157,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { MaterialTextView title = new MaterialTextView(requireContext()); title.setText(getString(R.string.player_performance_help_title) + " · " + playerName()); - title.setTextColor(Color.parseColor("#202124")); + title.setTextColor(ThemeController.current().colorOnSurface()); title.setTextSize(18); title.setTypeface(Typeface.DEFAULT, Typeface.BOLD); title.setGravity(Gravity.CENTER_VERTICAL); @@ -177,7 +179,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { content.setOrientation(LinearLayout.VERTICAL); content.setPadding(dp(4), dp(12), dp(4), dp(8)); scroll.addView(content, new ScrollView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Math.min(dp(560), ResUtil.getScreenHeight(requireContext()) * 3 / 5)); + LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Math.min(dp(560), DialogSafeArea.height(requireContext()) * 3 / 5)); scrollParams.topMargin = dp(10); root.addView(scroll, scrollParams); @@ -202,7 +204,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { addHelpItem(content, option.title(), option.description()); } - Dialog dialog = new Dialog(requireContext(), R.style.Theme_WebHTV_LightDialog); + Dialog dialog = new Dialog(requireContext(), R.style.Theme_WebHTV_Dialog); dialog.setContentView(root); dialog.setCanceledOnTouchOutside(true); dialog.setOnShowListener(ignored -> resizeHelpDialog(dialog)); @@ -239,7 +241,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { private void addHelpIntro(LinearLayout content, String text) { MaterialTextView intro = new MaterialTextView(requireContext()); intro.setText(text); - intro.setTextColor(Color.parseColor("#3C4043")); + intro.setTextColor(ThemeController.current().colorOnSurfaceVariant()); intro.setTextSize(13); intro.setLineSpacing(dp(3), 1f); content.addView(intro, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); @@ -248,7 +250,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { private void addHelpSection(LinearLayout content, String text) { MaterialTextView section = new MaterialTextView(requireContext()); section.setText(text); - section.setTextColor(Color.parseColor("#174EA6")); + section.setTextColor(ThemeController.current().colorPrimary()); section.setTextSize(15); section.setTypeface(Typeface.DEFAULT, Typeface.BOLD); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -260,14 +262,14 @@ public final class PlaybackPerformanceDialog extends DialogFragment { private void addHelpItem(LinearLayout content, String title, String description) { MaterialTextView name = new MaterialTextView(requireContext()); name.setText(title); - name.setTextColor(Color.parseColor("#202124")); + name.setTextColor(ThemeController.current().colorOnSurface()); name.setTextSize(14); name.setTypeface(Typeface.DEFAULT, Typeface.BOLD); content.addView(name, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); MaterialTextView detail = new MaterialTextView(requireContext()); detail.setText(description); - detail.setTextColor(Color.parseColor("#5F6368")); + detail.setTextColor(ThemeController.current().colorOnSurfaceVariant()); detail.setTextSize(13); detail.setLineSpacing(dp(3), 1f); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -299,9 +301,9 @@ public final class PlaybackPerformanceDialog extends DialogFragment { button.setFocusable(true); button.setFocusableInTouchMode(Util.isLeanback()); button.setCornerRadius(dp(6)); - button.setTextColor(ColorStateList.valueOf(Color.parseColor("#174EA6"))); - button.setBackgroundTintList(ColorStateList.valueOf(Color.WHITE)); - button.setStrokeColor(ColorStateList.valueOf(Color.parseColor("#8AB4F8"))); + button.setTextColor(ColorStateList.valueOf(ThemeController.current().colorPrimary())); + button.setBackgroundTintList(ColorStateList.valueOf(ThemeController.current().colorSurfaceContainerLowest())); + button.setStrokeColor(ColorStateList.valueOf(ThemeController.current().colorOutline())); button.setStrokeWidth(dp(1)); button.setOnFocusChangeListener((view, hasFocus) -> styleAction(button, hasFocus)); button.setOnClickListener(listener); @@ -325,15 +327,16 @@ public final class PlaybackPerformanceDialog extends DialogFragment { button.setFocusable(true); button.setFocusableInTouchMode(Util.isLeanback()); button.setBackgroundTintList(ContextCompat.getColorStateList(requireContext(), R.color.dialog_outlined_button_bg)); - button.setTextColor(Color.parseColor("#5F6368")); + button.setTextColor(ThemeController.current().colorOnSurfaceVariant()); button.setOnClickListener(listener); return button; } private void styleAction(MaterialButton button, boolean focused) { - button.setTextColor(ColorStateList.valueOf(Color.parseColor(focused ? "#FFFFFF" : "#174EA6"))); - button.setBackgroundTintList(ColorStateList.valueOf(Color.parseColor(focused ? "#1A73E8" : "#FFFFFF"))); - button.setStrokeColor(ColorStateList.valueOf(Color.parseColor(focused ? "#1A73E8" : "#8AB4F8"))); + ThemeTokens tokens = ThemeController.current(); + button.setTextColor(ColorStateList.valueOf(focused ? tokens.colorOnPrimary() : tokens.colorPrimary())); + button.setBackgroundTintList(ColorStateList.valueOf(focused ? tokens.colorPrimary() : tokens.colorSurfaceContainerLowest())); + button.setStrokeColor(ColorStateList.valueOf(focused ? tokens.colorPrimary() : tokens.colorOutline())); button.setStrokeWidth(dp(1)); } @@ -363,8 +366,8 @@ public final class PlaybackPerformanceDialog extends DialogFragment { tabs.setBackgroundColor(Color.TRANSPARENT); tabs.setTabMode(TabLayout.MODE_FIXED); tabs.setTabGravity(TabLayout.GRAVITY_FILL); - tabs.setSelectedTabIndicatorColor(Color.parseColor("#1A73E8")); - tabs.setTabTextColors(Color.parseColor("#5F6368"), Color.parseColor("#1A73E8")); + tabs.setSelectedTabIndicatorColor(ThemeController.current().colorPrimary()); + tabs.setTabTextColors(ThemeController.current().colorOnSurfaceVariant(), ThemeController.current().colorPrimary()); tabs.setTabRippleColor(ColorStateList.valueOf(Color.TRANSPARENT)); tabs.setUnboundedRipple(false); tabs.setFocusable(false); @@ -804,7 +807,7 @@ public final class PlaybackPerformanceDialog extends DialogFragment { private void addHeader(String text) { MaterialTextView header = new MaterialTextView(requireContext()); header.setText(text); - header.setTextColor(Color.parseColor("#5F6368")); + header.setTextColor(ThemeController.current().colorOnSurfaceVariant()); header.setTextSize(13); header.setGravity(Gravity.CENTER_VERTICAL); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(28)); @@ -825,12 +828,12 @@ public final class PlaybackPerformanceDialog extends DialogFragment { button.setInsetBottom(0); button.setText(label + " " + value); button.setTextSize(14); - button.setTextColor(ColorStateList.valueOf(Color.parseColor("#202124"))); + button.setTextColor(ColorStateList.valueOf(ThemeController.current().colorOnSurface())); button.setBackgroundTintList(ColorStateList.valueOf(overridden - ? Color.parseColor("#E8F0FE") : Color.WHITE)); + ? ThemeController.current().colorPrimaryContainer() : ThemeController.current().colorSurfaceContainerLowest())); button.setCornerRadius(dp(6)); - button.setStrokeColor(ColorStateList.valueOf(Color.parseColor(overridden - ? "#8AB4F8" : "#C4C7C5"))); + button.setStrokeColor(ColorStateList.valueOf(overridden + ? ThemeController.current().colorPrimary() : ThemeController.current().colorOutlineVariant())); button.setStrokeWidth(dp(1)); button.setFocusable(true); button.setFocusableInTouchMode(Util.isLeanback()); @@ -845,12 +848,14 @@ public final class PlaybackPerformanceDialog extends DialogFragment { private void styleRow(MaterialButton button, boolean enabled, boolean overridden, boolean focused) { - int text = focused ? Color.WHITE : enabled ? Color.parseColor("#202124") : Color.parseColor("#5F6368"); - int bg = focused ? Color.parseColor("#1A73E8") - : overridden ? Color.parseColor("#E8F0FE") : Color.WHITE; - int stroke = focused ? Color.parseColor("#1A73E8") - : overridden ? Color.parseColor("#8AB4F8") - : Color.parseColor("#C4C7C5"); + ThemeTokens tokens = ThemeController.current(); + int text = focused ? tokens.colorOnPrimary() + : enabled ? tokens.colorOnSurface() : tokens.colorOnSurfaceVariant(); + int bg = focused ? tokens.colorPrimary() + : overridden ? tokens.colorPrimaryContainer() : tokens.colorSurfaceContainerLowest(); + int stroke = focused ? tokens.colorPrimary() + : overridden ? tokens.colorPrimary() + : tokens.colorOutlineVariant(); button.setTextColor(ColorStateList.valueOf(text)); button.setBackgroundTintList(ColorStateList.valueOf(bg)); button.setStrokeColor(ColorStateList.valueOf(stroke)); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceModal.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceModal.java index d7f2a960d0..48f12b9876 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceModal.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackPerformanceModal.java @@ -80,7 +80,7 @@ final class PlaybackPerformanceModal { private static Shell createShell( Context context, CharSequence title) { - Dialog dialog = new Dialog(context, R.style.Theme_WebHTV_LightDialog); + Dialog dialog = new Dialog(context, R.style.Theme_WebHTV_Dialog); LinearLayout root = new LinearLayout(context); root.setOrientation(LinearLayout.VERTICAL); root.setBackgroundResource(R.drawable.shape_shell_proxy_dialog); @@ -109,7 +109,7 @@ final class PlaybackPerformanceModal { ScrollView scroll = new MaxHeightScrollView( context, Math.min(dp(context, 460), - ResUtil.getScreenHeight(context) * 3 / 5)); + DialogSafeArea.height(context) * 3 / 5)); scroll.setFillViewport(false); scroll.setOverScrollMode(View.OVER_SCROLL_NEVER); LinearLayout body = new LinearLayout(context); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackRemoteSyncDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackRemoteSyncDialog.java index f7d7351e37..204bc958be 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackRemoteSyncDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackRemoteSyncDialog.java @@ -30,6 +30,8 @@ import com.fongmi.android.tv.playback.PlaybackRemoteSyncResult; import com.fongmi.android.tv.playback.PlaybackRemoteSyncStore; import com.fongmi.android.tv.playback.PlaybackRemoteSyncer; import com.fongmi.android.tv.playback.RemoteSyncConfig; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.utils.Formatters; import com.fongmi.android.tv.utils.Notify; @@ -66,7 +68,7 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -76,8 +78,8 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); + int screenWidth = DialogSafeArea.width(requireActivity(), window); + int screenHeight = DialogSafeArea.height(requireActivity(), window); boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); @@ -162,7 +164,7 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { LinearLayoutCompat root = new LinearLayoutCompat(requireContext()); root.setOrientation(LinearLayoutCompat.VERTICAL); root.setPadding(dp(10), dp(9), dp(10), dp(9)); - root.setBackground(rowBackground(config)); + root.setBackground(rowBackground()); root.setFocusable(true); root.setClickable(true); root.setOnClickListener(view -> showEdit(copy(config))); @@ -174,12 +176,12 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { header.setGravity(Gravity.CENTER_VERTICAL); header.setOrientation(LinearLayoutCompat.HORIZONTAL); root.addView(header, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - header.addView(text(config.displayName(), 15, Color.BLACK, true), new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + header.addView(text(config.displayName(), 15, ThemeController.current().colorOnSurface(), true), new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); header.addView(badge(status(config), statusColor(config))); addDetail(root, config.url); addDetail(root, meta(config)); - if (!TextUtils.isEmpty(config.lastError)) addDetail(root, getString(R.string.playback_remote_sync_last_error, config.lastError), Color.parseColor("#B3261E")); + if (!TextUtils.isEmpty(config.lastError)) addDetail(root, getString(R.string.playback_remote_sync_last_error, config.lastError), ThemeController.current().colorError()); LinearLayoutCompat actions = new LinearLayoutCompat(requireContext()); actions.setGravity(Gravity.CENTER_VERTICAL); @@ -334,8 +336,8 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { } private int statusColor(RemoteSyncConfig config) { - if (config.enabled && config.isUsable()) return Color.parseColor("#137333"); - return Color.parseColor("#5F6368"); + if (config.enabled && config.isUsable()) return ThemeController.current().colorSuccess(); + return ThemeController.current().colorOnSurfaceVariant(); } private RemoteSyncConfig copy(RemoteSyncConfig source) { @@ -423,7 +425,7 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { button.setMinimumHeight(dp(36)); button.setPadding(dp(6), 0, dp(6), 0); ColorStateList bg = ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_bg : R.color.dialog_outlined_button_bg); - ColorStateList fg = danger ? ColorStateList.valueOf(Color.parseColor("#B3261E")) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); + ColorStateList fg = danger ? ColorStateList.valueOf(ThemeController.current().colorError()) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); button.setBackgroundTintList(bg); button.setTextColor(fg); if (!tonal) { @@ -434,7 +436,7 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { } private void addDetail(LinearLayoutCompat root, String value) { - addDetail(root, value, Color.parseColor("#5F6368")); + addDetail(root, value, ThemeController.current().colorOnSurfaceVariant()); } private void addDetail(LinearLayoutCompat root, String value, int color) { @@ -455,23 +457,33 @@ public class PlaybackRemoteSyncDialog extends BaseAlertDialog { return view; } + /** + * The badge fill follows the palette so the outlined status chip stays legible in both + * tables. It was a fixed {@code #FFFFFF}, which under the night table drew a white chip + * with the night success/error text on it. + */ private MaterialTextView badge(String value, int color) { MaterialTextView view = text(value, 12, color, true); view.setGravity(Gravity.CENTER); view.setSingleLine(true); view.setPadding(dp(8), dp(3), dp(8), dp(3)); GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(Color.parseColor("#FFFFFF")); + drawable.setColor(ThemeController.current().colorSurfaceContainerLowest()); drawable.setStroke(dp(1), color); drawable.setCornerRadius(dp(6)); view.setBackground(drawable); return view; } - private GradientDrawable rowBackground(RemoteSyncConfig config) { + /** + * The row card fill and stroke follow the active palette; see + * {@code ViewingRecordSyncDialog.rowBackground} for why the fixed light literals could + * not be rescued by {@code ThemeBinder}. + */ + private GradientDrawable rowBackground() { GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(Color.parseColor("#F5F6F7")); - drawable.setStroke(dp(1), Color.parseColor(config.isUsable() ? "#DADCE0" : "#E2E5E8")); + drawable.setColor(ThemeController.current().colorSurfaceContainerLow()); + drawable.setStroke(dp(1), ThemeController.current().colorOutlineVariant()); drawable.setCornerRadius(dp(6)); return drawable; } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackWebhookDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackWebhookDialog.java index 086718b081..12a34a292e 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackWebhookDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlaybackWebhookDialog.java @@ -28,6 +28,8 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogPlaybackWebhookBinding; import com.fongmi.android.tv.playback.PlaybackWebhookStore; import com.fongmi.android.tv.playback.WebhookConfig; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; @@ -91,7 +93,7 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -101,8 +103,8 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); + int screenWidth = DialogSafeArea.width(requireActivity(), window); + int screenHeight = DialogSafeArea.height(requireActivity(), window); boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); @@ -214,14 +216,14 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { header.setOrientation(LinearLayoutCompat.HORIZONTAL); root.addView(header, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - MaterialTextView title = text(config.displayName(), 15, Color.BLACK, true); + MaterialTextView title = text(config.displayName(), 15, ThemeController.current().colorOnSurface(), true); header.addView(title, new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); MaterialTextView status = badge(status(config), statusColor(config)); header.addView(status); addDetail(root, config.url); addDetail(root, meta(config)); - if (config.suspended && !TextUtils.isEmpty(config.lastError)) addDetail(root, getString(R.string.playback_webhook_last_error, config.lastError), Color.parseColor("#B3261E")); + if (config.suspended && !TextUtils.isEmpty(config.lastError)) addDetail(root, getString(R.string.playback_webhook_last_error, config.lastError), ThemeController.current().colorError()); LinearLayoutCompat actions = new LinearLayoutCompat(requireContext()); actions.setGravity(Gravity.CENTER_VERTICAL); @@ -431,9 +433,9 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { } private int statusColor(WebhookConfig config) { - if (config.suspended) return Color.parseColor("#B3261E"); - if (config.enabled && config.isUsable()) return Color.parseColor("#137333"); - return Color.parseColor("#5F6368"); + if (config.suspended) return ThemeController.current().colorError(); + if (config.enabled && config.isUsable()) return ThemeController.current().colorSuccess(); + return ThemeController.current().colorOnSurfaceVariant(); } private WebhookConfig copy(WebhookConfig source) { @@ -528,7 +530,7 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { button.setMinimumHeight(dp(36)); button.setPadding(dp(6), 0, dp(6), 0); ColorStateList bg = ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_bg : R.color.dialog_outlined_button_bg); - ColorStateList fg = danger ? ColorStateList.valueOf(Color.parseColor("#B3261E")) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); + ColorStateList fg = danger ? ColorStateList.valueOf(ThemeController.current().colorError()) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); button.setBackgroundTintList(bg); button.setTextColor(fg); if (!tonal) { @@ -539,7 +541,7 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { } private void addDetail(LinearLayoutCompat root, String value) { - addDetail(root, value, Color.parseColor("#5F6368")); + addDetail(root, value, ThemeController.current().colorOnSurfaceVariant()); } private void addDetail(LinearLayoutCompat root, String value, int color) { @@ -560,23 +562,40 @@ public class PlaybackWebhookDialog extends BaseAlertDialog { return view; } + /** + * The badge fill follows the palette so the outlined status chip stays legible in both + * tables; see {@code PlaybackRemoteSyncDialog.badge}. + */ private MaterialTextView badge(String value, int color) { MaterialTextView view = text(value, 12, color, true); view.setGravity(Gravity.CENTER); view.setSingleLine(true); view.setPadding(dp(8), dp(3), dp(8), dp(3)); GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(Color.parseColor("#FFFFFF")); + drawable.setColor(ThemeController.current().colorSurfaceContainerLowest()); drawable.setStroke(dp(1), color); drawable.setCornerRadius(dp(6)); view.setBackground(drawable); return view; } + /** + * A suspended row is the one row that must read as "attention" rather than "normal", so + * it keeps a distinct fill - now the semantic error container ({@code #F9DEDC} day / + * {@code #93000A} night) instead of a fixed light pink that only worked in the day table. + * Its stroke is the paired {@code colorError}, as in {@code CustomCspDialog.rowBackground}, + * so the cue survives for a viewer who cannot separate the two fills by hue: the fills + * differ by only 1.15:1 in the day table. Every other row takes the shared semantic + * hairline; see {@code ViewingRecordSyncDialog.rowBackground}. + */ private GradientDrawable rowBackground(WebhookConfig config) { GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(Color.parseColor(config.suspended ? "#FFF7F7" : "#F5F6F7")); - drawable.setStroke(dp(1), Color.parseColor(config.isUsable() ? "#DADCE0" : "#E2E5E8")); + drawable.setColor(config.suspended + ? ThemeController.current().colorErrorContainer() + : ThemeController.current().colorSurfaceContainerLow()); + drawable.setStroke(dp(1), config.suspended + ? ThemeController.current().colorError() + : ThemeController.current().colorOutlineVariant()); drawable.setCornerRadius(dp(6)); return drawable; } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlayerButtonConfigDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlayerButtonConfigDialog.java index 0d30420dff..035c71e4f8 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlayerButtonConfigDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/PlayerButtonConfigDialog.java @@ -21,6 +21,9 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterPlayerButtonConfigBinding; import com.fongmi.android.tv.databinding.DialogPlayerButtonConfigBinding; import com.fongmi.android.tv.setting.PlayerButtonSetting; +import com.fongmi.android.tv.theme.PlayerSettingsRowTheme; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Util; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -53,7 +56,7 @@ public class PlayerButtonConfigDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -63,8 +66,8 @@ public class PlayerButtonConfigDialog extends BaseAlertDialog { if (dialog == null) return; Window window = dialog.getWindow(); if (window == null) return; - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); + int screenWidth = DialogSafeArea.width(requireActivity(), window); + int screenHeight = DialogSafeArea.height(requireActivity(), window); boolean land = ResUtil.isLand(requireContext()); WindowManager.LayoutParams params = window.getAttributes(); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); @@ -167,8 +170,8 @@ public class PlayerButtonConfigDialog extends BaseAlertDialog { holder.binding.up.setFocusable(position > 0); holder.binding.down.setEnabled(position < items.size() - 1); holder.binding.down.setFocusable(position < items.size() - 1); - holder.binding.root.setAlpha(1f); - holder.binding.name.setAlpha(item.visible() ? 1f : 0.55f); + PlayerSettingsRowTheme.apply(holder.binding.root, holder.binding.name, ThemeController.current(), item.visible()); + PlayerSettingsRowTheme.applySwitch(holder.binding.visible, ThemeController.current()); holder.binding.root.setOnClickListener(view -> toggle(holder.getBindingAdapterPosition(), R.id.visible)); holder.binding.visible.setOnClickListener(view -> toggle(holder.getBindingAdapterPosition(), view.getId())); holder.binding.up.setOnClickListener(view -> move(-1, holder.getBindingAdapterPosition(), view.getId())); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/RemoteTrustDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/RemoteTrustDialog.java index 324a3584ac..3a98e5dbd7 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/RemoteTrustDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/RemoteTrustDialog.java @@ -26,6 +26,7 @@ import android.view.inputmethod.InputMethodManager; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.LinearLayoutCompat; import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; import androidx.core.widget.NestedScrollView; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; @@ -60,6 +61,7 @@ import com.fongmi.android.tv.remote.RemoteTokens; import com.fongmi.android.tv.bean.SyncOptions; import com.fongmi.android.tv.server.Server; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PermissionUtil; import com.fongmi.android.tv.utils.QRCode; @@ -68,7 +70,6 @@ import com.fongmi.android.tv.utils.Task; import com.github.catvod.net.OkHttp; import com.google.android.material.button.MaterialButton; import com.google.android.material.button.MaterialButtonToggleGroup; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; import com.google.android.material.textview.MaterialTextView; @@ -123,7 +124,7 @@ public final class RemoteTrustDialog { public static void show(FragmentActivity activity, Runnable callback) { Binding binding = build(activity); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) .setView(binding.root) .create(); binding.dialog = dialog; @@ -477,7 +478,7 @@ public final class RemoteTrustDialog { MaterialTextView hint = text(activity, activity.getString(R.string.remote_trust_send_server_hint), 12, "#5F6368", false); hint.setPadding(0, dp(activity, 8), 0, 0); root.addView(hint, matchWrap()); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) .setTitle(R.string.remote_trust_send_server_title) .setView(root) .setNegativeButton(R.string.dialog_cancel, null) @@ -868,7 +869,7 @@ public final class RemoteTrustDialog { code.setTextAlignment(View.TEXT_ALIGNMENT_CENTER); root.addView(code, matchWrap()); root.addView(caption(activity, R.string.remote_trust_bind_code_hint), topMargin(matchWrap(), 8)); - showModal(new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) + showModal(new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) .setTitle(R.string.remote_trust_bind_local_title) .setView(root) .setNegativeButton(R.string.dialog_cancel, null) @@ -933,7 +934,7 @@ public final class RemoteTrustDialog { return; } DialogRemoteTrustAddDeviceBinding dialogBinding = DialogRemoteTrustAddDeviceBinding.inflate(LayoutInflater.from(activity)); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) .setView(dialogBinding.getRoot()) .create(); dialogBinding.negative.setOnClickListener(v -> dialog.dismiss()); @@ -1149,7 +1150,7 @@ public final class RemoteTrustDialog { dialogBinding.inputLayout.setHint(activity.getString(hint)); dialogBinding.input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); dialogBinding.positive.setText("action.push".equals(type) ? R.string.remote_trust_send_push : R.string.remote_trust_send_search); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) .setView(dialogBinding.getRoot()) .create(); dialogBinding.negative.setOnClickListener(v -> dialog.dismiss()); @@ -1248,7 +1249,7 @@ public final class RemoteTrustDialog { state.contentScroll.addView(state.content, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); root.addView(state.contentScroll, topMargin(new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, configContentHeight(activity)), 10)); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) .setView(root) .create(); state.dialog = dialog; @@ -1310,7 +1311,7 @@ public final class RemoteTrustDialog { private static int configContentHeight(Context context) { boolean land = ResUtil.isLand(context); - int height = (int) (ResUtil.getScreenHeight(context) * (land ? 0.44f : 0.34f)); + int height = (int) (DialogSafeArea.height(context) * (land ? 0.44f : 0.34f)); return Math.max(dp(context, land ? 220 : 190), Math.min(dp(context, land ? 360 : 300), height)); } @@ -1754,6 +1755,8 @@ public final class RemoteTrustDialog { private static JsonObject configPayload(JsonObject object) { JsonObject payload = configPayload(payloadType(object), safe(object, "url"), safe(object, "name")); + if (object != null && object.has("interfaceKey")) payload.addProperty("interfaceKey", safe(object, "interfaceKey")); + if (object != null && object.has("urls")) payload.add("urls", object.get("urls")); if (object != null && object.has("homeKey")) payload.addProperty("homeKey", safe(object, "homeKey")); if (object != null && object.has("homeName")) payload.addProperty("homeName", safe(object, "homeName")); return payload; @@ -2167,7 +2170,7 @@ public final class RemoteTrustDialog { DialogRemoteTrustConfirmBinding dialogBinding = DialogRemoteTrustConfirmBinding.inflate(LayoutInflater.from(activity)); dialogBinding.title.setText(R.string.remote_trust_action_sync); dialogBinding.message.setText(activity.getString(R.string.remote_trust_sync_confirm, deviceName(selected.device))); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) .setView(dialogBinding.getRoot()) .create(); dialogBinding.negative.setOnClickListener(v -> dialog.dismiss()); @@ -2969,7 +2972,7 @@ public final class RemoteTrustDialog { DialogRemoteTrustQrBinding dialogBinding = DialogRemoteTrustQrBinding.inflate(LayoutInflater.from(activity)); dialogBinding.image.setImageBitmap(QRCode.getLightBitmap(server, 220, 1)); dialogBinding.value.setText(server); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.ThemeOverlay_WebHTV_Dialog) .setView(dialogBinding.getRoot()) .create(); dialogBinding.negative.setOnClickListener(v -> dialog.dismiss()); @@ -3085,7 +3088,7 @@ public final class RemoteTrustDialog { input.setSingleLine(singleLine); input.setTextSize(14); input.setMinHeight(dp(context, 46)); - input.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO); + ViewCompat.setImportantForAutofill(input, View.IMPORTANT_FOR_AUTOFILL_NO); input.setSelectAllOnFocus(false); input.setTextColor(Color.BLACK); input.setHintTextColor(Color.parseColor("#666666")); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShellProxyDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShellProxyDialog.java index 3941f7dd61..015e758c78 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShellProxyDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShellProxyDialog.java @@ -34,6 +34,7 @@ import com.fongmi.android.tv.databinding.DialogShellProxyBinding; import com.fongmi.android.tv.databinding.DialogShellProxyRuleEditBinding; import com.fongmi.android.tv.setting.ProxySetting; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.ui.custom.SettingClipboardOverlay; import com.fongmi.android.tv.utils.Notify; @@ -103,7 +104,7 @@ public class ShellProxyDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -113,8 +114,8 @@ public class ShellProxyDialog extends BaseAlertDialog { Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); + int screenWidth = DialogSafeArea.width(requireActivity(), window); + int screenHeight = DialogSafeArea.height(requireActivity(), window); boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShortDramaSourceDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShortDramaSourceDialog.java index 9017fbea14..21813c9768 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShortDramaSourceDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ShortDramaSourceDialog.java @@ -16,9 +16,9 @@ import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.ShortDramaConfig; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; import java.util.List; @@ -62,7 +62,9 @@ public class ShortDramaSourceDialog { // 初始化暂存数据 ShortDramaConfig config = ShortDramaConfig.objectFrom(Setting.getShortDramaConfig()); - tempEnabledRules = new ArrayList<>(config.getEnabledSites()); + tempEnabledRules = new ArrayList<>(config.isConfigured() + ? config.getEnabledSites() + : ShortDramaConfig.defaultRules()); tempDisabledSites = new ArrayList<>(config.getDisabledSites()); updateChipsDisplay(); @@ -77,7 +79,7 @@ public class ShortDramaSourceDialog { manageBtn.setOnClickListener(v -> showSiteManage()); resetBtn.setOnClickListener(v -> resetToDefault()); - dialog = new MaterialAlertDialogBuilder(activity) + dialog = new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.setting_short_drama_source) .setView(view) .setPositiveButton(R.string.dialog_positive, (d, w) -> onSave()) @@ -96,9 +98,7 @@ public class ShortDramaSourceDialog { List sites = VodConfig.get().getSites().stream().filter(s -> s != null && !s.isEmpty()).toList(); if (sites.isEmpty()) return; - List enabledRules = tempEnabledRules.isEmpty() - ? List.of(ShortDramaConfig.defaultRulesText().split(";")) - : new ArrayList<>(tempEnabledRules); + List enabledRules = new ArrayList<>(tempEnabledRules); List disabledSites = new ArrayList<>(tempDisabledSites); String[] labels = new String[sites.size()]; @@ -112,7 +112,7 @@ public class ShortDramaSourceDialog { checked[i] = matchedByRule && !inBlacklist; } - new MaterialAlertDialogBuilder(activity) + new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.dialog_short_drama_site_manage) .setMultiChoiceItems(labels, checked, (d, which, isChecked) -> checked[which] = isChecked) .setPositiveButton(R.string.dialog_positive, (d, w) -> applySiteManage(sites, enabledRules, disabledSites, checked)) @@ -250,9 +250,7 @@ public class ShortDramaSourceDialog { enabledChips.removeAllViews(); disabledChips.removeAllViews(); - List enabledRules = tempEnabledRules.isEmpty() - ? List.of(ShortDramaConfig.defaultRulesText().split(";")) - : tempEnabledRules; + List enabledRules = tempEnabledRules; for (String rule : enabledRules) { if (TextUtils.isEmpty(rule)) continue; @@ -324,6 +322,7 @@ public class ShortDramaSourceDialog { private void resetToDefault() { tempEnabledRules.clear(); + tempEnabledRules.addAll(ShortDramaConfig.defaultRules()); tempDisabledSites.clear(); updateChipsDisplay(); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthDialog.java index f5143eb991..180309007f 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteHealthDialog.java @@ -12,6 +12,7 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogSiteHealthBinding; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.button.MaterialButton; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -42,7 +43,7 @@ public class SiteHealthDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -54,7 +55,7 @@ public class SiteHealthDialog extends BaseAlertDialog { WindowManager.LayoutParams params = window.getAttributes(); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); - params.width = (int) (ResUtil.getScreenWidth(requireContext()) * (ResUtil.isLand(requireContext()) ? 0.44f : 0.92f)); + params.width = (int) (DialogSafeArea.width(requireActivity(), window) * (ResUtil.isLand(requireContext()) ? 0.44f : 0.92f)); params.height = WindowManager.LayoutParams.WRAP_CONTENT; window.setAttributes(params); window.setLayout(params.width, params.height); 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..aa8a1cc1a2 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 @@ -1,5 +1,6 @@ package com.fongmi.android.tv.ui.dialog; +import android.app.Dialog; import android.graphics.Color; import android.graphics.Typeface; import android.graphics.drawable.ColorDrawable; @@ -7,6 +8,7 @@ import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; import android.text.format.DateFormat; +import android.view.Gravity; import android.view.View; import android.view.Window; import android.view.WindowManager; @@ -22,6 +24,9 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.AdBlockStatsStore; import com.fongmi.android.tv.databinding.DialogSiteHealthReportBinding; import com.fongmi.android.tv.setting.SiteHealthStore; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeEditorUi; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.button.MaterialButton; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -54,24 +59,47 @@ public class SiteHealthReportDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override public void onStart() { super.onStart(); - if (getDialog() == null) return; - Window window = getDialog().getWindow(); - if (window == null) return; + Dialog dialog = getDialog(); + if (dialog == null || dialog.getWindow() == null) return; + Window window = dialog.getWindow(); + configureWindow(window); + // The window can be laid out before the platform reports its system-bar insets, in which + // case the first sizing falls back to the display bounds and the footer's cancel button + // ends up under the navigation bar. Re-sizing when the real insets arrive is what makes + // the fix independent of that ordering. + DialogSafeArea.follow(requireActivity(), window, () -> configureWindow(window)); + binding.close.requestFocus(); + } + + /** + * Sizes the dialog window from the system-bar-safe area instead of the raw display. + * + *

{@link ResUtil#getScreenHeight} returns the whole display, so a window sized from it is + * taller than the area the window manager actually offers a floating dialog: the window is + * centred in the bar-safe area and its last row - the fixed-height cancel button - is painted + * under the navigation bar and clipped. {@link DialogSafeArea} resolves that area for every + * dialog in this package; the gutter stays the existing 16dp portrait / 24dp landscape value + * so television geometry is unchanged. + */ + private void configureWindow(Window window) { 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); + ThemeDialogLayout.Area area = DialogSafeArea.area(requireActivity(), window); + params.width = ThemeDialogLayout.width(area.width(), margin); + params.height = ThemeDialogLayout.height(area.height(), margin); + // Gravity.CENTER centres the window inside the bar-safe area; an explicit params.x/y + // offset is re-clamped by the window manager and is deliberately not set. + params.gravity = Gravity.CENTER; window.setAttributes(params); window.setLayout(params.width, params.height); - binding.close.requestFocus(); } @Override @@ -253,7 +281,7 @@ public class SiteHealthReportDialog extends BaseAlertDialog { header.setOrientation(LinearLayoutCompat.HORIZONTAL); root.addView(header, new LinearLayoutCompat.LayoutParams(LinearLayoutCompat.LayoutParams.MATCH_PARENT, LinearLayoutCompat.LayoutParams.WRAP_CONTENT)); - MaterialTextView title = text(row.siteName, 16, R.color.black, Typeface.BOLD); + MaterialTextView title = text(row.siteName, 16, ThemeController.current().colorOnSurface(), Typeface.BOLD); header.addView(title, new LinearLayoutCompat.LayoutParams(0, LinearLayoutCompat.LayoutParams.WRAP_CONTENT, 1)); MaterialTextView status = text(statusLabel(row.status), 13, statusColor(row.status), Typeface.BOLD); @@ -262,7 +290,7 @@ public class SiteHealthReportDialog extends BaseAlertDialog { statusParams.setMargins(0, 0, dp(8), 0); header.addView(status, statusParams); - MaterialTextView toggle = text("+", 16, R.color.black_50, Typeface.BOLD); + MaterialTextView toggle = text("+", 16, ThemeController.current().colorOnSurfaceVariant(), Typeface.BOLD); toggle.setGravity(android.view.Gravity.CENTER); header.addView(toggle, new LinearLayoutCompat.LayoutParams(dp(20), LinearLayoutCompat.LayoutParams.WRAP_CONTENT)); @@ -301,7 +329,7 @@ public class SiteHealthReportDialog extends BaseAlertDialog { blockParams.setMargins(0, dp(8), 0, 0); root.addView(block, blockParams); - MaterialTextView label = text(stageText(labelRes, stage), 13, R.color.black_80, Typeface.NORMAL); + MaterialTextView label = text(stageText(labelRes, stage), 13, ThemeController.current().colorOnSurface(), Typeface.NORMAL); block.addView(label, new LinearLayoutCompat.LayoutParams(LinearLayoutCompat.LayoutParams.MATCH_PARENT, LinearLayoutCompat.LayoutParams.WRAP_CONTENT)); ProgressBar progress = new ProgressBar(requireContext(), null, android.R.attr.progressBarStyleHorizontal); @@ -316,8 +344,10 @@ public class SiteHealthReportDialog extends BaseAlertDialog { long blocked = AdBlockStatsStore.getSiteBlockedCount(row.siteKey); int playCount = row.playAttempts; // 始终显示播放与广告拦截比值(即使为 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); + int color = blocked > 0 + ? readableOnPanel(ResUtil.getColor(R.color.site_health_warn)) + : ThemeController.current().colorOnSurface(); + MaterialTextView view = text(getString(R.string.site_health_report_ad_blocked, playCount, blocked), 13, color, 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); @@ -347,7 +377,7 @@ public class SiteHealthReportDialog extends BaseAlertDialog { addRecentError(block, R.string.site_health_stage_parse, row.parse); addRecentError(block, R.string.site_health_stage_play, row.play); if (block.getChildCount() > 0) { - MaterialTextView title = text(getString(R.string.site_health_report_recent_errors), 12, R.color.black_70, Typeface.BOLD); + MaterialTextView title = text(getString(R.string.site_health_report_recent_errors), 12, ThemeController.current().colorOnSurfaceVariant(), Typeface.BOLD); block.addView(title, 0); } block.addView(clearButton(row)); @@ -388,7 +418,7 @@ public class SiteHealthReportDialog extends BaseAlertDialog { } private void showClearConfirmation(int titleRes, CharSequence message, Runnable action) { - AlertDialog dialog = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) .setTitle(titleRes) .setMessage(message) .setNegativeButton(R.string.dialog_negative, null) @@ -413,18 +443,24 @@ public class SiteHealthReportDialog extends BaseAlertDialog { } private MaterialTextView metaText(String value) { - MaterialTextView view = text(value, 12, R.color.black_60, Typeface.NORMAL); + MaterialTextView view = text(value, 12, ThemeController.current().colorOnSurfaceVariant(), Typeface.NORMAL); LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(LinearLayoutCompat.LayoutParams.MATCH_PARENT, LinearLayoutCompat.LayoutParams.WRAP_CONTENT); params.setMargins(0, dp(6), 0, 0); view.setLayoutParams(params); return view; } - private MaterialTextView text(String value, int sp, int colorRes, int style) { + /** + * The report body is generated in code, so its text cannot inherit a theme attribute the way + * the dialog XML does. It must use the resolved semantic roles instead: the panel is a dark + * surface in dark mode, and a hard-coded translucent black made every generated row + * unreadable there (the XML title, which uses {@code ?attr/colorOnSurface}, stayed legible). + */ + private MaterialTextView text(String value, int sp, int color, int style) { MaterialTextView view = new MaterialTextView(requireContext()); view.setText(value); view.setTextSize(sp); - view.setTextColor(ResUtil.getColor(colorRes)); + view.setTextColor(color); view.setTypeface(Typeface.DEFAULT, style); return view; } @@ -463,12 +499,22 @@ public class SiteHealthReportDialog extends BaseAlertDialog { } private int statusColor(SiteHealthStore.Status status) { - return switch (status) { - case GOOD -> R.color.site_health_good; - case BAD -> R.color.site_health_bad; - case WARN -> R.color.site_health_warn; - default -> R.color.black_50; + int color = switch (status) { + case GOOD -> ResUtil.getColor(R.color.site_health_good); + case BAD -> ResUtil.getColor(R.color.site_health_bad); + case WARN -> ResUtil.getColor(R.color.site_health_warn); + default -> ThemeController.current().colorOnSurfaceVariant(); }; + return readableOnPanel(color); + } + + /** + * The status/ad colours are tuned for a light panel. On a dark panel a mid-tone amber or green + * can fall below 4.5:1, so fall back to black/white by contrast - the same rule the theme + * editor uses for values it draws over a themed surface. + */ + private int readableOnPanel(int color) { + return ThemeEditorUi.readable(color, ThemeController.current().colorSurfaceContainerHigh()); } private int dp(int value) { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteNameDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteNameDialog.java index 8535264c5d..c944c4617d 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteNameDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SiteNameDialog.java @@ -13,8 +13,8 @@ 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.setting.SiteNameStore; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; import com.google.android.material.textview.MaterialTextView; @@ -47,7 +47,7 @@ public final class SiteNameDialog { } CharSequence[] labels = new CharSequence[sites.size()]; for (int i = 0; i < sites.size(); i++) labels[i] = label(sites.get(i)); - new MaterialAlertDialogBuilder(activity) + new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.setting_site_name) .setItems(labels, (dialog, which) -> showEditor(sites.get(which))) .setNeutralButton(R.string.setting_reset, (dialog, which) -> confirmReset()) @@ -86,7 +86,7 @@ public final class SiteNameDialog { inputParams.topMargin = Math.round(16 * activity.getResources().getDisplayMetrics().density); panel.addView(inputLayout, inputParams); - new MaterialAlertDialogBuilder(activity) + new WebHtvAlertDialogBuilder(activity) .setTitle(site.getDisplayName()) .setView(panel) .setPositiveButton(R.string.dialog_positive, (dialog, which) -> { @@ -104,7 +104,7 @@ public final class SiteNameDialog { } private void confirmReset() { - new MaterialAlertDialogBuilder(activity) + new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.site_name_reset_title) .setMessage(R.string.site_name_reset_message) .setPositiveButton(R.string.setting_reset, (dialog, which) -> { diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleManualSearchDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleManualSearchDialog.java index b2c04045d7..3c36afff32 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleManualSearchDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SubtitleManualSearchDialog.java @@ -23,11 +23,11 @@ import com.fongmi.android.tv.subtitle.model.SubtitleMatchType; import com.fongmi.android.tv.subtitle.model.SubtitleRequest; import com.fongmi.android.tv.subtitle.translate.SubtitleTranslationRequest; import com.fongmi.android.tv.subtitle.translate.SubtitleTranslationResult; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; 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.google.android.material.textfield.TextInputEditText; import java.util.ArrayList; @@ -60,7 +60,7 @@ public final class SubtitleManualSearchDialog { input.setTextColor(0xFF202124); input.setHintTextColor(0xFF5F6368); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.subtitle_manual_search) .setView(input) .setNegativeButton(R.string.dialog_negative, null) @@ -112,7 +112,7 @@ public final class SubtitleManualSearchDialog { if (candidates == null || candidates.isEmpty()) return; String[] labels = new String[candidates.size()]; for (int i = 0; i < candidates.size(); i++) labels[i] = label(candidates.get(i)); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(ResUtil.getString(R.string.subtitle_manual_select_title, candidates.size())) .setNegativeButton(R.string.dialog_negative, null) .setSingleChoiceItems(labels, indexOfCandidate(candidates, selected), (d, which) -> { @@ -154,7 +154,7 @@ public final class SubtitleManualSearchDialog { private static void maybeOfferAiTranslate(FragmentActivity activity, SubtitlePlaybackSession session, SubtitlePlaybackSession.Host host, SubtitleRequest request, SubtitleMatchResult result, SubtitleCandidate candidate, List candidates) { if (!shouldOfferAiTranslate(result, candidate)) return; - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.subtitle_ai_translate_title) .setMessage(R.string.subtitle_ai_translate_message) .setNegativeButton(R.string.dialog_negative, null) 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..4ef3afcaa5 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 @@ -10,6 +10,7 @@ import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; @@ -30,7 +31,7 @@ public final class SubtitleSettingsDialog { public static void showPreferredLanguage(FragmentActivity activity, String[] labels, String[] values, String currentValue, ValueCallback callback) { if (activity == null || labels == null || values == null || labels.length == 0 || values.length == 0) return; int checked = indexOf(values, currentValue); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.player_subtitle_language) .setNegativeButton(R.string.dialog_negative, null) .setSingleChoiceItems(labels, checked, (d, which) -> { @@ -44,7 +45,7 @@ public final class SubtitleSettingsDialog { public static void showSourceSummary(FragmentActivity activity, java.util.List providers, Runnable onSaved) { if (activity == null) return; if (providers == null || providers.isEmpty()) { - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.player_subtitle_source) .setMessage(R.string.player_subtitle_source_empty) .setPositiveButton(R.string.dialog_positive, null) @@ -54,7 +55,7 @@ public final class SubtitleSettingsDialog { } 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) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.player_subtitle_source) .setNegativeButton(R.string.dialog_negative, null) .setItems(names, (d, which) -> showSourceEnvironment(activity, providers.get(which), onSaved)) @@ -76,7 +77,7 @@ public final class SubtitleSettingsDialog { int horizontal = dp(activity, 20); container.setPadding(horizontal, dp(activity, 8), horizontal, 0); container.addView(layout); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(provider.getName()) .setView(container) .setNegativeButton(R.string.dialog_negative, null) @@ -99,7 +100,7 @@ public final class SubtitleSettingsDialog { public static void showRealtimeModel(FragmentActivity activity, String[] labels, int checked, boolean canDelete, IntCallback selection, Runnable deleteAction) { if (activity == null || labels == null || labels.length == 0) return; - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.subtitle_realtime_model_select_title) .setNegativeButton(R.string.dialog_negative, null) .setSingleChoiceItems(labels, checked, (d, which) -> { @@ -113,7 +114,7 @@ public final class SubtitleSettingsDialog { public static void showDeleteRealtimeModel(FragmentActivity activity, Runnable action) { if (activity == null) return; - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.subtitle_realtime_model_delete_title) .setMessage(R.string.subtitle_realtime_model_delete_message) .setNegativeButton(R.string.dialog_negative, null) @@ -126,7 +127,7 @@ public final class SubtitleSettingsDialog { public static void showRealtimeCompatibility(FragmentActivity activity, Runnable action) { if (activity == null) return; - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.subtitle_realtime_pcm_title) .setMessage(R.string.subtitle_realtime_pcm_required) .setNegativeButton(R.string.dialog_negative, null) @@ -139,7 +140,7 @@ public final class SubtitleSettingsDialog { public static void showRealtimeSpeedCompatibility(FragmentActivity activity, Runnable action) { if (activity == null) return; - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.subtitle_realtime_speed_title) .setMessage(R.string.subtitle_realtime_speed_required) .setNegativeButton(R.string.dialog_negative, null) @@ -169,7 +170,7 @@ public final class SubtitleSettingsDialog { container.setPadding(horizontal, dp(activity, 8), horizontal, 0); container.addView(layout, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(titleRes) .setView(container) .setNegativeButton(R.string.dialog_negative, null) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SyncPathDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SyncPathDialog.java index b7df9affcf..78842cba1c 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/SyncPathDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/SyncPathDialog.java @@ -15,6 +15,7 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterSyncPathDirBinding; import com.fongmi.android.tv.databinding.DialogSyncPathBinding; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.SyncFiles; @@ -55,7 +56,7 @@ public class SyncPathDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ThemeDialogLayout.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ThemeDialogLayout.java new file mode 100644 index 0000000000..f0127192c9 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ThemeDialogLayout.java @@ -0,0 +1,162 @@ +package com.fongmi.android.tv.ui.dialog; + +import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; + +/** + * Window sizing for the theme colour editor. + * + *

The editor previously inherited Material's default alert width, so the 13 colour + * slots, the 3 opacity sliders and the live preview were squeezed into a narrow column + * and the dialog covered only about 58% x 70% of a 1920x1080 screen - measured on + * device at [405,160][1515,920]. It now follows the same contract as the ad-block + * statistics dialog: fill the screen minus a constant safety margin. + * + *

The margin is a fixed dp value rather than a screen percentage. A percentage + * silently changes the usable area with the panel resolution, while the editor's rows + * have a fixed dp height and the preview needs a predictable amount of room; a fixed + * margin keeps the same physical gutter on every device. It also keeps the dialog from + * ever reaching the very edge of an overscanning TV panel. + * + *

The gutter is subtracted from the system-bar-safe area, not from the raw display + * bounds. {@code ResUtil.getScreenHeight} returns the full display on API 30+, so sizing + * the window from it pushed the footer's cancel/reset/save row under the navigation bar + * and the buttons were cut off. {@link #safeArea} subtracts the window's status-bar, + * navigation-bar and display-cutout insets instead, so the result stays inside every + * system bar and any TV overscan. + * + *

The same contract also sizes the playback-link health report dialog + * ({@code SiteHealthReportDialog}), whose footer cancel button was clipped for exactly the + * same reason. The arithmetic lives here so the two dialogs cannot drift apart. + */ +final class ThemeDialogLayout { + + /** Matches the ad-block statistics dialog's mobile gutter. */ + static final int MOBILE_MARGIN_DP = 16; + + /** Matches the ad-block statistics dialog's television gutter. */ + static final int LEANBACK_MARGIN_DP = 24; + + private ThemeDialogLayout() { + } + + /** + * The system-bar-safe area the editor may occupy. + * + * @param width usable width, already excluding the status/navigation bars and cutouts + * @param height usable height, already excluding the status/navigation bars and cutouts + */ + record Area(int width, int height) { + } + + /** + * The four system-bar insets that have to be kept clear, in pixels. + */ + record Insets(int left, int top, int right, int bottom) { + + static Insets none() { + return new Insets(0, 0, 0, 0); + } + + /** + * True when every edge is zero, i.e. the source carried no usable information. + * + *

A floating dialog window on API 28 reports {@code systemBars()} as all zeros even + * while a navigation bar is on screen (measured: the window's own insets are + * {@code [0,0][0,0]} while the display's stable area is {@code [0,42][1080,1830]}). + * Treating that as "no bars" is what let the editor grow past the navigation bar, so an + * all-zero report must stay indistinguishable from "unknown" and leave the host-content + * fallback in charge.

+ */ + boolean isEmpty() { + return left <= 0 && top <= 0 && right <= 0 && bottom <= 0; + } + + /** + * Insets implied by the display bounds minus the host content view. + * + *

The host already gave up that space to the status bar, the navigation bar, a + * display cutout and TV overscan, which makes this a reliable second source when the + * dialog window cannot report its own insets. A zero-sized content view means + * "unknown" and reports no insets. + */ + static Insets ofContent(int screenWidth, int screenHeight, int contentWidth, int contentHeight) { + if (contentWidth <= 0 || contentHeight <= 0) return none(); + return new Insets(0, 0, Math.max(0, screenWidth - contentWidth), + Math.max(0, screenHeight - contentHeight)); + } + + /** Never negative, never wider or taller than the display itself. */ + Insets clamped(int screenWidth, int screenHeight) { + int l = Math.max(0, left); + int t = Math.max(0, top); + int r = Math.max(0, Math.min(right, Math.max(0, screenWidth - l))); + int b = Math.max(0, Math.min(bottom, Math.max(0, screenHeight - t))); + return new Insets(l, t, r, b); + } + } + + /** Screen edge gutter in dp for the running flavour. */ + static int marginDp(boolean leanback) { + return leanback ? LEANBACK_MARGIN_DP : MOBILE_MARGIN_DP; + } + + /** + * The display minus the system-bar insets, or the whole display when that leaves no room. + * + *

On a 1080x1920 phone with a 42px status bar and a 90px navigation bar this is + * 1080x1788, and the window manager centres the resulting 1732px-tall window in the + * bar-safe area, so it spans y=[70,1802] and clears the navigation bar at y=1830. + * Sizing from the raw display made the window 1864px tall instead, which is what put + * the footer's save button under that bar. + */ + static Area safeArea(int screenWidth, int screenHeight, Insets insets) { + int screenW = Math.max(1, screenWidth); + int screenH = Math.max(1, screenHeight); + Insets bars = (insets == null ? Insets.none() : insets).clamped(screenW, screenH); + int width = screenW - bars.left() - bars.right(); + int height = screenH - bars.top() - bars.bottom(); + if (width <= 0 || height <= 0) return fullScreen(screenW, screenH); + return new Area(width, height); + } + + /** The whole display; used only when the system bars would leave no usable area. */ + static Area fullScreen(int screenWidth, int screenHeight) { + return new Area(Math.max(1, screenWidth), Math.max(1, screenHeight)); + } + + /** Window width that keeps {@code margin} on both sides, never below one pixel. */ + static int width(int screenWidth, int margin) { + return Math.max(1, Math.max(1, screenWidth) - Math.max(0, margin) * 2); + } + + /** Window height that keeps {@code margin} above and below, never below one pixel. */ + static int height(int screenHeight, int margin) { + return Math.max(1, Math.max(1, screenHeight) - Math.max(0, margin) * 2); + } + + /** + * The panel drawable the editor must keep as its window background. + * + *

{@code WebHtvAlertDialogBuilder} paints this editor's panel through the window + * background rather than through a view: the title row, the colour slots, the weighted + * scroll area and the action buttons carry no background of their own. Material wraps + * that panel in an {@link InsetDrawable} gutter, which is what kept the panel narrower + * than the window, so the wrapper is dropped now that the window carries the margin + * itself. + * + *

The panel itself has to be kept. Replacing the window background with a transparent + * fill is only safe for a dialog whose layout supplies its own card - the ad-block + * statistics dialog does - while here it erased the panel and left the settings page + * visible through the whole editor. + * + * @return the inset-free panel, or {@code null} when the window carries no background. + */ + static Drawable panelBackground(Drawable windowBackground) { + Drawable panel = windowBackground; + while (panel instanceof InsetDrawable inset) { + panel = inset.getDrawable(); + } + return panel; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ThemeImportDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ThemeImportDialog.java new file mode 100644 index 0000000000..9072dc6d01 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ThemeImportDialog.java @@ -0,0 +1,167 @@ +package com.fongmi.android.tv.ui.dialog; + +import android.content.Intent; +import android.net.Uri; +import android.text.TextUtils; +import android.view.View; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.viewbinding.ViewBinding; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.databinding.DialogThemeImportBinding; +import com.fongmi.android.tv.theme.ThemeProfile; +import com.fongmi.android.tv.theme.ThemeProfileValidator; +import com.fongmi.android.tv.theme.ThemeTweakCnAdapter; +import com.fongmi.android.tv.theme.ThemeTransfer; +import com.fongmi.android.tv.utils.Task; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.io.InputStream; +import java.util.List; + +/** Imports a safe color-only theme into the editor draft. */ +public final class ThemeImportDialog extends BaseAlertDialog { + + private DialogThemeImportBinding binding; + private Listener listener; + private ThemeProfile pending; + private List warnings = List.of(); + + public static void show(Fragment fragment, Listener listener) { + ThemeImportDialog dialog = new ThemeImportDialog(); + dialog.listener = listener; + dialog.show(fragment.getChildFragmentManager(), ThemeImportDialog.class.getSimpleName()); + } + + @Override + protected ViewBinding getBinding() { + return binding = DialogThemeImportBinding.inflate(getLayoutInflater()); + } + + @Override + protected MaterialAlertDialogBuilder getBuilder() { + return builder().setTitle(R.string.theme_import_title).setView(getBinding().getRoot()); + } + + @Override + protected void initView() { + binding.buttonOpenFile.setOnClickListener(view -> openFileLauncher.launch(new String[]{"application/json", "text/plain", "*/*"})); + binding.buttonCommunity.setOnClickListener(view -> openCommunity()); + binding.buttonPreview.setOnClickListener(view -> previewInput()); + binding.buttonUse.setOnClickListener(view -> usePending()); + binding.buttonCancel.setOnClickListener(view -> dismiss()); + binding.buttonUse.setEnabled(false); + } + + private void previewInput() { + String value = binding.input.getText() == null ? "" : binding.input.getText().toString().trim(); + if (value.isEmpty()) showError(getString(R.string.theme_import_empty)); + else if (ThemeTransfer.isHttps(value)) fetch(value); + else inspect(value, getString(R.string.theme_import_source_paste)); + } + + private void fetch(String url) { + setBusy(true); + Task.execute(() -> { + try { + String json = ThemeTransfer.fetch(url); + App.post(() -> { + if (!isAdded() || binding == null) return; + setBusy(false); + inspect(json, ThemeTransfer.host(url)); + }); + } catch (Exception error) { + App.post(() -> { + if (!isAdded() || binding == null) return; + setBusy(false); + showError(error.getMessage() == null ? getString(R.string.theme_import_failed) : error.getMessage()); + }); + } + }); + } + + private void inspect(String json, String source) { + try { + ThemeTweakCnAdapter.Result result = ThemeTweakCnAdapter.parse(json); + ThemeProfileValidator.Result checked = ThemeProfileValidator.validate(result.profile()); + if (!checked.valid()) throw new IllegalArgumentException(checked.message()); + pending = checked.profile(); + warnings = result.warnings(); + String label = TextUtils.isEmpty(source) ? pending.displayName() : pending.displayName() + " · " + source; + binding.preview.setText(getString(R.string.theme_import_preview, label)); + binding.warning.setText(warnings.isEmpty() ? getString(R.string.theme_import_no_warnings) + : getString(R.string.theme_import_warnings, String.join("\n", warnings))); + binding.warning.setVisibility(warnings.isEmpty() ? View.GONE : View.VISIBLE); + binding.error.setVisibility(View.GONE); + binding.buttonUse.setEnabled(true); + } catch (RuntimeException error) { + pending = null; + warnings = List.of(); + binding.buttonUse.setEnabled(false); + binding.preview.setText(""); + binding.warning.setVisibility(View.GONE); + showError(error.getMessage() == null ? getString(R.string.theme_import_failed) : error.getMessage()); + } + } + + private void usePending() { + if (pending == null || listener == null) return; + listener.onImported(pending.copy()); + dismiss(); + } + + private void openCommunity() { + try { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://tweakcn.com/community"))); + } catch (RuntimeException error) { + showError(getString(R.string.theme_import_browser_unavailable)); + } + } + + private void setBusy(boolean busy) { + binding.buttonPreview.setEnabled(!busy); + binding.buttonOpenFile.setEnabled(!busy); + binding.buttonCommunity.setEnabled(!busy); + binding.buttonUse.setEnabled(!busy && pending != null); + binding.error.setVisibility(View.GONE); + } + + private void showError(String message) { + binding.error.setText(message); + binding.error.setVisibility(View.VISIBLE); + } + + private final ActivityResultLauncher openFileLauncher = registerForActivityResult( + new ActivityResultContracts.OpenDocument(), this::readFile); + + private void readFile(Uri uri) { + if (uri == null) return; + setBusy(true); + Task.execute(() -> { + try (InputStream input = requireContext().getContentResolver().openInputStream(uri)) { + String json = ThemeTransfer.read(input); + App.post(() -> { + if (!isAdded() || binding == null) return; + setBusy(false); + binding.input.setText(json); + inspect(json, getString(R.string.theme_import_source_file)); + }); + } catch (Exception error) { + App.post(() -> { + if (!isAdded() || binding == null) return; + setBusy(false); + showError(error.getMessage() == null ? getString(R.string.theme_import_failed) : error.getMessage()); + }); + } + }); + } + + public interface Listener { + void onImported(@NonNull ThemeProfile profile); + } +} 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..0cbecf8d6c 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 @@ -29,6 +29,7 @@ import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.bean.TmdbPerson; import com.fongmi.android.tv.service.TmdbService; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.ui.activity.VideoActivity; import com.fongmi.android.tv.ui.adapter.TmdbPersonPhotoAdapter; import com.fongmi.android.tv.ui.adapter.TmdbPersonWorkAdapter; @@ -369,7 +370,7 @@ public class TmdbPersonDialog { MaterialCardView biographyCard = view.findViewById(R.id.biographyCard); ImageView profile = view.findViewById(R.id.profile); ImageView close = view.findViewById(R.id.closeBtn); - int overlay = light ? 0x99F4F7FA : 0x8F000000; + int overlay = applyScrimOpacity(light ? 0x99F4F7FA : 0x8F000000, light); int panelColor = light ? 0xFFF4F7FA : 0xF2101821; int cardColor = light ? 0xFFFFFFFF : 0x261C2833; int imageBg = light ? 0xFFE7EDF3 : 0xFF25313D; @@ -408,6 +409,19 @@ public class TmdbPersonDialog { return Setting.resolveTmdbDetailLightTheme(Setting.getTmdbDetailTheme(), night == Configuration.UI_MODE_NIGHT_YES); } + /** + * Keeps this dialog's own scrim colour and lets the user drive only its alpha. + * + *

The shipped scrim is translucent white in light mode and translucent black in + * dark mode; the theme token is translucent black in both. Substituting the token + * was measured to invert the light-mode scrim from lightening to darkening, so the + * user's {@code scrimOpacity} is applied to the dialog's existing colour instead. + * An unset slot returns {@code base} untouched, keeping the default byte-identical. + */ + private static int applyScrimOpacity(int base, boolean light) { + return ThemeController.applyScrimOpacity(base, ThemeController.configuredScrimOpacity(light)); + } + /** * 根据筛选条件显示作品(分批懒加载)。 */ diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSearchDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSearchDialog.java index a2614f194a..2cf2b753e8 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSearchDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/TmdbSearchDialog.java @@ -19,10 +19,10 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.databinding.DialogResultListBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.TmdbAdapter; import com.fongmi.android.tv.utils.KeyUtil; import com.fongmi.android.tv.utils.Util; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.Collections; import java.util.List; @@ -59,7 +59,7 @@ public class TmdbSearchDialog { public TmdbSearchDialog(Activity activity) { this.activity = activity; - ContextThemeWrapper dialogContext = new ContextThemeWrapper(activity, R.style.Theme_WebHTV_LightDialog); + ContextThemeWrapper dialogContext = new ContextThemeWrapper(activity, R.style.Theme_WebHTV_Dialog); this.binding = DialogResultListBinding.inflate(LayoutInflater.from(dialogContext)); } @@ -100,7 +100,7 @@ public class TmdbSearchDialog { public void show() { if (activity == null || activity.isFinishing() || activity.isDestroyed()) return; - dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog).setView(binding.getRoot()).create(); + dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog).setView(binding.getRoot()).create(); if (activity.isFinishing() || activity.isDestroyed()) return; dialog.show(); LightDialog.apply(dialog); 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..8c8633409f 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 @@ -19,10 +19,13 @@ 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.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Task; +import com.fongmi.android.tv.utils.TmdbProxy; import com.google.android.material.chip.Chip; import com.google.android.material.chip.ChipGroup; import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.MaterialAutoCompleteTextView; import java.util.ArrayList; import java.util.List; @@ -46,9 +49,18 @@ public class TmdbSourceDialog { private TextView disabledLabel; private EditText apiKeyInput; private EditText languageInput; - private EditText apiHostInput; - private EditText imageHostInput; + private MaterialAutoCompleteTextView apiHostInput; + private MaterialAutoCompleteTextView imageHostInput; + private EditText apiCustomInput; + private EditText imageCustomInput; private EditText omdbApiKeyInput; + private EditText ruleInputField; + private EditText disabledRuleInputField; + private View addBtnField; + private View addDisabledBtnField; + private View manageBtnField; + private View testBtnField; + private View resetBtnField; private Runnable onDismiss; private List tempEnabledRules; @@ -78,7 +90,11 @@ public class TmdbSourceDialog { apiKeyInput = view.findViewById(R.id.apiKeyInput); languageInput = view.findViewById(R.id.languageInput); apiHostInput = view.findViewById(R.id.apiHostInput); + setupRouteDropdown(apiHostInput, apiOptionLabels(), activity.getString(R.string.dialog_tmdb_api_host_label)); imageHostInput = view.findViewById(R.id.imageHostInput); + setupRouteDropdown(imageHostInput, imageOptionLabels(), activity.getString(R.string.dialog_tmdb_image_host_label)); + apiCustomInput = view.findViewById(R.id.apiCustomInput); + imageCustomInput = view.findViewById(R.id.imageCustomInput); omdbApiKeyInput = view.findViewById(R.id.omdbApiKeyInput); EditText ruleInput = view.findViewById(R.id.ruleInput); EditText disabledRuleInput = view.findViewById(R.id.disabledRuleInput); @@ -87,6 +103,13 @@ public class TmdbSourceDialog { TextView manageBtn = view.findViewById(R.id.manage); TextView testBtn = view.findViewById(R.id.testConfig); TextView resetBtn = view.findViewById(R.id.resetDefault); + ruleInputField = ruleInput; + disabledRuleInputField = disabledRuleInput; + addBtnField = addBtn; + addDisabledBtnField = addDisabledBtn; + manageBtnField = manageBtn; + testBtnField = testBtn; + resetBtnField = resetBtn; addBtn.setText(R.string.dialog_tmdb_add); addDisabledBtn.setText(R.string.dialog_tmdb_add); manageBtn.setText(R.string.dialog_tmdb_site_manage); @@ -104,10 +127,13 @@ public class TmdbSourceDialog { tempAllowedSites = new ArrayList<>(config.getAllowedSites()); apiKeyInput.setText(TextUtils.isEmpty(config.getAccessToken()) ? config.getApiKey() : config.getAccessToken()); languageInput.setText(config.getLanguage()); - apiHostInput.setText(config.getApiHost()); - imageHostInput.setText(config.getImageHost()); + apiHostInput.setText(apiDisplayFor(config), false); + imageHostInput.setText(imageDisplayFor(config), false); + apiCustomInput.setText(apiCustomValueFor(config)); + imageCustomInput.setText(imageCustomValueFor(config)); omdbApiKeyInput.setText(config.getOmdbApiKey()); updateChipsDisplay(); + updateRouteCustomVisibility(); addBtn.setOnClickListener(v -> addRule(ruleInput)); addDisabledBtn.setOnClickListener(v -> addDisabledRule(disabledRuleInput)); @@ -134,22 +160,30 @@ public class TmdbSourceDialog { .setView(view) .setPositiveButton(R.string.dialog_positive, (d, w) -> onSave()) .setNegativeButton(R.string.dialog_negative, null) - .setOnDismissListener(d -> { if (onDismiss != null) onDismiss.run(); }) + .setOnDismissListener(d -> { + if (onDismiss != null) onDismiss.run(); + }) .create(); dialog.show(); wireConfigDialogFocus(dialog, ruleInput, addBtn, disabledRuleInput, addDisabledBtn, manageBtn, resetBtn); LightDialog.apply(dialog); } + private void rewiringFocusAfterVisibilityChange() { + if (dialog == null) return; + wireConfigDialogFocus(dialog, ruleInputField, addBtnField, disabledRuleInputField, addDisabledBtnField, manageBtnField, resetBtnField); + } + private void testConfig(View testButton) { String credential = inputText(apiKeyInput); - String apiHost = inputText(apiHostInput); - String imageHost = inputText(imageHostInput); String omdbApiKey = inputText(omdbApiKeyInput); + updateRouteCustomVisibility(); + final String apiHost = apiValueFor(inputText(apiHostInput)); + final String imageHost = imageValueFor(inputText(imageHostInput)); AlertDialog sourceDialog = dialog; testButton.setEnabled(false); Task.execute(() -> { - TmdbConfigTestService.Result result = TmdbConfigTestService.test(credential, apiHost, imageHost, omdbApiKey); + TmdbConfigTestService.Result result = TmdbConfigTestService.test(credential, apiHost, imageHost, "", omdbApiKey); activity.runOnUiThread(() -> { testButton.setEnabled(true); if (activity.isFinishing() || activity.isDestroyed() || sourceDialog == null @@ -157,7 +191,7 @@ public class TmdbSourceDialog { 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) + new WebHtvAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.dialog_tmdb_test_result_title) .setMessage(apiResult + "\n" + imageResult + "\n" + omdbResult) .setPositiveButton(R.string.dialog_positive, null) @@ -176,8 +210,236 @@ public class TmdbSourceDialog { return input.getText() == null ? "" : input.getText().toString().trim(); } + private void setupRouteDropdown(MaterialAutoCompleteTextView input, String[] labels, String title) { + input.setKeyListener(null); + input.setAdapter(null); + input.setOnClickListener(v -> showRoutePicker(input, labels, title)); + input.setOnKeyListener((v, keyCode, event) -> { + if (event.getAction() != KeyEvent.ACTION_DOWN) return false; + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { + showRoutePicker(input, labels, title); + return true; + } + return false; + }); + } + + private void wireRouteDpadFocus(MaterialAutoCompleteTextView input, String[] labels, String title, View up, View down) { + input.setOnKeyListener((v, keyCode, event) -> { + if (event.getAction() != KeyEvent.ACTION_DOWN) return false; + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { + showRoutePicker(input, labels, title); + return true; + } + if (keyCode == KeyEvent.KEYCODE_DPAD_UP && up != null) return requestFocus(up); + if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && down != null) return requestFocus(down); + return false; + }); + } + + private void showRoutePicker(MaterialAutoCompleteTextView input, String[] labels, String title) { + String[] pickerLabels = withCustomLabel(labels); + String current = inputText(input); + int checked = -1; + for (int i = 0; i < pickerLabels.length; i++) if (pickerLabels[i].equals(current)) checked = i; + if (checked < 0 && !TextUtils.isEmpty(current)) checked = pickerLabels.length - 1; + final int focusIndex = Math.max(0, checked); + EditText customInput = customInputFor(input); + AlertDialog picker = new WebHtvAlertDialogBuilder(dialogContext, R.style.Theme_WebHTV_Dialog) + .setTitle(title) + .setSingleChoiceItems(pickerLabels, checked, (dialog, which) -> { + if (which == pickerLabels.length - 1) { + input.setText(pickerLabels[which], false); + showCustomInput(input, customInput); + } else { + hideCustomInput(input, customInput); + input.setText(pickerLabels[which], false); + } + dialog.dismiss(); + }) + .setNegativeButton(R.string.dialog_negative, null) + .show(); + picker.getListView().post(() -> { + if (picker.isShowing()) picker.getListView().setSelection(focusIndex); + if (picker.isShowing()) picker.getListView().requestFocus(); + }); + LightDialog.apply(picker); + } + + private String[] withCustomLabel(String[] labels) { + String[] result = new String[labels.length + 1]; + System.arraycopy(labels, 0, result, 0, labels.length); + result[labels.length] = activity.getString(R.string.dialog_tmdb_route_custom); + return result; + } + + private EditText customInputFor(MaterialAutoCompleteTextView input) { + return input == apiHostInput ? apiCustomInput : imageCustomInput; + } + + private String customLabel() { + return activity.getString(R.string.dialog_tmdb_route_custom); + } + + private void showCustomInput(MaterialAutoCompleteTextView input, EditText customInput) { + if (customInput == null) return; + customInput.setVisibility(View.VISIBLE); + rewiringFocusAfterVisibilityChange(); + customInput.post(() -> { + if (customInput.getVisibility() == View.VISIBLE && !activity.isFinishing() && !activity.isDestroyed()) { + requestFocus(customInput); + } + }); + } + + private void hideCustomInput(MaterialAutoCompleteTextView input, EditText customInput) { + if (customInput == null) return; + if (customInput.hasFocus() && input != null) requestFocus(input); + customInput.setVisibility(View.GONE); + rewiringFocusAfterVisibilityChange(); + } + + private void updateRouteCustomVisibility() { + updateCustomVisibility(apiHostInput, apiCustomInput); + updateCustomVisibility(imageHostInput, imageCustomInput); + } + + private void updateCustomVisibility(MaterialAutoCompleteTextView input, EditText customInput) { + if (input == null || customInput == null) return; + boolean visible = customLabel().equals(inputText(input)); + if (visible != isCustomVisible(customInput)) { + customInput.setVisibility(visible ? View.VISIBLE : View.GONE); + rewiringFocusAfterVisibilityChange(); + } + } + + private String[] apiOptionLabels() { + return new String[]{ + activity.getString(R.string.dialog_tmdb_api_auto), + activity.getString(R.string.dialog_tmdb_api_direct), + activity.getString(R.string.dialog_tmdb_api_itv666) + }; + } + + private String[] imageOptionLabels() { + return new String[]{ + activity.getString(R.string.dialog_tmdb_image_auto), + activity.getString(R.string.dialog_tmdb_image_direct), + activity.getString(R.string.dialog_tmdb_image_itv666), + activity.getString(R.string.dialog_tmdb_image_wsrv) + }; + } + + private String apiDisplayFor(TmdbConfig config) { + if (config != null && (config.isApiAuto() || config.isApiRouteDefault())) return activity.getString(R.string.dialog_tmdb_api_auto); + String value = config == null ? TmdbProxy.OFFICIAL_API : config.getApiHost(); + String display = routeDisplay(value, TmdbProxy.apiValues(), apiOptionLabels()); + return isCustomDisplay(display, apiOptionLabels()) ? customLabel() : display; + } + + private String imageDisplayFor(TmdbConfig config) { + if (config != null && (config.isImageAuto() || config.isImageRouteDefault())) return activity.getString(R.string.dialog_tmdb_image_auto); + String value = config == null ? TmdbProxy.OFFICIAL_IMAGE : config.getConfiguredImageBase(); + String display = routeDisplayImage(value, imageOptionLabels()); + return isCustomDisplay(display, imageOptionLabels()) ? customLabel() : display; + } + + /** display 未命中任何内置选项标签时才是真正的自定义线路。 */ + private boolean isCustomDisplay(String display, String[] builtInLabels) { + if (TextUtils.isEmpty(display)) return false; + for (String label : builtInLabels) { + if (TextUtils.equals(label, display)) return false; + } + return true; + } + + private String apiCustomValueFor(TmdbConfig config) { + if (config == null || config.isApiAuto() || config.isApiRouteDefault()) return ""; + String value = TmdbProxy.normalizeConfig(config.getApiHost()); + return isBuiltInApiValue(value) ? "" : value; + } + + private String imageCustomValueFor(TmdbConfig config) { + if (config == null || config.isImageAuto() || config.isImageRouteDefault()) return ""; + String value = TmdbProxy.normalizeImageConfig(config.getConfiguredImageBase()); + return isBuiltInImageValue(value) ? "" : value; + } + + private boolean isBuiltInApiValue(String value) { + List values = TmdbProxy.apiValues(); + for (String item : values) if (TextUtils.equals(item, value)) return true; + return TmdbProxy.isOfficialApiHost(value); + } + + private boolean isBuiltInImageValue(String value) { + List values = TmdbProxy.imageValues(); + for (String item : values) if (TextUtils.equals(item, value)) return true; + return TmdbProxy.isOfficialImageHost(value); + } + + private boolean isApiCustomMode() { + return apiCustomInput != null && apiCustomInput.getVisibility() == View.VISIBLE; + } + + private boolean isImageCustomMode() { + return imageCustomInput != null && imageCustomInput.getVisibility() == View.VISIBLE; + } + + private String apiValueFor(String value) { + if (isApiCustomMode()) { + String normalized = TmdbProxy.normalizeConfig(text(apiCustomInput)); + return TextUtils.isEmpty(normalized) ? TmdbProxy.OFFICIAL_API : normalized; + } + return routeValue(value, TmdbProxy.apiValues(), apiOptionLabels(), TmdbProxy.OFFICIAL_API); + } + + private String imageValueFor(String value) { + if (isImageCustomMode()) { + String normalized = TmdbProxy.normalizeImageConfig(text(imageCustomInput)); + return TextUtils.isEmpty(normalized) ? TmdbProxy.OFFICIAL_IMAGE : normalized; + } + String text = value == null ? "" : value.trim(); + String[] labels = imageOptionLabels(); + if (labels[0].equals(text)) return TmdbProxy.AUTO; + if (labels[1].equals(text)) return TmdbProxy.OFFICIAL_IMAGE; + if (labels[2].equals(text)) return TmdbProxy.ITV666; + if (labels[3].equals(text)) return TmdbProxy.WSRV_IMAGE; + String normalized = TmdbProxy.normalizeImageConfig(text); + return TextUtils.isEmpty(normalized) ? TmdbProxy.OFFICIAL_IMAGE : normalized; + } + + private String routeDisplay(String value, List values, String[] labels) { + String normalized = TmdbProxy.normalizeConfig(value); + for (int i = 0; i < values.size() && i < labels.length; i++) { + if (values.get(i).equals(normalized)) return labels[i]; + } + return normalized; + } + + private String routeDisplayImage(String value, String[] labels) { + String normalized = TmdbProxy.normalizeImageConfig(value); + List values = TmdbProxy.imageValues(); + for (int i = 0; i < values.size() && i < labels.length; i++) { + if (values.get(i).equals(normalized)) return labels[i]; + } + return normalized; + } + + private String routeValue(String value, List values, String[] labels, String fallback) { + String text = value == null ? "" : value.trim(); + for (int i = 0; i < values.size() && i < labels.length; i++) { + if (labels[i].equals(text)) return values.get(i); + } + String normalized = TmdbProxy.normalizeConfig(text); + return TextUtils.isEmpty(normalized) ? fallback : normalized; + } + + private static boolean isCustomVisible(EditText customInput) { + return customInput != null && customInput.getVisibility() == View.VISIBLE; + } + private MaterialAlertDialogBuilder builder() { - return new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog); + return new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog); } private void wireConfigDialogFocus(AlertDialog dialog, EditText ruleInput, View addBtn, EditText disabledRuleInput, View addDisabledBtn, View manageBtn, View resetBtn) { @@ -189,9 +451,20 @@ public class TmdbSourceDialog { View negative = dialog.getButton(AlertDialog.BUTTON_NEGATIVE); wireTextDpadFocus(apiKeyInput, null, languageInput, null, null); wireTextDpadFocus(languageInput, apiKeyInput, apiHostInput, null, null); - wireTextDpadFocus(apiHostInput, languageInput, imageHostInput, null, null); - wireTextDpadFocus(imageHostInput, apiHostInput, omdbApiKeyInput, null, null); - wireTextDpadFocus(omdbApiKeyInput, imageHostInput, ruleInput, null, null); + wireRouteDpadFocus(apiHostInput, apiOptionLabels(), + activity.getString(R.string.dialog_tmdb_api_host_label), languageInput, + isCustomVisible(apiCustomInput) ? apiCustomInput : imageHostInput); + if (isCustomVisible(apiCustomInput)) { + wireTextDpadFocus(apiCustomInput, apiHostInput, imageHostInput, null, null); + } + wireRouteDpadFocus(imageHostInput, imageOptionLabels(), + activity.getString(R.string.dialog_tmdb_image_host_label), + isCustomVisible(apiCustomInput) ? apiCustomInput : apiHostInput, + isCustomVisible(imageCustomInput) ? imageCustomInput : omdbApiKeyInput); + if (isCustomVisible(imageCustomInput)) { + wireTextDpadFocus(imageCustomInput, imageHostInput, omdbApiKeyInput, null, null); + } + wireTextDpadFocus(omdbApiKeyInput, isCustomVisible(imageCustomInput) ? imageCustomInput : imageHostInput, ruleInput, null, null); wireTextDpadFocus(ruleInput, omdbApiKeyInput, disabledRuleInput, null, addBtn); wireDpadFocus(addBtn, omdbApiKeyInput, addDisabledBtn, ruleInput, null); wireTextDpadFocus(disabledRuleInput, ruleInput, manageBtn, null, addDisabledBtn); @@ -246,10 +519,11 @@ public class TmdbSourceDialog { } private void onSave() { + updateRouteCustomVisibility(); String apiKey = text(apiKeyInput); String language = text(languageInput); - String apiHost = text(apiHostInput); - String imageHost = text(imageHostInput); + String apiHost = apiValueFor(text(apiHostInput)); + String imageHost = imageValueFor(text(imageHostInput)); String omdbApiKey = text(omdbApiKeyInput); boolean isToken = apiKey.split("\\.").length >= 3; StringBuilder sb = new StringBuilder("{"); @@ -258,13 +532,16 @@ public class TmdbSourceDialog { if (!TextUtils.isEmpty(language)) { sb.append("\"language\":\"").append(escape(language)).append("\","); } - if (!TextUtils.isEmpty(apiHost)) { - sb.append("\"apiBase\":\"").append(escape(apiHost)).append("\","); - } - // Always persist image host when provided so sanitize can normalize scheme/size. - if (!TextUtils.isEmpty(imageHost)) { - sb.append("\"imageBase\":\"").append(escape(imageHost)).append("\","); - } + boolean apiAuto = TmdbProxy.isAuto(apiHost); + boolean imageAuto = TmdbProxy.isAuto(imageHost); + sb.append("\"apiBase\":\"").append(escape(apiAuto ? TmdbProxy.OFFICIAL_API : apiHost)).append("\","); + sb.append("\"apiAuto\":").append(apiAuto).append(','); + sb.append("\"apiRouteConfigured\":true,"); + sb.append("\"apiRouteMode\":\"").append(apiAuto ? "auto" : TmdbProxy.isOfficialApiHost(apiHost) ? "direct" : "custom").append("\","); + sb.append("\"imageBase\":\"").append(escape(imageAuto ? TmdbProxy.OFFICIAL_IMAGE : imageHost)).append("\","); + sb.append("\"imageAuto\":").append(imageAuto).append(','); + sb.append("\"imageRouteConfigured\":true,"); + sb.append("\"imageRouteMode\":\"").append(imageAuto ? "auto" : TmdbProxy.isOfficialImageHost(imageHost) ? "direct" : "custom").append("\","); if (!TextUtils.isEmpty(omdbApiKey)) { sb.append("\"omdbApiKey\":\"").append(escape(omdbApiKey)).append("\","); } @@ -273,7 +550,13 @@ public class TmdbSourceDialog { sb.append("\"allowedSites\":").append(toJsonArray(tempAllowedSites)).append(','); sb.append("\"disabledSites\":").append(toJsonArray(tempDisabledSites)); sb.append('}'); - Setting.putTmdbConfig(TmdbConfig.objectFrom(sb.toString()).toJson()); + String savedConfig = TmdbConfig.objectFrom(sb.toString()).toJson(); + Setting.putTmdbConfig(savedConfig); + if (apiAuto || imageAuto) { + String warmupApi = apiAuto ? TmdbProxy.AUTO : apiHost; + String warmupImage = imageAuto ? TmdbProxy.AUTO : imageHost; + Task.execute(() -> TmdbConfigTestService.test(apiKey, warmupApi, warmupImage, "", "")); + } } private static String text(EditText input) { 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..b140a5d920 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; @@ -617,7 +618,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..01e4656378 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 @@ -33,7 +33,7 @@ public final class UpdateSettingsDialog { } public static void show(FragmentActivity activity) { - Dialog dialog = new Dialog(activity, R.style.Theme_WebHTV_LightDialog); + Dialog dialog = new Dialog(activity, R.style.Theme_WebHTV_Dialog); DialogUpdateSettingsBinding binding = DialogUpdateSettingsBinding.inflate(LayoutInflater.from(dialog.getContext())); State state = State.load(); dialog.setContentView(binding.getRoot()); @@ -133,13 +133,13 @@ public final class UpdateSettingsDialog { 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.width = (int) (DialogSafeArea.width(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))); + (int) (DialogSafeArea.height(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); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/VideoAspectRatioDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/VideoAspectRatioDialog.java index 6972bbdb10..aee9466848 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/VideoAspectRatioDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/VideoAspectRatioDialog.java @@ -15,8 +15,8 @@ import androidx.fragment.app.FragmentActivity; import com.fongmi.android.tv.R; import com.fongmi.android.tv.player.VideoAspectMode; import com.fongmi.android.tv.setting.PlayerSetting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Util; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputLayout; @@ -63,7 +63,7 @@ public final class VideoAspectRatioDialog { container.setPadding(dp(activity, 20), dp(activity, 8), dp(activity, 20), 0); container.addView(row, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - AlertDialog dialog = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog dialog = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.aspect_ratio_title) .setMessage(R.string.aspect_ratio_hint) .setView(container) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingRecordSyncDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingRecordSyncDialog.java index f626bb1abd..c4f71dfd17 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingRecordSyncDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingRecordSyncDialog.java @@ -25,6 +25,8 @@ import com.fongmi.android.tv.playback.PlaybackRemoteSyncStore; import com.fongmi.android.tv.playback.PlaybackWebhookStore; import com.fongmi.android.tv.playback.RemoteSyncConfig; import com.fongmi.android.tv.playback.ViewingRecordSyncStore; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.button.MaterialButton; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -57,7 +59,7 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -67,8 +69,8 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); + int screenWidth = DialogSafeArea.width(requireActivity(), window); + int screenHeight = DialogSafeArea.height(requireActivity(), window); boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); @@ -139,7 +141,7 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { } private View sectionTitle(int text) { - MaterialTextView view = text(getString(text), 12, Color.parseColor("#5F6368"), true); + MaterialTextView view = text(getString(text), 12, ThemeController.current().colorOnSurfaceVariant(), true); LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); params.topMargin = binding.content.getChildCount() == 0 ? 0 : dp(12); view.setLayoutParams(params); @@ -149,7 +151,7 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { private View switchRow(String title, String detail, boolean enabled, View.OnClickListener listener) { LinearLayoutCompat root = rowRoot(); LinearLayoutCompat header = header(root); - header.addView(text(title, 15, Color.BLACK, true), new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + header.addView(text(title, 15, ThemeController.current().colorOnSurface(), true), new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); MaterialButton toggle = actionButton(enabled ? R.string.setting_enable : R.string.setting_disable, enabled, false); toggle.setOnClickListener(listener); header.addView(toggle, new LinearLayoutCompat.LayoutParams(dp(92), dp(36))); @@ -163,7 +165,7 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { root.setClickable(true); root.setOnClickListener(listener); LinearLayoutCompat header = header(root); - header.addView(text(title, 15, Color.BLACK, true), new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + header.addView(text(title, 15, ThemeController.current().colorOnSurface(), true), new LinearLayoutCompat.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); MaterialButton action = actionButton(actionText, true, false); action.setOnClickListener(listener); header.addView(action, new LinearLayoutCompat.LayoutParams(dp(92), dp(36))); @@ -206,7 +208,7 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { } private void addDetail(LinearLayoutCompat root, String value) { - MaterialTextView view = text(value, 12, Color.parseColor("#5F6368"), false); + MaterialTextView view = text(value, 12, ThemeController.current().colorOnSurfaceVariant(), false); LinearLayoutCompat.LayoutParams params = new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); params.topMargin = dp(4); root.addView(view, params); @@ -230,7 +232,7 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { button.setMinimumHeight(dp(36)); button.setPadding(dp(6), 0, dp(6), 0); ColorStateList bg = ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_bg : R.color.dialog_outlined_button_bg); - ColorStateList fg = danger ? ColorStateList.valueOf(Color.parseColor("#B3261E")) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); + ColorStateList fg = danger ? ColorStateList.valueOf(ThemeController.current().colorError()) : ContextCompat.getColorStateList(requireContext(), tonal ? R.color.dialog_tonal_button_text : R.color.dialog_outlined_button_text); button.setBackgroundTintList(bg); button.setTextColor(fg); if (!tonal) { @@ -240,10 +242,24 @@ public class ViewingRecordSyncDialog extends BaseAlertDialog { return button; } + /** + * The row card fill and stroke follow the active palette. + * + *

These were fixed light literals ({@code #F5F6F7} fill, {@code #DADCE0} stroke), which + * no {@code webhtv_color_*} role ever equals, so {@code ThemeBinder}'s exact-match rewrite + * deliberately skipped them: under the night table the dialog panel went dark while every + * row stayed a near-white card with black text. {@code colorSurfaceContainerLow} is the + * semantic "card on a dialog panel" surface ({@code #F2F3F9} day / {@code #181C20} night) + * and {@code colorOutlineVariant} its paired hairline. + * + *

The previous two stroke greys ({@code #DADCE0} usable / {@code #E2E5E8} not) differed + * by 1.09:1, which carried no readable information - usability is already stated by the + * badge text - so both branches now share one semantic outline. + */ private GradientDrawable rowBackground() { GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(Color.parseColor("#F5F6F7")); - drawable.setStroke(dp(1), Color.parseColor("#DADCE0")); + drawable.setColor(ThemeController.current().colorSurfaceContainerLow()); + drawable.setStroke(dp(1), ThemeController.current().colorOutlineVariant()); drawable.setCornerRadius(dp(6)); return drawable; } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingReportRangeDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingReportRangeDialog.java index 6e33958221..c0ff8259e1 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingReportRangeDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/ViewingReportRangeDialog.java @@ -13,8 +13,8 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogViewingReportRangeBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.viewing.ViewingReportRange; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; import java.util.Arrays; @@ -35,7 +35,7 @@ public class ViewingReportRangeDialog { binding = DialogViewingReportRangeBinding.inflate(activity.getLayoutInflater()); adapter = new RangeAdapter(); binding.recycler.setAdapter(adapter); - dialog = new MaterialAlertDialogBuilder(activity) + dialog = new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.viewing_report_select_range) .setView(binding.getRoot()) .setNegativeButton(R.string.dialog_negative, null) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDebugDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDebugDialog.java index 8ead5f0967..d05bb90924 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDebugDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDebugDialog.java @@ -25,6 +25,7 @@ import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.databinding.DialogWebHomeExtensionDebugBinding; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.web.HomeWebController; @@ -75,7 +76,7 @@ public class WebHomeExtensionDebugDialog extends BaseAlertDialog implements Home @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -87,8 +88,8 @@ public class WebHomeExtensionDebugDialog extends BaseAlertDialog implements Home Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); + int screenWidth = DialogSafeArea.width(requireActivity(), window); + int screenHeight = DialogSafeArea.height(requireActivity(), window); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); params.width = (int) (screenWidth * (ResUtil.isLand(requireContext()) ? 0.96f : 0.98f)); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDialog.java index f251cfb9d8..adf67a5373 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeExtensionDialog.java @@ -37,6 +37,7 @@ import com.fongmi.android.tv.api.config.VodConfig; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.databinding.DialogWebHomeExtensionBinding; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.custom.SettingClipboardOverlay; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.Notify; @@ -96,7 +97,7 @@ public class WebHomeExtensionDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -108,8 +109,8 @@ public class WebHomeExtensionDialog extends BaseAlertDialog { Window window = getDialog().getWindow(); if (window == null) return; WindowManager.LayoutParams params = window.getAttributes(); - int screenWidth = ResUtil.getScreenWidth(requireContext()); - int screenHeight = ResUtil.getScreenHeight(requireContext()); + int screenWidth = DialogSafeArea.width(requireActivity(), window); + int screenHeight = DialogSafeArea.height(requireActivity(), window); boolean land = ResUtil.isLand(requireContext()); window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); window.getDecorView().setPadding(0, 0, 0, 0); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeThemeDialog.java b/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeThemeDialog.java index 162d6e6e41..39546bca44 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeThemeDialog.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/dialog/WebHomeThemeDialog.java @@ -15,6 +15,7 @@ import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.web.WebHomeTarget; @@ -38,8 +39,8 @@ public final class WebHomeThemeDialog { activity.getString(R.string.setting_web_home_theme_eclipse), activity.getString(R.string.setting_web_home_theme_custom) }; - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder( - activity, R.style.Theme_WebHTV_LightDialog) + MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder( + activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.setting_web_home_theme) .setNegativeButton(R.string.dialog_negative, null) .setSingleChoiceItems(items, selectedMode(), (dialog, which) -> { @@ -96,7 +97,7 @@ public final class WebHomeThemeDialog { int message = action == WebThemeManifestRollback.Action.ROLLBACK ? R.string.setting_web_home_theme_rollback_message : R.string.setting_web_home_theme_retry_message; - AlertDialog alert = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog alert = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.setting_web_home_theme_recovery) .setMessage(message) .setNegativeButton(R.string.dialog_negative, null) @@ -148,7 +149,7 @@ public final class WebHomeThemeDialog { container.setPadding(padding, 0, padding, 0); container.addView(input, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - AlertDialog alert = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog alert = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.setting_web_home_theme_custom) .setView(container) .setNegativeButton(R.string.dialog_negative, null) @@ -183,7 +184,7 @@ public final class WebHomeThemeDialog { } private static void showRemoteConfirmation(Activity activity, String url, Runnable onChanged, AlertDialog editor) { - AlertDialog warning = new MaterialAlertDialogBuilder(activity, R.style.Theme_WebHTV_LightDialog) + AlertDialog warning = new WebHtvAlertDialogBuilder(activity, R.style.Theme_WebHTV_Dialog) .setTitle(R.string.setting_web_home_theme_remote_warning_title) .setMessage(activity.getString(R.string.setting_web_home_theme_remote_warning_message, host(url))) .setNegativeButton(R.string.dialog_negative, null) diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/PlayerControlFocusHelper.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/PlayerControlFocusHelper.java index bb7a9097e2..a22870660e 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/PlayerControlFocusHelper.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/PlayerControlFocusHelper.java @@ -4,18 +4,26 @@ import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; +import com.fongmi.android.tv.R; + public final class PlayerControlFocusHelper { private PlayerControlFocusHelper() { } + /** + * 明确传入的目标优先;只有当调用方没有给出可用目标时,才保留 root 内已有的焦点。 + * 若「root 里已经有焦点」就提前返回,唤出控制栏时想恢复「上次选择的那个地方」 + * 会被 root 中恰好持有的其它焦点静默覆盖,焦点看起来“自己跑到别的按钮上”。 + */ public static boolean ensureFocus(View root, View preferred) { if (!isVisible(root)) return false; + View target = firstFocusable(preferred); + if (target != null && isDescendant(root, target)) return target.requestFocus(); View current = root.findFocus(); if (isFocusable(current) && isDescendant(root, current)) return true; - View target = firstFocusable(preferred); - if (target == null || !isDescendant(root, target)) target = firstFocusable(root); - return target != null && target.requestFocus(); + View fallback = firstFocusable(root); + return fallback != null && fallback.requestFocus(); } public static boolean handleKey(View root, View preferred, KeyEvent event) { @@ -24,6 +32,7 @@ public final class PlayerControlFocusHelper { ensureFocus(root, preferred); return true; } + syncSeekReturnTarget(root); if (!isDirectionKey(event) || event.getAction() != KeyEvent.ACTION_DOWN) return false; View focus = root.findFocus(); View next = focus == null ? null : focus.focusSearch(direction(event)); @@ -31,6 +40,52 @@ public final class PlayerControlFocusHelper { return false; } + /** + * 进度条按 UP 时应该回到的控件。 + * + *

焦点已经落在进度条自己身上时返回 {@code null}:此时必须保留上一次的「回来目标」, + * 否则目标会被改写成 seek 自身,UP 就只能靠几何搜索乱跳。 + * + *

判定必须覆盖「焦点在 seek 子树内」与「焦点是 seek 的祖先」两个方向:进度条的容器 + * {@code CustomSeekView} 自身在构造函数里 {@code setFocusable(true)},只是当前它的 + * {@code requestFocus} 会把请求转发给 {@code timeBar},所以容器自己拿不到焦点。一旦上游把该 + * 转发改成「失败就回退到 {@code super.requestFocus}」,容器就会成为可达的焦点持有者; + * 那时若把它写成 UP 目标,焦点会被转发回进度条,表现为「按 UP 没反应」。这里把两个方向 + * 都拒掉,使该不变不依赖上游委托细节(由 + * {@code containerNeverHoldsFocusBecauseRequestFocusDelegatesToTheSeekBar} 锁住前提)。 + * + *

焦点不在控制栏内、或者控件没有 id / 不可聚焦时同样返回 {@code null}(保留现状)。 + */ + public static View seekReturnTarget(View seek, View controls, View focused) { + if (seek == null || controls == null || focused == null) return null; + if (focused.getId() == View.NO_ID || !focused.isFocusable()) return null; + if (isDescendant(seek, focused) || isDescendant(focused, seek)) return null; + if (!isDescendant(controls, focused)) return null; + return focused; + } + + /** + * 让进度条的 UP 目标跟着「刚才停留的那颗按钮」走。 + * + *

电视端控制栏里从按钮按 DOWN 落到进度条后,再按 UP 应当回到用户刚才停留的那颗按钮; + * 而 FocusFinder 只认进度条自己({@code timeBar})的 nextFocusUpId,所以每收到一个遥控键、 + * 且焦点还停在控制栏的某颗控件上时,就把它改写成那颗控件。焦点已经在进度条上时保持不动 + * (见 {@link #seekReturnTarget})。目标之后被隐藏时 FocusFinder 会跳过它并回退到几何搜索, + * 因此不需要清理失效记忆。 + */ + public static void syncSeekReturnTarget(View root) { + if (root == null) return; + syncSeekReturnTarget(root, root.findFocus()); + } + + static void syncSeekReturnTarget(View root, View focus) { + if (root == null) return; + View timeBar = root.findViewById(R.id.timeBar); + if (timeBar == null) return; + View target = seekReturnTarget(timeBar, root, focus); + if (target != null) timeBar.setNextFocusUpId(target.getId()); + } + public static boolean containsFocus(View root) { if (!isVisible(root)) return false; View focus = root.findFocus(); diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/SourceEpisodeSeasonCache.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/SourceEpisodeSeasonCache.java index ac815637e7..1d259c6c5a 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/SourceEpisodeSeasonCache.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/SourceEpisodeSeasonCache.java @@ -5,7 +5,9 @@ import com.fongmi.android.tv.bean.Flag; import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.bean.Vod; +import java.util.ArrayList; import java.util.IdentityHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.ToIntFunction; @@ -29,8 +31,9 @@ public final class SourceEpisodeSeasonCache { if (flag == null || flag.getEpisodes() == null) return -1; Integer cached = flagSeasons.get(flag); if (cached != null) return cached; + List episodes = snapshot(flag.getEpisodes()); Integer season = null; - for (Episode episode : flag.getEpisodes()) { + for (Episode episode : episodes) { int candidate = episodeResolver.applyAsInt(episode); if (candidate < 0) continue; if (season != null && season != candidate) { @@ -77,7 +80,7 @@ public final class SourceEpisodeSeasonCache { public boolean hasMixedSeasons(Flag flag) { if (flag == null || flag.getEpisodes() == null) return false; Integer season = null; - for (Episode episode : flag.getEpisodes()) { + for (Episode episode : snapshot(flag.getEpisodes())) { int candidate = episodeResolver.applyAsInt(episode); if (candidate < 0) continue; if (season != null && season != candidate) return true; @@ -86,6 +89,12 @@ public final class SourceEpisodeSeasonCache { return false; } + private static List snapshot(List episodes) { + synchronized (episodes) { + return new ArrayList<>(episodes); + } + } + private static int resolveEpisodeSeason(Episode episode) { int candidate = EpisodeSeasonPolicy.resolveExplicitSourceSeason(episode == null ? "" : episode.getName()); if (candidate >= 0) return candidate; diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbCinemaTheme.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbCinemaTheme.java index ac58fdd3fa..37c3785fee 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbCinemaTheme.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbCinemaTheme.java @@ -40,23 +40,23 @@ public final class TmdbCinemaTheme { private static Palette light() { return new Palette( - 0xFFF4F7FA, - 0xBFFFFFFF, - 0xFFE7EDF3, + 0xFFEBE3DA, + 0x99FFFFFF, + 0xFFF6F1EA, 0xD9FFFFFF, - 0xFFE5F7EC, - 0x33424B57, - 0x66424B57, + 0xFFFFE9CC, + 0x33806E5F, + 0x66806E5F, 0xFF12202D, - 0xCC12202D, - 0x9912202D, - 0xE612202D, - 0xFF1D8F5A, - 0xFF20B866, - 0xBFF4F7FA, + 0xCC1D2A38, + 0x991D2A38, + 0xE61D2A38, + 0xFFA8702F, + 0xFFB7802F, + 0xBFEBE3DA, 0xD9FFFFFF, - 0x33424B57, - 0x1F12202D, + 0x33806E5F, + 0x1F1D2A38, 0xD9FFFFFF ); } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbDetailPrefetch.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbDetailPrefetch.java index 9a3e4e3ce2..44aa397516 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbDetailPrefetch.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbDetailPrefetch.java @@ -11,17 +11,23 @@ import java.util.List; import java.util.Locale; import java.util.concurrent.Callable; -final class TmdbDetailPrefetch { +/** + * TMDB 详情单槽预取:按规范化后的 {@code mediaType + tmdbId} 标识请求,负责复用、替换与取消。 + * + *

两个使用方:{@code TmdbUIAdapter}(视频详情页的原生详情路径)与 + * {@code TmdbDetailActivity}(原生 TMDB 详情页,站点详情与 TMDB 详情并行取回)。 + */ +public final class TmdbDetailPrefetch { private final ListeningExecutorService executor; private Identity identity; private ListenableFuture future; - TmdbDetailPrefetch(ListeningExecutorService executor) { + public TmdbDetailPrefetch(ListeningExecutorService executor) { this.executor = executor; } - synchronized StartResult start(TmdbItem item, Callable task) { + public synchronized StartResult start(TmdbItem item, Callable task) { Identity next = Identity.from(item); if (next == null) return null; if (next.equals(identity) && future != null && !future.isCancelled()) { @@ -34,7 +40,7 @@ final class TmdbDetailPrefetch { return new StartResult(state, future); } - synchronized ListenableFuture take(TmdbItem item) { + public synchronized ListenableFuture take(TmdbItem item) { Identity expected = Identity.from(item); if (expected == null || !expected.equals(identity)) return null; ListenableFuture result = future; @@ -43,7 +49,7 @@ final class TmdbDetailPrefetch { return result; } - synchronized void cancel() { + public synchronized void cancel() { cancelLocked(); } @@ -53,13 +59,13 @@ final class TmdbDetailPrefetch { future = null; } - enum StartState { + public enum StartState { STARTED, REUSED, REPLACED } - static final class StartResult { + public static final class StartResult { private final StartState state; private final ListenableFuture future; @@ -69,36 +75,36 @@ final class TmdbDetailPrefetch { this.future = future; } - StartState getState() { + public StartState getState() { return state; } - ListenableFuture getFuture() { + public ListenableFuture getFuture() { return future; } } - static final class Result { + public static final class Result { private final TmdbItem item; private final JsonObject detail; private final List cast; - Result(TmdbItem item, JsonObject detail, List cast) { + public Result(TmdbItem item, JsonObject detail, List cast) { this.item = item; this.detail = detail; this.cast = cast == null ? new ArrayList<>() : new ArrayList<>(cast); } - TmdbItem getItem() { + public TmdbItem getItem() { return item; } - JsonObject getDetail() { + public JsonObject getDetail() { return detail; } - List getCast() { + public List getCast() { return new ArrayList<>(cast); } } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbRowFocusChain.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbRowFocusChain.java new file mode 100644 index 0000000000..88104e68ad --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/TmdbRowFocusChain.java @@ -0,0 +1,46 @@ +package com.fongmi.android.tv.ui.helper; + +import java.util.ArrayList; +import java.util.List; + +/** + * 原生增强播放页 TMDB 区块的纵向焦点链计算。 + * + * 只把「可见且非空」的行纳入链条,其余行被完全跳过:某行没有数据(或标签与行一起被隐藏)时, + * 它的上下邻居直接相连——剧照下面没有海报就落到相关视频,相关视频也没有就落到主创团队。 + * 这样焦点永远交给一个真实存在的行,而不是先跳到不可见的 View 再退回几何搜索。 + */ +public final class TmdbRowFocusChain { + + /** 表示该方向没有邻居;调用方按上下文回退到列表首/末项或详情按钮。 */ + public static final int NO_NEIGHBOR = -1; + + private TmdbRowFocusChain() { + } + + /** + * 计算每个候选行的纵向邻居。 + * + * @param present 每个候选行的「可见且非空」标记,数组下标顺序即屏幕上的视觉顺序 + * @return 与 {@code present} 等长的数组,第 i 项为 {@code {upIndex, downIndex}}; + * 不参与链条的行返回 {@code {NO_NEIGHBOR, NO_NEIGHBOR}} + */ + public static int[][] link(boolean[] present) { + if (present == null) return new int[0][]; + int[][] result = new int[present.length][2]; + List active = new ArrayList<>(); + for (int index = 0; index < present.length; index++) { + // Java 的 int 默认值是 0,必须先铺上 NO_NEIGHBOR, + // 否则被跳过的行会误报邻居为第 0 行。 + result[index][0] = NO_NEIGHBOR; + result[index][1] = NO_NEIGHBOR; + if (present[index]) active.add(index); + } + for (int position = 0; position < active.size(); position++) { + int row = active.get(position); + result[row][0] = position == 0 ? NO_NEIGHBOR : active.get(position - 1); + result[row][1] = position == active.size() - 1 ? NO_NEIGHBOR : active.get(position + 1); + } + return result; + } +} 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 index 80cbfd0714..1eab0836ff 100644 --- 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 @@ -1,5 +1,6 @@ package com.fongmi.android.tv.ui.helper; +import com.fongmi.android.tv.utils.TmdbProxy; import androidx.annotation.Nullable; import com.fongmi.android.tv.bean.TmdbConfig; @@ -10,6 +11,7 @@ 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.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -82,7 +84,9 @@ public final class TmdbSourceAdapter { 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"}); + String titleKey = tv ? "name" : "title"; + String title = TmdbLanguagePolicy.bestDisplayValue(firstString(detail, tv ? new String[]{"name", "title"} : new String[]{"title", "name"}), + array(object(detail, "translations"), "translations"), titleKey, TmdbLanguagePolicy.requestLanguage(config)); 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); @@ -94,7 +98,7 @@ public final class TmdbSourceAdapter { mediaType, title, subtitle, - string(detail, "overview"), + TmdbLanguagePolicy.bestDisplayValue(string(detail, "overview"), array(object(detail, "translations"), "translations"), "overview", TmdbLanguagePolicy.requestLanguage(config)), poster, backdrop, "", @@ -295,11 +299,12 @@ public final class TmdbSourceAdapter { if (!element.isJsonObject()) continue; JsonObject episode = element.getAsJsonObject(); int number = integer(episode, "episode_number", result.size() + 1); + String target = TmdbLanguagePolicy.requestLanguage(config); result.add(new TmdbEpisode( number, - string(episode, "name"), + TmdbLanguagePolicy.bestDisplayValue(string(episode, "name"), array(object(episode, "translations"), "translations"), "name", target), string(episode, "air_date"), - string(episode, "overview"), + TmdbLanguagePolicy.bestDisplayValue(string(episode, "overview"), array(object(episode, "translations"), "translations"), "overview", target), imageUrl(config.getBackdropBase(), string(episode, "still_path")), number(episode, "vote_average", 0d), integer(episode, "runtime", 0), @@ -383,7 +388,7 @@ public final class TmdbSourceAdapter { if (normalized.isEmpty()) return ""; if (!normalized.startsWith("/")) return normalized; String normalizedBase = base == null ? "" : base.trim(); - return normalizedBase.isEmpty() ? normalized : normalizedBase + normalized; + return normalizedBase.isEmpty() ? normalized : TmdbProxy.imageUrl(normalizedBase, normalized); } private static String subtitle(String date, double vote) { 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 index 2f54ab6c3b..79096b451b 100644 --- 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 @@ -1,10 +1,13 @@ package com.fongmi.android.tv.ui.helper; +import android.text.TextUtils; + 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.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -69,6 +72,11 @@ public final class TmdbSourceCapabilityPlanner { } public static Plan plan(@Nullable TmdbBundle bundle, @Nullable TmdbSourcePayload payload, @Nullable UiState state) { + return plan(bundle, payload, state, ""); + } + + public static Plan plan(@Nullable TmdbBundle bundle, @Nullable TmdbSourcePayload payload, @Nullable UiState state, + @Nullable String targetLanguage) { UiState ui = state == null ? UiState.initialScreen() : state; Set required = new TreeSet<>(); if (ui.core()) required.add(CORE); @@ -84,19 +92,26 @@ public final class TmdbSourceCapabilityPlanner { 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); + for (String group : required) if (isAvailable(bundle, payload, group, ui.seasonNumber(), ui.episodeNumber(), targetLanguage)) 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) { + return isAvailable(bundle, payload, group, seasonNumber, episodeNumber, ""); + } + + public static boolean isAvailable(@Nullable TmdbBundle bundle, @Nullable TmdbSourcePayload payload, String group, + int seasonNumber, int episodeNumber, @Nullable String targetLanguage) { if (group == null || group.isEmpty()) return false; + if (group.equals(CORE) && payload != null && bundle != null && bundle.item() != null + && !coreLanguageAvailable(bundle.item(), bundle.detail(), payload, targetLanguage)) 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 CORE -> coreAvailable(bundle.item(), detail, payload, targetLanguage); case CREDITS -> !bundle.cast().isEmpty() || !bundle.creators().isEmpty(); case IMAGES -> !bundle.item().getPosterUrl().isEmpty() && !bundle.item().getBackdropUrl().isEmpty(); case EXTERNAL_IDS -> externalIdsAvailable(detail); @@ -140,12 +155,21 @@ public final class TmdbSourceCapabilityPlanner { return false; } - private static boolean coreAvailable(TmdbItem item, JsonObject detail) { + private static boolean coreAvailable(TmdbItem item, JsonObject detail, @Nullable TmdbSourcePayload payload, @Nullable String targetLanguage) { if (item.getTmdbId() <= 0 || item.getMediaType().isEmpty() || item.getTitle().isEmpty()) return false; + if (!coreLanguageAvailable(item, detail, payload, targetLanguage)) 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 coreLanguageAvailable(TmdbItem item, JsonObject detail, @Nullable TmdbSourcePayload payload, @Nullable String targetLanguage) { + if (TextUtils.isEmpty(targetLanguage)) return true; + String normalized = TmdbLanguagePolicy.normalize(targetLanguage); + String declared = payload == null ? "" : payload.getLanguage(); + if (!TextUtils.isEmpty(declared)) return TmdbLanguagePolicy.isDisplayLanguageCompatible(normalized, declared); + return TmdbLanguagePolicy.looksTargetLanguage(normalized, item.getTitle()) || TmdbLanguagePolicy.looksTargetLanguage(normalized, item.getOverview()); + } + private static boolean pageOneAvailable(JsonObject detail, String field) { JsonObject value = object(detail, field); if (value == null) return false; 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 index d71705859c..32f2c1d461 100644 --- 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 @@ -1,11 +1,14 @@ package com.fongmi.android.tv.ui.helper; +import android.text.TextUtils; + 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.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -26,7 +29,8 @@ public final class TmdbSourceMerger { SOURCE, LOCAL_CACHE, REMOTE_TMDB, - VOD_FALLBACK + VOD_FALLBACK, + REMOTE_TMDB_LANGUAGE } public record MergedBundle(TmdbBundle bundle, Map origins) { @@ -41,25 +45,36 @@ public final class TmdbSourceMerger { } public static TmdbBundle fillOnly(@Nullable TmdbBundle source, @Nullable TmdbSourcePayload payload, @Nullable TmdbBundle network) { - return merge(source, payload, null, network).bundle(); + return fillOnly(source, payload, network, ""); + } + + public static TmdbBundle fillOnly(@Nullable TmdbBundle source, @Nullable TmdbSourcePayload payload, + @Nullable TmdbBundle network, @Nullable String targetLanguage) { + return merge(source, payload, null, network, targetLanguage).bundle(); } public static MergedBundle merge(@Nullable TmdbBundle source, @Nullable TmdbSourcePayload payload, @Nullable TmdbBundle cache, @Nullable TmdbBundle network) { + return merge(source, payload, cache, network, ""); + } + + public static MergedBundle merge(@Nullable TmdbBundle source, @Nullable TmdbSourcePayload payload, + @Nullable TmdbBundle cache, @Nullable TmdbBundle network, + @Nullable String targetLanguage) { 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); + base = mergePair(base, payload, cache, Origin.LOCAL_CACHE, origins, targetLanguage); + base = mergePair(base, payload, network, Origin.REMOTE_TMDB, origins, targetLanguage); return new MergedBundle(base, origins); } private static TmdbBundle mergePair(TmdbBundle base, @Nullable TmdbSourcePayload payload, @Nullable TmdbBundle lower, - Origin lowerOrigin, Map origins) { + Origin lowerOrigin, Map origins, @Nullable String targetLanguage) { 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); + TmdbItem item = mergeItem(base.item(), lower.item(), payload, origins, lowerOrigin, targetLanguage); + JsonObject detail = mergeObject(base.detail(), lower.detail(), payload, "detail", origins, lowerOrigin, targetLanguage); 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); @@ -72,11 +87,12 @@ public final class TmdbSourceMerger { 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) { + private static TmdbItem mergeItem(TmdbItem source, TmdbItem lower, @Nullable TmdbSourcePayload payload, + Map origins, Origin lowerOrigin, @Nullable String targetLanguage) { if (source == null) return lower; - String title = choose(source.getTitle(), lower.getTitle(), "item.title", origins, lowerOrigin); + String title = chooseDisplay(source.getTitle(), lower.getTitle(), payload, targetLanguage, "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 overview = chooseDisplay(source.getOverview(), lower.getOverview(), payload, targetLanguage, "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); @@ -96,7 +112,7 @@ public final class TmdbSourceMerger { } private static JsonObject mergeObject(@Nullable JsonObject source, @Nullable JsonObject lower, @Nullable TmdbSourcePayload payload, String path, - Map origins, Origin lowerOrigin) { + Map origins, Origin lowerOrigin, @Nullable String targetLanguage) { JsonObject result = source == null ? new JsonObject() : source.deepCopy(); if (lower == null) return result; for (Map.Entry entry : lower.entrySet()) { @@ -104,11 +120,13 @@ public final class TmdbSourceMerger { String childPath = path.isEmpty() ? key : path + '.' + key; JsonElement current = result.get(key); JsonElement replacement = entry.getValue(); - if (current == null || isEmptyValue(current, payload, childPath)) { + boolean languageOverride = isCoreDisplayField(childPath) && !isEmptyValue(replacement, payload, childPath) + && shouldOverrideCoreDisplay(stringValue(current), stringValue(replacement), payload, targetLanguage); + if (current == null || languageOverride || isEmptyValue(current, payload, childPath)) { result.add(key, replacement.deepCopy()); - mark(origins, childPath, lowerOrigin); + mark(origins, childPath, languageOverride ? Origin.REMOTE_TMDB_LANGUAGE : lowerOrigin); } else if (current.isJsonObject() && replacement.isJsonObject()) { - result.add(key, mergeObject(current.getAsJsonObject(), replacement.getAsJsonObject(), payload, childPath, origins, lowerOrigin)); + result.add(key, mergeObject(current.getAsJsonObject(), replacement.getAsJsonObject(), payload, childPath, origins, lowerOrigin, targetLanguage)); } else if (current.isJsonArray() && replacement.isJsonArray()) { JsonArray merged = mergeArray(current.getAsJsonArray(), replacement.getAsJsonArray(), payload, childPath, origins, lowerOrigin); result.add(key, merged); @@ -230,6 +248,38 @@ public final class TmdbSourceMerger { }; } + private static String chooseDisplay(String source, String lower, @Nullable TmdbSourcePayload payload, + @Nullable String targetLanguage, String field, Map origins, Origin lowerOrigin) { + if (!TextUtils.isEmpty(source) && shouldOverrideCoreDisplay(source, lower, payload, targetLanguage)) { + mark(origins, field, Origin.REMOTE_TMDB_LANGUAGE); + return lower == null ? "" : lower; + } + return choose(source, lower, field, origins, lowerOrigin); + } + + private static boolean shouldOverrideCoreDisplay(String source, String lower, @Nullable TmdbSourcePayload payload, + @Nullable String targetLanguage) { + if (TextUtils.isEmpty(lower) || TextUtils.isEmpty(source)) return false; + if (TextUtils.isEmpty(targetLanguage)) return false; + String target = TmdbLanguagePolicy.normalize(targetLanguage); + String declared = payload == null ? "" : payload.getLanguage(); + if (!TmdbLanguagePolicy.isDisplayLanguageCompatible(target, declared)) { + return TmdbLanguagePolicy.looksTargetLanguage(target, lower); + } + return false; + } + + private static String stringValue(JsonElement value) { + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) return ""; + String text = value.getAsString(); + return text == null ? "" : text.trim(); + } + + private static boolean isCoreDisplayField(String path) { + String field = path.substring(path.lastIndexOf('.') + 1); + return "title".equals(field) || "name".equals(field) || "overview".equals(field); + } + 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); 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 index 2948189827..9b96498910 100644 --- 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 @@ -3,6 +3,7 @@ package com.fongmi.android.tv.ui.helper; import androidx.annotation.Nullable; import com.fongmi.android.tv.bean.TmdbSourcePayload; +import com.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -305,7 +306,8 @@ public final class TmdbSourcePayloadParser { private static String normalizeLanguage(String value) { String result = value == null ? "" : value.trim(); - return result.length() > 64 ? "" : result; + if (result.isEmpty() || result.length() > 64) return ""; + return TmdbLanguagePolicy.normalize(result); } private static String normalizeFetchedAt(String value) { 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..16c7af8946 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 @@ -34,6 +34,7 @@ import com.fongmi.android.tv.utils.EpisodeTitleFormatter; import com.fongmi.android.tv.utils.ResUtil; import com.fongmi.android.tv.utils.Task; import com.fongmi.android.tv.utils.TmdbDetailCache; +import com.fongmi.android.tv.utils.TmdbLanguagePolicy; import com.fongmi.android.tv.utils.TmdbImageSelector; import com.fongmi.android.tv.utils.Util; import com.github.catvod.crawler.SpiderDebug; @@ -230,7 +231,15 @@ public class TmdbUIAdapter { } if (keyed != null && (TextUtils.isEmpty(requestedEpisodeUrl) || containsEpisodeUrl(keyed, requestedEpisodeUrl))) return keyed; - return uniqueFlagForEpisodeUrl(flags, requestedEpisodeUrl); + Flag unique = uniqueFlagForEpisodeUrl(flags, requestedEpisodeUrl); + if (unique != null) return unique; + // 稳定键已经在当前线路表里命中了一条真实线路,只是那一集的 URL 不再属于它 + // (换源/源站刷新/带时效签名都会失配)。此时 keyed 仍是用户显式选过的那条线路, + // 不能像早期实现那样直接判罚为 null——调用方会把 null 当成「解析不了」而退回 + // flags.get(0),表现就是杀进程重进后线路强行回到第一条。线路名也一致时才认。 + if (keyed != null && !TextUtils.isEmpty(requestedFlagName) + && TextUtils.equals(requestedFlagName, keyed.getFlag())) return keyed; + return null; } if (!TextUtils.isEmpty(requestedEpisodeUrl)) { for (Flag flag : flags) { @@ -459,7 +468,7 @@ public class TmdbUIAdapter { * 指定的季号,没有时退化为唯一正片季(单季剧最常见)。返回 < 0 表示证据不足,不猜。 */ static int likelySeasonNumber(JsonObject detail, int intentSeason) { - List options = parseSeasonOptions(detail); + List options = parseSeasonOptions(detail, null); if (options.isEmpty()) return -1; int target = intentSeason; if (target < 0) { @@ -516,6 +525,7 @@ public class TmdbUIAdapter { TmdbDetailCache.Entry cached = takeTmdbDetailCache(item); if (cached != null) { detailPrefetch.cancel(); + seedSeasonEpisodes(cached); backgroundTasks.submit(() -> loadDetailSync(vod, cached.getItem(), cached.getDetail(), cached.getCast(), generation)); return; } @@ -581,9 +591,30 @@ public class TmdbUIAdapter { detailPrefetch.cancel(); this.tmdbItem = item; saveMatch(vod, item); + seedSeasonEpisodes(cached); backgroundTasks.submit(() -> loadDetailSync(vod, cached.getItem(), cached.getDetail(), cached.getCast(), generation)); } + /** + * 把详情页已经解析好的季集数据预填进内存缓存,让后续 {@link #seasonEpisodes} 直接命中内存。 + * + *

播放页的选集卡片模式取决于 {@code Episode.tmdbEpisode},而该字段只有成功拿到该季分集 + * 才会写入。详情页在跳转前已经取过同一季(否则它自己也画不出卡片),预填后播放页不必再发 + * 一次同样的请求:既省一次网络往返,也避免那次请求失败时详情页有卡片、播放页退化成纯文本。 + * 键与 {@link #seasonEpisodeKey} 保持一致,命中判据不变。 + */ + private void seedSeasonEpisodes(TmdbDetailCache.Entry cached) { + Map> episodes = cached == null ? null : cached.getSeasonEpisodes(); + if (episodes == null || episodes.isEmpty() || tmdbItem == null) return; + int seeded = 0; + for (Map.Entry> entry : episodes.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())); + seeded++; + } + SpiderDebug.log("tmdb", "season episodes source=detail-handoff seasons=%d", seeded); + } + /** * 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. @@ -599,12 +630,17 @@ public class TmdbUIAdapter { cancelActivePrefetch(); detailPrefetch.cancel(); applySourceBundle(bundle, sourceVod, payload); - TmdbSourceCapabilityPlanner.Plan plan = TmdbSourceCapabilityPlanner.plan(bundle, payload, TmdbSourceCapabilityPlanner.UiState.initialScreen()); + TmdbSourceCapabilityPlanner.Plan plan = TmdbSourceCapabilityPlanner.plan(bundle, payload, TmdbSourceCapabilityPlanner.UiState.initialScreen(), TmdbLanguagePolicy.requestLanguage(tmdbConfig)); 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); + // applySourceBundle 已把内嵌分季数据写进 seasonEpisodeCache,但写缓存本身不会给 Episode + // 挂上 tmdbEpisode;不跑一次绑定,内嵌 payload 的站点在播放页就永远是纯文本选集 + // (详情页按季集映射表画卡片,所以表现为“详情页有卡片、播放页没有”)。 + // 这条路径命中内存缓存,不会发起网络请求。 + if (tmdbItem != null && tmdbItem.isTv()) scheduleStartupEpisodeLoad(sourceVod, tmdbItem, generation); if (fillInitial) backgroundTasks.submit(() -> fillInitialSourceGaps(bundle, sourceVod, payload, plan, generation)); } @@ -674,7 +710,7 @@ public class TmdbUIAdapter { 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); + TmdbBundle merged = TmdbSourceMerger.fillOnly(sourceBundle, payload, networkBundle, TmdbLanguagePolicy.requestLanguage(requestConfig)); SpiderDebug.log("tmdb", "source fill finish cost=%dms missing=%s", System.currentTimeMillis() - start, plan.missing()); if (activity == null) return; activity.runOnUiThread(() -> { @@ -693,7 +729,7 @@ public class TmdbUIAdapter { } private static List sourceSeasonOptions(TmdbBundle bundle) { - List fromDetail = parseSeasonOptions(bundle.detail()); + List fromDetail = parseSeasonOptions(bundle.detail(), TmdbConfig.effectiveCurrent()); if (!fromDetail.isEmpty()) return fromDetail; List result = new ArrayList<>(); for (Integer seasonNumber : bundle.seasons()) { @@ -865,7 +901,7 @@ public class TmdbUIAdapter { private TmdbDetailCache.Entry takeTmdbDetailCache(TmdbItem item) { if (activity == null || activity.getIntent() == null || item == null) return null; - TmdbDetailCache.Entry cached = TmdbDetailCache.take(activity.getIntent().getStringExtra(TmdbDetailCache.EXTRA_KEY), item); + TmdbDetailCache.Entry cached = TmdbDetailCache.take(activity.getIntent().getStringExtra(TmdbDetailCache.EXTRA_KEY), item, TmdbLanguagePolicy.requestLanguage(tmdbConfig)); if (cached != null) SpiderDebug.log("tmdb", "detail core memory-cache hit title=%s media=%s id=%d", cached.getItem().getTitle(), cached.getItem().getMediaType(), cached.getItem().getTmdbId()); return cached; } @@ -1025,7 +1061,7 @@ public class TmdbUIAdapter { private void resolveSeason(Vod sourceVod, TmdbItem item, JsonObject detail) { synchronized (Setting.class) { - seasonOptions = parseSeasonOptions(detail); + seasonOptions = parseSeasonOptions(detail, tmdbConfig); List tmdbSeasons = new ArrayList<>(); Map seasonCounts = new HashMap<>(); for (SeasonOption option : seasonOptions) { @@ -1276,7 +1312,7 @@ public class TmdbUIAdapter { } } - static List parseSeasonOptions(JsonObject detail) { + static List parseSeasonOptions(JsonObject detail, TmdbConfig config) { List result = new ArrayList<>(); if (detail == null || !detail.has("seasons") || !detail.get("seasons").isJsonArray()) return result; for (JsonElement element : detail.getAsJsonArray("seasons")) { @@ -1286,7 +1322,7 @@ public class TmdbUIAdapter { if (number < 0) continue; result.add(new SeasonOption( number, - jsonString(season, "name"), + TmdbLanguagePolicy.bestDisplayValue(jsonString(season, "name"), season.has("translations") && season.get("translations").isJsonObject() && season.getAsJsonObject("translations").has("translations") && season.getAsJsonObject("translations").get("translations").isJsonArray() ? season.getAsJsonObject("translations").getAsJsonArray("translations") : null, "name", TmdbLanguagePolicy.requestLanguage(config)), jsonString(season, "air_date"), Math.max(0, jsonInt(season, "episode_count", 0)))); } @@ -1442,10 +1478,7 @@ public class TmdbUIAdapter { } private String detailTitle(TmdbItem item, JsonObject detail) { - if (detail == null) return ""; - String primary = "movie".equalsIgnoreCase(item.getMediaType()) ? jsonString(detail, "title") : jsonString(detail, "name"); - if (!TextUtils.isEmpty(primary)) return primary; - return "movie".equalsIgnoreCase(item.getMediaType()) ? jsonString(detail, "name") : jsonString(detail, "title"); + return tmdbService.preferredTitle(item, detail, tmdbConfig); } private static String jsonString(JsonObject object, String key) { @@ -1721,9 +1754,52 @@ public class TmdbUIAdapter { } private Integer currentEpisodeMetadataSeason() { - if (seasonResolution == null || seasonResolution.getStatus() != TmdbSeasonResolver.Status.RESOLVED) return null; - Integer selectedSeason = seasonResolution.getSelectedSeason(); - return selectedSeason != null && selectedSeason >= 0 ? selectedSeason : null; + if (seasonResolution != null && seasonResolution.getStatus() == TmdbSeasonResolver.Status.RESOLVED) { + Integer selectedSeason = seasonResolution.getSelectedSeason(); + if (selectedSeason != null && selectedSeason >= 0) return selectedSeason; + } + return soleOrdinarySeason(); + } + + /** + * 单季剧兜底:TMDB 元数据里只有一个正片季时,该季就是唯一可能的归属,无需解析器下结论。 + * + *

解析器在“请求季不在 TMDB 里”等情况下返回 AMBIGUOUS,此时旧实现直接放弃逐集绑定, + * 播放页就只剩纯文本按钮(详情页按季集映射表画卡片,所以表现为“详情页有卡片、播放页没有”)。 + * 本方法与 {@link TmdbSeasonResolver} 自身的 SINGLE_SEASON 启发式同口径:只有恰好一个正片季 + * 才成立,多季剧返回 null、行为完全不变。 + * + *

必须作为 {@link #currentEpisodeMetadataSeason()} 的组成部分而不是调用点局部变量: + * {@link #isCurrentEpisodeMetadataRequest} 与 {@code applyEpisodeTitles} 都用它做代次一致性校验, + * 只改调用点会让调度侧与执行侧看到不同的季号而互相否决。 + * + *

兜底只对“解析器没有可用结论、且没有相反证据”的来源生效:{@link TmdbSeasonResolver.Source#REQUEST} + * (请求季不在 TMDB)与 {@link TmdbSeasonResolver.Source#NONE}(源站路径显式关闭了启发式)。 + * 其余来源都代表解析器已经看到相反证据或用户显式选择,兜底覆盖它们只会帮倒忙: + * {@code MANUAL_FLAT} 是用户「保持原始剧集列表」的选择,覆盖后播放页会挂上 TMDB 集标题、 + * 并经 {@code TmdbEpisodeSorter.sort} 原地重排选集,与详情页(该模式下不画卡片)也不一致; + * {@code TITLE}/{@code EXPLICIT} 冲突表示源站剧名或集名明确指向 TMDB 不存在的季, + * 覆盖会把别的季的标题静默挂到这一季上。 + * + *

MULTI_SLICE 同样除外:解析器已经给出明确的分段结论时,兜底不得把它降级成单季路径。 + * 切片路径以 null 作为季号一致性校验的期望值({@code applyEpisodeTitlesForSlices} 传 null), + * 若兜底返回非 null,调度侧会改走单季绑定、执行侧的切片校验又必然否决, + * 结果是整个分段(含特别篇)一集都绑不上。 + */ + private Integer soleOrdinarySeason() { + if (isMultiSliceResolution()) return null; + if (seasonResolution != null) { + TmdbSeasonResolver.Source source = seasonResolution.getSource(); + if (source != TmdbSeasonResolver.Source.REQUEST && source != TmdbSeasonResolver.Source.NONE) return null; + } + if (seasonOptions == null || seasonOptions.isEmpty()) return null; + Integer sole = null; + for (SeasonOption option : seasonOptions) { + if (option == null || option.getSeasonNumber() <= 0) continue; + if (sole != null) return null; + sole = option.getSeasonNumber(); + } + return sole; } private boolean isMultiSliceResolution() { @@ -1746,8 +1822,8 @@ public class TmdbUIAdapter { private TmdbItem getCachedMatch(Vod vod) { if (vod == null) return null; TmdbMatchCache cache = Setting.getTmdbMatchCache(); - TmdbItem manual = cache.findManual(cacheSiteKey(vod), cacheVodId(vod), vod.getName()); - return manual != null ? manual : cache.find(cacheSiteKey(vod), cacheVodId(vod), vod.getName()); + TmdbItem manual = cache.findManual(cacheSiteKey(vod), cacheVodId(vod), vod.getName(), TmdbLanguagePolicy.requestLanguage(tmdbConfig)); + return manual != null ? manual : cache.find(cacheSiteKey(vod), cacheVodId(vod), vod.getName(), TmdbLanguagePolicy.requestLanguage(tmdbConfig)); } private boolean isManualMatch(Vod vod) { @@ -1778,7 +1854,7 @@ public class TmdbUIAdapter { // 不加锁会让后到的自动结果基于旧快照覆盖掉刚落盘的手动选择。 synchronized (Setting.class) { TmdbMatchCache cache = Setting.getTmdbMatchCache(); - cache.put(cacheSiteKey(vod), cacheVodId(vod), vod.getName(), item); + cache.put(cacheSiteKey(vod), cacheVodId(vod), vod.getName(), item, TmdbLanguagePolicy.requestLanguage(tmdbConfig)); Setting.putTmdbMatchCache(cache); } } @@ -1792,7 +1868,7 @@ public class TmdbUIAdapter { List aliases = manualMatchTitleAliases(vod); synchronized (Setting.class) { TmdbMatchCache cache = Setting.getTmdbMatchCache(); - cache.putManual(cacheSiteKey(vod), cacheVodId(vod), aliases, item); + cache.putManual(cacheSiteKey(vod), cacheVodId(vod), aliases, item, TmdbLanguagePolicy.requestLanguage(tmdbConfig)); Setting.putTmdbMatchCache(cache); } } diff --git a/app/src/main/java/com/fongmi/android/tv/ui/helper/TvFocusScale.java b/app/src/main/java/com/fongmi/android/tv/ui/helper/TvFocusScale.java new file mode 100644 index 0000000000..a3cde29b8c --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/ui/helper/TvFocusScale.java @@ -0,0 +1,74 @@ +package com.fongmi.android.tv.ui.helper; + +import android.view.View; + +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.theme.ThemeController; + +/** + * 电视端「聚焦时放大」的唯一实现、唯一时长来源,以及两个有据可依的强调档位。 + * + *

改动前,同一个焦点缩放在 TV 端有四种写法:最近观看/收藏/历史用 {@code 1.1f} + + * {@code 150ms},TMDB 相关视频与详情页视频卡用 {@code 1.04f} + {@code 120ms}, + * 网络诊断面板用 {@code 1.035f} + {@code 100ms}。数值与时长都没有记录说明为什么不同, + * 用户学到的是「聚焦有时放大、有时不放大、幅度还不一样」。 + * + *

为什么保留两个档位而不是一个。收敛过程中查到一个反证: + * {@code TmdbDetailActivityLayoutTest} 记录 {@code TmdbEpisodeAdapter} 刻意**完全不缩放**, + * 理由是「detail rows clip enlarged cards」——详情页那些密集行会把放大的卡片裁掉。 + * 所以密集宿主上的小倍率不是随意漂移,而是为了避免裁切。把 {@code 1.04} 一律提到 + * {@code 1.1} 会引入真实的裁切回归。因此本类只做真正该做的收敛: + * + *

    + *
  • 一套实现:缩放/复位只有这里一份,站点不再各自写 animate 链;
  • + *
  • 一个时长:全部取 {@code @integer/webhtv_focus_scale_duration};
  • + *
  • 两个档位:常规宿主取主题 token 的倍率,密集宿主取本类显式声明的紧凑倍率。
  • + *
+ * + *

明确不在收敛范围内的(它们不是焦点缩放,别顺手改):{@code AudioMiniPlayer} 的 + * {@code 1.03f/90ms} 是拖拽反馈、{@code 0.94f→1f/180ms} 是出现动画; + * {@code PlayerGesture} 的缩放是画面手势;{@code TmdbCardFocusHelper} / + * {@code TmdbEpisodeAdapter} 的 {@code setScaleX(1f)} 是刻意不缩放——那批卡片用 + * 焦点环 + 抬升表达焦点,是另一套已统一的机制。 + */ +public final class TvFocusScale { + + /** + * 密集宿主的紧凑倍率。 + * + *

用于 TMDB 相关视频/剧集这类行内卡片:放大后会被所在行裁切(见类注释的反证), + * 因此不能取主题的常规倍率。这个值是**唯一**允许写死缩放字面量的位置, + * 由 {@code TvFocusScaleContractTest} 钉住;若将来它也需要按主题调整, + * 应移入 {@code ThemeTokens} 而不是再散落到站点里。 + */ + private static final float COMPACT_SCALE = 1.04f; + + private TvFocusScale() { + } + + /** 常规卡片/列表项:聚焦放大到主题 token 的倍率,失焦回到 1.0。 */ + public static void apply(View view, boolean focused) { + animate(view, focused ? ThemeController.current().focusScale() : 1f); + } + + /** 密集宿主:聚焦放大到紧凑倍率,失焦回到 1.0。 */ + public static void applyCompact(View view, boolean focused) { + animate(view, focused ? COMPACT_SCALE : 1f); + } + + /** 不带动画地把视图复位,供 {@code onUnbindViewHolder} 之类的复用清理路径使用。 */ + public static void reset(View view) { + view.animate().cancel(); + view.setScaleX(1f); + view.setScaleY(1f); + } + + private static void animate(View view, float scale) { + view.animate().cancel(); + view.animate() + .scaleX(scale) + .scaleY(scale) + .setDuration(view.getResources().getInteger(R.integer.webhtv_focus_scale_duration)) + .start(); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/ui/web/CatWebActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/web/CatWebActivity.java index 90a9d383fc..ea49b7492a 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/web/CatWebActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/web/CatWebActivity.java @@ -20,6 +20,8 @@ import androidx.activity.OnBackPressedCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.appcompat.app.AppCompatActivity; +import androidx.webkit.WebSettingsCompat; +import androidx.webkit.WebViewFeature; import com.fongmi.android.tv.R; import com.fongmi.android.tv.utils.FileChooser; @@ -40,9 +42,13 @@ import java.io.File; */ public class CatWebActivity extends AppCompatActivity { + private static final String DESKTOP_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + /** 不带 TV- 前缀:SpiderDebug 自己会加,与 cat-msg / cat-source 保持一致。 */ private static final String TAG = "cat-web"; private static final String EXTRA_URL = "url"; + private static final String EXTRA_TITLE = "title"; + private static final String EXTRA_LOADING_TEXT = "loadingText"; private WebView webView; private ProgressBar progress; @@ -55,7 +61,18 @@ public class CatWebActivity extends AppCompatActivity { private ValueCallback chooser; public static Intent intent(Context context, String url) { - return new Intent(context, CatWebActivity.class).putExtra(EXTRA_URL, url); + return intent(context, url, "", ""); + } + + public static Intent browserIntent(Context context, String url, String title, String loadingText) { + return intent(context, url, title, loadingText); + } + + private static Intent intent(Context context, String url, String title, String loadingText) { + return new Intent(context, CatWebActivity.class) + .putExtra(EXTRA_URL, url) + .putExtra(EXTRA_TITLE, title) + .putExtra(EXTRA_LOADING_TEXT, loadingText); } @SuppressLint("SetJavaScriptEnabled") @@ -69,6 +86,8 @@ public class CatWebActivity extends AppCompatActivity { finish(); return; } + String title = getIntent().getStringExtra(EXTRA_TITLE); + if (!TextUtils.isEmpty(title)) setTitle(title); // Android 13+ 手势返回与系统返回键都先让 WebView 回退,退到底再关页面 getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { @@ -82,7 +101,8 @@ public class CatWebActivity extends AppCompatActivity { webView = findViewById(R.id.web_view); progress = findViewById(R.id.progress); loading = findViewById(R.id.loading); - ((TextView) findViewById(R.id.loading_text)).setText(R.string.cat_web_opening); + String loadingText = getIntent().getStringExtra(EXTRA_LOADING_TEXT); + ((TextView) findViewById(R.id.loading_text)).setText(TextUtils.isEmpty(loadingText) ? getString(R.string.cat_web_opening) : loadingText); ((TextView) findViewById(R.id.address)).setText(url); configure(); @@ -98,17 +118,27 @@ public class CatWebActivity extends AppCompatActivity { WebSettings s = webView.getSettings(); s.setJavaScriptEnabled(true); s.setDomStorageEnabled(true); - s.setUseWideViewPort(true); - s.setLoadWithOverviewMode(true); s.setSupportZoom(true); s.setBuiltInZoomControls(true); s.setDisplayZoomControls(false); + s.setUserAgentString(DESKTOP_UA); + s.setUseWideViewPort(true); + s.setLoadWithOverviewMode(true); + s.setTextZoom(100); + // 外部站点自带浅色主题时,系统算法深色化会把加载框变成黑底灰字。 + // 内置页只负责承载网页,不强制替站点换肤。 + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + WebSettingsCompat.setAlgorithmicDarkeningAllowed(s, false); + } else if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + WebSettingsCompat.setForceDark(s, WebSettingsCompat.FORCE_DARK_OFF); + } s.setAllowFileAccess(false); s.setAllowContentAccess(false); s.setCacheMode(WebSettings.LOAD_DEFAULT); // TV 上没有触摸,靠 D-pad 移动焦点;这两项让 WebView 参与焦点链 webView.setFocusable(true); webView.setFocusableInTouchMode(true); + webView.setInitialScale(100); webView.setBackgroundColor(0xFF101216); webView.setWebChromeClient(new WebChromeClient() { @Override diff --git a/app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java b/app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java index 8a72603cc2..3bc34f518f 100644 --- a/app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java +++ b/app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java @@ -22,6 +22,7 @@ import androidx.appcompat.app.AppCompatActivity; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Episode; import com.fongmi.android.tv.bean.History; +import com.fongmi.android.tv.service.ReaderTtsService; import com.fongmi.android.tv.ui.novel.NovelReaderHost; import com.fongmi.android.tv.ui.novel.NovelRouter; import com.fongmi.android.tv.ui.novel.ReaderHistory; @@ -1459,6 +1460,285 @@ public class WebReaderActivity extends AppCompatActivity { } } + /* ---------------- 朗读(TTS)JS 桥 ---------------- */ + + /** + * 朗读状态回调:把原生朗读的进度送回 WebView 面板(高亮跟随、状态文案、章末续读)。 + * 阅读页销毁时会清空(见 onDestroy),避免服务把状态发给已销毁的 WebView。 + */ + private final ReaderTtsService.WebCallback ttsWebCallback = new ReaderTtsService.WebCallback() { + @Override + public void onTtsState(String state, int paragraph, int total, String message, String title) { + JSONObject json = new JSONObject(); + try { + json.put("state", state); + json.put("paragraph", paragraph); + json.put("total", total); + json.put("message", message == null ? "" : message); + json.put("title", title == null ? "" : title); + } catch (Throwable ignore) { + } + evalJs("window.__onTtsState && window.__onTtsState(" + jsSafe(json.toString()) + ");"); + } + + @Override + public void onTtsParagraph(int paragraph, int total, String text) { + JSONObject json = new JSONObject(); + try { + json.put("paragraph", paragraph); + json.put("total", total); + json.put("text", text == null ? "" : text); + } catch (Throwable ignore) { + } + evalJs("window.__onTtsProgress && window.__onTtsProgress(" + jsSafe(json.toString()) + ");"); + } + + @Override + public void onTtsChapterEnd() { + evalJs("window.__onTtsChapterEnd && window.__onTtsChapterEnd();"); + } + + @Override + public void onTtsVoices(java.util.List voices) { + evalJs("window.__onTtsVoices && window.__onTtsVoices(" + jsSafe(voicesJson(voices)) + ");"); + } + }; + + private void evalJs(String script) { + runOnUiThread(() -> { + WebView view = webView; + if (view == null) return; + try { + view.evaluateJavascript(script, null); + } catch (Throwable ignore) { + } + }); + } + + private static String voicesJson(java.util.List voices) { + JSONArray array = new JSONArray(); + if (voices != null) { + for (com.fongmi.android.tv.tts.TtsVoice voice : voices) { + JSONObject json = new JSONObject(); + try { + json.put("id", voice.id); + json.put("name", voice.name.isEmpty() ? voice.id : voice.name); + json.put("lang", voice.lang); + } catch (Throwable ignore) { + } + array.put(json); + } + } + return array.toString(); + } + + /** + * 朗读面板初始化信息:当前状态 + 音源可用性 + 自定义引擎配置 + 内置音色。 + * 面板打开时调用一次,避免把长配置放在 HTML 里维护两份真相。 + */ + @JavascriptInterface + public String ttsInfo() { + JSONObject info = new JSONObject(); + try { + ReaderTtsService service = ReaderTtsService.get(); + info.put("state", service == null ? "stopped" : service.state()); + info.put("paragraph", service == null ? -1 : service.currentParagraph()); + info.put("total", service == null ? 0 : service.paragraphCount()); + info.put("timer", service == null ? 0 : service.timerMinutes()); + info.put("activeSource", service == null ? "" : service.sourceId()); + boolean systemAvailable = com.fongmi.android.tv.tts.TtsEngines.systemAvailable(this); + info.put("systemAvailable", systemAvailable); + info.put("systemDetail", systemAvailable ? "设备系统语音" : "设备未安装系统 TTS 引擎"); + info.put("edgeVoices", new JSONArray(voicesJson( + com.fongmi.android.tv.tts.TtsEngines.staticVoices(com.fongmi.android.tv.tts.TtsEngines.SOURCE_EDGE)))); + com.fongmi.android.tv.tts.TtsEngineConfig custom = + com.fongmi.android.tv.tts.TtsEngines.loadCustomConfig(); + info.put("custom", custom == null ? JSONObject.NULL : new JSONObject(custom.toJson())); + } catch (Throwable e) { + SpiderDebug.log(TAG, "ttsInfo failed %s", e.getMessage()); + } + return info.toString(); + } + + /** + * 取某个音源的音色列表。 + * + * 系统音色必须等 TextToSpeech 初始化完成(异步),因此这里返回空数组, + * 就绪后通过 {@code window.__onTtsVoices} 异步推送;其余音源为静态列表。 + */ + @JavascriptInterface + public String ttsVoices(String source) { + String id = source == null ? "" : source; + try { + if (com.fongmi.android.tv.tts.TtsEngines.SOURCE_SYSTEM.equals(id)) { + com.fongmi.android.tv.tts.TtsEngines.probeSystemVoices(this, voices -> + evalJs("window.__onTtsVoices && window.__onTtsVoices(" + jsSafe(voicesJson(voices)) + ");")); + return "[]"; + } + return voicesJson(com.fongmi.android.tv.tts.TtsEngines.staticVoices(id)); + } catch (Throwable e) { + SpiderDebug.log(TAG, "ttsVoices failed %s", e.getMessage()); + return "[]"; + } + } + + /** 保存自定义在线朗读引擎(legado 规则子集)。 */ + @JavascriptInterface + public boolean ttsSaveEngine(String json) { + com.fongmi.android.tv.tts.TtsEngineConfig config = + com.fongmi.android.tv.tts.TtsEngineConfig.fromJson(json); + if (config == null || !com.fongmi.android.tv.tts.TtsHttpRule.parse(config.url, config.contentType).isUsable()) { + return false; + } + com.fongmi.android.tv.tts.TtsEngines.saveCustomConfig(config); + return true; + } + + /** + * 开始朗读。 + * + * payload:{source, voice, rate, pitch, timer, index, title, rule, paragraphs:[...]} + * 正文段落不走 Intent(可能很长),而是经静态 StartRequest 传给服务。 + */ + @JavascriptInterface + public void ttsStart(String payload) { + try { + JSONObject json = new JSONObject(payload == null ? "{}" : payload); + String source = json.optString("source", com.fongmi.android.tv.tts.TtsEngines.SOURCE_SYSTEM); + float rate = (float) json.optDouble("rate", 1d); + float pitch = (float) json.optDouble("pitch", 1d); + String voice = json.optString("voice", ""); + int timer = json.optInt("timer", 0); + int index = Math.max(0, json.optInt("index", 0)); + String title = json.optString("title", vodName); + com.fongmi.android.tv.tts.TtsEngineConfig rule = null; + if (com.fongmi.android.tv.tts.TtsEngines.SOURCE_CUSTOM.equals(source)) { + rule = com.fongmi.android.tv.tts.TtsEngineConfig.fromJson(json.optString("rule", "")); + if (rule == null) { + pushTtsError("请先配置在线朗读引擎"); + return; + } + } + JSONArray array = json.optJSONArray("paragraphs"); + ArrayList paragraphs = new ArrayList<>(); + if (array != null) { + for (int i = 0; i < array.length(); i++) { + String text = array.optString(i, ""); + if (!TextUtils.isEmpty(text)) paragraphs.add(text); + } + } + if (paragraphs.isEmpty()) { + pushTtsError("没有可朗读的内容"); + return; + } + com.fongmi.android.tv.tts.TtsOptions options = + new com.fongmi.android.tv.tts.TtsOptions(rate, pitch, voice); + final com.fongmi.android.tv.tts.TtsEngineConfig startRule = rule; + final ReaderTtsService.StartRequest request = new ReaderTtsService.StartRequest( + source, options, startRule, paragraphs, index, timer, title); + // 下发任务同样回主线程:控制器/服务要求主线程语义(见 postToService 的注释), + // 在主线程内「先登记任务再 startForegroundService」也让这次交接天然有序。 + runOnUiThread(() -> { + ReaderTtsService.setPendingStart(request); + ReaderTtsService.setPendingWebCallback(ttsWebCallback); + ReaderTtsService service = ReaderTtsService.get(); + if (service != null) service.setWebCallback(ttsWebCallback); + Intent intent = new Intent(this, ReaderTtsService.class).setAction(ReaderTtsService.ACTION_START); + androidx.core.content.ContextCompat.startForegroundService(this, intent); + }); + } catch (Throwable e) { + SpiderDebug.log(TAG, "ttsStart failed %s", e.getMessage()); + pushTtsError("朗读启动失败:" + e.getMessage()); + } + } + + private void pushTtsError(String message) { + JSONObject json = new JSONObject(); + try { + json.put("state", "error"); + json.put("paragraph", -1); + json.put("total", 0); + json.put("message", message); + } catch (Throwable ignore) { + } + evalJs("window.__onTtsState && window.__onTtsState(" + jsSafe(json.toString()) + ");"); + } + + /** + * 朗读控制桥统一回主线程执行。 + * + * {@code @JavascriptInterface} 方法在 WebView 的 JavaBridge 线程被调用,而朗读控制器与 + * 前台服务都要求主线程语义(引擎回调也一律经 {@code onMain} 归一到主线程:下游要改 + * MediaPlayer、起/停前台通知、向 WebView 注入脚本)。若直接在 JS 线程改控制器状态, + * 就会与主线程的引擎回调并发:既可能读到被 {@code stop()} 换成空表的队列(控制器里 + * size 与 get 的竞态),也会让 startForeground/通知更新发生在非主线程。 + */ + private void postToService(java.util.function.Consumer action) { + runOnUiThread(() -> { + ReaderTtsService service = ReaderTtsService.get(); + if (service != null) action.accept(service); + }); + } + + @JavascriptInterface + public void ttsPause() { + postToService(ReaderTtsService::pauseReadAloud); + } + + @JavascriptInterface + public void ttsResume() { + postToService(ReaderTtsService::resumeReadAloud); + } + + @JavascriptInterface + public void ttsToggle() { + postToService(ReaderTtsService::toggleReadAloud); + } + + @JavascriptInterface + public void ttsPrev() { + postToService(ReaderTtsService::prevParagraph); + } + + @JavascriptInterface + public void ttsNext() { + postToService(ReaderTtsService::nextParagraph); + } + + @JavascriptInterface + public void ttsSeek(int paragraph) { + postToService(service -> service.seekParagraph(paragraph)); + } + + @JavascriptInterface + public void ttsSetRate(float rate) { + postToService(service -> service.setRate(rate)); + } + + @JavascriptInterface + public void ttsSetPitch(float pitch) { + postToService(service -> service.setPitch(pitch)); + } + + @JavascriptInterface + public void ttsSetVoice(String voice) { + postToService(service -> service.setVoice(voice)); + } + + @JavascriptInterface + public void ttsSetTimer(int minutes) { + postToService(service -> service.setTimer(minutes)); + } + + @JavascriptInterface + public void ttsStop() { + runOnUiThread(() -> { + ReaderTtsService service = ReaderTtsService.get(); + if (service != null) service.shutdown(); + else ReaderTtsService.stopAndQuit(); + }); + } + @Override public void onBackPressed() { // 直接关闭阅读页返回播放器(兼容旧 API;新 API 走 OnBackPressedDispatcher) @@ -1510,6 +1790,12 @@ public class WebReaderActivity extends AppCompatActivity { // onPause 已处理返回场景,这里兜住系统回收等不经过 finish() 的销毁。 markClosed(); picTokens.clear(); + // 朗读与阅读页同生命周期:离开阅读页就停播(通知栏控制只服务于仍在阅读的会话) + if (isFinishing()) { + ReaderTtsService service = ReaderTtsService.get(); + if (service != null) service.clearWebCallback(ttsWebCallback); + ReaderTtsService.stopAndQuit(); + } // 只在真正结束时清缓存:配置变更 / 系统回收导致的重建会再次用同一个 cacheKey // 读取正文与章节列表(Intent 里只带 key,不带数据),提前清掉会渲染成空章。 if (isFinishing() && !cacheKey.isEmpty()) { diff --git a/app/src/main/java/com/fongmi/android/tv/utils/AppVersion.java b/app/src/main/java/com/fongmi/android/tv/utils/AppVersion.java index bebc15e2f8..3e7ba33780 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/AppVersion.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/AppVersion.java @@ -2,8 +2,21 @@ package com.fongmi.android.tv.utils; import com.fongmi.android.tv.BuildConfig; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + public final class AppVersion { + /** + * Release tags carry a build stamp, for example {@code v5.6.0-202609221328} (stable) or + * {@code v5.6.0-beta-202610091755} (beta). The stamp is the release moment, so it is the only + * reliable way to order two builds that publish the same {@code versionCode}. + */ + private static final Pattern BUILD_STAMP = Pattern.compile("(?An older or identical tag is never an update. That matters because the stable and beta + * channels of the same release line can publish the same {@code versionCode}: treating + * "different tag" as "newer" made an older package look installable over a newer one, so the + * app kept offering a downgrade and the two channels overwrote each other in a loop. + * + *

Tags without a usable stamp keep the historical "different means update" behavior. Two + * usable stamps are always ordered against each other, also when one carries seconds and the + * other does not; only a missing stamp falls back to "different means update". + */ + public static boolean isNewer(String current, String target) { + String currentTag = stripPrefix(current); + String targetTag = stripPrefix(target); + if (targetTag.isEmpty() || targetTag.equals(currentTag)) return false; + String currentStamp = buildStamp(currentTag); + String targetStamp = buildStamp(targetTag); + if (currentStamp.isEmpty() || targetStamp.isEmpty()) return true; + return padStamp(targetStamp).compareTo(padStamp(currentStamp)) > 0; + } + + /** + * Widens a {@code yyyyMMddHHmm} stamp to {@code yyyyMMddHHmmss} so two stamps of different + * lengths stay comparable; a minute stamp means second zero of that minute. + */ + private static String padStamp(String stamp) { + StringBuilder padded = new StringBuilder(stamp); + while (padded.length() < STAMP_LENGTH) padded.append('0'); + return padded.toString(); + } + + /** Returns the trailing build stamp ({@code yyyyMMddHHmm[ss]}) of a release tag, or {@code ""}. */ + public static String buildStamp(String value) { + if (value == null) return ""; + Matcher matcher = BUILD_STAMP.matcher(value); + String stamp = ""; + while (matcher.find()) stamp = matcher.group(1); + return stamp; + } + public static String stripPrefix(String value) { if (value == null) return ""; value = value.trim(); diff --git a/app/src/main/java/com/fongmi/android/tv/utils/CrashLogSession.java b/app/src/main/java/com/fongmi/android/tv/utils/CrashLogSession.java new file mode 100644 index 0000000000..f14a36751a --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/utils/CrashLogSession.java @@ -0,0 +1,100 @@ +package com.fongmi.android.tv.utils; + +import android.content.Context; + +import com.github.catvod.crawler.DebugLogStore; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Builds the crash-session log served by the existing debug log page. + * + *

Runs on a worker thread: it reads the report dropped by the crashed process, appends the + * same-UID system logcat tail (the only pre-crash evidence when the debug log was off), starts a + * temporary in-memory diagnostic session and seeds it. Everything is best-effort; the crash + * screen must survive any failure here. + */ +public final class CrashLogSession { + + private static final int MAX_LOG_LINES = 800; + private static final int MAX_LOG_CHARS = 192 << 10; + private static final long LOGCAT_TIMEOUT_MS = 1500; + private static boolean prepared; + + private CrashLogSession() { + } + + public static synchronized void prepare(Context context) { + if (prepared) return; + prepared = true; + try { + boolean temporary = DebugLogStore.beginCrashSession(); + String report = CrashReport.readRecent(context); + List logcat = logcatTail(CrashLogText.reportPid(report)); + String body = CrashLogText.composeSession(report, logcat, logcat != null && !logcat.isEmpty(), report != null, !temporary); + for (String line : body.split("\n", -1)) { + if (!line.isEmpty()) DebugLogStore.appendCrashContext(line); + } + } catch (Throwable error) { + try { + DebugLogStore.appendCrashContext("# crash-session unavailable: " + error.getClass().getName()); + } catch (Throwable ignored) { + // The debug service still starts; the page then reports the original state. + } + } + } + + private static List logcatTail(int pid) { + Process process = null; + Thread watchdog = null; + StringBuilder output = new StringBuilder(); + try { + process = new ProcessBuilder("logcat", "-d", "-v", "threadtime", "-t", "4000") + .redirectErrorStream(true).start(); + watchdog = timeout(process); + watchdog.start(); + try (InputStream input = process.getInputStream()) { + byte[] buffer = new byte[8192]; + int read; + while (output.length() < MAX_LOG_CHARS && (read = input.read(buffer)) > 0) { + output.append(new String(buffer, 0, read, StandardCharsets.UTF_8)); + } + } + } catch (IOException | RuntimeException error) { + return null; + } finally { + if (watchdog != null) watchdog.interrupt(); + if (process != null) { + try { + process.destroy(); + } catch (RuntimeException ignored) { + } + } + } + if (output.length() == 0) return List.of(); + List raw = new ArrayList<>(); + for (String line : output.toString().split("\n")) if (!line.isEmpty()) raw.add(line); + if (raw.isEmpty()) return List.of(); + return CrashLogText.filterLogcat(raw, pid, MAX_LOG_LINES, MAX_LOG_CHARS); + } + + private static Thread timeout(Process process) { + Thread watchdog = new Thread(() -> { + try { + Thread.sleep(LOGCAT_TIMEOUT_MS); + } catch (InterruptedException ignored) { + return; + } + try { + process.destroy(); + } catch (RuntimeException ignored) { + } + }, "crash-logcat-timeout"); + watchdog.setDaemon(true); + return watchdog; + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/CrashLogText.java b/app/src/main/java/com/fongmi/android/tv/utils/CrashLogText.java new file mode 100644 index 0000000000..8774019917 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/utils/CrashLogText.java @@ -0,0 +1,234 @@ +package com.fongmi.android.tv.utils; + +import com.github.catvod.crawler.diagnostics.DiagnosticText; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Pure text assembly for the crash-time log session. No Android types: every Android fact is + * passed in, so the bounds, redaction and freshness rules stay unit-testable. + */ +public final class CrashLogText { + + public static final String REPORT_HEADER = "# webhtv-crash-report v1"; + public static final String SESSION_HEADER = "# crash-session v1"; + public static final int MAX_REPORT_CHARS = 96 * 1024; + public static final int MAX_STACK_CHARS = 48 * 1024; + public static final long FRESH_WINDOW_MS = 5 * 60_000L; + public static final int MAX_SESSION_LINES = 1200; + public static final int MAX_SESSION_CHARS = 256 * 1024; + private static final int MAX_FIELD_CHARS = 4 * 1024; + private static final Pattern LINES = Pattern.compile("\\r\\n|\\r|\\n"); + + private CrashLogText() { + } + + public record Facts( + String appVersion, + String wallTime, + long elapsedRealtimeMs, + String processName, + int pid, + int uid, + String thread, + boolean debugLogEnabled, + String device, + String system, + String javaClass, + String message, + String stack) { + } + + /** Crash report written by the dying process. Every field is redacted and bounded. */ + public static String format(Facts facts) { + StringBuilder out = new StringBuilder(MAX_REPORT_CHARS >> 2); + line(out, REPORT_HEADER); + line(out, "# Written by the crashed process; read by the crash screen process."); + line(out, "appVersion=" + oneLine(facts == null ? null : facts.appVersion())); + line(out, "wallTime=" + oneLine(facts == null ? null : facts.wallTime())); + line(out, "elapsedMs=" + (facts == null ? 0L : facts.elapsedRealtimeMs())); + line(out, "process=" + oneLine(facts == null ? null : facts.processName())); + line(out, "pid=" + (facts == null ? 0 : facts.pid())); + line(out, "uid=" + (facts == null ? 0 : facts.uid())); + line(out, "thread=" + oneLine(facts == null ? null : facts.thread())); + line(out, "debugLogEnabled=" + (facts != null && facts.debugLogEnabled())); + line(out, "device=" + oneLine(facts == null ? null : facts.device())); + line(out, "system=" + oneLine(facts == null ? null : facts.system())); + line(out, "javaClass=" + oneLine(facts == null ? null : facts.javaClass())); + line(out, "message=" + oneLine(facts == null ? null : facts.message())); + line(out, "--- stack ---"); + appendStack(out, facts == null ? null : facts.stack()); + return out.toString(); + } + + private static void appendStack(StringBuilder out, String stack) { + int budget = Math.min(MAX_STACK_CHARS, MAX_REPORT_CHARS - out.length()); + int used = 0; + boolean truncated = false; + if (stack != null) { + for (String raw : LINES.split(stack)) { + String text = oneLine(raw); + if (text.isEmpty()) continue; + if (used + text.length() + 1 > budget) { + truncated = true; + break; + } + line(out, text); + used += text.length() + 1; + } + } + if (stack == null || stack.isEmpty()) line(out, "# stack unavailable"); + else if (truncated) line(out, "# [truncated stack]"); + } + + /** elapsedRealtime recorded in a report, or -1 when absent/invalid. */ + public static long reportElapsed(String report) { + return reportValue(report, "elapsedMs=", -1L); + } + + /** pid recorded in a report, or -1 when absent/invalid. */ + public static int reportPid(String report) { + long value = reportValue(report, "pid=", -1L); + return value > 0 && value <= Integer.MAX_VALUE ? (int) value : -1; + } + + private static long reportValue(String report, String prefix, long fallback) { + if (report == null || report.isEmpty()) return fallback; + for (String line : LINES.split(report)) { + if (!line.startsWith(prefix)) continue; + try { + return Long.parseLong(line.substring(prefix.length()).trim()); + } catch (NumberFormatException ignored) { + return fallback; + } + } + return fallback; + } + + public static boolean isFresh(long reportElapsedMs, long nowElapsedMs, long windowMs) { + if (reportElapsedMs <= 0 || nowElapsedMs <= 0 || windowMs <= 0) return false; + long delta = nowElapsedMs - reportElapsedMs; + return delta >= 0 && delta <= windowMs; + } + + /** Keep only lines emitted by the crashed process and return its bounded tail. */ + public static List filterLogcat(List lines, int pid, int maxLines, int maxBytes) { + if (lines == null || lines.isEmpty() || maxLines <= 0 || maxBytes <= 0) return List.of(); + List kept = new ArrayList<>(); + int bytes = 0; + for (int i = lines.size() - 1; i >= 0 && kept.size() < maxLines; i--) { + String line = lines.get(i); + if (line == null || line.isEmpty()) continue; + if (pid > 0 && linePid(line) != pid) continue; + int cost = line.length() + 1; + if (bytes + cost > maxBytes) break; + bytes += cost; + kept.add(line); + } + Collections.reverse(kept); + return kept; + } + + /** System-logcat process id in a {@code -v threadtime} line, or -1. */ + static int linePid(String line) { + int field = 0; + int start = -1; + for (int i = 0; i <= line.length(); i++) { + boolean separator = i == line.length() || line.charAt(i) == ' ' || line.charAt(i) == '\t'; + if (!separator) { + if (start < 0) start = i; + continue; + } + if (start < 0) continue; + if (field == 2) return positiveInt(line, start, i); + field++; + start = -1; + } + return -1; + } + + private static int positiveInt(String text, int start, int end) { + int value = 0; + if (end <= start) return -1; + for (int i = start; i < end; i++) { + char c = text.charAt(i); + if (c < '0' || c > '9') return -1; + value = value * 10 + (c - '0'); + if (value > Integer.MAX_VALUE / 10) return -1; + } + return value > 0 ? value : -1; + } + + /** Body injected into the existing diagnostic log buffer for the crash session. */ + public static String composeSession(String report, List logcat, boolean logcatAvailable, + boolean reportFresh, boolean debugLogEnabled) { + StringBuilder out = new StringBuilder(4096); + line(out, SESSION_HEADER); + line(out, "# 崩溃会话日志:由崩溃界面自动开启,仅在本界面存活期间提供下载。"); + line(out, "# coverage: report=" + (reportFresh && report != null && !report.isEmpty() ? "fresh" : "missing-or-stale") + + " logcat=" + (logcatAvailable ? "own-uid-tail" : "unavailable") + + " debugLog=" + (debugLogEnabled ? "on" : "off")); + if (!debugLogEnabled) { + line(out, "# 调试日志在崩溃前未开启:崩溃前只能提供系统日志(同应用 UID 尾部),可能不完整。"); + } + line(out, "# === crash report ==="); + if (report != null && !report.isEmpty()) { + for (String raw : LINES.split(report)) line(out, oneLine(raw)); + } else { + line(out, "# 未找到本次崩溃的堆栈记录(可能为 native 崩溃、被系统杀死或写入失败)。"); + } + line(out, "# === system logcat (own uid tail) ==="); + if (logcatAvailable && logcat != null && !logcat.isEmpty()) { + for (String raw : logcat) line(out, oneLine(raw)); + } else { + line(out, "# 系统日志不可用。"); + } + return truncateSession(out); + } + + private static String truncateSession(StringBuilder source) { + String text = source.toString(); + if (text.length() <= MAX_SESSION_CHARS) return text; + String[] lines = LINES.split(text); + StringBuilder out = new StringBuilder(MAX_SESSION_CHARS + 128); + int count = 0; + for (String line : lines) { + if (count >= MAX_SESSION_LINES || out.length() + line.length() + 1 > MAX_SESSION_CHARS) { + line(out, "# [truncated crash session]"); + break; + } + line(out, line); + count++; + } + return out.toString(); + } + + /** + * Insert zero-width spaces after URL delimiters so TV text views can wrap a long address. + * Copying always uses the original URL, never this display form. + */ + public static String wrapUrlForDisplay(String url) { + if (url == null || url.isEmpty()) return ""; + StringBuilder out = new StringBuilder(url.length() + 16); + for (int i = 0; i < url.length(); i++) { + char c = url.charAt(i); + out.append(c); + if (c == ':' || c == '/' || c == '?' || c == '&' || c == '=') out.append('\u200B'); + } + return out.toString(); + } + + /** Redacted, single-line form used for every untrusted text field. */ + public static String oneLine(String value) { + if (value == null || value.isEmpty()) return ""; + String clean = DiagnosticText.clean(value).text(); + return clean.length() > MAX_FIELD_CHARS ? clean.substring(0, MAX_FIELD_CHARS) : clean; + } + + private static void line(StringBuilder out, String text) { + out.append(text).append('\n'); + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/CrashReport.java b/app/src/main/java/com/fongmi/android/tv/utils/CrashReport.java new file mode 100644 index 0000000000..3edf70c4c1 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/utils/CrashReport.java @@ -0,0 +1,219 @@ +package com.fongmi.android.tv.utils; + +import android.content.Context; +import android.os.Build; +import android.os.Process; +import android.os.SystemClock; +import android.util.Log; + +import com.github.catvod.crawler.DebugLogStore; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Date; +import java.util.Locale; + +/** + * Writes the crash facts from the dying process and hands them to the crash screen process. + * + *

CustomActivityOnCrash starts the error activity in a separate {@code :error_activity} + * process and kills the crashing process, so nothing in memory survives. The report is the only + * hand-off channel; it is written best-effort and must never take over process termination from + * the configured crash handler. + */ +public final class CrashReport { + + private static final String DIRECTORY = "crash"; + private static final String PREFIX = "webhtv-crash-"; + private static final String SUFFIX = ".txt"; + private static final int MAX_FILES = 3; + private static final int MAX_READ_CHARS = 256 << 10; + private static final int MAX_CMDLINE_BYTES = 256; + private static volatile boolean installed; + + private CrashReport() { + } + + /** + * Chain a crash writer in front of the configured handler (CustomActivityOnCrash). Idempotent. + * + *

Order matters: CAOC's normal crash path shows the error activity and kills the process + * without calling the handler it wrapped, so this writer must be the outermost handler. + * CAOC installs itself from {@code CaocInitProvider} with {@code initOrder="101"}, which runs + * before androidx.startup's {@code InitializationProvider}; this call from {@code Startup} + * therefore lands on top of CAOC's handler. Do not move it earlier, and do not let the provider + * order change: the report would silently stop being written while everything else kept working. + */ + public static void install(Context context) { + if (installed) return; + Context app = application(context); + if (app == null) return; + Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); + if (previous == null) return; + try { + Thread.setDefaultUncaughtExceptionHandler((thread, error) -> { + try { + write(app, thread, error); + } catch (Throwable ignored) { + // A report failure must not change crash handling. + } finally { + previous.uncaughtException(thread, error); + } + }); + installed = true; + } catch (RuntimeException ignored) { + // Very early startup may refuse handler changes; the crash screen then falls back to logcat. + } + } + + /** True when a report written by this process can be produced (mostly for tests/diagnostics). */ + static boolean write(Context context, Thread thread, Throwable error) { + File directory = directory(context); + if (directory == null) return false; + String report; + try { + report = CrashLogText.format(facts(thread, error)); + } catch (Throwable ignored) { + return false; + } + byte[] data = report.getBytes(StandardCharsets.UTF_8); + if (data.length == 0) return false; + File target = new File(directory, PREFIX + System.currentTimeMillis() + SUFFIX); + File temporary = new File(target.getPath() + ".new"); + try (FileOutputStream output = new FileOutputStream(temporary)) { + output.write(data); + output.flush(); + output.getFD().sync(); + } catch (IOException | RuntimeException ignored) { + temporary.delete(); + return false; + } + if (!temporary.renameTo(target)) { + temporary.delete(); + return false; + } + rotate(directory); + return true; + } + + /** Newest report written by the just-crashed process, or null when absent/stale/unreadable. */ + public static String readRecent(Context context) { + File[] files = reports(context); + if (files == null || files.length == 0) return null; + long now = SystemClock.elapsedRealtime(); + for (File file : files) { + String text = read(file); + if (text == null || text.isEmpty()) continue; + // Do not stop at the first stale report: names are wall-clock based, so a clock jump + // can make an older report sort first while a fresher one still exists. + if (CrashLogText.isFresh(CrashLogText.reportElapsed(text), now, CrashLogText.FRESH_WINDOW_MS)) return text; + } + return null; + } + + private static CrashLogText.Facts facts(Thread thread, Throwable error) { + String message = error == null ? "" : error.getMessage(); + return new CrashLogText.Facts( + AppVersion.fullName(), + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US).format(new Date()), + SystemClock.elapsedRealtime(), + processName(), + Process.myPid(), + Process.myUid(), + thread == null ? "" : thread.getName(), + DebugLogStore.isEnabled(), + safe(Build.MANUFACTURER) + " " + safe(Build.MODEL), + "Android " + safe(Build.VERSION.RELEASE) + " (sdk " + Build.VERSION.SDK_INT + ")", + error == null ? "" : error.getClass().getName(), + message == null ? "" : message, + error == null ? "" : Log.getStackTraceString(error)); + } + + private static String processName() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) return safe(android.app.Application.getProcessName()); + byte[] bytes = new byte[MAX_CMDLINE_BYTES]; + int length; + try (FileInputStream input = new FileInputStream("/proc/self/cmdline")) { + length = input.read(bytes); + } + if (length <= 0) return ""; + int end = 0; + while (end < length && bytes[end] != 0) end++; + return new String(bytes, 0, end, StandardCharsets.UTF_8); + } catch (Throwable ignored) { + return ""; + } + } + + private static void rotate(File directory) { + try { + File[] files = reports(directory); + if (files == null) return; + for (int i = MAX_FILES; i < files.length; i++) files[i].delete(); + } catch (RuntimeException ignored) { + } + } + + private static File[] reports(Context context) { + File directory = directory(context); + return directory == null ? null : reports(directory); + } + + private static File[] reports(File directory) { + if (directory == null || !directory.isDirectory()) return null; + File[] files = directory.listFiles((dir, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX)); + if (files == null) return null; + Arrays.sort(files, Comparator.comparing(File::getName).reversed()); + return files; + } + + private static String read(File file) { + if (file == null || !file.isFile() || file.length() <= 0) return null; + try (FileInputStream input = new FileInputStream(file)) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream((int) Math.min(file.length(), MAX_READ_CHARS)); + byte[] buffer = new byte[8192]; + int read; + while (bytes.size() < MAX_READ_CHARS && (read = input.read(buffer, 0, Math.min(buffer.length, MAX_READ_CHARS - bytes.size()))) > 0) { + bytes.write(buffer, 0, read); + } + return new String(bytes.toByteArray(), StandardCharsets.UTF_8); + } catch (IOException | RuntimeException ignored) { + return null; + } + } + + private static File directory(Context context) { + if (context == null) return null; + try { + File base = context.getFilesDir(); + if (base == null) return null; + File directory = new File(base, DIRECTORY); + if (!directory.isDirectory() && !directory.mkdirs()) return null; + return directory; + } catch (Throwable ignored) { + return null; + } + } + + private static Context application(Context context) { + if (context == null) return null; + try { + Context app = context.getApplicationContext(); + return app == null ? context : app; + } catch (RuntimeException ignored) { + return context; + } + } + + private static String safe(String value) { + return value == null ? "" : value; + } +} 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 index 53a2fe99eb..0fa0836d4c 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockNotice.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/HlsAdblockNotice.java @@ -1,13 +1,17 @@ package com.fongmi.android.tv.utils; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; -/** Playback-session debounce for repeated live-playlist refreshes. */ +/** Playback-session debounce for repeated live-playlist refreshes, plus the shared notice text. */ public final class HlsAdblockNotice { private static final long WINDOW_MS = 30_000L; private static final int MAX_ENTRIES = 64; + /** Display granularity of the removed-ad duration: one tenth of a second. */ + private static final long TENTHS_PER_SECOND = 10L; + private static final long TENTHS_PER_MINUTE = 60L * TENTHS_PER_SECOND; private static final Map RECENT = new LinkedHashMap<>(); private HlsAdblockNotice() {} @@ -20,4 +24,46 @@ public final class HlsAdblockNotice { while (RECENT.size() > MAX_ENTRIES) RECENT.remove(RECENT.keySet().iterator().next()); return true; } + + /** + * Builds the single user-visible ad-block confirmation used by every HLS channel. + * + *

The notice always reports the number of removed ad segments and, whenever the removed + * duration is known, the total ad time as well. The duration is included for both the + * structured rule path and the legacy fallback path, because a user who only sees a segment + * count cannot tell whether one 2-second bumper or a 3-minute ad break was removed.

+ */ + public static String message(int removedSegments, double removedDurationSec) { + String count = "已跳过 " + Math.max(0, removedSegments) + " 个广告片段"; + String duration = durationText(removedDurationSec); + return duration.isEmpty() ? count : count + ",总广告时长 " + duration; + } + + /** + * Formats a removed-ad duration as seconds below one minute and as minutes at or above it. + * + *

The value is rounded to the tenth-of-a-second display granularity before the unit + * is chosen, exactly like {@link AdBlockTimeFormatter#formatSeconds(double)}: a 59.96-second + * removal is reported as {@code 1 分钟}, never as the self-contradicting {@code 60.0 秒}.

+ * + * @return the text to show, or an empty string when no duration is displayable (zero, negative, + * non-finite, or smaller than the display granularity) so that the caller falls back to + * the segment count instead of showing a misleading {@code 0.0 秒} + */ + public static String durationText(double seconds) { + long tenths = displayableTenths(seconds); + if (tenths == 0L) return ""; + if (tenths < TENTHS_PER_MINUTE) { + return String.format(Locale.US, "%.1f 秒", tenths / (double) TENTHS_PER_SECOND); + } + long total = Math.round(tenths / (double) TENTHS_PER_SECOND); + long minutes = total / 60L; + long remainder = total % 60L; + return remainder == 0L ? minutes + " 分钟" : minutes + " 分 " + remainder + " 秒"; + } + + private static long displayableTenths(double seconds) { + if (!Double.isFinite(seconds) || seconds <= 0d) return 0L; + return Math.round(seconds * TENTHS_PER_SECOND); + } } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/ImgUtil.java b/app/src/main/java/com/fongmi/android/tv/utils/ImgUtil.java index cbf9101e04..f4f00c0132 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/ImgUtil.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/ImgUtil.java @@ -187,13 +187,17 @@ public class ImgUtil { @Override public boolean onLoadFailed(@Nullable GlideException e, Object model, @NonNull Target target, boolean isFirstResource) { failed.add(url); - if (!TextUtils.isEmpty(fallback) && !failed.contains(fallback)) load(text, fallback, view, vod, width, height); + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.IMAGE, TmdbProxy.routeForImageUrl(url)); + String autoFallback = TmdbProxy.nextAutoImageUrl(url); + if (!TextUtils.isEmpty(autoFallback) && !failed.contains(autoFallback)) load(text, autoFallback, view, vod, width, height); + else if (!TextUtils.isEmpty(fallback) && !failed.contains(fallback)) load(text, fallback, view, vod, width, height); else showTextDrawable(text, view, vod, false); return true; } @Override public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { + TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.IMAGE, TmdbProxy.routeForImageUrl(url), 0); return false; } }; @@ -203,13 +207,20 @@ public class ImgUtil { return new RequestListener<>() { @Override public boolean onLoadFailed(@Nullable GlideException e, Object model, @NonNull Target target, boolean isFirstResource) { - showTextDrawable(text, view, vod, false); failed.add(url); + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.IMAGE, TmdbProxy.routeForImageUrl(url)); + String autoFallback = TmdbProxy.nextAutoImageUrl(url); + if (!TextUtils.isEmpty(autoFallback) && !failed.contains(autoFallback)) { + load(text, autoFallback, view, vod, 0, 0); + } else { + showTextDrawable(text, view, vod, false); + } return true; } @Override public boolean onResourceReady(Drawable resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { + TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.IMAGE, TmdbProxy.routeForImageUrl(url), 0); return false; } }; diff --git a/app/src/main/java/com/fongmi/android/tv/utils/Notify.java b/app/src/main/java/com/fongmi/android/tv/utils/Notify.java index 86d88f8a27..64fc07c265 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/Notify.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/Notify.java @@ -15,7 +15,7 @@ import androidx.core.content.ContextCompat; import com.fongmi.android.tv.App; import com.fongmi.android.tv.databinding.ViewProgressBinding; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; public class Notify { @@ -76,7 +76,7 @@ public class Notify { private void create(Context context) { ViewProgressBinding binding = ViewProgressBinding.inflate(LayoutInflater.from(context)); - mDialog = new MaterialAlertDialogBuilder(context).setView(binding.getRoot()).create(); + mDialog = new WebHtvAlertDialogBuilder(context).setView(binding.getRoot()).create(); mDialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent); mDialog.show(); } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/OkGlideModule.java b/app/src/main/java/com/fongmi/android/tv/utils/OkGlideModule.java index cd3ffb9d3b..8993a23038 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/OkGlideModule.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/OkGlideModule.java @@ -11,7 +11,9 @@ import com.bumptech.glide.Registry; import com.bumptech.glide.annotation.GlideModule; import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader; import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory; import com.bumptech.glide.module.AppGlideModule; +import com.fongmi.android.tv.cache.CachePolicyStore; import com.github.catvod.net.OkHttp; import java.io.InputStream; @@ -22,10 +24,13 @@ public class OkGlideModule extends AppGlideModule { @Override public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) { builder.setLogLevel(Log.ERROR); + long limit = CachePolicyStore.getLimit(com.fongmi.android.tv.cache.CacheModuleId.GLIDE); + builder.setDiskCache(new InternalCacheDiskCacheFactory(context, "image_manager_disk_cache", + limit > 0 ? limit : 256L * 1024L * 1024L)); } @Override public void registerComponents(@NonNull Context context, @NonNull Glide glide, Registry registry) { registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory(OkHttp.client())); } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/ResUtil.java b/app/src/main/java/com/fongmi/android/tv/utils/ResUtil.java index 4a135a1253..3851c81abf 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/ResUtil.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/ResUtil.java @@ -18,6 +18,7 @@ import android.view.animation.AnimationUtils; import androidx.annotation.AnimRes; import androidx.annotation.ArrayRes; import androidx.annotation.ColorRes; +import androidx.annotation.DimenRes; import androidx.annotation.DrawableRes; import androidx.annotation.StringRes; import androidx.core.content.ContextCompat; @@ -90,6 +91,20 @@ public class ResUtil { return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getDisplayMetrics()); } + /** + * The dp value a dimension resource resolves to. + * + *

TV row presenters take a dp spacing and convert it themselves + * ({@code CustomRowPresenter} calls {@link #dp2px(int)}), so a call site that must read a + * design token instead of a literal needs the dp value. Deriving it from the resource keeps + * the token authoritative on every density, instead of a second hardcoded number drifting + * away from it. + */ + public static int dpOf(@DimenRes int resId) { + float density = getDisplayMetrics().density; + return density <= 0f ? 0 : Math.round(App.get().getResources().getDimension(resId) / density); + } + public static String getString(@StringRes int resId) { return App.get().getResources().getString(resId); } diff --git a/app/src/main/java/com/fongmi/android/tv/utils/TmdbDetailCache.java b/app/src/main/java/com/fongmi/android/tv/utils/TmdbDetailCache.java index 0a2d35418e..18968e63e9 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/TmdbDetailCache.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/TmdbDetailCache.java @@ -2,13 +2,17 @@ package com.fongmi.android.tv.utils; import android.text.TextUtils; +import com.fongmi.android.tv.bean.TmdbEpisode; import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.bean.TmdbPerson; +import com.github.catvod.crawler.SpiderDebug; import com.google.gson.JsonObject; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -20,18 +24,41 @@ public class TmdbDetailCache { private static final AtomicLong NEXT_ID = new AtomicLong(); private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + public static String put(TmdbItem item, JsonObject detail, List cast, String language) { + return put(item, detail, cast, language, null); + } + public static String put(TmdbItem item, JsonObject detail, List cast) { + return put(item, detail, cast, ""); + } + + /** + * 详情页已经解析好的季集数据随详情快照一起交给播放页。 + * + *

播放页的选集卡片模式由 {@code Episode.tmdbEpisode} 决定,而该字段只有播放页 + * 自己再取一次分季数据才会被写入。详情页在跳转前已经取过同一季(否则它自己也画不出 + * 卡片),让播放页复用这份数据可以消除第二次网络请求,避免请求失败时详情页有卡片、 + * 播放页却退化成纯文本按钮。 + */ + public static String put(TmdbItem item, JsonObject detail, List cast, String language, + Map> seasonEpisodes) { if (item == null || item.getTmdbId() <= 0 || detail == null) return ""; if (CACHE.size() >= MAX_PENDING) CACHE.clear(); String key = "tmdb_detail_" + NEXT_ID.incrementAndGet(); - CACHE.put(key, new Entry(item, detail, cast)); + CACHE.put(key, new Entry(item, detail, cast, TmdbLanguagePolicy.normalize(language), seasonEpisodes)); return key; } public static Entry take(String key, TmdbItem expected) { + return take(key, expected, ""); + } + + public static Entry take(String key, TmdbItem expected, String expectedLanguage) { if (TextUtils.isEmpty(key)) return null; Entry entry = CACHE.remove(key); - if (entry == null || !entry.matches(expected)) return null; + boolean rejected = entry != null && !entry.matches(expected, expectedLanguage); + if (rejected) SpiderDebug.log("tmdb-language-cache", "snapshotLanguage=%s target=%s result=reject", entry.getLanguage(), expectedLanguage); + if (entry == null || rejected) return null; return entry; } @@ -43,11 +70,38 @@ public class TmdbDetailCache { private final TmdbItem item; private final JsonObject detail; private final List cast; + private final String language; + private final Map> seasonEpisodes; - private Entry(TmdbItem item, JsonObject detail, List cast) { + private Entry(TmdbItem item, JsonObject detail, List cast, String language, + Map> seasonEpisodes) { this.item = item; this.detail = detail; this.cast = cast == null ? new ArrayList<>() : new ArrayList<>(cast); + this.language = language == null ? "" : language; + this.seasonEpisodes = copySeasonEpisodes(seasonEpisodes); + } + + /** + * 详情页已解析的季集数据(季号 -> 该季分集),没有时为 null。 + * 与 {@link #getCast()} 一致做防御性拷贝:快照一旦交给播放页就不应再受详情页改动影响。 + */ + public Map> getSeasonEpisodes() { + return copySeasonEpisodes(seasonEpisodes); + } + + private static Map> copySeasonEpisodes(Map> source) { + if (source == null || source.isEmpty()) return null; + Map> copy = new LinkedHashMap<>(); + for (Map.Entry> entry : source.entrySet()) { + if (entry.getKey() == null || entry.getKey() < 0 || entry.getValue() == null || entry.getValue().isEmpty()) continue; + copy.put(entry.getKey(), List.copyOf(entry.getValue())); + } + return copy.isEmpty() ? null : copy; + } + + public String getLanguage() { + return language; } public TmdbItem getItem() { @@ -62,9 +116,10 @@ public class TmdbDetailCache { return new ArrayList<>(cast); } - private boolean matches(TmdbItem expected) { + private boolean matches(TmdbItem expected, String expectedLanguage) { if (expected == null || item == null) return false; - return item.getTmdbId() == expected.getTmdbId() && normalize(item.getMediaType()).equals(normalize(expected.getMediaType())); + if (item.getTmdbId() != expected.getTmdbId() || !normalize(item.getMediaType()).equals(normalize(expected.getMediaType()))) return false; + return TmdbLanguagePolicy.matchesTarget(expectedLanguage, language); } private String normalize(String value) { diff --git a/app/src/main/java/com/fongmi/android/tv/utils/TmdbEpisodeSorter.java b/app/src/main/java/com/fongmi/android/tv/utils/TmdbEpisodeSorter.java index 6ccb90e944..e6dd763b71 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/TmdbEpisodeSorter.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/TmdbEpisodeSorter.java @@ -26,33 +26,39 @@ public final class TmdbEpisodeSorter { } public static void sort(Flag flag) { - if (flag == null || flag.getEpisodes() == null || flag.getEpisodes().size() < 2) return; - List indexed = new ArrayList<>(); - int recognized = 0; - int recognizedWithSeason = 0; - for (int i = 0; i < flag.getEpisodes().size(); i++) { - Episode episode = flag.getEpisodes().get(i); - int season = sourceSeasonNumber(episode); - int number = number(episode); - if (number > 0) { - recognized++; - if (season > 0) recognizedWithSeason++; + if (flag == null || flag.getEpisodes() == null) return; + List episodes = flag.getEpisodes(); + synchronized (episodes) { + if (episodes.size() < 2) return; + List indexed = new ArrayList<>(); + int recognized = 0; + int recognizedWithSeason = 0; + for (int i = 0; i < episodes.size(); i++) { + Episode episode = episodes.get(i); + int season = sourceSeasonNumber(episode); + int number = number(episode); + if (number > 0) { + recognized++; + if (season > 0) recognizedWithSeason++; + } + indexed.add(new IndexedEpisode(episode, season, number, i)); } - indexed.add(new IndexedEpisode(episode, season, number, i)); + if (recognized < 2) return; + // Use one strategy for the whole flag so the comparator remains transitive. + boolean useSeason = recognized == recognizedWithSeason; + Comparator comparator = (left, right) -> compare(left, right, useSeason); + if (isSorted(indexed, comparator)) return; + try { + indexed.sort(comparator); + } catch (IllegalArgumentException e) { + SpiderDebug.log("tmdb-episode-sort", "keep source order after sort failure: %s", e.getMessage()); + return; + } + // Reorder in place instead of clear()+add(). The episode list is shared with the UI; + // structural replacement increments ArrayList.modCount and can invalidate a UI iterator + // while SourceEpisodeSeasonCache resolves the current season on the main thread. + for (int i = 0; i < indexed.size(); i++) episodes.set(i, indexed.get(i).episode()); } - if (recognized < 2) return; - // Use one strategy for the whole flag so the comparator remains transitive. - boolean useSeason = recognized == recognizedWithSeason; - Comparator comparator = (left, right) -> compare(left, right, useSeason); - if (isSorted(indexed, comparator)) return; - try { - indexed.sort(comparator); - } catch (IllegalArgumentException e) { - SpiderDebug.log("tmdb-episode-sort", "keep source order after sort failure: %s", e.getMessage()); - return; - } - flag.getEpisodes().clear(); - for (IndexedEpisode item : indexed) flag.getEpisodes().add(item.episode()); } private static boolean isSorted(List episodes, Comparator comparator) { diff --git a/app/src/main/java/com/fongmi/android/tv/utils/TmdbImageSelector.java b/app/src/main/java/com/fongmi/android/tv/utils/TmdbImageSelector.java index 8754e01322..56510a58b5 100644 --- a/app/src/main/java/com/fongmi/android/tv/utils/TmdbImageSelector.java +++ b/app/src/main/java/com/fongmi/android/tv/utils/TmdbImageSelector.java @@ -90,6 +90,44 @@ public class TmdbImageSelector { return url.replaceFirst("/(w\\d+|h\\d+|original)/", "/original/"); } + /** + * 把 TMDB 图片地址换成官方档位(如 {@code w500});非 TMDB {@code /t/p/} 路径原样返回。 + *

+ * 只使用官方 OAS 声明过的档位:poster {@code w92/w154/w185/w342/w500/w780/original}、 + * backdrop {@code w300/w780/w1280/original}、still {@code w92/w185/w300/original}。 + * 代理包装线路(wsrv/itv666)把原始地址放在 query 里,包含 {@code /t/p/} 时同样可替换; + * 编码过或无法识别时返回原地址,宁可不优化也不破图。 + */ + public static String tierUrl(String url, String size) { + if (isEmpty(url) || isEmpty(size)) return isEmpty(url) ? "" : url; + int marker = url.indexOf("/t/p/"); + if (marker < 0) return url; + int sizeStart = marker + "/t/p/".length(); + int sizeEnd = url.indexOf('/', sizeStart); + if (sizeEnd <= sizeStart) return url; + return url.substring(0, sizeStart) + size.trim() + url.substring(sizeEnd); + } + + /** 详情页 rail 的海报缩略图档位(实测原图 1,187,694B → w500 85,680B)。 */ + public static String posterTier(String url) { + return tierUrl(url, "w500"); + } + + /** 详情页 rail 的图集/剧照缩略图档位(backdrop 官方档位 w780)。 */ + public static String photoTier(String url) { + return tierUrl(url, "w780"); + } + + /** + * 详情页 hero 海报档位(poster 官方档位 {@code w780})。 + *

+ * hero 海报显示尺寸远小于原图,实测 original 1,187,694B / w780 201,063B(−83%), + * 解码后按显示尺寸缩放,画质无可见差异。 + */ + public static String heroPosterTier(String url) { + return tierUrl(url, "w780"); + } + private static void addImages(List candidates, JsonObject detail, String key, String base, Orientation fallback) { JsonArray images = array(detail, "images", key); for (JsonElement element : images) { @@ -118,7 +156,7 @@ public class TmdbImageSelector { private static String image(String base, String path) { if (isEmpty(path)) return ""; String normalizedBase = originalBase(base); - return normalizedBase + (path.startsWith("/") ? path : "/" + path); + return TmdbProxy.imageUrl(normalizedBase, path); } private static String originalBase(String base) { diff --git a/app/src/main/java/com/fongmi/android/tv/utils/TmdbLanguagePolicy.java b/app/src/main/java/com/fongmi/android/tv/utils/TmdbLanguagePolicy.java new file mode 100644 index 0000000000..33e850c305 --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/utils/TmdbLanguagePolicy.java @@ -0,0 +1,155 @@ +package com.fongmi.android.tv.utils; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.fongmi.android.tv.bean.TmdbConfig; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** Pure language policy for TMDB display fields. */ +public final class TmdbLanguagePolicy { + + public static final String DEFAULT_LANGUAGE = "zh-CN"; + + public record TranslatedValue(String value, String language, boolean exact) { + public static final TranslatedValue EMPTY = new TranslatedValue("", "", false); + + public boolean isEmpty() { + return TextUtils.isEmpty(value); + } + } + + private TmdbLanguagePolicy() { + } + + public static String requestLanguage(@Nullable TmdbConfig config) { + return normalize(config == null ? null : config.getLanguage()); + } + + public static String normalize(@Nullable String language) { + String value = language == null ? "" : language.trim(); + if (TextUtils.isEmpty(value)) return DEFAULT_LANGUAGE; + String lower = value.toLowerCase(Locale.ROOT); + if ("zh-hans".equals(lower) || "zh-hans-cn".equals(lower) || "zh-cn".equals(lower) || "zh-sg".equals(lower) || "zh-my".equals(lower)) return "zh-CN"; + if ("zh-hant".equals(lower) || "zh-hant-tw".equals(lower) || "zh-tw".equals(lower) || "zh-hk".equals(lower) || "zh-mo".equals(lower)) return "zh-TW"; + String[] parts = lower.split("[-_]", 2); + if (parts.length == 2) return parts[0] + "-" + parts[1].toUpperCase(Locale.ROOT); + return lower.toUpperCase(Locale.ROOT); + } + + public static String root(@Nullable String language) { + String normalized = normalize(language); + int separator = normalized.indexOf('-'); + return separator > 0 ? normalized.substring(0, separator) : normalized; + } + + public static boolean isChinese(@Nullable String language) { + return "ZH".equals(root(language)); + } + + public static boolean matchesTarget(@Nullable String target, @Nullable String candidateLanguage) { + if (TextUtils.isEmpty(target)) return TextUtils.isEmpty(candidateLanguage); + String expected = normalize(target); + String candidate = normalize(candidateLanguage); + if (TextUtils.isEmpty(candidate)) return false; + // zh and en are root-only languages and may satisfy zh-CN/en-US snapshots. + // Regional variants such as zh-TW must not satisfy zh-CN; they remain a + // lower-priority fallback in bestValue rather than a compatible identity. + return expected.equalsIgnoreCase(candidate) || candidate.equalsIgnoreCase(root(expected)); + } + + public static boolean isDisplayLanguageCompatible(@Nullable String target, @Nullable String payloadLanguage) { + return matchesTarget(target, payloadLanguage); + } + + public static boolean looksTargetLanguage(@Nullable String target, @Nullable String value) { + String text = value == null ? "" : value.trim(); + if (text.isEmpty()) return false; + if (!isChinese(target)) return true; + for (int i = 0; i < text.length(); i++) { + char ch = text.charAt(i); + if (ch >= 0x3400 && ch <= 0x9FFF || ch >= 0xF900 && ch <= 0xFAFF || ch >= 0x20000 && ch <= 0x3FFFF) return true; + } + return false; + } + + public static TranslatedValue bestValue(@Nullable String primary, @Nullable JsonArray translations, + @NonNull String valueKey, @Nullable String targetLanguage) { + String target = normalize(targetLanguage); + Map ranked = new LinkedHashMap<>(); + for (Candidate candidate : translationCandidates(translations, valueKey)) { + int score = score(target, candidate.language()); + if (score >= 0) ranked.putIfAbsent(score, new TranslatedValue(candidate.value(), candidate.language(), score <= 2)); + } + if (!TextUtils.isEmpty(primary)) { + boolean primaryMatches = !isChinese(target) || looksTargetLanguage(target, primary); + int score = primaryMatches ? 0 : 4; + ranked.putIfAbsent(score, new TranslatedValue(primary.trim(), target, primaryMatches)); + } + for (int score = 0; score <= 4; score++) { + TranslatedValue value = ranked.get(score); + if (value != null && !value.isEmpty()) return value; + } + return TranslatedValue.EMPTY; + } + + public static String bestDisplayValue(@Nullable String primary, @Nullable JsonArray translations, + @NonNull String valueKey, @Nullable String targetLanguage) { + return bestValue(primary, translations, valueKey, targetLanguage).value(); + } + + private static List translationCandidates(@Nullable JsonArray translations, String valueKey) { + List result = new ArrayList<>(); + if (translations == null) return result; + for (JsonElement element : translations) { + if (element == null || !element.isJsonObject()) continue; + JsonObject translation = element.getAsJsonObject(); + JsonObject data = translation.has("data") && translation.get("data").isJsonObject() ? translation.getAsJsonObject("data") : null; + String value = string(data, valueKey); + if (TextUtils.isEmpty(value)) continue; + String language = firstNonEmpty(string(translation, "iso_639_1"), string(translation, "language")); + if (TextUtils.isEmpty(language)) continue; + String country = string(translation, "iso_3166_1"); + String full = TextUtils.isEmpty(country) ? language : language + "-" + country; + result.add(new Candidate(value.trim(), full)); + } + return result; + } + + private static int score(String target, String candidate) { + String expected = normalize(target); + String actual = normalize(candidate); + if (expected.equalsIgnoreCase(actual)) return 0; + String expectedRoot = root(expected); + String actualRoot = root(actual); + if (expectedRoot.equalsIgnoreCase(actualRoot)) return 1; + if (isChinese(expected) && isChinese(actual)) return 2; + if ("EN".equalsIgnoreCase(actualRoot)) return 4; + if (!TextUtils.isEmpty(actualRoot)) return 3; + return -1; + } + + private static String firstNonEmpty(String... values) { + for (String value : values) if (!TextUtils.isEmpty(value)) return value; + return ""; + } + + private static String string(JsonObject object, String key) { + if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive() || !object.get(key).getAsJsonPrimitive().isString()) return ""; + String value = object.get(key).getAsString(); + return value == null ? "" : value.trim(); + } + + private record Candidate(String value, String language) { + } +} diff --git a/app/src/main/java/com/fongmi/android/tv/utils/TmdbProxy.java b/app/src/main/java/com/fongmi/android/tv/utils/TmdbProxy.java new file mode 100644 index 0000000000..9d700af3eb --- /dev/null +++ b/app/src/main/java/com/fongmi/android/tv/utils/TmdbProxy.java @@ -0,0 +1,392 @@ +package com.fongmi.android.tv.utils; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +/** TMDB API 与图片线路的地址归一化和内置线路定义。 */ +public final class TmdbProxy { + + public static final String OFFICIAL_API = "https://api.tmdb.org"; + public static final String OFFICIAL_IMAGE = "https://images.tmdb.org"; + public static final String AUTO = "auto"; + /** 实测 API 与 /t/p 图片路径均可用的公共镜像。 */ + public static final String ITV666 = "http://tmdb.itv666.cc"; + /** wsrv.nl 将原始图片地址放在 url 查询参数中。 */ + public static final String WSRV_IMAGE = "https://wsrv.nl/?url=https://image.tmdb.org"; + + private static final String REMOVED_WORKER_POOL = "worker-pool"; + private static final String REMOVED_NASTOOL = "https://tmdb.nastool.org"; + private static final Pattern POOL_SEPARATOR = Pattern.compile("[,,;;\\s]+"); + + private static final List

主动作选「重新扫描」而不是「返回上级」:根目录没有上级(返回会退出页面, + * 与按钮文案不符),而"重新扫描当前目录"在任何目录都成立,且能反映外部变化 + * (刚用别的应用放进来的文件)——这正是本地文件浏览器最需要的能力。 + * + *

重新扫描复用 {@link #update(File)}:它是本页**唯一**的列表填充入口,既重算 + * {@link #list(File)} 又驱动状态机,所以"扫描后仍为空"会正确地停在空态而不是留白。 + */ + private void setEmptyContent() { + mBinding.progressLayout.setEmptyContent( + R.string.file_empty_title, + R.string.file_empty_description, + R.string.file_empty_action, + view -> rescan()); + } + + /** 重新扫描当前目录;尚未拿到目录时(权限回调之前)退回根目录。 */ + private void rescan() { + update(dir == null ? Path.root() : dir); + } + private void setRecyclerView() { mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setAdapter(mAdapter = new FileAdapter(this)); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java index b247575828..1afce9e86c 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java @@ -21,11 +21,11 @@ import com.fongmi.android.tv.databinding.ActivityHistoryBinding; import com.fongmi.android.tv.event.ConfigEvent; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.HistoryAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.SyncDialog; import com.fongmi.android.tv.utils.MobileWindow; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; @@ -53,15 +53,39 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl @Override protected void initView(Bundle savedInstanceState) { setSupportActionBar(mBinding.toolbar); + setEmptyContent(); setRecyclerView(); getHistory(); } + /** + * 空态内容:设计文档 §9.9 要求「标题 + 解释 + 一个主动作」。 + * + *

历史页与收藏页一样,新用户第一次进来时必为空。改动前只有一句 + * {@code error_empty}(「这里什麽都没有 👀」),既不解释为什么空,也不给出下一步。 + */ + private void setEmptyContent() { + mBinding.progressLayout.setEmptyContent( + R.string.history_empty_title, + R.string.history_empty_description, + R.string.history_empty_action, + view -> goHome()); + } + + /** 空态主按钮:回点播首页去挑一部。与收藏页/播放页的回首页写法一致。 */ + private void goHome() { + startActivity(new Intent(this, HomeActivity.class).addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)); + } + private void setRecyclerView() { int column = MobileWindow.isWide(this) ? Product.getColumn(this) : 3; mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setLayoutManager(new GridLayoutManager(this, column)); mBinding.recycler.setAdapter(mAdapter = new HistoryAdapter(this)); + // 列表数量变化后统一驱动状态机(BaseDiffAdapter 的钩子):注册一次,之后无论从哪条 + // 路径改动列表(单条删除、删除全部、刷新)状态都会跟着列表走,宿主不可能再忘。 + mAdapter.setOnCountChanged(count -> mBinding.progressLayout.showContent(true, count)); mBinding.recycler.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { @@ -76,7 +100,6 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl private void getHistory() { mAdapter.setItems(History.getForDisplay(), (hasChange) -> { - mBinding.progressLayout.showContent(true, mAdapter.getItemCount()); if (hasChange) mBinding.recycler.scrollToPosition(0); mBinding.recycler.post(this::updateMarquee); }); @@ -116,7 +139,7 @@ public class HistoryActivity extends BaseActivity implements HistoryAdapter.OnCl private void onDelete() { if (mAdapter.isDelete()) { - new MaterialAlertDialogBuilder(this).setTitle(R.string.dialog_delete_record).setMessage(Setting.isGlobalHistoryEnabled() ? R.string.dialog_delete_global_history : R.string.dialog_delete_history).setNegativeButton(R.string.dialog_negative, null).setPositiveButton(R.string.dialog_positive, (dialog, which) -> mAdapter.clear()).show(); + new WebHtvAlertDialogBuilder(this).setTitle(R.string.dialog_delete_record).setMessage(Setting.isGlobalHistoryEnabled() ? R.string.dialog_delete_global_history : R.string.dialog_delete_history).setNegativeButton(R.string.dialog_negative, null).setPositiveButton(R.string.dialog_positive, (dialog, which) -> mAdapter.clear()).show(); } else if (mAdapter.getItemCount() > 0) { mAdapter.setDelete(true); } 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..102992f67c 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 @@ -69,7 +69,7 @@ import com.google.gson.JsonObject; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; -public class HomeActivity extends BaseActivity implements NavigationBarView.OnItemSelectedListener, WebHomeChromeController.Host { +public class HomeActivity extends BaseActivity implements NavigationBarView.OnItemSelectedListener, WebHomeChromeController.Host, VodFragment.ConfigRetry { public static final String EXTRA_NAV_POSITION = "nav_position"; private static final String STATE_RETURN_VOD_FROM_ENHANCE = "returnVodFromEnhance"; @@ -91,6 +91,7 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); + setIntent(intent); checkAction(intent); } @@ -109,10 +110,12 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt mChrome = new WebHomeChromeController(this, mBinding, this, savedInstanceState, WebHomeChromeStartup.restore(mStartupConfig)); mBinding.getRoot().addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> checkWindowShape(right - left, bottom - top)); mBinding.navigation.setOnItemSelectedListener(this); + setNavigation(); PermissionUtil.requestFile(this, allGranted -> PermissionUtil.requestNotify(this)); FollowingScheduler.ensurePeriodic(this); FollowingScheduler.enqueueDueNow(this); initFragment(savedInstanceState); + if (savedInstanceState == null && getIntent().hasExtra(EXTRA_NAV_POSITION)) checkAction(getIntent()); initConfig(); } @@ -194,6 +197,19 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt WallConfig.get().init(); } + /** + * 首页错误态的重试动作。 + * + *

失败的那一步是这里的**内容源配置加载**({@link #initConfig()}),所以重试必须回到它—— + * 由 VodFragment 通过 {@link VodFragment.ConfigRetry} 回调过来。重试期间先把状态浮层切回 + * 加载态,避免错误态一直停在屏幕上。 + */ + @Override + public void retryConfig() { + StateEvent.progress(); + initConfig(); + } + private Callback getCallback() { return new Callback() { @Override @@ -201,11 +217,29 @@ public class HomeActivity extends BaseActivity implements NavigationBarView.OnIt checkAction(getIntent()); } + /** + * 还没有配置任何内容源(重置应用、重装或恢复出厂后的首次启动)。 + * + *

这不是失败:没有地址可请求、没有失败原因、重试也永远不会成功。改动前 + * {@code BaseConfig} 把它当 {@code error("")} 抛出来,首页便显示「内容源加载失败 / + * 重试」——一个不存在的故障加一个点了没用的按钮。这里改发「未配置」状态: + * 不接管首页,由 {@code VodFragment} 收起状态浮层、保留底部导航;添加接口仍走 + * 既有的「设置 → 点播」管理面板({@code HistoryDialog} 的「添加」)。 + */ + @Override + public void noConfig() { + resetVodChrome(); + checkAction(getIntent()); + StateEvent.noConfig(); + } + @Override public void error(String msg) { resetVodChrome(); checkAction(getIntent()); - StateEvent.empty(); + // 失败要发 ERROR(带原因),不能发 EMPTY:消费方对 EMPTY 的处理只是 + // "隐藏转圈",于是加载失败后首页是一片空白,用户只看到一个会消失的吐司。 + StateEvent.error(msg); Notify.show(msg); } }; diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/KeepActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/KeepActivity.java index d1e645c5e4..5569d89b85 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/KeepActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/KeepActivity.java @@ -22,11 +22,11 @@ import com.fongmi.android.tv.bean.Keep; import com.fongmi.android.tv.databinding.ActivityKeepBinding; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.impl.Callback; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.KeepAdapter; import com.fongmi.android.tv.ui.base.BaseActivity; import com.fongmi.android.tv.ui.dialog.SyncDialog; import com.fongmi.android.tv.utils.Notify; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; @@ -54,14 +54,38 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis @Override protected void initView(Bundle savedInstanceState) { setSupportActionBar(mBinding.toolbar); + setEmptyContent(); setRecyclerView(); getKeep(); } + /** + * 空态内容:设计文档 §9.9 要求「标题 + 解释 + 一个主动作」。 + * + *

收藏页是最典型的空态——用户第一次进来时列表必为空。改动前只有一句 + * {@code error_empty}(「这里什麽都没有 👀」),既不解释为什么空,也不给出下一步。 + */ + private void setEmptyContent() { + mBinding.progressLayout.setEmptyContent( + R.string.keep_empty_title, + R.string.keep_empty_description, + R.string.keep_empty_action, + view -> goHome()); + } + + /** 空态主按钮:回点播首页去挑一部。与 LiveActivity/VideoActivity 的回首页写法一致。 */ + private void goHome() { + startActivity(new Intent(this, HomeActivity.class).addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)); + } + private void setRecyclerView() { mBinding.recycler.setHasFixedSize(true); mBinding.recycler.setLayoutManager(new GridLayoutManager(this, Product.getColumn(this))); mBinding.recycler.setAdapter(mAdapter = new KeepAdapter(this)); + // 列表数量变化后统一驱动状态机(BaseDiffAdapter 的钩子):注册一次,之后无论从哪条 + // 路径改动列表(单条删除、删除全部、刷新)状态都会跟着列表走,宿主不可能再忘。 + mAdapter.setOnCountChanged(count -> mBinding.progressLayout.showContent(true, count)); mBinding.recycler.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { @@ -76,7 +100,6 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis private void getKeep() { mAdapter.setItems(Keep.getVod(), () -> { - mBinding.progressLayout.showContent(true, mAdapter.getItemCount()); mBinding.recycler.post(this::updateMarquee); }); } @@ -115,7 +138,7 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis private void onDelete() { if (mAdapter.isDelete()) { - new MaterialAlertDialogBuilder(this).setTitle(R.string.dialog_delete_record).setMessage(R.string.dialog_delete_keep).setNegativeButton(R.string.dialog_negative, null).setPositiveButton(R.string.dialog_positive, (dialog, which) -> mAdapter.clear()).show(); + new WebHtvAlertDialogBuilder(this).setTitle(R.string.dialog_delete_record).setMessage(R.string.dialog_delete_keep).setNegativeButton(R.string.dialog_negative, null).setPositiveButton(R.string.dialog_positive, (dialog, which) -> mAdapter.clear()).show(); } else if (mAdapter.getItemCount() > 0) { mAdapter.setDelete(true); } @@ -131,6 +154,17 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis @Override public void error(String msg) { Notify.show(msg); + // 设计文档 §9.9:错误态要给出「错误色 + 原因 + 重试动作」。 + // 改动前这里只弹一个会消失的吐司,页面没有任何变化。重试复用同一条加载路径, + // 所以重试成功与首次成功的行为完全一致。 + mBinding.progressLayout.showError( + R.string.error_config_load, + msg, + R.string.error_retry, + view -> { + mBinding.progressLayout.showProgress(); + loadConfig(config, item); + }); } }); } @@ -151,6 +185,8 @@ public class KeepActivity extends BaseActivity implements KeepAdapter.OnClickLis @Override public void onItemDelete(Keep item) { mAdapter.remove(item.delete(), () -> { + // 状态由 BaseDiffAdapter 的 setOnCountChanged 钩子统一驱动,这里不再手写—— + // 「手写」正是此前漏掉删除全部那条路径的原因。 if (mAdapter.getItemCount() == 0) mAdapter.setDelete(false); mBinding.recycler.post(this::updateMarquee); }); 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..ff80d24781 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 @@ -71,9 +71,11 @@ import com.fongmi.android.tv.playback.PlaybackOrientation; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.setting.LiveEpgSetting; import com.fongmi.android.tv.setting.LiveSetting; +import com.fongmi.android.tv.setting.PlayerButtonSetting; 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.theme.PlaybackMenuScrim; import com.fongmi.android.tv.ui.adapter.ChannelAdapter; import com.fongmi.android.tv.ui.helper.PipExitDecision; import com.fongmi.android.tv.ui.adapter.EpgDataAdapter; @@ -106,8 +108,10 @@ import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.util.ArrayList; +import java.util.HashMap; import java.util.Iterator; import java.util.List; +import java.util.Map; public class LiveActivity extends PlaybackActivity implements CustomKeyDown.Listener, TrackDialog.Listener, Biometric.Callback, PassListener, ConfigListener, LiveListener, GroupAdapter.OnClickListener, ChannelAdapter.OnClickListener, EpgDataAdapter.OnClickListener, CastDialog.Listener, InfoDialog.Listener, LiveControlDialog.Listener, LiveEpgDialog.Listener { @@ -128,6 +132,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private PlayerOsdController mOsd; private Result mPendingStartResult; private List mHides; + private Map mActionButtons; private String mPlaybackKey; private String mPendingReloadUrl; private String mPendingReloadMsg; @@ -139,8 +144,21 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private Runnable mEndRetry; private Runnable mBufferingTimeout; private boolean rotate; + /** + * 平板全屏内的方向覆盖:true 表示全屏时请求竖屏。 + * 直播的 {@link #rotate} 语义是「是否全屏」,与点播的「是否竖屏」不同, + * 因此用手动旋转必须用独立状态,避免破坏 {@link #isEmbeddedLiveUi()} 的判定。 + */ + private boolean livePortraitFullscreen; private int count; private boolean mFailedThisSession; + /** + * 本轮播放失败自动换线的起点线路下标,配合 {@link #mLineFallbackExhausted} 使用。 + * -1 表示当前没有进行中的失败回退轮换;自动换线绕回该下标即本频道所有线路都已试过, + * 置位 exhausted 阻止 onError 立即开启新一轮死循环;手动换线/换台/换源或播放成功后重置。 + */ + private int mLineFallbackAnchor = -1; + private boolean mLineFallbackExhausted; private PiP mPiP; private boolean mKeepPlaybackAfterPipExit; private OneShotPreDrawListener pipEntryListener; @@ -269,7 +287,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List mHides = new ArrayList<>(); mR1 = this::hideControl; mR2 = this::setTraffic; - mBufferingTimeout = this::startFlow; + mBufferingTimeout = this::onBufferingTimeout; mR3 = this::hideInfo; mEndRetry = this::checkNext; mPiP = new PiP(); @@ -289,12 +307,14 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List setNavigation(); setViewModel(); applyPadLiveMode(); + setupActionButtons(); 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); + if (Setting.isPlaybackOverlayEnabled()) mBinding.control.bottom.setBackground(PlaybackMenuScrim.create(this)); + else mBinding.control.bottom.setBackgroundResource(R.color.transparent); } @Override @@ -413,6 +433,42 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List mBinding.control.action.player.setText(player().getPlayerText()); } + /** + * 直播动作栏登记点播同名的播放器按钮,使「设置-播放-播放器按钮」的开关与排序对直播同样生效。 + * 直播专属按钮(配置、直播源、线路、反色、跨类)不登记,始终按布局显示。 + */ + private void setupActionButtons() { + mActionButtons = new HashMap<>(); + addActionButton(PlayerButtonSetting.PLAYER, mBinding.control.action.player); + addActionButton(PlayerButtonSetting.DECODE, mBinding.control.action.decode); + addActionButton(PlayerButtonSetting.PLAY_PARAMS, mBinding.control.action.playParams); + addActionButton(PlayerButtonSetting.SPEED, mBinding.control.action.speed); + addActionButton(PlayerButtonSetting.SCALE, mBinding.control.action.scale); + addActionButton(PlayerButtonSetting.CHANGE, mBinding.control.action.change); + addActionButton(PlayerButtonSetting.TEXT, mBinding.control.action.text); + addActionButton(PlayerButtonSetting.AUDIO, mBinding.control.action.audio); + addActionButton(PlayerButtonSetting.VIDEO, mBinding.control.action.video); + PlayerButtonSetting.applyOrder(mBinding.control.action.container, mActionButtons); + } + + private void addActionButton(String id, View view) { + mActionButtons.put(id, view); + } + + /** + * 先恢复无动态条件的按钮,再统一套用用户关闭项。 + * 顺序不能反过来:applyVisibility 只隐藏、不恢复,必须在条件判定之后执行。 + */ + private void applyActionButtonVisibility() { + if (mActionButtons == null) return; + mBinding.control.action.player.setVisibility(View.VISIBLE); + mBinding.control.action.decode.setVisibility(View.VISIBLE); + mBinding.control.action.playParams.setVisibility(View.VISIBLE); + mBinding.control.action.scale.setVisibility(View.VISIBLE); + mBinding.control.action.change.setVisibility(View.VISIBLE); + PlayerButtonSetting.applyVisibility(mActionButtons); + } + private void setScale(int scale) { LiveSetting.putScale(scale); applyLiveResizeMode(scale); @@ -494,14 +550,29 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void getLive() { Live live = getHome(); + setSource(live); if (!live.getGroups().isEmpty()) renderLive(live); mViewModel.parse(live); showProgress(); } + /** + * 控制栏的「直播源」按钮显示当前直播源名称:固定写「直播源」看不出此刻播的是哪一路源, + * 用户换源(含失效自动换源)都会重新走 {@link #getLive()} 刷新。名称为空时回退原文案。 + * 按钮文案被源名取代后读屏会只剩一个孤立的源名,因此同步补回「直播源」动作语义。 + */ + private void setSource(Live live) { + String name = live == null ? "" : live.getName(); + boolean named = !name.isEmpty(); + String label = named ? name : getString(R.string.live_control_source); + mBinding.control.action.source.setText(label); + mBinding.control.action.source.setContentDescription(named ? getString(R.string.live_control_source_named, name) : null); + refreshControlDialog(); + } + private void renderLive(Live live) { if (live == null || live.getGroups().isEmpty()) { - if (LiveSetting.isSourceFallback()) startFlow(); + if (LiveSetting.isSourceFallback()) startSourceFallback(); return; } if (liveMenuRendered) return; @@ -616,8 +687,24 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void onRotate() { Log.i(ORIENTATION_TAG, "rotate clicked embedded=" + isEmbeddedLiveUi() + " " + orientationState()); - if (isEmbeddedLiveUi()) enterFullscreenLive(); - else exitFullscreenLive(); + if (isEmbeddedLiveUi()) { + enterFullscreenLive(); + } else if (ResUtil.isPad()) { + toggleFullscreenOrientation(); + } else { + exitFullscreenLive(); + } + } + + /** + * 平板全屏内的旋转:只切换全屏方向(竖屏全屏↔横屏全屏),不退出全屏, + * 与点播 {@code VideoActivity#onRotate} 的语义保持一致。 + */ + private void toggleFullscreenOrientation() { + livePortraitFullscreen = !livePortraitFullscreen; + Log.i(ORIENTATION_TAG, "rotate fullscreen portrait=" + livePortraitFullscreen + " " + orientationState()); + setR1Callback(); + requestOrientation("rotate-fullscreen-pad", getFullscreenOrient()); } private void onPiP() { @@ -690,6 +777,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } private void exitFullscreenLive() { + livePortraitFullscreen = false; setRotate(false); hideInfo(); hideControl(); @@ -702,7 +790,8 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } private int getFullscreenOrient() { - return ResUtil.isPad() ? ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE : PlaybackOrientation.getEnterFullscreenOrientation(player().isPortrait()); + if (ResUtil.isPad()) return livePortraitFullscreen ? ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT : ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE; + return PlaybackOrientation.getEnterFullscreenOrientation(player().isPortrait()); } private int getEmbeddedOrient() { @@ -957,12 +1046,15 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void showControl() { if (service() == null || isInPictureInPictureMode()) return; + // 与点播一致:每次唤出控制栏都重算轨道类按钮。applyVisibility 只隐藏不恢复, + // 若直播存活期间(如 PiP)在设置里重新开启字幕/音轨/视轨,只有这里能把它们恢复。 + setTrackVisible(); setPlayParamsState(); boolean embedded = isEmbeddedLiveUi(); if (!embedded && isVisible(mBinding.recycler)) hideUI(false); mBinding.control.info.setVisibility(player().isEmpty() ? View.GONE : View.VISIBLE); mBinding.control.cast.setVisibility(View.GONE); - mBinding.control.right.rotate.setVisibility(isLock() || isPadLiveFullscreenMode() ? View.GONE : View.VISIBLE); + mBinding.control.right.rotate.setVisibility(isLock() ? View.GONE : View.VISIBLE); mBinding.control.center.setVisibility(isLock() ? View.GONE : View.VISIBLE); mBinding.control.bottom.setVisibility(isLock() ? View.GONE : View.VISIBLE); mBinding.control.action.getRoot().setVisibility(embedded ? View.GONE : View.VISIBLE); @@ -995,6 +1087,10 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List mBinding.control.action.playParams.setSelected(visible); mBinding.control.osdDiagnostics.setSelected(visible); mBinding.control.osdDiagnostics.setAlpha(visible ? 1f : 0.6f); + // 「播放参数」被播放器按钮设置关闭后,顶栏快捷图标一并隐藏;未关闭时保留一键开关。 + boolean entry = PlayerButtonSetting.isVisible(PlayerButtonSetting.PLAY_PARAMS) && service() != null && !player().isEmpty(); + mBinding.control.osdDiagnostics.setVisibility(entry ? View.VISIBLE : View.GONE); + applyActionButtonVisibility(); } private void showInfo() { @@ -1101,6 +1197,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } else if (mGroup != null) { mGroup.setPosition(mChannelAdapter.setSelected(item.group(mGroup))); mChannel = item; + resetLineFallback(); setArtwork(); showInfo(); hideUI(syncPosition); @@ -1326,11 +1423,14 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } private void handleSameReloadUrl(String msg) { - if (mChannel != null && !mChannel.isOnly()) { - nextLine(true); - } else { - onError(msg); - } + // 同 URL 重载失败等价于该线路播放失败:走带绕圈/耗尽保护的自动换线, + // 不重置本轮回退状态,避免 reload 路径重新开启死循环。 + // 沿用重载路径既有语义(不校验用户的自动换线开关),只补齐保护: + // 换不动(单线路或本轮已绕回起点)时必须给出可见失败反馈, + // 否则 fetch() 刚 showProgress() 的进度条会一直转、错误文案永远不出现。 + if (advanceLineForFallback()) return; + App.removeCallbacks(mBufferingTimeout); + showError(msg); } private void checkControl() { @@ -1470,7 +1570,8 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List @Override protected boolean onSourceHttpError(int statusCode, String msg) { - if (!LiveSetting.isSourceFallback()) return false; + // HTTP 播放错误优先交给直播回退流程:开启自动换线或接口回退任一时短路播放器重试链。 + if (!LiveSetting.isChange() && !LiveSetting.isSourceFallback()) return false; onError(msg); return true; } @@ -1489,6 +1590,17 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List } } + /** + * 缓冲超时(既没出画面也没报错):等价于本条线路播放失败。 + *

+ * 与 onError 不同,本路径不受 mFailedThisSession 限制:上一轮换线后新线路若一直卡在 + * 解析/缓冲阶段,mFailedThisSession 仍为 true,但此时必须仍能继续换线; + * 确实无线路可换(单线路或本轮已绕回起点)时给出可见的失败提示,避免进度条一直转。 + */ + private void onBufferingTimeout() { + if (!startFlow()) showError(ResUtil.getString(R.string.error_play_url)); + } + @Override protected void onReload(String msg) { if (mChannel == null) { @@ -1521,6 +1633,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List checkControl(); player().reset(); mFailedThisSession = false; + resetLineFallback(); break; case Player.STATE_ENDED: checkEnded(); @@ -1594,6 +1707,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List player().reset(); player().clear(); player().stop(); + resetLineFallback(); resetAdapter(); hideControl(); getLive(); @@ -1640,6 +1754,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List 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); mBinding.control.action.speed.setVisibility(player().isVod() ? View.VISIBLE : View.GONE); + applyActionButtonVisibility(); } private MediaMetadata buildMetadata() { @@ -1654,17 +1769,74 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List player().setMetadata(buildMetadata()); } - private void startFlow() { - Live next = LiveSetting.isSourceFallback() ? LiveConfig.getNextHome() : null; - LiveSourceFallbackPolicy.Action action = LiveSourceFallbackPolicy.decide( + /** + * 线路播放失败的自动回退:只在当前频道的线路间轮换(含最后一条绕回), + * 试完一圈即停,绝不因播放失败直接跳到配置里的下一个直播接口。 + * 调用点:onError、缓冲超时(mBufferingTimeout -> onBufferingTimeout)。 + *

+ * 本方法只负责「还能不能再换一条线路」,换不动时返回 false, + * 失败文案由调用方负责显示。 + * + * @return true 表示已换到另一条线路并重新拉流 + */ + private boolean startFlow() { + LiveSourceFallbackPolicy.Action action = LiveSourceFallbackPolicy.decideLineFailure( LiveSetting.isChange(), - LiveSetting.isSourceFallback(), mChannel != null, - mChannel == null || mChannel.isLast(), - mChannel == null || mChannel.isOnly(), + mChannel != null && !mChannel.isOnly()); + return action == LiveSourceFallbackPolicy.Action.NEXT_LINE && advanceLineForFallback(); + } + + /** + * 直播接口节目列表拉取失败(renderLive 空结果)时的回退:只有此时才直接 + * 跳转到配置里的下一个直播接口。 + */ + private void startSourceFallback() { + Live next = LiveConfig.getNextHome(); + LiveSourceFallbackPolicy.Action action = LiveSourceFallbackPolicy.decideSourceFailure( + LiveSetting.isSourceFallback(), next != null); - if (action == LiveSourceFallbackPolicy.Action.NEXT_LINE) nextLine(true); - else if (action == LiveSourceFallbackPolicy.Action.NEXT_SOURCE) setLive(next); + if (action == LiveSourceFallbackPolicy.Action.NEXT_SOURCE) setLive(next); + } + + /** + * 播放失败自动换线:从失败起点开始轮换,绕回起点即本轮所有线路都已试过,置位 + * exhausted 停止后续回退;否则刷新线路信息并重新拉流。 + *

+ * 本方法是唯一改动 mChannel 线路下标的地方,因此 exhausted 短路必须放在这里: + * 无论从 onError、缓冲超时还是同 URL 重载进来,都不可能再开启新一轮 + * A→B→A 死循环(否则 anchor 未清时换线会重新绕回起点并重新拉流)。 + * + * @return true 表示已换到另一条线路并重新拉流;false 表示没有可换的线路 + * (单线路)或本轮线路已绕回起点(回退耗尽),调用方需要自行给出失败反馈。 + */ + private boolean advanceLineForFallback() { + if (isLineFallbackExhausted()) return false; + if (mChannel == null || mChannel.isOnly()) return false; + if (mLineFallbackAnchor < 0) mLineFallbackAnchor = mChannel.getIndex(); + mChannel.switchLine(true); + if (mChannel.getIndex() == mLineFallbackAnchor) { + mLineFallbackExhausted = true; + return false; + } + showInfo(); + fetch(); + return true; + } + + /** + * 播放失败回退已耗尽(本轮所有线路都试过且都失败)时不再自动换线,避免死循环。 + */ + private boolean isLineFallbackExhausted() { + return mLineFallbackExhausted; + } + + /** + * 手动换线、换台、换直播接口或播放成功后,重置失败回退轮换状态。 + */ + private void resetLineFallback() { + mLineFallbackAnchor = -1; + mLineFallbackExhausted = false; } private boolean prevGroup() { @@ -1720,6 +1892,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void nextLine(boolean show) { if (mChannel == null || mChannel.isOnly()) return; + resetLineFallback(); mChannel.switchLine(true); if (show) showInfo(); else setInfo(); @@ -1729,6 +1902,7 @@ public class LiveActivity extends PlaybackActivity implements CustomKeyDown.List private void setLine(int position) { if (mChannel == null || position < 0 || position >= mChannel.getUrls().size()) return; if (mChannel.getIndex() == position) return; + resetLineFallback(); mChannel.setIndex(position); setInfo(); fetch(); 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..fc9b39d597 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 @@ -16,6 +16,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.os.Parcelable; +import android.os.SystemClock; import android.provider.Settings; import android.text.TextUtils; import android.text.style.ClickableSpan; @@ -73,6 +74,8 @@ import com.fongmi.android.tv.bean.Danmaku; import com.fongmi.android.tv.bean.Episode; import com.fongmi.android.tv.bean.EpisodePositionCache; import com.fongmi.android.tv.bean.Flag; +import com.fongmi.android.tv.bean.FlagPreferenceCache; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.helper.EpisodeSeasonSnapshot; import com.fongmi.android.tv.bean.History; import com.fongmi.android.tv.bean.Keep; @@ -125,9 +128,11 @@ 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.FlagSwitchPlaybackPolicy; 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.theme.PlaybackMenuScrim; import com.fongmi.android.tv.setting.SiteHealthStore; import com.fongmi.android.tv.setting.DetailRuntimeModePolicy; import com.fongmi.android.tv.setting.TmdbSitePolicy; @@ -186,11 +191,13 @@ 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.BatteryUtil; import com.fongmi.android.tv.utils.Clock; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.ImgUtil; import com.fongmi.android.tv.utils.EpisodeHistoryTitleResolver; import com.fongmi.android.tv.utils.EpisodeTitleFormatter; +import com.fongmi.android.tv.utils.Formatters; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PiP; import com.fongmi.android.tv.utils.PushParser; @@ -209,6 +216,7 @@ import com.google.gson.JsonObject; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.io.File; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -258,6 +266,14 @@ import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; public class VideoActivity extends PlaybackActivity implements Clock.Callback, CustomKeyDown.Listener, TrackDialog.Listener, ControlDialog.Listener, DanmakuDialog.Host, FlagAdapter.OnClickListener, EpisodeAdapter.OnClickListener, EpisodeGroupAdapter.OnClickListener, QualityAdapter.OnClickListener, QuickAdapter.OnClickListener, ParseAdapter.OnClickListener, CastDialog.Listener, InfoDialog.Listener, SubtitlePlaybackSession.Host, com.fongmi.android.tv.ui.novel.NovelReaderHost { + /** + * 用户拖拽 seek 时加载圈的最小可见时长。 + * + *

必须盖过一个完整的网速采样间隔({@code setTraffic} 每秒一跳),因为读数是由 + * 相邻两次采样的字节差算出的:窗口短于一跳就永远只能拿到没有间隔的首跳(空白)。 + * 取 1.2s 留出余量,避免收圈与首跳采样同帧竞争。 + */ + private static final long SEEK_PROGRESS_MIN_VISIBLE_MS = 1200L; private static final long LYRICS_OFFSET_MIN_MS = -5000L; private static final long LYRICS_OFFSET_MAX_MS = 5000L; private static final long LYRICS_OFFSET_STEP_MS = 500L; @@ -297,6 +313,8 @@ private KaraokeResult mPendingKaraokeResult; private AlertDialog mKaraokeResultDialog; private boolean mSuppressKaraokeResultAction; private boolean mRestoringConfigurationPlayback; +private boolean mProgrammaticFlagSwitch; +private boolean mDeferFlagSwitchPlayback; private boolean mSkipKaraokeTrackAutoLoad; private BottomSheetDialog mLyricsResultDialog; private BottomSheetDialog mAudioQueueDialog; @@ -439,6 +457,7 @@ private int mAudioBackgroundRandomNonce; private boolean revealManualSearch; private boolean useParse; private boolean rotate; + private boolean manualRotate; private boolean detailHealthRecorded; private boolean playHealthRecorded; private boolean mNativePersonalTmdbLoading; @@ -457,6 +476,8 @@ private int mAudioBackgroundRandomNonce; private Runnable mR3; private Runnable mR4; private Runnable mSeekProgressFallback; + private boolean mSeekProgressPending; + private long mSeekProgressStartedAtMs; private Runnable mTmdbDetailTimeout; private Clock mClock; private MpvPlayer mDiscMenuPlayer; @@ -740,6 +761,11 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco start(activity, key, id, name, pic, mark, false, tmdbItem); } + /** 追更页通过 flavor 专用入口调用;普通历史记录仍使用原有模式路由。 */ + public static void startFromFollowingHistory(Activity activity, History item) { + startFromHistory(activity, item); + } + public static void startFromHistory(Activity activity, History item) { if (shouldOpenLegacyTmdbDetail(item.getSiteKey(), item.getVodId())) { TmdbDetailActivity.startFromHistory(activity, item); @@ -1157,6 +1183,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco ? com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, index) : mTmdbUIAdapter == null ? "" : mTmdbUIAdapter.activeFlagKey(flag); mHistory.setSourceBindingKey(flagKey); + syncHistory(); } private Flag resolveHistoryPlaybackFlag(List flags) { @@ -1164,11 +1191,75 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco String flagKey = mHistory == null ? "" : mHistory.getSourceBindingKey(); String flagName = mHistory == null ? "" : mHistory.getVodFlag(); String episodeUrl = mHistory == null ? "" : mHistory.getEpisodeUrl(); + // 用户最后一次显式切换的线路优先于 History 里的线路快照:History.vodFlag 只是 + // 「上次起播时的线路」的副产物,切线路而没重新起播时它不会更新,杀进程后若还 + // 以它为准就会退回旧线路甚至第一条。偏好同样要能定位到这次要播的集, + // 否则会劫持跨源解算出的、确实含目标集的那条线路。 + Flag preferred = findPreferredFlag(flags, episodeUrl); + if (preferred != null) return preferred; Flag selected = com.fongmi.android.tv.ui.helper.TmdbUIAdapter.selectPlaybackFlag( flags, flagKey, episodeUrl, flagName); return selected == null ? flags.get(0) : selected; } + /** + * 记住用户显式切换的线路。线路选择原本只作为播放副产物写进 History,而切线路后 + * 不一定重新起播(seamless 没切集时不会走 onRefresh),mHistory 的 vodFlag/ + * sourceBindingKey 仍指向旧线路,杀进程后就退回了旧线路。这里与详情页保持一致, + * 切一下就落盘偏好,不依赖播放进度。 + */ + private void rememberFlagSelection(Flag flag) { + if (flag == null) return; + int index = mFlagAdapter == null ? -1 : mFlagAdapter.indexOf(flag); + if (index < 0 && mVod != null) index = com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagIndex(mVod.getFlags(), flag); + // 索引未知时不写稳定键:Flag.stableKey 会把 -1 夹成 0,留下一个必然命中首条线路的假键。 + String stableKey = index >= 0 ? com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, index) : ""; + FlagPreferenceCache.get().put(getKey(), getId(), stableKey, flag.getFlag()); + Task.execute(() -> FlagPreferenceCache.get().save()); + if (mHistory == null) return; + // History 里的 sourceBindingKey 与既有 setHistoryFlag 保持一致:稳定键不可用时退化到适配器当前键。 + String historyKey = TextUtils.isEmpty(stableKey) + ? (mTmdbUIAdapter == null ? "" : mTmdbUIAdapter.activeFlagKey(flag)) + : stableKey; + mHistory.setVodFlag(flag.getFlag()); + if (!TextUtils.isEmpty(historyKey)) mHistory.setSourceBindingKey(historyKey); + // 还没起播(无进度)时不写库,避免在「最近观看」里留下一条没有进度的空记录。 + if (mHistory.canSave()) syncHistory(); + } + + /** + * 读取上次显式选中的线路。稳定键(线路名#索引)优先,源站线路增删导致索引漂移时 + * 退化用线路名匹配。episodeUrl 非空时要求该线路确实含这一集。 + */ + private Flag findPreferredFlag(List flags, String episodeUrl) { + FlagPreferenceCache.FlagPreference preference = FlagPreferenceCache.get().get(getKey(), getId()); + if (preference == null) return null; + Flag candidate = null; + String stableKey = preference.getStableKey(); + if (!TextUtils.isEmpty(stableKey)) { + for (int i = 0; i < flags.size(); i++) { + Flag flag = flags.get(i); + if (flag != null && TextUtils.equals(stableKey, com.fongmi.android.tv.ui.helper.TmdbUIAdapter.flagKey(flag, i))) { + candidate = flag; + break; + } + } + } + if (candidate == null) { + String flagName = preference.getFlagName(); + if (TextUtils.isEmpty(flagName)) return null; + for (Flag flag : flags) { + if (flag != null && TextUtils.equals(flagName, flag.getFlag())) { + candidate = flag; + break; + } + } + } + if (candidate == null) return null; + if (TextUtils.isEmpty(episodeUrl) || candidate.containsEpisodeUrl(Episode.create("", episodeUrl))) return candidate; + return null; + } + private Episode getEpisode() { Flag flag = getFlag(); if (flag != null) { @@ -1297,7 +1388,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco tmdbHistoryResumePending = true; } if (!targetFlag.isSelected() || !targetEpisode.isSelected()) { + mProgrammaticFlagSwitch = true; onItemClick(targetFlag); + mProgrammaticFlagSwitch = false; return; } @@ -1544,7 +1637,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void setupIntroSkipConfirmListener() { mIntroSkipPlayback.setSkipConfirmListener((segment, action) -> { if (mIntroSkipConfirmDialog != null && mIntroSkipConfirmDialog.isShowing()) return false; - mIntroSkipConfirmDialog = new com.google.android.material.dialog.MaterialAlertDialogBuilder(this) + mIntroSkipConfirmDialog = new WebHtvAlertDialogBuilder(this) .setTitle(R.string.intro_skip_confirm_title) .setMessage(IntroSkipKinds.confirmMessage(segment)) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) @@ -1614,6 +1707,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.action.karaoke.setOnClickListener(view -> onKaraokeMode()); mBinding.control.action.speed.setOnClickListener(guarded(this::onSpeed)); mBinding.control.action.reset.setOnClickListener(guarded(this::onReset)); + // 错误层的重试复用控制层同一条「重做本次播放」动作:onReset() 会按用户的「重置」 + // 设置决定从头重播还是重新拉流,所以不新写播放路径,也就不会与既有的自动换源 + // 产生第二套恢复逻辑。重试成功后的收尾由 showProgress() 负责隐藏错误层。 + mBinding.widget.errorRetry.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 -> { @@ -1632,6 +1729,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.action.next.setOnClickListener(view -> checkNext()); mBinding.control.action.decode.setOnClickListener(guarded(this::onDecode)); 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.ending.setOnClickListener(guarded(this::onEnding)); mBinding.control.action.repeat.setOnClickListener(guarded(this::onRepeat)); mBinding.control.action.opening.setOnClickListener(guarded(this::onOpening)); @@ -2204,7 +2303,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco * 没有记录时 getPlayerOrDefault 会退回设置页的全局默认。 * 播放服务还没连上时先只记会话内核(取址在工作线程上读它),引擎由 onServiceConnected 补齐。 */ - private int applyHistoryPlayerKernel() { + private int applyHistoryPlayerKernel(boolean forcePrepare) { int kernel = mHistory == null ? PlayerSetting.getPlayer() : mHistory.getPlayerOrDefault(); PlayerSetting.putActivePlayer(kernel); if (service() == null) { @@ -2212,7 +2311,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return kernel; } mPendingPlayerKernel = PlayerSetting.NONE; - player().preparePlayer(kernel); + player().preparePlayer(kernel, forcePrepare); // preparePlayer() is intentionally allowed before playback ownership // is established; keep the mobile seek view on the replacement player. getSeekView().setProgressPlayer(player().getPlayer()); @@ -2221,6 +2320,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return kernel; } + private int applyHistoryPlayerKernel() { + return applyHistoryPlayerKernel(false); + } + /** * 服务连上后补齐本页在等的内核。 * 服务可能是上一次播放留活下来的,它建 PlayerManager 时读到的还是上一部剧的内核, @@ -3138,13 +3241,18 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco SpiderDebug.log("playback-action", "flag switch ui=mobile site=%s from=%s to=%s fullscreen=%s", getKey(), previous == null ? "" : previous.getFlag(), resolved.getFlag(), isFullscreen()); mFlagAdapter.setSelected(resolved); if (mTmdbUIAdapter != null) mTmdbUIAdapter.setActiveFlag(resolved); + rememberFlagSelection(resolved); scrollToPosition(mBinding.flag, mFlagAdapter.getPosition()); + boolean deferFlagSwitch = FlagSwitchPlaybackPolicy.shouldDeferPlayback(PlayerSetting.isDeferFlagSwitchPlayback(), mProgrammaticFlagSwitch); + mDeferFlagSwitchPlayback = deferFlagSwitch && !initialBinding; boolean episodeChanged = seamless(resolved); if (!episodeChanged) setEpisodeAdapter(resolved.getEpisodes()); scrollEpisodeToSelected(); setQualityVisible(false); if (initialBinding && !episodeChanged) onRefresh(); + else if (deferFlagSwitch && !episodeChanged) onRefresh(); loadTmdbRelatedVideosForCurrentEpisode(); + mDeferFlagSwitchPlayback = false; } @Override @@ -3499,7 +3607,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void onQuality() { Result result = mViewModel.getPlayer().getValue(); if (result == null || !result.getUrl().isMulti()) return; - new com.google.android.material.dialog.MaterialAlertDialogBuilder(this) + new WebHtvAlertDialogBuilder(this) .setTitle(R.string.detail_quality) .setSingleChoiceItems(getQualityItems(result), result.getUrl().getPosition(), (dialog, which) -> { dialog.dismiss(); @@ -3795,6 +3903,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void onRotate() { setR1Callback(); setRotate(!isRotate()); + // 用户显式选择的方向要压过 checkOrientation 的自动纠正:切下一集时新视频的尺寸回调 + // 会再次进来,否则「横屏视频手动转竖屏」会被当成过时状态立刻改回横屏。 + manualRotate = true; setRequestedOrientation(PlaybackOrientation.getRotateOrientation(isRotate())); } @@ -4798,7 +4909,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco if (result == null || isFinishing() || isDestroyed()) return; if (mKaraokeResultDialog != null && mKaraokeResultDialog.isShowing()) return; KaraokeResultView view = new KaraokeResultView(this).setResult(result); - AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_WebHTV_LightDialog).setView(view).create(); + AlertDialog dialog = new WebHtvAlertDialogBuilder(this, R.style.ThemeOverlay_WebHTV_Dialog).setView(view).create(); view.setAction(() -> { dialog.dismiss(); completeKaraokeResult(action); @@ -5014,8 +5125,17 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void onRefresh() { - invalidateShortDramaQueue("refresh"); saveHistory(); + // 「切换线路不自动播放」:线路点击只换线路/选集列表,旧流继续播。 + // 抑制必须先于拆流,否则用户还没点具体集数,正在看的视频就先被停掉了; + // 但仍在途的旧线路取流结果必须就地作废,否则它稍后返回会替用户自动起播旧线路。 + if (mDeferFlagSwitchPlayback) { + mDeferFlagSwitchPlayback = false; + if (mViewModel != null) mViewModel.cancelPlayerContent(); + invalidatePlayerContent(); + return; + } + invalidateShortDramaQueue("refresh"); if (mViewModel != null) mViewModel.cancelPlayerContent(); invalidatePlayerContent(); subtitlePlaybackSession.stop(this); @@ -5185,7 +5305,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco String[] items = new String[kernel.length + 1]; System.arraycopy(kernel, 0, items, 0, kernel.length); items[kernel.length] = getString(R.string.player_kernel_external); - new com.google.android.material.dialog.MaterialAlertDialogBuilder(this).setItems(items, (dialog, index) -> { + new WebHtvAlertDialogBuilder(this).setItems(items, (dialog, index) -> { if (index < kernel.length) { int which = PlayerSetting.kernelAt(index); if (!refreshAndSwitchPlayerKernel(which)) { @@ -5309,6 +5429,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.title.setVisibility(View.VISIBLE); setSizeText(); setRotate(current.isPortrait()); + // 重新进全屏就按当前视频形态重建自动基线,手动覆盖到此为止,避免 rotate 与标记互相矛盾。 + manualRotate = false; mKeyDown.resetScale(); // 短剧会话退出全屏后再双击/点全屏按钮进来不走 enterShortDramaFullscreen, // 这里也要按新形态重算,否则回到全屏时手势还是长视频那套轴向。 @@ -5351,6 +5473,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco syncShortDramaGesture(); App.post(mR3, 2000); setRotate(false); + // 退出全屏即结束本次手动覆盖,下次进全屏按视频形态重新建立自动基线。 + manualRotate = false; hideControl(); logVideoFrame("exitFullscreen after"); } @@ -5415,7 +5539,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco return; } - if (mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback); + // seek 窗口开着时不能摘掉它的最小可见计时器:那次 showProgress 属于 seek 自身 + // (BUFFERING 分支),计时器是窗口唯一的收尾路径。 + if (!mSeekProgressPending && mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback); mBinding.progress.getRoot().setVisibility(View.VISIBLE); if (mVod == null && shouldLoadTmdbDetail() && !mTmdbContentLoaded && !shouldRevealShellWhileLoading()) mBinding.progressLayout.showProgress(); else if (mVod != null && !mBinding.progressLayout.isContent()) mBinding.progressLayout.showContent(); @@ -5424,6 +5550,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void hideProgress() { + // 任何显式收圈都同时关闭 seek 窗口,避免窗口比圈活得更久。 + mSeekProgressPending = false; + mSeekProgressStartedAtMs = 0; if (mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback); mBinding.progress.getRoot().setVisibility(View.GONE); App.removeCallbacks(mR2); @@ -5459,13 +5588,13 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void showError(String text) { mBinding.widget.error.setVisibility(View.VISIBLE); - mBinding.widget.error.setText(text); + mBinding.widget.errorText.setText(text); hideProgress(); } private void hideError() { mBinding.widget.error.setVisibility(View.GONE); - mBinding.widget.error.setText(""); + mBinding.widget.errorText.setText(""); } private void showDanmaku() { @@ -5502,6 +5631,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco mBinding.control.fullscreen.setVisibility(isLock() || shortDrama ? View.GONE : View.VISIBLE); mBinding.control.keep.setVisibility(mHistory == null ? View.GONE : View.VISIBLE); mBinding.control.nightMode.setVisibility(mHistory == null ? View.GONE : View.VISIBLE); + // 电池+时间与沉浸融合模式内联播放器同规则:全屏、未锁定且已开始播放才显示。 + mBinding.control.batteryInfo.setVisibility(isFullscreen() && !isLock() && mHistory != null && !player().isEmpty() ? View.VISIBLE : View.GONE); + updateBatteryInfo(); boolean showPlayParams = PlayerButtonSetting.isVisible(PlayerButtonSetting.PLAY_PARAMS); mBinding.control.action.playParams.setVisibility(showPlayParams ? View.VISIBLE : View.GONE); mBinding.control.action.playParams.setSelected(mOsd != null && mOsd.isDiagnosticsVisible()); @@ -5605,6 +5737,22 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco App.post(mR2, 1000); } + /** + * 刷新控制栏的电池+时间块(与沉浸融合模式 TmdbDetailActivity.updateMobileInlineControlStatus + * 同源:都走 BatteryUtil 与 Formatters.TIME)。容器隐藏时直接返回,避免无谓地注册 + * ACTION_BATTERY_CHANGED 粘性广播;电量读不到(level < 0)时整条隐藏,不显示错误档位。 + */ + private void updateBatteryInfo() { + if (!isVisible(mBinding.control.batteryInfo)) return; + mBinding.control.batteryTime.setText(LocalDateTime.now().format(Formatters.TIME)); + int level = BatteryUtil.getLevel(this); + if (level < 0) { + mBinding.control.batteryInfo.setVisibility(View.GONE); + return; + } + mBinding.control.battery.setImageResource(BatteryUtil.getIcon(level)); + } + private void setOrient() { if (isPort() && isAutoRotate()) setRequestedOrientation(PlaybackOrientation.getPortAutoRotateOrientation()); if (isLand() && isAutoRotate()) setRequestedOrientation(PlaybackOrientation.getLandAutoRotateOrientation()); @@ -5766,7 +5914,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco applyImmersiveAudioSelection(mImmersiveAudioResolved); if (mHistory.isRevSort()) reverseEpisode(true); } else { + mProgrammaticFlagSwitch = true; onItemClick(resolveHistoryPlaybackFlag(item.getFlags())); + mProgrammaticFlagSwitch = false; if (mHistory.isRevSort()) reverseEpisode(true); } if (preservePlayback) SpiderDebug.log("karaoke-result", "configuration restore preserved playback key=%s episode=%s", player().getKey(), mHistory.getVodRemarks()); @@ -5936,8 +6086,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco boolean crossSource = mHistory.isCrossSourcePlayback(); boolean shareEpisodeProgress = crossSource || isResumeFromHistory() || Setting.isHistoryAggregationEffective(); boolean compatibleFlag = shareEpisodeProgress || TextUtils.equals(mHistory.getVodFlag(), flag.getFlag()); + // 历史集 URL 能定位到当前线路条目(同集多版本并存)时才启用版本消歧;换线路/刷新保留集号容错。 + boolean versionAware = flag.containsEpisodeUrl(mHistory.getEpisode()); boolean sameEpisode = episode != null && (shareEpisodeProgress - ? historyEpisode.matchesPlayback(mHistory.getEpisode()) + ? historyEpisode.matchesPlayback(mHistory.getEpisode(), versionAware) : episode.matches(mHistory.getEpisode())); if (!compatibleFlag || (episode != null && !sameEpisode)) { mHistory.setPosition(C.TIME_UNSET); @@ -6008,7 +6160,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco private void updateHistory(Episode item) { // 换线路或源站刷新时同一集的 URL、集名格式可能变化,统一按播放恢复规则识别。 Episode historyEpisode = withSourceSeasonEpisodeIdentity(item); - boolean sameEpisode = historyEpisode.matchesPlayback(mHistory.getEpisode()); + // 历史里那集的 URL 仍能定位到当前线路条目时(同集多版本并存)才启用版本消歧; + // 换线路/换源/源站刷新后 URL 必然失配,必须保留集号容错,否则跨线路续播会丢失进度。 + boolean versionAware = getFlag().containsEpisodeUrl(mHistory.getEpisode()); + boolean sameEpisode = historyEpisode.matchesPlayback(mHistory.getEpisode(), versionAware); boolean sameFlag = TextUtils.equals(mHistory.getVodFlag(), getFlag().getFlag()); if (!sameEpisode || !sameFlag) mIntroSkipPlayback.reset(); if ((!sameEpisode || !sameFlag) && service() != null) { @@ -6107,9 +6262,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco FollowingPlaybackBridge.findAsync(identityKey, existing -> { if (isFinishing() || isDestroyed()) return; if (existing != null) { - followingActionPending = false; - mBinding.following.setEnabled(true); - FollowingActivity.start(this, existing.identityKey); + // 播放页保持原地:已追更时再次点击即刻取消,绝不跳转追更页打断播放。 + cancelFollowing(identityKey); return; } Following item = FollowingPlaybackBridge.build(mHistory, currentSourceSeasonNumber()); @@ -6130,6 +6284,23 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco }); } + /** 播放页取消追更:写墓碑后立即刷新按钮状态,不离开当前播放页。 */ + private void cancelFollowing(String identityKey) { + FollowingScheduler.cancelNext(this, identityKey); + FollowingPlaybackBridge.deleteAsync(identityKey, error -> { + followingActionPending = false; + if (isFinishing() || isDestroyed()) return; + if (error != null) { + mBinding.following.setEnabled(true); + Notify.show(error.getMessage()); + return; + } + updateFollowingState(); + FollowingPlaybackBridge.refreshUnreadCountAsync(null); + Notify.show(R.string.following_canceled); + }); + } + private void updateFollowingState() { boolean eligible = FollowingSettings.isEnabled() && FollowingPlaybackBridge.isEligible(mHistory); int generation = ++followingUiGeneration; @@ -6423,7 +6594,7 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override protected void onControllerReadyReconciled() { - showPlaybackContent(); + if (canHideSeekProgress()) showPlaybackContent(); } @Override @@ -7603,6 +7774,10 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco player().resetTrack(); player().reset(); player().stop(); + // Automatic line fallback continues in the same failed playback session. + // Keep the remembered kernel, but recreate its engine so the next line cannot + // inherit a decoder/Surface failure that audio-only playback can survive. + applyHistoryPlayerKernel(true); showError(msg); startFlow(); } @@ -7637,8 +7812,11 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco showProgress(); break; case Player.STATE_READY: + // seek 窗口未关时不能因为一次 READY 读数就收圈:引擎在 seek 真正生效前 + // 仍可能宣称 READY,那正是「拖拽后先卡画面」的那一帧。圈已由 onSeekStarted + // 亮起,这里不收它就仍在屏上,由最小可见计时器与网速 ticker 收尾。 + if (canHideSeekProgress()) showPlaybackContent(); if (mPendingKaraokeResult == null) mKaraokeResultShown = false; - showPlaybackContent(); boolean pendingResumeSeekApplied = applyPendingResumeSeek(); checkControl(); refreshLyrics(); @@ -7698,8 +7876,32 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco logVideoFrame("onSurfaceAttached after"); } + /** + * 判断当前能否收掉 seek 期间挂起的加载圈。 + * + *

拖拽进度时 {@code onSeekStarted()} 先亮圈,随后才把 seek 交给控制器。这中间引擎仍 + * 报告 READY,于是挂在网速 ticker 上的兜底收口会在 seek 真正生效前把圈收掉——现象就是 + * 「拖拽后大概率看不到加载中/网速」。窗口打开期间必须先满过最小可见时长,且播放器确实已 + * READY 且不在真实加载阻塞中,才允许收口。窗口没关时圈就一直留着,由每秒一跳的网速 + * ticker 继续重判,不会自己冒出重试循环。 + */ + private boolean canHideSeekProgress() { + if (!mSeekProgressPending) return true; + if (SystemClock.elapsedRealtime() - mSeekProgressStartedAtMs < SEEK_PROGRESS_MIN_VISIBLE_MS) return false; + return service() != null + && player() != null + && !player().isReleased() + && !player().isEmpty() + && player().getPlaybackState() == Player.STATE_READY + && (!player().isLoading() || player().isPlaying()); + } + private void hideSeekProgressIfReady() { - if (service() == null || player() == null || player().isReleased() || player().isEmpty() || !isOwner() || player().getPlaybackState() != Player.STATE_READY) return; + if (!mSeekProgressPending) return; + if (service() == null || player() == null || player().isReleased() || player().isEmpty()) return; + if (!isOwner()) return; + if (!canHideSeekProgress()) return; + mSeekProgressPending = false; showPlaybackContent(); } @@ -7717,15 +7919,20 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco if (mBinding.progress.getRoot().getVisibility() != View.VISIBLE) return; if (service() == null || player() == null || player().isReleased() || player().isEmpty()) return; if (!isOwner()) return; + if (!canHideSeekProgress()) return; if (player().getPlaybackState() != Player.STATE_READY) return; showPlaybackContent(); } @Override protected void onSeekStarted() { - showProgress(); App.removeCallbacks(mSeekProgressFallback); - App.post(mSeekProgressFallback, 500); + // 窗口必须在 showProgress() 之前打开:它内部会立刻投递网速 ticker, + // 那个 ticker 的兜底收口会读到尚未开始 seek 的 READY。 + mSeekProgressPending = true; + mSeekProgressStartedAtMs = SystemClock.elapsedRealtime(); + showProgress(); + App.post(mSeekProgressFallback, SEEK_PROGRESS_MIN_VISIBLE_MS); } @Override @@ -7741,6 +7948,8 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco @Override public void onTimeChanged(long time) { android.util.Log.d("VideoActivity", "onTimeChanged: isOwner=" + isOwner() + " mHistory=" + (mHistory != null)); + // 控制栏可见时每秒刷新一次电量与时间(与沉浸融合模式刷新内联时间的节奏一致)。 + if (isVisible(mBinding.control.getRoot())) updateBatteryInfo(); if (!isOwner() || mHistory == null) return; long position, duration; mHistory.setCreateTime(time); @@ -7990,6 +8199,9 @@ private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.reco } private void checkOrientation() { + // 用户点过旋转按钮之后方向由他决定:这里只负责在用户没表态时按视频宽高自动纠正。 + // 否则全屏下把横屏视频手动转成竖屏,切下一集的尺寸回调会立刻把方向改回去。 + if (manualRotate) return; if (isFullscreen() && !isRotate() && player().isPortrait()) { setRequestedOrientation(PlaybackOrientation.getPortraitVideoSizeOrientation()); setRotate(true); @@ -8176,7 +8388,9 @@ private void checkOrientation() { private void nextFlag(int position) { Flag flag = mFlagAdapter.get(position + 1); Notify.show(getString(R.string.play_switch_flag, flag.getFlag())); + mProgrammaticFlagSwitch = true; onItemClick(flag); + mProgrammaticFlagSwitch = false; } private void nextSite() { @@ -11252,7 +11466,8 @@ private void dismissKaraokeResultDialogForRecreation() { private void applyPlaybackOverlay() { mBinding.control.getRoot().setBackgroundResource(R.color.transparent); - mBinding.control.bottom.setBackgroundResource(Setting.isPlaybackOverlayEnabled() ? R.drawable.shape_controller_scrim : R.color.transparent); + if (Setting.isPlaybackOverlayEnabled()) mBinding.control.bottom.setBackground(PlaybackMenuScrim.create(this)); + else mBinding.control.bottom.setBackgroundResource(R.color.transparent); } } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/WebHomeChromeController.java b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/WebHomeChromeController.java index afd22caf92..14008cef9a 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/activity/WebHomeChromeController.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/activity/WebHomeChromeController.java @@ -1,6 +1,5 @@ package com.fongmi.android.tv.ui.activity; -import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; import android.view.View; @@ -17,6 +16,7 @@ import androidx.core.view.WindowInsetsControllerCompat; import com.fongmi.android.tv.bean.Site; import com.fongmi.android.tv.databinding.ActivityHomeBinding; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.utils.Util; import com.fongmi.android.tv.web.WebHomeChrome; import com.fongmi.android.tv.web.WebHomeChromeOptions; @@ -222,7 +222,6 @@ final class WebHomeChromeController { private boolean useDarkIcons(String style) { if (WebHomeChromeOptions.STYLE_DARK.equals(style)) return true; if (WebHomeChromeOptions.STYLE_LIGHT.equals(style)) return false; - int mask = activity.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; - return mask != Configuration.UI_MODE_NIGHT_YES; + return !ThemeController.isNight(activity); } } 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..3f09d209d6 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 @@ -19,6 +19,8 @@ public class ConfigAdapter extends RecyclerView.Adapter mItems; private boolean readOnly; + private boolean protectCurrent; + private Config current; public ConfigAdapter(OnClickListener listener) { this.listener = listener; @@ -30,6 +32,8 @@ public class ConfigAdapter extends RecyclerView.Adapter TextUtils.equals(item.getUrl(), currentUrl)); + if (!readOnly && !protectCurrent && !TextUtils.isEmpty(currentUrl)) mItems.removeIf(item -> TextUtils.equals(item.getUrl(), currentUrl)); return this; } + private boolean isCurrent(Config item) { + if (!protectCurrent || current == null) return false; + if (current.getId() > 0 && item.getId() == current.getId()) return true; + return current.getType() == item.getType() + && !TextUtils.isEmpty(current.getInterfaceKey()) + && TextUtils.equals(current.getInterfaceKey(), item.getInterfaceKey()); + } + + public boolean isProtectedCurrent(Config item) { + return isCurrent(item); + } + public int remove(Config item) { int position = mItems.indexOf(item); if (position == -1) return -1; @@ -82,10 +104,18 @@ public class ConfigAdapter extends RecyclerView.Adapter listener.onTextClick(item)); - holder.binding.text.setOnLongClickListener(v -> listener.onTextLongClick(holder)); + holder.binding.text.setEnabled(!current); + holder.binding.text.setFocusable(!current); + holder.binding.text.setOnClickListener(v -> { + if (!current) listener.onTextClick(item); + }); + holder.binding.text.setOnLongClickListener(v -> !current && listener.onTextLongClick(holder)); + holder.binding.edit.setVisibility(readOnly ? View.GONE : View.VISIBLE); + holder.binding.edit.setOnClickListener(v -> listener.onEditClick(item)); holder.binding.delete.setVisibility(readOnly ? View.GONE : View.VISIBLE); + holder.binding.delete.setAlpha(current ? 0.38f : 1f); holder.binding.delete.setOnClickListener(v -> listener.onDeleteClick(item)); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/HistoryAdapter.java b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/HistoryAdapter.java index 46a918a1a6..1d47344311 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/HistoryAdapter.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/HistoryAdapter.java @@ -7,10 +7,8 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.History; import com.fongmi.android.tv.databinding.AdapterVodBinding; -import com.fongmi.android.tv.utils.HistoryProgressFormatter; import com.fongmi.android.tv.utils.ImgUtil; import java.util.List; @@ -103,9 +101,6 @@ public class HistoryAdapter extends BaseDiffAdapter 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/ThemeAdapter.java b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/ThemeAdapter.java index d5645f824f..5c27c6aba7 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/ThemeAdapter.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/adapter/ThemeAdapter.java @@ -15,7 +15,7 @@ import com.github.bassaer.library.MDColor; public class ThemeAdapter extends RecyclerView.Adapter { private final OnClickListener listener; - private final int selected; + private int selected; private final int[] mItems; public ThemeAdapter(OnClickListener listener, int[] items, int selected) { @@ -24,6 +24,11 @@ public class ThemeAdapter extends RecyclerView.Adapter this.mItems = items; } + public void setSelected(int color) { + selected = color; + notifyDataSetChanged(); + } + public interface OnClickListener { void onItemClick(int color); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java b/app/src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java index ac818acf96..a6250892f4 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java @@ -20,6 +20,7 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.Updater; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.ui.audio.AudioMiniPlayer; import com.fongmi.android.tv.ui.custom.CustomWallView; import com.fongmi.android.tv.utils.ResUtil; @@ -46,10 +47,16 @@ public abstract class BaseActivity extends AppCompatActivity { enableEdgeToEdge(); enableDynamicColor(); super.onCreate(savedInstanceState); - setContentView(getBinding().getRoot()); + ThemeController.applyFromPreferences(this); + // getBinding() inflates on every call, so resolve it once: the root that is set as + // content must be the same instance that initView()/initEvent() configure. + View content = getBinding().getRoot(); + setContentView(content); + ThemeController.bindTheme(content); audioMiniPlayer = new AudioMiniPlayer(this); EventBus.getDefault().register(this); initView(savedInstanceState); + ThemeController.bindTheme(content); setBackCallback(); initEvent(); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java index 29f77c29b9..3916b95fe3 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdBlockStatsDialog.java @@ -21,10 +21,10 @@ 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.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.AdBlockTimeFormatter; import com.fongmi.android.tv.widget.AdBlockChartView; import com.google.android.material.button.MaterialButton; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.text.SimpleDateFormat; import java.util.ArrayList; @@ -60,7 +60,7 @@ public class AdBlockStatsDialog { private AdBlockStatsDialog(Activity activity) { this.activity = activity; this.binding = DialogAdBlockStatsBinding.inflate(LayoutInflater.from(activity)); - this.dialog = new MaterialAlertDialogBuilder(activity) + this.dialog = new WebHtvAlertDialogBuilder(activity) .setView(binding.getRoot()) .create(); } @@ -75,8 +75,8 @@ public class AdBlockStatsDialog { 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); + int width = Math.max(1, DialogSafeArea.width(activity, window) - horizontalMargin * 2); + int height = Math.max(1, DialogSafeArea.height(activity, window) - verticalMargin * 2); android.view.WindowManager.LayoutParams params = window.getAttributes(); params.width = width; params.height = height; @@ -347,7 +347,7 @@ public class AdBlockStatsDialog { } private void onReset() { - new MaterialAlertDialogBuilder(activity) + new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.ad_stats_reset) .setMessage(R.string.ad_stats_reset_confirm) .setPositiveButton(android.R.string.ok, (d, which) -> { diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java index 855915f600..ec69a2b7a2 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRuleEditDialog.java @@ -15,6 +15,7 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.api.config.UserAdRuleStore; import com.fongmi.android.tv.bean.UserAdRule; import com.fongmi.android.tv.databinding.DialogAdRuleEditBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -55,7 +56,7 @@ public class AdRuleEditDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override 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..d29b0aa6f8 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 @@ -30,6 +30,7 @@ import com.fongmi.android.tv.bean.HlsAdRule; import com.fongmi.android.tv.bean.Rule; import com.fongmi.android.tv.bean.UserAdRule; import com.fongmi.android.tv.databinding.DialogAdRuleManageBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.AdRuleAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; import com.fongmi.android.tv.utils.ResUtil; @@ -73,7 +74,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -146,7 +147,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter } private void onDisableImported() { - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) .setTitle(R.string.ad_rule_disable_imported) .setMessage(R.string.ad_rule_disable_imported_confirm) .setPositiveButton(android.R.string.ok, (dialog, which) -> { @@ -176,7 +177,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter () -> 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) + MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) .setTitle(R.string.ad_rule_import_title) .setCustomTitle(toolbar) .setMultiChoiceItems(labels, selected, (dialog, which, checked) -> selected[which] = checked) @@ -279,7 +280,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter HlsRuleConfig.Entry current = HlsRuleConfig.getEntries().stream() .filter(entry -> item.key().equals(entry.key())) .findFirst().orElse(item); - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) .setTitle(current.name().isBlank() ? current.id() : current.name()) .setMessage(getHlsRuleDetail(current)) .setPositiveButton(android.R.string.ok, null) @@ -289,7 +290,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter private void showRuleDetail(String name, String summary, List hosts, List regex, List exclude, Runnable editAction) { String message = (summary.isEmpty() ? "" : summary + "\n\n") + "域名\n" + listText(hosts) + "\n\nURL 规则\n" + listText(regex) + "\n\n白名单\n" + listText(exclude); - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + MaterialAlertDialogBuilder builder = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) .setTitle(name) .setMessage(message) .setPositiveButton(android.R.string.ok, null); @@ -346,7 +347,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter } private void confirmDisable(String name, int messageRes, Runnable action) { - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) .setTitle(name) .setMessage(messageRes) .setPositiveButton(android.R.string.ok, (dialog, which) -> action.run()) @@ -393,7 +394,7 @@ public class AdRuleManageDialog extends BaseAlertDialog implements AdRuleAdapter @Override public void onDeleteClick(UserAdRule item) { - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) + new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog) .setTitle(R.string.ad_rule_delete_confirm) .setMessage(getString(R.string.ad_rule_delete_message, item.getName())) .setPositiveButton(R.string.ad_rule_delete_confirm, (dialog, which) -> deleteUserRule(item)) diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java index fc9b67e53d..3cc95a88fa 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AdRulePreviewDialog.java @@ -15,6 +15,7 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.AdDetectionResult; import com.fongmi.android.tv.databinding.DialogAdRulePreviewBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -53,7 +54,7 @@ public class AdRulePreviewDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushMethodDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushMethodDialog.java index d279296184..6c0a8df916 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushMethodDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushMethodDialog.java @@ -8,6 +8,7 @@ import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Device; import com.fongmi.android.tv.databinding.DialogApkPushMethodBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class ApkPushMethodDialog extends BaseAlertDialog { @@ -43,7 +44,7 @@ public class ApkPushMethodDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushUrlDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushUrlDialog.java index d020bf2049..fb0472bd2f 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushUrlDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ApkPushUrlDialog.java @@ -13,6 +13,7 @@ import com.fongmi.android.tv.Constant; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Device; import com.fongmi.android.tv.databinding.DialogRemoteTrustTextCommandBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.Sniffer; import com.fongmi.android.tv.utils.Util; @@ -57,7 +58,7 @@ public class ApkPushUrlDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java index fb7c1d1e65..16cff26164 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java @@ -1,7 +1,6 @@ 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; @@ -18,15 +17,24 @@ 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.theme.AppearanceRowTheme; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeProfile; +import com.fongmi.android.tv.theme.ThemeProfileStore; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.textview.MaterialTextView; -public final class AppearanceDialog extends DialogFragment implements ThemeDialog.Listener { +import java.util.ArrayList; +import java.util.List; +public final class AppearanceDialog extends DialogFragment { + + private final List rows = new ArrayList<>(); private String[] uiScales; private String[] languages; private String[] imageSizes; private MaterialTextView uiScaleValue; + private MaterialTextView themeModeValue; private MaterialTextView themeValue; private MaterialTextView imageSizeValue; private MaterialTextView languageValue; @@ -35,6 +43,10 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo new AppearanceDialog().show(fragment.getChildFragmentManager(), AppearanceDialog.class.getSimpleName()); } + /** One dialog row, kept so a theme change can re-colour it without a rebuild. */ + private record Row(LinearLayout root, MaterialTextView title, MaterialTextView summary) { + } + @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { @@ -48,6 +60,7 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo LinearLayout content = new LinearLayout(requireContext()); content.setOrientation(LinearLayout.VERTICAL); uiScaleValue = addRow(content, R.string.setting_ui_scale, uiScales[Setting.getUiScaleIndex()], this::chooseUiScale); + themeModeValue = addRow(content, R.string.setting_theme_mode, getThemeModeText(), this::chooseThemeMode); 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); @@ -58,7 +71,6 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo 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); @@ -66,14 +78,12 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo 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); @@ -82,12 +92,28 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo summaryParams.leftMargin = dp(12); row.addView(summary, summaryParams); + // Row fill, stroke and both text colours must come from one palette. The row used + // to keep the fixed light selector_git_cloud_card while its text already followed + // ThemeController.current(), which on the TV dark table measured 1.16:1 / 1.36:1. + AppearanceRowTheme.apply(row, title, summary, ThemeController.current()); + rows.add(new Row(row, title, summary)); + LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(58)); rowParams.bottomMargin = dp(8); content.addView(row, rowParams); return summary; } + /** + * Re-colours the rows with the active palette. + * + *

Called when the dialog is shown and again after a theme change, so an open + * dialog follows a new profile without being rebuilt. + */ + private void applyRowTheme() { + for (Row row : rows) AppearanceRowTheme.apply(row.root(), row.title(), row.summary(), ThemeController.current()); + } + private void chooseUiScale(View view) { ChoiceDialog.showSingle(this, R.string.setting_ui_scale, uiScales, Setting.getUiScaleIndex(), which -> { if (which == Setting.getUiScaleIndex()) return; @@ -98,6 +124,32 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo }); } + private void chooseThemeMode(View view) { + String[] modes = {getString(R.string.setting_theme_mode_system), getString(R.string.setting_theme_mode_light), getString(R.string.setting_theme_mode_dark)}; + int current = switch (Setting.getThemeMode()) { + case 0 -> 1; + case 1 -> 2; + default -> 0; + }; + ChoiceDialog.showSingle(this, R.string.setting_theme_mode, modes, current, which -> { + int mode = which == 0 ? -1 : which - 1; + if (mode == Setting.getThemeMode()) return; + Setting.putThemeMode(mode); + themeModeValue.setText(getThemeModeText()); + ThemeController.applyNightModeToApp(); + dismissAllowingStateLoss(); + RefreshEvent.theme(); + }); + } + + private String getThemeModeText() { + return switch (Setting.getThemeMode()) { + case 0 -> getString(R.string.setting_theme_mode_light); + case 1 -> getString(R.string.setting_theme_mode_dark); + default -> getString(R.string.setting_theme_mode_system); + }; + } + private void chooseImageSize(View view) { ChoiceDialog.showSingle(this, R.string.setting_size, imageSizes, PlayerSetting.getSize(), which -> { imageSizeValue.setText(imageSizes[which]); @@ -117,15 +169,24 @@ public final class AppearanceDialog extends DialogFragment implements ThemeDialo } @Override - public void setTheme(int color) { - themeValue.setText(themeText(color)); - Setting.putThemeColor(color); + public void onStart() { + super.onStart(); + // A profile applied while this dialog stayed on screen must reach the rows, and the + // rows are only attached after createContent(), so the repaint happens here. + applyRowTheme(); + } + + void onThemeProfileApplied() { + themeValue.setText(getThemeText()); + applyRowTheme(); dismissAllowingStateLoss(); RefreshEvent.theme(); } private String getThemeText() { - return themeText(Setting.getThemeColor()); + ThemeProfile profile = ThemeProfileStore.load(); + String name = profile.displayName(); + return "Default".equals(name) ? themeText(Setting.getThemeColor()) : name; } private String themeText(int color) { diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java index 119cee80a2..9404bafff8 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java @@ -14,6 +14,7 @@ import android.view.inputmethod.EditorInfo; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; import androidx.viewbinding.ViewBinding; import com.fongmi.android.tv.R; @@ -24,6 +25,8 @@ import com.fongmi.android.tv.bean.Config; import com.fongmi.android.tv.db.AppDatabase; import com.fongmi.android.tv.databinding.DialogConfigBinding; import com.fongmi.android.tv.impl.ConfigListener; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; +import com.fongmi.android.tv.playback.PlaybackIdentityResolver; import com.fongmi.android.tv.ui.custom.CustomTextListener; import com.fongmi.android.tv.utils.FileChooser; import com.fongmi.android.tv.utils.Notify; @@ -31,11 +34,16 @@ import com.fongmi.android.tv.utils.ResUtil; import com.github.catvod.utils.Path; import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import java.util.ArrayList; +import java.util.List; + public class ConfigDialog extends BaseAlertDialog { private DialogConfigBinding binding; + private Config config; private boolean append = true; private boolean edit; + private ConfigListener listener; private String ori; private int type; @@ -63,10 +71,21 @@ public class ConfigDialog extends BaseAlertDialog { return this; } + public ConfigDialog target(Config config) { + this.config = config; + return this; + } + public void show(Fragment fragment) { + listener = fragment instanceof ConfigListener ? (ConfigListener) fragment : null; show(fragment.getChildFragmentManager(), null); } + public void show(FragmentActivity activity) { + listener = activity instanceof ConfigListener ? (ConfigListener) activity : null; + show(activity.getSupportFragmentManager(), null); + } + @Override protected ViewBinding getBinding() { return binding = DialogConfigBinding.inflate(getLayoutInflater()); @@ -74,17 +93,18 @@ public class ConfigDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override protected void initView() { - Config config = getConfig(); + if (config == null) config = edit ? getConfig() : Config.create(type); binding.title.setText(getDialogTitle()); binding.positive.setText(edit ? R.string.dialog_edit : R.string.dialog_positive); binding.name.setText(config.getName()); binding.url.setText(ori = config.getUrl()); binding.url.setSelection(TextUtils.isEmpty(ori) ? 0 : ori.length()); + binding.addresses.setText(addressesText(config)); } @Override @@ -179,29 +199,50 @@ public class ConfigDialog extends BaseAlertDialog { private void onPositive() { String url = binding.url.getText().toString().trim(); String name = binding.name.getText().toString().trim(); - Config config = saveConfig(url, name); + Config config = saveConfig(url, name, binding.addresses.getText().toString()); if (config == null) { Notify.show(R.string.remote_trust_config_url_required); binding.url.requestFocus(); return; } - ((ConfigListener) requireParentFragment()).setConfig(config); + ConfigListener target = listener; + if (target == null && getParentFragment() instanceof ConfigListener) target = (ConfigListener) getParentFragment(); + if (target == null && requireActivity() instanceof ConfigListener) target = (ConfigListener) requireActivity(); + if (target != null) target.setConfig(config); + PlaybackIdentityResolver.resolveSaved(config); dismiss(); } - private Config saveConfig(String url, String name) { - Config config; + private Config saveConfig(String url, String name, String addresses) { + Config saved; if (url.isEmpty()) { if (!edit) return null; if (!TextUtils.isEmpty(ori)) Config.delete(ori, type); return getStoredConfig(); } else if (edit) { - config = Config.find(ori, type).url(url).name(name).update(); + saved = Config.find(config.getId()).url(url).name(name).update(); } else { Config exists = AppDatabase.get().getConfigDao().find(url, type); - config = exists != null ? exists.name(name).update() : Config.create(type).url(url).name(name).update(); + saved = exists != null ? exists.name(name).update() : null; + if (saved == null) { + return config = Config.create(type).url(url).name(name) + .urls(addresses(url, addresses)).insert().update(); + } } - return config; + return config = saved.urls(addresses(url, addresses)).update(); + } + + private List addresses(String primary, String text) { + List result = new ArrayList<>(); + result.add(primary); + for (String item : text.split("\\R")) if (!TextUtils.isEmpty(item.trim())) result.add(item.trim()); + return result; + } + + private String addressesText(Config config) { + List addresses = new ArrayList<>(config.getUrls()); + addresses.remove(config.getUrl()); + return TextUtils.join("\n", addresses); } private void configureWindow() { @@ -228,7 +269,7 @@ public class ConfigDialog extends BaseAlertDialog { return; } String url = "file:/" + path.replace(Path.rootPath(), ""); - ((ConfigListener) requireParentFragment()).setConfig(saveConfig(url, name)); + ((ConfigListener) requireParentFragment()).setConfig(saveConfig(url, name, binding.addresses.getText().toString())); dismiss(); }); } 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..b742275112 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 @@ -26,12 +26,13 @@ import com.fongmi.android.tv.bean.TmdbItem; import com.fongmi.android.tv.bean.TmdbPerson; import com.fongmi.android.tv.service.TmdbService; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; 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.card.MaterialCardView; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.gson.JsonObject; import java.util.ArrayList; @@ -286,7 +287,7 @@ public class EpisodeDetailDialog { TextView photoTitle = view.findViewById(R.id.photoTitle); TextView guestsTitle = view.findViewById(R.id.guestsTitle); - int overlay = light ? 0x99F4F7FA : 0xB3000000; + int overlay = applyScrimOpacity(light ? 0x99F4F7FA : 0xB3000000, light); int panelColor = light ? 0xFFF4F7FA : 0xFF2A2A2A; int imageBg = light ? 0xFFE7EDF3 : 0xFF1A1A1A; int primary = light ? 0xFF12202D : 0xFFFFFFFF; @@ -369,10 +370,23 @@ public class EpisodeDetailDialog { return Setting.resolveTmdbDetailLightTheme(Setting.getTmdbDetailTheme(), night == Configuration.UI_MODE_NIGHT_YES); } + /** + * Keeps this dialog's own scrim colour and lets the user drive only its alpha. + * + *

The shipped scrim is translucent white in light mode and translucent black in + * dark mode; the theme token is translucent black in both. Substituting the token + * was measured to invert the light-mode scrim from lightening to darkening, so the + * user's {@code scrimOpacity} is applied to the dialog's existing colour instead. + * An unset slot returns {@code base} untouched, keeping the default byte-identical. + */ + private static int applyScrimOpacity(int base, boolean light) { + return ThemeController.applyScrimOpacity(base, ThemeController.configuredScrimOpacity(light)); + } + private static void showSimpleDialog(FragmentActivity activity, Episode episode, android.content.DialogInterface.OnDismissListener dismissListener) { // 标题放固定文案,源站文件名放可换行的正文,避免长名被单行标题截断 - androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(activity) + androidx.appcompat.app.AlertDialog dialog = new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.detail_tmdb_empty) .setMessage(episode.getName()) .setPositiveButton(R.string.dialog_negative, null) 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..26cfd5342b 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 @@ -6,6 +6,7 @@ import android.graphics.drawable.ColorDrawable; import android.view.Gravity; import android.view.Window; import android.view.WindowManager; +import android.view.View; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; @@ -21,8 +22,10 @@ import com.fongmi.android.tv.api.config.WallConfig; import com.fongmi.android.tv.bean.Config; import com.fongmi.android.tv.databinding.DialogHistoryBinding; import com.fongmi.android.tv.impl.ConfigListener; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.ConfigAdapter; import com.fongmi.android.tv.ui.custom.SpaceItemDecoration; +import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -35,6 +38,7 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl private int type; private boolean readOnly; + private boolean manage; public static HistoryDialog create() { return new HistoryDialog(); @@ -60,6 +64,11 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl return this; } + public HistoryDialog manage() { + manage = true; + return this; + } + public void show(FragmentActivity activity) { show(activity.getSupportFragmentManager(), null); } @@ -85,7 +94,7 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override @@ -95,10 +104,22 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl binding.recycler.setHasFixedSize(false); if (isFull()) binding.recycler.setMaxHeight(ResUtil.dp2px(264)); binding.recycler.addItemDecoration(new SpaceItemDecoration(1, 8)); - binding.recycler.setAdapter(adapter.readOnly(readOnly).addAll(type, getConfig())); + binding.recycler.setAdapter(adapter.readOnly(readOnly).protectCurrent(manage).addAll(type, getConfig())); + binding.add.setVisibility(manage ? View.VISIBLE : View.GONE); + binding.add.setOnClickListener(v -> onAdd()); if (type == 0 && !readOnly) attachSortHelper(); } + private void onAdd() { + ConfigDialog dialog = ConfigDialog.create(); + if (type == 0) dialog.vod(); + else if (type == 1) dialog.live(); + else dialog.wall(); + if (getParentFragment() != null) dialog.show(getParentFragment()); + else dialog.show(requireActivity()); + dismiss(); + } + private void attachSortHelper() { sortTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { @Override public boolean isLongPressDragEnabled() { return false; } @@ -132,9 +153,24 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl return true; } + @Override + public void onEditClick(Config item) { + ConfigDialog dialog = ConfigDialog.create().target(item).edit(); + if (type == 0) dialog.vod(); + else if (type == 1) dialog.live(); + else dialog.wall(); + if (getParentFragment() != null) dialog.show(getParentFragment()); + else dialog.show(requireActivity().getSupportFragmentManager(), null); + dismiss(); + } + @Override public void onDeleteClick(Config item) { - new MaterialAlertDialogBuilder(requireContext()) + if (adapter.isProtectedCurrent(item)) { + Notify.show(R.string.config_current_delete_message); + return; + } + new WebHtvAlertDialogBuilder(requireContext()) .setTitle(R.string.config_delete_title) .setMessage(getString(R.string.config_delete_message, item.getDesc())) .setNegativeButton(R.string.dialog_negative, null) @@ -147,7 +183,7 @@ public class HistoryDialog extends BaseAlertDialog implements ConfigAdapter.OnCl @Override public void onStart() { super.onStart(); - if (adapter.getItemCount() == 0) dismiss(); + if (adapter.getItemCount() == 0 && !manage) dismiss(); else configureWindow(); } 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..b42446e9d7 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,10 +81,13 @@ 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.source.setText(parent.control.action.source.getText()); + binding.source.setContentDescription(parent.control.action.source.getContentDescription()); 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()); + syncConfiguredButtonVisibility(); setTrackVisible(); setListStyleSelected(); setScaleText(); @@ -182,10 +185,23 @@ 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()); + // 换源时宿主 control.action.source 已更新,弹窗保持打开也必须跟着改名与读屏语义。 + binding.source.setText(parent.control.action.source.getText()); + binding.source.setContentDescription(parent.control.action.source.getContentDescription()); binding.playParams.setSelected(parent.control.action.playParams.isSelected()); + syncConfiguredButtonVisibility(); setTrackVisible(); } + /** + * 面板与底部动作栏保持一致:用户在「播放器按钮」里关掉的直播同名按钮,面板内也不再出现。 + */ + private void syncConfiguredButtonVisibility() { + binding.player.setVisibility(parent.control.action.player.getVisibility()); + binding.decode.setVisibility(parent.control.action.decode.getVisibility()); + binding.playParams.setVisibility(parent.control.action.playParams.getVisibility()); + } + private void setScale(int mode) { listener().onLiveScalePanel(mode); setScaleText(); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java index 5f50e4b0dd..329d87f45e 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PlayerOsdDialog.java @@ -19,6 +19,8 @@ import androidx.recyclerview.widget.RecyclerView; import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.AdapterPlayerOsdBinding; import com.fongmi.android.tv.databinding.DialogPlayerOsdBinding; +import com.fongmi.android.tv.theme.PlayerSettingsRowTheme; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.utils.ResUtil; import java.util.Arrays; @@ -110,11 +112,12 @@ public final class PlayerOsdDialog extends DialogFragment { public void onBindViewHolder(@NonNull ViewHolder holder, int position) { boolean visible = checked[position]; holder.binding.name.setText(items[position]); - holder.binding.name.setAlpha(visible ? 1f : 0.55f); holder.binding.visible.setImageResource(visible ? R.drawable.ic_player_button_visible : R.drawable.ic_player_button_hidden); holder.binding.visible.setContentDescription(getString(visible ? R.string.setting_show : R.string.setting_hide)); holder.binding.visible.setSelected(visible); holder.binding.visible.setActivated(visible); + PlayerSettingsRowTheme.apply(holder.binding.root, holder.binding.name, ThemeController.current(), visible); + PlayerSettingsRowTheme.applySwitch(holder.binding.visible, ThemeController.current()); holder.binding.root.setOnClickListener(view -> toggle(holder.getBindingAdapterPosition())); holder.binding.visible.setOnClickListener(view -> toggle(holder.getBindingAdapterPosition())); } diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PushPlayUrlDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PushPlayUrlDialog.java index d8bfee2d06..03b5dd7de9 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PushPlayUrlDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/PushPlayUrlDialog.java @@ -12,6 +12,7 @@ import com.fongmi.android.tv.Constant; import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Device; import com.fongmi.android.tv.databinding.DialogRemoteTrustTextCommandBinding; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.Sniffer; import com.fongmi.android.tv.utils.Util; @@ -52,7 +53,7 @@ public class PushPlayUrlDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()); } @Override diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/RecommendationFeedbackDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/RecommendationFeedbackDialog.java index 4dfa5347a2..74e7a294cd 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/RecommendationFeedbackDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/RecommendationFeedbackDialog.java @@ -12,9 +12,9 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.DialogRecommendationFeedbackBinding; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.service.RecommendationFeedbackStore; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.adapter.RecommendationFeedbackAdapter; import com.fongmi.android.tv.utils.Notify; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class RecommendationFeedbackDialog implements RecommendationFeedbackAdapter.OnClickListener { @@ -33,7 +33,7 @@ public class RecommendationFeedbackDialog implements RecommendationFeedbackAdapt binding.recycler.setLayoutManager(new LinearLayoutManager(activity)); binding.recycler.setAdapter(adapter); binding.recycler.setItemAnimator(null); - dialog = new MaterialAlertDialogBuilder(activity) + dialog = new WebHtvAlertDialogBuilder(activity) .setTitle(R.string.recommendation_feedback_title) .setView(binding.getRoot()) .setNegativeButton(R.string.detail_close, null) diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java index 7f2825fac7..3621814027 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java @@ -1,47 +1,520 @@ package com.fongmi.android.tv.ui.dialog; +import android.app.Dialog; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.fragment.app.DialogFragment; 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.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.ui.adapter.ThemeAdapter; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.fongmi.android.tv.theme.ThemeColorPickerDialog; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeEditor; +import com.fongmi.android.tv.theme.ThemeEditorUi; +import com.fongmi.android.tv.theme.ThemePaletteStyle; +import com.fongmi.android.tv.theme.ThemePresets; +import com.fongmi.android.tv.theme.ThemePreviewView; +import com.fongmi.android.tv.theme.ThemeProfile; +import com.fongmi.android.tv.theme.ThemeProfileCodec; +import com.fongmi.android.tv.theme.ThemeProfileStore; +import com.fongmi.android.tv.theme.ThemeTokens; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; +import com.fongmi.android.tv.utils.ResUtil; +import com.fongmi.android.tv.utils.Util; -public class ThemeDialog extends BaseAlertDialog implements ThemeAdapter.OnClickListener { +import java.util.EnumMap; - private static final int[] COLORS = {-1, 0, 0xFF6750A4, 0xFF3949AB, 0xFF1E88E5, 0xFF00ACC1, 0xFF00897B, 0xFF43A047, 0xFF7CB342, 0xFFFB8C00, 0xFFE53935, 0xFFD81B60, 0xFF8E24AA, 0xFF6D4C41,}; - private DialogThemeBinding binding; +/** Locally themed editing session. Only Save publishes the draft to the rest of the app. */ +public final class ThemeDialog extends DialogFragment implements ThemePreviewView.Callbacks { + + private final EnumMap cards = new EnumMap<>(ThemePresets.Preset.class); + private final EnumMap styles = new EnumMap<>(ThemePaletteStyle.class); + private ThemeEditor editor; + private ThemeTokens previewTokens; + private boolean dark; + private int wallpaperColor; + private int savedScroll; + /** + * System-bar insets reported by the dialog window, or {@code null} before they arrive. + * + *

{@code onStart} runs before the window is always laid out, so the insets can still + * be missing when the window is first sized; the listener installed in {@link #onStart} + * re-sizes the window once they are known. + */ + private ThemeDialogLayout.Insets windowInsets; + private LinearLayout root; + private ThemePreviewView panel; + private ScrollView scroll; + private TextView title; + private TextView note; + private TextView presetTitle; + private TextView paletteTitle; + private TextView modeTitle; + private TextView status; + private Button lightButton; + private Button darkButton; + private Button importButton; + private Button resetButton; + private Button cancelButton; + private Button saveButton; public static void show(Fragment fragment) { - new ThemeDialog().show(fragment.getChildFragmentManager(), null); + new ThemeDialog().show(fragment.getChildFragmentManager(), ThemeDialog.class.getSimpleName()); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + editor = ThemeEditor.load(); + // 预览模式必须与 App 当前实际渲染的模式一致:此前这里用 Util.isLeanback() 把 TV 强行 + // 置为深色预览,于是用户在浅色模式下重新打开编辑器会看到「深色」被选中,误以为保存失败。 + dark = ThemeController.isNight(requireContext()); + wallpaperColor = Setting.getWallColor(); + if (savedInstanceState != null) { + dark = savedInstanceState.getBoolean("preview_dark", dark); + savedScroll = savedInstanceState.getInt("preview_scroll", 0); + String draft = savedInstanceState.getString("preview_draft"); + if (draft != null) { + try { + editor.replace(ThemeProfileCodec.parse(draft)); + } catch (RuntimeException ignored) { /* Invalid saved UI state must not block opening settings. */ } + } + } + previewTokens = editor.preview(dark, wallpaperColor); + root = ThemeEditorUi.column(requireContext()); + // This subtree belongs to the draft, not the builder's persisted-theme binder. + root.setTag("webhtv:ignore"); + ThemeEditorUi.padding(root, 18, 16); + buildHeader(); + + scroll = new ScrollView(requireContext()); + scroll.setClipToPadding(false); + LinearLayout content = ThemeEditorUi.column(requireContext()); + content.setPadding(0, 0, 0, dp(12)); + presetTitle = heading(R.string.theme_editor_presets); + content.addView(presetTitle, ThemeEditorUi.fullWidth(requireContext(), 14)); + // Default is first in the very first configuration group, ahead of palette styles. + content.addView(buildPresetRow(), ThemeEditorUi.fullWidth(requireContext(), 10)); + content.addView(buildModeRow(), ThemeEditorUi.fullWidth(requireContext(), 14)); + paletteTitle = heading(R.string.theme_editor_palette_style); + content.addView(paletteTitle, ThemeEditorUi.fullWidth(requireContext(), 12)); + content.addView(buildPaletteRow(), ThemeEditorUi.fullWidth(requireContext(), 8)); + panel = ThemePreviewView.createPanel(requireContext(), editor, dark, previewTokens, this); + content.addView(panel); + scroll.addView(content); + root.addView(scroll, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + buildFooter(); + render(); + // Custom actions avoid AlertDialog's unconditional positive-button dismissal on save failure. + return new WebHtvAlertDialogBuilder(requireContext()).setView(root).create(); + } + + private void buildHeader() { + LinearLayout header = ThemeEditorUi.row(requireContext()); + title = ThemeEditorUi.text(requireContext(), getString(R.string.setting_theme_color), 22, previewTokens.colorOnSurface()); + ThemeEditorUi.heading(title); + header.addView(title, ThemeEditorUi.weighted()); + importButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_import); + importButton.setOnClickListener(view -> ThemeImportDialog.show(this, profile -> { + ThemeEditor.Result result = editor.replace(profile); + if (result.success()) render(); + else setStatus(result.error()); + })); + header.addView(importButton); + root.addView(header); + note = ThemeEditorUi.text(requireContext(), getString(R.string.theme_editor_inline_hint), 12, previewTokens.colorOnSurfaceVariant()); + root.addView(note, ThemeEditorUi.fullWidth(requireContext(), 6)); + } + + private View buildPresetRow() { + LinearLayout row = ThemeEditorUi.row(requireContext()); + row.setPadding(dp(2), dp(2), dp(2), dp(2)); + for (ThemePresets.Preset preset : ThemePresets.Preset.values()) { + PresetCard card = new PresetCard(preset); + cards.put(preset, card); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp(128), ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMarginEnd(dp(10)); + row.addView(card.view, params); + } + return horizontalScroll(row); + } + + private View buildModeRow() { + LinearLayout row = ThemeEditorUi.row(requireContext()); + modeTitle = heading(R.string.theme_editor_edit_mode); + row.addView(modeTitle, ThemeEditorUi.weighted()); + lightButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_light); + darkButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_dark); + lightButton.setOnClickListener(view -> { dark = false; render(); }); + darkButton.setOnClickListener(view -> { dark = true; render(); }); + LinearLayout.LayoutParams lightParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + lightParams.setMarginEnd(dp(8)); + row.addView(lightButton, lightParams); + row.addView(darkButton); + return row; + } + + private View buildPaletteRow() { + LinearLayout row = ThemeEditorUi.row(requireContext()); + row.setPadding(dp(2), dp(2), dp(2), dp(2)); + for (ThemePaletteStyle style : ThemePaletteStyle.values()) { + Button button = ThemeEditorUi.button(requireContext(), paletteLabel(style)); + button.setOnClickListener(view -> { + editor.replace(ThemePresets.withPaletteStyle(editor.draft(), style)); + render(); + }); + styles.put(style, button); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMarginEnd(dp(8)); + row.addView(button, params); + } + return horizontalScroll(row); + } + + private View horizontalScroll(View child) { + HorizontalScrollView scroll = new HorizontalScrollView(requireContext()); + scroll.setHorizontalScrollBarEnabled(false); + scroll.addView(child); + return scroll; + } + + private void buildFooter() { + status = ThemeEditorUi.text(requireContext(), "", 12, previewTokens.colorOnSurfaceVariant()); + status.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE); + root.addView(status, ThemeEditorUi.fullWidth(requireContext(), 10)); + LinearLayout actions = ThemeEditorUi.row(requireContext()); + cancelButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_cancel); + resetButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_reset); + saveButton = ThemeEditorUi.button(requireContext(), R.string.theme_editor_apply); + cancelButton.setOnClickListener(view -> dismiss()); + resetButton.setOnClickListener(view -> { editor.reset(); render(); }); + saveButton.setOnClickListener(view -> applyDraft()); + for (Button button : new Button[]{cancelButton, resetButton, saveButton}) { + LinearLayout.LayoutParams params = ThemeEditorUi.weighted(); + if (button != saveButton) params.setMarginEnd(dp(8)); + actions.addView(button, params); + } + root.addView(actions, ThemeEditorUi.fullWidth(requireContext(), 8)); + } + + /** One snapshot drives every real control; no view is recreated during a colour edit or drag. */ + private void render() { + previewTokens = editor.preview(dark, wallpaperColor); + ThemeTokens tokens = previewTokens; + title.setTextColor(tokens.colorOnSurface()); + note.setTextColor(tokens.colorOnSurfaceVariant()); + for (TextView heading : new TextView[]{presetTitle, paletteTitle, modeTitle}) { + heading.setTextColor(tokens.colorOnSurfaceVariant()); + } + ThemeEditorUi.buttonColors(importButton, tokens.colorSecondaryContainer(), tokens.colorOnSecondaryContainer(), tokens, false); + modeButton(lightButton, !dark); + modeButton(darkButton, dark); + ThemeProfile draft = editor.draft(); + for (PresetCard card : cards.values()) card.render(draft); + for (ThemePaletteStyle style : styles.keySet()) { + boolean selected = style.id().equals(draft.paletteStyle); + modeButton(styles.get(style), selected); + } + if (panel != null) panel.render(editor, dark, tokens); + ThemeEditorUi.buttonColors(cancelButton, tokens.colorSurfaceContainer(), tokens.colorOnSurface(), tokens, false); + ThemeEditorUi.buttonColors(resetButton, tokens.colorSurfaceContainer(), tokens.colorOnSurface(), tokens, false); + ThemeEditorUi.buttonColors(saveButton, tokens.colorPrimary(), tokens.colorOnPrimary(), tokens, false); + setStatus(getString(editor.isDirty() ? R.string.theme_editor_dirty : R.string.theme_editor_saved_state)); + Dialog dialog = getDialog(); + if (dialog != null && dialog.getWindow() != null) { + Window window = dialog.getWindow(); + window.setBackgroundDrawable(ThemeEditorUi.shape(requireContext(), + ThemeEditorUi.withAlpha(tokens.colorSurface(), tokens.dialogOpacity()), 0, 0, 24)); + window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + WindowManager.LayoutParams params = window.getAttributes(); + params.dimAmount = ThemeEditorUi.alpha(tokens.colorScrim()); + window.setAttributes(params); + } + } + + private void modeButton(Button button, boolean selected) { + ThemeTokens tokens = previewTokens; + ThemeEditorUi.buttonColors(button, selected ? tokens.colorPrimaryContainer() : tokens.colorSurfaceContainer(), + selected ? tokens.colorOnPrimaryContainer() : tokens.colorOnSurface(), tokens, selected); + } + + private final class PresetCard { + final ThemePresets.Preset preset; + final ThemeProfile profile; + final ThemeTokens light; + final ThemeTokens night; + final LinearLayout view; + final TextView name; + final TextView detail; + final View[] swatches = new View[4]; + + PresetCard(ThemePresets.Preset preset) { + this.preset = preset; + profile = preset.profile(); + ThemeEditor sample = new ThemeEditor(profile); + light = sample.preview(false, wallpaperColor); + night = sample.preview(true, wallpaperColor); + view = ThemeEditorUi.column(requireContext()); + ThemeEditorUi.padding(view, 12, 12); + view.setFocusable(true); + view.setTag("theme-preset:" + preset.name()); + view.setOnClickListener(ignored -> { + ThemeProfile next = profile.copy(); + if (preset != ThemePresets.Preset.DEFAULT) next.name = getString(preset.label); + next.mode = editor.draft().mode; + editor.replace(next); + ThemeDialog.this.render(); + }); + name = ThemeEditorUi.text(requireContext(), getString(preset.label), 14, light.colorOnSurface()); + ThemeEditorUi.heading(name); + name.setSingleLine(true); + view.addView(name); + detail = ThemeEditorUi.text(requireContext(), "", 10, light.colorOnSurfaceVariant()); + detail.setSingleLine(true); + view.addView(detail, ThemeEditorUi.fullWidth(requireContext(), 6)); + LinearLayout row = ThemeEditorUi.row(requireContext()); + for (int i = 0; i < swatches.length; i++) { + swatches[i] = new View(requireContext()); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, dp(18), 1); + if (i < swatches.length - 1) params.setMarginEnd(dp(4)); + row.addView(swatches[i], params); + } + view.addView(row, ThemeEditorUi.fullWidth(requireContext(), 10)); + } + + void render(ThemeProfile draft) { + ThemeTokens tokens = dark ? night : light; + boolean active = ThemePresets.find(draft) == preset; + boolean modified = false; + if (active) { + ThemeProfile comparable = draft.copy(); + comparable.name = profile.name; + comparable.mode = profile.mode; + modified = !ThemeProfileCodec.encode(comparable).equals(ThemeProfileCodec.encode(profile)); + } + name.setText((active ? "✓ " : "") + getString(preset.label)); + name.setTextColor(tokens.colorOnSurface()); + detail.setText(modified ? R.string.theme_editor_preset_modified + : preset == ThemePresets.Preset.DEFAULT ? R.string.theme_editor_preset_original + : preset == ThemePresets.Preset.WALLPAPER ? R.string.theme_editor_preset_dynamic : R.string.theme_editor_preset_pair); + detail.setTextColor(tokens.colorOnSurfaceVariant()); + view.setSelected(active); + view.setContentDescription(getString(preset.label) + (active ? " · " + getString(R.string.theme_editor_selected) : "")); + view.setBackground(ThemeEditorUi.interactive(requireContext(), tokens.colorSurface(), tokens.colorOutline(), + previewTokens.colorFocus(), active)); + int[] colors = {tokens.colorPrimary(), tokens.colorSecondaryContainer(), tokens.colorSurfaceContainerHigh(), tokens.colorSuccess()}; + for (int i = 0; i < swatches.length; i++) swatches[i].setBackground(ThemeEditorUi.shape(requireContext(), colors[i], 0, 0, 5)); + } } @Override - protected ViewBinding getBinding() { - return binding = DialogThemeBinding.inflate(getLayoutInflater()); + public void onStart() { + super.onStart(); + Dialog dialog = getDialog(); + if (dialog == null) return; + configureWindow(dialog); + render(); + // The window can be laid out before the platform reports its system-bar insets, in + // which case the first sizing falls back to the display bounds and the footer row + // ends up under the navigation bar. Re-sizing when the real insets arrive is what + // makes the fix independent of that ordering. + Window window = dialog.getWindow(); + View decorView = window == null ? null : window.getDecorView(); + if (decorView != null) { + ViewCompat.setOnApplyWindowInsetsListener(decorView, (view, insets) -> { + androidx.core.graphics.Insets bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); + ThemeDialogLayout.Insets next = new ThemeDialogLayout.Insets( + bars.left, bars.top, bars.right, bars.bottom); + if (!next.equals(windowInsets)) { + windowInsets = next; + Dialog current = getDialog(); + if (current != null && current.getWindow() != null) configureWindow(current); + } + return insets; + }); + ViewCompat.requestApplyInsets(decorView); + } + if (savedScroll > 0) scroll.post(() -> scroll.scrollTo(0, savedScroll)); + } + + private void configureWindow(Dialog dialog) { + Window window = dialog.getWindow(); + if (window == null) return; + int margin = ResUtil.dp2px(ThemeDialogLayout.marginDp(Util.isLeanback())); + View decorView = window.getDecorView(); + int screenWidth = ResUtil.getScreenWidth(requireContext()); + int screenHeight = ResUtil.getScreenHeight(requireContext()); + ThemeDialogLayout.Area area = ThemeDialogLayout.safeArea(screenWidth, screenHeight, + systemBarInsets(screenWidth, screenHeight)); + int width = ThemeDialogLayout.width(area.width(), margin); + int height = ThemeDialogLayout.height(area.height(), margin); + WindowManager.LayoutParams params = window.getAttributes(); + params.width = width; + params.height = height; + params.gravity = Gravity.CENTER; + Drawable panel = ThemeDialogLayout.panelBackground(decorView.getBackground()); + if (panel != null) window.setBackgroundDrawable(panel); + decorView.setPadding(0, 0, 0, 0); + window.setAttributes(params); + window.setLayout(width, height); + if (root != null && root.getLayoutParams() != null) { + ViewGroup.LayoutParams rootParams = root.getLayoutParams(); + rootParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + rootParams.height = ViewGroup.LayoutParams.MATCH_PARENT; + root.setLayoutParams(rootParams); + } + } + + /** + * The system bars the editor window has to stay clear of. + * + *

Insets reported by the dialog window are exact and preferred. Until they arrive, the + * space the host already gave up to the bars (the display minus its content view) is + * used; that value also covers the host's own chrome, so it can only over-reserve, never + * clip. A host content view that has not been laid out yet reports nothing, and the + * display is used until the listener re-sizes the window. + */ + private ThemeDialogLayout.Insets systemBarInsets(int screenWidth, int screenHeight) { + // 1) The dialog window's own report, which is the exact value whenever the platform + // actually fills it in. + if (windowInsets != null && !windowInsets.isEmpty()) return windowInsets; + // 2) The host activity's root window insets. This is the same source Util.isFullscreen + // already trusts, and it stays correct while a floating dialog window keeps reporting + // systemBars() = [0,0,0,0] on API 28. + ThemeDialogLayout.Insets host = hostWindowInsets(); + if (!host.isEmpty()) return host; + // 3) The space the host content view has already given up to the bars. + ThemeDialogLayout.Insets fallback = + ThemeDialogLayout.Insets.ofContent(screenWidth, screenHeight, contentWidth(), contentHeight()); + return fallback.isEmpty() ? ThemeDialogLayout.Insets.none() : fallback; + } + + /** + * The system bars the host activity has to keep clear. + * + *

Used because a floating dialog window is not guaranteed to report its own insets: on + * API 28 the editor's window reports {@code systemBars() = [0,0,0,0]} while the navigation bar + * is on screen, and accepting that as "no bars" is what pushed the footer row under it.

+ */ + private ThemeDialogLayout.Insets hostWindowInsets() { + try { + WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(requireActivity().getWindow().getDecorView()); + if (insets == null) return ThemeDialogLayout.Insets.none(); + androidx.core.graphics.Insets bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); + return new ThemeDialogLayout.Insets(bars.left, bars.top, bars.right, bars.bottom); + } catch (RuntimeException error) { + return ThemeDialogLayout.Insets.none(); + } + } + + /** + * The host activity's content view size. + * + *

Its difference from the display is the system-bar space the host gave up, which is + * the safe-area fallback while the dialog window's own insets are still unknown. + */ + private int contentWidth() { + View content = requireActivity().findViewById(android.R.id.content); + return content == null ? 0 : content.getWidth(); + } + + private int contentHeight() { + View content = requireActivity().findViewById(android.R.id.content); + return content == null ? 0 : content.getHeight(); } @Override - protected MaterialAlertDialogBuilder getBuilder() { - return builder().setTitle(R.string.setting_theme_color).setView(getBinding().getRoot()); + public void onSaveInstanceState(@NonNull Bundle state) { + super.onSaveInstanceState(state); + if (editor != null) state.putString("preview_draft", ThemeProfileCodec.encode(editor.draft())); + state.putBoolean("preview_dark", dark); + state.putInt("preview_scroll", scroll == null ? savedScroll : scroll.getScrollY()); } @Override - protected void initView() { - binding.recycler.setAdapter(new ThemeAdapter(this, COLORS, Setting.getThemeColor())); + public void onDestroyView() { + super.onDestroyView(); + cards.clear(); + styles.clear(); + root = null; + panel = null; + scroll = null; + // The next window reports its own insets; a stale value from this one must not be reused. + windowInsets = null; } @Override - public void onItemClick(int color) { - ((Listener) requireParentFragment()).setTheme(color); - dismiss(); + public void onColorSlotClicked(ThemeEditor.Slot slot, boolean slotDark) { + String initial = ThemeEditorUi.hex(ThemePreviewView.colorOf(previewTokens, slot)); + ThemeColorPickerDialog.create(requireContext(), getString(ThemePreviewView.labelOf(slot)), initial, previewTokens, hex -> { + ThemeEditor.Result result = editor.set(slot, slotDark, hex, 0f); + if (result.success()) render(); + else setStatus(result.error()); + }).show(); } - public interface Listener { + @Override + public void onDraftChanged() { + render(); + } - void setTheme(int color); + private void applyDraft() { + ThemeProfileStore.ApplyResult result = editor.apply(); + if (!result.success()) { + setStatus(getString(R.string.theme_editor_save_failed, result.error())); + return; + } + dismissAllowingStateLoss(); + // AppearanceDialog already publishes the refresh event. Do not post a second recreation. + if (getParentFragment() instanceof AppearanceDialog appearance) appearance.onThemeProfileApplied(); + else RefreshEvent.theme(); + } + + private TextView heading(int label) { + TextView heading = ThemeEditorUi.text(requireContext(), getString(label), 13, previewTokens.colorOnSurfaceVariant()); + ThemeEditorUi.heading(heading); + return heading; + } + + private void setStatus(String message) { + status.setTextColor(previewTokens.colorOnSurfaceVariant()); + status.setText(message == null ? "" : message); + } + + private int paletteLabel(ThemePaletteStyle style) { + return switch (style) { + case TONAL_SPOT -> R.string.theme_editor_palette_tonal; + case VIBRANT -> R.string.theme_editor_palette_vibrant; + case EXPRESSIVE -> R.string.theme_editor_palette_expressive; + case RAINBOW -> R.string.theme_editor_palette_rainbow; + case FRUIT_SALAD -> R.string.theme_editor_palette_fruit_salad; + case FIDELITY -> R.string.theme_editor_palette_fidelity; + case CONTENT -> R.string.theme_editor_palette_content; + case NEUTRAL -> R.string.theme_editor_palette_neutral; + case MONOCHROME -> R.string.theme_editor_palette_monochrome; + }; + } + + private int dp(int value) { + return ThemeEditorUi.dp(requireContext(), value); } } - diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java index 3de7d3f5fc..bc5c6029a8 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java @@ -14,6 +14,10 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.bean.Update; import com.fongmi.android.tv.databinding.DialogUpdateBinding; import com.fongmi.android.tv.impl.UpdateListener; +import com.fongmi.android.tv.theme.ThemeController; +import com.fongmi.android.tv.theme.ThemeTokens; +import com.fongmi.android.tv.theme.UpdateVersionRowTheme; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.utils.AppVersion; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.MarkdownText; @@ -71,7 +75,7 @@ public class UpdateDialog extends BaseAlertDialog { @Override protected MaterialAlertDialogBuilder getBuilder() { - return new MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_LightDialog).setView(getBinding().getRoot()).setCancelable(false); + return new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog).setView(getBinding().getRoot()).setCancelable(false); } @Override @@ -155,6 +159,7 @@ public class UpdateDialog extends BaseAlertDialog { View content = stableChannel ? binding.stableContent : binding.betaContent; item.setSelected(expanded); content.setVisibility(expanded ? View.VISIBLE : View.GONE); + applyCardTheme(channel, expanded); if (stableChannel) { binding.stableVersion.setText(getVersion(update)); binding.stableStatus.setText(getStatus(update)); @@ -176,6 +181,27 @@ public class UpdateDialog extends BaseAlertDialog { } } + /** + * Paints one version card from the active palette. + * + *

The card used to take its fill from {@code selector_update_version_item} while its + * text came from three separate {@code ColorStateList}s. Those are different resource + * graphs: {@code ThemeBinder} rewrites the text colours but leaves the card's + * {@code StateListDrawable} untouched, so on a “壁纸取色” or custom palette the text and + * the fill could drift apart and the card went unreadable (reported: a solid + * {@code #0F1D26} card carrying {@code #49454F} text, 1.5:1). Handing both to + * {@link UpdateVersionRowTheme} keeps them on one snapshot and guarantees AA. + */ + private void applyCardTheme(String channel, boolean expanded) { + ThemeTokens tokens = ThemeController.current(); + boolean stableChannel = Update.CHANNEL_STABLE.equals(channel); + if (stableChannel) { + UpdateVersionRowTheme.apply(binding.stableItem, binding.stableName, binding.stableVersion, binding.stableStatus, binding.stableExpand, binding.stableDesc, expanded, tokens); + } else { + UpdateVersionRowTheme.apply(binding.betaItem, binding.betaName, binding.betaVersion, binding.betaStatus, binding.betaExpand, binding.betaDesc, expanded, tokens); + } + } + private void renderAction() { Update update = getSelected(); if (update == null || !update.hasUpdate()) binding.cancel.setText(R.string.about_acknowledge); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/WebDialog.java b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/WebDialog.java index 704b5343ff..b196e03956 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/WebDialog.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/dialog/WebDialog.java @@ -6,7 +6,7 @@ import android.view.View; import androidx.appcompat.app.AlertDialog; import com.fongmi.android.tv.App; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; public class WebDialog { @@ -17,7 +17,7 @@ public class WebDialog { } private WebDialog(View view) { - this.dialog = new MaterialAlertDialogBuilder(App.activity()).setView(view).create(); + this.dialog = new WebHtvAlertDialogBuilder(App.activity()).setView(view).create(); this.dialog.setOnDismissListener((DialogInterface.OnDismissListener) 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..17a28a849b 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 @@ -42,6 +42,7 @@ import com.fongmi.android.tv.databinding.FragmentCollectBinding; import com.fongmi.android.tv.model.SearchProgress; import com.fongmi.android.tv.model.SiteViewModel; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.setting.SiteGroupOrderStore; import com.fongmi.android.tv.setting.SiteHealthStore; import com.fongmi.android.tv.ui.activity.FolderActivity; @@ -638,8 +639,11 @@ public class CollectFragment extends BaseFragment implements MenuProvider, Colle } private GradientDrawable getGroupPopupBackground() { + // 同 SearchFragment.getScopePopupBackground():面板写死 Color.WHITE 时,深色模式下 + // 近白字的 colorOnSurface 压在白底上只有 1.29:1,分组下拉根本看不清。 + // 面板与 item 文字改用同一张表(M3 菜单容器 colorSurfaceContainer)。 GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(Color.WHITE); + drawable.setColor(ThemeController.current().colorSurfaceContainer()); drawable.setCornerRadius(ResUtil.dp2px(6)); return drawable; } @@ -666,7 +670,7 @@ public class CollectFragment extends BaseFragment implements MenuProvider, Colle view.setSingleLine(true); view.setGravity(Gravity.CENTER_VERTICAL); view.setIncludeFontPadding(false); - view.setTextColor(0xFF202124); + view.setTextColor(ThemeController.current().colorOnSurface()); view.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16); view.setPadding(ResUtil.dp2px(18), 0, ResUtil.dp2px(18), 0); view.setBackgroundResource(getSelectableItemBackground()); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SearchFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SearchFragment.java index 846b1e7225..e884aa261b 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SearchFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SearchFragment.java @@ -39,6 +39,7 @@ import com.fongmi.android.tv.bean.Word; import com.fongmi.android.tv.databinding.FragmentSearchBinding; import com.fongmi.android.tv.impl.Callback; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.ThemeController; import com.fongmi.android.tv.setting.SiteGroupOrderStore; import com.fongmi.android.tv.ui.adapter.HotWordAdapter; import com.fongmi.android.tv.ui.adapter.RecordAdapter; @@ -345,8 +346,12 @@ public class SearchFragment extends BaseFragment implements MenuProvider, WordAd } private GradientDrawable getScopePopupBackground() { + // 面板不能再写死 Color.WHITE:item 文字取主题的 colorOnSurface,深色模式下那是近白色 + // (#E2E2E9),压在写死的白底上实测只有 1.29:1,就是用户报告的「深色模式搜索界面 + // 的分组下拉界面看不清文字」。面板改用 M3 菜单容器角色 colorSurfaceContainer,与 item + // 文字来自同一张日夜表,ThemeResolver 另有一道 onSurface/surfaceContainer ≥ 4.5:1 的门。 GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(Color.WHITE); + drawable.setColor(ThemeController.current().colorSurfaceContainer()); drawable.setCornerRadius(ResUtil.dp2px(6)); return drawable; } @@ -373,7 +378,7 @@ public class SearchFragment extends BaseFragment implements MenuProvider, WordAd view.setSingleLine(true); view.setGravity(Gravity.CENTER_VERTICAL); view.setIncludeFontPadding(false); - view.setTextColor(0xFF202124); + view.setTextColor(ThemeController.current().colorOnSurface()); view.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16); view.setPadding(ResUtil.dp2px(18), 0, ResUtil.dp2px(18), 0); view.setBackgroundResource(getSelectableItemBackground()); 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..7c8c042b07 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 @@ -1,64 +1,32 @@ package com.fongmi.android.tv.ui.fragment; -import android.content.ContentResolver; -import android.net.Uri; -import android.text.InputType; -import android.text.TextUtils; -import android.view.Gravity; import android.view.LayoutInflater; +import android.util.TypedValue; import android.view.View; import android.view.ViewGroup; -import android.widget.EditText; 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.app.AlertDialog; import androidx.viewbinding.ViewBinding; -import com.fongmi.android.tv.App; import com.fongmi.android.tv.R; -import com.fongmi.android.tv.ad.audio.AdAudioRuleSnapshot; -import com.fongmi.android.tv.ad.audio.AdAudioRuleStore; -import com.fongmi.android.tv.ad.audio.AdAudioSetting; -import com.fongmi.android.tv.ad.audio.AdSkipPolicyController; -import com.fongmi.android.tv.ad.audio.ProbeRuleDownloader; -import com.fongmi.android.tv.ad.audio.ProbeRuleStore; -import com.fongmi.android.tv.ad.audio.SpeechAdConfig; -import com.fongmi.android.tv.ad.audio.SpeechAdSetting; 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.databinding.FragmentSettingAdBinding; -import com.fongmi.android.tv.server.Server; -import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.player.IntroSkipKinds; import com.fongmi.android.tv.setting.Setting; -import com.fongmi.android.tv.subtitle.RealtimeSubtitleSpeechRecognitionFactory; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.AdBlockStatsDialog; import com.fongmi.android.tv.ui.dialog.AdRuleManageDialog; -import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; -import com.fongmi.android.tv.utils.Task; import com.github.catvod.crawler.SpiderDebug; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import java.util.Locale; public class SettingAdFragment extends BaseFragment { private FragmentSettingAdBinding mBinding; private String[] introSkipMode; - private volatile AdAudioRuleSnapshot adAudioSnapshot = AdAudioRuleStore.get().current(); - private volatile long probeRevision; - 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(); @@ -77,17 +45,6 @@ public class SettingAdFragment extends BaseFragment { protected void initView() { introSkipMode = ResUtil.getStringArray(R.array.select_auto_skip_intro_outro); setText(); - loadSnapshots(); - } - - /** 规则文件的读取和解析都在磁盘上,只能异步做,结果缓存在字段里供 setText 使用。 */ - private void loadSnapshots() { - Task.execute(() -> { - adAudioSnapshot = AdAudioRuleStore.get().load(); - probeRuleCount = ProbeRuleStore.get().load().ruleSet().rules().size(); - probeRevision = ProbeRuleStore.get().revision(); - App.post(this::setText); - }); } @Override @@ -96,17 +53,6 @@ public class SettingAdFragment extends BaseFragment { mBinding.aiAdDetection.setOnClickListener(this::setAiAdDetection); mBinding.adRuleManage.setOnClickListener(view -> AdRuleManageDialog.create().show(requireActivity(), this::setText)); mBinding.adBlockStats.setOnClickListener(view -> AdBlockStatsDialog.create(requireActivity()).show()); - mBinding.adAudioFingerprint.setOnClickListener(this::toggleAdAudioFingerprint); - mBinding.adAudioFingerprint.setOnLongClickListener(this::manageAdAudioRules); - mBinding.adAudioAutoSkip.setOnClickListener(this::toggleAdAudioAutoSkip); - mBinding.probeRuleSource.setOnClickListener(this::editProbeRuleSource); - 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); mBinding.introSkipKinds.setOnClickListener(view -> IntroSkipKinds.show(requireActivity(), this::setText)); } @@ -117,37 +63,11 @@ public class SettingAdFragment extends BaseFragment { safeSet("adblock", mBinding.adblockText, () -> getSwitch(Setting.isAdblock())); safeRun("aiAdDetection", () -> { mBinding.aiAdDetection.setVisibility(Setting.isAiConfigReady() ? View.VISIBLE : View.GONE); - mBinding.aiAdDetectionText.setText(getSwitch(Setting.isAiAdDetection())); + setSummary(mBinding.aiAdDetectionText, getSwitch(Setting.isAiAdDetection())); }, () -> setError(mBinding.aiAdDetectionText)); safeSet("adRuleManage", mBinding.adRuleManageText, () -> getString(R.string.ad_rule_count_with_pending, UserAdRuleStore.load().size() + RuleConfig.get().getDefaultRules().size(), ImportedAdRuleCandidateStore.pending().size())); - safeSet("adAudioFingerprint", mBinding.adAudioFingerprintText, this::getAdAudioFingerprintText); - safeSet("adAudioAutoSkip", mBinding.adAudioAutoSkipText, () -> getSwitch(AdAudioSetting.isAutoSkipEnabled())); - safeSet("probeRuleSource", mBinding.probeRuleSourceText, this::getProbeRuleSourceText); - 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); - }); safeSet("autoSkipIntroOutro", mBinding.autoSkipIntroOutroText, () -> introSkipMode[Setting.getIntroSkipMode()]); safeSet("introSkipKinds", mBinding.introSkipKindsText, IntroSkipKinds::summary); } @@ -157,7 +77,22 @@ public class SettingAdFragment extends BaseFragment { } private void safeSet(String name, TextView view, TextSupplier supplier) { - safeRun(name, () -> view.setText(supplier.get()), () -> setError(view)); + safeRun(name, () -> setSummary(view, supplier.get()), () -> setError(view)); + } + + /** + * 写入取值摘要,并把颜色与底材复位到常规状态。 + * + *

复位这一步是必需的:{@link #setError} 会把同一个 TextView 染成错误色并换上一块深底, + * 而这些摘要全部**复用同一个控件**——不复位的话,某一行失败一次之后,即使后续刷新成功, + * 它也会一直显示为红字加深底。 + */ + private void setSummary(TextView view, CharSequence text) { + if (view == null) return; + view.setText(text); + view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaper)); + // 这些 TextView 在布局里原本都没有背景,所以置空就是"恢复原样"。 + view.setBackground(null); } private void safeRun(String name, Runnable action, Runnable fallback) { @@ -173,8 +108,27 @@ public class SettingAdFragment extends BaseFragment { } } + /** + * 内联错误:设计文档 §9.9 要求错误态有「错误色」。 + * + *

不能直接用 {@code ?attr/colorError}:那是跟随调色板的角色,浅色表的深红 + * ({@code #B3261E})压在深色壁纸行色带上实测只有约 1.06:1,完全不可读。这里用与壁纸 + * 同族的 {@code ?attr/webhtvColorOnWallpaperError},并同时换上一块**实心深底** + * ({@code @drawable/shape_error_value})——错误色直接画在行色带上只有 4.05–4.50:1, + * 略低于正文门槛 4.5:1;实心底上是 8.84:1,稳过门槛。底材是实心色,因此这个比值与 + * flavor 无关,与电视端共用同一份 drawable 与同一个角色。 + */ private void setError(TextView view) { - if (view != null) view.setText(R.string.error_config_get); + if (view == null) return; + view.setText(R.string.error_config_get); + view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaperError)); + view.setBackgroundResource(R.drawable.shape_error_value); + } + + /** 解析主题属性里的颜色值;未赋值时返回 0(透明)。 */ + private int themeColor(int attr) { + TypedValue value = new TypedValue(); + return requireContext().getTheme().resolveAttribute(attr, value, true) ? value.data : 0; } private interface TextSupplier { @@ -196,385 +150,6 @@ public class SettingAdFragment extends BaseFragment { setText(); } - private void toggleAdAudioAutoSkip(View view) { - AdAudioSetting.setAutoSkipEnabled(!AdAudioSetting.isAutoSkipEnabled()); - notifyAdAudioRuntime(); - setText(); - } - - private String getAdAudioFingerprintText() { - String enabled = getSwitch(AdAudioSetting.isEnabled()); - AdAudioRuleSnapshot snapshot = adAudioSnapshot; - if (snapshot == null || snapshot.hasError()) { - return enabled + " · " + getString(R.string.setting_ad_audio_rule_error); - } - int count = snapshot.ruleSet().rules().size(); - if (count == 0) return enabled + " · " + getString(R.string.setting_ad_audio_no_rules); - return enabled + " · " + getString(R.string.setting_ad_audio_rule_count, count, snapshot.version()); - } - - private void toggleAdAudioFingerprint(View view) { - AdAudioSetting.setEnabled(!AdAudioSetting.isEnabled()); - notifyAdAudioRuntime(); - setText(); - } - - private boolean manageAdAudioRules(View view) { - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.setting_ad_audio_title) - .setItems(new String[]{ - getString(R.string.setting_ad_audio_import), - getString(R.string.setting_ad_audio_clear) - }, (dialog, which) -> { - if (which == 0) adAudioRulePicker.launch(new String[]{"application/json", "text/json"}); - else confirmClearAdAudioRules(); - }) - .setNegativeButton(R.string.dialog_negative, null) - .show(); - return true; - } - - private void importAdAudioRules(Uri uri) { - if (uri == null || !canSetText()) return; - ContentResolver resolver = requireContext().getContentResolver(); - Task.execute(() -> { - AdAudioRuleSnapshot imported = null; - try { - imported = AdAudioRuleStore.get().importUri(resolver, uri); - adAudioSnapshot = imported; - } catch (Exception e) { - SpiderDebug.log("ad-audio-rules", e); - } - AdAudioRuleSnapshot result = imported; - App.post(() -> { - if (!canSetText()) return; - Notify.show(result == null ? getString(R.string.setting_ad_audio_import_failed) - : getString(R.string.setting_ad_audio_imported, result.ruleSet().rules().size(), result.version())); - notifyAdAudioRuntime(); - setText(); - }); - }); - } - - private void confirmClearAdAudioRules() { - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.setting_ad_audio_clear) - .setMessage(R.string.setting_ad_audio_clear_confirm) - .setPositiveButton(R.string.dialog_positive, (dialog, which) -> Task.execute(() -> { - boolean success = true; - try { - adAudioSnapshot = AdAudioRuleStore.get().clear(); - } catch (RuntimeException e) { - SpiderDebug.log("ad-audio-rules", e); - success = false; - } - boolean cleared = success; - App.post(() -> { - if (!canSetText()) return; - Notify.show(cleared ? R.string.setting_ad_audio_clear_done : R.string.setting_ad_audio_import_failed); - notifyAdAudioRuntime(); - setText(); - }); - })) - .setNegativeButton(R.string.dialog_negative, null) - .show(); - } - - private String getProbeRuleSourceText() { - String url = AdAudioSetting.getProbeRuleUrl(); - if (TextUtils.isEmpty(url)) return getString(R.string.setting_ad_probe_source_off); - if (AdAudioSetting.DEFAULT_PROBE_RULE_URL.equals(url)) return getString(R.string.setting_ad_probe_source_default); - return url; - } - - /** 空字符串表示关闭社区规则源;非空时只接受 https,规则没有签名,传输层是唯一的真实性保障。 */ - private void editProbeRuleSource(View view) { - EditText input = new EditText(requireContext()); - input.setSingleLine(true); - input.setText(AdAudioSetting.getProbeRuleUrl()); - input.setSelection(input.length()); - AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.setting_ad_probe_source) - .setMessage(R.string.setting_ad_probe_source_hint) - .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 -> { - String value = input.getText().toString().trim(); - if (!value.isEmpty() && !value.toLowerCase(Locale.ROOT).startsWith("https://")) { - Notify.show(R.string.setting_ad_probe_url_invalid); - return; - } - AdAudioSetting.setProbeRuleUrl(value); - setText(); - alert.dismiss(); - })); - alert.show(); - } - - private String getProbeRuleRefreshText() { - if (probeRevision <= 0L) return getString(R.string.setting_ad_probe_never); - return getString(R.string.setting_ad_probe_revision, probeRevision, probeRuleCount); - } - - private void refreshProbeRules(View view) { - mBinding.probeRuleRefreshText.setText(R.string.setting_ad_probe_refreshing); - boolean started = ProbeRuleDownloader.refreshNow(new ProbeRuleDownloader.Callback() { - @Override - public void onSuccess(AdAudioRuleSnapshot snapshot) { - if (mBinding == null) return; - Notify.show(snapshot.hasRules() - ? getString(R.string.setting_ad_probe_refreshed, snapshot.version(), snapshot.ruleSet().rules().size()) - : getString(R.string.setting_ad_probe_unchanged, 0)); - // loadSnapshots() 自己会在后台读完后 post setText(),这里不能再直接调, - // 否则会用刷新前的 revision 覆盖掉「正在刷新」,闪一下旧值。 - loadSnapshots(); - notifyAdAudioRuntime(); - } - - @Override - public void onFailure(Throwable error) { - if (mBinding == null) return; - Notify.show(getString(R.string.setting_ad_probe_failed, String.valueOf(error.getMessage()))); - setText(); - } - - @Override - public void onDisabled() { - if (mBinding == null) return; - Notify.show(R.string.setting_ad_probe_source_disabled); - setText(); - } - }); - // 已有刷新在跑时不会有回调,这里必须自己复位,否则文本永远停在「正在刷新」。 - if (!started) setText(); - } - - private void toggleSpeechAdEnabled(View view) { - SpeechAdSetting.setEnabled(!SpeechAdSetting.snapshot().enabled()); - notifyAdAudioRuntime(); - 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()); - input.setSingleLine(false); - input.setMinLines(3); - input.setGravity(Gravity.TOP); - input.setText(String.join(",", speech.keywords().values())); - input.setSelection(input.length()); - AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_keywords) - .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 -> { - SpeechAdSetting.setKeywords(input.getText().toString()); - notifyAdAudioRuntime(); - setText(); - alert.dismiss(); - })); - alert.show(); - } - - private void editSpeechAdSkipSeconds(View view) { - SpeechAdConfig speech = SpeechAdSetting.snapshot(); - EditText input = new EditText(requireContext()); - input.setInputType(InputType.TYPE_CLASS_NUMBER); - input.setText(String.valueOf(speech.skipSeconds())); - input.setSelectAllOnFocus(true); - AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_skip_seconds) - .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 -> { - final int value; - try { - value = Integer.parseInt(input.getText().toString().trim()); - } catch (RuntimeException e) { - input.setError(getString(R.string.speech_ad_skip_seconds_invalid)); - input.requestFocus(); - return; - } - int normalized = SpeechAdConfig.create(false, "", value, AdSkipPolicyController.Mode.PROMPT.name()).skipSeconds(); - if (normalized != value) { - input.setError(getString(R.string.speech_ad_skip_seconds_invalid)); - input.requestFocus(); - return; - } - SpeechAdSetting.setSkipSeconds(value); - notifyAdAudioRuntime(); - setText(); - alert.dismiss(); - })); - alert.show(); - } - - private void selectSpeechAdSkipMode(View view) { - SpeechAdConfig speech = SpeechAdSetting.snapshot(); - String[] modes = {getString(R.string.speech_ad_skip_mode_prompt), getString(R.string.speech_ad_skip_mode_auto)}; - int checked = speech.mode() == AdSkipPolicyController.Mode.AUTO ? 1 : 0; - new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog) - .setTitle(R.string.speech_ad_skip_mode) - .setNegativeButton(R.string.dialog_negative, null) - .setSingleChoiceItems(modes, checked, (dialog, which) -> { - SpeechAdSetting.setMode(which == 1 ? AdSkipPolicyController.Mode.AUTO : AdSkipPolicyController.Mode.PROMPT); - notifyAdAudioRuntime(); - setText(); - dialog.dismiss(); - }).show(); - } - - private void notifyAdAudioRuntime() { - PlaybackService service = Server.get().getService(); - if (service == null || service.player() == null || service.player().isReleased()) return; - service.player().reloadAdAudioSettings(); - } - @Override public void onHiddenChanged(boolean hidden) { if (!hidden) setText(); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAiFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAiFragment.java index 526ccfc5df..c990d33bf6 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAiFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAiFragment.java @@ -15,13 +15,13 @@ import com.fongmi.android.tv.databinding.FragmentSettingAiBinding; import com.fongmi.android.tv.service.RecommendationFeedbackStore; import com.fongmi.android.tv.setting.Setting; import com.fongmi.android.tv.subtitle.RealtimeSubtitleController; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.AiConfigDialog; import com.fongmi.android.tv.ui.dialog.RecommendationFeedbackDialog; import com.fongmi.android.tv.ui.dialog.SubtitleSettingsDialog; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.ResUtil; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; public class SettingAiFragment extends BaseFragment { @@ -87,7 +87,7 @@ public class SettingAiFragment extends BaseFragment { setText(); return; } - new MaterialAlertDialogBuilder(requireContext()) + new WebHtvAlertDialogBuilder(requireContext()) .setTitle(R.string.personal_recommendation_confirm_title) .setMessage(R.string.personal_recommendation_confirm_message) .setNegativeButton(R.string.dialog_negative, null) diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingEnhanceFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingEnhanceFragment.java index a2362c0347..72c61b826e 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingEnhanceFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingEnhanceFragment.java @@ -2,6 +2,7 @@ package com.fongmi.android.tv.ui.fragment; import android.content.Intent; import android.net.Uri; +import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -196,8 +197,8 @@ public class SettingEnhanceFragment extends BaseFragment { private void setShellProxyText() { safeRun("shellProxy", () -> { int count = ProxySetting.count(); - mBinding.shellProxyText.setText(getSwitch(Setting.isShellProxy()) + " · " + getString(R.string.setting_proxy_rule_count, count)); - mBinding.shellProxyConfigText.setText(getString(R.string.setting_proxy_rule_count, count)); + setSummary(mBinding.shellProxyText, getSwitch(Setting.isShellProxy()) + " · " + getString(R.string.setting_proxy_rule_count, count)); + setSummary(mBinding.shellProxyConfigText, getString(R.string.setting_proxy_rule_count, count)); }, () -> { setError(mBinding.shellProxyText); setError(mBinding.shellProxyConfigText); @@ -212,12 +213,27 @@ public class SettingEnhanceFragment extends BaseFragment { return; } CustomCspSetting.Count count = status.count(); - mBinding.customCspText.setText(getSwitch(status.enabled()) + " · " + getString(R.string.setting_custom_csp_count, count.active(), count.enabled())); + setSummary(mBinding.customCspText, getSwitch(status.enabled()) + " · " + getString(R.string.setting_custom_csp_count, count.active(), count.enabled())); }, () -> setError(mBinding.customCspText)); } private void safeSet(String name, TextView view, TextSupplier supplier) { - safeRun(name, () -> view.setText(supplier.get()), () -> setError(view)); + safeRun(name, () -> setSummary(view, supplier.get()), () -> setError(view)); + } + + /** + * 写入取值摘要,并把颜色与底材复位到常规状态。 + * + *

复位这一步是必需的:{@link #setError} 会把同一个 TextView 染成错误色并换上一块深底, + * 而这些摘要全部**复用同一个控件**——不复位的话,某一行失败一次之后,即使后续刷新成功, + * 它也会一直显示为红字加深底。 + */ + private void setSummary(TextView view, CharSequence text) { + if (view == null) return; + view.setText(text); + view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaper)); + // 这些 TextView 在布局里原本都没有背景,所以置空就是"恢复原样"。 + view.setBackground(null); } private void safeRun(String name, Runnable action, Runnable fallback) { @@ -233,8 +249,27 @@ public class SettingEnhanceFragment extends BaseFragment { } } + /** + * 内联错误:设计文档 §9.9 要求错误态有「错误色」。 + * + *

不能直接用 {@code ?attr/colorError}:那是跟随调色板的角色,浅色表的深红 + * ({@code #B3261E})压在深色壁纸行色带上实测只有约 1.06:1,完全不可读。这里用与壁纸 + * 同族的 {@code ?attr/webhtvColorOnWallpaperError},并同时换上一块**实心深底** + * ({@code @drawable/shape_error_value})——错误色直接画在行色带上只有 4.05–4.50:1, + * 略低于正文门槛 4.5:1;实心底上是 8.84:1,稳过门槛。底材是实心色,因此这个比值与 + * flavor 无关,与电视端共用同一份 drawable 与同一个角色。 + */ private void setError(TextView view) { - if (view != null) view.setText(R.string.error_config_get); + if (view == null) return; + view.setText(R.string.error_config_get); + view.setTextColor(themeColor(R.attr.webhtvColorOnWallpaperError)); + view.setBackgroundResource(R.drawable.shape_error_value); + } + + /** 解析主题属性里的颜色值;未赋值时返回 0(透明)。 */ + private int themeColor(int attr) { + TypedValue value = new TypedValue(); + return requireContext().getTheme().resolveAttribute(attr, value, true) ? value.data : 0; } private interface TextSupplier { @@ -243,12 +278,12 @@ public class SettingEnhanceFragment extends BaseFragment { private void setDriveCheck(View view) { Setting.putDriveCheck(!Setting.isDriveCheck()); - mBinding.driveCheckText.setText(getSwitch(Setting.isDriveCheck())); + setSummary(mBinding.driveCheckText, getSwitch(Setting.isDriveCheck())); } private void setDebugLog(View view) { Setting.putDebugLog(!Setting.isDebugLog()); - mBinding.debugLogText.setText(getSwitch(Setting.isDebugLog())); + setSummary(mBinding.debugLogText, getSwitch(Setting.isDebugLog())); if (!Setting.isDebugLog()) return; DebugLogDialog.show(this); } @@ -288,12 +323,12 @@ public class SettingEnhanceFragment extends BaseFragment { private void setPlaybackArtworkWall(View view) { Setting.putPlaybackArtworkWall(!Setting.isPlaybackArtworkWall()); - mBinding.playbackArtworkWallText.setText(getSwitch(Setting.isPlaybackArtworkWall())); + setSummary(mBinding.playbackArtworkWallText, getSwitch(Setting.isPlaybackArtworkWall())); } private void setWebHomeFullscreen(View view) { Setting.putWebHomeFullscreen(!Setting.isWebHomeFullscreen()); - mBinding.webHomeFullscreenText.setText(getSwitch(Setting.isWebHomeFullscreen())); + setSummary(mBinding.webHomeFullscreenText, getSwitch(Setting.isWebHomeFullscreen())); if (requireActivity() instanceof HomeActivity activity) { if (!Setting.isWebHomeFullscreen()) { JsonObject object = new JsonObject(); 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..ec347ce553 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 @@ -19,7 +19,9 @@ import com.fongmi.android.tv.bean.Live; 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.setting.ConfigSyncPolicy; 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; @@ -30,13 +32,15 @@ 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; 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.utils.AppVersion; +import com.fongmi.android.tv.cache.CacheCenter; +import com.fongmi.android.tv.cache.CachePolicyStore; +import com.fongmi.android.tv.ui.dialog.CacheManagementDialog; import com.fongmi.android.tv.utils.FileUtil; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PermissionUtil; @@ -98,10 +102,23 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit } private void setCacheText() { - FileUtil.getCacheSize(new Callback() { - @Override - public void success(String result) { - mBinding.cacheText.setText(result); + if (!CachePolicyStore.isManagementEnabled()) { + FileUtil.getCacheSize(new Callback() { + @Override + public void success(String result) { + if (mBinding != null && isAdded()) mBinding.cacheText.setText(result); + } + }); + return; + } + mBinding.cacheText.setText(R.string.cache_management_scanning); + CacheCenter.get().requestSnapshot(false, snapshot -> { + if (mBinding != null && isAdded()) { + String total = FileUtil.byteCountToDisplaySize(snapshot.totalBytes()); + long quota = snapshot.systemQuotaBytes(); + mBinding.cacheText.setText(quota > 0 + ? getString(R.string.cache_management_inline, total, FileUtil.byteCountToDisplaySize(quota)) + : total); } }); } @@ -113,7 +130,11 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit mBinding.live.setOnClickListener(this::onLive); mBinding.wall.setOnClickListener(this::onWall); mBinding.appearance.setOnClickListener(this::onAppearance); - mBinding.cache.setOnClickListener(this::onCache); + mBinding.cache.setOnClickListener(this::onCacheClear); + // The plain click restores the pre-split one-key clear; the panel moved to the long press. + // The row stays single-line by user request, so §15.2's "not long-press only" intent is a + // recorded deviation rather than a printed hint (docs/CACHE-MGMT-01-cache-management-design.md). + mBinding.cache.setOnLongClickListener(this::onCacheManage); mBinding.backup.setOnClickListener(this::onBackup); mBinding.enhance.setOnClickListener(this::onEnhance); mBinding.tmdb.setOnClickListener(this::onTmdb); @@ -125,11 +146,8 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit mBinding.subtitle.setOnClickListener(this::onSubtitle); mBinding.restore.setOnClickListener(this::onRestore); mBinding.version.setOnClickListener(this::onVersion); - mBinding.vod.setOnLongClickListener(this::onVodEdit); mBinding.vodHome.setOnClickListener(this::onVodHome); - mBinding.live.setOnLongClickListener(this::onLiveEdit); mBinding.liveHome.setOnClickListener(this::onLiveHome); - mBinding.wall.setOnLongClickListener(this::onWallEdit); mBinding.incognito.setOnClickListener(this::setIncognito); mBinding.vodHistory.setOnClickListener(this::onVodHistory); mBinding.liveHistory.setOnClickListener(this::onLiveHistory); @@ -152,7 +170,12 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit private void load(Config config) { switch (config.getType()) { case 0: + String previousVodUrl = VodConfig.getUrl(); VodConfig.load(config, getCallback()); + if (ConfigSyncPolicy.shouldSyncLive(previousVodUrl, LiveConfig.getUrl())) { + Config liveConfig = AppDatabase.get().getConfigDao().find(config.getUrl(), 1); + if (liveConfig != null) LiveConfig.load(liveConfig, new Callback()); + } break; case 1: LiveConfig.load(config, getCallback()); @@ -196,30 +219,15 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit } private void onVod(View view) { - ConfigDialog.create().vod().show(this); + HistoryDialog.create().vod().manage().show(this); } private void onLive(View view) { - ConfigDialog.create().live().show(this); + HistoryDialog.create().live().manage().show(this); } private void onWall(View view) { - ConfigDialog.create().wall().show(this); - } - - private boolean onVodEdit(View view) { - ConfigDialog.create().vod().edit().show(this); - return true; - } - - private boolean onLiveEdit(View view) { - ConfigDialog.create().live().edit().show(this); - return true; - } - - private boolean onWallEdit(View view) { - ConfigDialog.create().wall().edit().show(this); - return true; + HistoryDialog.create().wall().manage().show(this); } private void onVodHome(View view) { @@ -313,13 +321,45 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit mBinding.dohText.setText(doh.getName()); } - private void onCache(View view) { - FileUtil.clearCache(new Callback() { - @Override - public void success() { - setCacheText(); - } - }); + /** + * The settings row's plain click: clear every cache at once, the way this row behaved before it + * opened the cache management panel. + * + *

The full clean runs in the background and reports itself through a notification without + * opening the panel; the row re-reads itself when the cleanup publishes its change. With cache + * management switched off the legacy {@link FileUtil#clearCache} path is kept, because the panel + * and its cleanup engine belong to the feature that switch turns off.

+ */ + private void onCacheClear(View view) { + if (!CachePolicyStore.isManagementEnabled()) { + FileUtil.clearCache(new Callback() { + @Override + public void success() { + setCacheText(); + } + }); + return; + } + CacheManagementDialog.cleanEverything(); + } + + /** + * The settings row's long press: open the cache management panel (graded cleanup, module + * limits, auto cleanup and the running state). + * + *

With the plain click now clearing everything this is the panel's only entry. The row stays + * single-line by user request, so the gesture is not printed under the title and the panel is + * reached by the long press alone (deviation recorded in + * docs/CACHE-MGMT-01-cache-management-design.md). With cache management switched off there is + * no panel to open, so the long press keeps the legacy behaviour and repeats the plain clear.

+ */ + private boolean onCacheManage(View view) { + if (!CachePolicyStore.isManagementEnabled()) { + onCacheClear(view); + return true; + } + CacheManagementDialog.show(this); + return true; } private void onBackup(View view) { @@ -384,6 +424,19 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit setWallText(); } + /** + * Re-reads the cache inventory the moment a cleanup (or any other cache mutation) finishes. + * + *

The settings row used to keep showing the pre-cleanup value until the user switched to + * another page and back, because nothing told it the cache had changed.

+ */ + @Subscribe(threadMode = ThreadMode.MAIN) + public void onRefreshEvent(RefreshEvent event) { + if (event.getType() != RefreshEvent.Type.CACHE) return; + if (mBinding == null || !isAdded()) return; + setCacheText(); + } + private void setWallText() { mBinding.wallUrl.setText(Setting.getWallDesc(WallConfig.getDesc())); } @@ -398,5 +451,6 @@ public class SettingFragment extends BaseFragment implements ConfigListener, Sit public void onDestroyView() { super.onDestroyView(); EventBus.getDefault().unregister(this); + mBinding = null; } } 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..6a6413c984 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 @@ -1,6 +1,7 @@ package com.fongmi.android.tv.ui.fragment; import android.view.LayoutInflater; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; @@ -19,6 +20,7 @@ import com.fongmi.android.tv.setting.AutoBackupPolicy; import com.fongmi.android.tv.setting.GroupRuleConfig; import com.fongmi.android.tv.setting.PlayerSetting; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; import com.fongmi.android.tv.ui.activity.AppBrandingActivity; import com.fongmi.android.tv.ui.base.BaseFragment; import com.fongmi.android.tv.ui.dialog.GroupRuleDialog; @@ -27,7 +29,6 @@ import com.fongmi.android.tv.ui.dialog.SliderNumberDialog; import com.fongmi.android.tv.utils.Notify; import com.fongmi.android.tv.utils.PermissionUtil; import com.fongmi.android.tv.utils.Util; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.Locale; @@ -40,6 +41,7 @@ public class SettingPersonalFragment extends BaseFragment { private String[] globalHistoryMode; private String[] interfaceFailoverMode; private String[] searchResultSort; + private boolean globalHistoryTouchStarted; public static SettingPersonalFragment newInstance() { return new SettingPersonalFragment(); @@ -67,7 +69,18 @@ public class SettingPersonalFragment extends BaseFragment { mBinding.playbackOverlay.setOnClickListener(this::setPlaybackOverlay); mBinding.playBackToDetail.setOnClickListener(this::setPlayBackToDetail); mBinding.episodeHistory.setOnClickListener(this::setEpisodeHistory); - mBinding.globalHistory.setOnClickListener(this::setGlobalHistory); + // Require a touch sequence which starts on this row. Keep the marker through + // ACTION_UP so the following real click can change the persisted mode. + mBinding.globalHistory.setOnTouchListener((view, event) -> { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) globalHistoryTouchStarted = true; + else if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) globalHistoryTouchStarted = false; + return false; + }); + mBinding.globalHistory.setOnClickListener(view -> { + if (!globalHistoryTouchStarted) return; + globalHistoryTouchStarted = false; + setGlobalHistory(view); + }); mBinding.interfaceFailover.setOnClickListener(this::setInterfaceFailover); mBinding.playSpeed.setOnClickListener(this::setPlaySpeed); mBinding.groupRule.setOnClickListener(this::setGroupRule); @@ -77,7 +90,6 @@ 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() { @@ -96,7 +108,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() { @@ -213,7 +224,7 @@ public class SettingPersonalFragment extends BaseFragment { } private void showResetAppDialog(View view) { - new MaterialAlertDialogBuilder(requireActivity()) + new WebHtvAlertDialogBuilder(requireActivity()) .setTitle(R.string.dialog_reset_app) .setMessage(R.string.dialog_reset_app_data) .setNegativeButton(R.string.dialog_negative, null) @@ -225,12 +236,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..6a42ee5d56 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 @@ -90,6 +90,7 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B setPreloadText(); mBinding.autoPlayText.setText(getSwitch(PlayerSetting.isAutoPlay())); mBinding.autoChangeText.setText(getSwitch(PlayerSetting.isAutoChange())); + mBinding.deferFlagSwitchText.setText(getSwitch(PlayerSetting.isDeferFlagSwitchPlayback())); 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()]); @@ -140,6 +141,7 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.preloadPause.setOnClickListener(this::onPreloadPause); mBinding.autoPlay.setOnClickListener(this::setAutoPlay); mBinding.autoChange.setOnClickListener(this::setAutoChange); + mBinding.deferFlagSwitch.setOnClickListener(this::setDeferFlagSwitch); mBinding.liveSourceFallback.setOnClickListener(view -> { LiveSetting.putSourceFallback(!LiveSetting.isSourceFallback()); mBinding.liveSourceFallbackText.setText(getSwitch(LiveSetting.isSourceFallback())); @@ -438,6 +440,11 @@ public class SettingPlayerFragment extends BaseFragment implements UaListener, B mBinding.autoChangeText.setText(getSwitch(PlayerSetting.isAutoChange())); } + private void setDeferFlagSwitch(View view) { + PlayerSetting.putDeferFlagSwitchPlayback(!PlayerSetting.isDeferFlagSwitchPlayback()); + mBinding.deferFlagSwitchText.setText(getSwitch(PlayerSetting.isDeferFlagSwitchPlayback())); + } + private void setRememberBrightness(View view) { PlayerSetting.putRememberBrightness(!PlayerSetting.isRememberBrightness()); mBinding.rememberBrightnessText.setText(getSwitch(PlayerSetting.isRememberBrightness())); 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..20d6896132 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 @@ -13,10 +13,10 @@ import com.fongmi.android.tv.R; import com.fongmi.android.tv.databinding.FragmentSettingTmdbBinding; import com.fongmi.android.tv.event.RefreshEvent; import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.theme.WebHtvAlertDialogBuilder; 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.google.android.material.dialog.MaterialAlertDialogBuilder; public class SettingTmdbFragment extends BaseFragment { @@ -92,7 +92,7 @@ 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) -> { + AlertDialog alert = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog).setTitle(R.string.setting_detail_open_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailOpenModes(), getDetailOpenModeIndex(), (dialog, which) -> { int mode = DETAIL_OPEN_MODES[which]; Setting.putDetailOpenMode(mode); setText(); @@ -109,7 +109,7 @@ public class SettingTmdbFragment extends BaseFragment { private void setDetailThemeMode(View view) { if (!Setting.isTmdbMode(Setting.getDetailOpenMode())) return; - AlertDialog alert = new MaterialAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_LightDialog).setTitle(R.string.setting_detail_theme_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailThemeModes(), getDetailThemeModeIndex(), (dialog, which) -> { + AlertDialog alert = new WebHtvAlertDialogBuilder(requireActivity(), R.style.Theme_WebHTV_Dialog).setTitle(R.string.setting_detail_theme_mode).setNegativeButton(R.string.dialog_negative, null).setSingleChoiceItems(getDetailThemeModes(), getDetailThemeModeIndex(), (dialog, which) -> { Setting.putTmdbDetailStyle(DETAIL_THEME_MODES[which]); setText(); dialog.dismiss(); diff --git a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/VodFragment.java b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/VodFragment.java index 1a09889967..57fd9b38cf 100644 --- a/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/VodFragment.java +++ b/app/src/mobile/java/com/fongmi/android/tv/ui/fragment/VodFragment.java @@ -1,6 +1,7 @@ package com.fongmi.android.tv.ui.fragment; import android.net.Uri; +import android.app.Activity; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -428,10 +429,26 @@ public class VodFragment extends BaseFragment implements ConfigListener, SiteLis if (event.type() == ConfigEvent.Type.VOD) setLogo(); } + /** + * 状态浮层只能被「又一次启动 / 点重试」收掉,而配置可能在别的路径上变得可用: + * 恢复备份、远程指令切换接口、设置页切换接口、接收推送接口。这些路径发的是 + * {@link RefreshEvent#home()} 而不是 StateEvent,于是用户在这些页面上操作完回到首页, + * 首页仍顶着那张状态卡,**必须杀进程冷启动才恢复**(用户反馈的"杀进程后重进才行")。 + * + *

这里把「配置真的可用了」当成收卡的唯一判据:HOME 刷新意味着本 Fragment 马上会 + * 重新加载首页内容,此时若还处在空态/错误态就直接收起,不依赖任何调用方记得收。 + */ + private void dismissStateIfConfigured() { + if (mBinding.state.getVisibility() != View.VISIBLE) return; + if (VodConfig.get().getSites().isEmpty()) return; + hideState(); + } + @Subscribe(threadMode = ThreadMode.MAIN) public void onRefreshEvent(RefreshEvent event) { switch (event.getType()) { case HOME: + dismissStateIfConfigured(); if (mWeb != null && mWeb.isVisible()) { Site home = getHome(); WebHomeChromeStartup.remember(getConfig(), home); @@ -460,14 +477,93 @@ public class VodFragment extends BaseFragment implements ConfigListener, SiteLis public void onStateEvent(StateEvent event) { switch (event.type()) { case EMPTY: + // 空态:确实没有内容(与"加载失败"是两件事)。 + // 改动前这里只 hideProgress(),于是首页同样是一片空白。 hideProgress(); + showEmpty(); break; case PROGRESS: + hideState(); showProgress(); break; + case CONTENT: + hideState(); + break; + case ERROR: + // 设计文档 §9.9:错误态要给出「错误色 + 原因 + 重试动作」。 + // 改动前这里只 hideProgress()——于是内容源加载失败后首页是**一片空白**, + // 用户只看到一个会消失的吐司,既不知道原因也没有重试入口。 + hideProgress(); + showError(event.message()); + break; + case NO_CONFIG: + // 还没配接口:不是失败,所以不给"重试"(它永远不会成功),也不接管首页—— + // 收起状态浮层回到原生内容态,底部导航照常可用;添加接口走既有的 + // 「设置 → 点播」管理面板。 + hideProgress(); + showNoConfig(); + break; } } + /** + * 显示空态:设计文档 §9.9 要求「标题 + 解释 + 一个主动作」。 + * + *

与错误态的区别是**没有失败原因**、标题也不用错误色——它表达的是"确实没有内容"。 + * 主动作与错误态的重试一样复用 {@link #homeContent()}。 + */ + private void showEmpty() { + mBinding.state.setVisibility(View.VISIBLE); + mBinding.state.setEmptyContent( + R.string.home_empty_title, + R.string.home_empty_description, + R.string.home_empty_action, + view -> homeContent()); + mBinding.state.showEmpty(); + } + + /** + * 显示错误态:错误色标题 + 失败原因 + 重试。 + * + *

重试必须回到失败的那一步。首页的错误来自承载本 Fragment 的 Activity 的 + * **内容源配置加载**(`HomeActivity.initConfig()`),不是本 Fragment 的内容加载—— + * 实机验证时最初把重试接成 `homeContent()`,结果点了重试页面毫无变化,因为配置根本没被重新拉取。 + * 因此这里通过 {@link ConfigRetry} 把动作交回 Activity;Activity 未实现时退回内容重载, + * 至少不会变成一个死按钮。 + */ + private void showError(String message) { + mBinding.state.setVisibility(View.VISIBLE); + mBinding.state.showError(R.string.error_config_load, message, R.string.error_retry, view -> retryConfig()); + } + + /** + * 还没配接口:这不是失败,也不该由本 Fragment 独占整屏。 + * + *

配置为空时首页仍然是"正常首页":底部导航(点播/设置)照常可用,只是没有内容。 + * 状态浮层只留给真正的加载失败——用它接管首页会把顶栏和内容一起盖掉。 + */ + private void showNoConfig() { + hideState(); + showNativeContent(); + } + + /** 重新加载内容源配置:交给承载本 Fragment 的 Activity(失败的那一步在它那里)。 */ + private void retryConfig() { + Activity activity = getActivity(); + if (activity instanceof ConfigRetry) ((ConfigRetry) activity).retryConfig(); + else homeContent(); + } + + /** 由承载本 Fragment 的 Activity 实现:重新加载内容源配置。 */ + public interface ConfigRetry { + void retryConfig(); + } + + /** 收起状态浮层,回到正常内容。 */ + private void hideState() { + mBinding.state.setVisibility(View.GONE); + } + @Subscribe(threadMode = ThreadMode.MAIN) public void onCastEvent(CastEvent event) { ReceiveDialog.create().event(event).show(this); diff --git a/app/src/mobile/res/drawable/selector_tmdb_flag_item.xml b/app/src/mobile/res/drawable/selector_tmdb_flag_item.xml index 42fbb46e77..0ff17ed2e5 100644 --- a/app/src/mobile/res/drawable/selector_tmdb_flag_item.xml +++ b/app/src/mobile/res/drawable/selector_tmdb_flag_item.xml @@ -2,29 +2,29 @@ - - + + - - + + - - + + - - + + diff --git a/app/src/mobile/res/drawable/selector_tmdb_flag_item_dark.xml b/app/src/mobile/res/drawable/selector_tmdb_flag_item_dark.xml index c0208236d5..f0d62d2e53 100644 --- a/app/src/mobile/res/drawable/selector_tmdb_flag_item_dark.xml +++ b/app/src/mobile/res/drawable/selector_tmdb_flag_item_dark.xml @@ -1,4 +1,20 @@ + diff --git a/app/src/mobile/res/layout-land/activity_video.xml b/app/src/mobile/res/layout-land/activity_video.xml index b34c43bfde..d3d85f4f94 100644 --- a/app/src/mobile/res/layout-land/activity_video.xml +++ b/app/src/mobile/res/layout-land/activity_video.xml @@ -46,6 +46,7 @@ style="@style/Player.Vod" android:layout_width="match_parent" android:layout_height="match_parent" + app:shutter_background_color="@android:color/black" app:surface_type="none" /> - + android:fillViewport="true" + android:scrollbars="none"> - - - + android:orientation="vertical" + android:paddingTop="16dp" + android:paddingBottom="16dp"> - + - + - + - + + + + + + + + + + + + + + + + + diff --git a/app/src/mobile/res/layout/activity_following.xml b/app/src/mobile/res/layout/activity_following.xml new file mode 100644 index 0000000000..2f0462c982 --- /dev/null +++ b/app/src/mobile/res/layout/activity_following.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/mobile/res/layout/activity_tmdb_person.xml b/app/src/mobile/res/layout/activity_tmdb_person.xml index d41f971615..ac7f0327d8 100644 --- a/app/src/mobile/res/layout/activity_tmdb_person.xml +++ b/app/src/mobile/res/layout/activity_tmdb_person.xml @@ -4,7 +4,7 @@ android:id="@+id/root" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="#101820" + android:background="?attr/colorSurfaceContainerLowest" android:fitsSystemWindows="true"> @@ -166,7 +166,7 @@ android:layout_height="wrap_content" android:layout_marginTop="18dp" android:lineSpacingExtra="4dp" - android:textColor="#DDEAF2F8" + android:textColor="?attr/colorOnSurface" android:textSize="13sp" /> @@ -310,7 +310,7 @@ android:layout_height="wrap_content" android:layout_marginTop="18dp" android:text="@string/detail_person_empty" - android:textColor="#99FFFFFF" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="13sp" android:visibility="gone" /> diff --git a/app/src/mobile/res/layout/activity_video.xml b/app/src/mobile/res/layout/activity_video.xml index 48d2715aaf..135cad1a3b 100644 --- a/app/src/mobile/res/layout/activity_video.xml +++ b/app/src/mobile/res/layout/activity_video.xml @@ -41,6 +41,7 @@ style="@style/Player.Vod" android:layout_width="match_parent" android:layout_height="match_parent" + app:shutter_background_color="@android:color/black" app:surface_type="none" /> diff --git a/app/src/mobile/res/layout/adapter_channel.xml b/app/src/mobile/res/layout/adapter_channel.xml index 490aad0cb4..cc6d7d00cc 100644 --- a/app/src/mobile/res/layout/adapter_channel.xml +++ b/app/src/mobile/res/layout/adapter_channel.xml @@ -16,7 +16,7 @@ android:layout_marginEnd="12dp" android:duplicateParentState="true" android:fontFeatureSettings="tnum" - android:textColor="@color/selector_live_text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="01" /> @@ -35,7 +35,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="@color/selector_live_text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="CNN" /> diff --git a/app/src/mobile/res/layout/adapter_config.xml b/app/src/mobile/res/layout/adapter_config.xml index 0f2071ed08..9802d17329 100644 --- a/app/src/mobile/res/layout/adapter_config.xml +++ b/app/src/mobile/res/layout/adapter_config.xml @@ -21,6 +21,17 @@ app:strokeColor="@color/dialog_outlined_button_stroke" tools:text="https://fongmi.github.io/cat.json" /> + + + android:background="@color/webhtv_color_primary" /> @@ -33,7 +33,7 @@ android:layout_width="wrap_content" android:layout_height="match_parent" android:gravity="center_vertical" - android:textColor="@color/white_60" + android:textColor="@color/webhtv_color_on_surface_variant" android:textSize="12sp" tools:text="3" /> diff --git a/app/src/mobile/res/layout/adapter_device.xml b/app/src/mobile/res/layout/adapter_device.xml index fe2dcdd5e1..a559ad7b5c 100644 --- a/app/src/mobile/res/layout/adapter_device.xml +++ b/app/src/mobile/res/layout/adapter_device.xml @@ -12,7 +12,7 @@ android:id="@+id/type" android:layout_width="wrap_content" android:layout_height="wrap_content" - app:tint="#5F6368" + app:tint="?attr/colorOnSurfaceVariant" tools:src="@drawable/ic_cast_tv" /> @@ -36,7 +36,7 @@ android:layout_height="wrap_content" android:layout_marginTop="2dp" android:singleLine="true" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="12sp" tools:text="127.0.0.1" /> diff --git a/app/src/mobile/res/layout/adapter_epg_data.xml b/app/src/mobile/res/layout/adapter_epg_data.xml index 80bc682e63..9c0f873538 100644 --- a/app/src/mobile/res/layout/adapter_epg_data.xml +++ b/app/src/mobile/res/layout/adapter_epg_data.xml @@ -15,7 +15,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="@color/selector_live_text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="七龍珠" /> @@ -26,7 +26,7 @@ android:duplicateParentState="true" android:ellipsize="marquee" android:singleLine="true" - android:textColor="@color/selector_live_text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="12sp" tools:text="18:00 ~ 19:00" /> diff --git a/app/src/mobile/res/layout/adapter_episode_grid.xml b/app/src/mobile/res/layout/adapter_episode_grid.xml index bdbe05feae..e778f2c45e 100644 --- a/app/src/mobile/res/layout/adapter_episode_grid.xml +++ b/app/src/mobile/res/layout/adapter_episode_grid.xml @@ -49,7 +49,7 @@ android:clickable="true" android:focusable="true" android:visibility="gone" - app:cardBackgroundColor="#CC16202A" + app:cardBackgroundColor="?attr/colorSurfaceContainerHigh" app:cardCornerRadius="8dp" app:cardElevation="0dp" app:strokeColor="@color/selector_episode_card_stroke" @@ -69,7 +69,7 @@ android:id="@+id/still" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="#16202A" + android:background="?attr/colorSurfaceContainerHigh" android:contentDescription="@null" android:scaleType="centerCrop" /> @@ -188,7 +188,7 @@ android:ellipsize="end" android:lineSpacingExtra="1dp" android:maxLines="2" - android:textColor="#E6FFFFFF" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="10sp" tools:text="Episode overview preview." /> diff --git a/app/src/mobile/res/layout/adapter_episode_hori.xml b/app/src/mobile/res/layout/adapter_episode_hori.xml index 4ba59e617f..ade63c1eb9 100644 --- a/app/src/mobile/res/layout/adapter_episode_hori.xml +++ b/app/src/mobile/res/layout/adapter_episode_hori.xml @@ -52,7 +52,7 @@ android:clickable="true" android:focusable="true" android:visibility="gone" - app:cardBackgroundColor="#CC16202A" + app:cardBackgroundColor="?attr/colorSurfaceContainerHigh" app:cardCornerRadius="8dp" app:cardElevation="0dp" app:strokeColor="@color/selector_episode_card_stroke" @@ -66,7 +66,7 @@ android:id="@+id/still" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="#16202A" + android:background="?attr/colorSurfaceContainerHigh" android:contentDescription="@null" android:scaleType="centerCrop" /> @@ -149,7 +149,7 @@ android:ellipsize="end" android:lineSpacingExtra="1dp" android:maxLines="2" - android:textColor="#E6FFFFFF" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="10sp" tools:text="这是集数的简介内容..." /> diff --git a/app/src/mobile/res/layout/adapter_file.xml b/app/src/mobile/res/layout/adapter_file.xml index a5f6a0846a..a0084ebd39 100644 --- a/app/src/mobile/res/layout/adapter_file.xml +++ b/app/src/mobile/res/layout/adapter_file.xml @@ -22,7 +22,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="Download" /> diff --git a/app/src/mobile/res/layout/adapter_github_proxy.xml b/app/src/mobile/res/layout/adapter_github_proxy.xml index 62d5120cce..a63f994ce4 100644 --- a/app/src/mobile/res/layout/adapter_github_proxy.xml +++ b/app/src/mobile/res/layout/adapter_github_proxy.xml @@ -20,7 +20,7 @@ android:paddingStart="14dp" android:paddingEnd="14dp" android:singleLine="true" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="13sp" tools:text="https://gh-proxy.com/" /> @@ -31,7 +31,7 @@ android:layout_marginStart="8dp" android:gravity="center" android:minWidth="74dp" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="12sp" tools:text="123 ms" /> @@ -45,6 +45,6 @@ android:padding="10dp" android:scaleType="center" android:src="@drawable/ic_action_delete" - app:tint="#C5221F" /> + app:tint="?attr/colorError" /> diff --git a/app/src/mobile/res/layout/adapter_group.xml b/app/src/mobile/res/layout/adapter_group.xml index 8237fa5914..d18ef8ef26 100644 --- a/app/src/mobile/res/layout/adapter_group.xml +++ b/app/src/mobile/res/layout/adapter_group.xml @@ -17,7 +17,7 @@ android:ellipsize="marquee" android:gravity="center" android:singleLine="true" - android:textColor="@color/selector_live_text" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="收藏" /> diff --git a/app/src/mobile/res/layout/adapter_player_osd.xml b/app/src/mobile/res/layout/adapter_player_osd.xml index afdba9bcd8..f24f3d33cd 100644 --- a/app/src/mobile/res/layout/adapter_player_osd.xml +++ b/app/src/mobile/res/layout/adapter_player_osd.xml @@ -7,7 +7,7 @@ android:layout_height="48dp" android:layout_marginBottom="8dp" android:addStatesFromChildren="true" - android:background="@drawable/selector_git_cloud_card" + android:background="@drawable/selector_player_settings_row" android:clickable="true" android:descendantFocusability="afterDescendants" android:gravity="center_vertical" @@ -22,7 +22,7 @@ android:layout_weight="1" android:ellipsize="end" android:singleLine="true" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="15sp" tools:text="Playback params" /> @@ -30,12 +30,12 @@ android:id="@+id/visible" android:layout_width="40dp" android:layout_height="36dp" - android:background="@drawable/selector_dialog_switch" + android:background="@drawable/selector_player_settings_switch" android:contentDescription="@string/setting_show" android:focusable="true" android:padding="8dp" android:scaleType="center" android:src="@drawable/ic_player_button_visible" - app:tint="@color/dialog_tonal_button_text" /> + app:tint="@color/player_settings_icon_tint" /> diff --git a/app/src/mobile/res/layout/adapter_quick.xml b/app/src/mobile/res/layout/adapter_quick.xml index 7d4b72ae06..52f75c32d7 100644 --- a/app/src/mobile/res/layout/adapter_quick.xml +++ b/app/src/mobile/res/layout/adapter_quick.xml @@ -36,7 +36,7 @@ android:ellipsize="end" android:lineSpacingExtra="2dp" android:maxLines="2" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" tools:text="仙逆剧场版 神临之战" /> @@ -54,7 +54,7 @@ android:ellipsize="end" android:maxWidth="112dp" android:singleLine="true" - android:textColor="@color/white_60" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="12sp" tools:text="咕噜 | APP" /> @@ -66,7 +66,7 @@ android:layout_weight="1" android:ellipsize="end" android:singleLine="true" - android:textColor="@color/white_80" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="12sp" tools:text="更新至146集" /> diff --git a/app/src/mobile/res/layout/adapter_restore.xml b/app/src/mobile/res/layout/adapter_restore.xml index 11d7bed0a9..a5bb67cb03 100644 --- a/app/src/mobile/res/layout/adapter_restore.xml +++ b/app/src/mobile/res/layout/adapter_restore.xml @@ -19,7 +19,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:singleLine="true" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="14sp" /> @@ -38,6 +38,6 @@ android:layout_height="wrap_content" android:background="?attr/selectableItemBackgroundBorderless" android:src="@drawable/ic_setting_delete" - app:tint="#5F6368" /> + app:tint="?attr/colorOnSurfaceVariant" /> diff --git a/app/src/mobile/res/layout/adapter_search.xml b/app/src/mobile/res/layout/adapter_search.xml index 9f1c665f29..85005181f4 100644 --- a/app/src/mobile/res/layout/adapter_search.xml +++ b/app/src/mobile/res/layout/adapter_search.xml @@ -23,7 +23,7 @@ android:ellipsize="end" android:maxWidth="90dp" android:singleLine="true" - android:textColor="@color/text_site" + android:textColor="?attr/colorPrimary" android:textSize="12sp" android:visibility="gone" tools:text="泥巴" @@ -43,7 +43,7 @@ android:layout_weight="1" android:ellipsize="end" android:maxLines="3" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="蜘蛛人" /> @@ -56,7 +56,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="12sp" tools:text="1080p" /> diff --git a/app/src/mobile/res/layout/adapter_search_hot_word.xml b/app/src/mobile/res/layout/adapter_search_hot_word.xml index f9f8a339cd..c2a84e69b2 100644 --- a/app/src/mobile/res/layout/adapter_search_hot_word.xml +++ b/app/src/mobile/res/layout/adapter_search_hot_word.xml @@ -16,7 +16,7 @@ android:paddingStart="8dp" android:paddingEnd="8dp" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="13sp" tools:text="熱搜詞" /> diff --git a/app/src/mobile/res/layout/adapter_search_record.xml b/app/src/mobile/res/layout/adapter_search_record.xml index be36a827ac..a1e6007a1e 100644 --- a/app/src/mobile/res/layout/adapter_search_record.xml +++ b/app/src/mobile/res/layout/adapter_search_record.xml @@ -8,6 +8,6 @@ android:background="@drawable/shape_item_round" android:ellipsize="marquee" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="歷史" /> \ No newline at end of file diff --git a/app/src/mobile/res/layout/adapter_search_word.xml b/app/src/mobile/res/layout/adapter_search_word.xml index 816ad5cde8..cf6b415ce9 100644 --- a/app/src/mobile/res/layout/adapter_search_word.xml +++ b/app/src/mobile/res/layout/adapter_search_word.xml @@ -8,6 +8,6 @@ android:background="@drawable/shape_item_round" android:ellipsize="marquee" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="推薦" /> \ No newline at end of file diff --git a/app/src/mobile/res/layout/adapter_vod.xml b/app/src/mobile/res/layout/adapter_vod.xml index e21496f29f..402f30bb5d 100644 --- a/app/src/mobile/res/layout/adapter_vod.xml +++ b/app/src/mobile/res/layout/adapter_vod.xml @@ -11,7 +11,7 @@ android:id="@+id/image" android:layout_width="match_parent" android:layout_height="80dp" - android:background="@color/black_20" + android:background="?attr/colorSurfaceContainer" android:scaleType="centerCrop" app:shapeAppearanceOverlay="@style/Vod.Grid" /> @@ -23,7 +23,7 @@ android:layout_alignTop="@+id/image" android:layout_alignEnd="@+id/image" android:layout_alignBottom="@+id/history_info" - android:background="@color/black_50" + android:background="@color/webhtv_color_player_scrim" android:scaleType="center" android:src="@drawable/ic_vod_delete" android:visibility="gone" @@ -40,7 +40,7 @@ android:ellipsize="end" android:maxWidth="100dp" android:singleLine="true" - android:textColor="@color/text_remark" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="12sp" tools:text="00:32 / 13:08" /> @@ -54,7 +54,7 @@ android:ellipsize="end" android:maxWidth="92dp" android:singleLine="true" - android:textColor="@color/text_site" + android:textColor="?attr/colorPrimary" android:textSize="12sp" android:visibility="gone" tools:text="瓜子[优](DS)" @@ -68,12 +68,19 @@ android:layout_below="@+id/image" android:layout_alignStart="@+id/image" android:layout_alignEnd="@+id/image" - android:background="@color/black_20" - app:indicatorColor="@color/white" + android:background="?attr/colorSurfaceContainer" + app:indicatorColor="?attr/colorPrimary" app:trackColor="@color/transparent" app:trackStopIndicatorSize="0dp" app:trackThickness="2dp" /> + @@ -114,27 +121,10 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="@color/white_70" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="12sp" tools:text="第01集. 海边散步" /> - - diff --git a/app/src/mobile/res/layout/adapter_vod_list.xml b/app/src/mobile/res/layout/adapter_vod_list.xml index c44f9688c7..d9887295f6 100644 --- a/app/src/mobile/res/layout/adapter_vod_list.xml +++ b/app/src/mobile/res/layout/adapter_vod_list.xml @@ -32,7 +32,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="蜘蛛人" /> @@ -45,7 +45,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="12sp" tools:text="1080p" /> diff --git a/app/src/mobile/res/layout/adapter_vod_oval.xml b/app/src/mobile/res/layout/adapter_vod_oval.xml index 9fd9b12842..4c683319df 100644 --- a/app/src/mobile/res/layout/adapter_vod_oval.xml +++ b/app/src/mobile/res/layout/adapter_vod_oval.xml @@ -11,7 +11,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="8dp" - android:background="@color/black_20" + android:background="?attr/colorSurfaceContainer" android:foreground="@drawable/shape_vod_oval" app:shapeAppearanceOverlay="@style/Vod.Circle" /> @@ -27,7 +27,7 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" tools:text="蜘蛛人" /> diff --git a/app/src/mobile/res/layout/adapter_vod_rect.xml b/app/src/mobile/res/layout/adapter_vod_rect.xml index 79f7c8eb76..55585dc949 100644 --- a/app/src/mobile/res/layout/adapter_vod_rect.xml +++ b/app/src/mobile/res/layout/adapter_vod_rect.xml @@ -11,7 +11,7 @@ android:id="@+id/image" android:layout_width="match_parent" android:layout_height="80dp" - android:background="@color/black_20" + android:background="?attr/colorSurfaceContainer" app:shapeAppearanceOverlay="@style/Vod.Grid" /> @@ -107,7 +107,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:text="@string/ad_feedback_count" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="14sp" /> @@ -131,7 +131,7 @@ android:layout_height="wrap_content" android:layout_marginTop="4dp" android:text="@string/ad_ai_success_rate" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="14sp" /> @@ -140,7 +140,7 @@ android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="20dp" - android:background="@color/black_10" /> + android:background="@color/webhtv_color_scrim" /> @@ -245,7 +245,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/ad_stats_empty" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="14sp" android:visibility="gone" /> + android:background="@color/webhtv_color_scrim" /> + app:backgroundTint="@color/dialog_primary_button_bg" + app:strokeWidth="@dimen/webhtv_focus_ring_width" + app:strokeColor="@color/focus_ring_primary_container" /> diff --git a/app/src/mobile/res/layout/dialog_ad_rule_edit.xml b/app/src/mobile/res/layout/dialog_ad_rule_edit.xml index 798256b0b8..cc226b6537 100644 --- a/app/src/mobile/res/layout/dialog_ad_rule_edit.xml +++ b/app/src/mobile/res/layout/dialog_ad_rule_edit.xml @@ -114,7 +114,7 @@ android:text="@android:string/cancel" android:textColor="@color/dialog_outlined_button_text" app:backgroundTint="@color/dialog_outlined_button_bg" - app:strokeColor="@color/dialog_outlined_button_stroke" /> + app:strokeColor="?attr/colorOutline" /> + app:strokeColor="?attr/colorOutline" /> + app:strokeColor="?attr/colorOutline" /> @@ -63,7 +63,7 @@ android:textColor="@color/dialog_outlined_button_text" android:textSize="12sp" app:backgroundTint="@color/dialog_outlined_button_bg" - app:strokeColor="@color/dialog_outlined_button_stroke" /> + app:strokeColor="?attr/colorOutline" /> + app:strokeColor="?attr/colorOutline" /> @@ -95,7 +95,7 @@ android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/ad_rule_empty" - android:textColor="@color/white_60" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="12sp" android:visibility="gone" tools:visibility="visible" /> @@ -122,7 +122,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:textColor="@color/white_60" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="12sp" tools:text="来自配置 · 共 12 条(只读)" /> diff --git a/app/src/mobile/res/layout/dialog_ad_rule_preview.xml b/app/src/mobile/res/layout/dialog_ad_rule_preview.xml index 9edf75f3e4..04134eb48f 100644 --- a/app/src/mobile/res/layout/dialog_ad_rule_preview.xml +++ b/app/src/mobile/res/layout/dialog_ad_rule_preview.xml @@ -35,7 +35,7 @@ android:background="@drawable/shape_warning_background" android:padding="10dp" android:text="@string/ad_rule_low_confidence_warning" - android:textColor="@color/white" + android:textColor="?attr/colorOnSurface" android:textSize="13sp" android:visibility="gone" tools:visibility="visible" /> diff --git a/app/src/mobile/res/layout/dialog_apk_push_method.xml b/app/src/mobile/res/layout/dialog_apk_push_method.xml index 2a4bd70ee5..6acf362e3b 100644 --- a/app/src/mobile/res/layout/dialog_apk_push_method.xml +++ b/app/src/mobile/res/layout/dialog_apk_push_method.xml @@ -15,7 +15,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/apk_push_method_title" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="20sp" android:textStyle="bold" /> @@ -26,13 +26,13 @@ android:layout_marginTop="4dp" android:ellipsize="end" android:singleLine="true" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="14sp" tools:text="Target: Living room TV" /> + app:iconTint="@color/dialog_primary_button_text" + app:strokeColor="@color/focus_ring_primary_container" /> diff --git a/app/src/mobile/res/layout/dialog_cache_management.xml b/app/src/mobile/res/layout/dialog_cache_management.xml new file mode 100644 index 0000000000..05f0750b33 --- /dev/null +++ b/app/src/mobile/res/layout/dialog_cache_management.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/mobile/res/layout/dialog_config.xml b/app/src/mobile/res/layout/dialog_config.xml index 1bbcb3e8c5..b614a7c549 100644 --- a/app/src/mobile/res/layout/dialog_config.xml +++ b/app/src/mobile/res/layout/dialog_config.xml @@ -17,7 +17,7 @@ android:layout_height="wrap_content" android:ellipsize="end" android:singleLine="true" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="20sp" android:textStyle="bold" tools:text="点播" /> @@ -28,9 +28,9 @@ android:layout_height="wrap_content" android:layout_marginTop="14dp" android:hint="@string/remote_trust_config_name" - app:boxBackgroundColor="@color/white" - app:boxStrokeColor="@color/dialog_outlined_button_stroke" - app:hintTextColor="#5F6368"> + app:boxBackgroundColor="?attr/colorSurfaceContainerHighest" + app:boxStrokeColor="?attr/colorOutline" + app:hintTextColor="?attr/colorOnSurfaceVariant"> + android:textColor="?attr/colorOnSurface" + android:textColorHint="?attr/colorOnSurfaceVariant" /> @@ -52,12 +52,12 @@ android:layout_height="wrap_content" android:layout_marginTop="12dp" android:hint="@string/dialog_config_hint" - app:boxBackgroundColor="@color/white" - app:boxStrokeColor="@color/dialog_outlined_button_stroke" + app:boxBackgroundColor="?attr/colorSurfaceContainerHighest" + app:boxStrokeColor="?attr/colorOutline" app:endIconDrawable="@drawable/ic_action_choose" app:endIconMode="custom" app:endIconTint="@color/dialog_text_button" - app:hintTextColor="#5F6368" + app:hintTextColor="?attr/colorOnSurfaceVariant" app:startIconContentDescription="@string/dialog_config_choose_dir" app:startIconDrawable="@drawable/ic_folder"> @@ -70,8 +70,31 @@ android:inputType="text" android:scrollHorizontally="true" android:singleLine="true" - android:textColor="#202124" - android:textColorHint="#8A8F98" /> + android:textColor="?attr/colorOnSurface" + android:textColorHint="?attr/colorOnSurfaceVariant" /> + + + + + + @@ -96,7 +119,7 @@ android:text="@string/dialog_negative" android:textColor="@color/dialog_outlined_button_text" app:backgroundTint="@color/dialog_outlined_button_bg" - app:strokeColor="@color/dialog_outlined_button_stroke" /> + app:strokeColor="?attr/colorOutline" /> + app:backgroundTint="@color/dialog_primary_button_bg" + app:strokeWidth="@dimen/webhtv_focus_ring_width" + app:strokeColor="@color/focus_ring_primary_container" /> diff --git a/app/src/mobile/res/layout/dialog_control.xml b/app/src/mobile/res/layout/dialog_control.xml index 4096ec4a42..ea23da3073 100644 --- a/app/src/mobile/res/layout/dialog_control.xml +++ b/app/src/mobile/res/layout/dialog_control.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/shape_dialog_control_glass_panel" - tools:background="#1F1F1F"> + tools:background="?attr/colorSurfaceContainerLow"> + app:tint="?attr/webhtvColorOnWallpaper" /> + app:tint="?attr/webhtvColorOnWallpaper" /> diff --git a/app/src/mobile/res/layout/dialog_danmaku_search.xml b/app/src/mobile/res/layout/dialog_danmaku_search.xml index c289f1fd16..e574c2a8d6 100644 --- a/app/src/mobile/res/layout/dialog_danmaku_search.xml +++ b/app/src/mobile/res/layout/dialog_danmaku_search.xml @@ -27,8 +27,8 @@ android:inputType="textCapWords|textAutoCorrect|textAutoComplete" android:maxLength="255" android:singleLine="true" - android:textColor="@color/white" - android:textColorHint="@color/white_60" + android:textColor="?attr/webhtvColorOnWallpaper" + android:textColorHint="@color/webhtv_color_player_control_muted" android:textSize="14sp" /> @@ -41,7 +41,7 @@ android:layout_marginBottom="16dp" android:indeterminate="true" android:visibility="gone" - app:indicatorColor="@color/white_90" + app:indicatorColor="?attr/colorPrimary" app:indicatorSize="32dp" app:trackThickness="2dp" tools:visibility="visible" /> diff --git a/app/src/mobile/res/layout/dialog_danmaku_setting.xml b/app/src/mobile/res/layout/dialog_danmaku_setting.xml index 69b511dccf..d7fb187a8e 100644 --- a/app/src/mobile/res/layout/dialog_danmaku_setting.xml +++ b/app/src/mobile/res/layout/dialog_danmaku_setting.xml @@ -21,7 +21,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/danmaku_setting" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="20sp" android:textStyle="bold" /> diff --git a/app/src/mobile/res/layout/dialog_device.xml b/app/src/mobile/res/layout/dialog_device.xml index 866d924311..5c48c5d864 100644 --- a/app/src/mobile/res/layout/dialog_device.xml +++ b/app/src/mobile/res/layout/dialog_device.xml @@ -21,7 +21,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/device_select" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="16sp" /> + app:tint="?attr/colorOnSurfaceVariant" /> + app:tint="?attr/colorOnSurfaceVariant" /> diff --git a/app/src/mobile/res/layout/dialog_episode_list.xml b/app/src/mobile/res/layout/dialog_episode_list.xml index 83d5fb6433..00dda1a5fa 100644 --- a/app/src/mobile/res/layout/dialog_episode_list.xml +++ b/app/src/mobile/res/layout/dialog_episode_list.xml @@ -16,7 +16,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/detail_flag" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="15sp" android:textStyle="bold" /> @@ -39,7 +39,7 @@ android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/detail_season" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="15sp" android:textStyle="bold" android:visibility="gone" @@ -65,7 +65,7 @@ android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/detail_episode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="15sp" android:textStyle="bold" /> diff --git a/app/src/mobile/res/layout/dialog_history.xml b/app/src/mobile/res/layout/dialog_history.xml index 72ea868a00..3770411373 100644 --- a/app/src/mobile/res/layout/dialog_history.xml +++ b/app/src/mobile/res/layout/dialog_history.xml @@ -1,13 +1,33 @@ - + android:orientation="vertical" + android:padding="24dp"> + + + + + + diff --git a/app/src/mobile/res/layout/dialog_info.xml b/app/src/mobile/res/layout/dialog_info.xml index 58ffb9221e..f244a7cefb 100644 --- a/app/src/mobile/res/layout/dialog_info.xml +++ b/app/src/mobile/res/layout/dialog_info.xml @@ -16,7 +16,7 @@ android:layout_height="wrap_content" android:ellipsize="middle" android:linksClickable="false" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="20sp" /> diff --git a/app/src/mobile/res/layout/dialog_live.xml b/app/src/mobile/res/layout/dialog_live.xml index aa5419252e..582e8fd183 100644 --- a/app/src/mobile/res/layout/dialog_live.xml +++ b/app/src/mobile/res/layout/dialog_live.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/shape_dialog_control_glass_panel" - tools:background="#1F1F1F"> + tools:background="?attr/colorSurfaceContainerLow"> diff --git a/app/src/mobile/res/layout/dialog_live_control.xml b/app/src/mobile/res/layout/dialog_live_control.xml index 7311d7feb0..5daa990d22 100644 --- a/app/src/mobile/res/layout/dialog_live_control.xml +++ b/app/src/mobile/res/layout/dialog_live_control.xml @@ -4,7 +4,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/shape_dialog_control_glass_panel" - tools:background="#1F1F1F"> + tools:background="?attr/colorSurfaceContainerLow"> + tools:background="?attr/colorSurfaceContainerLow"> @@ -58,7 +58,7 @@ android:background="@null" android:gravity="center_vertical|end" android:text="@string/live_epg_apply" - android:textColor="@color/white_70" + android:textColor="@color/webhtv_color_player_control_muted" android:textSize="14sp" /> @@ -77,8 +77,8 @@ android:paddingStart="14dp" android:paddingEnd="14dp" android:singleLine="true" - android:textColor="@color/white" - android:textColorHint="@color/white_60" + android:textColor="?attr/webhtvColorOnWallpaper" + android:textColorHint="@color/webhtv_color_player_control_muted" android:textSize="13sp" /> + tools:background="?attr/colorSurfaceContainerLow"> + android:textColor="@color/webhtv_color_player_control_muted" /> + app:rippleColor="@color/webhtv_color_overlay_light" + app:strokeColor="@color/webhtv_color_player_control_muted" /> @@ -60,14 +60,14 @@ android:layout_weight="1" android:text="@string/offset_audio" android:textAppearance="?attr/textAppearanceBodyLarge" - android:textColor="@color/white_90" /> + android:textColor="?attr/webhtvColorOnWallpaper" /> + android:textColor="@color/webhtv_color_player_control_muted" /> @@ -105,14 +105,14 @@ android:layout_weight="1" android:text="@string/offset_text" android:textAppearance="?attr/textAppearanceBodyLarge" - android:textColor="@color/white_90" /> + android:textColor="?attr/webhtvColorOnWallpaper" /> + android:textColor="@color/webhtv_color_player_control_muted" /> diff --git a/app/src/mobile/res/layout/dialog_player_osd.xml b/app/src/mobile/res/layout/dialog_player_osd.xml index 6e259a2290..11c618c08d 100644 --- a/app/src/mobile/res/layout/dialog_player_osd.xml +++ b/app/src/mobile/res/layout/dialog_player_osd.xml @@ -19,7 +19,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/player_osd" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="18sp" android:textStyle="bold" /> diff --git a/app/src/mobile/res/layout/dialog_quick_search.xml b/app/src/mobile/res/layout/dialog_quick_search.xml index 3a279a5349..2d50fea356 100644 --- a/app/src/mobile/res/layout/dialog_quick_search.xml +++ b/app/src/mobile/res/layout/dialog_quick_search.xml @@ -25,7 +25,7 @@ android:ellipsize="end" android:gravity="start|center_vertical" android:maxLines="1" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="17sp" android:textStyle="bold" tools:text="快搜 “仙逆”" /> @@ -56,8 +56,8 @@ android:paddingStart="12dp" android:paddingEnd="12dp" android:singleLine="true" - android:textColor="@color/white" - android:textColorHint="@color/white_50" + android:textColor="?attr/webhtvColorOnWallpaper" + android:textColorHint="@color/webhtv_color_player_control_muted" android:textSize="15sp" /> + app:tint="?attr/webhtvColorOnWallpaper" /> + android:background="@color/webhtv_color_overlay_light" /> diff --git a/app/src/mobile/res/layout/dialog_receive.xml b/app/src/mobile/res/layout/dialog_receive.xml index a55b32b570..a86ad9b89c 100644 --- a/app/src/mobile/res/layout/dialog_receive.xml +++ b/app/src/mobile/res/layout/dialog_receive.xml @@ -16,7 +16,7 @@ android:id="@+id/image" android:layout_width="90dp" android:layout_height="120dp" - android:background="@color/black_10" + android:background="@color/webhtv_color_scrim" app:shapeAppearanceOverlay="@style/Vod.Grid.Large" /> @@ -59,7 +59,7 @@ android:layout_alignStart="@+id/name" android:layout_marginTop="2dp" android:singleLine="true" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="14sp" tools:text="Xiaomi 13" /> diff --git a/app/src/mobile/res/layout/dialog_restore.xml b/app/src/mobile/res/layout/dialog_restore.xml index a6b0112342..e9e8d39959 100644 --- a/app/src/mobile/res/layout/dialog_restore.xml +++ b/app/src/mobile/res/layout/dialog_restore.xml @@ -19,7 +19,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/restore_select" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/dialog_speed.xml b/app/src/mobile/res/layout/dialog_speed.xml index da4793347e..f9fd3709c8 100644 --- a/app/src/mobile/res/layout/dialog_speed.xml +++ b/app/src/mobile/res/layout/dialog_speed.xml @@ -15,9 +15,9 @@ android:stepSize="0.5" android:valueFrom="2" android:valueTo="5" - app:haloColor="#1F1A73E8" - app:labelStyle="@style/Widget.WebHTV.LightDialogSliderLabel" - app:thumbColor="#1A73E8" + app:haloColor="?attr/colorPrimary" + app:labelStyle="@style/Widget.WebHTV.SliderLabel" + app:thumbColor="?attr/colorPrimary" app:tickVisible="false" /> diff --git a/app/src/mobile/res/layout/dialog_timer.xml b/app/src/mobile/res/layout/dialog_timer.xml index 48750c4eed..aaba9baa29 100644 --- a/app/src/mobile/res/layout/dialog_timer.xml +++ b/app/src/mobile/res/layout/dialog_timer.xml @@ -20,7 +20,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/play_timer" - android:textColor="@color/white_70" + android:textColor="@color/webhtv_color_player_control_muted" android:textSize="16sp" /> @@ -98,7 +98,7 @@ android:layout_height="wrap_content" android:layout_marginTop="24dp" android:layout_marginBottom="24dp" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="48sp" tools:text="5:00" /> @@ -110,9 +110,9 @@ android:layout_marginBottom="8dp" android:singleLine="true" android:text="@string/timer_delay" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" app:backgroundTint="@color/selector_dialog_glass_button_tint" - app:rippleColor="#33FFFFFF" /> + app:rippleColor="@color/webhtv_color_overlay_light" /> + android:textColor="?attr/webhtvColorOnWallpaper" + app:strokeColor="@color/webhtv_color_player_control_muted" /> diff --git a/app/src/mobile/res/layout/dialog_title.xml b/app/src/mobile/res/layout/dialog_title.xml index 0393cd2af7..36acb945d6 100644 --- a/app/src/mobile/res/layout/dialog_title.xml +++ b/app/src/mobile/res/layout/dialog_title.xml @@ -22,7 +22,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/dialog_select_title" - android:textColor="@color/white_70" + android:textColor="@color/webhtv_color_player_control_muted" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/dialog_track.xml b/app/src/mobile/res/layout/dialog_track.xml index 9e54c7ff03..7b9360b764 100644 --- a/app/src/mobile/res/layout/dialog_track.xml +++ b/app/src/mobile/res/layout/dialog_track.xml @@ -21,7 +21,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:textColor="@color/white_70" + android:textColor="@color/webhtv_color_player_control_muted" android:textSize="16sp" tools:text="選擇字幕" /> @@ -33,7 +33,7 @@ android:gravity="center" android:paddingHorizontal="12dp" android:text="@string/play_track_secondary_subtitle" - android:textColor="@color/white_90" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="13sp" android:visibility="gone" tools:visibility="visible" /> @@ -44,7 +44,7 @@ android:layout_height="wrap_content" android:background="?attr/selectableItemBackgroundBorderless" android:src="@drawable/ic_action_choose" - app:tint="@color/white_90" + app:tint="?attr/webhtvColorOnWallpaper" android:visibility="gone" tools:visibility="visible" /> @@ -56,7 +56,7 @@ android:background="?attr/selectableItemBackgroundBorderless" android:contentDescription="@string/subtitle_manual_search" android:src="@drawable/ic_action_search" - app:tint="@color/white_90" + app:tint="?attr/webhtvColorOnWallpaper" android:visibility="gone" tools:visibility="visible" /> @@ -69,7 +69,7 @@ android:contentDescription="@string/subtitle_ai_translate_action" android:src="@drawable/ic_action_ai" android:visibility="gone" - app:tint="@color/white_90" + app:tint="?attr/webhtvColorOnWallpaper" tools:visibility="visible" /> @@ -115,7 +115,7 @@ android:layout_marginStart="16dp" android:background="?attr/selectableItemBackgroundBorderless" android:src="@drawable/ic_action_offset" - app:tint="@color/white_90" + app:tint="?attr/webhtvColorOnWallpaper" android:visibility="gone" tools:visibility="visible" /> @@ -142,7 +142,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/subtitle_realtime_source_language" - android:textColor="@color/white_90" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="14sp" /> @@ -163,7 +163,7 @@ android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingBottom="12dp" - android:textColor="@color/white_70" + android:textColor="@color/webhtv_color_player_control_muted" android:textSize="13sp" android:visibility="gone" tools:text="AI 正在翻译:1/3 分块,约剩 20s" diff --git a/app/src/mobile/res/layout/dialog_ua.xml b/app/src/mobile/res/layout/dialog_ua.xml index ec0d9494c0..ead3f43ef1 100644 --- a/app/src/mobile/res/layout/dialog_ua.xml +++ b/app/src/mobile/res/layout/dialog_ua.xml @@ -14,8 +14,8 @@ android:layout_height="wrap_content" android:hint="@string/player_ua" app:boxBackgroundMode="outline" - app:boxStrokeColor="@color/dialog_outlined_button_stroke" - app:hintTextColor="#5F6368"> + app:boxStrokeColor="?attr/colorOutline" + app:hintTextColor="?attr/colorOnSurfaceVariant"> + android:textColor="?attr/colorOnSurface" + android:textColorHint="?attr/colorOnSurfaceVariant" /> diff --git a/app/src/mobile/res/layout/dialog_update.xml b/app/src/mobile/res/layout/dialog_update.xml index 4e2eb24ccb..1d42ba6bbb 100644 --- a/app/src/mobile/res/layout/dialog_update.xml +++ b/app/src/mobile/res/layout/dialog_update.xml @@ -23,7 +23,7 @@ android:layout_height="wrap_content" android:layout_weight="1" android:text="@string/update_title" - android:textColor="#202124" + android:textColor="?attr/colorOnSurface" android:textSize="20sp" android:textStyle="bold" /> @@ -35,7 +35,7 @@ android:contentDescription="@string/lut_close" android:padding="8dp" android:src="@drawable/ic_dialog_close" - app:tint="#5F6368" /> + app:tint="?attr/colorOnSurfaceVariant" /> @@ -45,7 +45,7 @@ android:layout_height="wrap_content" android:layout_marginTop="6dp" android:text="@string/update_hint" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="13sp" /> @@ -83,8 +84,9 @@ android:id="@+id/stableName" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:duplicateParentState="true" android:text="@string/update_channel_stable" - android:textColor="#202124" + android:textColor="@color/update_version_item_title" android:textSize="16sp" android:textStyle="bold" /> @@ -99,7 +101,7 @@ android:paddingTop="4dp" android:paddingEnd="8dp" android:paddingBottom="4dp" - android:textColor="#174EA6" + android:textColor="?attr/colorPrimary" android:textSize="12sp" tools:text="可更新" /> @@ -109,8 +111,9 @@ android:layout_height="wrap_content" android:layout_marginStart="10dp" android:layout_weight="1" + android:duplicateParentState="true" android:gravity="end" - android:textColor="#174EA6" + android:textColor="@color/update_version_item_accent" android:textSize="13sp" tools:text="收起" /> @@ -121,8 +124,9 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="7dp" + android:duplicateParentState="true" android:maxLines="2" - android:textColor="#5F6368" + android:textColor="@color/update_version_item_meta" android:textSize="13sp" tools:text="5.6.0-202608101200" /> @@ -131,6 +135,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" + android:duplicateParentState="true" android:orientation="vertical" tools:visibility="visible"> @@ -138,8 +143,9 @@ android:id="@+id/stableDesc" android:layout_width="match_parent" android:layout_height="wrap_content" + android:duplicateParentState="true" android:lineSpacingExtra="2dp" - android:textColor="#303134" + android:textColor="@color/update_version_item_title" android:textSize="14sp" tools:text="### 更新内容 - 稳定版更新说明" /> @@ -154,7 +160,9 @@ android:textColor="@color/dialog_primary_button_text" android:textSize="14sp" app:backgroundTint="@color/dialog_primary_button_bg" - app:cornerRadius="8dp" /> + app:cornerRadius="8dp" + app:strokeColor="@color/focus_ring_primary_container" + app:strokeWidth="@dimen/webhtv_focus_ring_width" /> @@ -174,6 +182,7 @@ @@ -181,8 +190,9 @@ android:id="@+id/betaName" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:duplicateParentState="true" android:text="@string/update_channel_beta" - android:textColor="#202124" + android:textColor="@color/update_version_item_title" android:textSize="16sp" android:textStyle="bold" /> @@ -197,7 +207,7 @@ android:paddingTop="4dp" android:paddingEnd="8dp" android:paddingBottom="4dp" - android:textColor="#174EA6" + android:textColor="?attr/colorPrimary" android:textSize="12sp" tools:text="可更新" /> @@ -207,8 +217,9 @@ android:layout_height="wrap_content" android:layout_marginStart="10dp" android:layout_weight="1" + android:duplicateParentState="true" android:gravity="end" - android:textColor="#174EA6" + android:textColor="@color/update_version_item_accent" android:textSize="13sp" tools:text="展开" /> @@ -219,8 +230,9 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="7dp" + android:duplicateParentState="true" android:maxLines="2" - android:textColor="#5F6368" + android:textColor="@color/update_version_item_meta" android:textSize="13sp" tools:text="5.6.0-beta-202608101200" /> @@ -229,6 +241,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" + android:duplicateParentState="true" android:orientation="vertical" tools:visibility="gone"> @@ -236,8 +249,9 @@ android:id="@+id/betaDesc" android:layout_width="match_parent" android:layout_height="wrap_content" + android:duplicateParentState="true" android:lineSpacingExtra="2dp" - android:textColor="#303134" + android:textColor="@color/update_version_item_title" android:textSize="14sp" tools:text="### 更新内容 - 测试版更新说明" /> @@ -252,7 +266,9 @@ android:textColor="@color/dialog_primary_button_text" android:textSize="14sp" app:backgroundTint="@color/dialog_primary_button_bg" - app:cornerRadius="8dp" /> + app:cornerRadius="8dp" + app:strokeColor="@color/focus_ring_primary_container" + app:strokeWidth="@dimen/webhtv_focus_ring_width" /> @@ -278,7 +294,7 @@ android:ellipsize="end" android:maxLines="2" android:minLines="2" - android:textColor="#5F6368" + android:textColor="?attr/colorOnSurfaceVariant" android:textSize="13sp" tools:text="正在下载 46% · 3.2 MB/s · 已用 00:12" /> @@ -305,6 +321,8 @@ android:textColor="@color/dialog_primary_button_text" android:textSize="14sp" app:backgroundTint="@color/dialog_primary_button_bg" - app:cornerRadius="8dp" /> + app:cornerRadius="8dp" + app:strokeColor="@color/focus_ring_primary_container" + app:strokeWidth="@dimen/webhtv_focus_ring_width" /> diff --git a/app/src/mobile/res/layout/dialog_video_content.xml b/app/src/mobile/res/layout/dialog_video_content.xml index 9e64e16095..1837b4d409 100644 --- a/app/src/mobile/res/layout/dialog_video_content.xml +++ b/app/src/mobile/res/layout/dialog_video_content.xml @@ -16,7 +16,7 @@ android:maxLines="1" android:paddingHorizontal="24dp" android:text="@string/detail_content" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="18sp" android:textStyle="bold" /> @@ -38,7 +38,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:lineSpacingExtra="5dp" - android:textColor="@color/white_90" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="15sp" tools:text="一個身世神秘的少年,因對自身身世的好奇而離開故鄉,前赴京都。" /> diff --git a/app/src/mobile/res/layout/fragment_setting.xml b/app/src/mobile/res/layout/fragment_setting.xml index 15b08e629d..1523dd131d 100644 --- a/app/src/mobile/res/layout/fragment_setting.xml +++ b/app/src/mobile/res/layout/fragment_setting.xml @@ -47,7 +47,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_vod" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -103,7 +103,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_live" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -159,7 +159,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_wall" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -206,7 +206,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_enhance" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -222,7 +222,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_tmdb" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -238,7 +238,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_ai" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -255,7 +255,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_personal" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -272,7 +272,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_player" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -290,7 +290,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_ad" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -317,7 +317,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_danmaku" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -334,7 +334,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/setting_subtitle" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -352,7 +352,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_appearance" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -379,7 +379,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_incognito" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -406,7 +406,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_doh" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -433,7 +433,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_cache" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -466,7 +466,7 @@ android:paddingEnd="12dp" android:paddingBottom="8dp" android:text="@string/setting_backup" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -499,7 +499,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_about" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_ad.xml b/app/src/mobile/res/layout/fragment_setting_ad.xml index 33b0472793..549b4ba3ff 100644 --- a/app/src/mobile/res/layout/fragment_setting_ad.xml +++ b/app/src/mobile/res/layout/fragment_setting_ad.xml @@ -29,7 +29,7 @@ android:paddingEnd="16dp" android:paddingBottom="16dp"> - + @@ -75,7 +75,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_ai_ad_detection" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> - + @@ -134,303 +134,12 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/ad_block_stats" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -474,7 +183,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_intro_skip_kinds" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_ai.xml b/app/src/mobile/res/layout/fragment_setting_ai.xml index c1c5d9eb65..2147038359 100644 --- a/app/src/mobile/res/layout/fragment_setting_ai.xml +++ b/app/src/mobile/res/layout/fragment_setting_ai.xml @@ -33,7 +33,7 @@ - + @@ -73,7 +73,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_personal_recommendation" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -99,7 +99,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_recommendation_feedback" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -135,7 +135,7 @@ - + @@ -185,7 +185,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_ai_max_concurrency" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -212,7 +212,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_ai_chunk_count" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_danmaku.xml b/app/src/mobile/res/layout/fragment_setting_danmaku.xml index cbcd60c967..0531ed42d2 100644 --- a/app/src/mobile/res/layout/fragment_setting_danmaku.xml +++ b/app/src/mobile/res/layout/fragment_setting_danmaku.xml @@ -40,7 +40,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_load" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -69,7 +69,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_api" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -100,7 +100,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_auto_load" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -129,7 +129,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/danmaku_spider_first" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_enhance.xml b/app/src/mobile/res/layout/fragment_setting_enhance.xml index 1e40dd2527..1ff97fe185 100644 --- a/app/src/mobile/res/layout/fragment_setting_enhance.xml +++ b/app/src/mobile/res/layout/fragment_setting_enhance.xml @@ -43,7 +43,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_lab" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -70,7 +70,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_drive_check" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -97,7 +97,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_site_name" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -124,7 +124,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_local_reader" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -151,7 +151,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_novel_source" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -178,7 +178,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_comic_source" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -205,7 +205,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_audio_source" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -232,7 +232,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_short_drama_source" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -262,7 +262,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_debug_log" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -289,7 +289,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_site_health_sort" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -316,7 +316,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_manage_page" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -343,7 +343,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_remote_trust" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -372,7 +372,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_git_cloud" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -401,7 +401,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_shell_proxy" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -429,7 +429,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_shell_proxy_config" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -458,7 +458,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_custom_csp" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -487,7 +487,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_web_home_extension" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -514,7 +514,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_web_home_theme" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -543,7 +543,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_web_home_fullscreen" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -570,7 +570,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_csp_warmup" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -597,7 +597,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_playback_artwork_wall" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -624,7 +624,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_viewing_record_sync" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -651,7 +651,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_login_state" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -680,7 +680,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_one_key_sync" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_personal.xml b/app/src/mobile/res/layout/fragment_setting_personal.xml index ff1c289fce..e1a4ffc136 100644 --- a/app/src/mobile/res/layout/fragment_setting_personal.xml +++ b/app/src/mobile/res/layout/fragment_setting_personal.xml @@ -39,7 +39,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_thread" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -66,7 +66,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_following" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -93,7 +93,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_auto_backup" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -120,7 +120,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_playback_overlay" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -147,7 +147,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_play_back_to_detail" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -174,7 +174,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_episode_history" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -201,7 +201,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_global_history" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -228,7 +228,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_interface_failover" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -256,7 +256,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_play_speed" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -286,7 +286,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_group_rule" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -314,7 +314,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_ui" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -341,7 +341,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_column" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -368,7 +368,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_site_column" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -395,7 +395,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_search_result_sort" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -422,7 +422,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_reset_app" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -439,7 +439,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_app_branding" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> - - - - - - - - diff --git a/app/src/mobile/res/layout/fragment_setting_player.xml b/app/src/mobile/res/layout/fragment_setting_player.xml index c8750bdd92..876b4a0df9 100644 --- a/app/src/mobile/res/layout/fragment_setting_player.xml +++ b/app/src/mobile/res/layout/fragment_setting_player.xml @@ -40,7 +40,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_render" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -67,7 +67,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_button_config" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -96,7 +96,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_pad_live" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -125,7 +125,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_exo_4k_compat" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -154,7 +154,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_kernel" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -182,7 +182,7 @@ android:layout_marginEnd="16dp" android:singleLine="true" android:text="@string/player_mpv_config" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -212,7 +212,7 @@ android:layout_marginEnd="16dp" android:singleLine="true" android:text="@string/player_mpv_render" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -241,7 +241,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_bluray_menu" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -268,7 +268,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_scale" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -295,7 +295,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_lut" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -324,7 +324,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_caption" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -353,7 +353,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_osd" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -382,7 +382,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_buffer" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -400,7 +400,7 @@ android:layout_height="wrap_content" android:layout_marginStart="4dp" android:text="@string/times" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -418,7 +418,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_buffer_bytes" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -447,7 +447,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_back_buffer" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -476,7 +476,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_cache" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -505,7 +505,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -534,7 +534,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_threads" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -563,7 +563,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_size" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -592,7 +592,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_time" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -621,7 +621,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_ahead" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -650,7 +650,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_preload_pause" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -679,7 +679,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_auto_play" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -708,7 +708,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_auto_change" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> + + + + + + + + @@ -766,7 +795,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_live_source_fallback" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -795,7 +824,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_failure_fallback" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -824,7 +853,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_speed" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -842,7 +871,7 @@ android:layout_height="wrap_content" android:layout_marginStart="4dp" android:text="@string/times" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -860,7 +889,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_tunnel" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -887,7 +916,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_music_notification" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -914,7 +943,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_audio_book_notification" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -941,7 +970,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_audio_decode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -968,7 +997,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_audio_passthrough" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -995,7 +1024,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_video_decode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -1022,7 +1051,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_aac_track" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -1049,7 +1078,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_background" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -1076,7 +1105,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_ua" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_subtitle.xml b/app/src/mobile/res/layout/fragment_setting_subtitle.xml index b7a76fd158..76e98d7e94 100644 --- a/app/src/mobile/res/layout/fragment_setting_subtitle.xml +++ b/app/src/mobile/res/layout/fragment_setting_subtitle.xml @@ -39,7 +39,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_auto_match" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -66,7 +66,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_language" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -95,7 +95,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/player_subtitle_source" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_setting_tmdb.xml b/app/src/mobile/res/layout/fragment_setting_tmdb.xml index 1185149443..94a07191d6 100644 --- a/app/src/mobile/res/layout/fragment_setting_tmdb.xml +++ b/app/src/mobile/res/layout/fragment_setting_tmdb.xml @@ -45,7 +45,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_tmdb_source" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -75,7 +75,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_detail_open_mode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -103,7 +103,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_detail_theme_mode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -132,7 +132,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_tmdb_match_mode" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -159,7 +159,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_tmdb_episode_file_size" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> @@ -187,7 +187,7 @@ android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="@string/setting_history_aggregation" - android:textColor="@color/white" + android:textColor="?attr/webhtvColorOnWallpaper" android:textSize="16sp" /> diff --git a/app/src/mobile/res/layout/fragment_vod.xml b/app/src/mobile/res/layout/fragment_vod.xml index 2fede0e64c..820f239cdb 100644 --- a/app/src/mobile/res/layout/fragment_vod.xml +++ b/app/src/mobile/res/layout/fragment_vod.xml @@ -104,6 +104,23 @@ layout="@layout/view_progress" android:visibility="gone" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/mobile/res/layout/view_control_live.xml b/app/src/mobile/res/layout/view_control_live.xml index 9f43e96f28..a2f5de9bdb 100644 --- a/app/src/mobile/res/layout/view_control_live.xml +++ b/app/src/mobile/res/layout/view_control_live.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:id="@+id/playerControlRoot" android:background="@color/black_20"> + @@ -21,6 +23,9 @@ @@ -118,6 +119,44 @@ android:scaleType="center" android:src="@drawable/ic_control_setting" /> + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/mobile/res/layout/view_progress.xml b/app/src/mobile/res/layout/view_progress.xml index b8a49513ba..7c0ccf11da 100644 --- a/app/src/mobile/res/layout/view_progress.xml +++ b/app/src/mobile/res/layout/view_progress.xml @@ -5,12 +5,17 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + app:indicatorColor="?attr/webhtvColorOnWallpaper" /> - + + tools:visibility="visible"> + + + + + + + android:visible="true" /> + android:visible="true" /> + android:visible="true" /> diff --git a/app/src/mobile/res/values-sw600dp/following_fractions.xml b/app/src/mobile/res/values-sw600dp/following_fractions.xml new file mode 100644 index 0000000000..a9967155b0 --- /dev/null +++ b/app/src/mobile/res/values-sw600dp/following_fractions.xml @@ -0,0 +1,4 @@ + + + 18% + diff --git a/app/src/mobile/res/values/following_fractions.xml b/app/src/mobile/res/values/following_fractions.xml new file mode 100644 index 0000000000..c10e263500 --- /dev/null +++ b/app/src/mobile/res/values/following_fractions.xml @@ -0,0 +1,4 @@ + + + 30% + diff --git a/app/src/mobile/res/values/styles.xml b/app/src/mobile/res/values/styles.xml index ee508d10c2..857003980d 100644 --- a/app/src/mobile/res/values/styles.xml +++ b/app/src/mobile/res/values/styles.xml @@ -1,7 +1,18 @@ - @@ -32,7 +43,7 @@ wrap_content 13dp 7dp - @color/white_70 + @color/webhtv_color_player_control_muted 13sp bold @@ -63,7 +74,7 @@ 0 2 4 - @color/white + @color/webhtv_color_player_control 12sp @@ -76,7 +87,7 @@ center 3dp 3dp - @color/white_80 + @color/webhtv_color_player_control_muted 11sp diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioConsumerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioConsumerTest.java deleted file mode 100644 index ebb5a06f99..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioConsumerTest.java +++ /dev/null @@ -1,164 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; - -public class AdAudioConsumerTest { - - @Test - public void resetFinishesOldMatcherAndDropsLateResult() { - FakeMatcherFactory factory = new FakeMatcherFactory(); - List candidates = new ArrayList<>(); - AdAudioConsumer consumer = new AdAudioConsumer(factory, candidates::add, 4); - consumer.start(11L, 3L, AudioFingerprintRuleSet.empty()); - consumer.onPcm(new PlaybackMediaSignalHub.PcmFrame(11L, 3L, new float[]{0.1f}, 16_000, 0L)); - consumer.onLifecycle(new PlaybackMediaSignalHub.Lifecycle( - 11L, 4L, PlaybackMediaSignalHub.ResetReason.SEEK, 20_000L)); - factory.old().finishEvents = List.of(event(AudioFingerprintMatcher.Type.FULL_MATCHED, "old")); - - consumer.drainForTest(); - - assertTrue(candidates.isEmpty()); - assertEquals(1, factory.old().finishedCount); - assertEquals(4L, consumer.generation()); - } - - @Test - public void pcmIsConvertedToSaturatedPcm16AndEventsBecomeCandidates() { - FakeMatcherFactory factory = new FakeMatcherFactory(); - List candidates = new ArrayList<>(); - AdAudioConsumer consumer = new AdAudioConsumer(factory, candidates::add, 4); - consumer.start(1L, 2L, AudioFingerprintRuleSet.empty()); - - consumer.onPcm(new PlaybackMediaSignalHub.PcmFrame( - 1L, 2L, new float[]{-1.0f, 0.5f, 1.2f}, 16_000, 123L)); - consumer.drainForTest(); - - assertEquals(List.of((short) -32768, (short) 16384, (short) 32767), factory.current().lastSamples); - assertEquals(1, candidates.size()); - assertEquals(123L, candidates.get(0).event().startTimeMs()); - } - - @Test - public void fullQueueDropsOldestFrameAndReportsOverflow() { - FakeMatcherFactory factory = new FakeMatcherFactory(); - AdAudioConsumer consumer = new AdAudioConsumer(factory, candidate -> { }, 2); - consumer.start(1L, 1L, AudioFingerprintRuleSet.empty()); - - consumer.onPcm(frame(1L)); - consumer.onPcm(frame(2L)); - consumer.onPcm(frame(3L)); - consumer.drainForTest(); - - assertEquals(1, consumer.queueOverflowCount()); - assertEquals(3L, factory.current().lastCaptureStartMs); - } - - @Test - public void matcherFailureIsIsolatedAndConsumerContinues() { - FakeMatcherFactory factory = new FakeMatcherFactory(); - AdAudioConsumer consumer = new AdAudioConsumer(factory, candidate -> { }, 2); - consumer.start(1L, 1L, AudioFingerprintRuleSet.empty()); - factory.current().throwOnFeed = true; - - consumer.onPcm(frame(1L)); - consumer.drainForTest(); - factory.current().throwOnFeed = false; - consumer.onPcm(frame(2L)); - consumer.drainForTest(); - - assertEquals(1, consumer.matcherErrorCount()); - assertEquals(2L, factory.current().lastCaptureStartMs); - } - - @Test - public void sourceChangeStartsMatcherForTheNewSession() { - FakeMatcherFactory factory = new FakeMatcherFactory(); - List candidates = new ArrayList<>(); - AdAudioConsumer consumer = new AdAudioConsumer(factory, candidates::add, 2); - consumer.start(1L, 3L, AudioFingerprintRuleSet.empty()); - - consumer.onLifecycle(new PlaybackMediaSignalHub.Lifecycle( - 2L, 0L, PlaybackMediaSignalHub.ResetReason.SOURCE_CHANGED, 0L)); - consumer.onPcm(new PlaybackMediaSignalHub.PcmFrame( - 2L, 0L, new float[]{0.1f}, 16_000, 0L)); - consumer.drainForTest(); - - assertEquals(1, candidates.size()); - assertEquals(2L, candidates.get(0).sessionId()); - assertEquals(0L, candidates.get(0).generation()); - assertEquals(2, factory.created.size()); - } - - @Test - public void rejectedWorkerIsReportedWithoutEscapingAudioCallback() { - AdAudioDiagnostics diagnostics = new AdAudioDiagnostics(); - AdAudioConsumer consumer = new AdAudioConsumer( - ruleSet -> new FakeMatcher(), candidate -> { }, 2, - command -> { throw new IllegalStateException("worker rejected"); }, diagnostics); - consumer.start(1L, 1L, AudioFingerprintRuleSet.empty()); - - consumer.onPcm(frame(1L)); - - assertEquals(1L, diagnostics.count(AdAudioDiagnostics.Code.MATCHER_ERROR)); - } - - private static PlaybackMediaSignalHub.PcmFrame frame(long startMs) { - return new PlaybackMediaSignalHub.PcmFrame(1L, 1L, new float[]{0.1f}, 16_000, startMs); - } - - private static AudioFingerprintMatcher.MatchEvent event(AudioFingerprintMatcher.Type type, String id) { - return new AudioFingerprintMatcher.MatchEvent(type, id, 123L, 456L, 0.9f, 6); - } - - private static final class FakeMatcherFactory implements AdAudioConsumer.MatcherFactory { - private final List created = new ArrayList<>(); - - @Override - public AdAudioConsumer.Matcher create(AudioFingerprintRuleSet ruleSet) { - FakeMatcher matcher = new FakeMatcher(); - created.add(matcher); - return matcher; - } - - FakeMatcher current() { - return created.get(created.size() - 1); - } - - FakeMatcher old() { - return created.get(0); - } - } - - private static final class FakeMatcher implements AdAudioConsumer.Matcher { - private List feedEvents = List.of(event(AudioFingerprintMatcher.Type.START_MATCHED, "ad")); - private List finishEvents = List.of(); - private boolean throwOnFeed; - private int finishedCount; - private long lastCaptureStartMs = -1L; - private List lastSamples = List.of(); - - @Override - public List feed(short[] samples, int sampleRate, long captureStartMs) { - if (throwOnFeed) throw new IllegalStateException("test matcher failure"); - lastCaptureStartMs = captureStartMs; - List values = new ArrayList<>(samples.length); - for (short sample : samples) values.add(sample); - lastSamples = values; - return feedEvents; - } - - @Override - public List finish() { - finishedCount++; - return finishEvents; - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioDetectionMultiplexerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioDetectionMultiplexerTest.java deleted file mode 100644 index 853f0e786f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioDetectionMultiplexerTest.java +++ /dev/null @@ -1,200 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; - -public class AdAudioDetectionMultiplexerTest { - - @Test - public void duplicateCandidatesFromTwoProvidersAreEmittedOnce() { - RecordingListener output = new RecordingListener(); - AdAudioDetectionMultiplexer mux = multiplexer(8, output); - - mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 3_000L, true, 0.91d)); - mux.onCandidate(candidate("probe", "rule-1", 1_000L, 3_000L, true, 0.98d)); - - assertEquals(1, output.candidates.size()); - assertEquals(1L, mux.diagnostics().emitted()); - assertEquals(1L, mux.diagnostics().duplicates()); - } - - @Test - public void fullMatchUpgradesAnAlreadyEmittedStartExactlyOnce() { - RecordingListener output = new RecordingListener(); - AdAudioDetectionMultiplexer mux = multiplexer(8, output); - - mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 3_000L, false, 0.85d)); - mux.onCandidate(candidate("probe", "rule-1", 1_000L, 3_000L, true, 0.96d)); - mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 3_000L, true, 0.97d)); - - assertEquals(2, output.candidates.size()); - assertTrue(output.candidates.get(1).fullMatch()); - assertEquals(1L, mux.diagnostics().upgrades()); - assertEquals(1L, mux.diagnostics().duplicates()); - } - - @Test - public void staleGenerationAndWrongRuleVersionAreDropped() { - RecordingListener output = new RecordingListener(); - AdAudioDetectionMultiplexer mux = multiplexer(8, output); - - mux.onCandidate(new AdAudioSignalProvider.AdAudioCandidate( - 7L, 1L, "rule-1", "rules-v1", 1_000L, 3_000L, - true, 0.9d, "pcm")); - mux.onCandidate(new AdAudioSignalProvider.AdAudioCandidate( - 7L, 2L, "rule-1", "rules-v0", 1_000L, 3_000L, - true, 0.9d, "probe")); - - assertTrue(output.candidates.isEmpty()); - assertEquals(2L, mux.diagnostics().stale()); - } - - @Test - public void timelineResetClearsDedupAndRejectsOldCallbacks() { - RecordingListener output = new RecordingListener(); - AdAudioDetectionMultiplexer mux = multiplexer(8, output); - mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 3_000L, true, 0.9d)); - - mux.onTimelineReset(new AdAudioSignalProvider.TimelineReset( - 7L, 3L, AdAudioSignalProvider.ResetReason.SEEK, 5_000L)); - mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 3_000L, true, 0.9d)); - mux.onCandidate(new AdAudioSignalProvider.AdAudioCandidate( - 7L, 3L, "rule-1", "rules-v1", 1_000L, 3_000L, - true, 0.9d, "pcm")); - - assertEquals(2, output.candidates.size()); - assertEquals(1, output.resets.size()); - assertEquals(1L, mux.diagnostics().resets()); - assertEquals(1L, mux.diagnostics().stale()); - } - - @Test - public void oversizedIntervalsAreRejectedBeforeStateAllocation() { - RecordingListener output = new RecordingListener(); - AdAudioDetectionMultiplexer mux = multiplexer(8, output); - - mux.onCandidate(candidate( - "probe", "rule-1", 1_000L, 1_000L + 600_001L, true, 0.9d)); - - assertTrue(output.candidates.isEmpty()); - assertEquals(0, mux.trackedCandidateCount()); - assertEquals(1L, mux.diagnostics().invalid()); - } - - @Test - public void unknownRuleIdsAreRejectedBeforeStateAllocation() { - RecordingListener output = new RecordingListener(); - AdAudioDetectionMultiplexer mux = multiplexer(8, output); - - mux.onCandidate(candidate( - "probe", "untrusted-rule", 1_000L, 3_000L, true, 0.9d)); - - assertTrue(output.candidates.isEmpty()); - assertEquals(0, mux.trackedCandidateCount()); - assertEquals(1L, mux.diagnostics().invalid()); - } - - @Test - public void speechRulePassesOnlyWhenExplicitlyAllowed() { - RecordingListener output = new RecordingListener(); - AdAudioDetectionMultiplexer mux = new AdAudioDetectionMultiplexer( - new AdAudioSignalProvider.SessionContext( - 7L, 2L, "media", "https://media.example/video.m3u8", Map.of()), - "rules-v1", Set.of(SpeechAdSignalProvider.RULE_ID), 8, output); - - mux.onCandidate(candidate( - SpeechAdSignalProvider.ID, SpeechAdSignalProvider.RULE_ID, - 1_000L, 3_000L, true, 1.0d)); - mux.onCandidate(candidate( - SpeechAdSignalProvider.ID, "untrusted-speech-rule", - 4_000L, 6_000L, true, 1.0d)); - - assertEquals(1, output.candidates.size()); - assertEquals(SpeechAdSignalProvider.RULE_ID, - output.candidates.get(0).ruleId()); - assertEquals(1L, mux.diagnostics().invalid()); - } - @Test - public void boundedStateEvictsOldestCandidates() { - RecordingListener output = new RecordingListener(); - AdAudioDetectionMultiplexer mux = multiplexer(2, output); - - mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 2_000L, true, 0.9d)); - mux.onCandidate(candidate("pcm", "rule-2", 2_000L, 3_000L, true, 0.9d)); - mux.onCandidate(candidate("pcm", "rule-3", 3_000L, 4_000L, true, 0.9d)); - mux.onCandidate(candidate("probe", "rule-1", 1_000L, 2_000L, true, 0.9d)); - - assertEquals(4, output.candidates.size()); - assertEquals(2, mux.trackedCandidateCount()); - assertEquals(2L, mux.diagnostics().evicted()); - } - - @Test - public void downstreamFailuresAndProviderErrorsAreIsolatedAndCounted() { - AdAudioSignalProvider.Listener throwing = new AdAudioSignalProvider.Listener() { - @Override - public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { - throw new IllegalStateException("candidate sink failed"); - } - - @Override - public void onProviderError(AdAudioSignalProvider.ProviderError error) { - throw new IllegalStateException("error sink failed"); - } - }; - AdAudioDetectionMultiplexer mux = multiplexer(8, throwing); - - mux.onCandidate(candidate("pcm", "rule-1", 1_000L, 2_000L, true, 0.9d)); - mux.onProviderError(new AdAudioSignalProvider.ProviderError( - "probe", AdAudioSignalProvider.ErrorCode.ANALYSIS_FAILED, "decode failed")); - - assertEquals(1L, mux.diagnostics().emitted()); - assertEquals(1L, mux.diagnostics().providerErrors()); - assertEquals(2L, mux.diagnostics().listenerErrors()); - } - - private static AdAudioDetectionMultiplexer multiplexer( - int capacity, AdAudioSignalProvider.Listener listener) { - return new AdAudioDetectionMultiplexer( - new AdAudioSignalProvider.SessionContext( - 7L, 2L, "media", "https://media.example/video.m3u8", Map.of()), - "rules-v1", Set.of("rule-1", "rule-2", "rule-3"), - capacity, listener); - } - - private static AdAudioSignalProvider.AdAudioCandidate candidate( - String providerId, String ruleId, long startMs, long endMs, - boolean fullMatch, double similarity) { - return new AdAudioSignalProvider.AdAudioCandidate( - 7L, 2L, ruleId, "rules-v1", startMs, endMs, - fullMatch, similarity, providerId); - } - - private static final class RecordingListener implements AdAudioSignalProvider.Listener { - private final List candidates = new ArrayList<>(); - private final List errors = new ArrayList<>(); - private final List resets = new ArrayList<>(); - - @Override - public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { - candidates.add(candidate); - } - - @Override - public void onProviderError(AdAudioSignalProvider.ProviderError error) { - errors.add(error); - } - - @Override - public void onTimelineReset(AdAudioSignalProvider.TimelineReset reset) { - resets.add(reset); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioDiagnosticsTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioDiagnosticsTest.java deleted file mode 100644 index bf9fab8449..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioDiagnosticsTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -public class AdAudioDiagnosticsTest { - - @Test - public void snapshotContainsOnlyFixedCodeCounters() { - AdAudioDiagnostics diagnostics = new AdAudioDiagnostics(); - diagnostics.record(AdAudioDiagnostics.Code.QUEUE_OVERFLOW); - diagnostics.record(AdAudioDiagnostics.Code.QUEUE_OVERFLOW); - diagnostics.record(AdAudioDiagnostics.Code.SEEK_REJECTED); - - AdAudioDiagnostics.Snapshot snapshot = diagnostics.snapshot(); - - assertEquals(2L, snapshot.count(AdAudioDiagnostics.Code.QUEUE_OVERFLOW)); - assertEquals(1L, snapshot.count(AdAudioDiagnostics.Code.SEEK_REJECTED)); - assertEquals(AdAudioDiagnostics.Code.SEEK_REJECTED, snapshot.lastCode()); - } - - @Test - public void repeatedCodesAreSampledSoTheAudioThreadNeverLogsPerFrame() { - // record() is reachable once per PCM frame (STALE_GENERATION after a flush, - // QUEUE_OVERFLOW while the recognizer lags) and each log line is a synchronous - // file write, so only the first few plus a decayed tail may be emitted. - assertTrue(AdAudioDiagnostics.shouldLogCount(1L)); - assertTrue(AdAudioDiagnostics.shouldLogCount(3L)); - assertFalse(AdAudioDiagnostics.shouldLogCount(4L)); - assertFalse(AdAudioDiagnostics.shouldLogCount(99L)); - assertTrue(AdAudioDiagnostics.shouldLogCount(100L)); - assertFalse(AdAudioDiagnostics.shouldLogCount(101L)); - - long logged = 0; - for (long i = 1; i <= 5_000L; i++) if (AdAudioDiagnostics.shouldLogCount(i)) logged++; - assertEquals(53L, logged); - } - - @Test - public void countersStayAccurateEvenWhenLoggingIsSampled() { - AdAudioDiagnostics diagnostics = new AdAudioDiagnostics(); - for (int i = 0; i < 250; i++) { - diagnostics.record(AdAudioDiagnostics.Code.STALE_GENERATION); - } - assertEquals(250L, diagnostics.count(AdAudioDiagnostics.Code.STALE_GENERATION)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuleSourceContractTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuleSourceContractTest.java deleted file mode 100644 index 7a96164b96..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuleSourceContractTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThrows; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.nio.file.Path; - -public class AdAudioRuleSourceContractTest { - - @Rule - public final TemporaryFolder temporaryFolder = new TemporaryFolder(); - - @Test - public void localStoreImplementsStableSourceContract() throws Exception { - Path directory = temporaryFolder.newFolder("rules").toPath(); - AdAudioRuleSource source = new AdAudioRuleStore(directory); - - AdAudioRuleSnapshot snapshot = source.load(); - - assertEquals("local", snapshot.sourceId()); - assertFalse(snapshot.hasRules()); - assertNull(snapshot.probeSidecar()); - assertFalse(snapshot.probeAvailable()); - assertThrows(UnsupportedOperationException.class, - () -> snapshot.warnings().add("mutable")); - } - - @Test - public void legacySnapshotRemainsWithoutProbeSidecar() { - AdAudioRuleSnapshot snapshot = new AdAudioRuleSnapshot( - "local", "", AudioFingerprintRuleSet.empty(), java.util.List.of(), ""); - - assertNull(snapshot.probeSidecar()); - assertFalse(snapshot.probeAvailable()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuleStoreTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuleStoreTest.java deleted file mode 100644 index 88f33c36c7..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuleStoreTest.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -public class AdAudioRuleStoreTest { - - @Rule - public final TemporaryFolder temporaryFolder = new TemporaryFolder(); - - @Test - public void importedSnapshotSurvivesRestart() throws Exception { - Path directory = temporaryFolder.newFolder().toPath(); - - AdAudioRuleSnapshot imported = new AdAudioRuleStore(directory).importJson(validRuleJson("rule-a")); - AdAudioRuleSnapshot reloaded = new AdAudioRuleStore(directory).load(); - - assertEquals("rule-a", imported.ruleSet().rules().get(0).id()); - assertEquals("rule-a", reloaded.ruleSet().rules().get(0).id()); - assertEquals(imported.version(), reloaded.version()); - assertFalse(reloaded.hasError()); - } - - /** 社区 Probe Rules v1 可以直接导入,落盘时被规范化成本项目的 v2 格式。 */ - @Test - public void probeV1RulesImportAndReloadAsCanonicalV2() throws Exception { - Path directory = temporaryFolder.newFolder().toPath(); - String probeJson; - try (InputStream stream = getClass().getResourceAsStream("/probe-rules-v1-community.json")) { - assertNotNull(stream); - probeJson = new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } - - AdAudioRuleSnapshot imported = new AdAudioRuleStore(directory).importJson(probeJson); - AdAudioRuleSnapshot reloaded = new AdAudioRuleStore(directory).load(); - String stored = Files.readString(directory.resolve("ad-audio-rules.json"), StandardCharsets.UTF_8); - - assertEquals(4, imported.ruleSet().rules().size()); - assertEquals(4, reloaded.ruleSet().rules().size()); - assertEquals(imported.version(), reloaded.version()); - assertFalse(reloaded.hasError()); - assertTrue(stored.contains(AudioFingerprintRuleSet.ALGORITHM_ID)); - assertFalse(stored.contains(ProbeRuleCodec.FORMAT)); - } - - /** - * v2 的规则 id 是自由字符串,所以 id 里出现 Probe 的 format 值时不能被误判成 v1。 - * 用子串匹配分流会让这个合法的 v2 文件整包被拒。 - */ - @Test - public void v2RuleIdContainingTheProbeFormatValueStillImportsAsV2() throws Exception { - Path directory = temporaryFolder.newFolder().toPath(); - - AdAudioRuleSnapshot imported = - new AdAudioRuleStore(directory).importJson(validRuleJson(ProbeRuleCodec.FORMAT)); - - assertEquals(ProbeRuleCodec.FORMAT, imported.ruleSet().rules().get(0).id()); - } - - @Test - public void invalidImportDoesNotReplaceLastGoodSnapshot() throws Exception { - Path directory = temporaryFolder.newFolder().toPath(); - AdAudioRuleStore store = new AdAudioRuleStore(directory); - store.importJson(validRuleJson("rule-a")); - - assertThrows(IllegalArgumentException.class, () -> store.importJson("{\"rules\":[")); - - AdAudioRuleSnapshot reloaded = new AdAudioRuleStore(directory).load(); - assertEquals("rule-a", reloaded.ruleSet().rules().get(0).id()); - assertFalse(Files.exists(directory.resolve("ad-audio-rules.json.tmp"))); - } - - @Test - public void oversizedImportDoesNotReplaceLastGoodSnapshot() throws Exception { - Path directory = temporaryFolder.newFolder().toPath(); - AdAudioRuleStore store = new AdAudioRuleStore(directory); - store.importJson(validRuleJson("rule-a")); - - String oversized = " ".repeat(AdAudioRuleStore.MAX_IMPORT_BYTES + 1); - assertThrows(IllegalArgumentException.class, () -> store.importJson(oversized)); - - assertEquals("rule-a", new AdAudioRuleStore(directory).load().ruleSet().rules().get(0).id()); - } - - @Test - public void corruptPersistedFileProducesErrorSnapshot() throws Exception { - Path directory = temporaryFolder.newFolder().toPath(); - Files.writeString(directory.resolve("ad-audio-rules.json"), "not-json"); - - AdAudioRuleSnapshot snapshot = new AdAudioRuleStore(directory).load(); - - assertTrue(snapshot.ruleSet().rules().isEmpty()); - assertTrue(snapshot.hasError()); - } - - @Test - public void clearRemovesRulesAndTemporaryFile() throws Exception { - Path directory = temporaryFolder.newFolder().toPath(); - AdAudioRuleStore store = new AdAudioRuleStore(directory); - store.importJson(validRuleJson("rule-a")); - Files.writeString(directory.resolve("ad-audio-rules.json.tmp"), "partial"); - - AdAudioRuleSnapshot snapshot = store.clear(); - - assertTrue(snapshot.ruleSet().rules().isEmpty()); - assertFalse(Files.exists(directory.resolve("ad-audio-rules.json"))); - assertFalse(Files.exists(directory.resolve("ad-audio-rules.json.tmp"))); - } - - private static String validRuleJson(String id) { - return "{" - + "\"schemaVersion\":2," - + "\"algorithm\":{\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," - + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16}," - + "\"rules\":[{\"id\":\"" + id + "\",\"durationMs\":15000,\"anchorOffsetMs\":0," - + "\"anchorDurationMs\":3000,\"fingerprint\":[" - + "\"32f0007c\",\"35c100e0\",\"3b8b01c0\",\"d30a0380\"]}]}"; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerTest.java deleted file mode 100644 index 5935a16205..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioRuntimeControllerTest.java +++ /dev/null @@ -1,979 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.player.audio.PlaybackMediaClock; -import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; -import com.fongmi.android.tv.subtitle.SpeechRecognitionFactory; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -public class AdAudioRuntimeControllerTest { - - @Test - public void enabledRulesRequestCaptureOnlyWhileEligibleUiIsBound() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - AdAudioRuntimeController runtime = runtime(hub, playback, goodSnapshot()); - - runtime.start(true); - assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - runtime.bindUi(new FakeUiPort()); - assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - assertTrue(runtime.needsPipelineRebuild()); - - runtime.unbindUi(); - assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - runtime.close(); - } - - @Test - public void disabledEmptyAndIneligibleConfigurationsNeverCapture() { - PlaybackMediaSignalHub disabledHub = new PlaybackMediaSignalHub(8); - disabledHub.beginSession(0L); - AdAudioRuntimeController disabled = runtime( - disabledHub, new FakePlaybackPort(disabledHub, true), goodSnapshot()); - disabled.start(false); - disabled.bindUi(new FakeUiPort()); - - PlaybackMediaSignalHub emptyHub = new PlaybackMediaSignalHub(8); - emptyHub.beginSession(0L); - AdAudioRuntimeController empty = runtime( - emptyHub, new FakePlaybackPort(emptyHub, true), emptySnapshot()); - empty.start(true); - empty.bindUi(new FakeUiPort()); - - PlaybackMediaSignalHub ineligibleHub = new PlaybackMediaSignalHub(8); - ineligibleHub.beginSession(0L); - AdAudioRuntimeController ineligible = runtime( - ineligibleHub, new FakePlaybackPort(ineligibleHub, false), goodSnapshot()); - ineligible.start(true); - ineligible.bindUi(new FakeUiPort()); - - assertFalse(disabledHub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - assertFalse(emptyHub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - assertFalse(ineligibleHub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - disabled.close(); - empty.close(); - ineligible.close(); - } - - @Test - public void refreshReleasesCaptureWhenPlaybackBecomesLive() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - AdAudioRuntimeController runtime = runtime(hub, playback, goodSnapshot()); - runtime.start(true); - runtime.bindUi(new FakeUiPort()); - assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - - playback.eligible = false; - runtime.refresh(); - - assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - runtime.close(); - } - - @Test - public void ruleSnapshotErrorIsReportedAndCaptureStaysDisabled() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - AdAudioRuntimeController runtime = runtime( - hub, new FakePlaybackPort(hub, true), errorSnapshot()); - - runtime.start(true); - runtime.bindUi(new FakeUiPort()); - - assertEquals(1L, runtime.diagnostics().count( - AdAudioDiagnostics.Code.RULE_LOAD_FAILED)); - assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - runtime.close(); - } - - @Test - public void nullRuleSnapshotIsReportedAndCaptureStaysDisabled() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - AdAudioRuntimeController runtime = new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), () -> null, playback, - Runnable::run, () -> { }); - - runtime.start(true); - runtime.bindUi(new FakeUiPort()); - - assertEquals(1L, runtime.diagnostics().count( - AdAudioDiagnostics.Code.RULE_LOAD_FAILED)); - assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - runtime.close(); - } - - @Test - public void reloadRulesReplacesTheActiveMatcher() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - PlaybackMediaSignalHub.Session firstSession = hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - MutableRuleSource rules = new MutableRuleSource(snapshotForRule("old-ad")); - AdAudioRuntimeController runtime = new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), rules, playback, - Runnable::run, () -> { }); - FakeUiPort ui = new FakeUiPort(); - runtime.start(true); - runtime.bindUi(ui); - - rules.snapshot = snapshotForRule("new-ad"); - runtime.reloadRules(); - PlaybackMediaSignalHub.Session nextSession = hub.beginSession(0L); - hub.publishPcm(nextSession.frame(chirp16k(), 16_000, 0L)); - - assertEquals("new-ad", ui.lastPrompt.ruleId()); - assertTrue(firstSession.id() != nextSession.id()); - runtime.close(); - } - - @Test - public void repeatedStartReplacesTheActiveMatcher() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - MutableRuleSource rules = new MutableRuleSource(snapshotForRule("old-ad")); - AdAudioRuntimeController runtime = new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), rules, playback, - Runnable::run, () -> { }); - FakeUiPort ui = new FakeUiPort(); - runtime.start(true); - runtime.bindUi(ui); - - rules.snapshot = snapshotForRule("new-ad"); - runtime.start(true); - PlaybackMediaSignalHub.Session session = hub.beginSession(0L); - hub.publishPcm(session.frame(chirp16k(), 16_000, 0L)); - - assertEquals("new-ad", ui.lastPrompt.ruleId()); - runtime.close(); - } - - @Test - public void probeProviderStartsOnlyWhenSnapshotHasVerifiedSidecar() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - FakeSignalProvider probe = new FakeSignalProvider("probe"); - AtomicInteger factoryCalls = new AtomicInteger(); - AdAudioRuntimeController runtime = runtimeWithProbe( - hub, playback, snapshotForRuleWithSidecar("ad"), sidecar -> { - factoryCalls.incrementAndGet(); - return probe; - }); - - runtime.start(true); - runtime.bindUi(new FakeUiPort()); - - assertEquals(1, factoryCalls.get()); - assertEquals(1, probe.starts); - assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, probe.state()); - runtime.close(); - - PlaybackMediaSignalHub noSidecarHub = new PlaybackMediaSignalHub(8); - noSidecarHub.beginSession(0L); - AdAudioRuntimeController noSidecar = runtimeWithProbe( - noSidecarHub, new FakePlaybackPort(noSidecarHub, true), - snapshotForRule("ad"), sidecar -> { - factoryCalls.incrementAndGet(); - return new FakeSignalProvider("unexpected"); - }); - noSidecar.start(true); - noSidecar.bindUi(new FakeUiPort()); - - assertEquals(1, factoryCalls.get()); - noSidecar.close(); - } - - @Test - public void duplicateProbeCandidatesReachThePromptOnlyOnce() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - PlaybackMediaSignalHub.Session session = hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - FakeSignalProvider probe = new FakeSignalProvider("probe"); - AdAudioRuntimeController runtime = runtimeWithProbe( - hub, playback, snapshotForRuleWithSidecar("ad"), sidecar -> probe); - FakeUiPort ui = new FakeUiPort(); - runtime.start(true); - runtime.bindUi(ui); - AdAudioSignalProvider.AdAudioCandidate candidate = providerCandidate( - session, "ad", 0L, 10_000L); - - probe.emit(candidate); - probe.emit(candidate); - - assertEquals(1, ui.candidateShows); - assertEquals(AdSkipPolicyController.Mode.PROMPT, runtime.skipMode()); - runtime.close(); - } - - @Test - public void switchingToAutoDuringPlaybackAppliesOnlyLaterProbeCandidates() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - PlaybackMediaSignalHub.Session session = hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - FakeSignalProvider probe = new FakeSignalProvider("probe"); - AdAudioRuntimeController runtime = runtimeWithProbe( - hub, playback, snapshotForRulesWithSidecar( - "prompt-ad", "auto-ad"), sidecar -> probe); - FakeUiPort ui = new FakeUiPort(); - runtime.start(true); - runtime.bindUi(ui); - - probe.emit(providerCandidate(session, "prompt-ad", 0L, 8_000L)); - ui.actions.ignore(); - runtime.setSkipMode(AdSkipPolicyController.Mode.AUTO); - probe.emit(providerCandidate(session, "auto-ad", 0L, 10_000L)); - - assertEquals(1, ui.candidateShows); - assertEquals(AdSkipPolicyController.Mode.AUTO, runtime.skipMode()); - assertEquals(1, playback.seekTargets.size()); - runtime.close(); - } - - @Test - public void probeFailureAndSuspendDoNotLeakOrDisablePcmFallback() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - FakeSignalProvider probe = new FakeSignalProvider("probe"); - AdAudioRuntimeController runtime = runtimeWithProbe( - hub, playback, snapshotForRuleWithSidecar("ad"), sidecar -> probe); - runtime.start(true); - runtime.bindUi(new FakeUiPort()); - - probe.fail(AdAudioSignalProvider.ErrorCode.ANALYSIS_FAILED); - - assertTrue(runtime.isActive()); - assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - - runtime.suspend(); - - assertEquals(1, probe.closes); - assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - runtime.close(); - } - - @Test - public void speechRunsWithoutFingerprintRulesAndUsesItsOwnAutoMode() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - FakeSignalProvider speech = new FakeSignalProvider(SpeechAdSignalProvider.ID); - AdAudioRuntimeController runtime = runtimeWithProviders( - hub, playback, emptySnapshot(), - ignored -> new NoopAdAudioSignalProvider("probe"), () -> speech); - FakeUiPort ui = new FakeUiPort(); - - runtime.setSpeechConfig(SpeechAdConfig.create( - true, "\u8d4c\u573a", 15, "AUTO")); - runtime.start(false); - runtime.bindUi(ui); - - assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, speech.state()); - speech.emit(SpeechAdSignalProvider.RULE_ID, 10_000L, 25_000L); - assertEquals(List.of(25_000L), playback.seekTargets); - assertEquals(0, ui.candidateShows); - runtime.close(); - } - - @Test - public void speechSuppressionClosesOnlySpeechAndLeavesFingerprintRunning() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - FakeSignalProvider probe = new FakeSignalProvider("probe"); - FakeSignalProvider speech = new FakeSignalProvider(SpeechAdSignalProvider.ID); - AdAudioRuntimeController runtime = runtimeWithProviders( - hub, playback, snapshotForRuleWithSidecar("ad"), - ignored -> probe, () -> speech); - - runtime.setSpeechConfig(SpeechAdConfig.create(true, "赌场", 15, "PROMPT")); - runtime.start(true); - runtime.bindUi(new FakeUiPort()); - runtime.suppressSpeechForCurrentSession(); - - assertTrue(runtime.isSpeechSuppressed()); - assertEquals(AdAudioSignalProvider.ProviderState.CLOSED, speech.state()); - assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, probe.state()); - assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - runtime.close(); - } - - @Test - public void speechStartFailureDoesNotStopPcmOrProbe() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - FakeSignalProvider probe = new FakeSignalProvider("probe"); - FakeSignalProvider speech = new FakeSignalProvider(SpeechAdSignalProvider.ID); - speech.failOnStart = true; - AdAudioRuntimeController runtime = runtimeWithProviders( - hub, playback, snapshotForRuleWithSidecar("ad"), - ignored -> probe, () -> speech); - - runtime.setSpeechConfig(SpeechAdConfig.create( - true, "\u8d4c\u573a", 15, "PROMPT")); - runtime.start(true); - runtime.bindUi(new FakeUiPort()); - - assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, probe.state()); - assertTrue(runtime.isActive()); - assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - runtime.close(); - } - - @Test - public void speechModeSwitchAffectsOnlyFutureSpeechCandidates() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - List speeches = new ArrayList<>(); - AdAudioRuntimeController runtime = runtimeWithProviders( - hub, playback, emptySnapshot(), - ignored -> new NoopAdAudioSignalProvider("probe"), () -> { - FakeSignalProvider provider = new FakeSignalProvider( - SpeechAdSignalProvider.ID); - speeches.add(provider); - return provider; - }); - FakeUiPort ui = new FakeUiPort(); - - runtime.setSpeechConfig(SpeechAdConfig.create( - true, "\u8d4c\u573a", 15, "PROMPT")); - runtime.start(false); - runtime.bindUi(ui); - speeches.get(0).emit(SpeechAdSignalProvider.RULE_ID, 1_000L, 16_000L); - ui.actions.ignore(); - - runtime.setSpeechConfig(SpeechAdConfig.create( - true, "\u8d4c\u573a", 15, "AUTO")); - speeches.get(1).emit(SpeechAdSignalProvider.RULE_ID, 30_000L, 45_000L); - - assertEquals(1, ui.candidateShows); - assertEquals(List.of(45_000L), playback.seekTargets); - assertEquals(AdAudioSignalProvider.ProviderState.CLOSED, - speeches.get(0).state()); - runtime.close(); - } - @Test - public void replacingSpeechConfigClosesProductionSessionAndDropsOldCallbacks() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - FakeSpeechRecognitionFactory recognition = new FakeSpeechRecognitionFactory(); - AdAudioRuntimeController runtime = new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), - AdAudioRuntimeControllerTest::emptySnapshot, playback, recognition, - Runnable::run, () -> { }); - FakeUiPort ui = new FakeUiPort(); - - runtime.setSpeechConfig(SpeechAdConfig.create( - true, "\u8d4c\u573a", 15, "PROMPT")); - runtime.start(false); - runtime.bindUi(ui); - FakeSpeechSession first = recognition.sessions.get(0); - - runtime.setSpeechConfig(SpeechAdConfig.create( - true, "\u9996\u5145", 30, "AUTO")); - - assertEquals(1, first.closeCalls); - assertEquals(2, recognition.sessions.size()); - first.listener.onResult("\u8d4c\u573a", 1L, 2L, 1); - assertTrue(playback.seekTargets.isEmpty()); - assertEquals(0, ui.candidateShows); - runtime.close(); - } - - @Test - public void compoundRuleIsRoutedAndForcedToPromptUntilWordTimingIsVerified() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - List speeches = new ArrayList<>(); - SpeechAdRule rule = SpeechAdRuleCodec.parse("广告>回来,30").rules().get(0); - AdAudioRuntimeController runtime = runtimeWithProviders( - hub, playback, emptySnapshot(), - ignored -> new NoopAdAudioSignalProvider("probe"), () -> { - FakeSignalProvider provider = new FakeSignalProvider( - SpeechAdSignalProvider.ID); - speeches.add(provider); - return provider; - }); - FakeUiPort ui = new FakeUiPort(); - - runtime.setSpeechConfig(SpeechAdConfig.create( - true, "", new SpeechAdRuleSet(List.of(rule)), 15, "AUTO")); - runtime.start(false); - runtime.bindUi(ui); - - assertEquals(1, speeches.size()); - speeches.get(0).emit(rule.id(), 10_000L, 45_000L); - - assertEquals(1, ui.candidateShows); - assertTrue(playback.seekTargets.isEmpty()); - ui.actions.confirm(); - assertEquals(List.of(45_000L), playback.seekTargets); - runtime.close(); - } - @Test - public void rebindingTheSameUiKeepsTheRunningSpeechSession() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - FakeSpeechRecognitionFactory recognition = new FakeSpeechRecognitionFactory(); - AdAudioRuntimeController runtime = new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), - AdAudioRuntimeControllerTest::emptySnapshot, playback, recognition, - Runnable::run, () -> { }); - FakeUiPort ui = new FakeUiPort(); - - runtime.setSpeechConfig(SpeechAdConfig.create( - true, "赌场", 15, "PROMPT")); - runtime.start(false); - runtime.bindUi(ui); - assertEquals(1, recognition.sessions.size()); - - // PlaybackActivity rebinds on every playback state change; that must not tear the - // recognizer down and lose the in-flight utterance. - runtime.bindUi(ui); - runtime.bindUi(ui); - - assertEquals(1, recognition.sessions.size()); - assertEquals(0, recognition.sessions.get(0).closeCalls); - - // The rebind installed a new coordinator. Candidates from the still-running - // provider must reach it, not the discarded one. - hub.publishPcm(hub.session().frame(new float[] {0.1f}, 16_000, 30_000L)); - recognition.sessions.get(0).listener.onResult( - "赌场", 30_000_000L, 31_000_000L, 1); - assertEquals(1, ui.candidateShows); - runtime.close(); - } - - @Test - public void refreshKeepsAnIdleSpeechProviderInsteadOfRebuildingIt() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - // Duration still unknown: mirrors STATE_BUFFERING right after the pipeline rebuild. - playback.positionEligible = false; - FakeSpeechRecognitionFactory recognition = new FakeSpeechRecognitionFactory(); - AdAudioRuntimeController runtime = new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), - AdAudioRuntimeControllerTest::emptySnapshot, playback, recognition, - Runnable::run, () -> { }); - - runtime.setSpeechConfig(SpeechAdConfig.create(true, "赌场", 15, "PROMPT")); - runtime.start(false); - runtime.bindUi(new FakeUiPort()); - // Resources are only acquired once the position is eligible, so nothing exists yet. - assertTrue(recognition.sessions.isEmpty()); - - // Playback flaps between BUFFERING and READY; each flap calls refresh(). An IDLE - // provider must count as live, otherwise every refresh destroys and re-creates the - // recognizer and it never reaches RUNNING. - runtime.refresh(); - runtime.refresh(); - runtime.refresh(); - - assertTrue(recognition.sessions.isEmpty()); - - // Once the duration is known the provider must acquire its recognizer exactly once - // and then survive further refreshes without being rebuilt. - playback.positionEligible = true; - runtime.refresh(); - assertEquals(1, recognition.sessions.size()); - assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - - runtime.refresh(); - runtime.refresh(); - assertEquals(1, recognition.sessions.size()); - assertEquals(0, recognition.sessions.get(0).closeCalls); - - // Falling back to buffering must not tear the recognizer down again. - playback.positionEligible = false; - runtime.refresh(); - assertEquals(1, recognition.sessions.size()); - assertEquals(0, recognition.sessions.get(0).closeCalls); - runtime.close(); - } - - @Test - public void newHubSessionRebuildsAParkedSpeechProviderInsteadOfKeepingItsStaleLease() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - FakeSpeechRecognitionFactory recognition = new FakeSpeechRecognitionFactory(); - AdAudioRuntimeController runtime = new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), - AdAudioRuntimeControllerTest::emptySnapshot, playback, recognition, - Runnable::run, () -> { }); - - runtime.setSpeechConfig(SpeechAdConfig.create(true, "赌场", 15, "PROMPT")); - runtime.start(false); - runtime.bindUi(new FakeUiPort()); - assertEquals(1, recognition.sessions.size()); - FakeSpeechSession first = recognition.sessions.get(0); - - // A new media session (换源/换集) without a suspend() in between. The parked provider - // holds a capture lease bound to the old session, so it must be rebuilt. - hub.beginSession(0L); - runtime.refresh(); - - assertEquals(1, first.closeCalls); - assertEquals(2, recognition.sessions.size()); - runtime.close(); - } - - @Test(timeout = 15_000) - public void speechOnlyCaptureCanRebuildBeforeSlowNativeCreateFinishes() throws Exception { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub, true); - FakeSpeechRecognitionFactory recognition = new FakeSpeechRecognitionFactory(); - ExecutorService owner = Executors.newSingleThreadExecutor(); - ExecutorService caller = Executors.newSingleThreadExecutor(); - CountDownLatch checking = new CountDownLatch(1); - CountDownLatch allowReady = new CountDownLatch(1); - CountDownLatch creating = new CountDownLatch(1); - CountDownLatch allowCreate = new CountDownLatch(1); - AtomicInteger readinessChecks = new AtomicInteger(); - SpeechRecognitionFactory slowFactory = new SpeechRecognitionFactory() { - @Override - public boolean isReady() { - readinessChecks.incrementAndGet(); - checking.countDown(); - awaitLatch(allowReady); - return true; - } - - @Override - public Session create(Listener listener) { - creating.countDown(); - awaitLatch(allowCreate); - return recognition.create(listener); - } - }; - AdAudioRuntimeController runtime = new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), - AdAudioRuntimeControllerTest::emptySnapshot, playback, slowFactory, - owner, owner::shutdown); - PlaybackMediaSignalHub.PipelineLease pipeline = null; - try { - runtime.setSpeechConfig(SpeechAdConfig.create(true, "赌场", 15, "PROMPT")); - runtime.start(false); // Speech only: no fingerprint lease can mask activation. - caller.submit(() -> runtime.bindUi(new FakeUiPort())).get(2, TimeUnit.SECONDS); - assertTrue(checking.await(2, TimeUnit.SECONDS)); - caller.submit(runtime::refresh).get(2, TimeUnit.SECONDS); - assertFalse(runtime.needsPipelineRebuild()); - assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - - allowReady.countDown(); - assertTrue(creating.await(2, TimeUnit.SECONDS)); - // The next host refresh sees capture even while native create is blocked. - caller.submit(runtime::refresh).get(2, TimeUnit.SECONDS); - assertTrue(runtime.needsPipelineRebuild()); - pipeline = hub.attachPipeline(); - hub.resetTimeline(1_000L, PlaybackMediaSignalHub.ResetReason.ENGINE_REBUILD); - caller.submit(runtime::refresh).get(2, TimeUnit.SECONDS); - assertFalse(runtime.needsPipelineRebuild()); - assertEquals(1, readinessChecks.get()); - - allowCreate.countDown(); - owner.submit(() -> { }).get(2, TimeUnit.SECONDS); - hub.publishPcm(hub.session().frame(new float[] {0.1f}, 16_000, 1_000L)); - owner.submit(() -> { }).get(2, TimeUnit.SECONDS); - assertEquals(1, recognition.sessions.size()); - assertEquals(1, recognition.sessions.get(0).acceptCalls); - assertEquals(0, recognition.sessions.get(0).closeCalls); - } finally { - allowReady.countDown(); - allowCreate.countDown(); - runtime.close(); - caller.shutdownNow(); - owner.awaitTermination(2, TimeUnit.SECONDS); - if (pipeline != null) pipeline.close(); - hub.close(); - } - } - - @Test(timeout = 15_000) - public void slowClosePrecedesReplacementCreateAndRuntimeShutdownKeepsFinalCleanup() - throws Exception { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - hub.beginSession(0L); - FakeSpeechRecognitionFactory recognition = new FakeSpeechRecognitionFactory(); - ExecutorService owner = Executors.newSingleThreadExecutor(); - ExecutorService caller = Executors.newSingleThreadExecutor(); - AdAudioRuntimeController runtime = new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), - AdAudioRuntimeControllerTest::emptySnapshot, - new FakePlaybackPort(hub, true), recognition, owner, owner::shutdown); - try { - runtime.setSpeechConfig(SpeechAdConfig.create(true, "赌场", 15, "PROMPT")); - runtime.start(false); - runtime.bindUi(new FakeUiPort()); - owner.submit(() -> { }).get(2, TimeUnit.SECONDS); - FakeSpeechSession first = recognition.sessions.get(0); - first.blockClose(); - caller.submit(() -> runtime.setSpeechConfig( - SpeechAdConfig.create(true, "首充", 20, "PROMPT"))) - .get(2, TimeUnit.SECONDS); - assertTrue(first.closeStarted.await(2, TimeUnit.SECONDS)); - assertEquals(0, first.closeCalls); - assertEquals(1, recognition.sessions.size()); - - first.allowClose.countDown(); - owner.submit(() -> { }).get(2, TimeUnit.SECONDS); - assertEquals(1, first.closeCalls); - assertEquals(2, recognition.sessions.size()); - FakeSpeechSession second = recognition.sessions.get(1); - second.blockClose(); - caller.submit(runtime::close).get(2, TimeUnit.SECONDS); - assertTrue(second.closeStarted.await(2, TimeUnit.SECONDS)); - assertTrue(owner.isShutdown()); - assertFalse(owner.isTerminated()); - assertEquals(0, second.closeCalls); - second.allowClose.countDown(); - assertTrue(owner.awaitTermination(2, TimeUnit.SECONDS)); - assertEquals(1, second.closeCalls); - assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - } finally { - for (FakeSpeechSession session : recognition.sessions) { - if (session.allowClose != null) session.allowClose.countDown(); - } - runtime.close(); - caller.shutdownNow(); - owner.awaitTermination(2, TimeUnit.SECONDS); - hub.close(); - } - } - - private static void awaitLatch(CountDownLatch latch) { - try { - if (!latch.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out"); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(error); - } - } - - private static AdAudioRuntimeController runtime( - PlaybackMediaSignalHub hub, FakePlaybackPort playback, AdAudioRuleSnapshot snapshot) { - return new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), () -> snapshot, playback, - Runnable::run, () -> { }); - } - - private static AdAudioRuntimeController runtimeWithProbe( - PlaybackMediaSignalHub hub, FakePlaybackPort playback, - AdAudioRuleSnapshot snapshot, - AdAudioRuntimeController.ProbeProviderFactory factory) { - return new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), () -> snapshot, playback, - Runnable::run, () -> { }, factory); - } - - private static AdAudioRuntimeController runtimeWithProviders( - PlaybackMediaSignalHub hub, FakePlaybackPort playback, - AdAudioRuleSnapshot snapshot, - AdAudioRuntimeController.ProbeProviderFactory probeFactory, - AdAudioRuntimeController.SpeechProviderFactory speechFactory) { - return new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), () -> snapshot, playback, - Runnable::run, () -> { }, probeFactory, speechFactory); - } - private static AdAudioRuleSnapshot goodSnapshot() { - return snapshotForRule("ad"); - } - - private static AdAudioRuleSnapshot snapshotForRule(String ruleId) { - AudioFingerprintRuleSet rules = AudioFingerprintRuleCodec.fromJson("{" - + "\"schemaVersion\":2,\"algorithm\":{" - + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," - + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16}," - + "\"rules\":[{\"id\":\"" + ruleId + "\",\"durationMs\":10000," - + "\"anchorOffsetMs\":0,\"anchorDurationMs\":3000," - + "\"fingerprint\":[\"32f0007c\",\"35c100e0\",\"3b8b01c0\",\"d30a0380\"]}]}" ); - return new AdAudioRuleSnapshot("test", "v1", rules, List.of(), ""); - } - - private static AdAudioRuleSnapshot snapshotForRuleWithSidecar(String ruleId) { - return snapshotForRulesWithSidecar(ruleId); - } - - private static AdAudioRuleSnapshot snapshotForRulesWithSidecar(String... ruleIds) { - AdAudioRuleSnapshot base = snapshotForRule(ruleIds[0]); - AudioFingerprintRule prototype = base.ruleSet().rules().get(0); - List rules = new ArrayList<>(ruleIds.length); - for (String ruleId : ruleIds) { - rules.add(new AudioFingerprintRule( - ruleId, prototype.durationMs(), prototype.anchorOffsetMs(), - prototype.anchorDurationMs(), prototype.fingerprint(), - prototype.variants())); - } - AudioFingerprintRuleSet ruleSet = new AudioFingerprintRuleSet( - base.ruleSet().config(), rules); - byte[] canonical = "{\"format\":\"ad-audio-probe-rules\"}" - .getBytes(StandardCharsets.UTF_8); - ProbeRuleSidecar sidecar = ProbeRuleSidecar.verified( - "test", 1L, new byte[32], ProbeRuleSidecar.ALGORITHM_ID, - "converter-1", canonical, sha256(canonical)); - return new AdAudioRuleSnapshot( - base.sourceId(), base.version(), ruleSet, - base.warnings(), base.lastError(), sidecar); - } - - private static AdAudioSignalProvider.AdAudioCandidate providerCandidate( - PlaybackMediaSignalHub.Session session, String ruleId, - long startMs, long endMs) { - return new AdAudioSignalProvider.AdAudioCandidate( - session.id(), session.generation(), ruleId, "v1", - startMs, endMs, true, 0.95d, "probe"); - } - - private static byte[] sha256(byte[] value) { - try { - return MessageDigest.getInstance("SHA-256").digest(value); - } catch (Exception e) { - throw new AssertionError(e); - } - } - - private static AdAudioRuleSnapshot emptySnapshot() { - return new AdAudioRuleSnapshot( - "test", "", AudioFingerprintRuleSet.empty(), List.of(), ""); - } - - private static AdAudioRuleSnapshot errorSnapshot() { - return new AdAudioRuleSnapshot( - "test", "", AudioFingerprintRuleSet.empty(), List.of(), "INVALID_JSON"); - } - - private static float[] chirp16k() { - int sampleRate = 16_000; - int seconds = 3; - float[] output = new float[sampleRate * seconds]; - double startFrequency = 300; - double endFrequency = 3_000; - double rate = (endFrequency - startFrequency) / seconds; - for (int index = 0; index < output.length; index++) { - double time = index / (double) sampleRate; - double phase = 2.0 * Math.PI * (startFrequency * time + 0.5 * rate * time * time); - short pcm = (short) Math.round(Math.sin(phase) * Short.MAX_VALUE * 0.8); - output[index] = pcm / (float) Short.MAX_VALUE; - } - return output; - } - - private static final class MutableRuleSource implements AdAudioRuleSource { - private AdAudioRuleSnapshot snapshot; - - MutableRuleSource(AdAudioRuleSnapshot snapshot) { - this.snapshot = snapshot; - } - - @Override - public AdAudioRuleSnapshot load() { - return snapshot; - } - } - - private static final class FakePlaybackPort implements AdAudioRuntimeController.PlaybackPort { - private final PlaybackMediaSignalHub hub; - private final List seekTargets = new java.util.ArrayList<>(); - private boolean eligible; - /** Mirrors STATE_BUFFERING: the controller gate passes but duration is unknown. */ - private boolean positionEligible = true; - - FakePlaybackPort(PlaybackMediaSignalHub hub, boolean eligible) { - this.hub = hub; - this.eligible = eligible; - } - - @Override - public boolean isEligible(long sessionId, long generation) { - return eligible; - } - - @Override - public AdSkipCoordinator.PlaybackSnapshot snapshot(long sessionId, long generation) { - boolean seekable = eligible && positionEligible; - return new AdSkipCoordinator.PlaybackSnapshot( - sessionId, generation, 1_000L, - positionEligible ? 100_000L : -1L, seekable, !eligible, - new PlaybackMediaClock.Snapshot(generation, 0L, 50_000L, 1_000L, true, true)); - } - - @Override - public AdSkipCoordinator.SeekResult seekTo(long sessionId, long generation, long positionMs) { - PlaybackMediaSignalHub.Session session = hub.session(); - boolean applied = eligible && session.id() == sessionId && session.generation() == generation; - if (applied) seekTargets.add(positionMs); - return new AdSkipCoordinator.SeekResult(applied, session.id(), session.generation()); - } - } - - private static final class FakeUiPort implements AdSkipCoordinator.UiPort { - private int candidateShows; - private AdSkipCoordinator.Prompt lastPrompt; - private AdSkipCoordinator.Actions actions; - - @Override - public void showCandidate(AdSkipCoordinator.Prompt prompt, AdSkipCoordinator.Actions actions) { - candidateShows++; - lastPrompt = prompt; - this.actions = actions; - } - - @Override - public void showUndo(AdSkipCoordinator.UndoPrompt prompt, AdSkipCoordinator.Actions actions) { - this.actions = actions; - } - - @Override - public void dismiss(long sessionId) { - } - } - - private static final class FakeSpeechRecognitionFactory - implements SpeechRecognitionFactory { - private final List sessions = new ArrayList<>(); - - @Override - public boolean isReady() { - return true; - } - - @Override - public Session create(Listener listener) { - FakeSpeechSession session = new FakeSpeechSession(listener); - sessions.add(session); - return session; - } - } - - private static final class FakeSpeechSession - implements SpeechRecognitionFactory.Session { - private final SpeechRecognitionFactory.Listener listener; - private int acceptCalls; - private int closeCalls; - private CountDownLatch closeStarted; - private CountDownLatch allowClose; - - private FakeSpeechSession(SpeechRecognitionFactory.Listener listener) { - this.listener = listener; - } - - @Override - public void accept(float[] samples, long startUs, long endUs, - int timelineToken) { - acceptCalls++; - } - - @Override - public void reset() { - } - - @Override - public void close() { - if (closeStarted != null) { - closeStarted.countDown(); - awaitLatch(allowClose); - } - closeCalls++; - } - - private void blockClose() { - closeStarted = new CountDownLatch(1); - allowClose = new CountDownLatch(1); - } - } - private static final class FakeSignalProvider implements AdAudioSignalProvider { - private final String id; - private Listener listener; - private SessionContext context; - private String ruleVersion; - private ProviderState state = ProviderState.DISABLED; - private int starts; - private int closes; - private boolean failOnStart; - - private FakeSignalProvider(String id) { - this.id = id; - } - - @Override - public String id() { - return id; - } - - @Override - public void start(SessionContext context, AdAudioRuleSnapshot rules, Listener listener) { - if (failOnStart) throw new IllegalStateException("start failed"); - this.context = context; - this.ruleVersion = rules.version(); - this.listener = listener; - starts++; - state = ProviderState.RUNNING; - } - - @Override - public void onHostPosition(HostPosition position) { - } - - @Override - public void onTimelineReset(TimelineReset reset) { - } - - @Override - public void setEnabled(boolean enabled) { - state = enabled ? ProviderState.IDLE : ProviderState.DISABLED; - } - - @Override - public ProviderState state() { - return state; - } - - @Override - public void close() { - if (state == ProviderState.CLOSED) return; - state = ProviderState.CLOSED; - closes++; - } - - private void emit(AdAudioCandidate candidate) { - listener.onCandidate(candidate); - } - - private void emit(String ruleId, long startMs, long endMs) { - listener.onCandidate(new AdAudioCandidate( - context.sessionId(), context.generation(), ruleId, ruleVersion, - startMs, endMs, true, 1.0d, id)); - } - - private void fail(ErrorCode code) { - listener.onProviderError(new ProviderError(id, code, "test failure")); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioSignalProviderTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioSignalProviderTest.java deleted file mode 100644 index 7af43ac818..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdAudioSignalProviderTest.java +++ /dev/null @@ -1,280 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.util.LinkedHashMap; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; - -public class AdAudioSignalProviderTest { - - @Test - public void sessionContextDefensivelyCopiesHeaders() { - Map headers = new LinkedHashMap<>(); - headers.put("Referer", "https://media.example/"); - - AdAudioSignalProvider.SessionContext context = - new AdAudioSignalProvider.SessionContext( - 7L, 2L, "media-1", "https://media.example/video.m3u8", headers); - headers.put("Authorization", "late mutation"); - - assertEquals(Map.of("Referer", "https://media.example/"), context.headers()); - assertThrows(UnsupportedOperationException.class, - () -> context.headers().put("X-Test", "value")); - } - - @Test - public void boundaryValuesRejectInvalidTimelineAndCandidateData() { - assertThrows(IllegalArgumentException.class, - () -> new AdAudioSignalProvider.SessionContext( - -1L, 0L, "media", "https://media.example/video", Map.of())); - assertThrows(IllegalArgumentException.class, - () -> new AdAudioSignalProvider.HostPosition( - 1L, 0L, -1L, 10_000L, true, false)); - assertThrows(IllegalArgumentException.class, - () -> new AdAudioSignalProvider.AdAudioCandidate( - 1L, 0L, "rule", "v1", 2_000L, 1_000L, - true, 1.0d, "pcm")); - assertThrows(IllegalArgumentException.class, - () -> new AdAudioSignalProvider.AdAudioCandidate( - 1L, 0L, "rule", "v1", 1_000L, 2_000L, - true, Double.NaN, "pcm")); - } - - @Test - public void noopProviderHasNoResourcesOrCallbacksAndClosesIdempotently() { - NoopAdAudioSignalProvider provider = new NoopAdAudioSignalProvider("probe"); - AtomicInteger callbacks = new AtomicInteger(); - AdAudioSignalProvider.Listener listener = new AdAudioSignalProvider.Listener() { - @Override - public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { - callbacks.incrementAndGet(); - } - - @Override - public void onProviderError(AdAudioSignalProvider.ProviderError error) { - callbacks.incrementAndGet(); - } - }; - - assertEquals("probe", provider.id()); - assertEquals(AdAudioSignalProvider.ProviderState.DISABLED, provider.state()); - provider.setEnabled(true); - assertEquals(AdAudioSignalProvider.ProviderState.IDLE, provider.state()); - provider.start(context(7L, 2L), snapshot(), listener); - provider.onHostPosition(new AdAudioSignalProvider.HostPosition( - 6L, 1L, 1_000L, 10_000L, true, false)); - provider.onTimelineReset(new AdAudioSignalProvider.TimelineReset( - 6L, 1L, AdAudioSignalProvider.ResetReason.SEEK, 2_000L)); - - assertEquals(0, callbacks.get()); - assertEquals(AdAudioSignalProvider.ProviderState.IDLE, provider.state()); - - provider.close(); - provider.close(); - provider.setEnabled(true); - provider.start(context(8L, 0L), snapshot(), listener); - - assertEquals(0, callbacks.get()); - assertEquals(AdAudioSignalProvider.ProviderState.CLOSED, provider.state()); - } - - @Test - public void disablingNoopReturnsItToDisabledState() { - NoopAdAudioSignalProvider provider = new NoopAdAudioSignalProvider("probe"); - - provider.setEnabled(true); - provider.setEnabled(false); - - assertEquals(AdAudioSignalProvider.ProviderState.DISABLED, provider.state()); - } - - @Test - public void pcmProviderMapsMatcherEventsAndTracksTimelineGeneration() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); - PlaybackMediaSignalHub.Session session = hub.beginSession(0L); - RecordingListener listener = new RecordingListener(); - PcmAdAudioSignalProvider provider = pcmProvider(hub, ruleSet -> matcherReturning( - new AudioFingerprintMatcher.MatchEvent( - AudioFingerprintMatcher.Type.FULL_MATCHED, - "ad-1", 1_000L, 3_000L, 0.95f, 8))); - - provider.setEnabled(true); - provider.start(context(session.id(), session.generation()), rulesSnapshot(), listener); - hub.publishPcm(session.frame(new float[] {0.1f, -0.1f}, 16_000, 900L)); - - assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, provider.state()); - assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - assertEquals(1, listener.candidates.size()); - AdAudioSignalProvider.AdAudioCandidate first = listener.candidates.get(0); - assertEquals(session.id(), first.sessionId()); - assertEquals(0L, first.generation()); - assertEquals("rules-v1", first.ruleVersion()); - assertEquals("pcm", first.providerId()); - assertTrue(first.fullMatch()); - - PlaybackMediaSignalHub.Session reset = hub.resetTimeline( - 4_000L, PlaybackMediaSignalHub.ResetReason.SEEK); - hub.publishPcm(reset.frame(new float[] {0.2f, -0.2f}, 16_000, 4_000L)); - - assertEquals(2, listener.candidates.size()); - assertEquals(reset.generation(), listener.candidates.get(1).generation()); - assertTrue(listener.errors.isEmpty()); - } - - @Test - public void disablingPcmProviderReleasesCaptureAndStopsCallbacks() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); - PlaybackMediaSignalHub.Session session = hub.beginSession(0L); - RecordingListener listener = new RecordingListener(); - PcmAdAudioSignalProvider provider = pcmProvider(hub, ruleSet -> matcherReturning( - new AudioFingerprintMatcher.MatchEvent( - AudioFingerprintMatcher.Type.START_MATCHED, - "ad-1", 1_000L, 3_000L, 0.9f, 4))); - provider.setEnabled(true); - provider.start(context(session.id(), session.generation()), rulesSnapshot(), listener); - - provider.setEnabled(false); - hub.publishPcm(session.frame(new float[] {0.1f}, 16_000, 1_000L)); - - assertEquals(AdAudioSignalProvider.ProviderState.DISABLED, provider.state()); - assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - assertTrue(listener.candidates.isEmpty()); - provider.close(); - provider.close(); - assertEquals(AdAudioSignalProvider.ProviderState.CLOSED, provider.state()); - } - - @Test - public void pcmMatcherFailureIsReportedWithoutEscapingTheHub() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); - PlaybackMediaSignalHub.Session session = hub.beginSession(0L); - RecordingListener listener = new RecordingListener(); - PcmAdAudioSignalProvider provider = pcmProvider(hub, ruleSet -> new AdAudioConsumer.Matcher() { - @Override - public List feed( - short[] samples, int sampleRate, long captureStartMs) { - throw new IllegalStateException("matcher failed"); - } - - @Override - public List finish() { - return List.of(); - } - }); - provider.setEnabled(true); - provider.start(context(session.id(), session.generation()), rulesSnapshot(), listener); - - hub.publishPcm(session.frame(new float[] {0.1f}, 16_000, 1_000L)); - provider.onHostPosition(new AdAudioSignalProvider.HostPosition( - session.id(), session.generation(), 1_000L, 10_000L, true, false)); - - assertEquals(1, listener.errors.size()); - assertEquals(AdAudioSignalProvider.ErrorCode.ANALYSIS_FAILED, - listener.errors.get(0).code()); - assertEquals(AdAudioSignalProvider.ProviderState.DEGRADED, provider.state()); - } - - @Test - public void pcmMatcherRestartFailureAfterResetIsReported() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); - PlaybackMediaSignalHub.Session session = hub.beginSession(0L); - RecordingListener listener = new RecordingListener(); - AtomicInteger creates = new AtomicInteger(); - PcmAdAudioSignalProvider provider = pcmProvider(hub, ruleSet -> { - if (creates.incrementAndGet() > 1) { - throw new IllegalStateException("restart failed"); - } - return quietMatcher(); - }); - provider.setEnabled(true); - provider.start(context(session.id(), session.generation()), rulesSnapshot(), listener); - - hub.resetTimeline(2_000L, PlaybackMediaSignalHub.ResetReason.SEEK); - - assertEquals(1, listener.errors.size()); - assertEquals(AdAudioSignalProvider.ErrorCode.ANALYSIS_FAILED, - listener.errors.get(0).code()); - assertEquals(AdAudioSignalProvider.ProviderState.DEGRADED, provider.state()); - } - - private static AdAudioSignalProvider.SessionContext context(long sessionId, long generation) { - return new AdAudioSignalProvider.SessionContext( - sessionId, generation, "media", "https://media.example/video.m3u8", Map.of()); - } - - private static AdAudioRuleSnapshot snapshot() { - return new AdAudioRuleSnapshot( - "test", "v1", AudioFingerprintRuleSet.empty(), List.of(), ""); - } - - private static AdAudioRuleSnapshot rulesSnapshot() { - AudioFingerprintRule rule = new AudioFingerprintRule( - "ad-1", 3_000L, 0L, 1_000L, - new int[] {1, 2, 3, 4}, List.of()); - AudioFingerprintRuleSet rules = new AudioFingerprintRuleSet( - AudioFingerprintConfig.standard(), List.of(rule)); - return new AdAudioRuleSnapshot("signed:test", "rules-v1", rules, List.of(), ""); - } - - private static PcmAdAudioSignalProvider pcmProvider( - PlaybackMediaSignalHub hub, AdAudioConsumer.MatcherFactory matcherFactory) { - return new PcmAdAudioSignalProvider( - hub, matcherFactory, 4, Runnable::run, new AdAudioDiagnostics()); - } - - private static AdAudioConsumer.Matcher matcherReturning( - AudioFingerprintMatcher.MatchEvent event) { - return new AdAudioConsumer.Matcher() { - @Override - public List feed( - short[] samples, int sampleRate, long captureStartMs) { - return List.of(event); - } - - @Override - public List finish() { - return List.of(); - } - }; - } - - private static AdAudioConsumer.Matcher quietMatcher() { - return new AdAudioConsumer.Matcher() { - @Override - public List feed( - short[] samples, int sampleRate, long captureStartMs) { - return List.of(); - } - - @Override - public List finish() { - return List.of(); - } - }; - } - - private static final class RecordingListener implements AdAudioSignalProvider.Listener { - private final List candidates = new ArrayList<>(); - private final List errors = new ArrayList<>(); - - @Override - public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { - candidates.add(candidate); - } - - @Override - public void onProviderError(AdAudioSignalProvider.ProviderError error) { - errors.add(error); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorTest.java deleted file mode 100644 index 142abee943..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipCoordinatorTest.java +++ /dev/null @@ -1,284 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.player.audio.PlaybackMediaClock; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.LongSupplier; - -public class AdSkipCoordinatorTest { - - @Test - public void confirmSeeksAndUndoReturnsToOriginalPosition() { - ManualTime time = new ManualTime(); - FakePlaybackPort playback = new FakePlaybackPort(snapshot( - 7L, 2L, 45_000L, 120_000L, true, false, - freshClock(2L, 35_000L, 35_000L, 25_000L))); - FakeUiPort ui = new FakeUiPort(); - AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); - - coordinator.onCandidate(candidate(7L, 2L, "ad-1", 10_000L, 25_000L, - AudioFingerprintMatcher.Type.START_MATCHED)); - ui.actions.confirm(); - ui.actions.undo(); - - assertEquals(List.of(60_000L, 45_000L), playback.seekTargets); - assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state()); - } - - @Test - public void coordinatorSeekLifecyclePreservesUndoAcrossNewGeneration() { - ManualTime time = new ManualTime(); - FakePlaybackPort playback = new FakePlaybackPort(snapshot( - 7L, 2L, 45_000L, 120_000L, true, false, - freshClock(2L, 35_000L, 35_000L, 25_000L))); - FakeUiPort ui = new FakeUiPort(); - AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); - - coordinator.onCandidate(candidate(7L, 2L, "ad-1", 10_000L, 25_000L, - AudioFingerprintMatcher.Type.START_MATCHED)); - ui.actions.confirm(); - coordinator.onTimelineReset(new com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub.Lifecycle( - 7L, 3L, com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub.ResetReason.SEEK, 60_000L)); - ui.actions.undo(); - - assertEquals(List.of(60_000L, 45_000L), playback.seekTargets); - assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state()); - } - - @Test - public void staleGenerationAndLivePlaybackAreRejected() { - FakePlaybackPort stalePlayback = new FakePlaybackPort(snapshot( - 7L, 3L, 1_000L, 100_000L, true, false, - freshClock(3L, 0L, 30_000L, 1_000L))); - FakeUiPort staleUi = new FakeUiPort(); - AdSkipCoordinator stale = new AdSkipCoordinator(stalePlayback, staleUi, 5_000L); - stale.onCandidate(candidate(7L, 2L, "old", 0L, 10_000L, - AudioFingerprintMatcher.Type.START_MATCHED)); - - FakePlaybackPort livePlayback = new FakePlaybackPort(snapshot( - 8L, 1L, 1_000L, 100_000L, true, true, - freshClock(1L, 0L, 30_000L, 1_000L))); - FakeUiPort liveUi = new FakeUiPort(); - AdSkipCoordinator live = new AdSkipCoordinator(livePlayback, liveUi, 5_000L); - live.onCandidate(candidate(8L, 1L, "live", 0L, 10_000L, - AudioFingerprintMatcher.Type.START_MATCHED)); - - assertEquals(0, staleUi.candidateShows); - assertEquals(0, liveUi.candidateShows); - } - - @Test - public void fullMatchUpgradesPromptButDuplicateStartIsIgnored() { - FakePlaybackPort playback = new FakePlaybackPort(snapshot( - 1L, 1L, 5_000L, 100_000L, true, false, - freshClock(1L, 0L, 30_000L, 5_000L))); - FakeUiPort ui = new FakeUiPort(); - AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); - - coordinator.onCandidate(candidate(1L, 1L, "ad", 0L, 10_000L, - AudioFingerprintMatcher.Type.START_MATCHED)); - coordinator.onCandidate(candidate(1L, 1L, "ad", 0L, 10_000L, - AudioFingerprintMatcher.Type.START_MATCHED)); - coordinator.onCandidate(candidate(1L, 1L, "ad", 0L, 10_000L, - AudioFingerprintMatcher.Type.FULL_MATCHED)); - - assertEquals(2, ui.candidateShows); - assertTrue(ui.lastPrompt.fullMatch()); - } - - @Test - public void ignoreSuppressesOnlyTheCurrentCandidate() { - FakePlaybackPort playback = new FakePlaybackPort(snapshot( - 1L, 1L, 5_000L, 100_000L, true, false, - freshClock(1L, 0L, 30_000L, 5_000L))); - FakeUiPort ui = new FakeUiPort(); - AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); - AdAudioConsumer.Candidate first = candidate(1L, 1L, "ad-1", 0L, 10_000L, - AudioFingerprintMatcher.Type.START_MATCHED); - - coordinator.onCandidate(first); - ui.actions.ignore(); - coordinator.onCandidate(first); - coordinator.onCandidate(candidate(1L, 1L, "ad-2", 0L, 12_000L, - AudioFingerprintMatcher.Type.START_MATCHED)); - - assertEquals(2, ui.candidateShows); - assertEquals("ad-2", ui.lastPrompt.ruleId()); - } - - @Test - public void undoAfterDeadlineDoesNotSeekAgain() { - ManualTime time = new ManualTime(); - FakePlaybackPort playback = new FakePlaybackPort(snapshot( - 1L, 1L, 5_000L, 100_000L, true, false, - freshClock(1L, 0L, 30_000L, 5_000L))); - FakeUiPort ui = new FakeUiPort(); - AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); - coordinator.onCandidate(candidate(1L, 1L, "ad", 0L, 10_000L, - AudioFingerprintMatcher.Type.FULL_MATCHED)); - ui.actions.confirm(); - time.now = 5_001L; - - ui.actions.undo(); - - assertEquals(List.of(10_000L), playback.seekTargets); - assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state()); - } - - @Test - public void staleDialogActionCannotConfirmReplacementPrompt() { - FakePlaybackPort playback = new FakePlaybackPort(snapshot( - 1L, 1L, 5_000L, 100_000L, true, false, - freshClock(1L, 0L, 30_000L, 5_000L))); - FakeUiPort ui = new FakeUiPort(); - AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); - coordinator.onCandidate(candidate(1L, 1L, "ad-1", 0L, 10_000L, - AudioFingerprintMatcher.Type.START_MATCHED)); - AdSkipCoordinator.Actions old = ui.actions; - coordinator.onCandidate(candidate(1L, 1L, "ad-2", 0L, 20_000L, - AudioFingerprintMatcher.Type.START_MATCHED)); - - old.confirm(); - - assertTrue(playback.seekTargets.isEmpty()); - assertEquals("ad-2", ui.lastPrompt.ruleId()); - } - - @Test - public void automaticCandidateSeeksImmediatelyAndKeepsUndo() { - ManualTime time = new ManualTime(); - FakePlaybackPort playback = new FakePlaybackPort(snapshot( - 1L, 1L, 5_000L, 100_000L, true, false, - freshClock(1L, 0L, 30_000L, 5_000L))); - FakeUiPort ui = new FakeUiPort(); - AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L, time); - - boolean applied = coordinator.onAutoCandidate(providerCandidate( - 1L, 1L, "ad", 0L, 10_000L, true)); - - assertTrue(applied); - assertEquals(0, ui.candidateShows); - assertEquals(List.of(10_000L), playback.seekTargets); - assertEquals(AdSkipCoordinator.State.UNDO_WINDOW, coordinator.state()); - - ui.actions.undo(); - - assertEquals(List.of(10_000L, 5_000L), playback.seekTargets); - assertEquals(AdSkipCoordinator.State.IDLE, coordinator.state()); - } - - @Test - public void ignoreSuppressesOnlyTheMatchedIntervalNotEveryLaterMatchOfTheRule() { - FakePlaybackPort playback = new FakePlaybackPort(snapshot( - 1L, 1L, 5_000L, 1_500_000L, true, false, - freshClock(1L, 0L, 1_000_000L, 5_000L))); - FakeUiPort ui = new FakeUiPort(); - AdSkipCoordinator coordinator = new AdSkipCoordinator(playback, ui, 5_000L); - - coordinator.onCandidate(candidate(1L, 1L, "speech-keyword", 60_000L, 75_000L, - AudioFingerprintMatcher.Type.FULL_MATCHED)); - ui.actions.ignore(); - coordinator.onCandidate(candidate(1L, 1L, "speech-keyword", 60_000L, 75_000L, - AudioFingerprintMatcher.Type.FULL_MATCHED)); - coordinator.onCandidate(candidate(1L, 1L, "speech-keyword", 900_000L, 915_000L, - AudioFingerprintMatcher.Type.FULL_MATCHED)); - - assertEquals(2, ui.candidateShows); - assertEquals(915_000L, ui.lastPrompt.targetMediaMs()); - } - - private static AdSkipCoordinator.PlaybackSnapshot snapshot( - long session, long generation, long position, long duration, - boolean seekable, boolean live, PlaybackMediaClock.Snapshot clock) { - return new AdSkipCoordinator.PlaybackSnapshot( - session, generation, position, duration, seekable, live, clock); - } - - private static PlaybackMediaClock.Snapshot freshClock( - long generation, long anchor, long captured, long presented) { - return new PlaybackMediaClock.Snapshot(generation, anchor, captured, presented, true, true); - } - - private static AdAudioConsumer.Candidate candidate( - long session, long generation, String ruleId, long start, long end, - AudioFingerprintMatcher.Type type) { - return new AdAudioConsumer.Candidate(session, generation, - new AudioFingerprintMatcher.MatchEvent(type, ruleId, start, end, 0.9f, 6)); - } - - private static AdAudioSignalProvider.AdAudioCandidate providerCandidate( - long session, long generation, String ruleId, long start, long end, - boolean fullMatch) { - return new AdAudioSignalProvider.AdAudioCandidate( - session, generation, ruleId, "rules-v1", start, end, - fullMatch, 0.9d, "pcm"); - } - - private static final class ManualTime implements LongSupplier { - long now; - - @Override - public long getAsLong() { - return now; - } - } - - private static final class FakePlaybackPort implements AdSkipCoordinator.PlaybackPort { - private AdSkipCoordinator.PlaybackSnapshot snapshot; - private final List seekTargets = new ArrayList<>(); - - FakePlaybackPort(AdSkipCoordinator.PlaybackSnapshot snapshot) { - this.snapshot = snapshot; - } - - @Override - public AdSkipCoordinator.PlaybackSnapshot snapshot(long sessionId, long generation) { - return snapshot; - } - - @Override - public AdSkipCoordinator.SeekResult seekTo(long sessionId, long generation, long positionMs) { - if (snapshot.sessionId() != sessionId || snapshot.generation() != generation) { - return AdSkipCoordinator.SeekResult.rejected(sessionId, generation); - } - seekTargets.add(positionMs); - long nextGeneration = generation == Long.MAX_VALUE ? 0L : generation + 1L; - PlaybackMediaClock.Snapshot nextClock = new PlaybackMediaClock.Snapshot( - nextGeneration, snapshot.clock().mediaAnchorMs(), snapshot.clock().capturedUntilMs(), - snapshot.clock().presentedCaptureMs(), snapshot.clock().playing(), snapshot.clock().fresh()); - snapshot = new AdSkipCoordinator.PlaybackSnapshot( - snapshot.sessionId(), nextGeneration, positionMs, snapshot.durationMs(), - snapshot.seekable(), snapshot.live(), nextClock); - return new AdSkipCoordinator.SeekResult(true, sessionId, nextGeneration); - } - } - - private static final class FakeUiPort implements AdSkipCoordinator.UiPort { - private int candidateShows; - private AdSkipCoordinator.Prompt lastPrompt; - private AdSkipCoordinator.Actions actions; - - @Override - public void showCandidate(AdSkipCoordinator.Prompt prompt, AdSkipCoordinator.Actions actions) { - candidateShows++; - lastPrompt = prompt; - this.actions = actions; - } - - @Override - public void showUndo(AdSkipCoordinator.UndoPrompt prompt, AdSkipCoordinator.Actions actions) { - this.actions = actions; - } - - @Override - public void dismiss(long sessionId) { - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipPolicyControllerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipPolicyControllerTest.java deleted file mode 100644 index 9dc9b4c6b6..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/AdSkipPolicyControllerTest.java +++ /dev/null @@ -1,177 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -public class AdSkipPolicyControllerTest { - - @Test - public void defaultsToPromptAndRoutesNewCandidatesToPromptSink() { - RecordingSinks sinks = new RecordingSinks(); - AdSkipPolicyController policy = policy(sinks); - - policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, false)); - - assertEquals(AdSkipPolicyController.Mode.PROMPT, policy.mode()); - assertEquals(1, sinks.prompted.size()); - assertTrue(sinks.automated.isEmpty()); - } - - @Test - public void switchingToAutoAffectsOnlyNewCandidates() { - RecordingSinks sinks = new RecordingSinks(); - AdSkipPolicyController policy = policy(sinks); - policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, false)); - - policy.setMode(AdSkipPolicyController.Mode.AUTO); - policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, true)); - policy.onCandidate(candidate("rule-2", 4_000L, 6_000L, true)); - - assertEquals(2, sinks.prompted.size()); - assertTrue(sinks.prompted.get(1).fullMatch()); - assertEquals(1, sinks.automated.size()); - assertEquals("rule-2", sinks.automated.get(0).ruleId()); - } - - @Test - public void switchingBackToPromptAffectsOnlyLaterCandidates() { - RecordingSinks sinks = new RecordingSinks(); - AdSkipPolicyController policy = policy(sinks); - policy.setMode(AdSkipPolicyController.Mode.AUTO); - policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, true)); - - policy.setMode(AdSkipPolicyController.Mode.PROMPT); - policy.onCandidate(candidate("rule-2", 4_000L, 6_000L, true)); - - assertEquals(1, sinks.automated.size()); - assertEquals(1, sinks.prompted.size()); - assertEquals("rule-2", sinks.prompted.get(0).ruleId()); - } - - @Test - public void generationResetClearsDecisionsAndRejectsOldCallbacks() { - RecordingSinks sinks = new RecordingSinks(); - AdSkipPolicyController policy = policy(sinks); - policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, true)); - - policy.onTimelineReset(new AdAudioSignalProvider.TimelineReset( - 7L, 3L, AdAudioSignalProvider.ResetReason.SEEK, 5_000L)); - policy.onCandidate(candidate("rule-2", 4_000L, 6_000L, true)); - policy.onCandidate(new AdAudioSignalProvider.AdAudioCandidate( - 7L, 3L, "rule-1", "rules-v1", 1_000L, 3_000L, - true, 0.95d, "pcm")); - - assertEquals(2, sinks.prompted.size()); - assertEquals(1L, policy.diagnostics().stale()); - assertEquals(1L, policy.diagnostics().resets()); - } - - @Test - public void repeatedModeAndDuplicateCandidatesAreIdempotent() { - RecordingSinks sinks = new RecordingSinks(); - AdSkipPolicyController policy = policy(sinks); - - policy.setMode(AdSkipPolicyController.Mode.PROMPT); - policy.setMode(AdSkipPolicyController.Mode.PROMPT); - policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, true)); - policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, true)); - - assertEquals(1, sinks.prompted.size()); - assertEquals(0L, policy.diagnostics().modeSwitches()); - assertEquals(1L, policy.diagnostics().duplicates()); - } - - @Test - public void speechAutoDoesNotChangePcmOrProbePolicy() { - RecordingSinks sinks = new RecordingSinks(); - AdSkipPolicyController policy = policy(sinks); - policy.setModeResolver(provider -> "speech".equals(provider) - ? AdSkipPolicyController.Mode.AUTO - : AdSkipPolicyController.Mode.PROMPT); - - policy.onCandidate(candidate("speech-rule", 1_000L, 3_000L, true, "speech")); - policy.onCandidate(candidate("pcm-rule", 4_000L, 6_000L, true, "pcm")); - policy.onCandidate(candidate("probe-rule", 7_000L, 9_000L, true, "probe")); - - assertEquals(1, sinks.automated.size()); - assertEquals("speech", sinks.automated.get(0).providerId()); - assertEquals(2, sinks.prompted.size()); - assertEquals("pcm", sinks.prompted.get(0).providerId()); - assertEquals("probe", sinks.prompted.get(1).providerId()); - } - - @Test - public void unknownProviderDefaultsToPromptWhenResolverReturnsNull() { - RecordingSinks sinks = new RecordingSinks(); - AdSkipPolicyController policy = policy(sinks); - policy.setModeResolver(provider -> null); - - policy.onCandidate(candidate("future-rule", 1_000L, 3_000L, true, "future")); - - assertEquals(1, sinks.prompted.size()); - assertTrue(sinks.automated.isEmpty()); - } - - @Test - public void resolverFailureDefaultsToPrompt() { - RecordingSinks sinks = new RecordingSinks(); - AdSkipPolicyController policy = policy(sinks); - policy.setModeResolver(provider -> { throw new IllegalStateException("boom"); }); - - policy.onCandidate(candidate("future-rule", 1_000L, 3_000L, true, "future")); - - assertEquals(1, sinks.prompted.size()); - assertTrue(sinks.automated.isEmpty()); - } - @Test - public void closeStopsAllFutureDispatch() { - RecordingSinks sinks = new RecordingSinks(); - AdSkipPolicyController policy = policy(sinks); - - policy.close(); - policy.setMode(AdSkipPolicyController.Mode.AUTO); - policy.onCandidate(candidate("rule-1", 1_000L, 3_000L, true)); - - assertTrue(sinks.prompted.isEmpty()); - assertTrue(sinks.automated.isEmpty()); - } - - private static AdSkipPolicyController policy(RecordingSinks sinks) { - return new AdSkipPolicyController( - new AdAudioSignalProvider.SessionContext( - 7L, 2L, "media", "https://media.example/video.m3u8", Map.of()), - "rules-v1", 16, sinks::prompt, sinks::auto); - } - - private static AdAudioSignalProvider.AdAudioCandidate candidate( - String ruleId, long startMs, long endMs, boolean fullMatch) { - return new AdAudioSignalProvider.AdAudioCandidate( - 7L, 2L, ruleId, "rules-v1", startMs, endMs, - fullMatch, 0.95d, "pcm"); - } - - private static AdAudioSignalProvider.AdAudioCandidate candidate( - String ruleId, long startMs, long endMs, boolean fullMatch, String providerId) { - return new AdAudioSignalProvider.AdAudioCandidate( - 7L, 2L, ruleId, "rules-v1", startMs, endMs, - fullMatch, 0.95d, providerId); - } - private static final class RecordingSinks { - private final List prompted = new ArrayList<>(); - private final List automated = new ArrayList<>(); - - private void prompt(AdAudioSignalProvider.AdAudioCandidate candidate) { - prompted.add(candidate); - } - - private void auto(AdAudioSignalProvider.AdAudioCandidate candidate) { - automated.add(candidate); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintContractTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintContractTest.java deleted file mode 100644 index 11d36829f1..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintContractTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import org.junit.Test; - -import java.util.List; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; - -public class AudioFingerprintContractTest { - - @Test - public void standardConfigMatchesSdkV2() { - AudioFingerprintConfig config = AudioFingerprintConfig.standard(); - - assertEquals(16_000, config.sampleRate()); - assertEquals(512, config.windowMs()); - assertEquals(256, config.hopMs()); - assertEquals(16, config.bandCount()); - assertEquals(8_192, config.windowSamples()); - assertEquals(4_096, config.hopSamples()); - } - - @Test - public void sampleCountsUseSdkRoundingForFractionalMilliseconds() { - AudioFingerprintConfig config = new AudioFingerprintConfig(11_025, 513, 257, 16); - - assertEquals(5_656, config.windowSamples()); - assertEquals(2_833, config.hopSamples()); - } - - @Test(expected = IllegalArgumentException.class) - public void configRejectsExcessiveFftWorkload() { - new AudioFingerprintConfig(192_000, 4_000, 64, 16); - } - - @Test(expected = IllegalArgumentException.class) - public void configRejectsHopLongerThanWindow() { - new AudioFingerprintConfig(16_000, 256, 512, 16); - } - - @Test(expected = IllegalArgumentException.class) - public void ruleRejectsTooShortFingerprint() { - new AudioFingerprintRule("ad-1", 15_000, 0, 5_000, new int[]{1, 2, 3}, List.of()); - } - - @Test(expected = IllegalArgumentException.class) - public void ruleRejectsAnchorOutsideDuration() { - new AudioFingerprintRule("ad-1", 5_000, 2_000, 4_000, sequence(1), List.of()); - } - - @Test - public void ruleDefensivelyCopiesSequences() { - int[] primary = sequence(1); - int[] variant = sequence(11); - AudioFingerprintRule rule = new AudioFingerprintRule( - "ad-1", 15_000, 0, 5_000, primary, List.of(variant)); - - primary[0] = 99; - variant[0] = 99; - int[] returned = rule.fingerprint(); - returned[0] = 77; - - assertArrayEquals(sequence(1), rule.fingerprint()); - assertArrayEquals(sequence(11), rule.variants().get(0)); - assertEquals(2, rule.allSequences().size()); - } - - @Test(expected = IllegalArgumentException.class) - public void ruleSetRejectsDuplicateIds() { - AudioFingerprintRule first = rule("same", 1); - AudioFingerprintRule second = rule("same", 11); - - new AudioFingerprintRuleSet(AudioFingerprintConfig.standard(), List.of(first, second)); - } - - @Test - public void ruleSetExposesSdkSchemaAndAlgorithm() { - AudioFingerprintRuleSet rules = new AudioFingerprintRuleSet( - AudioFingerprintConfig.standard(), List.of(rule("ad-1", 1))); - - assertEquals(2, AudioFingerprintRuleSet.SCHEMA_VERSION); - assertEquals("spectral-sequence-v2", AudioFingerprintRuleSet.ALGORITHM_ID); - assertEquals(1, rules.rules().size()); - } - - private static AudioFingerprintRule rule(String id, int seed) { - return new AudioFingerprintRule(id, 15_000, 0, 5_000, sequence(seed), List.of()); - } - - private static int[] sequence(int seed) { - return new int[]{seed, seed + 1, seed + 2, seed + 3}; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintMatcherTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintMatcherTest.java deleted file mode 100644 index 7a12f73c50..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintMatcherTest.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -public class AudioFingerprintMatcherTest { - - private static final AudioFingerprintConfig CONFIG = AudioFingerprintConfig.standard(); - private static final int[] CHIRP_HASHES = hashes( - "32f0007c,35c100e0,3b8b01c0,d30a0380,2e0b0700," - + "0c650600,9c560c00,49db0c00,d9161800,70d41800"); - - @Test - public void emitsStartThenFullMatchWithPredictedEnd() { - AudioFingerprintMatcher matcher = matcherFor(chirpRule()); - List events = feedInChunks(matcher, chirp16k()); - - assertEquals(2, events.size()); - assertEquals(AudioFingerprintMatcher.Type.START_MATCHED, events.get(0).type()); - assertEquals("chirp-ad", events.get(0).ruleId()); - assertEquals(0, events.get(0).startTimeMs()); - assertEquals(15_000, events.get(0).endTimeMs()); - assertEquals(6, events.get(0).matchedFrames()); - assertEquals(AudioFingerprintMatcher.Type.FULL_MATCHED, events.get(1).type()); - assertTrue(events.get(1).confidence() >= 0.78f); - } - - @Test - public void shortRuleEmitsStartAndFullMatchOnTheSameFrame() { - AudioFingerprintRule shortRule = new AudioFingerprintRule( - "short-ad", 15_000, 0, 1_280, - Arrays.copyOf(CHIRP_HASHES, AudioFingerprintRule.MIN_SEQUENCE_FRAMES), List.of()); - AudioFingerprintMatcher matcher = matcherFor(shortRule); - - List events = feedInChunks(matcher, chirp16k()); - - assertEquals(2, events.size()); - assertEquals(AudioFingerprintMatcher.Type.START_MATCHED, events.get(0).type()); - assertEquals(AudioFingerprintMatcher.Type.FULL_MATCHED, events.get(1).type()); - } - - @Test - public void exactWindowBoundaryDoesNotRequireAnExtraPcmSample() { - int frames = AudioFingerprintRule.MIN_SEQUENCE_FRAMES; - int sampleCount = CONFIG.windowSamples() + (frames - 1) * CONFIG.hopSamples(); - AudioFingerprintRule shortRule = new AudioFingerprintRule( - "exact-boundary", 15_000, 0, 1_280, - Arrays.copyOf(CHIRP_HASHES, frames), List.of()); - - List events = feedInChunks( - matcherFor(shortRule), Arrays.copyOf(chirp16k(), sampleCount)); - - assertEquals(2, events.size()); - assertEquals(AudioFingerprintMatcher.Type.FULL_MATCHED, events.get(1).type()); - } - - @Test - public void finishFlushesTheFinalFractionalResample() { - short[] source = Arrays.copyOf(chirp(8_000, 2), 10_240); - List variants = SpectralFingerprint.extractVariants(source, 8_000, 1, CONFIG); - AudioFingerprintRule rule = new AudioFingerprintRule( - "fractional-tail", 15_000, 0, 1_280, variants.get(0), List.of()); - AudioFingerprintMatcher matcher = matcherFor(rule); - - assertTrue(matcher.feed(new AudioFingerprintMatcher.PcmChunk(source, 8_000, 1, 0)) - .events().isEmpty()); - AudioFingerprintMatcher.FeedResult result = matcher.finish(); - - assertEquals(AudioFingerprintMatcher.Status.MATCHED, result.status()); - assertEquals(2, result.events().size()); - assertEquals(AudioFingerprintMatcher.Type.FULL_MATCHED, result.events().get(1).type()); - } - - @Test - public void resamplingIsInvariantToPcmChunkBoundaries() { - short[] source = chirp(48_000, 3); - List variants = SpectralFingerprint.extractVariants(source, 48_000, 1, CONFIG); - AudioFingerprintRule rule = new AudioFingerprintRule( - "chunk-invariant", 15_000, 0, 3_000, variants.get(0), - variants.subList(1, variants.size())); - - List whole = feedInChunks( - matcherFor(rule), source, 48_000, source.length); - List split = feedInChunks( - matcherFor(rule), source, 48_000, 1_024); - - assertEquals(types(whole), types(split)); - assertEquals(2, split.size()); - assertEquals(AudioFingerprintMatcher.Type.FULL_MATCHED, split.get(1).type()); - } - - @Test - public void timelineGapResetsPendingCandidate() { - AudioFingerprintMatcher matcher = matcherFor(chirpRule()); - short[] first = Arrays.copyOf(chirp16k(), 10_000); - short[] second = Arrays.copyOfRange(chirp16k(), 10_000, 20_000); - - assertEquals(AudioFingerprintMatcher.Status.NO_MATCH, - matcher.feed(new AudioFingerprintMatcher.PcmChunk(first, 16_000, 1, 0)).status()); - assertEquals(AudioFingerprintMatcher.Status.RESET, - matcher.feed(new AudioFingerprintMatcher.PcmChunk(second, 16_000, 1, 10_000)).status()); - } - - @Test - public void timelineResetRemainsObservableWhenTheNewChunkMatches() { - AudioFingerprintMatcher matcher = matcherFor(chirpRule()); - matcher.feed(new AudioFingerprintMatcher.PcmChunk(new short[10_000], 16_000, 1, 0)); - - AudioFingerprintMatcher.FeedResult result = matcher.feed( - new AudioFingerprintMatcher.PcmChunk(chirp16k(), 16_000, 1, 10_000)); - - assertEquals(AudioFingerprintMatcher.Status.RESET, result.status()); - assertTrue(!result.events().isEmpty()); - } - - @Test - public void malformedChunkIsRejectedWithoutThrowing() { - AudioFingerprintMatcher matcher = matcherFor(chirpRule()); - - AudioFingerprintMatcher.FeedResult result = matcher.feed(null); - - assertEquals(AudioFingerprintMatcher.Status.INVALID_INPUT, result.status()); - assertTrue(result.events().isEmpty()); - } - - @Test - public void oversizedOrMalformedPcmIsRejected() { - AudioFingerprintMatcher matcher = matcherFor(chirpRule()); - - assertEquals(AudioFingerprintMatcher.Status.INVALID_INPUT, - matcher.feed(new AudioFingerprintMatcher.PcmChunk(new short[16_000 * 31], 16_000, 1, 0)).status()); - assertEquals(AudioFingerprintMatcher.Status.INVALID_INPUT, - matcher.feed(new AudioFingerprintMatcher.PcmChunk(new short[9], 16_000, 9, 0)).status()); - assertEquals(AudioFingerprintMatcher.Status.INVALID_INPUT, - matcher.feed(new AudioFingerprintMatcher.PcmChunk(new short[10], 16_000, 3, 0)).status()); - assertEquals(AudioFingerprintMatcher.Status.INVALID_INPUT, - matcher.feed(new AudioFingerprintMatcher.PcmChunk( - new short[1_000_008], 192_000, 8, 0)).status()); - } - - @Test(expected = IllegalArgumentException.class) - public void matcherConfigRejectsNaNRatio() { - new AudioFingerprintMatcher.Config(6, 7, Float.NaN, 0.78f, 5_000, 2_500); - } - - @Test - public void silenceDoesNotCreateMatch() { - AudioFingerprintMatcher matcher = matcherFor(chirpRule()); - - AudioFingerprintMatcher.FeedResult result = matcher.feed( - new AudioFingerprintMatcher.PcmChunk(new short[16_000 * 3], 16_000, 1, 0)); - - assertEquals(AudioFingerprintMatcher.Status.NO_MATCH, result.status()); - assertTrue(result.events().isEmpty()); - } - - private static AudioFingerprintMatcher matcherFor(AudioFingerprintRule rule) { - return new AudioFingerprintMatcher( - new AudioFingerprintRuleSet(CONFIG, List.of(rule)), - AudioFingerprintMatcher.Config.conservative()); - } - - private static AudioFingerprintRule chirpRule() { - return new AudioFingerprintRule("chirp-ad", 15_000, 0, 3_000, CHIRP_HASHES, List.of()); - } - - private static List feedInChunks( - AudioFingerprintMatcher matcher, short[] samples) { - return feedInChunks(matcher, samples, 16_000, samples.length); - } - - private static List feedInChunks( - AudioFingerprintMatcher matcher, short[] samples, int sampleRate, int chunkFrames) { - List events = new ArrayList<>(); - for (int offset = 0; offset < samples.length; offset += chunkFrames) { - int length = Math.min(chunkFrames, samples.length - offset); - events.addAll(matcher.feed(new AudioFingerprintMatcher.PcmChunk( - Arrays.copyOfRange(samples, offset, offset + length), - sampleRate, 1, offset * 1_000L / sampleRate)).events()); - } - return events; - } - - private static List types(List events) { - return events.stream().map(AudioFingerprintMatcher.MatchEvent::type).toList(); - } - - private static int[] hashes(String csv) { - return Arrays.stream(csv.split(",")) - .mapToInt(value -> (int) Long.parseLong(value, 16)) - .toArray(); - } - - private static short[] chirp16k() { - return chirp(16_000, 3); - } - - private static short[] chirp(int sampleRate, int seconds) { - int sampleCount = sampleRate * seconds; - double startFrequency = 300; - double endFrequency = 3_000; - double rate = (endFrequency - startFrequency) / seconds; - short[] output = new short[sampleCount]; - for (int i = 0; i < output.length; i++) { - double time = i / (double) sampleRate; - double phase = 2 * Math.PI * (startFrequency * time + 0.5 * rate * time * time); - output[i] = (short) Math.round(16_000 * Math.sin(phase)); - } - return output; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintRuleCodecTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintRuleCodecTest.java deleted file mode 100644 index 3ba642435f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/AudioFingerprintRuleCodecTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; - -public class AudioFingerprintRuleCodecTest { - - @Test - public void sdkV2JsonRoundTrips() { - AudioFingerprintRuleSet parsed = AudioFingerprintRuleCodec.fromJson(validSdkJson()); - AudioFingerprintRuleSet roundTripped = AudioFingerprintRuleCodec.fromJson( - AudioFingerprintRuleCodec.toJson(parsed)); - - assertEquals(2, parsed.rules().size()); - assertEquals("spectral-sequence-v2", AudioFingerprintRuleSet.ALGORITHM_ID); - assertArrayEquals(parsed.rules().get(0).fingerprint(), roundTripped.rules().get(0).fingerprint()); - assertEquals(parsed.rules().get(0).variants().size(), roundTripped.rules().get(0).variants().size()); - } - - @Test(expected = IllegalArgumentException.class) - public void malformedHexIsRejectedInsteadOfSilentlyDroppingRule() { - AudioFingerprintRuleCodec.fromJson(jsonWithHash("not-hex")); - } - - @Test(expected = IllegalArgumentException.class) - public void oversizedRuleSetIsRejectedBeforeAllocation() { - AudioFingerprintRuleCodec.fromJson(jsonWithRuleCount(AudioFingerprintRuleSet.MAX_RULES + 1)); - } - - @Test(expected = IllegalArgumentException.class) - public void duplicateRuleIdsRejectTheWholePackage() { - AudioFingerprintRuleCodec.fromJson(jsonWithRuleCount(2) - .replace("ad-1", "same") - .replace("ad-2", "same")); - } - - @Test(expected = IllegalArgumentException.class) - public void unsupportedAlgorithmIsRejected() { - AudioFingerprintRuleCodec.fromJson(validSdkJson().replace("spectral-sequence-v2", "other-v1")); - } - - @Test(expected = IllegalArgumentException.class) - public void unknownRootFieldIsRejected() { - AudioFingerprintRuleCodec.fromJson(validSdkJson().replace("\"rules\":[", "\"unknown\":{},\"rules\":[")); - } - - @Test(expected = IllegalArgumentException.class) - public void deeplyNestedJsonIsRejectedBeforeTreeConstruction() { - StringBuilder nested = new StringBuilder(validSdkJson().replace("\"rules\":[", "\"junk\":")); - for (int i = 0; i < 80; i++) nested.append('['); - nested.append('0'); - for (int i = 0; i < 80; i++) nested.append(']'); - nested.append(",\"rules\":[]}"); - AudioFingerprintRuleCodec.fromJson(nested.toString()); - } - - @Test(expected = IllegalArgumentException.class) - public void oversizedSerializedRuleSetIsRejected() { - int[] hashes = new int[AudioFingerprintRule.MAX_SEQUENCE_FRAMES]; - List rules = new ArrayList<>(); - for (int i = 0; i < 11; i++) { - rules.add(new AudioFingerprintRule("large-" + i, 15_000, 0, 3_000, - hashes, List.of(hashes, hashes, hashes, hashes))); - } - AudioFingerprintRuleCodec.toJson(new AudioFingerprintRuleSet( - AudioFingerprintConfig.standard(), rules)); - } - - @Test - public void largeAcceptedRuleSetStillRoundTrips() { - int[] hashes = new int[46]; - List rules = new ArrayList<>(); - for (int i = 0; i < AudioFingerprintRuleSet.MAX_RULES; i++) { - String prefix = "rule-" + i + '-'; - String id = prefix + "x".repeat(128 - prefix.length()); - rules.add(new AudioFingerprintRule(id, 15_000, 0, 3_000, hashes, List.of())); - } - AudioFingerprintRuleSet ruleSet = new AudioFingerprintRuleSet( - AudioFingerprintConfig.standard(), rules); - - String json = AudioFingerprintRuleCodec.toJson(ruleSet); - - assertEquals(AudioFingerprintRuleSet.MAX_RULES, - AudioFingerprintRuleCodec.fromJson(json).rules().size()); - } - - private static String validSdkJson() { - return "{" - + "\"schemaVersion\":2," - + "\"algorithm\":{" - + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," - + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16}," - + "\"rules\":[" - + "{\"id\":\"ad-1\",\"durationMs\":15000,\"anchorOffsetMs\":0," - + "\"anchorDurationMs\":3000,\"fingerprint\":[\"32f0007c\",\"35c100e0\",\"3b8b01c0\",\"d30a0380\"]," - + "\"variants\":[[\"32e00078\",\"2dc500e0\",\"33890180\",\"f70a0300\"]]}," - + "{\"id\":\"ad-2\",\"durationMs\":12000,\"anchorOffsetMs\":1000," - + "\"anchorDurationMs\":3000,\"fingerprint\":[\"32f0007c\",\"35c100e0\",\"3b8b01c0\",\"d30a0380\"]}" - + "]}"; - } - - private static String jsonWithHash(String hash) { - return validSdkJson().replace("32f0007c", hash); - } - - private static String jsonWithRuleCount(int count) { - String rule = "{\"id\":\"ad-1\",\"durationMs\":15000,\"anchorOffsetMs\":0," - + "\"anchorDurationMs\":3000,\"fingerprint\":[\"32f0007c\",\"35c100e0\",\"3b8b01c0\",\"d30a0380\"]}"; - StringBuilder rules = new StringBuilder(); - for (int i = 0; i < count; i++) { - if (i > 0) rules.append(','); - rules.append(rule.replace("ad-1", "ad-" + (i + 1))); - } - return "{\"schemaVersion\":2,\"algorithm\":{" - + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," - + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16},\"rules\":[" - + rules + "]}"; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/PrioritizedAdAudioRuleSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/PrioritizedAdAudioRuleSourceTest.java deleted file mode 100644 index a1c8f43c47..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/PrioritizedAdAudioRuleSourceTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import org.junit.Test; - -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class PrioritizedAdAudioRuleSourceTest { - - @Test - public void validLocalRulesWinWithoutLoadingSignedSource() { - AdAudioRuleSnapshot local = snapshot("local", "l1", rules("local-rule"), List.of(), ""); - AtomicInteger signedLoads = new AtomicInteger(); - AdAudioRuleSource signed = () -> { - signedLoads.incrementAndGet(); - return snapshot("signed:official", "s1", rules("signed-rule"), List.of(), ""); - }; - - AdAudioRuleSnapshot selected = - new PrioritizedAdAudioRuleSource(() -> local, signed).load(); - - assertEquals(local, selected); - assertEquals(0, signedLoads.get()); - } - - @Test - public void emptyLocalRulesFallBackToSignedSnapshot() { - AdAudioRuleSnapshot signed = - snapshot("signed:official", "s1", rules("signed-rule"), List.of(), ""); - PrioritizedAdAudioRuleSource source = new PrioritizedAdAudioRuleSource( - () -> snapshot("local", "", AudioFingerprintRuleSet.empty(), List.of(), ""), - () -> signed); - - AdAudioRuleSnapshot selected = source.load(); - - assertEquals(signed, selected); - } - - @Test - public void invalidLocalRulesFallBackWithFixedWarning() { - AdAudioRuleSnapshot signed = snapshot( - "signed:official", "s2", rules("signed-rule"), List.of("CACHE_RECOVERED"), ""); - PrioritizedAdAudioRuleSource source = new PrioritizedAdAudioRuleSource( - () -> snapshot("local", "", AudioFingerprintRuleSet.empty(), - List.of(), "RULE_LOAD_FAILED"), - () -> signed); - - AdAudioRuleSnapshot selected = source.load(); - - assertEquals("signed:official", selected.sourceId()); - assertEquals("signed-rule", selected.ruleSet().rules().get(0).id()); - assertTrue(selected.warnings().contains("CACHE_RECOVERED")); - assertTrue(selected.warnings().contains("LOCAL_SOURCE_INVALID")); - assertFalse(selected.hasError()); - } - - @Test - public void localExceptionStillFallsBackToSignedRules() { - AdAudioRuleSnapshot signed = - snapshot("signed:official", "s3", rules("signed-rule"), List.of(), ""); - PrioritizedAdAudioRuleSource source = new PrioritizedAdAudioRuleSource( - () -> { - throw new IllegalStateException("broken local source"); - }, - () -> signed); - - AdAudioRuleSnapshot selected = source.load(); - - assertEquals(signed.sourceId(), selected.sourceId()); - assertTrue(selected.warnings().contains("LOCAL_SOURCE_INVALID")); - } - - @Test - public void nullLocalSnapshotStillFallsBackToSignedRules() { - AdAudioRuleSnapshot signed = - snapshot("signed:official", "s4", rules("signed-rule"), List.of(), ""); - PrioritizedAdAudioRuleSource source = - new PrioritizedAdAudioRuleSource(() -> null, () -> signed); - - AdAudioRuleSnapshot selected = source.load(); - - assertEquals(signed.sourceId(), selected.sourceId()); - assertTrue(selected.warnings().contains("LOCAL_SOURCE_INVALID")); - } - - @Test - public void bothUnavailableReturnEmptySnapshotWithLocalSpecificError() { - PrioritizedAdAudioRuleSource source = new PrioritizedAdAudioRuleSource( - () -> snapshot("local", "", AudioFingerprintRuleSet.empty(), - List.of(), "RULE_LOAD_FAILED"), - () -> snapshot("signed:official", "", AudioFingerprintRuleSet.empty(), - List.of(), "NO_VALID_SIGNED_PACKAGE")); - - AdAudioRuleSnapshot selected = source.load(); - - assertFalse(selected.hasRules()); - assertEquals("RULE_LOAD_FAILED", selected.lastError()); - assertTrue(selected.warnings().contains("LOCAL_SOURCE_INVALID")); - } - - private static AdAudioRuleSnapshot snapshot(String sourceId, String version, - AudioFingerprintRuleSet ruleSet, - List warnings, String error) { - return new AdAudioRuleSnapshot(sourceId, version, ruleSet, warnings, error); - } - - private static AudioFingerprintRuleSet rules(String id) { - AudioFingerprintRule rule = new AudioFingerprintRule( - id, 15_000L, 0L, 3_000L, - new int[]{0, 1, 2, 3}, List.of()); - return new AudioFingerprintRuleSet(AudioFingerprintConfig.standard(), List.of(rule)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodecBoundaryTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodecBoundaryTest.java deleted file mode 100644 index 373cfcbc68..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodecBoundaryTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; - -import org.junit.Test; - -/** 评审清单里的边界与溢出项,逐条钉住。 */ -public class ProbeRuleCodecBoundaryTest { - - /** revision 超出 JS 安全整数必须拒绝,且不能逃出成 NumberFormatException。 */ - @Test - public void revisionBeyondSafeIntegerIsRejectedAsIllegalArgument() { - assertThrows(IllegalArgumentException.class, - () -> ProbeRuleCodec.fromJson(valid().replace("\"revision\":2", "\"revision\":9007199254740992"))); - } - - /** 20 位数字超 long 上限,Long.parseLong 会抛 NumberFormatException,必须被转成 IAE。 */ - @Test - public void revisionWiderThanLongIsRejectedAsIllegalArgument() { - assertThrows(IllegalArgumentException.class, - () -> ProbeRuleCodec.fromJson(valid().replace("\"revision\":2", "\"revision\":99999999999999999999"))); - } - - /** durationMs 上下边界都是闭区间。 */ - @Test - public void durationBoundsAreInclusive() { - assertEquals(1, ProbeRuleCodec.fromJson(json(rule("a", 600_000, 0, 5_000))).ruleSet().rules().size()); - assertThrows(IllegalArgumentException.class, - () -> ProbeRuleCodec.fromJson(json(rule("a", 600_001, 0, 5_000)))); - assertThrows(IllegalArgumentException.class, - () -> ProbeRuleCodec.fromJson(json(rule("a", 999, 0, 5_000)))); - } - - /** anchorDurationMs 2000..5000 闭区间。 */ - @Test - public void anchorDurationBoundsAreInclusive() { - assertEquals(1, ProbeRuleCodec.fromJson(json(rule("a", 20_000, 0, 2_000))).ruleSet().rules().size()); - assertEquals(1, ProbeRuleCodec.fromJson(json(rule("a", 20_000, 0, 5_000))).ruleSet().rules().size()); - assertThrows(IllegalArgumentException.class, - () -> ProbeRuleCodec.fromJson(json(rule("a", 20_000, 0, 1_999)))); - assertThrows(IllegalArgumentException.class, - () -> ProbeRuleCodec.fromJson(json(rule("a", 20_000, 0, 5_001)))); - } - - /** anchorOffset + anchorDuration == durationMs 恰好合法(边界不能少算一)。 */ - @Test - public void anchorTouchingTheAdEndIsAccepted() { - assertEquals(1, ProbeRuleCodec.fromJson(json(rule("a", 5_000, 0, 5_000))).ruleSet().rules().size()); - assertThrows(IllegalArgumentException.class, - () -> ProbeRuleCodec.fromJson(json(rule("a", 5_000, 1, 5_000)))); - } - - /** test.adStartMs 加上 durationMs 溢出 long 时不能因为回绕而通过检查。 */ - @Test - public void testAdStartOverflowIsRejected() { - String rule = rule("a", 20_000, 0, 5_000); - String withTest = rule.substring(0, rule.length() - 1) - + ",\"test\":{\"url\":\"https://e.com/a.m3u8\",\"adStartMs\":9223372036854775000}}"; - assertThrows(IllegalArgumentException.class, () -> ProbeRuleCodec.fromJson(json(withTest))); - } - - private static String valid() { - return json(rule("a", 20_000, 0, 5_000)); - } - - private static String json(String rules) { - return "{\"format\":\"ad-audio-probe-rules\",\"schemaVersion\":1,\"revision\":2," - + "\"algorithm\":\"spectral-sequence-v1\",\"rules\":[" + rules + "]}"; - } - - private static String rule(String id, long durationMs, long anchorOffsetMs, long anchorDurationMs) { - StringBuilder phases = new StringBuilder(); - for (int p = 0; p < ProbeRuleCodec.PHASES_MS.length; p++) { - int phaseMs = ProbeRuleCodec.PHASES_MS[p]; - if (p > 0) phases.append(','); - phases.append("{\"phaseMs\":").append(phaseMs).append(",\"hashes\":["); - int n = (int) ((anchorDurationMs - phaseMs - 512) / 256) + 1; - for (int f = 0; f < n; f++) { - if (f > 0) phases.append(','); - phases.append('"').append(String.format("%08x", 0x0f1e2d3c ^ (f * 0x9e3779b9))).append('"'); - } - phases.append("]}"); - } - return "{\"id\":\"" + id + "\",\"durationMs\":" + durationMs - + ",\"anchorOffsetMs\":" + anchorOffsetMs - + ",\"anchorDurationMs\":" + anchorDurationMs - + ",\"fingerprints\":[" + phases + "]}"; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodecTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodecTest.java deleted file mode 100644 index 4b535c2f74..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleCodecTest.java +++ /dev/null @@ -1,317 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import org.junit.Test; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -/** - * Probe Rules v1 与本项目 {@code spectral-sequence-v2} 的兼容契约。 - * - *

固件 {@code probe-rules-v1-community.json} 是 0o755/m3u8-ad-audio-rules 在 revision 2 - * 时的真实文件,用来保证解析器不是只能吃自造样本。 - */ -public class ProbeRuleCodecTest { - - private static final String COMMUNITY_RULES = "/probe-rules-v1-community.json"; - private static final int SEED = 0x0f1e2d3c; - - @Test - public void communityRulesJsonConvertsToProjectRuleSet() throws IOException { - ProbeRuleCodec.Document document = ProbeRuleCodec.fromBytes(communityRules()); - - assertEquals(2L, document.revision()); - assertEquals(4, document.ruleSet().rules().size()); - assertEquals(AudioFingerprintConfig.standard(), document.ruleSet().config()); - - AudioFingerprintRule first = document.ruleSet().rules().get(0); - assertEquals("ad-1786989757783-1", first.id()); - assertEquals(47_000L, first.durationMs()); - assertEquals(0L, first.anchorOffsetMs()); - assertEquals(5_000L, first.anchorDurationMs()); - assertEquals(4, first.allSequences().size()); - } - - @Test - public void phaseZeroBecomesMainFingerprintAndRemainingPhasesBecomeVariants() throws IOException { - AudioFingerprintRule rule = ProbeRuleCodec.fromBytes(communityRules()).ruleSet().rules().get(0); - - assertEquals(0x687f0807, rule.fingerprint()[0]); - assertEquals(3, rule.variants().size()); - assertEquals(0x4e370617, rule.variants().get(0)[0]); - assertEquals(0x4f960397, rule.variants().get(1)[0]); - assertEquals(0x4f9601d7, rule.variants().get(2)[0]); - assertEquals(18, rule.fingerprint().length); - assertEquals(18, rule.variants().get(0).length); - assertEquals(18, rule.variants().get(1).length); - assertEquals(17, rule.variants().get(2).length); - } - - /** - * 上游相位标签必须与 {@link SpectralFingerprint} 的取相位步长一致,否则 v1 的 {@code phaseMs} - * 就不能直接当成本项目的变体顺序。 - */ - @Test - public void projectPhaseOffsetsMatchProbePhaseLabels() { - AudioFingerprintConfig config = AudioFingerprintConfig.standard(); - int phaseStep = Math.max(1, config.hopSamples() / 4); - - for (int phase = 0; phase < ProbeRuleCodec.PHASES_MS.length; phase++) { - long phaseMs = Math.round(phase * phaseStep * 1_000.0 / config.sampleRate()); - assertEquals(ProbeRuleCodec.PHASES_MS[phase], phaseMs); - } - } - - /** 每个相位的帧数必须等于本项目取窗循环在同样锚点长度下真正产出的帧数。 */ - @Test - public void frameCountsMatchTheProjectExtractionLoop() throws IOException { - AudioFingerprintConfig config = AudioFingerprintConfig.standard(); - - for (AudioFingerprintRule rule : ProbeRuleCodec.fromBytes(communityRules()).ruleSet().rules()) { - for (int phase = 0; phase < ProbeRuleCodec.PHASES_MS.length; phase++) { - assertEquals("phase " + ProbeRuleCodec.PHASES_MS[phase], - framesFromExtractionLoop(rule.anchorDurationMs(), - ProbeRuleCodec.PHASES_MS[phase], config), - rule.allSequences().get(phase).length); - } - } - } - - /** - * 反演社区 hash 的位布局:低 16 位是「频带 >= 均值」,高 16 位是「频带 >= 下一频带」, - * 且 band15 与 band0 环绕比较。两者互相约束,随机布局无法全部满足。 - */ - @Test - public void communityHashesUseTheProjectBitLayout() throws IOException { - int checked = 0; - for (AudioFingerprintRule rule : ProbeRuleCodec.fromBytes(communityRules()).ruleSet().rules()) { - for (int[] sequence : rule.allSequences()) { - for (int hash : sequence) { - if (hash == 0) continue; - checked += assertBandLayout(hash); - } - } - } - assertTrue("constraints checked: " + checked, checked > 500); - } - - @Test - public void testMetadataIsValidatedThenDropped() { - String json = json(withTest(rule("ad-one", 20_000, 0, 5_000, SEED), - "{\"url\":\"https://example.com/a.m3u8\",\"adStartMs\":15000}")); - - assertEquals(1, ProbeRuleCodec.fromJson(json).ruleSet().rules().size()); - } - - @Test - public void byteOrderMarkIsAccepted() { - byte[] bytes = ("\uFEFF" + valid()).getBytes(StandardCharsets.UTF_8); - - assertEquals(1, ProbeRuleCodec.fromBytes(bytes).ruleSet().rules().size()); - } - - @Test(expected = IllegalArgumentException.class) - public void unsupportedFormatIsRejected() { - ProbeRuleCodec.fromJson(valid().replace("ad-audio-probe-rules", "ad-audio-rules")); - } - - @Test(expected = IllegalArgumentException.class) - public void unsupportedSchemaVersionIsRejected() { - ProbeRuleCodec.fromJson(valid().replace("\"schemaVersion\":1", "\"schemaVersion\":2")); - } - - /** 本项目自己的 v2 规则文件必须走 {@link AudioFingerprintRuleCodec},不能从这里进来。 */ - @Test(expected = IllegalArgumentException.class) - public void projectAlgorithmIdIsRejected() { - ProbeRuleCodec.fromJson(valid() - .replace("spectral-sequence-v1", AudioFingerprintRuleSet.ALGORITHM_ID)); - } - - @Test(expected = IllegalArgumentException.class) - public void zeroRevisionIsRejected() { - ProbeRuleCodec.fromJson(valid().replace("\"revision\":2", "\"revision\":0")); - } - - @Test(expected = IllegalArgumentException.class) - public void nonIntegerRevisionIsRejected() { - ProbeRuleCodec.fromJson(valid().replace("\"revision\":2", "\"revision\":2.0")); - } - - @Test(expected = IllegalArgumentException.class) - public void duplicateRootFieldIsRejected() { - ProbeRuleCodec.fromJson(valid().replace("\"revision\":2", "\"revision\":2,\"revision\":3")); - } - - @Test(expected = IllegalArgumentException.class) - public void unknownRootFieldIsRejected() { - ProbeRuleCodec.fromJson(valid().replace("\"revision\":2", "\"revision\":2,\"enabled\":true")); - } - - @Test(expected = IllegalArgumentException.class) - public void trailingDataIsRejected() { - ProbeRuleCodec.fromJson(valid() + "{}"); - } - - @Test(expected = IllegalArgumentException.class) - public void unknownRuleFieldIsRejected() { - ProbeRuleCodec.fromJson(valid().replace("\"durationMs\":20000", "\"durationMs\":20000,\"enabled\":true")); - } - - @Test(expected = IllegalArgumentException.class) - public void uppercaseRuleIdIsRejected() { - ProbeRuleCodec.fromJson(valid().replace("\"ad-one\"", "\"AD-ONE\"")); - } - - @Test(expected = IllegalArgumentException.class) - public void uppercaseHashIsRejected() { - ProbeRuleCodec.fromJson(valid().replaceFirst("\"([0-9a-f]{8})\"", "\"0F1E2D3C\"")); - } - - @Test(expected = IllegalArgumentException.class) - public void missingPhaseIsRejected() { - ProbeRuleCodec.fromJson(valid() - .replaceFirst(",\\{\"phaseMs\":192,\"hashes\":\\[[^\\]]*]}", "")); - } - - @Test(expected = IllegalArgumentException.class) - public void duplicatePhaseIsRejected() { - ProbeRuleCodec.fromJson(valid().replace("\"phaseMs\":64", "\"phaseMs\":0")); - } - - @Test(expected = IllegalArgumentException.class) - public void unsupportedPhaseIsRejected() { - ProbeRuleCodec.fromJson(valid().replace("\"phaseMs\":64", "\"phaseMs\":32")); - } - - /** 帧数少一帧就说明锚点长度与序列不自洽,必须整包拒绝而不是当成短规则收下。 */ - @Test(expected = IllegalArgumentException.class) - public void wrongFrameCountIsRejected() { - ProbeRuleCodec.fromJson(valid().replaceFirst(",\"[0-9a-f]{8}\"]", "]")); - } - - @Test(expected = IllegalArgumentException.class) - public void anchorShorterThanUpstreamMinimumIsRejected() { - ProbeRuleCodec.fromJson(json(rule("ad-one", 20_000, 0, 1_536, SEED))); - } - - @Test(expected = IllegalArgumentException.class) - public void anchorBeyondDurationIsRejected() { - ProbeRuleCodec.fromJson(json(rule("ad-one", 4_000, 2_000, 5_000, SEED))); - } - - @Test(expected = IllegalArgumentException.class) - public void indistinctPrefixIsRejected() { - ProbeRuleCodec.fromJson(json(flatRule("ad-one", 20_000, 5_000))); - } - - /** 前缀相同但推导出的广告终点不同,运行时无法安全裁决,整包拒绝。 */ - @Test(expected = IllegalArgumentException.class) - public void sharedPrefixWithDifferentEndOffsetIsRejected() { - ProbeRuleCodec.fromJson(json(rule("ad-one", 20_000, 0, 5_000, SEED) - + "," + rule("ad-two", 30_000, 0, 5_000, SEED))); - } - - @Test - public void sharedPrefixWithTheSameEndOffsetIsAccepted() { - String json = json(rule("ad-one", 20_000, 0, 5_000, SEED) - + "," + rule("ad-two", 25_000, 5_000, 5_000, SEED)); - - assertEquals(2, ProbeRuleCodec.fromJson(json).ruleSet().rules().size()); - } - - @Test(expected = IllegalArgumentException.class) - public void unknownTestFieldIsRejected() { - ProbeRuleCodec.fromJson(json(withTest(rule("ad-one", 20_000, 0, 5_000, SEED), - "{\"url\":\"https://example.com/a.m3u8\",\"adStartMs\":15000,\"note\":\"x\"}"))); - } - - @Test(expected = IllegalArgumentException.class) - public void nonHttpTestUrlIsRejected() { - ProbeRuleCodec.fromJson(json(withTest(rule("ad-one", 20_000, 0, 5_000, SEED), - "{\"url\":\"file:///tmp/a.m3u8\",\"adStartMs\":15000}"))); - } - - private byte[] communityRules() throws IOException { - try (InputStream stream = getClass().getResourceAsStream(COMMUNITY_RULES)) { - assertNotNull(stream); - return stream.readAllBytes(); - } - } - - private static String valid() { - return json(rule("ad-one", 20_000, 0, 5_000, SEED)); - } - - private static String json(String rules) { - return "{\"format\":\"ad-audio-probe-rules\",\"schemaVersion\":1,\"revision\":2," - + "\"algorithm\":\"spectral-sequence-v1\",\"rules\":[" + rules + "]}"; - } - - private static String rule(String id, long durationMs, long anchorOffsetMs, - long anchorDurationMs, int seed) { - return "{\"id\":\"" + id + "\",\"durationMs\":" + durationMs - + ",\"anchorOffsetMs\":" + anchorOffsetMs - + ",\"anchorDurationMs\":" + anchorDurationMs - + ",\"fingerprints\":[" + fingerprints(anchorDurationMs, seed, false) + "]}"; - } - - /** 前八帧全部相同,用来触发区分度不足的拒绝路径。 */ - private static String flatRule(String id, long durationMs, long anchorDurationMs) { - return "{\"id\":\"" + id + "\",\"durationMs\":" + durationMs - + ",\"anchorOffsetMs\":0,\"anchorDurationMs\":" + anchorDurationMs - + ",\"fingerprints\":[" + fingerprints(anchorDurationMs, SEED, true) + "]}"; - } - - private static String withTest(String rule, String test) { - return rule.substring(0, rule.length() - 1) + ",\"test\":" + test + "}"; - } - - private static String fingerprints(long anchorDurationMs, int seed, boolean flat) { - StringBuilder phases = new StringBuilder(); - for (int phase = 0; phase < ProbeRuleCodec.PHASES_MS.length; phase++) { - int phaseMs = ProbeRuleCodec.PHASES_MS[phase]; - if (phase > 0) phases.append(','); - phases.append("{\"phaseMs\":").append(phaseMs).append(",\"hashes\":["); - int count = frames(anchorDurationMs, phaseMs); - for (int frame = 0; frame < count; frame++) { - if (frame > 0) phases.append(','); - int hash = flat ? seed : seed ^ (frame * 0x9e3779b9); - phases.append('"').append(String.format("%08x", hash)).append('"'); - } - phases.append("]}"); - } - return phases.toString(); - } - - private static int frames(long anchorDurationMs, int phaseMs) { - return (int) ((anchorDurationMs - phaseMs - 512) / 256) + 1; - } - - private static int framesFromExtractionLoop(long anchorDurationMs, int phaseMs, - AudioFingerprintConfig config) { - int total = (int) Math.round(anchorDurationMs * config.sampleRate() / 1_000.0); - int offset = (int) Math.round(phaseMs * config.sampleRate() / 1_000.0); - int frames = 0; - for (int start = offset; start + config.windowSamples() <= total; start += config.hopSamples()) { - frames++; - } - return frames; - } - - private static int assertBandLayout(int hash) { - int checked = 0; - for (int band = 0; band < 16; band++) { - boolean aboveMean = (hash & (1 << band)) != 0; - if (aboveMean == ((hash & (1 << ((band + 1) % 16))) != 0)) continue; - assertEquals("band " + band + " of " + Integer.toHexString(hash), - aboveMean, (hash & (1 << (band + 16))) != 0); - checked++; - } - return checked; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleDownloaderTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleDownloaderTest.java deleted file mode 100644 index 9e5e262134..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleDownloaderTest.java +++ /dev/null @@ -1,262 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okio.Buffer; - -public class ProbeRuleDownloaderTest { - - @Rule - public final TemporaryFolder temporaryFolder = new TemporaryFolder(); - - private MockWebServer server; - private OkHttpClient client; - - @Before - public void setUp() throws IOException { - server = new MockWebServer(); - server.start(); - // 必须与生产配置一致:重定向由 ProbeRuleDownloader 手动逐跳跟随, - // 用默认 followRedirects(true) 的 client 会让重定向相关断言失去意义。 - client = new OkHttpClient.Builder().followRedirects(false).build(); - } - - @After - public void tearDown() throws IOException { - server.shutdown(); - } - - /** 规则源不签名,所以传输层必须是 HTTPS,否则任何中间人都能改指纹。 */ - @Test - public void plainHttpUrlIsRejectedBeforeAnyRequest() throws Exception { - IllegalArgumentException error = assertThrows(IllegalArgumentException.class, - () -> ProbeRuleDownloader.refresh("http://example.com/rules.json", store())); - - assertEquals("probe rule url must be https", error.getMessage()); - assertEquals(0, server.getRequestCount()); - } - - @Test - public void successfulDownloadIsInstalled() throws Exception { - server.enqueue(new MockResponse().setBody(community())); - ProbeRuleStore store = store(); - - AdAudioRuleSnapshot snapshot = ProbeRuleDownloader.refresh(url(), store, client); - - assertEquals(4, snapshot.ruleSet().rules().size()); - assertTrue(snapshot.version().startsWith("2:")); - assertEquals(2L, store.revision()); - } - - @Test - public void oversizedBodyIsRejectedAndNothingIsCached() throws Exception { - Buffer body = new Buffer(); - body.write(new byte[ProbeRuleStore.MAX_DOWNLOAD_BYTES + 1024]); - server.enqueue(new MockResponse().setBody(body)); - ProbeRuleStore store = store(); - - assertThrows(IOException.class, () -> ProbeRuleDownloader.refresh(url(), store, client)); - - assertEquals(0L, store.revision()); - } - - @Test - public void serverErrorIsRejected() throws Exception { - server.enqueue(new MockResponse().setResponseCode(500)); - - assertThrows(IOException.class, () -> ProbeRuleDownloader.refresh(url(), store(), client)); - } - - @Test - public void malformedBodyLeavesTheStoreEmpty() throws Exception { - server.enqueue(new MockResponse().setBody("{\"format\":\"nope\"}")); - ProbeRuleStore store = store(); - - assertThrows(IllegalArgumentException.class, - () -> ProbeRuleDownloader.refresh(url(), store, client)); - - assertEquals(0L, store.revision()); - } - - /** 304 表示内容没变,应当返回现有缓存而不是当成下载失败。 */ - @Test - public void notModifiedKeepsTheCachedSnapshot() throws Exception { - ProbeRuleStore store = store(); - store.install(community().getBytes(StandardCharsets.UTF_8)); - server.enqueue(new MockResponse().setResponseCode(304)); - - AdAudioRuleSnapshot snapshot = ProbeRuleDownloader.refresh(url(), store, client); - - assertEquals(4, snapshot.ruleSet().rules().size()); - assertEquals(2L, store.revision()); - } - - /** - * chunked 响应没有 Content-Length,声明长度检查会拿到 -1, - * 因此边读边计数的上限必须独立生效。 - */ - @Test - public void oversizedChunkedBodyIsStillRejected() throws Exception { - Buffer body = new Buffer(); - body.write(new byte[ProbeRuleStore.MAX_DOWNLOAD_BYTES + 1024]); - server.enqueue(new MockResponse().setChunkedBody(body, 64 * 1024)); - ProbeRuleStore store = store(); - - assertThrows(IOException.class, () -> ProbeRuleDownloader.refresh(url(), store, client)); - - assertEquals(0L, store.revision()); - } - - /** - * URL 非法时必须回调,否则设置页置上的「正在刷新」文本会一直停在那里。 - * 这里只覆盖不联网的分支,联网分支由 {@link #successfulDownloadIsInstalled()} 覆盖。 - */ - @Test - public void invalidUrlReportsFailureExactlyOnce() { - RecordingCallback callback = new RecordingCallback(); - - boolean started = ProbeRuleDownloader.refreshNow( - callback, "http://example.com/rules.json", null); - - assertTrue("caller must not have to reset its own state", started); - assertEquals(0, callback.success.get()); - assertEquals(0, callback.disabled.get()); - assertNotNull(callback.failure.get()); - assertEquals(0, server.getRequestCount()); - } - - /** - * 空地址表示用户关闭了社区规则源,属于正常状态,必须走 onDisabled 而不是 - * 把「必须是 https」这种内部英文消息弹给用户。 - */ - @Test - public void emptySourceReportsDisabledInsteadOfAnError() { - RecordingCallback callback = new RecordingCallback(); - - boolean started = ProbeRuleDownloader.refreshNow(callback, "", null); - - assertTrue(started); - assertEquals(1, callback.disabled.get()); - assertNull(callback.failure.get()); - assertEquals(0, callback.success.get()); - assertEquals(0, server.getRequestCount()); - } - - /** - * 规则源无签名,传输层是唯一保障,所以 https 到明文的降级必须在**发出明文请求之前**拒绝。 - * OkHttp 的 followSslRedirects(false) 实测挡不住这一跳(它照样跟随), - * 所以重定向改成手动逐跳跟随,判定逻辑就是这里直接测的这个函数。 - */ - @Test - public void redirectOffHttpsIsRejectedBeforeTheNextRequest() throws Exception { - HttpUrl current = HttpUrl.get("https://rules.example.com/rules.json"); - - assertThrows(IOException.class, - () -> ProbeRuleDownloader.nextRedirect(current, "http://evil.example.com/x.json", true)); - - // 协议相对地址会沿用当前协议,仍是 https,不算降级;换主机本身是允许的。 - assertEquals("https://evil.example.com/x.json", - ProbeRuleDownloader.nextRedirect(current, "//evil.example.com/x.json", true)); - } - - @Test - public void redirectWithinHttpsIsAccepted() throws Exception { - HttpUrl current = HttpUrl.get("https://rules.example.com/rules.json"); - - assertEquals("https://rules.example.com/v2.json", - ProbeRuleDownloader.nextRedirect(current, "/v2.json", true)); - assertEquals("https://cdn.example.com/v2.json", - ProbeRuleDownloader.nextRedirect(current, "https://cdn.example.com/v2.json", true)); - } - - @Test - public void redirectWithoutLocationIsRejected() { - HttpUrl current = HttpUrl.get("https://rules.example.com/rules.json"); - - assertThrows(IOException.class, () -> ProbeRuleDownloader.nextRedirect(current, null, true)); - assertThrows(IOException.class, () -> ProbeRuleDownloader.nextRedirect(current, "", true)); - } - - /** 同协议内的重定向必须能正常跟随。 */ - @Test - public void sameSchemeRedirectIsFollowedEndToEnd() throws Exception { - server.enqueue(new MockResponse().setResponseCode(302).setHeader("Location", "/final.json")); - server.enqueue(new MockResponse().setBody(community())); - ProbeRuleStore store = store(); - - AdAudioRuleSnapshot snapshot = ProbeRuleDownloader.refresh(url(), store, client); - - assertEquals(4, snapshot.ruleSet().rules().size()); - assertEquals(2, server.getRequestCount()); - } - - /** 重定向循环必须有界,否则会一直打请求。 */ - @Test - public void redirectLoopIsBounded() throws Exception { - for (int i = 0; i < 20; i++) { - server.enqueue(new MockResponse().setResponseCode(302).setHeader("Location", "/again.json")); - } - - assertThrows(IOException.class, () -> ProbeRuleDownloader.refresh(url(), store(), client)); - - assertTrue("redirects must be bounded, saw " + server.getRequestCount(), - server.getRequestCount() <= 8); - } - - private static final class RecordingCallback implements ProbeRuleDownloader.Callback { - - private final AtomicInteger success = new AtomicInteger(); - private final AtomicInteger disabled = new AtomicInteger(); - private final AtomicReference failure = new AtomicReference<>(); - - @Override - public void onSuccess(AdAudioRuleSnapshot snapshot) { - success.incrementAndGet(); - } - - @Override - public void onFailure(Throwable error) { - failure.set(error); - } - - @Override - public void onDisabled() { - disabled.incrementAndGet(); - } - } - - private String url() { - return server.url("/rules.json").toString(); - } - - private ProbeRuleStore store() throws IOException { - return new ProbeRuleStore(temporaryFolder.newFolder().toPath()); - } - - private String community() throws IOException { - try (InputStream stream = getClass().getResourceAsStream("/probe-rules-v1-community.json")) { - assertNotNull(stream); - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecarTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecarTest.java deleted file mode 100644 index 8580c4d0ab..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleSidecarTest.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertThrows; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; - -public class ProbeRuleSidecarTest { - - private static final long REVISION = 8L; - private static final String SOURCE_PACKAGE_ID = "official.ad-audio"; - - @Test - public void validSidecarBindsToSignedPayload() throws Exception { - byte[] sourceDigest = digest("signed-v2"); - byte[] canonicalRules = rules(); - ProbeRuleSidecar sidecar = sidecar(sourceDigest, canonicalRules); - - sidecar.requireBoundTo(SOURCE_PACKAGE_ID, REVISION, sourceDigest); - - assertEquals(SOURCE_PACKAGE_ID, sidecar.sourcePackageId()); - assertEquals(REVISION, sidecar.sourceRevision()); - assertEquals(ProbeRuleSidecar.ALGORITHM_ID, sidecar.algorithm()); - assertEquals("converter-1", sidecar.converterVersion()); - assertArrayEquals(canonicalRules, sidecar.canonicalRules()); - } - - @Test - public void bindingRejectsRevisionMismatch() throws Exception { - byte[] sourceDigest = digest("signed-v2"); - ProbeRuleSidecar sidecar = sidecar(sourceDigest, rules()); - - assertThrows(IllegalArgumentException.class, - () -> sidecar.requireBoundTo(SOURCE_PACKAGE_ID, REVISION + 1L, sourceDigest)); - } - - @Test - public void bindingRejectsSourcePackageMismatch() throws Exception { - byte[] sourceDigest = digest("signed-v2"); - ProbeRuleSidecar sidecar = sidecar(sourceDigest, rules()); - - assertThrows(IllegalArgumentException.class, - () -> sidecar.requireBoundTo("other.ad-audio", REVISION, sourceDigest)); - } - - @Test - public void bindingRejectsSourceDigestMismatch() throws Exception { - ProbeRuleSidecar sidecar = sidecar(digest("signed-v2"), rules()); - - assertThrows(IllegalArgumentException.class, - () -> sidecar.requireBoundTo(SOURCE_PACKAGE_ID, REVISION, digest("other-v2"))); - } - - @Test - public void verifiedRejectsSidecarDigestMismatch() throws Exception { - byte[] canonicalRules = rules(); - - assertThrows(IllegalArgumentException.class, - () -> ProbeRuleSidecar.verified(SOURCE_PACKAGE_ID, REVISION, digest("signed-v2"), - ProbeRuleSidecar.ALGORITHM_ID, "converter-1", canonicalRules, - digest("different-sidecar"))); - } - - @Test - public void verifiedRejectsUnsupportedAlgorithm() throws Exception { - byte[] canonicalRules = rules(); - - assertThrows(IllegalArgumentException.class, - () -> ProbeRuleSidecar.verified(SOURCE_PACKAGE_ID, REVISION, digest("signed-v2"), - "spectral-sequence-v2", "converter-1", canonicalRules, - digest(canonicalRules))); - } - - @Test - public void verifiedRejectsOversizedCanonicalRules() throws Exception { - byte[] oversized = new byte[AdAudioRuleStore.MAX_IMPORT_BYTES + 1]; - - assertThrows(IllegalArgumentException.class, - () -> ProbeRuleSidecar.verified(SOURCE_PACKAGE_ID, REVISION, digest("signed-v2"), - ProbeRuleSidecar.ALGORITHM_ID, "converter-1", oversized, - digest(oversized))); - } - - @Test - public void verifiedRejectsInvalidUtf8Rules() throws Exception { - byte[] invalidUtf8 = new byte[]{(byte) 0xC3, 0x28}; - - assertThrows(IllegalArgumentException.class, - () -> ProbeRuleSidecar.verified(SOURCE_PACKAGE_ID, REVISION, - digest("signed-v2"), ProbeRuleSidecar.ALGORITHM_ID, - "converter-1", invalidUtf8, digest(invalidUtf8))); - } - - @Test - public void byteArraysAreDefensivelyCopied() throws Exception { - byte[] sourceDigest = digest("signed-v2"); - byte[] canonicalRules = rules(); - byte[] sidecarDigest = digest(canonicalRules); - ProbeRuleSidecar sidecar = ProbeRuleSidecar.verified(SOURCE_PACKAGE_ID, REVISION, sourceDigest, - ProbeRuleSidecar.ALGORITHM_ID, "converter-1", canonicalRules, sidecarDigest); - - sourceDigest[0] ^= 1; - canonicalRules[0] ^= 1; - sidecarDigest[0] ^= 1; - - byte[] first = sidecar.canonicalRules(); - byte[] second = sidecar.canonicalRules(); - assertNotSame(first, second); - assertArrayEquals(rules(), first); - assertArrayEquals(digest("signed-v2"), sidecar.sourceDigest()); - assertArrayEquals(digest(rules()), sidecar.sidecarDigest()); - } - - private static ProbeRuleSidecar sidecar(byte[] sourceDigest, byte[] canonicalRules) - throws Exception { - return ProbeRuleSidecar.verified(SOURCE_PACKAGE_ID, REVISION, sourceDigest, - ProbeRuleSidecar.ALGORITHM_ID, "converter-1", canonicalRules, - digest(canonicalRules)); - } - - private static byte[] rules() { - return ("{\"format\":\"ad-audio-probe-rules\",\"schemaVersion\":1," - + "\"revision\":8,\"rules\":[]}").getBytes(StandardCharsets.UTF_8); - } - - private static byte[] digest(String value) throws Exception { - return digest(value.getBytes(StandardCharsets.UTF_8)); - } - - private static byte[] digest(byte[] value) throws Exception { - return MessageDigest.getInstance("SHA-256").digest(value); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleStoreTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleStoreTest.java deleted file mode 100644 index 180beffb1c..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/ProbeRuleStoreTest.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -/** 上游 m3u8-ad-audio-probe 的规则更新语义:只升不降、同版本冲突拒绝、失败保留旧缓存。 */ -public class ProbeRuleStoreTest { - - @Rule - public final TemporaryFolder temporaryFolder = new TemporaryFolder(); - - @Test - public void installedRulesSurviveRestart() throws Exception { - Path directory = temporaryFolder.newFolder().toPath(); - - AdAudioRuleSnapshot installed = new ProbeRuleStore(directory).install(community()); - ProbeRuleStore reopened = new ProbeRuleStore(directory); - AdAudioRuleSnapshot loaded = reopened.load(); - - assertEquals(4, installed.ruleSet().rules().size()); - assertEquals(4, loaded.ruleSet().rules().size()); - assertEquals(installed.version(), loaded.version()); - assertTrue(loaded.version().startsWith("2:")); - assertEquals(2L, reopened.revision()); - assertFalse(loaded.hasError()); - } - - @Test - public void higherRevisionReplacesTheCache() throws Exception { - ProbeRuleStore store = new ProbeRuleStore(temporaryFolder.newFolder().toPath()); - store.install(community()); - - AdAudioRuleSnapshot updated = store.install(withRevision(3)); - - assertTrue(updated.version().startsWith("3:")); - assertEquals(3L, store.revision()); - } - - @Test - public void lowerRevisionIsRejectedAndCacheKeeps() throws Exception { - ProbeRuleStore store = new ProbeRuleStore(temporaryFolder.newFolder().toPath()); - store.install(community()); - - IllegalArgumentException error = - assertThrows(IllegalArgumentException.class, () -> store.install(withRevision(1))); - - assertEquals(ProbeRuleStore.ERROR_ROLLBACK, error.getMessage()); - assertEquals(2L, store.revision()); - } - - @Test - public void sameRevisionWithDifferentContentIsAPublishConflict() throws Exception { - ProbeRuleStore store = new ProbeRuleStore(temporaryFolder.newFolder().toPath()); - store.install(community()); - byte[] tampered = text().replace("ad-1786989757783-1", "ad-1786989757783-9") - .getBytes(StandardCharsets.UTF_8); - - IllegalArgumentException error = - assertThrows(IllegalArgumentException.class, () -> store.install(tampered)); - - assertEquals(ProbeRuleStore.ERROR_CONFLICT, error.getMessage()); - assertEquals("ad-1786989757783-1", store.load().ruleSet().rules().get(0).id()); - } - - @Test - public void reinstallingIdenticalBytesIsAccepted() throws Exception { - ProbeRuleStore store = new ProbeRuleStore(temporaryFolder.newFolder().toPath()); - AdAudioRuleSnapshot first = store.install(community()); - - AdAudioRuleSnapshot second = store.install(community()); - - assertEquals(first.version(), second.version()); - } - - @Test - public void corruptCacheReportsAnErrorAndCanBeReplaced() throws Exception { - Path directory = temporaryFolder.newFolder().toPath(); - ProbeRuleStore store = new ProbeRuleStore(directory); - store.install(community()); - Files.writeString(directory.resolve("ad-audio-probe-rules.json"), "{\"rules\":["); - - AdAudioRuleSnapshot broken = store.load(); - AdAudioRuleSnapshot repaired = store.install(community()); - - assertEquals(ProbeRuleStore.ERROR_LOAD_FAILED, broken.lastError()); - assertFalse(broken.hasRules()); - assertEquals(4, repaired.ruleSet().rules().size()); - } - - @Test - public void malformedDownloadLeavesNoTemporaryFile() throws Exception { - Path directory = temporaryFolder.newFolder().toPath(); - ProbeRuleStore store = new ProbeRuleStore(directory); - - assertThrows(IllegalArgumentException.class, - () -> store.install("{\"format\":\"nope\"}".getBytes(StandardCharsets.UTF_8))); - - assertFalse(Files.exists(directory.resolve("ad-audio-probe-rules.json"))); - assertFalse(Files.exists(directory.resolve("ad-audio-probe-rules.json.tmp"))); - assertEquals(0L, store.revision()); - } - - @Test - public void emptyStoreLoadsWithoutError() throws Exception { - AdAudioRuleSnapshot snapshot = - new ProbeRuleStore(temporaryFolder.newFolder().toPath()).load(); - - assertFalse(snapshot.hasRules()); - assertFalse(snapshot.hasError()); - assertEquals(ProbeRuleStore.SOURCE_ID, snapshot.sourceId()); - } - - /** 手动导入的规则优先于社区源,社区源只在本地为空时补位。 */ - @Test - public void manualImportWinsOverTheCommunityFeed() throws Exception { - ProbeRuleStore probe = new ProbeRuleStore(temporaryFolder.newFolder().toPath()); - AdAudioRuleStore local = new AdAudioRuleStore(temporaryFolder.newFolder().toPath()); - probe.install(community()); - local.importJson(text().replace("ad-1786989757783-1", "mine")); - - AdAudioRuleSnapshot resolved = new PrioritizedAdAudioRuleSource(local, probe).load(); - - assertEquals("mine", resolved.ruleSet().rules().get(0).id()); - } - - private byte[] withRevision(int revision) throws Exception { - return text().replace("\"revision\": 2", "\"revision\": " + revision) - .getBytes(StandardCharsets.UTF_8); - } - - private byte[] community() throws Exception { - return text().getBytes(StandardCharsets.UTF_8); - } - - private String text() throws Exception { - try (InputStream stream = getClass().getResourceAsStream("/probe-rules-v1-community.json")) { - assertNotNull(stream); - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarFixtures.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarFixtures.java deleted file mode 100644 index 64a62004e7..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarFixtures.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Base64; -import java.util.Optional; - -final class SignedProbeRuleSidecarFixtures { - - static final long NOW = 1_786_900_000_000L; - static final String PACKAGE_ID = "official.ad-audio.probe-v1"; - static final String SOURCE_PACKAGE_ID = "official.ad-audio"; - static final String KEY_ID = "release-1"; - static final String SIGNATURE_ALGORITHM = "ED25519"; - - private SignedProbeRuleSidecarFixtures() { - } - - static byte[] signedEnvelope(long artifactRevision, String converterVersion) - throws Exception { - String unsigned = envelope(artifactRevision, converterVersion, - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); - byte[] input = SignedProbeRuleSidecarCodec.parse(bytes(unsigned)).signingInput(); - String signature = Base64.getUrlEncoder().withoutPadding().encodeToString( - SignedRulePackageTestKeys.signer().sign(input)); - return bytes(envelope(artifactRevision, converterVersion, signature)); - } - - static SignedProbeRuleSidecarVerifier verifier() { - TrustedRuleKeyRegistry keys = keyId -> KEY_ID.equals(keyId) - ? Optional.of(new TrustedRuleKeyRegistry.Entry(KEY_ID, SIGNATURE_ALGORITHM, - NOW - 86_400_000L, NOW + 86_400_000L, - TrustedRuleKeyRegistry.Status.ACTIVE, SignedRulePackageTestKeys.verifier())) - : Optional.empty(); - return new SignedProbeRuleSidecarVerifier(PACKAGE_ID, keys, () -> NOW); - } - - static byte[] rules() { - return ("{\"format\":\"ad-audio-probe-rules\",\"schemaVersion\":1," - + "\"revision\":42,\"algorithm\":\"spectral-sequence-v1\"," - + "\"rules\":[]}").getBytes(StandardCharsets.UTF_8); - } - - private static String envelope(long artifactRevision, String converterVersion, - String signature) throws Exception { - byte[] rules = rules(); - return "{" - + "\"artifactSchemaVersion\":1," - + "\"artifactType\":\"ad-audio-probe-sidecar\"," - + "\"packageId\":\"" + PACKAGE_ID + "\"," - + "\"revision\":" + artifactRevision + "," - + "\"createdAtEpochMs\":" + (NOW - 60_000L) + "," - + "\"expiresAtEpochMs\":" + (NOW + 86_400_000L) + "," - + "\"sourcePackageId\":\"" + SOURCE_PACKAGE_ID + "\"," - + "\"sourceRevision\":42," - + "\"sourcePayloadSha256\":\"" + hex(digest("signed-v2-payload")) + "\"," - + "\"algorithm\":\"spectral-sequence-v1\"," - + "\"converterVersion\":\"" + converterVersion + "\"," - + "\"rulesSha256\":\"" + hex(digest(rules)) + "\"," - + "\"rulesBase64Url\":\"" - + Base64.getUrlEncoder().withoutPadding().encodeToString(rules) + "\"," - + "\"signature\":{\"keyId\":\"" + KEY_ID - + "\",\"algorithm\":\"" + SIGNATURE_ALGORITHM - + "\",\"value\":\"" + signature + "\"}}"; - } - - private static byte[] digest(String value) throws Exception { - return digest(value.getBytes(StandardCharsets.UTF_8)); - } - - private static byte[] digest(byte[] value) throws Exception { - return MessageDigest.getInstance("SHA-256").digest(value); - } - - private static String hex(byte[] value) { - StringBuilder result = new StringBuilder(value.length * 2); - for (byte item : value) result.append(String.format("%02x", item & 0xff)); - return result.toString(); - } - - private static byte[] bytes(String value) { - return value.getBytes(StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarStoreTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarStoreTest.java deleted file mode 100644 index 17eeba5d36..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarStoreTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -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 org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -public class SignedProbeRuleSidecarStoreTest { - - @Rule - public final TemporaryFolder temporaryFolder = new TemporaryFolder(); - - @Test - public void installAndLoadUseIndependentCurrentSlot() throws Exception { - Path directory = temporaryFolder.newFolder("sidecar").toPath(); - SignedProbeRuleSidecarStore store = new SignedProbeRuleSidecarStore( - directory, SignedProbeRuleSidecarFixtures.verifier()); - - SignedProbeRuleSidecarStore.InstallResult installed = store.install( - SignedProbeRuleSidecarFixtures.signedEnvelope(1L, "probe-1")); - SignedProbeRuleSidecarStore.LoadResult loaded = store.load(); - - assertTrue(installed.changed()); - assertEquals(1L, installed.verifiedPackage().revision()); - assertTrue(loaded.hasPackage()); - assertFalse(loaded.recoveredPrevious()); - assertEquals(1L, loaded.verifiedPackage().revision()); - assertEquals(1L, loaded.highestAcceptedRevision()); - } - - @Test - public void corruptCurrentRecoversPreviousWithoutLoweringHighWater() throws Exception { - Path directory = temporaryFolder.newFolder("recovery").toPath(); - SignedProbeRuleSidecarStore store = new SignedProbeRuleSidecarStore( - directory, SignedProbeRuleSidecarFixtures.verifier()); - store.install(SignedProbeRuleSidecarFixtures.signedEnvelope(1L, "probe-1")); - store.install(SignedProbeRuleSidecarFixtures.signedEnvelope(2L, "probe-2")); - - Files.write(directory.resolve("current.json"), - "corrupt".getBytes(StandardCharsets.UTF_8)); - - SignedProbeRuleSidecarStore.LoadResult loaded = store.load(); - - assertTrue(loaded.hasPackage()); - assertTrue(loaded.recoveredPrevious()); - assertEquals(1L, loaded.verifiedPackage().revision()); - assertEquals(2L, loaded.highestAcceptedRevision()); - assertTrue(loaded.warnings().contains("CACHE_RECOVERED")); - } - - @Test - public void rollbackAndSameRevisionConflictAreRejected() throws Exception { - Path directory = temporaryFolder.newFolder("rollback").toPath(); - SignedProbeRuleSidecarStore store = new SignedProbeRuleSidecarStore( - directory, SignedProbeRuleSidecarFixtures.verifier()); - store.install(SignedProbeRuleSidecarFixtures.signedEnvelope(2L, "probe-2")); - - assertCode(SignedRulePackageException.Code.REVISION_ROLLBACK, - () -> store.install(SignedProbeRuleSidecarFixtures.signedEnvelope(1L, "probe-1"))); - assertCode(SignedRulePackageException.Code.REVISION_CONFLICT, - () -> store.install(SignedProbeRuleSidecarFixtures.signedEnvelope(2L, "probe-3"))); - } - - @Test - public void clearDownloadedLeavesNoUsableSidecar() throws Exception { - Path directory = temporaryFolder.newFolder("clear").toPath(); - SignedProbeRuleSidecarStore store = new SignedProbeRuleSidecarStore( - directory, SignedProbeRuleSidecarFixtures.verifier()); - store.install(SignedProbeRuleSidecarFixtures.signedEnvelope(1L, "probe-1")); - - store.clearDownloaded(); - - assertFalse(store.load().hasPackage()); - } - - private static void assertCode(SignedRulePackageException.Code expected, - ThrowingRunnable action) { - try { - action.run(); - fail("expected " + expected); - } catch (SignedRulePackageException e) { - assertEquals(expected, e.code()); - } catch (Exception e) { - throw new AssertionError(e); - } - } - - @FunctionalInterface - private interface ThrowingRunnable { - void run() throws Exception; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarVerifierTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarVerifierTest.java deleted file mode 100644 index 30001c6074..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedProbeRuleSidecarVerifierTest.java +++ /dev/null @@ -1,258 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.fail; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Base64; -import java.util.Optional; - -public class SignedProbeRuleSidecarVerifierTest { - - private static final long MINUTE_MS = 60_000L; - private static final long DAY_MS = 24 * 60 * MINUTE_MS; - private static final long NOW = 1_786_900_000_000L; - private static final long CREATED_AT = NOW - MINUTE_MS; - private static final long EXPIRES_AT = NOW + DAY_MS; - private static final String PACKAGE_ID = "official.ad-audio.probe-v1"; - private static final String SOURCE_PACKAGE_ID = "official.ad-audio"; - private static final long ARTIFACT_REVISION = 5L; - private static final long SOURCE_REVISION = 42L; - private static final String KEY_ID = "release-1"; - private static final String SIGNATURE_ALGORITHM = "ED25519"; - private static final String CONVERTER_VERSION = "probe-oracle-1"; - private static final String ZERO_SIGNATURE = - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - - @Test - public void activeKeyVerifiesIndependentSidecarArtifact() throws Exception { - byte[] envelope = signedEnvelope(); - - VerifiedProbeRuleSidecarPackage verified = verifier( - TrustedRuleKeyRegistry.Status.ACTIVE).verify(envelope, VerificationMode.INSTALL); - - assertEquals(PACKAGE_ID, verified.packageId()); - assertEquals(ARTIFACT_REVISION, verified.revision()); - assertEquals(CREATED_AT, verified.createdAtEpochMs()); - assertEquals(EXPIRES_AT, verified.expiresAtEpochMs()); - assertEquals(KEY_ID, verified.keyId()); - assertEquals(SIGNATURE_ALGORITHM, verified.signatureAlgorithm()); - assertEquals(SOURCE_PACKAGE_ID, verified.sidecar().sourcePackageId()); - assertEquals(SOURCE_REVISION, verified.sidecar().sourceRevision()); - assertEquals(CONVERTER_VERSION, verified.sidecar().converterVersion()); - assertArrayEquals(rules(), verified.sidecar().canonicalRules()); - verified.sidecar().requireBoundTo(SOURCE_PACKAGE_ID, SOURCE_REVISION, sourceDigest()); - assertEquals(64, verified.artifactSha256().length()); - } - - @Test - public void everySecurityFieldIsCoveredBySignatureOrDigest() throws Exception { - String valid = text(signedEnvelope()); - String sourceDigest = hex(sourceDigest()); - String rulesDigest = hex(digest(rules())); - - assertCode(SignedRulePackageException.Code.PACKAGE_ID_MISMATCH, - () -> verifier(TrustedRuleKeyRegistry.Status.ACTIVE).verify( - bytes(valid.replace("\"packageId\":\"" + PACKAGE_ID + "\"", - "\"packageId\":\"other.probe-v1\"")), VerificationMode.INSTALL)); - assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, - () -> verifyTampered(valid.replace("\"revision\":5", "\"revision\":6"))); - assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, - () -> verifyTampered(valid.replace("\"sourcePackageId\":\"" + SOURCE_PACKAGE_ID + "\"", - "\"sourcePackageId\":\"other.ad-audio\""))); - assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, - () -> verifyTampered(valid.replace("\"sourceRevision\":42", - "\"sourceRevision\":43"))); - assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, - () -> verifyTampered(valid.replace(sourceDigest, flipHex(sourceDigest)))); - assertCode(SignedRulePackageException.Code.PAYLOAD_INVALID, - () -> verifyTampered(valid.replace(ProbeRuleSidecar.ALGORITHM_ID, - "spectral-sequence-v2"))); - assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, - () -> verifyTampered(valid.replace(CONVERTER_VERSION, "probe-oracle-2"))); - assertCode(SignedRulePackageException.Code.PAYLOAD_DIGEST_MISMATCH, - () -> verifyTampered(valid.replace(rulesDigest, flipHex(rulesDigest)))); - - byte[] changedRules = "{\"format\":\"changed\"}".getBytes(StandardCharsets.UTF_8); - assertCode(SignedRulePackageException.Code.PAYLOAD_DIGEST_MISMATCH, - () -> verifyTampered(valid.replace(base64(rules()), base64(changedRules)))); - assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, - () -> verifyTampered(tamperSignature(valid))); - } - - @Test - public void malformedJsonAndEncodingAreRejectedBeforeVerification() throws Exception { - String valid = text(signedEnvelope()); - - assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, - () -> SignedProbeRuleSidecarCodec.parse(bytes(valid.replace( - "\"artifactType\"", "\"unknown\":0,\"artifactType\"")))); - assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, - () -> SignedProbeRuleSidecarCodec.parse(bytes(valid.replace( - "\"revision\":5", "\"revision\":5,\"revision\":6")))); - assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, - () -> SignedProbeRuleSidecarCodec.parse(bytes(valid + "{}"))); - assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, - () -> SignedProbeRuleSidecarCodec.parse(bytes(valid.replace( - "\"revision\":5", "\"revision\":5.0")))); - assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, - () -> SignedProbeRuleSidecarCodec.parse(bytes(valid.replace( - base64(rules()), "not+base64")))); - assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, - () -> SignedProbeRuleSidecarCodec.parse(new byte[]{(byte) 0xC3, 0x28})); - } - - @Test - public void nonCanonicalSignatureBase64UrlIsRejected() throws Exception { - String valid = text(signedEnvelope()); - String signature = signatureValue(valid); - String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - int canonicalIndex = alphabet.indexOf(signature.charAt(signature.length() - 1)); - String nonCanonical = signature.substring(0, signature.length() - 1) - + alphabet.charAt((canonicalIndex & 0x30) + 1); - assertArrayEquals(Base64.getUrlDecoder().decode(signature), - Base64.getUrlDecoder().decode(nonCanonical)); - - assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, - () -> SignedProbeRuleSidecarCodec.parse(bytes(valid.replace( - "\"value\":\"" + signature + "\"", - "\"value\":\"" + nonCanonical + "\"")))); - } - - @Test - public void retiredKeyIsCacheOnlyAndRevokedKeyIsAlwaysRejected() throws Exception { - byte[] envelope = signedEnvelope(); - - assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, - () -> verifier(TrustedRuleKeyRegistry.Status.RETIRED) - .verify(envelope, VerificationMode.INSTALL)); - assertEquals(ARTIFACT_REVISION, - verifier(TrustedRuleKeyRegistry.Status.RETIRED) - .verify(envelope, VerificationMode.CACHE).revision()); - assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, - () -> verifier(TrustedRuleKeyRegistry.Status.REVOKED) - .verify(envelope, VerificationMode.CACHE)); - } - - @Test - public void artifactArraysAreDefensivelyCopied() throws Exception { - VerifiedProbeRuleSidecarPackage verified = verifier( - TrustedRuleKeyRegistry.Status.ACTIVE).verify( - signedEnvelope(), VerificationMode.INSTALL); - - byte[] first = verified.artifactDigest(); - first[0] ^= 1; - - assertArrayEquals(digest(verified.signingInput()), verified.artifactDigest()); - } - - private static byte[] signedEnvelope() throws Exception { - String unsigned = envelope(ZERO_SIGNATURE); - byte[] input = SignedProbeRuleSidecarCodec.parse(bytes(unsigned)).signingInput(); - return bytes(envelope(base64(SignedRulePackageTestKeys.signer().sign(input)))); - } - - private static String envelope(String signature) throws Exception { - return "{" - + "\"artifactSchemaVersion\":1," - + "\"artifactType\":\"ad-audio-probe-sidecar\"," - + "\"packageId\":\"" + PACKAGE_ID + "\"," - + "\"revision\":" + ARTIFACT_REVISION + "," - + "\"createdAtEpochMs\":" + CREATED_AT + "," - + "\"expiresAtEpochMs\":" + EXPIRES_AT + "," - + "\"sourcePackageId\":\"" + SOURCE_PACKAGE_ID + "\"," - + "\"sourceRevision\":" + SOURCE_REVISION + "," - + "\"sourcePayloadSha256\":\"" + hex(sourceDigest()) + "\"," - + "\"algorithm\":\"" + ProbeRuleSidecar.ALGORITHM_ID + "\"," - + "\"converterVersion\":\"" + CONVERTER_VERSION + "\"," - + "\"rulesSha256\":\"" + hex(digest(rules())) + "\"," - + "\"rulesBase64Url\":\"" + base64(rules()) + "\"," - + "\"signature\":{" - + "\"keyId\":\"" + KEY_ID + "\"," - + "\"algorithm\":\"" + SIGNATURE_ALGORITHM + "\"," - + "\"value\":\"" + signature + "\"}}"; - } - - private static SignedProbeRuleSidecarVerifier verifier(TrustedRuleKeyRegistry.Status status) { - TrustedRuleKeyRegistry keys = keyId -> KEY_ID.equals(keyId) - ? Optional.of(new TrustedRuleKeyRegistry.Entry(KEY_ID, SIGNATURE_ALGORITHM, - NOW - DAY_MS, NOW + DAY_MS, status, SignedRulePackageTestKeys.verifier())) - : Optional.empty(); - return new SignedProbeRuleSidecarVerifier(PACKAGE_ID, keys, () -> NOW); - } - - private static VerifiedProbeRuleSidecarPackage verifyTampered(String value) throws Exception { - return verifier(TrustedRuleKeyRegistry.Status.ACTIVE) - .verify(bytes(value), VerificationMode.INSTALL); - } - - private static void assertCode(SignedRulePackageException.Code expected, - ThrowingRunnable action) { - try { - action.run(); - fail("expected " + expected); - } catch (SignedRulePackageException e) { - assertEquals(expected, e.code()); - } catch (Exception e) { - throw new AssertionError(e); - } - } - - private static String tamperSignature(String envelope) { - int index = envelope.lastIndexOf("\"value\":\"") + "\"value\":\"".length(); - char current = envelope.charAt(index); - char replacement = current == 'A' ? 'B' : 'A'; - return envelope.substring(0, index) + replacement + envelope.substring(index + 1); - } - - private static String signatureValue(String envelope) { - int start = envelope.lastIndexOf("\"value\":\"") + "\"value\":\"".length(); - return envelope.substring(start, envelope.indexOf('"', start)); - } - - private static byte[] rules() { - return ("{\"format\":\"ad-audio-probe-rules\",\"schemaVersion\":1," - + "\"revision\":42,\"algorithm\":\"spectral-sequence-v1\"," - + "\"rules\":[]}").getBytes(StandardCharsets.UTF_8); - } - - private static byte[] sourceDigest() throws Exception { - return digest("signed-v2-payload".getBytes(StandardCharsets.UTF_8)); - } - - private static byte[] digest(byte[] value) throws Exception { - return MessageDigest.getInstance("SHA-256").digest(value); - } - - private static String base64(byte[] value) { - return Base64.getUrlEncoder().withoutPadding().encodeToString(value); - } - - private static String hex(byte[] value) { - StringBuilder result = new StringBuilder(value.length * 2); - for (byte item : value) result.append(String.format("%02x", item & 0xff)); - return result.toString(); - } - - private static String flipHex(String value) { - return (value.charAt(0) == '0' ? "1" : "0") + value.substring(1); - } - - private static byte[] bytes(String value) { - return value.getBytes(StandardCharsets.UTF_8); - } - - private static String text(byte[] value) { - return new String(value, StandardCharsets.UTF_8); - } - - @FunctionalInterface - private interface ThrowingRunnable { - void run() throws Exception; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageCodecTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageCodecTest.java deleted file mode 100644 index 2e29469f2e..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageCodecTest.java +++ /dev/null @@ -1,215 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.util.Locale; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; - -public class SignedRulePackageCodecTest { - - private static final String PAYLOAD_DIGEST = - "94db293f86613c918ea2135ff6cfde34fb6219dafd721ac194d7f35c1074a3d5"; - private static final String SIGNATURE = - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - private static final String SIGNING_INPUT_HEX = - "7765626874762e61642d617564696f2e7061636b6167652f763100" - + "00116f6666696369616c2e61642d617564696f" - + "000000000000002a" - + "000001a00b4d2400" - + "000001a0a5cbec00" - + "000972656c656173652d31" - + "000745443235353139" - + PAYLOAD_DIGEST; - - @Test - public void validEnvelopeParsesAndProducesExactSigningInput() throws Exception { - SignedRulePackageCodec.Parsed parsed = SignedRulePackageCodec.parse(bytes(validEnvelope())); - - assertEquals(1, parsed.packageSchemaVersion()); - assertEquals("official.ad-audio", parsed.packageId()); - assertEquals(42L, parsed.revision()); - assertEquals(1_786_896_000_000L, parsed.createdAtEpochMs()); - assertEquals(1_789_488_000_000L, parsed.expiresAtEpochMs()); - assertEquals("release-1", parsed.keyId()); - assertEquals("ED25519", parsed.algorithm()); - assertEquals(canonicalPayload(), parsed.canonicalPayload()); - assertArrayEquals(hex(PAYLOAD_DIGEST), parsed.payloadDigest()); - assertArrayEquals(hex(SIGNING_INPUT_HEX), parsed.signingInput()); - assertEquals(64, parsed.signature().length); - assertEquals(0, parsed.ruleSet().rules().size()); - - parsed.payloadDigest()[0] ^= 1; - parsed.signature()[0] ^= 1; - parsed.signingInput()[0] ^= 1; - assertArrayEquals(hex(PAYLOAD_DIGEST), parsed.payloadDigest()); - assertEquals(0, parsed.signature()[0]); - assertArrayEquals(hex(SIGNING_INPUT_HEX), parsed.signingInput()); - } - - @Test - public void unknownFieldsAreRejectedAtEveryDefinedObject() { - assertMalformed(validEnvelope().replace("\"packageId\"", "\"unknown\":0,\"packageId\"")); - assertMalformed(validEnvelope().replace("\"keyId\"", "\"unknown\":0,\"keyId\"")); - assertCode(SignedRulePackageException.Code.PAYLOAD_INVALID, - () -> SignedRulePackageCodec.parse(bytes(validEnvelope() - .replace("\"rules\":[]", "\"unknown\":0,\"rules\":[]")))); - } - - @Test - public void duplicateFieldsAreRejectedAtEveryNestingLevel() { - assertMalformed(validEnvelope().replace("\"revision\":42,", - "\"revision\":42,\"revision\":43,")); - assertMalformed(validEnvelope().replace("\"keyId\":\"release-1\",", - "\"keyId\":\"release-1\",\"keyId\":\"release-2\",")); - assertMalformed(validEnvelope().replace("\"schemaVersion\":2", - "\"schemaVersion\":2,\"schemaVersion\":2")); - assertMalformed(validEnvelope().replace("\"id\":\"spectral-sequence-v2\"}", - "\"id\":\"spectral-sequence-v2\",\"id\":\"spectral-sequence-v2\"}")); - assertMalformed(validEnvelope().replace("\"rules\":[]", "\"rules\":[{" - + "\"id\":\"ad-1\",\"id\":\"ad-2\",\"durationMs\":15000," - + "\"anchorOffsetMs\":0,\"anchorDurationMs\":3000," - + "\"fingerprint\":[\"00000000\",\"00000001\",\"00000002\",\"00000003\"]}]")); - } - - @Test - public void trailingJsonIsRejected() { - assertMalformed(validEnvelope() + "{}"); - } - - @Test - public void decimalRevisionIsRejected() { - assertMalformed(validEnvelope().replace("\"revision\":42", "\"revision\":42.0")); - } - - @Test - public void illegalPackageAndKeyIdsAreRejected() { - assertMalformed(validEnvelope().replace("official.ad-audio", "official/ad-audio")); - assertMalformed(validEnvelope().replace("release-1", "release key")); - } - - @Test - public void uppercaseDigestIsRejected() { - assertMalformed(validEnvelope().replace(PAYLOAD_DIGEST, - PAYLOAD_DIGEST.toUpperCase(Locale.ROOT))); - } - - @Test - public void paddedBase64UrlIsRejected() { - assertMalformed(validEnvelope().replace(SIGNATURE + "\"", SIGNATURE + "=\"")); - } - - @Test - public void wrongEd25519SignatureLengthIsRejected() { - assertMalformed(validEnvelope().replace(SIGNATURE, "AA")); - } - - @Test - public void deeplyNestedJsonIsRejectedBeforeTreeConstruction() { - StringBuilder nested = new StringBuilder("\"junk\":"); - for (int i = 0; i < 80; i++) nested.append('['); - nested.append('0'); - for (int i = 0; i < 80; i++) nested.append(']'); - nested.append(','); - assertMalformed(validEnvelope().replaceFirst("\\{", "{" + nested)); - } - - @Test - public void tokenBudgetIsEnforcedBeforeTreeConstruction() { - StringBuilder values = new StringBuilder("\"junk\":["); - for (int i = 0; i <= SignedRulePackageCodec.MAX_JSON_TOKENS; i++) { - if (i > 0) values.append(','); - values.append('0'); - } - values.append("],"); - assertMalformed(validEnvelope().replaceFirst("\\{", "{" + values)); - } - - @Test - public void objectMemberBudgetIsEnforcedBeforeTreeConstruction() { - StringBuilder members = new StringBuilder("\"junk\":{"); - for (int i = 0; i < 65; i++) { - if (i > 0) members.append(','); - members.append('\"').append('f').append(i).append("\":0"); - } - members.append("},"); - assertMalformed(validEnvelope().replaceFirst("\\{", "{" + members)); - } - - @Test - public void invalidUtf8IsRejected() { - assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, - () -> SignedRulePackageCodec.parse(new byte[]{'{', (byte) 0xc3, '}'})); - } - - @Test - public void rawPackageSizeIsRejectedBeforeDecoding() { - assertCode(SignedRulePackageException.Code.PACKAGE_TOO_LARGE, - () -> SignedRulePackageCodec.parse( - new byte[SignedRulePackageCodec.MAX_PACKAGE_BYTES + 1])); - } - - private static void assertMalformed(String json) { - assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, - () -> SignedRulePackageCodec.parse(bytes(json))); - } - - private static void assertCode(SignedRulePackageException.Code expected, ThrowingRunnable action) { - try { - action.run(); - fail("Expected " + expected); - } catch (SignedRulePackageException e) { - assertEquals(expected, e.code()); - assertEquals(expected.name(), e.getMessage()); - } catch (Exception e) { - throw new AssertionError("Unexpected exception", e); - } - } - - private static byte[] bytes(String value) { - return value.getBytes(StandardCharsets.UTF_8); - } - - private static byte[] hex(String value) { - byte[] result = new byte[value.length() / 2]; - for (int i = 0; i < result.length; i++) { - result[i] = (byte) Integer.parseInt(value.substring(i * 2, i * 2 + 2), 16); - } - return result; - } - - private static String canonicalPayload() { - return "{\"schemaVersion\":2,\"algorithm\":{" - + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," - + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16},\"rules\":[]}"; - } - - private static String nonCanonicalPayload() { - return "{\"rules\":[],\"algorithm\":{" - + "\"bandCount\":16,\"hopMs\":256,\"windowMs\":512," - + "\"sampleRate\":16000,\"id\":\"spectral-sequence-v2\"}," - + "\"schemaVersion\":2}"; - } - - private static String validEnvelope() { - return "{" - + "\"packageSchemaVersion\":1," - + "\"packageId\":\"official.ad-audio\"," - + "\"revision\":42," - + "\"createdAtEpochMs\":1786896000000," - + "\"expiresAtEpochMs\":1789488000000," - + "\"payloadSha256\":\"" + PAYLOAD_DIGEST + "\"," - + "\"payload\":" + nonCanonicalPayload() + ',' - + "\"signature\":{" - + "\"keyId\":\"release-1\"," - + "\"algorithm\":\"ED25519\"," - + "\"value\":\"" + SIGNATURE + "\"}}"; - } - - private interface ThrowingRunnable { - void run() throws Exception; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageSourceTest.java deleted file mode 100644 index 31b230b12f..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageSourceTest.java +++ /dev/null @@ -1,154 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import org.junit.Test; - -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; - -public class SignedRulePackageSourceTest { - - private static final String PACKAGE_ID = "official.audio.ads"; - private static final String DIGEST = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - - @Test - public void verifiedPackageMapsToStableSignedSnapshotIdentity() { - VerifiedRulePackage verified = verifiedPackage(42); - SignedRulePackageSource source = new SignedRulePackageSource( - PACKAGE_ID, - () -> SignedRulePackageStore.LoadResult.success( - verified, false, 42, List.of())); - - AdAudioRuleSnapshot snapshot = source.load(); - - assertEquals("signed:" + PACKAGE_ID, snapshot.sourceId()); - assertEquals("42:0123456789abcdef", snapshot.version()); - assertEquals(verified.ruleSet(), snapshot.ruleSet()); - assertTrue(snapshot.warnings().isEmpty()); - assertFalse(snapshot.hasError()); - assertFalse(snapshot.probeAvailable()); - } - - @Test - public void matchingSidecarIsAttachedWithoutChangingMainSnapshotIdentity() { - VerifiedRulePackage main = verifiedPackage(42); - VerifiedProbeRuleSidecarPackage sidecar = verifiedSidecar( - PACKAGE_ID, 42, main.payloadDigest()); - SignedRulePackageSource source = new SignedRulePackageSource( - PACKAGE_ID, - () -> SignedRulePackageStore.LoadResult.success(main, false, 42, List.of()), - () -> SignedProbeRuleSidecarStore.LoadResult.success(sidecar, false, 1, List.of())); - - AdAudioRuleSnapshot snapshot = source.load(); - - assertEquals("42:0123456789abcdef", snapshot.version()); - assertTrue(snapshot.probeAvailable()); - assertEquals(sidecar.sidecar(), snapshot.probeSidecar()); - assertTrue(snapshot.warnings().isEmpty()); - } - - @Test - public void mismatchingSidecarIsIgnoredAndMainPackageStillLoads() { - VerifiedRulePackage main = verifiedPackage(42); - VerifiedProbeRuleSidecarPackage sidecar = verifiedSidecar( - PACKAGE_ID, 41, main.payloadDigest()); - SignedRulePackageSource source = new SignedRulePackageSource( - PACKAGE_ID, - () -> SignedRulePackageStore.LoadResult.success(main, false, 42, List.of()), - () -> SignedProbeRuleSidecarStore.LoadResult.success(sidecar, false, 1, List.of())); - - AdAudioRuleSnapshot snapshot = source.load(); - - assertFalse(snapshot.probeAvailable()); - assertNull(snapshot.probeSidecar()); - assertEquals(List.of("PROBE_SIDECAR_MISMATCH"), snapshot.warnings()); - assertFalse(snapshot.hasError()); - } - - @Test - public void unavailableSidecarAddsWarningWithoutFailingMainPackage() { - VerifiedRulePackage main = verifiedPackage(42); - SignedRulePackageSource source = new SignedRulePackageSource( - PACKAGE_ID, - () -> SignedRulePackageStore.LoadResult.success(main, false, 42, List.of()), - () -> SignedProbeRuleSidecarStore.LoadResult.failure( - 0, SignedRulePackageException.Code.NO_VALID_SIGNED_PACKAGE)); - - AdAudioRuleSnapshot snapshot = source.load(); - - assertFalse(snapshot.probeAvailable()); - assertEquals(List.of("PROBE_SIDECAR_UNAVAILABLE"), snapshot.warnings()); - assertFalse(snapshot.hasError()); - } - - @Test - public void recoveredPreviousAddsCacheRecoveredWarningExactlyOnce() { - SignedRulePackageSource source = new SignedRulePackageSource( - PACKAGE_ID, - () -> SignedRulePackageStore.LoadResult.success( - verifiedPackage(7), true, 9, List.of("CACHE_RECOVERED"))); - - AdAudioRuleSnapshot snapshot = source.load(); - - assertEquals(List.of("CACHE_RECOVERED"), snapshot.warnings()); - } - - @Test - public void unavailableCacheMapsItsSpecificErrorToAnEmptySnapshot() { - SignedRulePackageSource source = new SignedRulePackageSource( - PACKAGE_ID, - () -> SignedRulePackageStore.LoadResult.failure( - 8, SignedRulePackageException.Code.CACHE_IO_FAILED)); - - AdAudioRuleSnapshot snapshot = source.load(); - - assertEquals("signed:" + PACKAGE_ID, snapshot.sourceId()); - assertEquals("", snapshot.version()); - assertFalse(snapshot.hasRules()); - assertEquals("CACHE_IO_FAILED", snapshot.lastError()); - } - - private static VerifiedRulePackage verifiedPackage(long revision) { - return new VerifiedRulePackage( - PACKAGE_ID, - revision, - 1_000L, - 2_000L, - DIGEST, - new byte[32], - "{}", - AudioFingerprintRuleSet.empty(), - "test-key", - "ED25519"); - } - - private static VerifiedProbeRuleSidecarPackage verifiedSidecar( - String sourcePackageId, long sourceRevision, byte[] sourceDigest) { - byte[] rules = "{\"format\":\"ad-audio-probe-rules\"}" - .getBytes(StandardCharsets.UTF_8); - byte[] sidecarDigest = sha256(rules); - ProbeRuleSidecar sidecar = ProbeRuleSidecar.verified( - sourcePackageId, sourceRevision, sourceDigest, - ProbeRuleSidecar.ALGORITHM_ID, "converter-1", rules, sidecarDigest); - byte[] artifactDigest = new byte[32]; - return new VerifiedProbeRuleSidecarPackage( - "probe.package", 1L, 1_000L, 2_000L, sidecar, - "0000000000000000000000000000000000000000000000000000000000000000", - artifactDigest, "test-key", "ED25519", new byte[] {1, 2, 3}); - } - - private static byte[] sha256(byte[] value) { - try { - return MessageDigest.getInstance("SHA-256").digest(value); - } catch (Exception e) { - throw new AssertionError(e); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageStoreTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageStoreTest.java deleted file mode 100644 index 8f07229fd3..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageStoreTest.java +++ /dev/null @@ -1,442 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.MessageDigest; -import java.util.Base64; -import java.util.Optional; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -public class SignedRulePackageStoreTest { - - private static final long MINUTE_MS = 60_000L; - private static final long DAY_MS = 24 * 60 * MINUTE_MS; - private static final long NOW = 1_786_900_000_000L; - private static final long CREATED_AT = NOW - MINUTE_MS; - private static final long EXPIRES_AT = NOW + 30 * DAY_MS; - private static final String PACKAGE_ID = "official.ad-audio"; - private static final String KEY_ID = "release-1"; - private static final String ALGORITHM = "ED25519"; - private static final String EMPTY_PAYLOAD = - "{\"schemaVersion\":2,\"algorithm\":{" - + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," - + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16},\"rules\":[]}"; - private static final String ONE_RULE_PAYLOAD = - "{\"schemaVersion\":2,\"algorithm\":{" - + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," - + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16}," - + "\"rules\":[{\"id\":\"ad-1\",\"durationMs\":15000," - + "\"anchorOffsetMs\":0,\"anchorDurationMs\":3000," - + "\"fingerprint\":[\"00000000\",\"00000001\"," - + "\"00000002\",\"00000003\"]}]}"; - - @Rule - public TemporaryFolder temporary = new TemporaryFolder(); - - @Test - public void firstInstallPersistsCurrentAndState() throws Exception { - TestTrust trust = new TestTrust(); - Path directory = directory("first"); - SignedRulePackageStore store = store(directory, trust); - - SignedRulePackageStore.InstallResult installed = store.install(envelope(1)); - SignedRulePackageStore.LoadResult loaded = store.load(); - - assertTrue(installed.changed()); - assertEquals(1L, installed.verifiedPackage().revision()); - assertEquals(1L, installed.highestAcceptedRevision()); - assertTrue(Files.exists(directory.resolve("current.json"))); - assertTrue(Files.exists(directory.resolve("state.json"))); - assertFalse(Files.exists(directory.resolve("previous.json"))); - assertTrue(loaded.hasPackage()); - assertEquals(1L, loaded.verifiedPackage().revision()); - assertFalse(loaded.recoveredPrevious()); - assertEquals(1L, loaded.highestAcceptedRevision()); - } - - @Test - public void higherRevisionRotatesCurrentToPrevious() throws Exception { - TestTrust trust = new TestTrust(); - Path directory = directory("rotation"); - SignedRulePackageStore store = store(directory, trust); - store.install(envelope(1)); - - store.install(envelope(2)); - String previous = Files.readString(directory.resolve("previous.json")); - - assertEquals(2L, store.load().verifiedPackage().revision()); - assertTrue(previous.contains("\"revision\":1")); - } - - @Test - public void lowerRevisionIsRejectedWithoutChangingCurrent() throws Exception { - TestTrust trust = new TestTrust(); - SignedRulePackageStore store = store(directory("rollback"), trust); - store.install(envelope(2)); - - assertCode(SignedRulePackageException.Code.REVISION_ROLLBACK, - () -> store.install(envelope(1))); - assertEquals(2L, store.load().verifiedPackage().revision()); - } - - @Test - public void sameRevisionAndDigestIsIdempotentWithoutAnyWrite() throws Exception { - TestTrust trust = new TestTrust(); - Path directory = directory("idempotent"); - store(directory, trust).install(envelope(1)); - SignedRulePackageStore noWriteStore = new SignedRulePackageStore( - directory, trust.verifier(), stage -> { - throw new IOException("write should not occur"); - }); - - SignedRulePackageStore.InstallResult result = noWriteStore.install(envelope(1)); - - assertFalse(result.changed()); - assertEquals(1L, result.highestAcceptedRevision()); - } - - @Test - public void sameRevisionWithDifferentDigestIsRejected() throws Exception { - TestTrust trust = new TestTrust(); - SignedRulePackageStore store = store(directory("conflict"), trust); - store.install(envelope(1)); - - assertCode(SignedRulePackageException.Code.REVISION_CONFLICT, - () -> store.install(signedEnvelope(1, CREATED_AT, EXPIRES_AT, - ONE_RULE_PAYLOAD))); - assertEquals(0, store.load().verifiedPackage().ruleSet().rules().size()); - } - - @Test - public void sameRevisionCanRestoreCurrentAfterDownloadedFilesAreCleared() throws Exception { - TestTrust trust = new TestTrust(); - Path directory = directory("clear-restore"); - SignedRulePackageStore store = store(directory, trust); - store.install(envelope(1)); - - store.clearDownloaded(); - SignedRulePackageStore.LoadResult cleared = store.load(); - SignedRulePackageStore.InstallResult restored = store.install(envelope(1)); - - assertFalse(cleared.hasPackage()); - assertEquals(1L, cleared.highestAcceptedRevision()); - assertTrue(restored.changed()); - assertEquals(1L, store.load().verifiedPackage().revision()); - } - - @Test - public void corruptCurrentRecoversPreviousWithoutLoweringHighWater() throws Exception { - TestTrust trust = new TestTrust(); - Path directory = directory("recover-previous"); - SignedRulePackageStore store = store(directory, trust); - store.install(envelope(1)); - store.install(envelope(2)); - corrupt(directory.resolve("current.json")); - - SignedRulePackageStore.LoadResult recovered = store(directory, trust).load(); - - assertTrue(recovered.hasPackage()); - assertTrue(recovered.recoveredPrevious()); - assertTrue(recovered.warnings().contains("CACHE_RECOVERED")); - assertEquals(1L, recovered.verifiedPackage().revision()); - assertEquals(2L, recovered.highestAcceptedRevision()); - assertCode(SignedRulePackageException.Code.REVISION_ROLLBACK, - () -> store(directory, trust).install(envelope(1))); - } - - @Test - public void expiredPreviousIsNotRecovered() throws Exception { - TestTrust trust = new TestTrust(); - Path directory = directory("expired-previous"); - SignedRulePackageStore store = store(directory, trust); - store.install(signedEnvelope(1, CREATED_AT, NOW + 1_000, EMPTY_PAYLOAD)); - store.install(signedEnvelope(2, CREATED_AT, NOW + DAY_MS, EMPTY_PAYLOAD)); - corrupt(directory.resolve("current.json")); - trust.now.set(NOW + 2_000); - - SignedRulePackageStore.LoadResult loaded = store(directory, trust).load(); - - assertFalse(loaded.hasPackage()); - assertEquals(SignedRulePackageException.Code.NO_VALID_SIGNED_PACKAGE, - loaded.errorCode()); - assertEquals(2L, loaded.highestAcceptedRevision()); - } - - @Test - public void revokedPreviousIsNotRecovered() throws Exception { - TestTrust trust = new TestTrust(); - Path directory = directory("revoked-previous"); - SignedRulePackageStore store = store(directory, trust); - store.install(envelope(1)); - store.install(envelope(2)); - corrupt(directory.resolve("current.json")); - trust.status.set(TrustedRuleKeyRegistry.Status.REVOKED); - - SignedRulePackageStore.LoadResult loaded = store(directory, trust).load(); - - assertFalse(loaded.hasPackage()); - assertEquals(SignedRulePackageException.Code.NO_VALID_SIGNED_PACKAGE, - loaded.errorCode()); - assertEquals(2L, loaded.highestAcceptedRevision()); - } - - @Test - public void missingOrCorruptStateIsReconstructedFromCurrent() throws Exception { - TestTrust trust = new TestTrust(); - for (String mode : new String[]{"missing", "corrupt"}) { - Path directory = directory("state-" + mode); - SignedRulePackageStore store = store(directory, trust); - store.install(envelope(2)); - if ("missing".equals(mode)) Files.delete(directory.resolve("state.json")); - else corrupt(directory.resolve("state.json")); - - SignedRulePackageStore.LoadResult loaded = store(directory, trust).load(); - - assertTrue(loaded.hasPackage()); - assertEquals(2L, loaded.highestAcceptedRevision()); - assertTrue(Files.readString(directory.resolve("state.json")) - .contains("\"highestAcceptedRevision\":2")); - } - } - - @Test - public void syntacticallyValidConflictingStateIsRepairedFromSignedCurrent() throws Exception { - TestTrust trust = new TestTrust(); - Path directory = directory("state-conflicting-digest"); - SignedRulePackageStore store = store(directory, trust); - store.install(envelope(1)); - Path stateFile = directory.resolve("state.json"); - String validState = Files.readString(stateFile); - String conflictingState = validState.replaceFirst( - "\"payloadSha256\":\"[0-9a-f]{64}\"", - "\"payloadSha256\":\"" + "0".repeat(64) + "\""); - assertFalse(validState.equals(conflictingState)); - Files.writeString(stateFile, conflictingState); - - SignedRulePackageStore.LoadResult loaded = store(directory, trust).load(); - - assertTrue(loaded.hasPackage()); - assertEquals(validState, Files.readString(stateFile)); - assertFalse(store(directory, trust).install(envelope(1)).changed()); - } - - @Test - public void clearDownloadedPreservesReconstructedHighWater() throws Exception { - TestTrust trust = new TestTrust(); - Path directory = directory("preserve-high-water"); - SignedRulePackageStore store = store(directory, trust); - store.install(envelope(1)); - store.install(envelope(2)); - Files.delete(directory.resolve("state.json")); - store(directory, trust).load(); - - store(directory, trust).clearDownloaded(); - - assertCode(SignedRulePackageException.Code.REVISION_ROLLBACK, - () -> store(directory, trust).install(envelope(1))); - } - - @Test - public void corruptPreviousDoesNotDisplaceValidCurrent() throws Exception { - TestTrust trust = new TestTrust(); - Path directory = directory("corrupt-previous"); - SignedRulePackageStore store = store(directory, trust); - store.install(envelope(1)); - store.install(envelope(2)); - corrupt(directory.resolve("previous.json")); - - SignedRulePackageStore.LoadResult loaded = store(directory, trust).load(); - - assertTrue(loaded.hasPackage()); - assertEquals(2L, loaded.verifiedPackage().revision()); - assertFalse(loaded.recoveredPrevious()); - } - - @Test - public void everyAtomicReplacementFailureLeavesOnlyACompleteOldOrNewPackage() - throws Exception { - for (SignedRulePackageStore.WriteStage target : SignedRulePackageStore.WriteStage.values()) { - TestTrust trust = new TestTrust(); - Path directory = directory("fault-" + target.name()); - store(directory, trust).install(envelope(1)); - SignedRulePackageStore failing = new SignedRulePackageStore( - directory, trust.verifier(), stage -> { - if (stage == target) throw new IOException("injected"); - }); - - assertCode(SignedRulePackageException.Code.CACHE_IO_FAILED, - () -> failing.install(envelope(2))); - SignedRulePackageStore.LoadResult loaded = store(directory, trust).load(); - - assertTrue(loaded.hasPackage()); - long revision = loaded.verifiedPackage().revision(); - assertTrue(revision == 1 || revision == 2); - assertFalse(Files.exists(directory.resolve("current.tmp"))); - assertFalse(Files.exists(directory.resolve("previous.tmp"))); - assertFalse(Files.exists(directory.resolve("state.tmp"))); - } - } - - @Test - public void separateStoreInstancesSerializeOperationsForTheSameDirectory() - throws Exception { - TestTrust trust = new TestTrust(); - Path directory = directory("concurrent-instances"); - CountDownLatch firstBeforeCurrent = new CountDownLatch(1); - CountDownLatch releaseFirst = new CountDownLatch(1); - CountDownLatch secondBeforeCurrent = new CountDownLatch(1); - CountDownLatch releaseSecond = new CountDownLatch(1); - AtomicReference firstFailure = new AtomicReference<>(); - AtomicReference secondFailure = new AtomicReference<>(); - SignedRulePackageStore first = new SignedRulePackageStore( - directory, trust.verifier(), stage -> { - if (stage == SignedRulePackageStore.WriteStage.BEFORE_CURRENT_REPLACE) { - firstBeforeCurrent.countDown(); - await(releaseFirst); - } - }); - SignedRulePackageStore second = new SignedRulePackageStore( - directory, trust.verifier(), stage -> { - if (stage == SignedRulePackageStore.WriteStage.BEFORE_CURRENT_REPLACE) { - secondBeforeCurrent.countDown(); - await(releaseSecond); - } - }); - Thread firstThread = installThread(first, 1, firstFailure); - Thread secondThread = installThread(second, 2, secondFailure); - - firstThread.start(); - assertTrue(firstBeforeCurrent.await(5, TimeUnit.SECONDS)); - secondThread.start(); - boolean secondEnteredEarly = secondBeforeCurrent.await(500, TimeUnit.MILLISECONDS); - if (secondEnteredEarly) { - releaseSecond.countDown(); - secondThread.join(5_000); - releaseFirst.countDown(); - } else { - releaseFirst.countDown(); - assertTrue(secondBeforeCurrent.await(5, TimeUnit.SECONDS)); - releaseSecond.countDown(); - } - firstThread.join(5_000); - secondThread.join(5_000); - - assertFalse(firstThread.isAlive()); - assertFalse(secondThread.isAlive()); - if (firstFailure.get() != null) throw new AssertionError(firstFailure.get()); - if (secondFailure.get() != null) throw new AssertionError(secondFailure.get()); - assertEquals(2L, store(directory, trust).load().verifiedPackage().revision()); - } - - private Path directory(String name) throws IOException { - return temporary.newFolder(name).toPath(); - } - - private static SignedRulePackageStore store(Path directory, TestTrust trust) { - return new SignedRulePackageStore(directory, trust.verifier()); - } - - private static byte[] envelope(long revision) throws Exception { - return signedEnvelope(revision, CREATED_AT, EXPIRES_AT, EMPTY_PAYLOAD); - } - - private static byte[] signedEnvelope(long revision, long createdAt, - long expiresAt, String payload) throws Exception { - AudioFingerprintRuleSet ruleSet = AudioFingerprintRuleCodec.fromJson(payload); - String canonicalPayload = AudioFingerprintRuleCodec.toJson(ruleSet); - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(canonicalPayload.getBytes(StandardCharsets.UTF_8)); - byte[] signature = SignedRulePackageTestKeys.signer().sign( - SignedRulePackageCodec.signingInput(PACKAGE_ID, revision, createdAt, - expiresAt, KEY_ID, ALGORITHM, digest)); - String json = "{" - + "\"packageSchemaVersion\":1," - + "\"packageId\":\"" + PACKAGE_ID + "\"," - + "\"revision\":" + revision + ',' - + "\"createdAtEpochMs\":" + createdAt + ',' - + "\"expiresAtEpochMs\":" + expiresAt + ',' - + "\"payloadSha256\":\"" + hex(digest) + "\"," - + "\"payload\":" + canonicalPayload + ',' - + "\"signature\":{" - + "\"keyId\":\"" + KEY_ID + "\"," - + "\"algorithm\":\"" + ALGORITHM + "\"," - + "\"value\":\"" + Base64.getUrlEncoder().withoutPadding() - .encodeToString(signature) + "\"}}"; - return json.getBytes(StandardCharsets.UTF_8); - } - - private static String hex(byte[] bytes) { - StringBuilder result = new StringBuilder(bytes.length * 2); - for (byte value : bytes) result.append(String.format("%02x", value & 0xff)); - return result.toString(); - } - - private static void corrupt(Path file) throws IOException { - Files.writeString(file, "{"); - } - - private static Thread installThread(SignedRulePackageStore store, long revision, - AtomicReference failure) { - return new Thread(() -> { - try { - store.install(envelope(revision)); - } catch (Throwable e) { - failure.set(e); - } - }); - } - - private static void await(CountDownLatch latch) throws IOException { - try { - if (!latch.await(5, TimeUnit.SECONDS)) throw new IOException("latch timeout"); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("interrupted", e); - } - } - - private static void assertCode(SignedRulePackageException.Code expected, ThrowingRunnable action) { - try { - action.run(); - fail("Expected " + expected); - } catch (SignedRulePackageException e) { - assertEquals(expected, e.code()); - assertEquals(expected.name(), e.getMessage()); - } catch (Exception e) { - throw new AssertionError("Unexpected exception", e); - } - } - - private static final class TestTrust { - final AtomicLong now = new AtomicLong(NOW); - final AtomicReference status = - new AtomicReference<>(TrustedRuleKeyRegistry.Status.ACTIVE); - - SignedRulePackageVerifier verifier() { - TrustedRuleKeyRegistry registry = keyId -> KEY_ID.equals(keyId) - ? Optional.of(new TrustedRuleKeyRegistry.Entry( - KEY_ID, ALGORITHM, 0, Long.MAX_VALUE, - status.get(), SignedRulePackageTestKeys.verifier())) - : Optional.empty(); - return new SignedRulePackageVerifier(PACKAGE_ID, registry, now::get); - } - } - - private interface ThrowingRunnable { - void run() throws Exception; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageTestKeys.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageTestKeys.java deleted file mode 100644 index c1569e17f5..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageTestKeys.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import com.google.crypto.tink.Configuration; -import com.google.crypto.tink.KeysetHandle; -import com.google.crypto.tink.PublicKeySign; -import com.google.crypto.tink.PublicKeyVerify; -import com.google.crypto.tink.RegistryConfiguration; -import com.google.crypto.tink.signature.Ed25519PrivateKeyManager; -import com.google.crypto.tink.signature.SignatureConfig; - -import java.security.GeneralSecurityException; - -final class SignedRulePackageTestKeys { - - private static final PublicKeySign SIGNER; - private static final PublicKeyVerify VERIFIER; - - static { - try { - SignatureConfig.register(); - KeysetHandle privateHandle = KeysetHandle.generateNew( - Ed25519PrivateKeyManager.rawEd25519Template()); - Configuration configuration = RegistryConfiguration.get(); - SIGNER = privateHandle.getPrimitive(configuration, PublicKeySign.class); - VERIFIER = privateHandle.getPublicKeysetHandle() - .getPrimitive(configuration, PublicKeyVerify.class); - } catch (GeneralSecurityException e) { - throw new ExceptionInInitializerError(e); - } - } - - static PublicKeySign signer() { - return SIGNER; - } - - static PublicKeyVerify verifier() { - return VERIFIER; - } - - private SignedRulePackageTestKeys() { - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageVerifierTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageVerifierTest.java deleted file mode 100644 index 9267028c24..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SignedRulePackageVerifierTest.java +++ /dev/null @@ -1,307 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Base64; -import java.util.Optional; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; - -public class SignedRulePackageVerifierTest { - - private static final long MINUTE_MS = 60_000L; - private static final long DAY_MS = 24 * 60 * MINUTE_MS; - private static final long NOW = 1_786_900_000_000L; - private static final long CREATED_AT = NOW - MINUTE_MS; - private static final long EXPIRES_AT = NOW + DAY_MS; - private static final String PACKAGE_ID = "official.ad-audio"; - private static final String KEY_ID = "release-1"; - private static final String ALGORITHM = "ED25519"; - private static final String EMPTY_PAYLOAD = - "{\"schemaVersion\":2,\"algorithm\":{" - + "\"id\":\"spectral-sequence-v2\",\"sampleRate\":16000," - + "\"windowMs\":512,\"hopMs\":256,\"bandCount\":16},\"rules\":[]}"; - - @Test - public void activeKeyVerifiesInstallAndCache() throws Exception { - SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - NOW - DAY_MS, NOW + DAY_MS); - byte[] envelope = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, - KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - - VerifiedRulePackage installed = verifier.verify(envelope, VerificationMode.INSTALL); - VerifiedRulePackage cached = verifier.verify(envelope, VerificationMode.CACHE); - - assertEquals(PACKAGE_ID, installed.packageId()); - assertEquals(42L, installed.revision()); - assertEquals(CREATED_AT, installed.createdAtEpochMs()); - assertEquals(EXPIRES_AT, installed.expiresAtEpochMs()); - assertEquals(KEY_ID, installed.keyId()); - assertEquals(ALGORITHM, installed.signatureAlgorithm()); - assertEquals(EMPTY_PAYLOAD, installed.canonicalPayload()); - assertEquals(0, installed.ruleSet().rules().size()); - assertArrayEquals(installed.payloadDigest(), cached.payloadDigest()); - } - - @Test - public void tamperingEverySignedFieldIsRejected() throws Exception { - SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - NOW - DAY_MS, NOW + DAY_MS); - String valid = text(signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, - KEY_ID, ALGORITHM, EMPTY_PAYLOAD)); - String digest = digestHex(EMPTY_PAYLOAD); - - assertCode(SignedRulePackageException.Code.PACKAGE_ID_MISMATCH, - () -> verifier.verify(bytes(valid.replace(PACKAGE_ID, "other.ad-audio")), - VerificationMode.INSTALL)); - assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, - () -> verifier.verify(bytes(valid.replace("\"revision\":42", "\"revision\":43")), - VerificationMode.INSTALL)); - assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, - () -> verifier.verify(bytes(valid.replace(Long.toString(CREATED_AT), - Long.toString(CREATED_AT + 1))), VerificationMode.INSTALL)); - assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, - () -> verifier.verify(bytes(valid.replace(Long.toString(EXPIRES_AT), - Long.toString(EXPIRES_AT + 1))), VerificationMode.INSTALL)); - assertCode(SignedRulePackageException.Code.UNKNOWN_KEY, - () -> verifier.verify(bytes(valid.replace(KEY_ID, "release-2")), - VerificationMode.INSTALL)); - assertCode(SignedRulePackageException.Code.UNSUPPORTED_SIGNATURE_ALGORITHM, - () -> verifier.verify(bytes(valid.replace(ALGORITHM, "ED25518")), - VerificationMode.INSTALL)); - assertCode(SignedRulePackageException.Code.PAYLOAD_DIGEST_MISMATCH, - () -> verifier.verify(bytes(valid.replace("\"rules\":[]", "\"rules\":[{" - + "\"id\":\"ad-1\",\"durationMs\":15000," - + "\"anchorOffsetMs\":0,\"anchorDurationMs\":3000," - + "\"fingerprint\":[\"00000000\",\"00000001\"," - + "\"00000002\",\"00000003\"]}]")), - VerificationMode.INSTALL)); - assertCode(SignedRulePackageException.Code.PAYLOAD_DIGEST_MISMATCH, - () -> verifier.verify(bytes(valid.replace(digest, - (digest.charAt(0) == '0' ? "1" : "0") + digest.substring(1))), - VerificationMode.INSTALL)); - assertCode(SignedRulePackageException.Code.SIGNATURE_INVALID, - () -> verifier.verify(bytes(tamperSignature(valid)), VerificationMode.INSTALL)); - } - - @Test - public void correctlySignedUnknownKeyIsRejected() throws Exception { - byte[] envelope = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, - "unknown-1", ALGORITHM, EMPTY_PAYLOAD); - - assertCode(SignedRulePackageException.Code.UNKNOWN_KEY, - () -> verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - NOW - DAY_MS, NOW + DAY_MS).verify(envelope, VerificationMode.INSTALL)); - } - - @Test - public void correctlySignedUnsupportedAlgorithmIsRejected() throws Exception { - byte[] envelope = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, - KEY_ID, "ECDSA", EMPTY_PAYLOAD); - - assertCode(SignedRulePackageException.Code.UNSUPPORTED_SIGNATURE_ALGORITHM, - () -> verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - NOW - DAY_MS, NOW + DAY_MS).verify(envelope, VerificationMode.INSTALL)); - } - - @Test - public void retiredKeyIsCacheOnly() throws Exception { - SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.RETIRED, - NOW - DAY_MS, NOW + DAY_MS); - byte[] envelope = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, - KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - - assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, - () -> verifier.verify(envelope, VerificationMode.INSTALL)); - assertEquals(42L, verifier.verify(envelope, VerificationMode.CACHE).revision()); - } - - @Test - public void revokedKeyIsAlwaysRejected() throws Exception { - SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.REVOKED, - NOW - DAY_MS, NOW + DAY_MS); - byte[] envelope = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, - KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - - assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, - () -> verifier.verify(envelope, VerificationMode.INSTALL)); - assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, - () -> verifier.verify(envelope, VerificationMode.CACHE)); - } - - @Test - public void packageCreationMustFallWithinKeyValidityBounds() throws Exception { - byte[] before = signedEnvelope(PACKAGE_ID, 42, NOW - 2 * DAY_MS, EXPIRES_AT, - KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - byte[] after = signedEnvelope(PACKAGE_ID, 42, NOW, EXPIRES_AT, - KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - - assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, - () -> verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - NOW - DAY_MS, NOW + DAY_MS).verify(before, VerificationMode.CACHE)); - assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, - () -> verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - NOW - DAY_MS, NOW - 1).verify(after, VerificationMode.CACHE)); - } - - @Test - public void keyValidityBoundsAreInclusive() throws Exception { - byte[] atNotBefore = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, - KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - byte[] atNotAfter = signedEnvelope(PACKAGE_ID, 43, NOW, EXPIRES_AT, - KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - - assertEquals(42L, verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - CREATED_AT, NOW + DAY_MS).verify(atNotBefore, VerificationMode.CACHE).revision()); - assertEquals(43L, verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - CREATED_AT, NOW).verify(atNotAfter, VerificationMode.CACHE).revision()); - } - - @Test - public void expiredActiveKeyCannotInstallButCanValidateExistingCache() throws Exception { - SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - CREATED_AT - DAY_MS, NOW - 1); - byte[] envelope = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, - KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - - assertCode(SignedRulePackageException.Code.KEY_NOT_VALID, - () -> verifier.verify(envelope, VerificationMode.INSTALL)); - assertEquals(42L, verifier.verify(envelope, VerificationMode.CACHE).revision()); - } - - @Test - public void packageFutureSkewIsLimitedToFiveMinutes() throws Exception { - byte[] boundary = signedEnvelope(PACKAGE_ID, 42, - NOW + 5 * MINUTE_MS, NOW + DAY_MS, KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - byte[] tooFar = signedEnvelope(PACKAGE_ID, 43, - NOW + 5 * MINUTE_MS + 1, NOW + DAY_MS, KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - NOW - DAY_MS, NOW + 2 * DAY_MS); - - assertEquals(42L, verifier.verify(boundary, VerificationMode.INSTALL).revision()); - assertCode(SignedRulePackageException.Code.PACKAGE_NOT_YET_VALID, - () -> verifier.verify(tooFar, VerificationMode.INSTALL)); - } - - @Test - public void packageExpiryBoundaryIsExclusive() throws Exception { - byte[] expired = signedEnvelope(PACKAGE_ID, 42, CREATED_AT, NOW, - KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - byte[] valid = signedEnvelope(PACKAGE_ID, 43, CREATED_AT, NOW + 1, - KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - SignedRulePackageVerifier verifier = verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - NOW - DAY_MS, NOW + DAY_MS); - - assertCode(SignedRulePackageException.Code.PACKAGE_EXPIRED, - () -> verifier.verify(expired, VerificationMode.INSTALL)); - assertEquals(43L, verifier.verify(valid, VerificationMode.INSTALL).revision()); - } - - @Test - public void packageLifetimeCannotExceedNinetyDays() throws Exception { - long created = NOW - 1; - byte[] envelope = signedEnvelope(PACKAGE_ID, 42, created, - created + 90 * DAY_MS + 1, KEY_ID, ALGORITHM, EMPTY_PAYLOAD); - - assertCode(SignedRulePackageException.Code.PACKAGE_MALFORMED, - () -> verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - NOW - DAY_MS, NOW + 100 * DAY_MS) - .verify(envelope, VerificationMode.INSTALL)); - } - - @Test - public void verifiedDigestIsDefensivelyCopied() throws Exception { - VerifiedRulePackage verified = verifier(TrustedRuleKeyRegistry.Status.ACTIVE, - NOW - DAY_MS, NOW + DAY_MS).verify( - signedEnvelope(PACKAGE_ID, 42, CREATED_AT, EXPIRES_AT, - KEY_ID, ALGORITHM, EMPTY_PAYLOAD), VerificationMode.INSTALL); - byte[] expected = verified.payloadDigest(); - - verified.payloadDigest()[0] ^= 1; - - assertArrayEquals(expected, verified.payloadDigest()); - } - - private static SignedRulePackageVerifier verifier(TrustedRuleKeyRegistry.Status status, - long notBefore, long notAfter) { - TrustedRuleKeyRegistry.Entry entry = new TrustedRuleKeyRegistry.Entry( - KEY_ID, ALGORITHM, notBefore, notAfter, status, - SignedRulePackageTestKeys.verifier()); - TrustedRuleKeyRegistry registry = keyId -> KEY_ID.equals(keyId) - ? Optional.of(entry) - : Optional.empty(); - return new SignedRulePackageVerifier(PACKAGE_ID, registry, () -> NOW); - } - - private static byte[] signedEnvelope(String packageId, long revision, - long createdAt, long expiresAt, - String keyId, String algorithm, - String payload) throws Exception { - AudioFingerprintRuleSet ruleSet = AudioFingerprintRuleCodec.fromJson(payload); - String canonicalPayload = AudioFingerprintRuleCodec.toJson(ruleSet); - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(canonicalPayload.getBytes(StandardCharsets.UTF_8)); - byte[] signature = SignedRulePackageTestKeys.signer().sign( - SignedRulePackageCodec.signingInput(packageId, revision, createdAt, - expiresAt, keyId, algorithm, digest)); - String json = "{" - + "\"packageSchemaVersion\":1," - + "\"packageId\":\"" + packageId + "\"," - + "\"revision\":" + revision + ',' - + "\"createdAtEpochMs\":" + createdAt + ',' - + "\"expiresAtEpochMs\":" + expiresAt + ',' - + "\"payloadSha256\":\"" + hex(digest) + "\"," - + "\"payload\":" + canonicalPayload + ',' - + "\"signature\":{" - + "\"keyId\":\"" + keyId + "\"," - + "\"algorithm\":\"" + algorithm + "\"," - + "\"value\":\"" + Base64.getUrlEncoder().withoutPadding() - .encodeToString(signature) + "\"}}"; - return bytes(json); - } - - private static String tamperSignature(String json) { - int start = json.indexOf("\"value\":\"") + "\"value\":\"".length(); - char replacement = json.charAt(start) == 'A' ? 'B' : 'A'; - return json.substring(0, start) + replacement + json.substring(start + 1); - } - - private static String digestHex(String payload) throws Exception { - return hex(MessageDigest.getInstance("SHA-256") - .digest(payload.getBytes(StandardCharsets.UTF_8))); - } - - private static String hex(byte[] bytes) { - StringBuilder result = new StringBuilder(bytes.length * 2); - for (byte value : bytes) result.append(String.format("%02x", value & 0xff)); - return result.toString(); - } - - private static String text(byte[] bytes) { - return new String(bytes, StandardCharsets.UTF_8); - } - - private static byte[] bytes(String value) { - return value.getBytes(StandardCharsets.UTF_8); - } - - private static void assertCode(SignedRulePackageException.Code expected, ThrowingRunnable action) { - try { - action.run(); - fail("Expected " + expected); - } catch (SignedRulePackageException e) { - assertEquals(expected, e.code()); - assertEquals(expected.name(), e.getMessage()); - } catch (Exception e) { - throw new AssertionError("Unexpected exception", e); - } - } - - private interface ThrowingRunnable { - void run() throws Exception; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpectralFingerprintTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpectralFingerprintTest.java deleted file mode 100644 index 7f9382c6be..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpectralFingerprintTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import org.junit.Test; - -import java.util.Arrays; -import java.util.List; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; - -public class SpectralFingerprintTest { - - private static final AudioFingerprintConfig CONFIG = AudioFingerprintConfig.standard(); - - @Test - public void silenceProducesZeroHashesForEveryPhase() { - List variants = SpectralFingerprint.extractVariants( - new short[16_000 * 3], 16_000, 1, CONFIG); - - assertEquals(4, variants.size()); - for (int[] sequence : variants) { - for (int hash : sequence) assertEquals(0, hash); - } - } - - @Test - public void stereoDownmixAndResampleMatchMonoReference() { - short[] stereo48k = sine(48_000, 3, 500, 2); - short[] mono16k = sine(16_000, 3, 500, 1); - - assertSequencesEqual( - SpectralFingerprint.extractVariants(mono16k, 16_000, 1, CONFIG), - SpectralFingerprint.extractVariants(stereo48k, 48_000, 2, CONFIG)); - } - - @Test - public void sdkGoldenVectorHasStableHashes() { - int[] expected = hashes( - "32f0007c,35c100e0,3b8b01c0,d30a0380,2e0b0700," - + "0c650600,9c560c00,49db0c00,d9161800,70d41800"); - - assertArrayEquals(expected, - SpectralFingerprint.extract(chirp16k(), 16_000, 1, CONFIG)); - } - - @Test - public void tooShortAudioDoesNotCreateAUsableSequence() { - assertEquals(0, SpectralFingerprint.extractVariants( - new short[CONFIG.windowSamples() - 1], 16_000, 1, CONFIG).size()); - } - - private static void assertSequencesEqual(List expected, List actual) { - assertEquals(expected.size(), actual.size()); - for (int i = 0; i < expected.size(); i++) assertArrayEquals(expected.get(i), actual.get(i)); - } - - private static int[] hashes(String csv) { - return Arrays.stream(csv.split(",")) - .mapToInt(value -> (int) Long.parseLong(value, 16)) - .toArray(); - } - - private static short[] sine(int sampleRate, int seconds, double frequency, int channels) { - short[] output = new short[sampleRate * seconds * channels]; - for (int frame = 0; frame < sampleRate * seconds; frame++) { - short sample = (short) Math.round(16_000 * Math.sin(2 * Math.PI * frequency * frame / sampleRate)); - for (int channel = 0; channel < channels; channel++) output[frame * channels + channel] = sample; - } - return output; - } - - private static short[] chirp16k() { - int sampleRate = 16_000; - int sampleCount = sampleRate * 3; - double startFrequency = 300; - double endFrequency = 3_000; - double rate = (endFrequency - startFrequency) / 3; - short[] output = new short[sampleCount]; - for (int i = 0; i < output.length; i++) { - double time = i / (double) sampleRate; - double phase = 2 * Math.PI * (startFrequency * time + 0.5 * rate * time * time); - output[i] = (short) Math.round(16_000 * Math.sin(phase)); - } - return output; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdConfigTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdConfigTest.java deleted file mode 100644 index 8cecdbc4fd..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdConfigTest.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.*; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; - -import org.junit.Test; - -public class SpeechAdConfigTest { - - @Test - public void defaultsAreDisabledPromptAndFifteenSeconds() { - SpeechAdConfig config = SpeechAdConfig.defaults(); - assertFalse(config.enabled()); - assertEquals(AdSkipPolicyController.Mode.PROMPT, config.mode()); - assertEquals(15, config.skipSeconds()); - assertEquals(SpeechAdConfig.DEFAULT_KEYWORDS, String.join(",", config.keywords().values())); - assertFalse(config.keywords().isEmpty()); - } - - @Test - public void unsafeDurationIsClamped() { - assertEquals(1, SpeechAdConfig.create(true, "赌场", 0, "PROMPT").skipSeconds()); - assertEquals(120, SpeechAdConfig.create(true, "赌场", 999, "AUTO").skipSeconds()); - } - - @Test - public void unknownOrNullModeFallsBackToPrompt() { - assertEquals(AdSkipPolicyController.Mode.PROMPT, - SpeechAdConfig.create(true, "赌场", 15, "UNKNOWN").mode()); - assertEquals(AdSkipPolicyController.Mode.PROMPT, - SpeechAdConfig.create(true, "赌场", 15, null).mode()); - } - - @Test - public void keywordsAreNormalizedAndDeduplicated() { - SpeechAdConfig config = SpeechAdConfig.create(true, " 赌场,赌场,\uFF27\uFF21\uFF2D\uFF25 ", 15, "AUTO"); - assertEquals(java.util.List.of("赌场", "game"), config.keywords().values()); - assertEquals(AdSkipPolicyController.Mode.AUTO, config.mode()); - } - - @Test - public void compoundRulesAreOptionalAndHaveStableBoundedRoutingVersion() { - SpeechAdConfig first = SpeechAdConfig.createWithRules( - true, "", "广告之后*>马上回来,[2,30]", 15, "AUTO"); - SpeechAdConfig second = SpeechAdConfig.createWithRules( - true, "", "广告之后*>马上回来,[2,30]", 15, "AUTO"); - - assertTrue(first.hasSpeechRules()); - assertEquals(1, first.rules().rules().size()); - assertEquals(first.rulesVersion(), second.rulesVersion()); - assertTrue(first.rulesVersion().length() <= 16); - assertEquals(AdSkipPolicyController.Mode.AUTO, first.mode()); - } - - @Test - public void legacyConfigKeepsAnEmptyCompoundRuleSnapshot() { - SpeechAdConfig config = SpeechAdConfig.create(true, "赌场", 15, "PROMPT"); - - assertTrue(config.hasSpeechRules()); - assertTrue(config.rules().isEmpty()); - assertEquals("", config.rulesVersion()); - } - - @Test - public void snapshotKeywordsAreImmutable() { - SpeechAdConfig config = SpeechAdConfig.create(true, "赌场,棋牌", 15, "PROMPT"); - try { - config.keywords().values().add("下注"); - fail("keyword snapshot must be immutable"); - } catch (UnsupportedOperationException expected) { - // expected - } - } - - @Test - public void settingSnapshotHasSafeDefaultsWithoutAndroidContext() { - SpeechAdConfig config = SpeechAdSetting.snapshot(); - assertFalse(config.enabled()); - assertEquals(AdSkipPolicyController.Mode.PROMPT, config.mode()); - assertEquals(15, config.skipSeconds()); - } - - @Test(expected = NullPointerException.class) - public void settingModeRejectsNull() { - SpeechAdSetting.setMode(null); - } - - @Test - public void settingKeepsLegacyKeysAndAddsBoundedRulePreferences() throws Exception { - String source = readSource("SpeechAdSetting.java"); - assertTrue(source.contains("speech_ad_enabled")); - assertTrue(source.contains("speech_ad_keywords")); - assertTrue(source.contains("speech_ad_skip_seconds")); - assertTrue(source.contains("speech_ad_skip_mode")); - assertTrue(source.contains("Prefers.getBoolean(KEY_ENABLED, false)")); - assertTrue(source.contains("Prefers.getString(KEY_KEYWORDS, SpeechAdConfig.DEFAULT_KEYWORDS)")); - assertTrue(source.contains("Prefers.getInt(KEY_SKIP_SECONDS, 15)")); - assertTrue(source.contains("Prefers.getString(KEY_SKIP_MODE, AdSkipPolicyController.Mode.PROMPT.name())")); - assertTrue(source.contains("SpeechAdKeywordSet.parse(value)")); - assertTrue(source.contains("Math.max(1, Math.min(120, value))")); - assertTrue(source.contains("Objects.requireNonNull(value, \"mode\")")); - assertEquals("speech_ad_rules_v1", SpeechAdSetting.KEY_RULES); - assertEquals("speech_ad_rules_source", SpeechAdSetting.KEY_RULE_SOURCE); - assertEquals("speech_ad_builtin_enabled", SpeechAdSetting.KEY_BUILTIN_ENABLED); - assertFalse(SpeechAdSetting.isBuiltinEnabled()); - assertTrue(source.contains("SpeechAdRuleCodec.MAX_INPUT_BYTES")); - assertTrue(source.contains("newDecoder().onMalformedInput(CodingErrorAction.REPORT)")); - } - - @Test - public void ruleDocumentIsCanonicalizedAndMalformedRestoreKeepsPreviousValue() { - assertEquals("广告之后*>马上回来,[2,30]", - SpeechAdSetting.canonicalize(" 广告之后** > 马上回来,[2,30] ")); - - Map previous = Map.of( - SpeechAdSetting.KEY_RULES, "旧规则,30", - SpeechAdSetting.KEY_RULE_SOURCE, "user", - SpeechAdSetting.KEY_BUILTIN_ENABLED, false); - Map malformed = Map.of( - SpeechAdSetting.KEY_RULES, "新规则,-1", - SpeechAdSetting.KEY_RULE_SOURCE, "imported", - SpeechAdSetting.KEY_BUILTIN_ENABLED, false); - - Map restored = SpeechAdSetting.sanitizePreferences(malformed, previous, false); - assertEquals("旧规则,30", restored.get(SpeechAdSetting.KEY_RULES)); - assertEquals("user", restored.get(SpeechAdSetting.KEY_RULE_SOURCE)); - assertEquals(false, restored.get(SpeechAdSetting.KEY_BUILTIN_ENABLED)); - } - - @Test - public void ruleMergeDeduplicatesExactRulesAndRejectsWindowConflicts() { - SpeechAdRuleSet same = SpeechAdRuleCodec.parse("广告之后,30"); - assertEquals(1, SpeechAdSetting.merge(same, same).rules().size()); - - SpeechAdRuleSet builtin = SpeechAdRuleCodec.parse("广告之后,30"); - SpeechAdRuleSet custom = SpeechAdRuleCodec.parse("广告之后,[2,30]"); - IllegalArgumentException error = assertThrows(IllegalArgumentException.class, - () -> SpeechAdSetting.merge(builtin, custom)); - assertTrue(error.getMessage().contains("conflicting windows")); - } - - @Test - public void invalidPreferenceTypesAreRejectedWithoutLeakingValues() { - Map previous = Map.of( - SpeechAdSetting.KEY_RULES, "旧规则,30", - SpeechAdSetting.KEY_RULE_SOURCE, "user", - SpeechAdSetting.KEY_BUILTIN_ENABLED, false); - Map malformed = Map.of( - SpeechAdSetting.KEY_RULES, "新规则,30", - SpeechAdSetting.KEY_RULE_SOURCE, 42, - SpeechAdSetting.KEY_BUILTIN_ENABLED, true); - - Map restored = SpeechAdSetting.sanitizePreferences(malformed, previous, false); - assertEquals(previous, Map.of( - SpeechAdSetting.KEY_RULES, restored.get(SpeechAdSetting.KEY_RULES), - SpeechAdSetting.KEY_RULE_SOURCE, restored.get(SpeechAdSetting.KEY_RULE_SOURCE), - SpeechAdSetting.KEY_BUILTIN_ENABLED, restored.get(SpeechAdSetting.KEY_BUILTIN_ENABLED))); - } - - private static String readSource(String fileName) throws Exception { - Path moduleRoot = Files.exists(Path.of("src/main/java")) ? Path.of("src/main/java") : Path.of("app/src/main/java"); - Path path = moduleRoot.resolve(Path.of("com", "fongmi", "android", "tv", "ad", "audio", fileName)); - return Files.readString(path, StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdKeywordSetTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdKeywordSetTest.java deleted file mode 100644 index 8d9f6a9586..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdKeywordSetTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import org.junit.Test; - -import java.util.List; - -public class SpeechAdKeywordSetTest { - - @Test - public void parseNormalizesMixedSeparatorsAndDeduplicates() { - SpeechAdKeywordSet set = SpeechAdKeywordSet.parse(" 澳门,赌场;澳门; 首充, DOWNLOAD\r\n提现 "); - - assertEquals(List.of("澳门", "赌场", "首充", "download", "提现"), set.values()); - } - - @Test - public void chineseKeywordsUseSubstringMatching() { - SpeechAdKeywordSet set = SpeechAdKeywordSet.parse("首充,提现"); - - assertEquals("首充", set.firstMatch("现在完成首充即可提现").orElseThrow()); - } - - @Test - public void asciiKeywordsRequireWordBoundaries() { - SpeechAdKeywordSet set = SpeechAdKeywordSet.parse("AD,404"); - - assertEquals("ad", set.firstMatch("an ad starts now").orElseThrow()); - assertEquals("404", set.firstMatch("error 404 occurred").orElseThrow()); - assertTrue(set.firstMatch("download finished").isEmpty()); - assertTrue(set.firstMatch("ad2 starts now").isEmpty()); - assertTrue(set.firstMatch("error404 occurred").isEmpty()); - } - - @Test - public void emptyNullAndPunctuationOnlyTokensAreDropped() { - assertTrue(SpeechAdKeywordSet.parse(null).isEmpty()); - assertTrue(SpeechAdKeywordSet.parse(",;\n---").isEmpty()); - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsMoreThanConfiguredKeywordCapacity() { - StringBuilder value = new StringBuilder(); - for (int i = 0; i < 129; i++) value.append("词").append(i).append(","); - - SpeechAdKeywordSet.parse(value.toString()); - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsKeywordLongerThanConfiguredLimit() { - SpeechAdKeywordSet.parse("a".repeat(65)); - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsInputLongerThanConfiguredLimit() { - SpeechAdKeywordSet.parse("a".repeat(8_193)); - } - - @Test - public void equalityAndHashCodeDependOnNormalizedOrderedValues() { - SpeechAdKeywordSet first = SpeechAdKeywordSet.parse(" 澳门,AD,澳门 "); - SpeechAdKeywordSet same = SpeechAdKeywordSet.parse("澳门;ad"); - SpeechAdKeywordSet differentOrder = SpeechAdKeywordSet.parse("ad;澳门"); - - assertEquals(first, same); - assertEquals(first.hashCode(), same.hashCode()); - assertNotEquals(first, differentOrder); - assertNotEquals(first, null); - } - - @Test - public void valuesCannotBeModified() { - SpeechAdKeywordSet set = SpeechAdKeywordSet.parse("澳门"); - - try { - set.values().add("赌场"); - fail("values must be immutable"); - } catch (UnsupportedOperationException expected) { - assertFalse(set.isEmpty()); - } - } -} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdMatcherTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdMatcherTest.java deleted file mode 100644 index 23283c6724..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdMatcherTest.java +++ /dev/null @@ -1,182 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -public class SpeechAdMatcherTest { - - @Test - public void matchesOrderedSegmentsAcrossRecognitionsAndClampsWindow() { - SpeechAdMatcher matcher = matcher("广告之后*>马上回来,[2,30]"); - - assertTrue(matcher.accept("广告之后有精彩内容", 10_000_000L, - 12_000_000L, 7).isEmpty()); - SpeechAdMatcher.Match match = matcher.accept("马上回来", 13_000_000L, - 15_000_000L, 7).get(0); - - assertEquals(8_000L, match.candidateStartMs()); - assertEquals(6_000L, match.candidateStartMs(6_000L)); - assertEquals(45_000L, match.candidateEndMs(-1L)); - assertEquals(40_000L, match.candidateEndMs(40_000L)); - assertEquals(6_000L, match.candidateEndMs(6_000L)); - } - - @Test - public void wildcardLayoutHandlesRepeatedLiteralsAndAdjacentWildcards() { - assertEquals(1, matcher("a*a,1").accept("a中a", 0L, - 1_000_000L, 1).size()); - assertEquals(1, matcher("a**a,1").accept("a中a", 0L, - 1_000_000L, 1).size()); - assertEquals(1, matcher("a*a*a,1").accept("a中a再a", 0L, - 1_000_000L, 1).size()); - } - - @Test - public void doesNotMatchWrongOrderAndExpiresPendingSequence() { - SpeechAdMatcher wrongOrder = matcher("广告之后>马上回来,1"); - assertTrue(wrongOrder.accept("马上回来", 0L, 1_000_000L, 1).isEmpty()); - assertTrue(wrongOrder.accept("广告之后", 2_000_000L, - 3_000_000L, 1).isEmpty()); - - SpeechAdMatcher expired = matcher("广告之后>马上回来,1"); - assertTrue(expired.accept("广告之后", 0L, 1_000_000L, 1).isEmpty()); - assertTrue(expired.accept("马上回来", 31_000_000L, - 32_000_000L, 1).isEmpty()); - assertTrue(expired.accept("广告之后", 40_000_000L, - 41_000_000L, 1).isEmpty()); - assertEquals(1, expired.accept("马上回来", 42_000_000L, - 43_000_000L, 1).size()); - } - - @Test - public void timelineChangeClearsPendingSequence() { - SpeechAdMatcher matcher = matcher("广告之后>马上回来,1"); - - assertTrue(matcher.accept(new SpeechAdMatcher.Recognition( - "广告之后", 0L, 1_000_000L, 1)).isEmpty()); - assertTrue(matcher.accept("马上回来", 1_000_000L, - 2_000_000L, 2).isEmpty()); - assertTrue(matcher.accept("广告之后", 3_000_000L, - 4_000_000L, 2).isEmpty()); - assertEquals(1, matcher.accept("马上回来", 4_000_000L, - 5_000_000L, 2).size()); - } - - @Test - public void enforcesCooldownForRepeatedMatches() { - SpeechAdMatcher matcher = matcher("广告,1"); - - assertEquals(1, matcher.accept("广告", 0L, 1_000_000L, 1).size()); - assertTrue(matcher.accept("广告", 20_000_000L, - 21_000_000L, 1).isEmpty()); - assertEquals(1, matcher.accept("广告", 32_000_000L, - 33_000_000L, 1).size()); - } - - @Test - public void ignoresInvalidIntervalsAndOversizedRecognition() { - SpeechAdMatcher matcher = matcher("广告,1"); - - assertTrue(matcher.accept("广告", -1L, 1_000_000L, 1).isEmpty()); - assertTrue(matcher.accept("广告", 1_000_000L, - 1_000_000L, 1).isEmpty()); - assertTrue(matcher.accept("a".repeat( - SpeechAdMatcher.MAX_RECOGNIZED_TEXT_CODE_POINTS + 1), - 2_000_000L, 3_000_000L, 1).isEmpty()); - } - - @Test - public void firstLiteralSplitAcrossResultsKeepsOriginalTimestamp() { - SpeechAdMatcher matcher = matcher("广告之后*>马上回来,[2,30]"); - assertTrue(matcher.accept("广告", 10_000_000L, 11_000_000L, 1).isEmpty()); - assertTrue(matcher.accept("之后", 12_000_000L, 13_000_000L, 1).isEmpty()); - SpeechAdMatcher.Match match = matcher.accept("马上回来", 14_000_000L, - 15_000_000L, 1).get(0); - assertEquals(10_000_000L, match.firstStartUs()); - assertEquals(8_000L, match.candidateStartMs()); - } - - @Test - public void unfinishedFirstSegmentCannotSurviveTheSequenceWindow() { - SpeechAdMatcher matcher = matcher("广告*回来,30"); - assertTrue(matcher.accept("广告", 0L, 1_000_000L, 1).isEmpty()); - assertTrue(matcher.accept("回来", 31_000_000L, 32_000_000L, 1).isEmpty()); - } - - @Test - public void sequenceWindowIncludesTheEndOfTheLastRecognition() { - SpeechAdMatcher matcher = matcher("广告>回来,30"); - assertTrue(matcher.accept("广告", 0L, 1_000_000L, 1).isEmpty()); - assertTrue(matcher.accept("回来", 29_000_000L, 31_000_000L, 1).isEmpty()); - } - - @Test - public void staleOverlappingCallbacksCannotCompleteASequence() { - SpeechAdMatcher matcher = matcher("广告>回来,30"); - assertTrue(matcher.accept("广告", 10_000_000L, 12_000_000L, 1).isEmpty()); - assertTrue(matcher.accept("回来", 11_000_000L, 13_000_000L, 1).isEmpty()); - assertEquals(1, matcher.accept("回来", 13_000_000L, 14_000_000L, 1).size()); - } - - @Test - public void anyTimelineTokenIncludingMinValueIsARealGeneration() { - SpeechAdMatcher matcher = matcher("广告>回来,30"); - assertTrue(matcher.accept("广告", 0L, 1_000_000L, Integer.MIN_VALUE).isEmpty()); - assertTrue(matcher.accept("回来", 2_000_000L, 3_000_000L, 5).isEmpty()); - } - - @Test - public void discardedRecognitionBreaksTextContinuity() { - SpeechAdMatcher matcher = matcher("广告*回来,30"); - assertTrue(matcher.accept("广告", 0L, 1_000_000L, 1).isEmpty()); - assertTrue(matcher.accept("x".repeat(4_097), 2_000_000L, 3_000_000L, 1).isEmpty()); - assertTrue(matcher.accept("回来", 4_000_000L, 5_000_000L, 1).isEmpty()); - } - - @Test - public void wildcardAllowsEmptyAndMultilineGapsButLiteralOrderMatters() { - assertEquals(1, matcher("本片*冠名,25").accept("本片冠名", 0, 1_000, 1).size()); - assertEquals(1, matcher("本片*冠名,25").accept("本片\n由品牌\n冠名", 0, 1_000, 1).size()); - assertTrue(matcher("本片*冠名,25").accept("冠名本片", 0, 1_000, 1).isEmpty()); - assertTrue(matcher("本片冠名,25").accept("本片由品牌冠名", 0, 1_000, 1).isEmpty()); - assertEquals(1, matcher("广告>回来,30").accept("广告之后马上回来", 0, 1_000, 1).size()); - } - - @Test - public void resetClearsCooldownAndSequenceState() { - SpeechAdMatcher matcher = matcher("广告,1"); - assertEquals(1, matcher.accept("广告", 0, 1_000, 1).size()); - matcher.reset(); - assertEquals(1, matcher.accept("广告", 0, 1_000, 1).size()); - matcher.reset(2); - assertEquals(2, matcher.timelineToken()); - assertEquals(1, matcher.accept("广告", 0, 1_000, 2).size()); - } - - @Test - public void cooldownBoundaryIsInclusiveAndPerRule() { - SpeechAdMatcher matcher = matcher("广告,1\n赞助,1"); - assertEquals(1, matcher.accept("广告", 0, 1_000_000L, 1).size()); - assertEquals(1, matcher.accept("赞助", 2_000_000L, 3_000_000L, 1).size()); - assertTrue(matcher.accept("广告", 30_000_000L, 30_999_999L, 1).isEmpty()); - assertEquals(1, matcher.accept("广告", 31_000_000L, 32_000_000L, 1).size()); - } - - @Test - public void boundedHistoryDropsOldTextAndStillFindsNewMatches() { - SpeechAdMatcher matcher = matcher("广告*回来,30"); - assertTrue(matcher.accept("广告", 0, 1_000, 1).isEmpty()); - for (int i = 1; i <= 4; i++) { - assertTrue(matcher.accept("x".repeat(4_096), i * 1_000L, - (i + 1) * 1_000L, 1).isEmpty()); - } - assertTrue(matcher.accept("回来", 5_000, 6_000, 1).isEmpty()); - assertEquals(1, matcher.accept("广告马上回来", 6_000, 7_000, 1).size()); - } - - private static SpeechAdMatcher matcher(String rule) { - return new SpeechAdMatcher(SpeechAdRuleCodec.parse(rule)); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdPlaybackHealthTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdPlaybackHealthTest.java deleted file mode 100644 index f439521226..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdPlaybackHealthTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -public class SpeechAdPlaybackHealthTest { - - @Test - public void stableSamplesKeepSpeechEnabled() { - AdAudioRuntimeController.SpeechAdPlaybackHealth gate = - new AdAudioRuntimeController.SpeechAdPlaybackHealth(); - - assertEquals(AdAudioRuntimeController.SpeechAdPlaybackHealth.Decision.OBSERVED, - gate.observe(0, 0, 0, 0)); - assertEquals(AdAudioRuntimeController.SpeechAdPlaybackHealth.Decision.OBSERVED, - gate.observe(5_000, 1, 0, 0)); - assertFalse(gate.isSuppressed()); - } - - @Test - public void confirmedUnderrunsSuppressSpeech() { - AdAudioRuntimeController.SpeechAdPlaybackHealth gate = - new AdAudioRuntimeController.SpeechAdPlaybackHealth(); - - gate.observe(0, 0, 0, 0); - assertEquals(AdAudioRuntimeController.SpeechAdPlaybackHealth.Decision.DEGRADED, - gate.observe(5_000, 0, 1, 0)); - assertEquals(AdAudioRuntimeController.SpeechAdPlaybackHealth.Decision.SUPPRESS, - gate.observe(10_000, 0, 2, 0)); - assertEquals(AdAudioRuntimeController.SpeechAdPlaybackHealth.Decision.SUPPRESSED, - gate.observe(15_000, 0, 3, 0)); - assertTrue(gate.isSuppressed()); - } - - @Test - public void rebufferAndDroppedFrameDeltasAreCountedOnlyOncePerSample() { - AdAudioRuntimeController.SpeechAdPlaybackHealth gate = - new AdAudioRuntimeController.SpeechAdPlaybackHealth(); - - gate.observe(0, 0, 0, 0); - assertEquals(AdAudioRuntimeController.SpeechAdPlaybackHealth.Decision.HELD, - gate.observe(1_000, 100, 0, 1)); - assertEquals(AdAudioRuntimeController.SpeechAdPlaybackHealth.Decision.DEGRADED, - gate.observe(5_000, 100, 0, 1)); - assertEquals(AdAudioRuntimeController.SpeechAdPlaybackHealth.Decision.SUPPRESS, - gate.observe(10_000, 120, 0, 1)); - assertTrue(gate.isSuppressed()); - } - - @Test - public void resetStartsAFreshPlaybackSession() { - AdAudioRuntimeController.SpeechAdPlaybackHealth gate = - new AdAudioRuntimeController.SpeechAdPlaybackHealth(); - gate.observe(0, 0, 0, 0); - gate.observe(5_000, 0, 1, 0); - gate.observe(10_000, 0, 2, 0); - assertTrue(gate.isSuppressed()); - - gate.reset(); - assertFalse(gate.isSuppressed()); - assertEquals(AdAudioRuntimeController.SpeechAdPlaybackHealth.Decision.OBSERVED, - gate.observe(0, 0, 0, 0)); - } - - @Test - public void longTelemetryGapDiscardsOldEvidence() { - AdAudioRuntimeController.SpeechAdPlaybackHealth gate = - new AdAudioRuntimeController.SpeechAdPlaybackHealth(); - gate.observe(0, 0, 0, 0); - gate.observe(5_000, 0, 1, 0); - - assertEquals(AdAudioRuntimeController.SpeechAdPlaybackHealth.Decision.OBSERVED, - gate.observe(20_001, 0, 2, 0)); - assertFalse(gate.isSuppressed()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdRuleCodecTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdRuleCodecTest.java deleted file mode 100644 index bb8911bc84..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdRuleCodecTest.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.fail; - -import org.junit.Test; - -import java.util.Collections; -import java.util.List; - -public class SpeechAdRuleCodecTest { - - @Test - public void parsesBomCommentsBothCommasAndRangeSeparator() { - String source = "\uFEFF# default rules\n" - + "// ignored line\n" - + "广告之后*>马上回来,30 // trailing comment\n" - + "中场休息*>广告时间,[2,30]\n" - + "广告之后马上回来,30\n"; - - SpeechAdRuleSet rules = SpeechAdRuleCodec.parse(source); - - assertEquals(3, rules.rules().size()); - assertEquals("广告之后*>马上回来,30", rules.rules().get(0).canonical()); - assertEquals(0L, rules.rules().get(0).preRollMs()); - assertEquals(30_000L, rules.rules().get(0).postRollMs()); - assertEquals("中场休息*>广告时间,[2,30]", rules.rules().get(1).canonical()); - assertEquals(2_000L, rules.rules().get(1).preRollMs()); - assertEquals(30_000L, rules.rules().get(1).postRollMs()); - assertEquals(SpeechAdRuleCodec.serialize(rules), rules.toText()); - } - - @Test - public void normalizesAndDeduplicatesEquivalentRules() { - SpeechAdRuleSet rules = SpeechAdRuleCodec.parse( - "DOWNLOAD,1\n download,1\n下载,1"); - - assertEquals(2, rules.rules().size()); - assertEquals("download,1", rules.rules().get(0).canonical()); - assertEquals(rules.rules().get(0), rules.find(rules.rules().get(0).id())); - } - - @Test - public void acceptsEmptyInputAndKeepsRulesImmutable() { - assertTrue(SpeechAdRuleCodec.parse(null).isEmpty()); - assertTrue(SpeechAdRuleCodec.parse("").isEmpty()); - - SpeechAdRuleSet rules = SpeechAdRuleCodec.parse("广告,1"); - try { - rules.rules().add(rules.rules().get(0)); - fail("rules must be immutable"); - } catch (UnsupportedOperationException expected) { - assertFalse(rules.isEmpty()); - } - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsMissingAction() { - SpeechAdRuleCodec.parse("广告之后"); - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsMalformedRangeAction() { - SpeechAdRuleCodec.parse("广告,[2]"); - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsNegativeAction() { - SpeechAdRuleCodec.parse("广告,-1"); - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsActionBeyondBound() { - SpeechAdRuleCodec.parse("广告,121"); - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsEmptyAndWildcardOnlySegments() { - SpeechAdRuleCodec.parse("广告>>马上回来,1"); - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsTooManySegments() { - SpeechAdRuleCodec.parse(String.join(">", Collections.nCopies(9, "句")) + ",1"); - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsWildcardOnlyRule() { - SpeechAdRuleCodec.parse("*,1"); - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsTooManyRules() { - StringBuilder source = new StringBuilder(); - for (int i = 0; i < SpeechAdRuleCodec.MAX_RULES + 1; i++) { - source.append("广告").append(i).append(",1\n"); - } - SpeechAdRuleCodec.parse(source.toString()); - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsOversizedDocument() { - SpeechAdRuleCodec.parse("广告,1\n".repeat(20_000)); - } - - @Test(expected = IllegalArgumentException.class) - public void rejectsSubsecondRollInProgrammaticRule() { - SpeechAdRule.Segment segment = new SpeechAdRule.Segment( - List.of("广告"), List.of(), false, false); - new SpeechAdRule("speech-test", List.of(segment), 500L, 0L); - } - - @Test - public void canonicalTextRoundTripsIncludingRangesAndRepeatedWildcards() { - SpeechAdRuleSet rules = SpeechAdRuleCodec.parse("本片**冠名,[4,30]\n广告*>回来[2,35]"); - SpeechAdRuleSet restored = SpeechAdRuleCodec.parse(rules.toText()); - assertEquals(rules.toText(), restored.toText()); - assertEquals(rules.rules().get(0).id(), restored.rules().get(0).id()); - assertEquals("本片*冠名,[4,30]", rules.rules().get(0).canonical()); - } - - @Test - public void segmentLimitAppliesToTheWholePatternNotEachLiteral() { - assertThrows(IllegalArgumentException.class, - () -> SpeechAdRuleCodec.parse("a".repeat(80) + "*" + "b".repeat(80) + ",1")); - assertEquals(1, SpeechAdRuleCodec.parse("a".repeat(128) + ",1").rules().size()); - } - - @Test - public void malformedSuffixesRejectTheWholeDocumentWithLineNumber() { - for (String invalid : List.of("广告,1,2", "广告,[2,3],4", "广告,1.5", "广告,+1", - "广告,[2,]", "广告,1e2", "广告,[0,121]", "广告>>回来,1", "***,1")) { - IllegalArgumentException error = assertThrows(invalid, IllegalArgumentException.class, - () -> SpeechAdRuleCodec.parse("正常,1\n" + invalid)); - assertTrue(error.getMessage(), error.getMessage().startsWith("line 2:")); - } - } - - @Test - public void inputLimitCountsUtf8BytesAndDoesNotLogRuleText() { - String oversized = "#" + "字".repeat(22_000); - IllegalArgumentException error = assertThrows(IllegalArgumentException.class, - () -> SpeechAdRuleCodec.parse(oversized)); - assertFalse(error.getMessage().contains("字")); - } - - @Test - public void programmaticAdjacentLiteralsBehaveLikeCanonicalText() { - SpeechAdRule.Segment segment = new SpeechAdRule.Segment( - List.of("a", "b"), List.of(false), false, false); - assertEquals(3, segment.find("ax ab", 0).start()); - assertEquals(5, segment.find("ax ab", 0).end()); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdRuntimeEndToEndTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdRuntimeEndToEndTest.java deleted file mode 100644 index 40603fa451..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdRuntimeEndToEndTest.java +++ /dev/null @@ -1,240 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.player.audio.PlaybackMediaClock; -import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; -import com.fongmi.android.tv.subtitle.SpeechRecognitionFactory; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; - -public class SpeechAdRuntimeEndToEndTest { - - @Test - public void realHubAndRuntimeHonorPromptAutoUndoAndTimelineIsolation() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(8); - PlaybackMediaSignalHub.Session session = hub.beginSession(0L); - FakePlaybackPort playback = new FakePlaybackPort(hub); - FakeUiPort ui = new FakeUiPort(); - FakeRecognitionFactory recognition = new FakeRecognitionFactory(); - AtomicReference config = new AtomicReference<>( - SpeechAdConfig.create(true, "赌场", 15, "PROMPT")); - - AdAudioRuntimeController runtime = new AdAudioRuntimeController( - hub, new PlaybackMediaClock(500L), - SpeechAdRuntimeEndToEndTest::emptySnapshot, playback, - Runnable::run, () -> { }, - ignored -> new NoopAdAudioSignalProvider("probe"), - () -> new SpeechAdSignalProvider( - hub, recognition, config::get, Runnable::run, - new AdAudioDiagnostics())); - - runtime.setSpeechConfig(config.get()); - runtime.start(false); - runtime.bindUi(ui); - assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - assertEquals(AdSkipPolicyController.Mode.PROMPT, runtime.skipMode()); - - FakeRecognitionSession promptSession = recognition.current(); - hub.publishPcm(session.frame(new float[] {0.1f}, 16_000, 1_000L)); - promptSession.emit("欢迎来到赌场", promptSession.lastTimelineToken); - - assertEquals(1, ui.candidateShows); - assertTrue(playback.seekTargets.isEmpty()); - ui.confirm(); - assertEquals(List.of(16_000L), playback.seekTargets); - ui.undo(); - assertEquals(List.of(16_000L, 1_000L), playback.seekTargets); - - config.set(SpeechAdConfig.create(true, "赌场", 15, "AUTO")); - runtime.setSpeechConfig(config.get()); - assertEquals(1, promptSession.closeCalls); - assertEquals(AdSkipPolicyController.Mode.PROMPT, runtime.skipMode()); - - promptSession.emit("赌场", promptSession.lastTimelineToken); - assertEquals(List.of(16_000L, 1_000L), playback.seekTargets); - assertEquals(1, ui.candidateShows); - - FakeRecognitionSession autoSession = recognition.current(); - hub.publishPcm(session.frame(new float[] {0.2f}, 16_000, 1_100L)); - autoSession.emit("赌场", autoSession.lastTimelineToken); - - assertEquals(1, ui.candidateShows); - assertEquals(List.of(16_000L, 1_000L, 16_100L), playback.seekTargets); - ui.undo(); - assertEquals(List.of(16_000L, 1_000L, 16_100L, 1_000L), playback.seekTargets); - - int staleTimelineToken = autoSession.lastTimelineToken; - playback.positionMs = 30_000L; - PlaybackMediaSignalHub.Session reset = hub.resetTimeline( - 30_000L, PlaybackMediaSignalHub.ResetReason.SEEK); - assertEquals(1, autoSession.resetCalls); - - autoSession.emit("赌场", staleTimelineToken); - assertEquals(4, playback.seekTargets.size()); - assertEquals(1, ui.candidateShows); - - runtime.refresh(); - // The recognizer is reused across the reset instead of being rebuilt; isolation - // comes from the bumped timeline token plus session.reset(), not from a new session. - FakeRecognitionSession resumedSession = recognition.current(); - assertTrue(autoSession == resumedSession); - assertEquals(0, autoSession.closeCalls); - assertEquals(1, autoSession.resetCalls); - - autoSession.emit("赌场", staleTimelineToken); - assertEquals(4, playback.seekTargets.size()); - assertEquals(1, ui.candidateShows); - - hub.publishPcm(reset.frame(new float[] {0.3f}, 16_000, 30_000L)); - resumedSession.emit("赌场", resumedSession.lastTimelineToken); - - assertEquals(List.of(16_000L, 1_000L, 16_100L, 1_000L, 45_000L), - playback.seekTargets); - ui.undo(); - assertEquals(List.of(16_000L, 1_000L, 16_100L, 1_000L, 45_000L, 30_000L), - playback.seekTargets); - - runtime.close(); - hub.close(); - } - - private static AdAudioRuleSnapshot emptySnapshot() { - return new AdAudioRuleSnapshot( - "test", "", AudioFingerprintRuleSet.empty(), List.of(), ""); - } - - private static final class FakePlaybackPort - implements AdAudioRuntimeController.PlaybackPort { - private final PlaybackMediaSignalHub hub; - private final List seekTargets = new ArrayList<>(); - private long positionMs = 1_000L; - - private FakePlaybackPort(PlaybackMediaSignalHub hub) { - this.hub = hub; - } - - @Override - public boolean isEligible(long sessionId, long generation) { - PlaybackMediaSignalHub.Session current = hub.session(); - return current.id() == sessionId && current.generation() == generation; - } - - @Override - public AdSkipCoordinator.PlaybackSnapshot snapshot( - long sessionId, long generation) { - return new AdSkipCoordinator.PlaybackSnapshot( - sessionId, generation, positionMs, 120_000L, true, false, - new PlaybackMediaClock.Snapshot( - generation, 0L, positionMs, positionMs, true, true)); - } - - @Override - public AdSkipCoordinator.SeekResult seekTo( - long sessionId, long generation, long positionMs) { - PlaybackMediaSignalHub.Session current = hub.session(); - boolean applied = current.id() == sessionId - && current.generation() == generation; - if (applied) seekTargets.add(positionMs); - return new AdSkipCoordinator.SeekResult( - applied, current.id(), current.generation()); - } - } - - private static final class FakeUiPort implements AdSkipCoordinator.UiPort { - private int candidateShows; - private AdSkipCoordinator.Actions candidateActions; - private AdSkipCoordinator.Actions undoActions; - - @Override - public void showCandidate( - AdSkipCoordinator.Prompt prompt, - AdSkipCoordinator.Actions actions) { - candidateShows++; - candidateActions = actions; - } - - @Override - public void showUndo( - AdSkipCoordinator.UndoPrompt prompt, - AdSkipCoordinator.Actions actions) { - undoActions = actions; - } - - @Override - public void dismiss(long sessionId) { - } - - private void confirm() { - candidateActions.confirm(); - } - - private void undo() { - undoActions.undo(); - } - } - - private static final class FakeRecognitionFactory - implements SpeechRecognitionFactory { - private final List sessions = new ArrayList<>(); - - @Override - public boolean isReady() { - return true; - } - - @Override - public Session create(Listener listener) { - FakeRecognitionSession session = new FakeRecognitionSession(listener); - sessions.add(session); - return session; - } - - private FakeRecognitionSession current() { - return sessions.get(sessions.size() - 1); - } - } - - private static final class FakeRecognitionSession - implements SpeechRecognitionFactory.Session { - private final SpeechRecognitionFactory.Listener listener; - private int lastTimelineToken; - private long lastStartUs; - private long lastEndUs; - private int resetCalls; - private int closeCalls; - - private FakeRecognitionSession(SpeechRecognitionFactory.Listener listener) { - this.listener = listener; - } - - @Override - public void accept(float[] samples, long startUs, long endUs, - int timelineToken) { - lastTimelineToken = timelineToken; - lastStartUs = startUs; - lastEndUs = endUs; - } - - @Override - public void reset() { - resetCalls++; - } - - @Override - public void close() { - closeCalls++; - } - - /** Echoes the capture window of the last accepted frame, like the real recognizer. */ - private void emit(String text, int timelineToken) { - listener.onResult(text, lastStartUs, lastEndUs, timelineToken); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdSignalProviderTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdSignalProviderTest.java deleted file mode 100644 index 57ae82418a..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/SpeechAdSignalProviderTest.java +++ /dev/null @@ -1,868 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import com.fongmi.android.tv.player.audio.PlaybackMediaSignalHub; -import com.fongmi.android.tv.subtitle.SpeechRecognitionFactory; - -import org.junit.Test; - -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Queue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executor; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -public class SpeechAdSignalProviderTest { - - @Test - public void keywordMatchEmitsBoundedSpeechCandidate() { - Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); - fixture.host(10_000L, 20_000L, true, false); - assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, fixture.provider.state()); - assertTrue(fixture.hub.isCaptureRequested( - PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - - fixture.publish(new float[] {0.1f, 0.2f}, 8_000, 10_000L); - FakeSession recognizer = fixture.factory.current(); - assertEquals(4, recognizer.acceptedSamples.get(0).length); - assertEquals(10_000_000L, recognizer.lastStartUs); - assertEquals(10_000_250L, recognizer.lastEndUs); - assertEquals(SpeechRecognitionFactory.ExecutionProfile.AD_AUDIO, - fixture.factory.lastProfile); - recognizer.emit("\u6b22\u8fce\u6765\u5230\u8d4c\u573a", recognizer.lastTimelineToken()); - - assertEquals(1, fixture.emitted.size()); - AdAudioSignalProvider.AdAudioCandidate candidate = fixture.emitted.get(0); - assertEquals(SpeechAdSignalProvider.ID, candidate.providerId()); - assertEquals(SpeechAdSignalProvider.RULE_ID, candidate.ruleId()); - assertEquals("v1", candidate.ruleVersion()); - assertEquals(10_000L, candidate.startMs()); - assertEquals(25_000L, candidate.endMs()); - assertTrue(candidate.fullMatch()); - assertEquals(1.0d, candidate.similarity(), 0.0d); - assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.SPEECH_MATCHED)); - fixture.close(); - } - - @Test - public void compoundRuleUsesRecognitionEndAndRemainsAnApproximateCandidate() { - SpeechAdConfig config = SpeechAdConfig.createWithRules( - true, "", "广告之后*>马上回来,[2,30]", 15, "AUTO"); - Fixture fixture = new Fixture(config, Runnable::run, 8); - fixture.host(1_000L, 60_000L, true, false); - fixture.publish(new float[] {0.1f}, 16_000, 10_000L); - FakeSession recognizer = fixture.factory.current(); - - recognizer.emitAt("广告", 10_000_000L, 11_000_000L, - recognizer.lastTimelineToken); - recognizer.emitAt("之后", 12_000_000L, 13_000_000L, - recognizer.lastTimelineToken); - recognizer.emitAt("马上回来", 14_000_000L, 15_000_000L, - recognizer.lastTimelineToken); - - assertEquals(1, fixture.emitted.size()); - AdAudioSignalProvider.AdAudioCandidate candidate = fixture.emitted.get(0); - assertEquals(SpeechAdRuleCodec.parse("广告之后*>马上回来,[2,30]").rules().get(0).id(), - candidate.ruleId()); - assertEquals(8_000L, candidate.startMs()); - assertEquals(45_000L, candidate.endMs()); - assertFalse(candidate.fullMatch()); - assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.SPEECH_MATCHED)); - fixture.close(); - } - - @Test - public void compoundRuleResetsOnTimelineAndDoesNotJoinStaleText() { - SpeechAdConfig config = SpeechAdConfig.createWithRules( - true, "", "广告>回来,30", 15, "PROMPT"); - Fixture fixture = new Fixture(config, Runnable::run, 8); - fixture.host(1_000L, 60_000L, true, false); - fixture.publish(new float[] {0.1f}, 16_000, 10_000L); - FakeSession recognizer = fixture.factory.current(); - int staleTimeline = recognizer.lastTimelineToken; - recognizer.emitAt("广告", 10_000_000L, 11_000_000L, staleTimeline); - - PlaybackMediaSignalHub.Session reset = fixture.hub.resetTimeline( - 20_000L, PlaybackMediaSignalHub.ResetReason.SEEK); - fixture.provider.onHostPosition(host(reset, 20_000L, 60_000L, true, false)); - recognizer.emitAt("回来", 12_000_000L, 13_000_000L, staleTimeline); - assertTrue(fixture.emitted.isEmpty()); - - fixture.hub.publishPcm(reset.frame(new float[] {0.1f}, 16_000, 20_000L)); - int currentTimeline = recognizer.lastTimelineToken; - recognizer.emitAt("广告", 20_000_000L, 21_000_000L, currentTimeline); - recognizer.emitAt("回来", 22_000_000L, 23_000_000L, currentTimeline); - assertEquals(1, fixture.emitted.size()); - fixture.close(); - } - - @Test - public void providerDoesNotRunWhenDisabledModelKeywordsOrVodClockAreInvalid() { - Fixture missingModel = new Fixture(false, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); - missingModel.host(1_000L, 20_000L, true, false); - assertEquals(AdAudioSignalProvider.ProviderState.DEGRADED, missingModel.provider.state()); - assertEquals(1L, missingModel.diagnostics.count( - AdAudioDiagnostics.Code.SPEECH_MODEL_UNAVAILABLE)); - assertFalse(missingModel.hub.isCaptureRequested( - PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - missingModel.close(); - - Fixture disabled = new Fixture(true, config(false, "\u8d4c\u573a", 15), Runnable::run, 8); - disabled.host(1_000L, 20_000L, true, false); - assertEquals(AdAudioSignalProvider.ProviderState.DISABLED, disabled.provider.state()); - assertFalse(disabled.hub.isCaptureRequested( - PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - disabled.close(); - - Fixture emptyKeywords = new Fixture(true, config(true, "", 15), Runnable::run, 8); - emptyKeywords.host(1_000L, 20_000L, true, false); - assertNotEquals(AdAudioSignalProvider.ProviderState.RUNNING, - emptyKeywords.provider.state()); - emptyKeywords.close(); - - Fixture live = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); - live.host(1_000L, 20_000L, true, true); - assertNotEquals(AdAudioSignalProvider.ProviderState.RUNNING, live.provider.state()); - live.close(); - - Fixture unseekable = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); - unseekable.host(1_000L, 20_000L, false, false); - assertNotEquals(AdAudioSignalProvider.ProviderState.RUNNING, unseekable.provider.state()); - unseekable.close(); - - Fixture unknownDuration = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); - unknownDuration.host(1_000L, -1L, true, false); - assertNotEquals(AdAudioSignalProvider.ProviderState.RUNNING, - unknownDuration.provider.state()); - unknownDuration.close(); - } - - @Test - public void modelReadinessRunsOnOwnerInsteadOfHostPositionCallback() { - ManualExecutor worker = new ManualExecutor(); - Fixture fixture = new Fixture(true, config(true, "赌场", 15), worker, 8); - - fixture.host(1_000L, 20_000L, true, false); - - assertEquals(0, fixture.factory.readyCalls); - assertTrue(fixture.factory.sessions.isEmpty()); - worker.runAll(); - assertEquals(1, fixture.factory.readyCalls); - assertEquals(1, fixture.factory.sessions.size()); - fixture.close(); - } - - @Test - public void resetDropsLateWorkAndContinuesWithTheSameRecognizerSession() { - ManualExecutor worker = new ManualExecutor(); - Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), worker, 4); - fixture.host(10_000L, 60_000L, true, false); - // Session creation is an owner-worker command in Phase 2A. - worker.runAll(); - FakeSession recognizer = fixture.factory.current(); - fixture.publish(new float[] {0.1f}, 16_000, 10_000L); - worker.runAll(); - int oldTimelineToken = recognizer.lastTimelineToken(); - fixture.publish(new float[] {0.2f}, 16_000, 10_100L); - - PlaybackMediaSignalHub.Session resetSession = fixture.hub.resetTimeline( - 30_000L, PlaybackMediaSignalHub.ResetReason.SEEK); - worker.runAll(); - recognizer.emit("\u8d4c\u573a", oldTimelineToken); - - assertTrue(fixture.emitted.isEmpty()); - assertEquals(1, recognizer.resetCalls); - assertEquals(0, recognizer.closeCalls); - assertEquals(1, recognizer.acceptedSamples.size()); - assertTrue(fixture.diagnostics.count(AdAudioDiagnostics.Code.STALE_GENERATION) >= 1L); - assertTrue(fixture.diagnostics.count( - AdAudioDiagnostics.Code.SPEECH_STALE_CALLBACK) >= 1L); - assertTrue(fixture.hub.isCaptureRequested( - PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - - fixture.provider.onHostPosition(host( - resetSession, 30_000L, 60_000L, true, false)); - fixture.hub.publishPcm(resetSession.frame( - new float[] {0.3f}, 16_000, 30_000L)); - worker.runAll(); - assertEquals(2, recognizer.acceptedSamples.size()); - recognizer.emit("\u8d4c\u573a", recognizer.lastTimelineToken()); - worker.runAll(); - - assertEquals(1, fixture.emitted.size()); - assertEquals(resetSession.generation(), fixture.emitted.get(0).generation()); - assertEquals(30_000L, fixture.emitted.get(0).startMs()); - fixture.close(); - } - @Test - public void cooldownSuppressesPartialAndFinalDuplicates() { - Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); - fixture.host(10_000L, 60_000L, true, false); - fixture.publish(new float[] {0.1f}, 16_000, 10_000L); - FakeSession recognizer = fixture.factory.current(); - - recognizer.emit("\u8d4c\u573a", recognizer.lastTimelineToken()); - fixture.host(20_000L, 60_000L, true, false); - recognizer.emit("\u6b22\u8fce\u6765\u5230\u8d4c\u573a", recognizer.lastTimelineToken()); - - assertEquals(1, fixture.emitted.size()); - assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.SPEECH_COOLDOWN)); - fixture.close(); - } - - @Test - public void candidateTracksSpokenMomentNotTheLastPublishedHostPosition() { - Fixture fixture = new Fixture(true, config(true, "赌场", 15), Runnable::run, 8); - // Host position is published once when playback becomes READY and is not pumped - // again during steady playback. - fixture.host(0L, 2_400_000L, true, false); - - // 15 minutes later the keyword is spoken; the capture clock knows this, the cached - // host position does not. - fixture.publish(new float[] {0.1f}, 16_000, 900_000L); - FakeSession recognizer = fixture.factory.current(); - recognizer.emitAt("赌场", 900_000_000L, 901_000_000L, recognizer.lastTimelineToken()); - - assertEquals(1, fixture.emitted.size()); - assertEquals(900_000L, fixture.emitted.get(0).startMs()); - assertEquals(915_000L, fixture.emitted.get(0).endMs()); - fixture.close(); - } - - @Test - public void ineligiblePositionParksTheProviderWithoutTearingDownTheRecognizer() { - Fixture fixture = new Fixture(true, config(true, "赌场", 15), Runnable::run, 8); - fixture.host(10_000L, 20_000L, true, false); - assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, fixture.provider.state()); - FakeSession running = fixture.factory.current(); - - // Buffering: duration momentarily unknown. This happens repeatedly during normal - // playback, so it must not destroy the recognizer or drop the capture lease -- - // rebuilding sherpa on every buffering blip loses the in-flight utterance and - // forces the audio pipeline to be re-attached. - fixture.host(10_000L, -1L, false, false); - - assertEquals(AdAudioSignalProvider.ProviderState.IDLE, fixture.provider.state()); - assertEquals(0, running.closeCalls); - assertEquals(1, fixture.factory.sessions.size()); - assertTrue(fixture.hub.isCaptureRequested( - PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - - // Recovering must resume the same session rather than start a new one. - fixture.host(11_000L, 20_000L, true, false); - assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, fixture.provider.state()); - assertEquals(1, fixture.factory.sessions.size()); - fixture.close(); - } - - @Test - public void mismatchedHostGenerationIsIgnoredBeforeActivation() { - Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); - - fixture.provider.onHostPosition(new AdAudioSignalProvider.HostPosition( - fixture.session.id(), fixture.session.generation() + 1L, - 1_000L, 20_000L, true, false)); - - assertNotEquals(AdAudioSignalProvider.ProviderState.RUNNING, fixture.provider.state()); - assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.STALE_GENERATION)); - fixture.host(1_000L, 20_000L, true, false); - assertEquals(AdAudioSignalProvider.ProviderState.RUNNING, fixture.provider.state()); - fixture.close(); - } - - @Test - public void candidateUsesCaptureTimeAndLeavesDurationClampingToTheCoordinator() { - Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 120), Runnable::run, 8); - fixture.host(59_500L, 60_000L, true, false); - fixture.publish(new float[] {0.1f}, 16_000, 59_500L); - FakeSession recognizer = fixture.factory.current(); - - recognizer.emit("\u8d4c\u573a", recognizer.lastTimelineToken()); - - // The provider reports the raw capture interval, exactly like the fingerprint - // provider. AdSkipCoordinator.targetFor is the single place that clamps to - // duration, so clamping here would double-apply it. - assertEquals(1, fixture.emitted.size()); - assertEquals(59_500L, fixture.emitted.get(0).startMs()); - assertEquals(179_500L, fixture.emitted.get(0).endMs()); - fixture.close(); - } - - @Test - public void factoryAndRecognizerFailuresAreIsolated() { - Fixture createFailure = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); - createFailure.factory.throwOnCreate = true; - createFailure.host(1_000L, 20_000L, true, false); - assertEquals(AdAudioSignalProvider.ProviderState.DEGRADED, - createFailure.provider.state()); - assertEquals(1L, createFailure.diagnostics.count( - AdAudioDiagnostics.Code.SPEECH_START_FAILED)); - createFailure.close(); - - Fixture acceptFailure = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); - acceptFailure.host(1_000L, 20_000L, true, false); - acceptFailure.factory.current().throwOnAccept = true; - acceptFailure.publish(new float[] {0.1f}, 16_000, 1_000L); - acceptFailure.factory.current().fail(); - assertTrue(acceptFailure.diagnostics.count(AdAudioDiagnostics.Code.MATCHER_ERROR) >= 2L); - acceptFailure.close(); - } - - @Test - public void listenerExceptionsAreIsolatedWithoutLeakingRecognizedText() { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); - PlaybackMediaSignalHub.Session session = hub.beginSession(0L); - FakeRecognizerFactory factory = new FakeRecognizerFactory(true); - AdAudioDiagnostics diagnostics = new AdAudioDiagnostics(); - SpeechAdSignalProvider provider = new SpeechAdSignalProvider( - hub, factory, () -> config(true, "\u8d4c\u573a", 15), Runnable::run, diagnostics); - AdAudioSignalProvider.Listener throwing = new AdAudioSignalProvider.Listener() { - @Override - public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { - throw new IllegalStateException("listener failed"); - } - - @Override - public void onProviderError(AdAudioSignalProvider.ProviderError error) { - throw new IllegalStateException("listener failed"); - } - - @Override - public void onTimelineReset(AdAudioSignalProvider.TimelineReset reset) { - throw new IllegalStateException("listener failed"); - } - }; - - provider.setEnabled(true); - provider.start(context(session), rules(), throwing); - provider.onHostPosition(host(session, 1_000L, 20_000L, true, false)); - hub.publishPcm(session.frame(new float[] {0.1f}, 16_000, 1_000L)); - factory.current().emit("\u8d4c\u573a", factory.current().lastTimelineToken()); - provider.onTimelineReset(new AdAudioSignalProvider.TimelineReset( - session.id(), session.generation() + 1L, - AdAudioSignalProvider.ResetReason.SEEK, 2_000L)); - - assertTrue(diagnostics.count(AdAudioDiagnostics.Code.MATCHER_ERROR) >= 2L); - provider.close(); - hub.close(); - } - - @Test - public void mailboxEvictsOldestPcmAndKeepsNewestFrames() { - ManualExecutor worker = new ManualExecutor(); - Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), worker, 2); - fixture.host(1_000L, 20_000L, true, false); - worker.runAll(); - - fixture.publish(new float[] {1.0f}, 16_000, 1_000L); - fixture.publish(new float[] {2.0f}, 16_000, 1_100L); - fixture.publish(new float[] {3.0f}, 16_000, 1_200L); - assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.QUEUE_OVERFLOW)); - worker.runAll(); - - FakeSession recognizer = fixture.factory.current(); - assertEquals(2, recognizer.acceptedSamples.size()); - assertEquals(2.0f, recognizer.acceptedSamples.get(0)[0], 0.0f); - assertEquals(3.0f, recognizer.acceptedSamples.get(1)[0], 0.0f); - assertEquals(1, recognizer.resetCalls); - fixture.close(); - } - - @Test(timeout = 10_000) - public void slowAcceptDoesNotBlockResetOrCloseAndNativeCallsRemainSerialized() - throws Exception { - PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); - PlaybackMediaSignalHub.Session session = hub.beginSession(0L); - FakeRecognizerFactory factory = new FakeRecognizerFactory(true); - ExecutorService owner = Executors.newSingleThreadExecutor(); - ExecutorService caller = Executors.newSingleThreadExecutor(); - AdAudioDiagnostics diagnostics = new AdAudioDiagnostics(); - List emitted = new ArrayList<>(); - SpeechAdSignalProvider provider = new SpeechAdSignalProvider( - hub, factory, () -> config(true, "赌场", 15), owner, diagnostics); - FakeSession recognizer = null; - try { - provider.setEnabled(true); - provider.start(context(session), rules(), listener( - emitted, new ArrayList<>(), new ArrayList<>())); - provider.onHostPosition(host(session, 1_000L, 20_000L, true, false)); - owner.submit(() -> { }).get(2, TimeUnit.SECONDS); - recognizer = factory.current(); - recognizer.blockAccept(); - - caller.submit(() -> hub.publishPcm(session.frame(new float[] {0.1f}, 16_000, 1_000L))) - .get(2, TimeUnit.SECONDS); - assertTrue(recognizer.acceptStarted.await(2, TimeUnit.SECONDS)); - caller.submit(() -> { - provider.onHostPosition(host(session, 1_200L, 20_000L, true, false)); - hub.publishPcm(session.frame(new float[] {0.2f}, 16_000, 1_200L)); - hub.resetTimeline(2_000L, PlaybackMediaSignalHub.ResetReason.SEEK); - }).get(2, TimeUnit.SECONDS); - int staleToken = recognizer.lastTimelineToken; - recognizer.emit("赌场", staleToken); - assertEquals(0, recognizer.resetCalls); - recognizer.allowAccept.countDown(); - owner.submit(() -> { }).get(2, TimeUnit.SECONDS); - assertEquals(1, recognizer.resetCalls); - assertFalse(recognizer.concurrentNativeCall.get()); - assertTrue(emitted.isEmpty()); - - PlaybackMediaSignalHub.Session resetSession = hub.session(); - provider.onHostPosition(host(resetSession, 2_000L, 20_000L, true, false)); - recognizer.blockAccept(); - caller.submit(() -> hub.publishPcm(resetSession.frame(new float[] {0.3f}, 16_000, 2_000L))) - .get(2, TimeUnit.SECONDS); - assertTrue(recognizer.acceptStarted.await(2, TimeUnit.SECONDS)); - caller.submit(provider::close).get(2, TimeUnit.SECONDS); - owner.shutdown(); // Exact production ordering: cleanup is still inside the owner. - assertEquals(0, recognizer.closeCalls); - recognizer.emit("赌场", recognizer.lastTimelineToken); - recognizer.allowAccept.countDown(); - assertTrue(owner.awaitTermination(2, TimeUnit.SECONDS)); - assertFalse(recognizer.concurrentNativeCall.get()); - assertEquals(1, recognizer.closeCalls); - assertTrue(emitted.isEmpty()); - assertFalse(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - assertEquals(1L, diagnostics.count(AdAudioDiagnostics.Code.SPEECH_CLOSE_PENDING)); - assertEquals(1L, diagnostics.count(AdAudioDiagnostics.Code.SPEECH_CLOSED)); - assertEquals(0L, diagnostics.count(AdAudioDiagnostics.Code.SPEECH_OWNER_REJECTED)); - } finally { - if (recognizer != null && recognizer.allowAccept != null) recognizer.allowAccept.countDown(); - provider.close(); - owner.shutdown(); - caller.shutdownNow(); - owner.awaitTermination(2, TimeUnit.SECONDS); - hub.close(); - } - } - - @Test(timeout = 10_000) - public void closeFinishesPendingSubmissionBeforeTheRuntimeCanShutdownTheOwner() - throws Exception { - ExecutorService owner = Executors.newSingleThreadExecutor(); - ExecutorService callers = Executors.newFixedThreadPool(2); - AtomicBoolean pauseSubmission = new AtomicBoolean(); - CountDownLatch submitting = new CountDownLatch(1); - CountDownLatch allowSubmission = new CountDownLatch(1); - CountDownLatch closeEntered = new CountDownLatch(1); - CountDownLatch closeReturned = new CountDownLatch(1); - Executor handoff = command -> { - if (pauseSubmission.getAndSet(false)) { - submitting.countDown(); - try { - if (!allowSubmission.await(5, TimeUnit.SECONDS)) { - throw new IllegalStateException("submission timed out"); - } - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(error); - } - } - owner.execute(command); - }; - Fixture fixture = new Fixture(true, config(true, "赌场", 15), handoff, 4); - try { - fixture.host(1_000L, 20_000L, true, false); - owner.submit(() -> { }).get(2, TimeUnit.SECONDS); - FakeSession session = fixture.factory.current(); - pauseSubmission.set(true); - var publish = callers.submit(() -> fixture.publish(new float[] {0.1f}, 16_000, 1_000L)); - assertTrue(submitting.await(2, TimeUnit.SECONDS)); - var close = callers.submit(() -> { - closeEntered.countDown(); - fixture.provider.close(); - owner.shutdown(); - closeReturned.countDown(); - }); - assertTrue(closeEntered.await(2, TimeUnit.SECONDS)); - // Native isn't running here: the only pending work is the executor handoff. - assertFalse(closeReturned.await(200, TimeUnit.MILLISECONDS)); - assertFalse(owner.isShutdown()); - allowSubmission.countDown(); - publish.get(2, TimeUnit.SECONDS); - close.get(2, TimeUnit.SECONDS); - assertTrue(owner.awaitTermination(2, TimeUnit.SECONDS)); - assertEquals(1, session.closeCalls); - assertFalse(fixture.hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - assertEquals(0L, fixture.diagnostics.count(AdAudioDiagnostics.Code.SPEECH_OWNER_REJECTED)); - } finally { - allowSubmission.countDown(); - fixture.close(); - owner.shutdown(); - callers.shutdownNow(); - owner.awaitTermination(2, TimeUnit.SECONDS); - } - } - - @Test - public void pcmGapRejectsInFlightTextAndResetsBeforeJoiningNewAudio() { - ManualExecutor owner = new ManualExecutor(); - Fixture fixture = new Fixture(true, config(true, "赌场", 15), owner, 2); - fixture.host(1_000L, 20_000L, true, false); - owner.runAll(); - FakeSession recognizer = fixture.factory.current(); - recognizer.joinSpeech = true; - fixture.publish(new float[] {1f}, 16_000, 1_000L); // 赌 - owner.runAll(); - int previousTimeline = recognizer.lastTimelineToken; - fixture.publish(new float[] {0f}, 16_000, 1_100L); - fixture.publish(new float[] {0f}, 16_000, 1_200L); - fixture.publish(new float[] {3f}, 16_000, 1_300L); // 场, after a dropped frame - recognizer.emitAt("赌场", 1_000_000L, 1_300_000L, previousTimeline); - owner.runAll(); - assertTrue(fixture.emitted.isEmpty()); - assertEquals(1, recognizer.resetCalls); - assertNotEquals(previousTimeline, recognizer.lastTimelineToken); - assertEquals(2L, fixture.diagnostics.count(AdAudioDiagnostics.Code.SPEECH_PCM_QUEUE_PEAK)); - fixture.close(); - } - - @Test - public void resetStormIsCoalescedAndRejectedInputDoesNotEnterNative() { - ManualExecutor owner = new ManualExecutor(); - Fixture fixture = new Fixture(true, config(true, "赌场", 15), owner, 2); - fixture.host(1_000L, 20_000L, true, false); - owner.runAll(); - FakeSession recognizer = fixture.factory.current(); - for (int i = 0; i < 1_000; i++) fixture.hub.resetTimeline( - 2_000L, PlaybackMediaSignalHub.ResetReason.SEEK); - assertEquals(1, owner.tasks.size()); - owner.runAll(); - assertEquals(1, recognizer.resetCalls); - fixture.provider.onHostPosition(host(fixture.hub.session(), 2_000L, 20_000L, true, false)); - fixture.hub.publishPcm(fixture.hub.session().frame(new float[192_001], 16_000, 2_000L)); - owner.runAll(); - assertTrue(recognizer.acceptedSamples.isEmpty()); - assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.SPEECH_PCM_DROPPED)); - fixture.close(); - } - - @Test - public void queuedResultsAreBoundedAndInvalidatedOnClose() { - ManualExecutor owner = new ManualExecutor(); - Fixture fixture = new Fixture(true, config(true, "赌场", 15), owner, 2); - fixture.host(1_000L, 20_000L, true, false); - owner.runAll(); - fixture.publish(new float[] {0.1f}, 16_000, 1_000L); - owner.runAll(); - FakeSession recognizer = fixture.factory.current(); - for (int i = 0; i < 20; i++) recognizer.emit("赌场", recognizer.lastTimelineToken); - assertEquals(12L, fixture.diagnostics.count(AdAudioDiagnostics.Code.QUEUE_OVERFLOW)); - fixture.provider.close(); - owner.runAll(); - assertTrue(fixture.emitted.isEmpty()); - assertEquals(1, recognizer.closeCalls); - fixture.close(); - } - - @Test - public void executorRejectionDegradesWithoutRunningNativeOnTheCaller() { - Executor rejecting = command -> { throw new java.util.concurrent.RejectedExecutionException(); }; - Fixture fixture = new Fixture(true, config(true, "赌场", 15), rejecting, 2); - fixture.host(1_000L, 20_000L, true, false); - assertEquals(AdAudioSignalProvider.ProviderState.DEGRADED, fixture.provider.state()); - assertEquals(0, fixture.factory.readyCalls); - assertTrue(fixture.factory.sessions.isEmpty()); - assertFalse(fixture.hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.SPEECH_OWNER_REJECTED)); - fixture.close(); - } - - @Test - public void failedResetStopsThisProviderInsteadOfJoiningAcrossTheGap() { - Fixture fixture = new Fixture(true, config(true, "赌场", 15), Runnable::run, 2); - fixture.host(1_000L, 20_000L, true, false); - FakeSession recognizer = fixture.factory.current(); - recognizer.throwOnReset = true; - PlaybackMediaSignalHub.Session reset = fixture.hub.resetTimeline( - 2_000L, PlaybackMediaSignalHub.ResetReason.SEEK); - fixture.provider.onHostPosition(host(reset, 2_000L, 20_000L, true, false)); - fixture.hub.publishPcm(reset.frame(new float[] {1f}, 16_000, 2_000L)); - assertEquals(AdAudioSignalProvider.ProviderState.DEGRADED, fixture.provider.state()); - assertEquals(1, recognizer.closeCalls); - assertTrue(recognizer.acceptedSamples.isEmpty()); - fixture.close(); - } - - @Test - public void emptyAndNonMatchingTextDoNotEmitCandidates() { - Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); - fixture.host(1_000L, 20_000L, true, false); - fixture.publish(new float[] {0.1f}, 16_000, 1_000L); - FakeSession recognizer = fixture.factory.current(); - - recognizer.emit(" ", recognizer.lastTimelineToken()); - recognizer.emit("\u6b22\u8fce\u6536\u770b\u8282\u76ee", recognizer.lastTimelineToken()); - - assertTrue(fixture.emitted.isEmpty()); - assertEquals(1L, fixture.diagnostics.count(AdAudioDiagnostics.Code.SPEECH_TEXT_EMPTY)); - fixture.close(); - } - - @Test - public void closeIsIdempotentAndReleasesRegistrationLeaseAndSession() { - Fixture fixture = new Fixture(true, config(true, "\u8d4c\u573a", 15), Runnable::run, 8); - fixture.host(1_000L, 20_000L, true, false); - FakeSession recognizer = fixture.factory.current(); - recognizer.throwOnClose = true; - - fixture.provider.close(); - fixture.provider.close(); - - assertEquals(AdAudioSignalProvider.ProviderState.CLOSED, fixture.provider.state()); - assertEquals(1, recognizer.closeCalls); - assertFalse(fixture.hub.isCaptureRequested( - PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); - PlaybackMediaSignalHub.Registration replacement = fixture.hub.register( - "speech-ad", Runnable::run, 1, new PlaybackMediaSignalHub.Consumer() {}); - assertNotNull(replacement); - replacement.close(); - fixture.hub.close(); - } - - private static SpeechAdConfig config(boolean enabled, String keywords, int skipSeconds) { - return SpeechAdConfig.create(enabled, keywords, skipSeconds, "PROMPT"); - } - - private static AdAudioSignalProvider.SessionContext context( - PlaybackMediaSignalHub.Session session) { - return new AdAudioSignalProvider.SessionContext( - session.id(), session.generation(), "media", "https://example.test/video", - Map.of()); - } - - private static AdAudioSignalProvider.HostPosition host( - PlaybackMediaSignalHub.Session session, long positionMs, long durationMs, - boolean seekable, boolean live) { - return new AdAudioSignalProvider.HostPosition( - session.id(), session.generation(), positionMs, durationMs, seekable, live); - } - - private static AdAudioRuleSnapshot rules() { - return new AdAudioRuleSnapshot( - "test", "v1", AudioFingerprintRuleSet.empty(), List.of(), ""); - } - - private static AdAudioSignalProvider.Listener listener( - List emitted, - List errors, - List resets) { - return new AdAudioSignalProvider.Listener() { - @Override - public void onCandidate(AdAudioSignalProvider.AdAudioCandidate candidate) { - emitted.add(candidate); - } - - @Override - public void onProviderError(AdAudioSignalProvider.ProviderError error) { - errors.add(error); - } - - @Override - public void onTimelineReset(AdAudioSignalProvider.TimelineReset reset) { - resets.add(reset); - } - }; - } - - private static final class Fixture implements AutoCloseable { - private final PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); - private final PlaybackMediaSignalHub.Session session = hub.beginSession(0L); - private final FakeRecognizerFactory factory; - private final AdAudioDiagnostics diagnostics = new AdAudioDiagnostics(); - private final List emitted = new ArrayList<>(); - private final List errors = new ArrayList<>(); - private final List resets = new ArrayList<>(); - private final SpeechAdSignalProvider provider; - private final Executor worker; - - private Fixture(boolean modelReady, SpeechAdConfig config, - Executor worker, int mailboxCapacity) { - this.worker = worker; - factory = new FakeRecognizerFactory(modelReady); - provider = new SpeechAdSignalProvider( - hub, factory, () -> config, mailboxCapacity, worker, diagnostics); - provider.setEnabled(true); - provider.start(context(session), rules(), listener(emitted, errors, resets)); - } - - private Fixture(SpeechAdConfig config, Executor worker, int mailboxCapacity) { - this.worker = worker; - factory = new FakeRecognizerFactory(true); - provider = new SpeechAdSignalProvider( - hub, factory, () -> config, mailboxCapacity, worker, diagnostics); - provider.setEnabled(true); - provider.start(context(session), rules(), listener(emitted, errors, resets)); - } - - private void host(long positionMs, long durationMs, - boolean seekable, boolean live) { - provider.onHostPosition(SpeechAdSignalProviderTest.host( - session, positionMs, durationMs, seekable, live)); - } - - private void publish(float[] samples, int sampleRate, long startMs) { - hub.publishPcm(session.frame(samples, sampleRate, startMs)); - } - - @Override - public void close() { - provider.close(); - if (worker instanceof ManualExecutor manual) manual.runAll(); - hub.close(); - } - } - - private static final class FakeRecognizerFactory implements SpeechRecognitionFactory { - private final boolean ready; - private final List sessions = new ArrayList<>(); - private final CountDownLatch sessionCreated = new CountDownLatch(1); - private int readyCalls; - private SpeechRecognitionFactory.ExecutionProfile lastProfile; - private boolean throwOnCreate; - - private FakeRecognizerFactory(boolean ready) { - this.ready = ready; - } - - @Override - public boolean isReady() { - readyCalls++; - return ready; - } - - @Override - public Session create(Listener listener) { - return create(listener, SpeechRecognitionFactory.ExecutionProfile.SUBTITLE); - } - - @Override - public Session create(Listener listener, ExecutionProfile profile) { - if (throwOnCreate) throw new IllegalStateException("create failed"); - lastProfile = profile; - FakeSession session = new FakeSession(listener); - sessions.add(session); - sessionCreated.countDown(); - return session; - } - - private FakeSession current() { - assertFalse(sessions.isEmpty()); - return sessions.get(sessions.size() - 1); - } - } - - private static final class FakeSession implements SpeechRecognitionFactory.Session { - private final SpeechRecognitionFactory.Listener listener; - private final List acceptedSamples = new ArrayList<>(); - private int lastTimelineToken; - private long lastStartUs; - private long lastEndUs; - private int resetCalls; - private int closeCalls; - private boolean throwOnAccept; - private boolean throwOnClose; - private boolean throwOnReset; - private boolean joinSpeech; - private final StringBuilder utterance = new StringBuilder(); - private CountDownLatch acceptStarted; - private CountDownLatch allowAccept; - private final CountDownLatch closeFinished = new CountDownLatch(1); - private final AtomicBoolean concurrentNativeCall = new AtomicBoolean(); - private volatile boolean nativeAccepting; - - private FakeSession(SpeechRecognitionFactory.Listener listener) { - this.listener = listener; - } - - @Override - public void accept(float[] samples, long startUs, long endUs, int timelineToken) { - if (throwOnAccept) throw new IllegalStateException("accept failed"); - acceptedSamples.add(samples.clone()); - lastTimelineToken = timelineToken; - lastStartUs = startUs; - lastEndUs = endUs; - if (joinSpeech) { - if (samples[0] == 1f) utterance.append("赌"); - if (samples[0] == 3f) { - utterance.append("场"); - listener.onResult(utterance.toString(), startUs, endUs, timelineToken); - } - } - if (acceptStarted != null) { - nativeAccepting = true; - acceptStarted.countDown(); - try { - allowAccept.await(); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } finally { - nativeAccepting = false; - } - } - } - - @Override - public void reset() { - if (nativeAccepting) concurrentNativeCall.set(true); - resetCalls++; - if (throwOnReset) throw new IllegalStateException("reset failed"); - utterance.setLength(0); - } - - @Override - public void close() { - if (nativeAccepting) concurrentNativeCall.set(true); - closeCalls++; - if (throwOnClose) throw new IllegalStateException("close failed"); - closeFinished.countDown(); - } - - private void blockAccept() { - acceptStarted = new CountDownLatch(1); - allowAccept = new CountDownLatch(1); - } - - private int lastTimelineToken() { - return lastTimelineToken; - } - - /** Echoes the capture window of the last accepted frame, like the real recognizer. */ - private void emit(String text, int timelineToken) { - listener.onResult(text, lastStartUs, lastEndUs, timelineToken); - } - - private void emitAt(String text, long startUs, long endUs, int timelineToken) { - listener.onResult(text, startUs, endUs, timelineToken); - } - - private void fail() { - listener.onError(new IllegalStateException("recognizer failed")); - } - } - - private static final class ManualExecutor implements Executor { - private final Queue tasks = new ArrayDeque<>(); - - @Override - public void execute(Runnable command) { - tasks.add(command); - } - - private void runAll() { - Runnable task; - while ((task = tasks.poll()) != null) task.run(); - } - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/StreamingPcmResamplerTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/StreamingPcmResamplerTest.java deleted file mode 100644 index 2ac119804a..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/StreamingPcmResamplerTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import java.util.Arrays; - -import org.junit.Test; - -import static org.junit.Assert.assertArrayEquals; - -public class StreamingPcmResamplerTest { - - @Test - public void chunkBoundariesDoNotChangeResampledSamples() { - short[] source = ramp(441, 3); - short[] expected = expectedResample(source, 44_100, 16_000); - - StreamingPcmResampler resampler = new StreamingPcmResampler(16_000); - short[] first = resampler.append(Arrays.copyOfRange(source, 0, 127), 44_100, 1); - short[] second = resampler.append(Arrays.copyOfRange(source, 127, 319), 44_100, 1); - short[] tail = resampler.append(Arrays.copyOfRange(source, 319, source.length), 44_100, 1); - short[] flushed = resampler.flush(); - - assertArrayEquals(expected, concat(first, second, tail, flushed)); - } - - private static short[] expectedResample(short[] source, int sourceRate, int targetRate) { - int targetLength = Math.max(1, (int) Math.round(source.length * targetRate / (double) sourceRate)); - short[] expected = new short[targetLength]; - for (int i = 0; i < targetLength; i++) { - double position = i * sourceRate / (double) targetRate; - int left = Math.min(source.length - 1, (int) position); - int right = Math.min(source.length - 1, left + 1); - expected[i] = (short) Math.round(source[left] * (1 - (position - left)) - + source[right] * (position - left)); - } - return expected; - } - - private static short[] ramp(int frames, int step) { - short[] source = new short[frames]; - for (int i = 0; i < frames; i++) source[i] = (short) (i * step); - return source; - } - - private static short[] concat(short[]... arrays) { - int length = 0; - for (short[] array : arrays) length += array.length; - short[] result = new short[length]; - int offset = 0; - for (short[] array : arrays) { - System.arraycopy(array, 0, result, offset, array.length); - offset += array.length; - } - return result; - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ad/audio/TinkEd25519CompatibilityTest.java b/app/src/test/java/com/fongmi/android/tv/ad/audio/TinkEd25519CompatibilityTest.java deleted file mode 100644 index fffc1b571a..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ad/audio/TinkEd25519CompatibilityTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.fongmi.android.tv.ad.audio; - -import static org.junit.Assert.assertEquals; - -import com.google.crypto.tink.PublicKeySign; -import com.google.crypto.tink.PublicKeyVerify; - -import org.junit.Test; - -import java.nio.charset.StandardCharsets; - -public class TinkEd25519CompatibilityTest { - - @Test - public void rawEd25519SignatureIsPortableLength() throws Exception { - byte[] message = "webhtv-ed25519-test".getBytes(StandardCharsets.US_ASCII); - PublicKeySign signer = SignedRulePackageTestKeys.signer(); - PublicKeyVerify verifier = SignedRulePackageTestKeys.verifier(); - - byte[] signature = signer.sign(message); - - assertEquals(64, signature.length); - verifier.verify(signature, message); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/api/config/VodConfigSiteKeyTest.java b/app/src/test/java/com/fongmi/android/tv/api/config/VodConfigSiteKeyTest.java new file mode 100644 index 0000000000..a0c6156ace --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/api/config/VodConfigSiteKeyTest.java @@ -0,0 +1,42 @@ +package com.fongmi.android.tv.api.config; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.lang.reflect.Method; +import java.util.List; + +/** Keeps saved following/history source routes compatible with configuration key-case changes. */ +public class VodConfigSiteKeyTest { + + @Test + public void getSiteFallsBackToCaseInsensitiveKey() throws Exception { + Method method = VodConfig.class.getDeclaredMethod("getSite", String.class); + method.setAccessible(true); + VodConfig config = new VodConfig(); + setSites(config, List.of(site("Wogg"), site("Libvio"))); + + assertEquals("Wogg", siteKey(method, config, "wogg")); + assertEquals("Libvio", siteKey(method, config, "LIBVIO")); + assertEquals("Wogg", siteKey(method, config, "Wogg")); + assertTrue(((com.fongmi.android.tv.bean.Site) method.invoke(config, "missing")).isEmpty()); + } + + private static String siteKey(Method method, VodConfig config, String key) throws Exception { + return ((com.fongmi.android.tv.bean.Site) method.invoke(config, key)).getKey(); + } + + private static void setSites(VodConfig config, List sites) throws Exception { + Method setter = VodConfig.class.getDeclaredMethod("setSites", List.class); + setter.setAccessible(true); + setter.invoke(config, sites); + } + + private static com.fongmi.android.tv.bean.Site site(String key) { + com.fongmi.android.tv.bean.Site site = new com.fongmi.android.tv.bean.Site(); + site.setKey(key); + return site; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/api/loader/CatSpiderResolveTest.java b/app/src/test/java/com/fongmi/android/tv/api/loader/CatSpiderResolveTest.java new file mode 100644 index 0000000000..3797b8b919 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/api/loader/CatSpiderResolveTest.java @@ -0,0 +1,83 @@ +package com.fongmi.android.tv.api.loader; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * 猫源请求地址重建的契约:只有本机回环 api 才跟随 Node 运行时当前端口重建, + * 远端(非本机)T4 猫源 api 必须原样请求。 + * + *

背景:站点 api 在配置加载时被 rebase 成 {@code http://127.0.0.1:/spider/...} + * 并驻留内存。:node 子进程崩溃自动重启后端口可能变化,固化的旧端口会一直白等超时。 + * 但远端猫源(如 {@code https://…/spider/…})与本地 bundle 的 api 形状相同——若重建逻辑 + * 只看“是否含 /spider/ + 本地端口非 0”,本地 node 在跑时远端站点会被错误改写到 127.0.0.1, + * 表现为“切换订阅后远端源全部失效”。 + * + *

resolve 依赖 NodeRuntime/OkHttp 等运行时,单测不易直接构造;这里钉死它的地址判定内核 + * {@link CatSpider#isLoopbackBase(String)}(包内可见),覆盖远端/回环/userinfo/无 host 各形态。 + */ +public class CatSpiderResolveTest { + + private static final String[] REMOTE_BASES = { + "https://catpaw.example.me", + "https://user:pass@catpaw.example.me", + "http://192.168.50.10:9988", + "https://example.me:8443", + }; + + private static final String[] LOOPBACK_BASES = { + "http://127.0.0.1:9988", + "http://localhost:9988", + "http://127.0.0.1", + }; + + @Test + public void remoteBasesAreNotLoopback() { + for (String base : REMOTE_BASES) { + assertFalse("远端 base 不得被判定为本机:" + base, CatSpider.isLoopbackBase(base)); + } + } + + @Test + public void loopbackBasesAreLoopback() { + for (String base : LOOPBACK_BASES) { + assertTrue("回环 base 必须判定为本机:" + base, CatSpider.isLoopbackBase(base)); + } + } + + @Test + public void userinfoDoesNotHideHost() { + // userinfo 里带 @ 和 : 时不能把凭据段误当 host——那会把远端站点误判成本机。 + assertFalse(CatSpider.isLoopbackBase("https://user:pass@127.0.0.1@evil.example.me")); + assertTrue("userinfo 含 127.0.0.1 文本但 host 是远端域名时仍应判远端(lastIndexOf@ 取真 host)", + CatSpider.isLoopbackBase("https://pass@127.0.0.1")); + } + + @Test + public void patternOnlyAcceptsSpiderRoutes() { + // BUNDLE_BASE 的形状契约:api 形如 [scheme://host]/spider//[/...],方法名接在后面。 + assertTrue(CatSpider.BUNDLE_BASE.matcher("http://127.0.0.1:9988/spider/douban/3").matches()); + assertTrue(CatSpider.BUNDLE_BASE.matcher("https://user:pass@catpaw.example.me/spider/muou/3").matches()); + assertTrue(CatSpider.BUNDLE_BASE.matcher("/spider/muou/3").matches()); + assertFalse("非 /spider/ 路由不参与重建", CatSpider.BUNDLE_BASE.matcher("http://127.0.0.1:9988/config").matches()); + assertFalse("缺站点段不参与重建", CatSpider.BUNDLE_BASE.matcher("http://127.0.0.1:9988/spider/").matches()); + } + + @Test + public void matchesRequiresAbsoluteHttp() { + assertTrue(CatSpider.matches("https://user:pass@catpaw.example.me/spider/muou/3")); + assertTrue(CatSpider.matches("http://127.0.0.1:9988/spider/douban/3")); + assertFalse("相对路径不匹配", CatSpider.matches("/spider/muou/3")); + assertFalse("空串不匹配", CatSpider.matches("")); + } + + @Test + public void apiTrailingSlashStripped() { + assertEquals("构造时去掉尾部斜杠,避免拼接出双斜杠路由", + "http://127.0.0.1:9988/spider/douban/3", new CatSpider("http://127.0.0.1:9988/spider/douban/3/").apiForTest()); + assertEquals("http://127.0.0.1:9988/spider/douban/3", new CatSpider("http://127.0.0.1:9988/spider/douban/3").apiForTest()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/api/loader/NativeBridgeGuardTest.java b/app/src/test/java/com/fongmi/android/tv/api/loader/NativeBridgeGuardTest.java new file mode 100644 index 0000000000..145510665e --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/api/loader/NativeBridgeGuardTest.java @@ -0,0 +1,32 @@ +package com.fongmi.android.tv.api.loader; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class NativeBridgeGuardTest { + + @Test + public void nativeBridgePropertyMarksTranslatedRuntime() { + assertTrue(NativeBridgeGuard.isTranslatedRuntime("libnb.so", List.of("x86_64", "x86"), "x86_64")); + assertFalse(NativeBridgeGuard.isTranslatedRuntime("", List.of("x86_64", "x86"), "x86_64")); + assertFalse(NativeBridgeGuard.isTranslatedRuntime(null, List.of("x86_64", "x86"), "x86_64")); + } + + @Test + public void foreignAbiFamilyWithoutBridgePropertyAlsoCounts() { + // Emulators sometimes expose the translated ABI list before the system + // property becomes readable through reflection. + assertTrue(NativeBridgeGuard.isTranslatedRuntime("", List.of("x86_64", "x86", "arm64-v8a", "armeabi-v7a"), "x86_64")); + assertFalse(NativeBridgeGuard.isTranslatedRuntime("", List.of("x86_64", "x86"), "x86_64")); + assertFalse(NativeBridgeGuard.isTranslatedRuntime("", List.of("arm64-v8a", "armeabi-v7a"), "arm64-v8a")); + } + + @Test + public void armPrimaryWithX86SupportIsAlsoTreatedAsTranslated() { + assertTrue(NativeBridgeGuard.isTranslatedRuntime("", List.of("arm64-v8a", "x86_64"), "arm64-v8a")); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/api/loader/PyLoaderDiagnosticsWiringTest.java b/app/src/test/java/com/fongmi/android/tv/api/loader/PyLoaderDiagnosticsWiringTest.java new file mode 100644 index 0000000000..9460398741 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/api/loader/PyLoaderDiagnosticsWiringTest.java @@ -0,0 +1,71 @@ +package com.fongmi.android.tv.api.loader; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * PyLoader 的初始化失败必须同时留在「诊断日志」和「Logcat」两条通道上。 + * + *

{@code SpiderDebug.log(tag, …)} 的每一条重载先是 {@code DebugLogStore.acceptsTag(tag)}, + * 而它等于 {@code enabled && …};{@code enabled} 默认是 false({@code Prefers.getBoolean} + * 的默认值),只有用户在设置里打开「调试日志」才为真。因此只用 SpiderDebug 记录初始化失败, + * 在默认设置下等同于「完全静默」——而这里正是「站点全空却没有任何线索」的唯一解释点。

+ * + *

兄弟实现 {@code JsLoader} 同时保留了两条通道,本测试把 PyLoader 拉回同一口径。

+ */ +public class PyLoaderDiagnosticsWiringTest { + + private static final Path SOURCE = Path.of("src/main/java/com/fongmi/android/tv/api/loader/PyLoader.java"); + + private static String source() throws Exception { + return code(SOURCE); + } + + /** + * 去掉注释行后再断言:否则 {@code // e.printStackTrace();} 这种注释也能满足「有兜底日志」的断言, + * 守卫就退化成恒真检查。 + */ + private static String code(Path file) throws Exception { + StringBuilder result = new StringBuilder(); + for (String line : Files.readString(file).replace("\r\n", "\n").split("\n", -1)) { + if (!line.trim().startsWith("//") && !line.trim().startsWith("*")) result.append(line).append('\n'); + } + return result.toString(); + } + + @Test + public void initFailureKeepsAnUnconditionalLogcatChannel() throws Exception { + String source = source(); + int diagnose = source.indexOf("SpiderDebug.log(PY_TAG, e);"); + int unconditional = source.indexOf("e.printStackTrace();", diagnose); + + assertTrue("init failure must still be recorded in the diagnostic log", diagnose > 0); + assertTrue("init failure must also reach Logcat without 调试日志 enabled", unconditional > diagnose); + assertEquals("the unconditional channel must live in the same catch block", + -1, source.indexOf("e.printStackTrace();", unconditional + 1)); + } + + @Test + public void initFailureStillGoesThroughTheDiagnosticStore() throws Exception { + String source = source(); + int named = source.indexOf("SpiderDebug.log(PY_TAG, \"init failed key=%s api=%s error=%s:%s\""); + + // 两条通道缺一不可:诊断日志带 key/api/异常类型,Logcat 无条件可见。 + assertTrue("init failure must name the site and the failing callable", named > 0); + assertTrue(source.contains("SpiderDebug.log(PY_TAG, e);")); + } + + @Test + public void siblingJsLoaderUsesTheSameTwoChannels() throws Exception { + String source = code(Path.of("src/main/java/com/fongmi/android/tv/api/loader/JsLoader.java")); + + // 这个断言的意义是:一旦别的 loader 改了口径,PyLoader 的「两条通道」也就不再是少数派特例。 + assertTrue(source.contains("SpiderDebug.log(\"JsLoader\", e);")); + assertTrue(source.contains("e.printStackTrace();")); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/BackupPreferenceFilterTest.java b/app/src/test/java/com/fongmi/android/tv/bean/BackupPreferenceFilterTest.java index 31791a7f6e..31be3cc311 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/BackupPreferenceFilterTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/BackupPreferenceFilterTest.java @@ -56,26 +56,6 @@ public class BackupPreferenceFilterTest { assertFalse(Backup.include("touch_optimized", spiderOnly)); } - @Test - public void speechAdRulePreferencesFollowSettingsOption() { - SyncOptions settingsOnly = new SyncOptions().config(false).spider(false) - .webHome(false).settings(true); - SyncOptions configOnly = new SyncOptions().config(true).spider(false) - .webHome(false).settings(false); - SyncOptions spiderOnly = new SyncOptions().config(false).spider(true) - .webHome(false).settings(false); - SyncOptions everything = new SyncOptions().config(true).spider(true) - .webHome(true).settings(true); - - for (String key : new String[]{ - "speech_ad_rules_v1", "speech_ad_rules_source", "speech_ad_builtin_enabled"}) { - assertTrue(key, Backup.include(key, settingsOnly)); - assertTrue(key, Backup.include(key, everything)); - assertFalse(key, Backup.include(key, configOnly)); - assertFalse(key, Backup.include(key, spiderOnly)); - } - } - @Test public void playbackOverlayFollowsSettingsOption() { SyncOptions settingsOnly = new SyncOptions().config(false).spider(false).webHome(false).settings(true); @@ -85,6 +65,19 @@ public class BackupPreferenceFilterTest { assertFalse(Backup.include("playback_overlay_enabled", spiderOnly)); } + @Test + public void appearanceModeAndThemeProfileFollowSettingsOption() { + SyncOptions settingsOnly = new SyncOptions().config(false).spider(false).webHome(false).settings(true); + SyncOptions spiderOnly = new SyncOptions().config(false).spider(true).webHome(false).settings(false); + + for (String key : new String[]{ + "theme_mode", "theme_profile_v2_json", + "theme_profile_v2_last_good", "theme_profile_v2_schema"}) { + assertTrue(key, Backup.include(key, settingsOnly)); + assertFalse(key, Backup.include(key, spiderOnly)); + } + } + @Test public void githubProxyPreferencesFollowSettingsOption() { SyncOptions settingsOnly = new SyncOptions().config(false).spider(false).webHome(false).settings(true); @@ -178,6 +171,23 @@ public class BackupPreferenceFilterTest { "perf_exo_single_rate_rescue_enabled_v1", everything)); } + @Test + public void themeProfilePreferencesFollowSettingsOption() { + SyncOptions settingsOnly = new SyncOptions().config(false).spider(false).webHome(false).settings(true); + SyncOptions webHomeOnly = new SyncOptions().config(false).spider(false).webHome(true).settings(false); + + // The B-safe profile is persisted under the v2 keys only: the legacy v1 + // `theme_profile_*` keys are deliberately never reused, so an old TweakCN + // profile can never be misread as a current one. + assertTrue(Backup.include("theme_profile_v2_json", settingsOnly)); + assertTrue(Backup.include("theme_profile_v2_last_good", settingsOnly)); + assertTrue(Backup.include("theme_profile_v2_schema", settingsOnly)); + assertTrue(Backup.include("theme_mode", settingsOnly)); + assertFalse(Backup.include("theme_profile_v2_json", webHomeOnly)); + assertFalse("the legacy v1 profile key must not come back", + Backup.include("theme_profile_json", settingsOnly)); + } + @Test public void updateDownloadSettingsFollowAppSettingsSync() { SyncOptions settings = new SyncOptions().config(false).spider(false).settings(true); diff --git a/app/src/test/java/com/fongmi/android/tv/bean/EpisodeVersionIdentityTest.java b/app/src/test/java/com/fongmi/android/tv/bean/EpisodeVersionIdentityTest.java new file mode 100644 index 0000000000..898ac3ec9f --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/bean/EpisodeVersionIdentityTest.java @@ -0,0 +1,217 @@ +package com.fongmi.android.tv.bean; + +import org.junit.Test; + + + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +/** + * 同一 TMDB 集存在多个版本(同名但不同 URL 的源站条目)时的版本身份判定。 + *

+ * 场景来源:炫彩详情模式点击选集卡片跳到播放页后,用户点击“第二版本”却播放/选中“第一版本”。 + * 根因是 `matchesPlayback`/`matches` 在双方都绑定 TMDB 季集号时直接以“集号相同”返回同集, + * 版本差异(URL)根本没参与比较。 + */ +public class EpisodeVersionIdentityTest { + + private static Episode version(String name, String url, int season, int number) { + Episode episode = Episode.create(name, url); + episode.setTmdbEpisode(new TmdbEpisode(number, "", "", "", "", 0, 0, 0, season)); + return episode; + } + + @Test + public void sameTmdbEpisodeWithDifferentUrlsIsADifferentVersionInOneFlag() { + // 同一线路内的同集多版本(历史 URL 仍能定位到本线路条目时,调用方传 versionAware=true): + // URL 不同 → 不同版本,否则“点第二版本却被当作第一版本”。 + Episode first = version("正片", "url-v1", 1, 3); + Episode second = version("正片", "url-v2", 1, 3); + + assertFalse(second.matchesPlayback(first, true)); + assertFalse(first.matchesPlayback(second, true)); + assertFalse(second.matches(first)); + assertFalse(first.matches(second)); + } + + @Test + public void crossFlagOrRefreshedUrlsKeepTolerantNumberMatch() { + // 换线路/换源或源站刷新后,同一集的 URL 必然变化,单参 matchesPlayback 必须保留集号容错, + // 否则跨线路续播会丢失进度、选中回落第一集(1fd83c6f53 跨线路续播契约)。 + Episode lineA = version("正片", "url-line-a", 1, 3); + Episode lineB = version("正片", "url-line-b", 1, 3); + + assertTrue(lineB.matchesPlayback(lineA)); + assertTrue(lineA.matchesPlayback(lineB)); + // 调用方未确认同线路上下文时,带参调用同样回落容错。 + assertTrue(lineB.matchesPlayback(lineA, false)); + } + + @Test + public void sameTmdbEpisodeAndSameUrlIsTheSameVersion() { + Episode first = version("正片", "url-v1", 1, 3); + Episode again = version("正片", "url-v1", 1, 3); + + assertTrue(again.matchesPlayback(first)); + assertTrue(again.matchesPlayback(first, true)); + assertTrue(again.matches(first)); + } + + @Test + public void unknownUrlOnOneSideMustNotFallBackToTheFirstVersion() { + Episode known = version("正片", "url-v2", 1, 3); + Episode unknown = version("正片", "", 1, 3); + + assertFalse(known.matchesPlayback(unknown, true)); + assertFalse(unknown.matchesPlayback(known, true)); + } + + @Test + public void sameTmdbEpisodeWithoutAnyVersionInfoStaysTheSameEpisode() { + Episode first = version("", "", 1, 3); + Episode second = version("", "", 1, 3); + + assertTrue(second.matchesPlayback(first)); + assertTrue(second.matchesPlayback(first, true)); + } + + @Test + public void sameTmdbEpisodeUsesSourceNameWhenNoUrlIsAvailable() { + Episode first = version("[1080P] 第3集", "", 1, 3); + Episode sameName = version("[1080P] 第3集", "", 1, 3); + Episode otherName = version("[4K] 第3集", "", 1, 3); + + assertTrue(sameName.matchesPlayback(first)); + assertTrue(sameName.matchesPlayback(first, true)); + assertFalse(otherName.matchesPlayback(first, true)); + } + + @Test + public void sameTmdbEpisodeUsesSourceDescriptionAsVersionEvidence() { + Episode first = Episode.create("[720P] 第3集", "1080P", ""); + first.setTmdbEpisode(new TmdbEpisode(3, "", "", "", "", 0, 0, 0, 1)); + Episode second = Episode.create("[720P] 第3集", "4K", ""); + second.setTmdbEpisode(new TmdbEpisode(3, "", "", "", "", 0, 0, 0, 1)); + + assertFalse(second.matchesPlayback(first, true)); + } + + @Test + public void differentTmdbEpisodesStillRejectEachOther() { + Episode third = version("正片", "url-3", 1, 3); + Episode fourth = version("正片", "url-4", 1, 4); + + assertFalse(fourth.matchesPlayback(third)); + } + + @Test + public void differentTmdbSeasonWithSameNumberStillRejects() { + Episode firstSeason = version("正片", "url-s1e3", 1, 3); + Episode secondSeason = version("正片", "url-s2e3", 2, 3); + + assertFalse(secondSeason.matchesPlayback(firstSeason)); + } + + @Test + public void legacyUnboundEpisodesKeepTolerantUrlFallback() { + Episode saved = Episode.create("第9集", "old-url"); + Episode refreshed = Episode.create("第9集", "new-url"); + + assertTrue(refreshed.matchesPlayback(saved)); + } + + @Test + public void findWithUrlSelectsTheRequestedVersionAmongDuplicates() { + Flag flag = new Flag("线路1"); + Episode first = Episode.create("正片", "url-v1"); + Episode second = Episode.create("正片", "url-v2"); + flag.getEpisodes().add(first); + flag.getEpisodes().add(second); + + assertSame(second, flag.find(Episode.create("正片", "url-v2"), true)); + assertSame(first, flag.find(Episode.create("正片", "url-v1"), true)); + } + + @Test + public void findWithUrlStillFallsBackToEpisodeNumberWhenUrlIsUnknown() { + Flag flag = new Flag("线路1"); + Episode first = Episode.create("第1集", "url-v1"); + Episode second = Episode.create("第2集", "url-v2"); + flag.getEpisodes().add(first); + flag.getEpisodes().add(second); + + // 定位请求不带 URL 时,仍按集名/集号解析,不返回 null。 + assertSame(second, flag.find(Episode.create("第2集", ""), true)); + } + + @Test + public void findWithoutUrlKeepsStrictMissSemantics() { + Flag flag = new Flag("线路1"); + flag.getEpisodes().add(Episode.create("第1集", "url-v1")); + flag.getEpisodes().add(Episode.create("第2集", "url-v2")); + + // 多集线路才存在「严格未命中」语义:单集线路的 find(Episode, true) 会无条件返回该集。 + assertNull(flag.find(Episode.create("另一个名字", ""), true)); + } + + @Test + public void unknownSeasonKeepsTolerantTmdbNumberMatch() { + // 季号一侧未知时不得做版本消歧,否则跨源续播(同集号、换源、换 URL)会被误判为不同集。 + Episode known = version("第3集", "url-v2", -1, 3); + Episode other = version("源站第三集", "url-v9", 2, 3); + + assertTrue(other.matchesPlayback(known)); + assertTrue(known.matchesPlayback(other)); + } + + @Test + public void matchesPlaybackTreatsHistoryRebuiltFromVersionAsSameVersion() { + // 播放页把“用户点到的版本”写进 History(remarks + episodeUrl), + // 再次比较时应认出同一版本,而不是同集的第一个版本。 + // 调用方(同线路内,历史 URL 仍能定位到本线路条目)传 versionAware=true 启用消歧。 + Episode second = version("正片", "url-v2", 1, 3); + History history = new History(); + history.setVodRemarks(second.getName()); + history.setEpisodeUrl(second.getUrl()); + history.setTmdbEpisodePosition(second); + + Episode rebuilt = history.getEpisode(); + assertTrue(rebuilt.matchesPlayback(second)); + assertTrue(rebuilt.matchesPlayback(second, true)); + assertFalse(rebuilt.matchesPlayback(version("正片", "url-v1", 1, 3), true)); + } + + @Test + public void flagFindPrefersRequestedVariantOverTmdbNumberShortcut() { + Flag flag = new Flag("线路1"); + Episode first = version("正片", "url-v1", 1, 3); + Episode second = version("正片", "url-v2", 1, 3); + flag.getEpisodes().add(first); + flag.getEpisodes().add(second); + + Episode resolved = flag.find(version("正片", "url-v2", 1, 3), true); + assertSame(second, resolved); + } + + @Test + public void containsEpisodeUrlMarksVersionAwareOnlyWhenUrlStillResolves() { + Flag flag = new Flag("线路1"); + Episode first = version("正片", "url-v1", 1, 3); + Episode second = version("正片", "url-v2", 1, 3); + flag.getEpisodes().add(first); + flag.getEpisodes().add(second); + + // 历史 URL 仍能定位到本线路条目:同集多版本并存,可启用版本消歧。 + assertTrue(flag.containsEpisodeUrl(version("正片", "url-v1", 1, 3))); + assertTrue(flag.containsEpisodeUrl(version("正片", "url-v2", 1, 3))); + // TMDB 位置冲突的同 URL 条目不得算命中(与 find 的 URL 优先口径一致)。 + assertFalse(flag.containsEpisodeUrl(version("正片", "url-v1", 2, 3))); + // 换线路/换源/源站刷新:旧 URL 已不在本线路,必须保留集号容错。 + assertFalse(flag.containsEpisodeUrl(version("正片", "url-gone", 1, 3))); + assertFalse(flag.containsEpisodeUrl(version("正片", "", 1, 3))); + assertFalse(flag.containsEpisodeUrl(null)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/HistoryPlaybackTest.java b/app/src/test/java/com/fongmi/android/tv/bean/HistoryPlaybackTest.java index f682d5e134..c6b77c13aa 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/HistoryPlaybackTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/HistoryPlaybackTest.java @@ -130,6 +130,8 @@ public class HistoryPlaybackTest { @Test public void playbackEpisodeMatchAllowsUnknownSeasonWhenTmdbEpisodeNumberMatches() { + // 季号任一侧未知(-1)时,无法断定这是同集的不同版本,必须保留集号容错: + // URL 与源站集名都不同也仍视为同一集,否则跨源续播会失效。 Episode unknownSeason = Episode.create("源站第2集", "old-url"); unknownSeason.setTmdbEpisode(new TmdbEpisode(2, "", "", "", "", 0, 0, 0, -1)); Episode knownSeason = Episode.create("第2集", "new-url"); @@ -138,6 +140,41 @@ public class HistoryPlaybackTest { assertTrue(knownSeason.matchesPlayback(unknownSeason)); } + @Test + public void playbackEpisodeMatchRejectsDifferentVariantOnlyWhenSeasonIsConfirmed() { + // 季号两侧都已知且相等、且调用方确认同线路(历史 URL 仍能定位到本线路条目): + // 才允许按 URL/源站条目文本区分同集多版本。 + Episode firstVersion = tmdbVersion("url-v1"); + Episode secondVersion = tmdbVersion("url-v2"); + Episode secondVersionAgain = tmdbVersion("url-v2"); + + assertFalse(secondVersion.matchesPlayback(firstVersion, true)); + assertFalse(secondVersion.matches(firstVersion)); + assertTrue(secondVersionAgain.matchesPlayback(secondVersion)); + assertTrue(secondVersionAgain.matchesPlayback(secondVersion, true)); + } + + @Test + public void playbackEpisodeMatchKeepsCrossLineToleranceForDifferentUrls() { + // 换线路/换源或源站刷新后 URL 必然变化:单参容错必须保留,否则跨线路续播会丢失进度。 + Episode lineA = tmdbVersion("url-line-a"); + Episode lineB = tmdbVersion("url-line-b"); + + assertTrue(lineB.matchesPlayback(lineA)); + assertTrue(lineA.matchesPlayback(lineB)); + } + + @Test + public void playbackEpisodeMatchAllowsUnknownSeasonWhenNoVersionInfoCanSeparateThem() { + // 双方都带 TMDB 集号但没有任何版本信息(URL 与源站名均为空)时,只能视为同一集。 + Episode unknownSeason = Episode.create("", ""); + unknownSeason.setTmdbEpisode(new TmdbEpisode(2, "", "", "", "", 0, 0, 0, -1)); + Episode knownSeason = Episode.create("", ""); + knownSeason.setTmdbEpisode(new TmdbEpisode(2, "", "", "", "", 0, 0, 202, 2)); + + assertTrue(knownSeason.matchesPlayback(unknownSeason)); + } + @Test public void playbackEpisodeMatchRejectsDifferentEpisode() { Episode saved = Episode.create("第9集", "old-url"); @@ -742,6 +779,13 @@ public class HistoryPlaybackTest { assertFalse(unset.isEndingReached(2_580_000, 2_700_000)); } + /** 同一季同一集(S2E2)下、仅 URL 不同的同集版本。 */ + private static Episode tmdbVersion(String url) { + Episode episode = Episode.create("源站第2集", url); + episode.setTmdbEpisode(new TmdbEpisode(2, "", "", "", "", 0, 0, 202, 2)); + return episode; + } + private static History history(String key, String name, String remarks, String episodeUrl, long position, long duration) { History history = new History(); history.setKey(key); diff --git a/app/src/test/java/com/fongmi/android/tv/bean/ShortDramaConfigTest.java b/app/src/test/java/com/fongmi/android/tv/bean/ShortDramaConfigTest.java new file mode 100644 index 0000000000..f126b04406 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/bean/ShortDramaConfigTest.java @@ -0,0 +1,95 @@ +package com.fongmi.android.tv.bean; + +import com.google.gson.Gson; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ShortDramaConfigTest { + + @Test + public void explicitEmptyRulesDisableDefaults() { + ShortDramaConfig config = new Gson().fromJson( + "{\"configured\":true,\"enabledSites\":[],\"disabledSites\":[]}", + ShortDramaConfig.class); + config.sanitize(); + + assertTrue(config.isConfigured()); + assertEquals("", config.getDisplayRules()); + assertFalse(config.isSiteEnabled("nodejs_short", "[短]短剧站")); + } + + @Test + public void missingConfigurationStillUsesDefaults() { + ShortDramaConfig config = new ShortDramaConfig().sanitize(); + + assertFalse(config.isConfigured()); + assertEquals(Arrays.asList("[短]", "短剧"), ShortDramaConfig.defaultRules()); + assertTrue(config.isSiteEnabled("nodejs_short", "[短]短剧站")); + } + + @Test + public void dialogDoesNotRecreateDefaultsAfterRemovingAllRules() throws Exception { + // 只对可执行代码断言:注释里提到这些符号(本文件的注释就会)不应影响契约。 + String source = codeOnly(read("app/src/main/java/com/fongmi/android/tv/ui/dialog/ShortDramaSourceDialog.java")); + + // 三个「空规则不得回填默认」的站点:show() 打开弹窗时、showSiteManage() 勾选态计算时、 + // updateChipsDisplay() 画 chip 时。不钉具体写法(`new ArrayList<>(...)` 这类拷贝是可选的), + // 只钉语义不变量:默认规则只能从两处合法入口进入。 + assertTrue(source.contains("tempEnabledRules = new ArrayList<>(config.isConfigured()")); + // 兜底本身必须彻底消失:`tempEnabledRules.isEmpty()` 曾经是「用户清空 -> 默认规则复活」 + // 的判定式,而 defaultRulesText() 正是它回填的内容。 + assertFalse("空规则判定式必须已从对话框中移除", + source.contains("tempEnabledRules.isEmpty()")); + assertFalse("对话框不得再用 defaultRulesText() 回填默认规则", + source.contains("defaultRulesText")); + // defaultRules() 只有两个合法入口:① 未配置过时 show() 的初始值;② 用户主动点「恢复默认」。 + // 第三个入口就意味某条路径在用户清空后又把默认规则塞了回来——正是本缺陷的形态。 + assertTrue("未配置过时初始值必须回到默认规则(configured 为 false 的归一分支)", + source.contains(": ShortDramaConfig.defaultRules());")); + assertTrue("「恢复默认」按钮必须真的写回默认规则", + source.contains("tempEnabledRules.addAll(ShortDramaConfig.defaultRules());")); + assertTrue("默认规则不得出现第三个入口(每条路径都要尊重用户清空)", + count(source, "ShortDramaConfig.defaultRules()") <= 2); + } + + private static int count(String source, String needle) { + int total = 0; + for (int index = source.indexOf(needle); index >= 0; index = source.indexOf(needle, index + needle.length())) total++; + return total; + } + + /** 去掉注释,让源码契约匹配可执行代码,而不是描述这些符号的说明文字。 */ + private static String codeOnly(String source) { + StringBuilder out = new StringBuilder(source.length()); + int index = 0; + while (index < source.length()) { + char current = source.charAt(index); + char next = index + 1 < source.length() ? source.charAt(index + 1) : '\0'; + if (current == '/' && next == '/') { + while (index < source.length() && source.charAt(index) != '\n') index++; + } else if (current == '/' && next == '*') { + index += 2; + while (index + 1 < source.length() && !(source.charAt(index) == '*' && source.charAt(index + 1) == '/')) index++; + index = Math.min(source.length(), index + 2); + } else { + out.append(current); + index++; + } + } + return out.toString(); + } + + 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/test/java/com/fongmi/android/tv/bean/StableInterfaceIdentityAcceptanceTest.java b/app/src/test/java/com/fongmi/android/tv/bean/StableInterfaceIdentityAcceptanceTest.java new file mode 100644 index 0000000000..d553223e22 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/bean/StableInterfaceIdentityAcceptanceTest.java @@ -0,0 +1,73 @@ +package com.fongmi.android.tv.bean; + +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 StableInterfaceIdentityAcceptanceTest { + + @Test + public void addressChangesKeepTheExistingConfigIdentity() throws Exception { + String model = read("app/src/main/java/com/fongmi/android/tv/bean/Config.java"); + String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java"); + String leanback = read("app/src/leanback/java/com/fongmi/android/tv/ui/dialog/ConfigDialog.java"); + + assertTrue(model.contains("public Config replaceUrl(String oldUrl, String newUrl)")); + assertTrue(model.contains("ensureInterfaceKey();")); + assertTrue(mobile.contains("Config.find(config.getId()).url(url)")); + assertTrue(leanback.contains("Config.find(config.getId()).url(text)")); + assertFalse(mobile.contains("Config.delete(ori, type);\n return Config.find(ori, type).url(url)")); + } + + @Test + public void failoverAndRemoteUpdatesStayInsideOneInterface() throws Exception { + String vod = read("app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java"); + String remote = read("app/src/main/java/com/fongmi/android/tv/remote/RemoteConfigOps.java"); + + assertTrue(vod.contains("List addresses = config.getUrls();")); + assertTrue(vod.contains("round.origin.url(loaded.getUrl()).update();")); + assertTrue(remote.contains("findByInterfaceKey(interfaceKey, type)")); + assertTrue(remote.contains("mergeUrls(urls(payload)).url(url)")); + assertTrue(remote.contains("if (config == null) config = Config.create(type);")); + assertFalse(remote.contains("if (config == null) config = Config.find(url, type);")); + } + + @Test + public void migrationAndBackupRestorePreserveStableMapping() throws Exception { + String migration = read("app/src/main/java/com/fongmi/android/tv/db/Migrations.java"); + String backup = read("app/src/main/java/com/fongmi/android/tv/bean/Backup.java"); + String identity = read("app/src/main/java/com/fongmi/android/tv/playback/PlaybackConfigIdentity.java"); + + assertTrue(migration.contains("MIGRATION_45_46")); + assertTrue(migration.contains("CREATE UNIQUE INDEX IF NOT EXISTS `index_Config_interfaceKey_type`")); + assertTrue(migration.contains("UPDATE Config SET interfaceKey")); + assertTrue(backup.contains("findByInterfaceKey(item.getInterfaceKey(), item.getType())")); + assertTrue(backup.contains("item.interfaceKey(current.getInterfaceKey()).mergeUrls(current.getUrls())")); + assertTrue(identity.contains("normalizeKey(config.getInterfaceKey())")); + assertTrue(identity.contains("keyForUrl(config.getUrl())")); + } + + @Test + public void playbackHistoryPersistsItsStableInterfaceBinding() throws Exception { + String history = read("app/src/main/java/com/fongmi/android/tv/bean/History.java"); + String migration = read("app/src/main/java/com/fongmi/android/tv/db/Migrations.java"); + String database = read("app/src/main/java/com/fongmi/android/tv/db/AppDatabase.java"); + + assertTrue(history.contains("@SerializedName(\"sourceBindingKey\")")); + assertTrue(history.contains("@ColumnInfo(defaultValue = \"\")\n private String sourceBindingKey;")); + assertTrue(migration.contains("MIGRATION_46_47")); + assertTrue(migration.contains("ALTER TABLE History ADD COLUMN `sourceBindingKey` TEXT DEFAULT ''")); + assertTrue(database.contains("VERSION = 47")); + assertTrue(database.contains("addMigrations(Migrations.MIGRATION_46_47)")); + } + + 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/test/java/com/fongmi/android/tv/bean/TmdbConfigCustomRouteModeTest.java b/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigCustomRouteModeTest.java new file mode 100644 index 0000000000..f1492a5569 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigCustomRouteModeTest.java @@ -0,0 +1,56 @@ +package com.fongmi.android.tv.bean; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** 验证自定义线路保存→重读后 imageRouteMode/apiRouteMode 仍为 custom 且不被误判为内置线路。 */ +public class TmdbConfigCustomRouteModeTest { + + private static TmdbConfig roundTrip(String json) { + return TmdbConfig.objectFrom(TmdbConfig.objectFrom(json).toJson()); + } + + @Test + public void customImageRoute_survivesRoundTrip_withHttpsUrl() { + TmdbConfig config = roundTrip("{\"imageBase\":\"https://images.mytest.example.com\",\"imageRouteMode\":\"custom\",\"imageRouteConfigured\":true,\"apiKey\":\"k\"}"); + // sanitize 会把 /t/p/w342 附加到自定义图片 base 上,getImageHost 则剥回主机 + assertEquals("https://images.mytest.example.com/t/p/w342", config.getConfiguredImageBase()); + assertFalse(config.isImageAuto()); + assertFalse(config.isImageRouteDefault()); + assertEquals("https://images.mytest.example.com", config.getImageHost()); + } + + @Test + public void customImageRoute_survivesRoundTrip_withBareDomain() { + TmdbConfig config = roundTrip("{\"imageBase\":\"images.mytest.example.com\",\"imageRouteMode\":\"custom\",\"imageRouteConfigured\":true,\"apiKey\":\"k\"}"); + assertFalse(config.isImageAuto()); + assertFalse(config.isImageRouteDefault()); + assertEquals("https://images.mytest.example.com", config.getImageHost()); + } + + @Test + public void customApiRoute_survivesRoundTrip() { + TmdbConfig config = roundTrip("{\"apiBase\":\"https://api.mytest.example.com\",\"apiRouteMode\":\"custom\",\"apiRouteConfigured\":true,\"apiKey\":\"k\"}"); + assertFalse(config.isApiAuto()); + assertFalse(config.isApiRouteDefault()); + assertEquals("https://api.mytest.example.com", config.getApiHost()); + } + + @Test + public void itv666ImageRoute_roundTrip_staysCustom() { + // 从 itv666 切到自定义的保存路径: onSave 写入自定义 base + custom mode + TmdbConfig config = roundTrip("{\"imageBase\":\"http://tmdb.itv666.cc\",\"imageRouteMode\":\"custom\",\"imageRouteConfigured\":true,\"apiKey\":\"k\"}"); + assertEquals("http://tmdb.itv666.cc/t/p/w342", config.getConfiguredImageBase()); + assertEquals("http://tmdb.itv666.cc", config.getImageHost()); + } + + @Test + public void wsrvWrapperImageRoute_roundTrip() { + TmdbConfig config = roundTrip("{\"imageBase\":\"https://wsrv.nl/?url=https://image.tmdb.org\",\"imageRouteMode\":\"custom\",\"imageRouteConfigured\":true,\"apiKey\":\"k\"}"); + assertTrue(config.getConfiguredImageBase().startsWith("https://wsrv.nl/?url=")); + assertFalse(config.isImageAuto()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigEffectiveTest.java b/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigEffectiveTest.java index 502bfb7e68..7dd3131542 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigEffectiveTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigEffectiveTest.java @@ -77,4 +77,16 @@ public class TmdbConfigEffectiveTest { assertFalse(TmdbConfig.isOfficialApiBase("https://api.tmdb.org/3/proxy")); assertFalse(TmdbConfig.isOfficialApiBase("https://user@api.tmdb.org/3")); } + @Test + public void transientCredentialDisablesConfiguredProxy() { + TmdbConfig configured = TmdbConfig.objectFrom("{\"proxyBase\":\"https://mirror.example.com\",\"language\":\"en-US\"}"); + SubscriptionTmdbCredentialStore.Scope scope = SubscriptionTmdbCredentialStore.beginSubscription(4, "https://source.example/config", "test"); + assertTrue(SubscriptionTmdbCredentialStore.accept(SOURCE_KEY, 4, "https://source.example/config", scope.getEpoch(), "site", "vod")); + + TmdbConfig effective = TmdbConfig.effective(configured, SubscriptionTmdbCredentialStore.snapshot(scope)); + + assertEquals("https://api.tmdb.org/3", effective.getApiBase()); + assertFalse(effective.isProxyEnabled()); + } + } diff --git a/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigImageHostTest.java b/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigImageHostTest.java index 149d18b2a4..e8be9f30e2 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigImageHostTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/TmdbConfigImageHostTest.java @@ -3,6 +3,7 @@ package com.fongmi.android.tv.bean; import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class TmdbConfigImageHostTest { @@ -68,4 +69,56 @@ public class TmdbConfigImageHostTest { assertEquals("https://img.mirror.test", reloaded.getImageHost()); assertTrue(saved.contains("img.mirror.test")); } + @Test + public void proxyBaseOverridesApiRequestBaseWithoutChangingConfiguredBase() { + TmdbConfig config = TmdbConfig.objectFrom("{\"apiBase\":\"https://api.tmdb.org/3\",\"proxyBase\":\"https://proxy.example.com/tmdb\",\"apiKey\":\"test-key\"}"); + + assertEquals("https://proxy.example.com/tmdb/3", config.getApiBase()); + assertEquals("https://api.tmdb.org/3", config.getConfiguredApiBase()); + assertEquals("https://proxy.example.com/tmdb", config.getProxyBase()); + assertEquals("https://proxy.example.com/tmdb", config.getImageHost()); + assertEquals("https://images.tmdb.org", config.getConfiguredImageHost()); + assertTrue(config.isProxyEnabled()); + } + + @Test + public void explicitImageDomainWinsOverProxyRoute() { + TmdbConfig config = TmdbConfig.objectFrom("{\"apiBase\":\"https://api.tmdb.org/3\",\"proxyBase\":\"https://proxy.example.com\",\"imageBase\":\"https://cdn.example.com/t/p/w342\",\"apiKey\":\"test-key\"}"); + + assertEquals("https://proxy.example.com/3", config.getApiBase()); + assertEquals("https://cdn.example.com/t/p/w342", config.getImageBase()); + assertEquals("https://cdn.example.com", config.getImageHost()); + } + + @Test + public void customImageAndAutoRoutesArePersistedAndResolved() { + TmdbConfig custom = TmdbConfig.objectFrom("{\"apiBase\":\"https://custom-api.example/3\",\"imageBase\":\"https://custom-image.example/t/p/w342\",\"apiKey\":\"k\"}"); + assertEquals("https://custom-api.example/3", custom.getApiBase()); + assertEquals("https://custom-image.example/t/p/w342", custom.getImageBase()); + + TmdbConfig auto = TmdbConfig.objectFrom("{\"apiBase\":\"https://api.tmdb.org/3\",\"apiAuto\":true,\"imageBase\":\"https://images.tmdb.org/t/p/w342\",\"imageAuto\":true,\"apiKey\":\"k\"}"); + assertTrue(auto.isApiAuto()); + assertTrue(auto.isImageAuto()); + assertEquals("https://api.tmdb.org/3", auto.getApiBase()); + assertTrue(auto.getImageBase().contains("/t/p/w342")); + } + + @Test + public void legacyOfficialRoutesArePresentedAsDefaultAutoCandidates() { + TmdbConfig config = TmdbConfig.objectFrom("{\"apiBase\":\"https://api.tmdb.org/3\",\"imageBase\":\"https://images.tmdb.org/t/p/w342\",\"apiKey\":\"k\"}"); + + assertTrue(config.isApiRouteDefault()); + assertTrue(config.isImageRouteDefault()); + assertTrue(config.isApiAuto()); + assertTrue(config.isImageAuto()); + } + + @Test + public void explicitOfficialDirectRouteStaysDirectAfterNewSave() { + TmdbConfig config = TmdbConfig.objectFrom("{\"apiBase\":\"https://api.tmdb.org/3\",\"apiAuto\":false,\"apiRouteConfigured\":true,\"apiRouteMode\":\"direct\",\"imageBase\":\"https://images.tmdb.org/t/p/w342\",\"imageAuto\":false,\"imageRouteConfigured\":true,\"imageRouteMode\":\"direct\",\"apiKey\":\"k\"}"); + + assertFalse(config.isApiAuto()); + assertFalse(config.isImageAuto()); + } + } diff --git a/app/src/test/java/com/fongmi/android/tv/bean/TmdbMatchCacheTest.java b/app/src/test/java/com/fongmi/android/tv/bean/TmdbMatchCacheTest.java index 80bfcaa4c8..e610e35e7e 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/TmdbMatchCacheTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/TmdbMatchCacheTest.java @@ -53,6 +53,20 @@ public class TmdbMatchCacheTest { assertEquals(200, cache.find("site-b", "vod-b", "重名剧").getTmdbId()); } + @Test + public void displayItemHidesMismatchedLanguageButKeepsIdentity() { + TmdbMatchCache.Entry entry = TmdbMatchCache.Entry.from(item(200, "中文标题"), "zh-Hans"); + + assertEquals("中文标题", entry.toDisplayItem("zh-CN").getTitle()); + assertEquals(200, entry.toDisplayItem("en-US").getTmdbId()); + assertEquals("", entry.toDisplayItem("en-US").getTitle()); + assertEquals("中文标题", entry.toDisplayItem("").getTitle()); + + TmdbMatchCache.Entry traditional = TmdbMatchCache.Entry.from(item(201, "繁體標題"), "zh-TW"); + assertEquals("繁體標題", traditional.toDisplayItem("zh-TW").getTitle()); + assertEquals("", traditional.toDisplayItem("zh-CN").getTitle()); + } + @Test public void manualMatchSurvivesTitleRewriteAndBlocksAutomaticOverwrite() { TmdbMatchCache cache = new TmdbMatchCache(); diff --git a/app/src/test/java/com/fongmi/android/tv/bean/TmdbSourcePayloadTest.java b/app/src/test/java/com/fongmi/android/tv/bean/TmdbSourcePayloadTest.java index 18c0d57f2d..1124bd07de 100644 --- a/app/src/test/java/com/fongmi/android/tv/bean/TmdbSourcePayloadTest.java +++ b/app/src/test/java/com/fongmi/android/tv/bean/TmdbSourcePayloadTest.java @@ -43,7 +43,7 @@ public class TmdbSourcePayloadTest { @Test public void parserNormalizesIdentityCapabilitiesAndImages() { TmdbSourcePayload raw = GSON.fromJson(""" - {"schema":1,"id":1399,"media_type":"TV","season_number":2,"language":" zh-CN ", + {"schema":1,"id":1399,"media_type":"TV","season_number":2,"language":" ZH-Hans ", "fetched_at":"2026-09-19T00:00:00Z","complete":["images","season:02","unknown","images"], "detail":{"id":1399,"season_number":2,"name":"Example","poster_path":"/p.jpg", "backdrop_path":"http://example.test/b.jpg","images":{"posters":[]}, @@ -62,6 +62,21 @@ public class TmdbSourcePayloadTest { assertFalse(parsed.hasCapability("unknown")); } + @Test + public void parserKeepsUnknownLanguageEmptyAndNormalizesKnownAliases() { + TmdbSourcePayload raw = payload(550, "movie", 0, "{\"id\":550}"); + raw.setLanguage(" zh-sg "); + TmdbSourcePayload simplified = TmdbSourcePayloadParser.parse(raw); + assertEquals("zh-CN", simplified.getLanguage()); + + raw.setLanguage("zh-Hant"); + TmdbSourcePayload traditional = TmdbSourcePayloadParser.parse(raw); + assertEquals("zh-TW", traditional.getLanguage()); + + TmdbSourcePayload unknown = payload(550, "movie", 0, "{\"id\":550}"); + assertEquals("", unknown.getLanguage()); + } + @Test public void parserRejectsInvalidIdentityAndConflictingDetail() { assertNull(TmdbSourcePayloadParser.parse(payload(0, "movie", 0, "{\"id\":550}"))); diff --git a/app/src/test/java/com/fongmi/android/tv/bean/UpdateOrderingTest.java b/app/src/test/java/com/fongmi/android/tv/bean/UpdateOrderingTest.java new file mode 100644 index 0000000000..33e715e904 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/bean/UpdateOrderingTest.java @@ -0,0 +1,65 @@ +package com.fongmi.android.tv.bean; + +import com.fongmi.android.tv.BuildConfig; +import com.fongmi.android.tv.utils.AppVersion; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Reproduces the update loop seen on an Android 9 box: the installed beta build and the stable + * manifest carried the same {@code versionCode} and the stable one was older, so the app offered a + * downgrade, installed it, read the beta manifest again, and repeated forever. Both packages also + * disagreed about the database schema, so each pass destroyed the user's data. + */ +public class UpdateOrderingTest { + + private static final String OLDER_STABLE = "v5.6.0-202609221328"; + private static final String NEWER_BETA = "v5.6.0-beta-202610091755"; + + @Test + public void olderManifestWithTheSameVersionCodeIsNotAnUpdate() { + Update update = manifest("stable", OLDER_STABLE, BuildConfig.VERSION_CODE); + assertTrue(update.hasManifest()); + assertFalse("An older package of the same version code must not be offered as an update", + update.hasUpdate()); + } + + @Test + public void identicalManifestIsNotAnUpdate() { + Update update = manifest("stable", AppVersion.fullName(), BuildConfig.VERSION_CODE); + assertFalse(update.hasUpdate()); + } + + @Test + public void higherVersionCodeIsAlwaysAnUpdate() { + Update update = manifest("stable", "v0.0.1-202001010000", BuildConfig.VERSION_CODE + 1); + assertTrue(update.hasUpdate()); + } + + @Test + public void lowerVersionCodeIsNeverAnUpdate() { + Update update = manifest("stable", "v9.9.9-209901010000", BuildConfig.VERSION_CODE - 1); + assertFalse(update.hasUpdate()); + } + + @Test + public void manifestWithoutDownloadTargetIsNotAnUpdate() { + Update update = manifest("stable", "v9.9.9-209901010000", BuildConfig.VERSION_CODE + 1); + update.githubUrl = ""; + update.oci = null; + assertFalse(update.hasUpdate()); + } + + private static Update manifest(String channel, String name, int code) { + Update update = new Update(); + update.channel = channel; + update.name = name; + update.versionName = "5.6.0"; + update.code = code; + update.githubUrl = "https://example.invalid/" + name + ".apk"; + return update; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheAutoCleanupPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheAutoCleanupPolicyTest.java new file mode 100644 index 0000000000..92559c4ce7 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CacheAutoCleanupPolicyTest.java @@ -0,0 +1,31 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class CacheAutoCleanupPolicyTest { + + @Test + public void lowSpaceUses512MbOrTenPercentFloor() { + assertTrue(CacheAutoCleanupPolicy.isLowSpace(400L * 1024L * 1024L, 8L * 1024L * 1024L * 1024L)); + assertFalse(CacheAutoCleanupPolicy.isLowSpace(900L * 1024L * 1024L, 8L * 1024L * 1024L * 1024L)); + assertFalse(CacheAutoCleanupPolicy.isLowSpace(4L * 1024L * 1024L * 1024L, 20L * 1024L * 1024L * 1024L)); + } + + @Test + public void playingAlwaysDowngradesToLight() { + assertEquals(CacheCleanupMode.LIGHT, CacheAutoCleanupPolicy.cleanupMode(true, true)); + assertEquals(CacheCleanupMode.STANDARD, CacheAutoCleanupPolicy.cleanupMode(false, true)); + assertEquals(CacheCleanupMode.LIGHT, CacheAutoCleanupPolicy.cleanupMode(false, false)); + } + + @Test + public void lowSpaceNeedsTwoConsecutiveSamples() { + assertFalse(CacheAutoCleanupPolicy.shouldTrigger(1, false)); + assertTrue(CacheAutoCleanupPolicy.shouldTrigger(2, false)); + assertTrue(CacheAutoCleanupPolicy.shouldTrigger(0, true)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheAutoPlanAndTempFamilyTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheAutoPlanAndTempFamilyTest.java new file mode 100644 index 0000000000..a7e472fdb8 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CacheAutoPlanAndTempFamilyTest.java @@ -0,0 +1,113 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * Locks the two review findings fixed for the cache-management branch. + * + *

1. Automatic cleanup must honour the registry's {@code allowAutomaticCleanup} declaration + * instead of only skipping the diagnostic-log module.

+ * + *

2. The temporary-file cleanup must delete exactly the family the inventory reports, instead of + * a second hand-written prefix list that covered fewer names than the registry.

+ */ +public class CacheAutoPlanAndTempFamilyTest { + + @Test + public void automaticLightPlanKeepsOnlyRegistryApprovedModules() { + assertEquals(List.of(CacheModuleId.EPG, CacheModuleId.TEMP_FILES, CacheModuleId.LEGACY_FILES), + CacheScheduler.automaticPlan(CacheCleanupMode.LIGHT).modules()); + assertEquals(CacheCleanupMode.LIGHT, CacheScheduler.automaticPlan(CacheCleanupMode.LIGHT).mode()); + } + + /** + * The STANDARD tier is what the low-space automatic run uses. It contains GLIDE and + * WEBHOME_RAW for manual cleanup, but those entries declare {@code allowAutomaticCleanup=false} + * and therefore must not survive into an automatic plan. + */ + @Test + public void automaticStandardPlanDropsModulesThatForbidAutomaticCleanup() { + List modules = CacheScheduler.automaticPlan(CacheCleanupMode.STANDARD).modules(); + assertFalse("GLIDE declares allowAutomaticCleanup=false", modules.contains(CacheModuleId.GLIDE)); + assertFalse("WEBHOME_RAW declares allowAutomaticCleanup=false", modules.contains(CacheModuleId.WEBHOME_RAW)); + assertFalse(modules.contains(CacheModuleId.PLUGIN_SCRIPTS)); + assertFalse(modules.contains(CacheModuleId.DIAGNOSTIC_LOGS)); + assertFalse(modules.contains(CacheModuleId.UNCLASSIFIED)); + assertFalse(modules.contains(CacheModuleId.EXO)); + assertTrue(modules.contains(CacheModuleId.LYRICS)); + assertTrue(modules.contains(CacheModuleId.KARAOKE)); + assertTrue(modules.contains(CacheModuleId.WEBHOME_EXT)); + assertTrue(modules.contains(CacheModuleId.EPG)); + assertTrue(modules.contains(CacheModuleId.TEMP_FILES)); + assertTrue(modules.contains(CacheModuleId.LEGACY_FILES)); + } + + /** No automatic tier may ever contain a module the registry forbids for automatic cleanup. */ + @Test + public void everyAutomaticPlanRespectsTheRegistryDeclaration() { + for (CacheCleanupMode mode : CacheCleanupMode.values()) { + if (mode == CacheCleanupMode.MODULE) continue; + for (CacheModuleId id : CacheScheduler.automaticPlan(mode).modules()) { + assertTrue("automatic plan leaked " + id, CachePolicyEngine.allowsAutomatic(id)); + } + } + } + + @Test + public void automaticPlanOfNullFallsBackToLight() { + assertEquals(CacheScheduler.automaticPlan(CacheCleanupMode.LIGHT).modules(), + CacheScheduler.automaticPlan(null).modules()); + } + + /** + * The cleanup candidate set must be derived from the registry, so every name the inventory + * counts as a temporary file is also deletable. + */ + @Test + public void temporaryFamilyComesFromTheRegistryAndCoversReportedSuffixes() throws Exception { + File cache = cacheDir("temp-family"); + CacheCleanupManager.TemporaryFamily family = CacheCleanupManager.temporaryFamily(cache); + + assertTrue("update.apk is reported and must stay cleanable", family.matches("update.apk")); + assertTrue("a sync archive is reported by the .zip suffix", family.matches("webhtv-sync-123.zip")); + assertTrue("a login-state archive is reported by the .zip suffix", family.matches("webhtv-login-state-123.zip")); + assertTrue("a legacy backup archive is reported by the .zip suffix", family.matches("bak-20260928-1200.zip")); + assertTrue("goProxy.log is reported by the .log suffix", family.matches("goProxy.log")); + assertTrue(family.matches("pushed-123.apk")); + assertTrue(family.matches("pushed-url-123.apk")); + assertTrue(family.matches("partial.tmp")); + assertTrue(family.matches("apk-push-123.apk")); + } + + /** Owner-managed files the registry excludes stay out of the candidate set. */ + @Test + public void temporaryFamilyNeverClaimsProtectedOrUnrelatedNames() throws Exception { + File cache = cacheDir("temp-family-exclude"); + CacheCleanupManager.TemporaryFamily family = CacheCleanupManager.temporaryFamily(cache); + + assertFalse("the diagnostic log is excluded by the registry", + family.matches("webhtv-debug-log.txt")); + assertFalse(family.matches("mpv-playback-recovery.lock")); + assertFalse(family.matches("mpv-playback-recovery.state")); + assertFalse(family.matches("mpv-playback-recovery.result")); + assertFalse("a rotated diagnostic segment is not a temporary file", + family.matches("webhtv-debug-log.txt.1")); + assertFalse(family.matches("unrelated.bin")); + assertFalse(family.matches("notes.txt")); + assertFalse(family.matches(null)); + } + + private static File cacheDir(String prefix) throws Exception { + Path base = Path.of(System.getProperty("java.io.tmpdir")).toRealPath(); + return Files.createTempDirectory(base, prefix).toFile(); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheCleanupJournalTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheCleanupJournalTest.java new file mode 100644 index 0000000000..281718ae23 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CacheCleanupJournalTest.java @@ -0,0 +1,23 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.List; + +public class CacheCleanupJournalTest { + + @Test + public void journalRecordCarriesReasonAndResultFacts() { + CacheCleanupRecord record = new CacheCleanupRecord(100, 25, "periodic", + CacheCleanupMode.LIGHT, CacheCleanupStatus.COMPLETED, + 2048, 512, 3, 1, List.of()); + + assertEquals(1536, record.releasedBytes()); + assertEquals("periodic", record.reason()); + assertEquals(CacheCleanupStatus.COMPLETED, record.status()); + assertTrue(record.deletedFiles() == 3); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheFullCleanupTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheFullCleanupTest.java new file mode 100644 index 0000000000..f750e7d236 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CacheFullCleanupTest.java @@ -0,0 +1,253 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Locks the two user-reported defects fixed for this branch. + * + *

1. The temporary-file row reported freshly written {@code webhtv-*.zip} / {@code update.apk} + * leftovers and then released nothing, because the row's own button applied the 24-hour retention + * meant for background runs. An explicit request must ignore that window while still protecting a + * transfer that is running right now.

+ * + *

2. The settings row's plain click has to clear everything the way the pre-split one-key clear + * did, which needs a residual sweep over whatever no module claims.

+ */ +public class CacheFullCleanupTest { + + private static final long NOW = 1_800_000_000_000L; + private static final long DAY_MS = 24L * 60L * 60L * 1000L; + + /** The reported defect: a fresh archive is exactly what the user asked to remove. */ + @Test + public void explicitRequestDeletesFreshTemporaryFiles() throws Exception { + File cache = cacheDir("temp-explicit"); + File archive = write(cache, "webhtv-sync-4242.zip", 4096); + File apk = write(cache, "update.apk", 2048); + archive.setLastModified(NOW - 60_000L); + apk.setLastModified(NOW - 60_000L); + + CacheCleanupManager.Outcome outcome = CacheCleanupManager.clearTemporaryFiles(cache, + CacheCleanupManager.explicitRetention(CacheCleanupMode.MODULE, DAY_MS), 0L, false, false, NOW); + + assertTrue(outcome.success()); + assertFalse("a fresh sync archive must not survive the row's own button", archive.exists()); + assertFalse("a fresh update download must not survive the row's own button", apk.exists()); + } + + /** + * The same files stay for a background or tiered run: only an explicit request is immediate. + * + *

The window is not written into the test, it is asked of the same decision the cleanup + * switch uses, so weakening that decision fails here instead of silently leaving the row's own + * button with a 24-hour window again.

+ */ + @Test + public void backgroundRetentionStillKeepsFreshTemporaryFiles() throws Exception { + File cache = cacheDir("temp-retained"); + File archive = write(cache, "webhtv-sync-4242.zip", 4096); + archive.setLastModified(NOW - 60_000L); + + CacheCleanupManager.Outcome outcome = CacheCleanupManager.clearTemporaryFiles(cache, + CacheCleanupManager.explicitRetention(CacheCleanupMode.LIGHT, DAY_MS), 0L, false, false, NOW); + + assertTrue(outcome.success()); + assertTrue("an age-windowed run must keep a one-minute-old archive", archive.exists()); + } + + /** A transfer that is running right now survives even the immediate request. */ + @Test + public void runningTransferSurvivesTheExplicitRequest() throws Exception { + File cache = cacheDir("temp-in-use"); + File apk = write(cache, "update.apk", 2048); + File pushed = write(cache, "pushed-url-9.apk", 1024); + apk.setLastModified(NOW - 60_000L); + pushed.setLastModified(NOW - 60_000L); + + CacheCleanupManager.Outcome outcome = + CacheCleanupManager.clearTemporaryFiles(cache, 0L, 0L, true, true, NOW); + + assertTrue(outcome.success()); + assertTrue("the running updater keeps its download", apk.exists()); + assertTrue("the running push keeps its archive", pushed.exists()); + } + + /** + * The explicit request ignores the age window but not the module's own candidate set: a family + * the registry does not claim stays on disk even when nothing is in use. + */ + @Test + public void explicitRequestStillOnlyClaimsTheReportedFamily() throws Exception { + File cache = cacheDir("temp-foreign"); + File notes = write(cache, "notes.txt", 256); + File partial = write(cache, "attachment.zip.part", 512); + File archive = write(cache, "webhtv-sync-4242.zip", 2048); + notes.setLastModified(NOW - 60_000L); + partial.setLastModified(NOW - 60_000L); + archive.setLastModified(NOW - 60_000L); + + CacheCleanupManager.Outcome outcome = + CacheCleanupManager.clearTemporaryFiles(cache, 0L, 0L, false, false, NOW); + + assertTrue(outcome.success()); + assertTrue("a .txt note is not part of the temporary family", notes.exists()); + assertTrue("a partial download is not part of the temporary family", partial.exists()); + assertFalse("the module deletes exactly the family it reports", archive.exists()); + } + + /** The sweep removes what no module claims, including the scripts nobody is loading. */ + @Test + public void sweepRemovesLeftoversAndIdlePluginScripts() throws Exception { + File cache = cacheDir("sweep-leftovers"); + File leftover = write(cache, "unknown-owner.bin", 512); + File staleTree = new File(cache, "stale-dir"); + write(staleTree, "deep/data.bin", 128); + File idleScript = write(new File(cache, "js"), "idle-site.js", 256); + File activeScript = write(new File(cache, "js"), "active-site.js", 256); + + ArrayList warnings = new ArrayList<>(); + CacheCleanupManager.sweepResidual(cache, List.of(activeScript), false, false, warnings); + + assertFalse(leftover.exists()); + assertFalse("a tree no module owns is a leftover", staleTree.exists()); + assertFalse(idleScript.exists()); + assertTrue("the script of a running loader must survive", activeScript.exists()); + assertTrue("nothing in this scenario is a failure: " + warnings, warnings.isEmpty()); + } + + /** + * Every age-windowed module decides through one shared rule, so the reported defect can not + * come back at a single call site. The row's own button (MODULE) and the row's plain click + * (FULL) are explicit requests; the tiered and automatic runs keep the background window. + */ + @Test + public void onlyExplicitRequestsIgnoreTheBackgroundRetentionWindow() { + assertTrue(CacheCleanupManager.explicitRequest(CacheCleanupMode.MODULE)); + assertTrue(CacheCleanupManager.explicitRequest(CacheCleanupMode.FULL)); + assertFalse(CacheCleanupManager.explicitRequest(CacheCleanupMode.LIGHT)); + assertFalse(CacheCleanupManager.explicitRequest(CacheCleanupMode.STANDARD)); + assertFalse(CacheCleanupManager.explicitRequest(CacheCleanupMode.DEEP)); + + assertEquals(0L, CacheCleanupManager.explicitRetention(CacheCleanupMode.MODULE, DAY_MS)); + assertEquals(0L, CacheCleanupManager.explicitRetention(CacheCleanupMode.FULL, DAY_MS)); + assertEquals(DAY_MS, CacheCleanupManager.explicitRetention(CacheCleanupMode.LIGHT, DAY_MS)); + assertEquals(DAY_MS, CacheCleanupManager.explicitRetention(CacheCleanupMode.STANDARD, DAY_MS)); + assertEquals(DAY_MS, CacheCleanupManager.explicitRetention(CacheCleanupMode.DEEP, DAY_MS)); + } + + /** + * The two carve-outs the full clean documents: a transfer that is running right now, and the + * trees the caller preserved because a playback cache has to wait for playback to stop. + */ + @Test + public void sweepKeepsPreservedTreesTransfersAndRecoveryFiles() throws Exception { + File cache = cacheDir("sweep-carveouts"); + File playback = new File(cache, "exo"); + write(playback, "segment.bin", 4096); + File recovery = write(cache, "mpv-playback-recovery.state", 64); + File apk = write(cache, "update.apk", 2048); + File leftover = write(cache, "unknown-owner.bin", 512); + + ArrayList warnings = new ArrayList<>(); + CacheCleanupManager.sweepResidual(cache, List.of(playback), true, false, warnings); + + assertTrue("a deferred playback cache keeps its tree", new File(playback, "segment.bin").exists()); + assertTrue("the mpv recovery file is never swept", recovery.exists()); + assertTrue("the running updater keeps its download", apk.exists()); + assertFalse(leftover.exists()); + assertFalse("the skipped files must be reported: " + warnings, warnings.isEmpty()); + } + + /** + * Locks the two decisions the reported defects actually live at, not the helpers they call. + * + *

This class hands {@code clearTemporaryFiles} whichever window it wants to exercise, and + * {@code sweepResidual} is called directly, so the call sites inside {@code CacheCleanupManager} + * are never executed: reverting the temporary-file row to the 24-hour background window - the + * exact code path the user reported as "released nothing, deleted 0 files" - leaves every other + * test in this class green, and reverting the full-clean dispatch turns the settings row's + * plain click back into a tiered run. That is how both defects shipped. + * + *

The call sites can not be reached from a JVM test (they resolve + * {@code App.get().getCacheDir()}, {@code Updater.isDownloading()} and the player state), so + * they are locked the way this repository locks its other Android-only call sites: by reading + * the production source and asserting the decision is made by the shared rule instead of being + * written at the call site.

+ */ + @Test + public void everyCleanupCallSiteUsesTheSharedRule() throws Exception { + String source = productionSource(); + + // 1. The settings row's plain click must dispatch to the full clean, not to a tiered module + // loop. (The gesture moved off the long press on 2026-10-21; the long press opens the panel.) + String run = method(source, "private static void run(CacheCleanupPlan plan, String reason,"); + assertTrue("FULL must select the full-clean path: " + run, + run.contains("if (plan.mode() == CacheCleanupMode.FULL) {")); + assertTrue("FULL must still reach cleanEverything", run.contains("cleanEverything(plan, progress)")); + + // 2. Each age-windowed module must take its window from the shared rule. + String execution = method(source, "private static Outcome executeCleanup(CacheModuleId id, CacheCleanupMode mode)"); + assertTrue("the module loop must ask the shared rule once: " + execution, + execution.contains("boolean now = explicitRequest(mode);")); + assertTrue(execution.contains("case LYRICS -> now")); + assertTrue(execution.contains("case KARAOKE -> now")); + assertTrue(execution.contains("case EPG -> now ? outcome(EpgParser.clearCache())")); + assertTrue(execution.contains("case PLUGIN_SCRIPTS -> clearPluginCache(explicitRetention(mode, retention),")); + assertTrue(execution.contains("case TEMP_FILES -> clearTemporaryFiles(cache, explicitRetention(mode, TEMP_RETENTION_MS), limit,")); + assertTrue(execution.contains("case LEGACY_FILES -> clearLegacyPaths(cache, mode);")); + assertFalse("the window must not be compared to a single mode again: " + execution, + execution.contains("== CacheCleanupMode.MODULE")); + + String legacy = method(source, "private static Outcome clearLegacyPaths(File cache, CacheCleanupMode mode)"); + assertTrue(legacy.contains("long retentionMs = explicitRetention(mode, LEGACY_RETENTION_MS);")); + + // 3. No call site may pass a background window as a literal. This is the assertion that + // fails if the temporary-file row is ever reverted to the 24-hour window. + for (String raw : (execution + legacy).split("\n")) { + String line = raw.trim(); + if (line.startsWith("//") || line.startsWith("*")) continue; + if (!line.contains("TEMP_RETENTION_MS") && !line.contains("LEGACY_RETENTION_MS")) continue; + assertTrue("a retention window is decided at the call site instead of by explicitRetention: " + + line, line.contains("explicitRetention(")); + } + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("missing production method: " + signature, start >= 0); + int end = source.indexOf("\n }\n", start); + assertTrue("unterminated production method: " + signature, end > start); + return source.substring(start, end); + } + + private static String productionSource() throws Exception { + Path relative = Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", + "cache", "CacheCleanupManager.java"); + Path path = Files.exists(relative) ? relative : Path.of("..").resolve(relative).normalize(); + return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n"); + } + + private static File write(File dir, String name, int size) throws Exception { + File file = new File(dir, name); + File parent = file.getParentFile(); + assertTrue("could not create " + parent, parent != null && (parent.isDirectory() || parent.mkdirs())); + Files.write(file.toPath(), new byte[size]); + return file; + } + + private static File cacheDir(String prefix) throws Exception { + Path base = Path.of(System.getProperty("java.io.tmpdir")).toRealPath(); + return Files.createTempDirectory(base, prefix).toFile(); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheInventoryTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheInventoryTest.java new file mode 100644 index 0000000000..15ab92393b --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CacheInventoryTest.java @@ -0,0 +1,143 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +public class CacheInventoryTest { + + @Test + public void percentageHandlesZeroSmallAndNormalValues() { + assertEquals("0%", CacheFormat.percent(0, 100)); + assertEquals("<0.1%", CacheFormat.percent(1, 10_000)); + assertEquals("25.0%", CacheFormat.percent(25, 100)); + } + + @Test + public void measuresRecursiveFilesAndTimes() throws Exception { + Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-inventory-recursive"); + try { + Files.writeString(root.resolve("a.bin"), "abc", StandardCharsets.UTF_8); + Files.createDirectories(root.resolve("nested")); + Files.writeString(root.resolve("nested/b.bin"), "12345", StandardCharsets.UTF_8); + + CacheMeasurement result = CacheInventory.measureRoots( + CacheModuleId.EXO, List.of(CacheRoot.tree(root.toFile()))); + + assertEquals(8, result.bytes()); + assertEquals(2, result.fileCount()); + assertEquals(CacheAvailability.AVAILABLE, result.availability()); + } finally { + delete(root.toFile()); + } + } + + @Test + public void filesOnlyRootCountsDirectChildrenAndAppliesSuffixFilter() throws Exception { + Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-inventory-files"); + try { + Files.writeString(root.resolve("keep.apk"), "1234", StandardCharsets.UTF_8); + Files.writeString(root.resolve("skip.zip"), "123456", StandardCharsets.UTF_8); + Files.createDirectories(root.resolve("nested")); + Files.writeString(root.resolve("nested/keep.apk"), "12345678", StandardCharsets.UTF_8); + + CacheMeasurement result = CacheInventory.measureRoots( + CacheModuleId.TEMP_FILES, + List.of(CacheRoot.files(root.toFile(), Set.of(".apk"), Set.of()))); + + assertEquals(4, result.bytes()); + assertEquals(1, result.fileCount()); + } finally { + delete(root.toFile()); + } + } + + @Test + public void excludesProtectedFiles() throws Exception { + Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-inventory-protected"); + try { + Files.writeString(root.resolve("update.apk"), "12345", StandardCharsets.UTF_8); + Files.writeString(root.resolve("mpv-playback-recovery.lock"), "ignored", StandardCharsets.UTF_8); + + CacheMeasurement result = CacheInventory.measureRoots( + CacheModuleId.TEMP_FILES, + List.of(CacheRoot.files(root.toFile(), Set.of(), Set.of("mpv-playback-recovery.lock")))); + + assertEquals(5, result.bytes()); + assertEquals(1, result.fileCount()); + } finally { + delete(root.toFile()); + } + } + + @Test + public void missingDirectoryIsEmptyAndDoesNotCreateIt() throws Exception { + Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-inventory-missing").resolve("missing"); + + CacheMeasurement result = CacheInventory.measureRoots( + CacheModuleId.LYRICS, List.of(CacheRoot.tree(root.toFile()))); + + assertEquals(0, result.bytes()); + assertEquals(0, result.fileCount()); + assertEquals(CacheAvailability.EMPTY, result.availability()); + assertEquals(false, Files.exists(root)); + } + + @Test + public void orphanRootSkipsManagedDirectories() throws Exception { + Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-inventory-orphan"); + try { + Files.createDirectories(root.resolve("exo")); + Files.writeString(root.resolve("exo/managed.bin"), "123456", StandardCharsets.UTF_8); + Files.writeString(root.resolve("orphan.tmp"), "12", StandardCharsets.UTF_8); + Files.writeString(root.resolve("keep.txt"), "1234", StandardCharsets.UTF_8); + + CacheMeasurement result = CacheInventory.measureRoots( + CacheModuleId.LEGACY_FILES, + List.of(CacheRoot.orphanTree(root.toFile(), Set.of("exo"), Set.of(".tmp")))); + + assertEquals(4, result.bytes()); + assertEquals(1, result.fileCount()); + } finally { + delete(root.toFile()); + } + } + + /** + * A name excluded by prefix applies to both files and directories, so an owner-managed family + * such as the diagnostic logs is never reported as unclassified cache. + */ + @Test + public void orphanRootHonoursExcludePrefixes() throws Exception { + Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-inventory-orphan-prefix"); + try { + Files.writeString(root.resolve("webhtv-debug-log.txt"), "1234", StandardCharsets.UTF_8); + Files.writeString(root.resolve("webhtv-diagnostic-incident-0.txt"), "12", StandardCharsets.UTF_8); + Files.writeString(root.resolve("unknown.bin"), "123456", StandardCharsets.UTF_8); + + CacheMeasurement result = CacheInventory.measureRoots( + CacheModuleId.UNCLASSIFIED, + List.of(CacheRoot.orphanTree(root.toFile(), Set.of(), Set.of(), + Set.of("webhtv-debug-log", "webhtv-diagnostic-incident-")))); + + assertEquals(6, result.bytes()); + assertEquals(1, result.fileCount()); + } finally { + delete(root.toFile()); + } + } + + private static void delete(File file) { + File[] children = file.listFiles(); + if (children != null) for (File child : children) delete(child); + //noinspection ResultOfMethodCallIgnored + file.delete(); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheLegacyRulesTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheLegacyRulesTest.java new file mode 100644 index 0000000000..9ed93046c4 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CacheLegacyRulesTest.java @@ -0,0 +1,161 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Guards the versioned legacy rule table (design §9.13). + * + *

The regression these tests lock down: the legacy module used to measure an inverted orphan + * tree while cleaning only one directory, so its reported size could never be deleted.

+ */ +public class CacheLegacyRulesTest { + + @Test + public void everyRuleIsDeclaredWithAVersionAndReason() { + List rules = CacheLegacyRules.rules(); + assertFalse("the rule table must not be empty", rules.isEmpty()); + HashSet names = new HashSet<>(); + int previousVersion = 0; + for (CacheLegacyRules.Rule rule : rules) { + assertTrue("rule needs a name", rule.name() != null && !rule.name().isBlank()); + assertTrue("duplicate rule name: " + rule.name(), names.add(rule.name())); + assertTrue("a rule must not contain a path separator: " + rule.name(), + !rule.name().contains("/") && !rule.name().contains("\\")); + assertTrue("rule version must be positive: " + rule.name(), rule.sinceVersion() >= 1); + assertTrue("rule versions must be declared oldest first: " + rule.name(), + rule.sinceVersion() >= previousVersion); + previousVersion = rule.sinceVersion(); + assertTrue("rule needs a documented reason: " + rule.name(), + rule.note() != null && !rule.note().isBlank()); + } + assertEquals("table version must cover every declared rule", + previousVersion, CacheLegacyRules.TABLE_VERSION); + } + + @Test + public void measurementRootsMatchTheDeclaredRulesExactly() throws Exception { + File cache = cacheDir("legacy-roots"); + + List roots = CacheLegacyRules.roots(cache); + HashSet rootNames = new HashSet<>(); + for (CacheRoot root : roots) rootNames.add(root.root().getName()); + + assertEquals(CacheLegacyRules.rules().size(), roots.size()); + assertEquals(new HashSet<>(CacheLegacyRules.names()), rootNames); + for (CacheRoot root : roots) { + assertTrue("legacy root must be measurable as a tree: " + root.root(), + root.recursive() && root.unfiltered()); + assertTrue("legacy root must stay inside the cache dir: " + root.root(), + root.root().getCanonicalPath().startsWith(cache.getCanonicalPath() + File.separator)); + } + } + + /** + * The decisive regression test: the module's measured set equals the set a cleanup iterates, + * because both are built from {@link CacheLegacyRules#rules()}. + */ + @Test + public void moduleRootsAreBuiltFromTheSameRuleTableCleanupUses() throws Exception { + File cache = cacheDir("legacy-consistency"); + + CacheModule legacy = null; + for (CacheModule module : CacheModuleRegistry.modules(cache)) { + if (module.id() == CacheModuleId.LEGACY_FILES) legacy = module; + } + assertTrue("legacy module must exist", legacy != null); + + Set moduleRootNames = new HashSet<>(); + for (CacheRoot root : legacy.roots()) moduleRootNames.add(root.root().getName()); + + Set cleanedNames = new HashSet<>(); + for (CacheLegacyRules.Rule rule : CacheLegacyRules.rules()) cleanedNames.add(rule.name()); + + assertEquals("legacy must measure exactly what it cleans", cleanedNames, moduleRootNames); + } + + @Test + public void declaredLegacyNamesStayOutOfTheUnclassifiedReport() throws Exception { + File cache = cacheDir("legacy-not-unclassified"); + + CacheModule unclassified = null; + for (CacheModule module : CacheModuleRegistry.modules(cache)) { + if (module.id() == CacheModuleId.UNCLASSIFIED) unclassified = module; + } + assertTrue("unclassified module must exist", unclassified != null); + + for (CacheRoot root : unclassified.roots()) { + for (String name : CacheLegacyRules.names()) { + assertTrue("legacy path must be excluded from unclassified: " + name, + root.excludeNames().contains(name)); + } + } + } + + /** + * The declared rule must not also be registered as a managed root: that is precisely the old + * incoherence, where {@code restore-legacy} was excluded from measurement and then cleaned. + */ + @Test + public void declaredRulePathsAreHonouredByMeasurement() throws Exception { + Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), + "legacy-measure"); + try { + Files.createDirectories(root.resolve("restore-legacy")); + Files.writeString(root.resolve("restore-legacy/leftover.bin"), "12345678"); + Files.createDirectories(root.resolve("subtitle_asset")); + Files.writeString(root.resolve("subtitle_asset/old.bin"), "1234"); + + CacheMeasurement legacy = CacheInventory.measureRoots( + CacheModuleId.LEGACY_FILES, CacheLegacyRules.roots(root.toFile())); + + assertEquals("both declared legacy paths must be measured", 12, legacy.bytes()); + assertEquals(2, legacy.fileCount()); + } finally { + delete(root.toFile()); + } + } + + /** A path that is not in the table must not be reported as legacy. */ + @Test + public void undeclaredDirectoriesAreNotLegacy() throws Exception { + Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), + "legacy-undeclared"); + try { + Files.createDirectories(root.resolve("plugin-preheat")); + Files.writeString(root.resolve("plugin-preheat/live-cache.txt"), "123456"); + Files.writeString(root.resolve("webhtv-debug-log.txt"), "1234"); + + CacheMeasurement legacy = CacheInventory.measureRoots( + CacheModuleId.LEGACY_FILES, CacheLegacyRules.roots(root.toFile())); + + assertEquals("an unregistered live cache must not be reported as legacy", + 0, legacy.bytes()); + assertEquals(0, legacy.fileCount()); + } finally { + delete(root.toFile()); + } + } + + private static File cacheDir(String prefix) throws Exception { + Path base = Path.of(System.getProperty("java.io.tmpdir")).toRealPath(); + return Files.createTempDirectory(base, prefix).toFile(); + } + + private static void delete(File file) { + File[] children = file.listFiles(); + if (children != null) for (File child : children) delete(child); + //noinspection ResultOfMethodCallIgnored + file.delete(); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheModuleRegistryTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheModuleRegistryTest.java new file mode 100644 index 0000000000..aec914a92c --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CacheModuleRegistryTest.java @@ -0,0 +1,154 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class CacheModuleRegistryTest { + + @Test + public void registryDefinesEveryModuleExactlyOnce() throws Exception { + File cache = cacheDir("registry-ids"); + List modules = CacheModuleRegistry.modules(cache); + + EnumSet ids = EnumSet.noneOf(CacheModuleId.class); + for (CacheModule module : modules) { + assertTrue("duplicate id " + module.id(), ids.add(module.id())); + assertFalse("module without roots: " + module.id(), module.roots().isEmpty()); + } + assertEquals(EnumSet.allOf(CacheModuleId.class), ids); + } + + @Test + public void everyRootStaysInsideCacheDir() throws Exception { + File cache = cacheDir("registry-inside"); + String cacheKey = cache.getCanonicalPath(); + + for (CacheModule module : CacheModuleRegistry.modules(cache)) { + for (CacheRoot root : module.roots()) { + String key = root.root().getCanonicalPath(); + assertTrue(module.id() + " escapes cache dir: " + key, + key.equals(cacheKey) || key.startsWith(cacheKey + File.separator)); + } + } + } + + @Test + public void productionRegistryPassesValidation() throws Exception { + File cache = cacheDir("registry-valid"); + List modules = CacheModuleRegistry.modules(cache); + assertEquals(List.of(), CacheModuleRegistry.validate(cache, modules)); + } + + @Test + public void validationRejectsRootOutsideCacheDir() throws Exception { + File cache = cacheDir("registry-escape"); + File outside = cacheDir("registry-outside"); + ArrayList modules = new ArrayList<>(CacheModuleRegistry.modules(cache)); + modules.add(module(CacheModuleId.EXO, new File(outside, "exo"))); + + List problems = CacheModuleRegistry.validate(cache, modules); + + assertTrue(problems.toString(), problems.stream().anyMatch(p -> p.contains("escapes cache dir"))); + } + + @Test + public void validationRejectsNestedTreeRoots() throws Exception { + File cache = cacheDir("registry-nested"); + ArrayList modules = new ArrayList<>(); + modules.add(module(CacheModuleId.EXO, new File(cache, "outer"))); + modules.add(module(CacheModuleId.LYRICS, new File(cache, "outer/inner"))); + + List problems = CacheModuleRegistry.validate(cache, modules); + + assertTrue(problems.toString(), problems.stream().anyMatch(p -> p.contains("nested tree root"))); + } + + @Test + public void validationRejectsDuplicateTreeRoot() throws Exception { + File cache = cacheDir("registry-duplicate"); + ArrayList modules = new ArrayList<>(); + modules.add(module(CacheModuleId.EXO, new File(cache, "shared"))); + modules.add(module(CacheModuleId.LYRICS, new File(cache, "shared"))); + + List problems = CacheModuleRegistry.validate(cache, modules); + + assertTrue(problems.toString(), problems.stream().anyMatch(p -> p.contains("overlapping tree roots"))); + } + + @Test + public void registryIsPureFunctionOfCacheDir() throws Exception { + File first = cacheDir("registry-pure-a"); + File second = cacheDir("registry-pure-b"); + + Set firstIds = idsOf(CacheModuleRegistry.modules(first)); + Set secondIds = idsOf(CacheModuleRegistry.modules(second)); + assertEquals(firstIds, secondIds); + + for (CacheRoot root : rootsOf(CacheModuleRegistry.modules(second))) { + assertTrue("root must follow the supplied cache dir: " + root.root(), + root.root().getCanonicalPath().startsWith(second.getCanonicalPath())); + } + assertEquals(CacheModuleRegistry.modules(first), CacheModuleRegistry.modules(first)); + } + + private static Set idsOf(List modules) { + return new HashSet<>(modules.stream().map(CacheModule::id).toList()); + } + + private static List rootsOf(List modules) { + ArrayList roots = new ArrayList<>(); + for (CacheModule module : modules) roots.addAll(module.roots()); + return roots; + } + + private static CacheModule module(CacheModuleId id, File root) { + return new CacheModule() { + @Override + public CacheModuleId id() { + return id; + } + + @Override + public CacheGroup group() { + return CacheGroup.LEGACY; + } + + @Override + public List roots() { + return List.of(CacheRoot.tree(root)); + } + + @Override + public CacheCleanability cleanability() { + return CacheCleanability.SAFE_NOW; + } + + @Override + public CacheEvictionPolicy evictionPolicy() { + return CacheEvictionPolicy.AGE_BASED; + } + + @Override + public CacheProtection protection() { + return new CacheProtection(true, true, false, false, 0, 0, false); + } + }; + } + + private static File cacheDir(String prefix) throws Exception { + Path base = Path.of(System.getProperty("java.io.tmpdir")).toRealPath(); + return Files.createTempDirectory(base, prefix).toFile(); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CachePathSafetyTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CachePathSafetyTest.java new file mode 100644 index 0000000000..d88a5c14df --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CachePathSafetyTest.java @@ -0,0 +1,92 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Assume; +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystemException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public class CachePathSafetyTest { + + @Test + public void ancestorSymlinkDoesNotHideRegularCacheFiles() throws Exception { + Path real = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-alias-real"); + Path alias = real.getParent().resolve(real.getFileName() + "-alias"); + try { + Files.createSymbolicLink(alias, real); + } catch (UnsupportedOperationException | FileSystemException error) { + delete(real.toFile()); + Assume.assumeTrue("symbolic links unavailable", false); + } + try { + Files.createDirectories(real.resolve("data/lyrics")); + Files.writeString(real.resolve("data/lyrics/marker.bin"), "1234", StandardCharsets.UTF_8); + + File aliased = alias.resolve("data/lyrics/marker.bin").toFile(); + assertTrue(aliased.isFile()); + assertFalse(CachePathSafety.isSymbolicLink(aliased)); + + CacheMeasurement result = CacheInventory.measureRoots( + CacheModuleId.LYRICS, List.of(CacheRoot.tree(alias.resolve("data/lyrics").toFile()))); + + assertEquals(4, result.bytes()); + assertEquals(1, result.fileCount()); + assertEquals(CacheAvailability.AVAILABLE, result.availability()); + } finally { + Files.deleteIfExists(alias); + delete(real.toFile()); + } + } + + @Test + public void finalComponentSymlinkIsStillDetected() throws Exception { + Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-link-root"); + Path target = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), "cache-link-target"); + try { + Files.writeString(target.resolve("victim.bin"), "secret", StandardCharsets.UTF_8); + try { + Files.createSymbolicLink(root.resolve("link.bin"), target.resolve("victim.bin")); + } catch (UnsupportedOperationException | FileSystemException error) { + Assume.assumeTrue("symbolic links unavailable", false); + } + + // The production check compares the canonical path with the parent-canonical + name + // composition. That comparison only sees a link when File.getCanonicalFile() resolves + // it, which is the documented behaviour on Linux/Android but not on every host JVM + // (Windows keeps the link path). Skip on hosts whose canonicalization does not follow + // links, otherwise this test would fail for platform reasons instead of code reasons. + File link = root.resolve("link.bin").toFile(); + Assume.assumeTrue("File.getCanonicalFile() does not follow symbolic links on this host", + !link.getCanonicalFile().equals(link.getAbsoluteFile())); + + assertTrue(CachePathSafety.isSymbolicLink(link)); + + CacheMeasurement result = CacheInventory.measureRoots( + CacheModuleId.LYRICS, List.of(CacheRoot.tree(root.toFile()))); + + assertEquals(0, result.bytes()); + assertEquals(0, result.fileCount()); + assertEquals(CacheAvailability.PARTIAL, result.availability()); + assertTrue(target.resolve("victim.bin").toFile().exists()); + } finally { + Files.deleteIfExists(root.resolve("link.bin")); + delete(root.toFile()); + delete(target.toFile()); + } + } + + private static void delete(File file) { + File[] children = file.listFiles(); + if (children != null) for (File child : children) delete(child); + //noinspection ResultOfMethodCallIgnored + file.delete(); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CachePolicyEngineTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CachePolicyEngineTest.java new file mode 100644 index 0000000000..1ea8ddc394 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CachePolicyEngineTest.java @@ -0,0 +1,134 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.File; +import java.util.List; + +public class CachePolicyEngineTest { + + @Test + public void lightPlanContainsOnlyLowRiskModules() { + assertEquals(List.of(CacheModuleId.EPG, CacheModuleId.TEMP_FILES, CacheModuleId.LEGACY_FILES), + CachePolicyEngine.plan(CacheCleanupMode.LIGHT).modules()); + } + + @Test + public void standardPlanNeverContainsPlaybackCache() { + List modules = CachePolicyEngine.plan(CacheCleanupMode.STANDARD).modules(); + assertFalse(modules.contains(CacheModuleId.EXO)); + assertFalse(modules.contains(CacheModuleId.MPV_HLS)); + assertFalse(modules.contains(CacheModuleId.MPV_DEMUXER)); + assertFalse(modules.contains(CacheModuleId.MPV_RUNTIME)); + } + + @Test + public void deepPlanCoversPlaybackAndMediaExactlyOnce() { + List modules = CachePolicyEngine.plan(CacheCleanupMode.DEEP).modules(); + assertEquals(CacheModuleId.values().length - 2, modules.size()); + assertEquals(modules.size(), modules.stream().distinct().count()); + // L3 = L2 + playback caches. The report-only modules are excluded by design. + assertTrue(modules.contains(CacheModuleId.EXO)); + assertTrue(modules.contains(CacheModuleId.MPV_HLS)); + assertTrue(modules.contains(CacheModuleId.MPV_DEMUXER)); + assertTrue(modules.contains(CacheModuleId.MPV_RUNTIME)); + assertFalse(modules.contains(CacheModuleId.DIAGNOSTIC_LOGS)); + assertFalse(modules.contains(CacheModuleId.UNCLASSIFIED)); + } + + /** + * The legacy module's cleanup must be reached by the tiered plans too, since that is the entry + * the user actually pressed when the cleanup reported "0 files deleted". + */ + @Test + public void legacyModuleIsPartOfEveryTierThatClaimsIt() { + assertTrue(CachePolicyEngine.plan(CacheCleanupMode.LIGHT).modules() + .contains(CacheModuleId.LEGACY_FILES)); + assertTrue(CachePolicyEngine.plan(CacheCleanupMode.STANDARD).modules() + .contains(CacheModuleId.LEGACY_FILES)); + assertTrue(CachePolicyEngine.plan(CacheCleanupMode.DEEP).modules() + .contains(CacheModuleId.LEGACY_FILES)); + } + + /** + * Diagnostic logs are manually cleanable through their own module row, but no tiered run and + * no automatic run may remove them: they are the evidence a user may still need. + */ + @Test + public void diagnosticLogsAreManualOnly() { + assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.DIAGNOSTIC_LOGS)); + assertFalse(CachePolicyEngine.plan(CacheCleanupMode.LIGHT).modules() + .contains(CacheModuleId.DIAGNOSTIC_LOGS)); + assertFalse(CachePolicyEngine.plan(CacheCleanupMode.STANDARD).modules() + .contains(CacheModuleId.DIAGNOSTIC_LOGS)); + assertFalse(CachePolicyEngine.plan(CacheCleanupMode.DEEP).modules() + .contains(CacheModuleId.DIAGNOSTIC_LOGS)); + assertEquals(List.of(CacheModuleId.DIAGNOSTIC_LOGS), + CachePolicyEngine.module(CacheModuleId.DIAGNOSTIC_LOGS).modules()); + } + + /** Unclassified cache is report-only: it must never be offered as cleanable. */ + @Test + public void unclassifiedCacheIsNotDirectlyCleanable() { + assertEquals(CacheCleanupStatus.NOT_ALLOWED, + CachePolicyEngine.directCleanupStatus(CacheModuleId.UNCLASSIFIED, false)); + assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.UNCLASSIFIED)); + } + + /** The UI restriction must come from the registry, not from a hardcoded module check. */ + @Test + public void manualCleanupPermissionComesFromTheRegistry() { + File cache = new File(System.getProperty("java.io.tmpdir")); + assertFalse(CachePolicyEngine.manualCleanupAllowed(CacheModuleId.PLUGIN_SCRIPTS, cache)); + assertFalse(CachePolicyEngine.manualCleanupAllowed(CacheModuleId.UNCLASSIFIED, cache)); + assertTrue(CachePolicyEngine.manualCleanupAllowed(CacheModuleId.LEGACY_FILES, cache)); + assertTrue(CachePolicyEngine.manualCleanupAllowed(CacheModuleId.DIAGNOSTIC_LOGS, cache)); + assertFalse(CachePolicyEngine.manualCleanupAllowed(null, cache)); + assertFalse(CachePolicyEngine.manualCleanupAllowed(CacheModuleId.LEGACY_FILES, null)); + } + + /** + * The settings row's plain click clears every module, including the report-only ones a tiered + * plan deliberately protects, and it is the mode that selects the full clean path. + */ + @Test + public void fullPlanCoversEveryModuleExactlyOnce() { + CacheCleanupPlan plan = CachePolicyEngine.plan(CacheCleanupMode.FULL); + assertEquals(CacheCleanupMode.FULL, plan.mode()); + assertEquals(CacheModuleId.values().length, plan.modules().size()); + assertEquals(plan.modules().size(), plan.modules().stream().distinct().count()); + assertTrue(plan.modules().contains(CacheModuleId.EXO)); + assertTrue(plan.modules().contains(CacheModuleId.DIAGNOSTIC_LOGS)); + assertTrue(plan.modules().contains(CacheModuleId.UNCLASSIFIED)); + } + + @Test + public void modulePlanIsExplicitAndSingleModule() { + assertEquals(List.of(CacheModuleId.LYRICS), + CachePolicyEngine.module(CacheModuleId.LYRICS).modules()); + } + + @Test + public void playbackModulesAreDeferredWhilePlaying() { + assertEquals(CacheCleanupStatus.DEFERRED, + CachePolicyEngine.directCleanupStatus(CacheModuleId.EXO, true)); + assertEquals(CacheCleanupStatus.DEFERRED, + CachePolicyEngine.directCleanupStatus(CacheModuleId.MPV_HLS, true)); + assertEquals(CacheCleanupStatus.COMPLETED, + CachePolicyEngine.directCleanupStatus(CacheModuleId.EXO, false)); + } + + @Test + public void automaticCleanupExcludesPlaybackAndOwnerManagedModules() { + assertTrue(CachePolicyEngine.allowsAutomatic(CacheModuleId.LYRICS)); + assertTrue(CachePolicyEngine.allowsAutomatic(CacheModuleId.EPG)); + assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.EXO)); + assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.WEBHOME_RAW)); + assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.GLIDE)); + assertFalse(CachePolicyEngine.allowsAutomatic(CacheModuleId.PLUGIN_SCRIPTS)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CachePolicyStoreTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CachePolicyStoreTest.java new file mode 100644 index 0000000000..8727b541a1 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CachePolicyStoreTest.java @@ -0,0 +1,27 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class CachePolicyStoreTest { + + @Test + public void featureSwitchDefaultsToEnabledWhenUnset() { + assertTrue(CachePolicyStore.enabledByDefault(null)); + } + + @Test + public void featureSwitchRespectsExplicitValues() { + assertTrue(CachePolicyStore.enabledByDefault(true)); + assertFalse(CachePolicyStore.enabledByDefault(false)); + } + + @Test + public void migrationRunsOnlyForOlderSchemas() { + assertTrue("fresh install must migrate", CachePolicyStore.needsMigration(0)); + assertFalse("current schema must be a no-op", CachePolicyStore.needsMigration(1)); + assertFalse("newer/unknown schema must keep working", CachePolicyStore.needsMigration(99)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheRetentionManagerTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheRetentionManagerTest.java new file mode 100644 index 0000000000..b6dce34371 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CacheRetentionManagerTest.java @@ -0,0 +1,186 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Assume; +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.FileSystemException; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +public class CacheRetentionManagerTest { + + @Test + public void enforceLimitDeletesOldestFilesFirst() throws Exception { + Path root = Files.createTempDirectory("cache-retention"); + try { + File old = write(root, "old.bin", "1234567890", 1_000L); + File fresh = write(root, "fresh.bin", "1234567890", 2_000L); + + boolean success = CacheRetentionManager.applyLimit(root.toFile(), 10, + 0, () -> 3_000L, Set.of()); + + assertTrue(success); + assertFalse(old.exists()); + assertFalse(fresh.exists()); + } finally { + delete(root.toFile()); + } + } + + @Test + public void retentionDeletesOnlyExpiredFiles() throws Exception { + Path root = Files.createTempDirectory("cache-retention-ttl"); + try { + File expired = write(root, "expired.bin", "123", 1_000L); + File fresh = write(root, "fresh.bin", "123", 9_000L); + + AtomicLong now = new AtomicLong(10_000L); + boolean success = CacheRetentionManager.applyLimit(root.toFile(), 0, + 5_000L, now::get, Set.of()); + + assertTrue(success); + assertFalse(expired.exists()); + assertTrue(fresh.exists()); + } finally { + delete(root.toFile()); + } + } + + @Test + public void excludedNamesAreNeverDeleted() throws Exception { + Path root = Files.createTempDirectory("cache-retention-exclude"); + try { + File keep = write(root, "keep.lock", "123", 1L); + CacheRetentionManager.applyLimit(root.toFile(), 0, 1L, + () -> 10_000L, Set.of("keep.lock")); + assertTrue(keep.exists()); + } finally { + delete(root.toFile()); + } + } + + @Test + public void symbolicLinksAreNotFollowedOrDeleted() throws Exception { + Path outside = Files.createTempDirectory("cache-retention-outside"); + Path root = Files.createTempDirectory("cache-retention-link"); + File victim = write(outside, "victim.bin", "secret", 1L); + try { + try { + Files.createSymbolicLink(root.resolve("link.bin"), victim.toPath()); + } catch (UnsupportedOperationException | FileSystemException error) { + Assume.assumeTrue("symbolic links unavailable", false); + } + + boolean success = CacheRetentionManager.applyLimit(root.toFile(), 0, 1L, + () -> 10_000L, Set.of()); + + assertTrue(success); + assertTrue(victim.exists()); + } finally { + delete(root.toFile()); + delete(outside.toFile()); + } + } + + @Test + public void fileLimitKeepsFilesYoungerThanMinimumAge() throws Exception { + Path root = Files.createTempDirectory("cache-retention-file-limit"); + try { + File old = write(root, "old.tmp", "1234567890", 1_000L); + File young = write(root, "young.tmp", "1234567890", 9_500L); + boolean success = CacheRetentionManager.enforceFileLimit( + root.toFile(), List.of(old, young), 10, 1_000L, 10_000L); + assertTrue(success); + assertFalse(old.exists()); + assertTrue(young.exists()); + } finally { + delete(root.toFile()); + } + } + + @Test + public void fileLimitNeverDeletesOutsideAllowedRoot() throws Exception { + Path root = Files.createTempDirectory("cache-retention-bounds-root"); + Path outside = Files.createTempDirectory("cache-retention-bounds-outside"); + try { + File victim = write(outside, "victim.tmp", "1234567890", 1_000L); + + boolean success = CacheRetentionManager.enforceFileLimit( + root.toFile(), List.of(victim), 5, 0L, 10_000L); + + assertTrue(success); + assertTrue("file outside the declared root must be preserved", victim.exists()); + } finally { + delete(root.toFile()); + delete(outside.toFile()); + } + } + + @Test + public void fileLimitSkipsSymbolicLinksInsideRoot() throws Exception { + Path root = Files.createTempDirectory("cache-retention-bounds-link"); + Path outside = Files.createTempDirectory("cache-retention-link-target"); + try { + File victim = write(outside, "target.tmp", "1234567890", 1_000L); + java.nio.file.Path link = root.resolve("link.tmp"); + try { + Files.createSymbolicLink(link, victim.toPath()); + } catch (UnsupportedOperationException | java.nio.file.FileSystemException error) { + org.junit.Assume.assumeTrue("symbolic links unavailable", false); + } + + boolean success = CacheRetentionManager.enforceFileLimit( + root.toFile(), List.of(link.toFile()), 5, 0L, 10_000L); + + assertTrue(success); + assertTrue("symlink target must be preserved", victim.exists()); + } finally { + delete(root.toFile()); + delete(outside.toFile()); + } + } + + @Test + public void pluginLimitExcludesActiveFile() throws Exception { + Path root = Files.createTempDirectory("cache-plugin-retention"); + try { + File expired = write(root, "expired.jar", "1234", 1_000L); + File active = write(root, "active.jar", "1234", 1_000L); + File fresh = write(root, "fresh.jar", "1234", 9_500L); + + boolean success = CacheRetentionManager.applyLimit(root.toFile(), 0, + 5_000L, () -> 10_000L, Set.of("active.jar")); + + assertTrue(success); + assertFalse(expired.exists()); + assertFalse(expired.exists()); + assertTrue(active.exists()); + assertTrue(fresh.exists()); + } finally { + delete(root.toFile()); + } + } + + private static File write(Path root, String name, String value, long modified) throws Exception { + Path file = root.resolve(name); + Files.writeString(file, value, StandardCharsets.UTF_8); + assertTrue(file.toFile().setLastModified(modified)); + return file.toFile(); + } + + private static void delete(File file) { + File[] children = file.listFiles(); + if (children != null) for (File child : children) delete(child); + //noinspection ResultOfMethodCallIgnored + file.delete(); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheRootPrefixTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheRootPrefixTest.java new file mode 100644 index 0000000000..2db1acc3ac --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CacheRootPrefixTest.java @@ -0,0 +1,97 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +/** + * Covers the prefix filters added for owners that rotate one logical file under derived names, + * such as the diagnostic log family ({@code webhtv-debug-log.txt}, {@code ...txt.1}, + * {@code webhtv-diagnostic-incident-0.txt}). A suffix filter can not address that family. + */ +public class CacheRootPrefixTest { + + @Test + public void includePrefixCountsOnlyTheDeclaredFamily() throws Exception { + Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), + "root-prefix-include"); + try { + Files.writeString(root.resolve("webhtv-debug-log.txt"), "1234", StandardCharsets.UTF_8); + Files.writeString(root.resolve("webhtv-debug-log.txt.1"), "123456", StandardCharsets.UTF_8); + Files.writeString(root.resolve("webhtv-diagnostic-incident-0.txt"), "12", StandardCharsets.UTF_8); + Files.writeString(root.resolve("update.apk"), "12345678", StandardCharsets.UTF_8); + Files.writeString(root.resolve("tv.lck"), "1", StandardCharsets.UTF_8); + + CacheMeasurement result = CacheInventory.measureRoots(CacheModuleId.DIAGNOSTIC_LOGS, + List.of(CacheRoot.prefixedFiles(root.toFile(), + Set.of("webhtv-debug-log", "webhtv-diagnostic-incident-"), Set.of()))); + + assertEquals(12, result.bytes()); + assertEquals(3, result.fileCount()); + } finally { + delete(root.toFile()); + } + } + + @Test + public void excludePrefixWinsOverIncludePrefix() throws Exception { + Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), + "root-prefix-exclude"); + try { + Files.writeString(root.resolve("webhtv-debug-log.txt"), "1234", StandardCharsets.UTF_8); + Files.writeString(root.resolve("webhtv-diagnostics.journal"), "123456", StandardCharsets.UTF_8); + + CacheMeasurement result = CacheInventory.measureRoots(CacheModuleId.DIAGNOSTIC_LOGS, + List.of(CacheRoot.prefixedFiles(root.toFile(), + Set.of("webhtv-"), Set.of("webhtv-diagnostics.journal")))); + + assertEquals(4, result.bytes()); + assertEquals(1, result.fileCount()); + } finally { + delete(root.toFile()); + } + } + + /** An unfiltered recursive root must still count everything below it. */ + @Test + public void prefixFiltersDoNotChangePlainTreeBehaviour() throws Exception { + Path root = Files.createTempDirectory(Path.of(System.getProperty("java.io.tmpdir")).toRealPath(), + "root-prefix-tree"); + try { + Files.createDirectories(root.resolve("nested")); + Files.writeString(root.resolve("nested/a.txt"), "12345", StandardCharsets.UTF_8); + + CacheMeasurement result = CacheInventory.measureRoots(CacheModuleId.LYRICS, + List.of(CacheRoot.tree(root.toFile()))); + + assertEquals(5, result.bytes()); + assertEquals(1, result.fileCount()); + } finally { + delete(root.toFile()); + } + } + + @Test + public void unfilteredReportsOnlyUnfilteredRoots() { + File any = new File(System.getProperty("java.io.tmpdir")); + assertTrue(CacheRoot.tree(any).unfiltered()); + assertFalse(CacheRoot.prefixedFiles(any, Set.of("a"), Set.of()).unfiltered()); + assertFalse(CacheRoot.files(any, Set.of(".apk"), Set.of()).unfiltered()); + } + + private static void delete(File file) { + File[] children = file.listFiles(); + if (children != null) for (File child : children) delete(child); + //noinspection ResultOfMethodCallIgnored + file.delete(); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheTempFilePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheTempFilePolicyTest.java new file mode 100644 index 0000000000..befe609ab0 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CacheTempFilePolicyTest.java @@ -0,0 +1,29 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class CacheTempFilePolicyTest { + + @Test + public void inFlightUpdateIsProtected() { + assertTrue(CacheTempFilePolicy.isInUse("update.apk", true, false)); + assertFalse(CacheTempFilePolicy.isInUse("update.apk", false, false)); + } + + @Test + public void inFlightApkUrlPushIsProtected() { + assertTrue(CacheTempFilePolicy.isInUse("pushed-url-123.apk", false, true)); + assertFalse(CacheTempFilePolicy.isInUse("pushed-url-123.apk", false, false)); + } + + @Test + public void unrelatedFilesAreNeverMarkedInUse() { + assertFalse(CacheTempFilePolicy.isInUse("webhtv-sync-1.zip", true, true)); + assertFalse(CacheTempFilePolicy.isInUse("pushed-123.apk", true, true)); + assertFalse(CacheTempFilePolicy.isInUse(null, true, true)); + assertFalse(CacheTempFilePolicy.isInUse("", true, true)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/cache/CacheTotalLimitPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/cache/CacheTotalLimitPolicyTest.java new file mode 100644 index 0000000000..be5eacef87 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/cache/CacheTotalLimitPolicyTest.java @@ -0,0 +1,28 @@ +package com.fongmi.android.tv.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class CacheTotalLimitPolicyTest { + + @Test + public void unlimitedWhenNotConfigured() { + assertEquals(0, CacheTotalLimitPolicy.effectiveLimit(0, 1024)); + assertFalse(CacheTotalLimitPolicy.overLimit(4096, 0)); + } + + @Test + public void systemQuotaClampsUserLimit() { + assertEquals(512, CacheTotalLimitPolicy.effectiveLimit(1024, 512)); + assertEquals(1024, CacheTotalLimitPolicy.effectiveLimit(1024, 0)); + } + + @Test + public void overLimitOnlyWhenEffectiveLimitIsPositive() { + assertTrue(CacheTotalLimitPolicy.overLimit(600, 512)); + assertFalse(CacheTotalLimitPolicy.overLimit(500, 512)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/compat/Android7ApiCompatContractTest.java b/app/src/test/java/com/fongmi/android/tv/compat/Android7ApiCompatContractTest.java new file mode 100644 index 0000000000..900f730c28 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/compat/Android7ApiCompatContractTest.java @@ -0,0 +1,102 @@ +package com.fongmi.android.tv.compat; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Android 7 (API 24/25) contracts for API 26/34 platform calls that have an API 1 compatible form. + * + *

Two defects were found on a real Android 7 device and fixed; these guards keep the fixes from + * being reverted silently, because neither defect can be reproduced by a JVM unit test: the JVM + * still exposes {@code Process#isAlive()} and the {@code StringBuilder} matcher overloads. + */ +public class Android7ApiCompatContractTest { + + private static final Pattern MATCHER_APPEND = Pattern.compile( + "\\.(appendReplacement|appendTail)\\s*\\(\\s*([A-Za-z_$][\\w$]*)"); + private static final Pattern DECLARATION = Pattern.compile("\\b(StringBuilder|StringBuffer)\\s+([A-Za-z_$][\\w$]*)\\b"); + + @Test + public void autofillCallsGoThroughTheApiIsolatedCompatLayer() throws Exception { + for (String file : new String[]{"GitCloudDialog.java", "RemoteTrustDialog.java"}) { + String source = stripComments(read("app/src/main/java/com/fongmi/android/tv/ui/dialog/" + file)); + assertTrue(file + " must import androidx.core.view.ViewCompat", + source.contains("import androidx.core.view.ViewCompat;")); + assertEquals(file + " must route the API 26 call through the compat layer exactly once", + 1, occurrences(source, ".setImportantForAutofill(")); + assertTrue(file + " must call ViewCompat.setImportantForAutofill (no-op below API 26)", + source.contains("ViewCompat.setImportantForAutofill(")); + } + } + + @Test + public void noProductionSourceAppendsMatcherOutputIntoAStringBuilder() throws Exception { + List violations = new ArrayList<>(); + int inspected = 0; + try (Stream files = Files.walk(mainJavaRoot())) { + for (Path file : (Iterable) files.filter(path -> path.toString().endsWith(".java"))::iterator) { + String source = read(file); + Matcher call = MATCHER_APPEND.matcher(source); + while (call.find()) { + inspected++; + String variable = call.group(2); + Set types = new HashSet<>(); + Matcher declaration = DECLARATION.matcher(source); + while (declaration.find()) { + if (declaration.group(2).equals(variable)) types.add(declaration.group(1)); + } + if (types.contains("StringBuilder") && !types.contains("StringBuffer")) { + violations.add(file + " -> " + variable); + } + } + } + } + assertTrue("the scan must actually inspect the repo matcher appends (found " + inspected + ")", + inspected >= 30); + assertEquals("Matcher#appendReplacement/appendTail(StringBuilder, ...) needs API 34: " + violations, + 0, violations.size()); + } + + private static String stripComments(String source) { + return source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//[^\n]*", " "); + } + + private static int occurrences(String source, String needle) { + int count = 0; + int from = source.indexOf(needle); + while (from >= 0) { + count++; + from = source.indexOf(needle, from + needle.length()); + } + return count; + } + + private static Path mainJavaRoot() { + Path moduleRelative = Path.of("src", "main", "java"); + if (Files.exists(moduleRelative)) return moduleRelative; + return Path.of("app", "src", "main", "java"); + } + + private static String read(String name) throws Exception { + return read(Path.of(name)); + } + + private static String read(Path path) throws Exception { + if (!Files.exists(path)) path = Path.of("..").resolve(path).normalize(); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/content/ActionCardHelperTest.java b/app/src/test/java/com/fongmi/android/tv/content/ActionCardHelperTest.java new file mode 100644 index 0000000000..b35f7731c3 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/content/ActionCardHelperTest.java @@ -0,0 +1,34 @@ +package com.fongmi.android.tv.content; + +import static org.junit.Assert.assertEquals; + +import com.google.gson.JsonParser; + +import org.junit.Test; + +public class ActionCardHelperTest { + + @Test + public void emptyResponseIsSuccessfulAndSilent() { + assertEquals("", ActionCardHelper.nonJsonResponse(null)); + assertEquals("", ActionCardHelper.nonJsonResponse("")); + assertEquals("", ActionCardHelper.nonJsonResponse(" \n\t")); + assertEquals("", ActionCardHelper.nonJsonResponse("null")); + assertEquals("", ActionCardHelper.nonJsonResponse(" null ")); + } + + @Test + public void nonJsonTextIsPreservedForTheUser() { + assertEquals("动作执行失败", ActionCardHelper.nonJsonResponse(" 动作执行失败 ")); + } + + @Test + public void invalidActionEndpointsStillReturnAnErrorMessage() { + assertEquals("站点不存在", messageOf(ActionCardHelper.error("站点不存在"))); + assertEquals("动作执行失败", messageOf(ActionCardHelper.error(null))); + } + + private static String messageOf(String response) { + return JsonParser.parseString(response).getAsJsonObject().get("msg").getAsString(); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/db/AppDatabaseIntegrityRecoverySourceTest.java b/app/src/test/java/com/fongmi/android/tv/db/AppDatabaseIntegrityRecoverySourceTest.java new file mode 100644 index 0000000000..e2316fafe5 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/db/AppDatabaseIntegrityRecoverySourceTest.java @@ -0,0 +1,80 @@ +package com.fongmi.android.tv.db; + +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 for the crash-loop guard: Room reports an identity mismatch through an + * {@code IllegalStateException} while the database is opened, which no call site can catch. The + * builder must therefore install a helper that quarantines the unreadable database and rebuilds it, + * while still rethrowing every unrelated open failure. + */ +public class AppDatabaseIntegrityRecoverySourceTest { + + @Test + public void databaseBuilderInstallsTheIntegrityRecoveryHelper() throws Exception { + String source = source(); + assertTrue("The Room builder must install the recovering open helper factory", + source.contains("new IntegrityRecoveringOpenHelperFactory(new FrameworkSQLiteOpenHelperFactory())")); + assertTrue("The factory must be passed to Room rather than kept unused", + source.contains(".openHelperFactory(")); + } + + @Test + public void onlyTheIdentityFailureIsRecovered() throws Exception { + String source = source(); + String method = between(source, "private static boolean isIdentityFailure", "static void quarantine"); + + assertTrue("The recovery must be limited to Room's identity mismatch", + method.contains("Room cannot verify the data integrity")); + assertTrue("Unrelated open failures must be rethrown", source.contains("!isIdentityFailure(e)) throw e;")); + assertFalse("The recovery must not swallow failures by type alone", + between(source, "private SupportSQLiteDatabase open", "private static boolean isIdentityFailure") + .contains("catch (RuntimeException e)")); + } + + @Test + public void recoveryIsBoundedToASingleRetry() throws Exception { + String source = source(); + String method = between(source, "private SupportSQLiteDatabase open", "private static boolean isIdentityFailure"); + + assertTrue("A second failure must not retry forever", method.contains("if (recovered || !isIdentityFailure(e)) throw e;")); + assertTrue("The retry must use a freshly created helper", method.contains("delegate = factory.create(configuration);")); + assertTrue("The failed helper must be closed before rebuilding", method.contains("delegate.close();")); + } + + @Test + public void quarantinePreservesTheUnreadableDatabaseFile() throws Exception { + String source = source(); + String method = between(source, "static void quarantine", "private static void trimQuarantine"); + + assertTrue("The original database file must be renamed aside, not deleted", + method.contains("source.renameTo(target)")); + assertTrue("Quarantine copies must stay bounded", source.contains("QUARANTINE_KEEP_COUNT = 2")); + assertTrue("Sidecar write-ahead log files must be cleared", source.contains("DATABASE_SUFFIXES = {\"\", \"-wal\", \"-shm\", \"-journal\"}")); + } + + private static String between(String source, String start, String end) { + int from = source.indexOf(start); + int to = source.indexOf(end, from + start.length()); + assertTrue("source must contain " + start, from >= 0); + assertTrue("source must contain " + end, to > from); + return source.substring(from, to); + } + + private static String source() throws Exception { + return new String(Files.readAllBytes(findMainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "db", "AppDatabase.java"))), 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"); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/db/dao/HistoryDaoTest.java b/app/src/test/java/com/fongmi/android/tv/db/dao/HistoryDaoTest.java index fa759a1a11..cf5d7c8e80 100644 --- a/app/src/test/java/com/fongmi/android/tv/db/dao/HistoryDaoTest.java +++ b/app/src/test/java/com/fongmi/android/tv/db/dao/HistoryDaoTest.java @@ -21,7 +21,41 @@ public class HistoryDaoTest { assertTrue(compact.contains("SELECT * FROM History WHERE cid = :cid ORDER BY createTime DESC")); assertTrue(compact.contains("public abstract List find(int cid);")); assertFalse(lower.contains("createtime >= :createtime")); - assertFalse(lower.contains("limit")); + + // 本条断言必须**只针对 find(int cid) 自己的查询**。 + // 原先是对整个文件做 `lower.contains("limit")`,而 `findBySiteKey` 合法地带 + // `LIMIT :limit`(按站点读历史需要条数上限),于是整文件检查被别的方法击穿, + // 守卫报出的是假失败,而不是它真正要保护的「find(int) 没有时间/条数上限」。 + // 按方法取查询文本后,断言与测试名重新一致,且新增带 LIMIT 的方法不会再误伤。 + String findQuery = queryOf(source, "public abstract List find(int cid);"); + assertFalse("find(int cid) must not cap the row count", + findQuery.toLowerCase(Locale.ROOT).contains("limit")); + + // 反向对照:证明上面的按方法取查询确实生效(否则 scoping 写错也会静默全绿)。 + String siteQuery = queryOf(source, "public abstract List findBySiteKey("); + assertTrue("findBySiteKey legitimately caps its row count", + siteQuery.toLowerCase(Locale.ROOT).contains("limit")); + + // findBySiteKey 必须用 substr 做精确前缀匹配,**不能**用 LIKE。 + // siteKey 形如 `csp_xxx`,其中 `_` 是 LIKE 的单字符通配符、`%` 是多字符通配符; + // 一旦改成 LIKE,`csp_a` 会命中 `csp_ab`,且含 `%` 的站点配置会读出别的站点历史。 + // 这是本端点的核心安全决策,因此单独锁定,而不是只断言"查询里有 limit"—— + // 后者在把 substr 变异成 LIKE 之后仍然全绿。 + assertTrue("findBySiteKey must match the site key by exact prefix (substr)", + siteQuery.contains("substr(`key`, 1, length(:keyPrefix)) = :keyPrefix")); + assertFalse("findBySiteKey must not use LIKE: `_` / `%` in a site key would wildcard-match", + siteQuery.toUpperCase(Locale.ROOT).contains("LIKE")); + } + + /** 取某个方法声明**紧邻其前**的 {@code @Query} 注解文本。 */ + private static String queryOf(String source, String signature) { + int method = source.indexOf(signature); + assertTrue("missing method: " + signature, method >= 0); + int query = source.lastIndexOf("@Query(", method); + assertTrue("missing @Query for: " + signature, query >= 0); + int end = source.indexOf("\")", query); + assertTrue("unterminated @Query for: " + signature, end > query); + return source.substring(query, end); } private static String read(Path path) throws Exception { diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingMergePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingMergePolicyTest.java index ee6b916f47..29214ee11e 100644 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingMergePolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/following/FollowingMergePolicyTest.java @@ -6,6 +6,7 @@ import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; public class FollowingMergePolicyTest { @@ -41,6 +42,66 @@ public class FollowingMergePolicyTest { assertFalse(FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0).enabled); } + @Test + public void tombstoneSpreadsToActivePeerAndWins() { + Following local = following(); + local.deletedAt = 500; + local.enabled = false; + Following remote = following(); + remote.updatedAt = 900; + + Following merged = FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0); + assertTrue(merged.isDeleted()); + assertEquals(500, merged.deletedAt); + assertFalse(merged.enabled); + } + + @Test + public void newerTombstoneWinsBetweenTwoTombstones() { + Following local = following(); + local.deletedAt = 500; + Following remote = following(); + remote.deletedAt = 800; + + assertEquals(800, FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0).deletedAt); + } + + @Test + public void reFollowAfterDeletionRevivesWhenCreatedAtIsNewer() { + Following local = following(); + local.deletedAt = 500; + Following remote = following(); + remote.createdAt = 700; // 重新追更发生在取消之后 + remote.updatedAt = 700; + + Following merged = FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0); + assertFalse(merged.isDeleted()); + assertTrue(merged.enabled); + } + + @Test + public void deletionAfterReFollowReTombstonesTheRow() { + Following local = following(); + local.createdAt = 700; + Following remote = following(); + remote.deletedAt = 900; // 取消发生在重新追更之后 + + Following merged = FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0); + assertTrue(merged.isDeleted()); + assertEquals(900, merged.deletedAt); + } + + @Test + public void legacyActiveRowWithoutCreatedAtDoesNotReviveTombstone() { + Following local = following(); + local.deletedAt = 500; + Following remote = following(); + remote.createdAt = 0; // 旧版本快照的活跃行,createdAt 缺失 + + Following merged = FollowingMergePolicy.mergeFollowing(List.of(local), List.of(remote)).get(0); + assertTrue(merged.isDeleted()); + } + private static Following following() { Following item = new Following(); item.identityKey = "tmdb:tv:1:s1"; diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingMetadataClientTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingMetadataClientTest.java index 8a5fb2fb98..2d3194b8d6 100644 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingMetadataClientTest.java +++ b/app/src/test/java/com/fongmi/android/tv/following/FollowingMetadataClientTest.java @@ -30,6 +30,8 @@ public class FollowingMetadataClientTest { assertEquals(8, snapshot.latestReleasedEpisode); assertEquals(12, snapshot.seasonTotalEpisodes); assertEquals(9, snapshot.nextAirEpisode); + assertEquals(24, snapshot.seriesTotalEpisodes); + assertEquals(1, snapshot.nextAirWeekday); assertTrue(snapshot.nextAirAt > 0); assertEquals(1234, snapshot.fetchedAt); } @@ -55,4 +57,11 @@ public class FollowingMetadataClientTest { assertEquals(10, snapshot.latestReleasedEpisode); assertEquals(2, snapshot.seasonReleasedEpisodes); } + + @Test + public void weekdayIsStableForUtcAirDate() { + assertEquals(1, FollowingMetadataSnapshot.weekday("2026-09-20")); + assertEquals(2, FollowingMetadataSnapshot.weekday("2026-09-21")); + assertEquals(7, FollowingMetadataSnapshot.weekday("2026-09-26")); + } } diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingSchedulePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingSchedulePolicyTest.java index 77bb02cdfb..0503cdf56d 100644 --- a/app/src/test/java/com/fongmi/android/tv/following/FollowingSchedulePolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/following/FollowingSchedulePolicyTest.java @@ -33,6 +33,27 @@ public class FollowingSchedulePolicyTest { assertEquals(now + TimeUnit.HOURS.toMillis(24), check); } + @Test + public void foregroundUsesShortIntervalWithoutChangingEndedAndPlannedPolicy() { + long now = 1_000_000; + assertEquals(now + FollowingSchedulePolicy.FOREGROUND_INTERVAL, + FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.RETURNING, 0, true)); + assertEquals(now + FollowingSchedulePolicy.FOREGROUND_INTERVAL, + FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.UNKNOWN, 0, true)); + assertEquals(now + TimeUnit.HOURS.toMillis(24), + FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.PLANNED, 0, true)); + assertEquals(now + TimeUnit.DAYS.toMillis(7), + FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.ENDED, 0, true)); + } + + @Test + public void foregroundReturningWithNextAirStillChecksWithinCadence() { + long now = 1_000_000; + long check = FollowingSchedulePolicy.nextCheckAt(now, FollowingMetadataSnapshot.RETURNING, + now + TimeUnit.DAYS.toMillis(3), true); + assertEquals(now + FollowingSchedulePolicy.FOREGROUND_INTERVAL, check); + } + @Test public void failureBackoffGrowsAndCapsAtOneDay() { assertEquals(TimeUnit.MINUTES.toMillis(15), FollowingSchedulePolicy.backoffAt(0, 1)); diff --git a/app/src/test/java/com/fongmi/android/tv/following/FollowingWorkStartupSourceTest.java b/app/src/test/java/com/fongmi/android/tv/following/FollowingWorkStartupSourceTest.java new file mode 100644 index 0000000000..b29ec23b8a --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/following/FollowingWorkStartupSourceTest.java @@ -0,0 +1,80 @@ +package com.fongmi.android.tv.following; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Pattern; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import static org.junit.Assert.*; + +public class FollowingWorkStartupSourceTest { + private static final String ANDROID = "http://schemas.android.com/apk/res/android"; + private static final String TOOLS = "http://schemas.android.com/tools"; + + @Test + public void onlyWorkManagerDefaultInitializerIsReplaced() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + NodeList providers = factory.newDocumentBuilder().parse( + root().resolve("app/src/main/AndroidManifest.xml").toFile()).getElementsByTagName("provider"); + Element startup = null; + for (int i = 0; i < providers.getLength(); i++) { + Element provider = (Element) providers.item(i); + if ("androidx.startup.InitializationProvider".equals(provider.getAttributeNS(ANDROID, "name"))) startup = provider; + } + assertNotNull(startup); + assertEquals("merge", startup.getAttributeNS(TOOLS, "node")); + boolean removed = false, replacement = false, appStartup = false; + NodeList metadata = startup.getElementsByTagName("meta-data"); + for (int i = 0; i < metadata.getLength(); i++) { + Element item = (Element) metadata.item(i); + String name = item.getAttributeNS(ANDROID, "name"); + if ("androidx.work.WorkManagerInitializer".equals(name)) removed = "remove".equals(item.getAttributeNS(TOOLS, "node")); + if ("com.fongmi.android.tv.following.FollowingWorkInitializer".equals(name)) replacement = "androidx.startup".equals(item.getAttributeNS(ANDROID, "value")); + if ("com.fongmi.android.tv.Startup".equals(name)) appStartup = true; + } + assertTrue("Default WorkManager initializer must not crash the process on DE-storage TV firmware", removed); + assertTrue("Keep automatic recovery on process/service restart via a safe initializer", replacement); + assertTrue("Do not remove unrelated app initialization", appStartup); + } + + @Test + public void workManagerUsesCredentialStorageWithoutChangingAppStorage() throws Exception { + String source = Files.readString(root().resolve("app/src/main/java/com/fongmi/android/tv/following/FollowingWorkManager.java")); + assertTrue("WorkManager must be handed a credential-protected package context", + matches(source, "createPackageContext\\(\\s*app\\.getPackageName\\(\\),\\s*CONTEXT_CREDENTIAL_PROTECTED_STORAGE\\)")); + assertTrue("WorkManager re-normalizes the Context, so the adapter must keep the CE wrapper as the application Context", + matches(source, "public Context getApplicationContext\\(\\)\\s*\\{[^}]*return this;")); + assertTrue("A normal credential-protected device must keep its existing WorkManager storage", + matches(source, "if \\(!app\\.isDeviceProtectedStorage\\(\\)\\) return app;")); + assertTrue("Only the credential-protected context may reach WorkManager", + matches(source, "WorkManager\\.initialize\\(\\s*workContext\\s*,")); + assertTrue("A device-protected start must defer instead of initializing", + matches(source, "!isUserUnlocked\\(app\\)")); + assertTrue("The unlock state must be re-checked after registering, because the broadcast is not replayed", + matches(source, "registerUnlockReceiver\\(app\\);\\s*if \\(!isUserUnlocked\\(app\\)\\) return;")); + } + + private static boolean matches(String source, String regex) { + return Pattern.compile(regex, Pattern.DOTALL).matcher(source).find(); + } + + @Test + public void allFollowingSchedulingAndCancellationUsesTheStorageGate() throws Exception { + String source = Files.readString(root().resolve("app/src/main/java/com/fongmi/android/tv/following/FollowingScheduler.java")); + assertFalse("A direct getter can reintroduce the crash after removing the default initializer", + source.contains("WorkManager.getInstance(")); + assertEquals(5, source.split("FollowingWorkManager.withManager\\(", -1).length - 1); + } + + private static Path root() { + Path path = Path.of("").toAbsolutePath(); + while (path != null && !Files.exists(path.resolve("app/src/main/AndroidManifest.xml"))) path = path.getParent(); + if (path == null) throw new AssertionError("repository root not found"); + return path; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/history/GlobalHistoryResumeSourceTest.java b/app/src/test/java/com/fongmi/android/tv/history/GlobalHistoryResumeSourceTest.java index 7af7322e99..e14aa67ad1 100644 --- a/app/src/test/java/com/fongmi/android/tv/history/GlobalHistoryResumeSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/history/GlobalHistoryResumeSourceTest.java @@ -191,7 +191,7 @@ public class GlobalHistoryResumeSourceTest { assertTrue(resolver.contains("SiteApi.searchContent(site, history.getVodName(), false, \"1\")")); assertTrue(coordinator.contains("ProgressBar")); - assertTrue(coordinator.contains("R.style.Theme_WebHTV_LightDialog")); + assertTrue(coordinator.contains("R.style.Theme_WebHTV_Dialog")); assertTrue(coordinator.contains("setCancelable(false)")); assertTrue(coordinator.contains("dismiss(loading)")); assertFalse(coordinator.contains("Notify.show(R.string.history_source_searching)")); diff --git a/app/src/test/java/com/fongmi/android/tv/history/GlobalHistorySettingSourceTest.java b/app/src/test/java/com/fongmi/android/tv/history/GlobalHistorySettingSourceTest.java index 6fe208a832..cf4a403a3e 100644 --- a/app/src/test/java/com/fongmi/android/tv/history/GlobalHistorySettingSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/history/GlobalHistorySettingSourceTest.java @@ -6,6 +6,7 @@ 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 GlobalHistorySettingSourceTest { @@ -17,10 +18,20 @@ public class GlobalHistorySettingSourceTest { assertTrue(source.contains("GLOBAL_HISTORY_OFF = 0")); assertTrue(source.contains("GLOBAL_HISTORY_AUTO = 1")); assertTrue(source.contains("GLOBAL_HISTORY_SEARCH = 2")); - assertTrue(source.contains("Prefers.getInt(\"global_history_mode\", GLOBAL_HISTORY_OFF)")); + assertTrue(source.contains("getAll().get(\"global_history_mode\")")); assertTrue(source.contains("Prefers.put(\"global_history_mode\"")); } + @Test + public void explicitGlobalHistoryOffIsPersistedDistinctly() throws Exception { + String source = read("app/src/main/java/com/fongmi/android/tv/setting/Setting.java"); + + assertTrue(source.contains("mode == GLOBAL_HISTORY_OFF ? -1 : mode")); + assertTrue(source.contains("value instanceof Boolean legacy")); + assertTrue(source.contains("value instanceof Number number")); + assertTrue(source.contains("return clampGlobalHistoryMode(number.intValue())")); + } + @Test public void bothPersonalSettingScreensExposeGlobalHistoryMode() throws Exception { String mobile = read("app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingPersonalFragment.java"); @@ -29,6 +40,11 @@ public class GlobalHistorySettingSourceTest { String leanbackLayout = read("app/src/leanback/res/layout/activity_setting_personal.xml"); assertTrue(mobile.contains("mBinding.globalHistory")); + assertTrue(mobile.contains("event.getActionMasked() == MotionEvent.ACTION_DOWN")); + assertFalse(mobile.contains("event.getActionMasked() == MotionEvent.ACTION_UP")); + assertTrue(mobile.contains("MotionEvent.ACTION_CANCEL) globalHistoryTouchStarted = false")); + assertTrue(mobile.contains("if (!globalHistoryTouchStarted) return")); + assertTrue(mobile.contains("globalHistoryTouchStarted = false")); assertTrue(mobile.contains("select_global_history_mode")); assertTrue(leanback.contains("mBinding.globalHistory")); assertTrue(leanback.contains("select_global_history_mode")); diff --git a/app/src/test/java/com/fongmi/android/tv/lab/LabRunnerProcessCompatTest.java b/app/src/test/java/com/fongmi/android/tv/lab/LabRunnerProcessCompatTest.java new file mode 100644 index 0000000000..c51f4ad02e --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/lab/LabRunnerProcessCompatTest.java @@ -0,0 +1,89 @@ +package com.fongmi.android.tv.lab; + +import org.junit.Test; + +import java.io.InputStream; +import java.io.OutputStream; +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; + +/** + * Android 7 (API 24/25) contract for the Lab runtime. + * + *

{@code Process#isAlive()} is API 26, so the original call sites threw {@code NoSuchMethodError} + * as soon as a Lab entry with commands was installed. Because that failure is an {@code Error}, + * {@code LabAutoStart}'s {@code catch (Exception)} could not stop it and the process died. The + * replacement probes {@code exitValue()} (API 1) and must keep doing so. + */ +public class LabRunnerProcessCompatTest { + + @Test + public void probeTreatsOnlyATerminatedProcessAsDead() { + assertTrue("a still running process must be reported alive", LabRunner.isAlive(fake(false))); + assertFalse("a terminated process must be reported dead", LabRunner.isAlive(fake(true))); + assertFalse("a missing process must be reported dead", LabRunner.isAlive(null)); + } + + @Test + public void labSourcesProbeThroughExitValueInsteadOfTheApi26Call() throws Exception { + String runner = stripComments(read("app/src/main/java/com/fongmi/android/tv/lab/LabRunner.java")); + String terminal = stripComments(read("app/src/main/java/com/fongmi/android/tv/lab/LabTerminalActivity.java")); + + assertTrue("the helper must probe the API 1 exitValue() contract", + runner.contains("process.exitValue()")); + assertFalse("java.lang.Process#isAlive() is API 26 and must not be called in LabRunner", + runner.contains(".isAlive()")); + assertFalse("java.lang.Process#isAlive() is API 26 and must not be called in LabTerminalActivity", + terminal.contains(".isAlive()")); + assertTrue("LabTerminalActivity must route every liveness check through the compatible helper", + terminal.contains("LabRunner.isAlive(")); + } + + private static Process fake(boolean terminated) { + return new Process() { + @Override + public OutputStream getOutputStream() { + return OutputStream.nullOutputStream(); + } + + @Override + public InputStream getInputStream() { + return InputStream.nullInputStream(); + } + + @Override + public InputStream getErrorStream() { + return InputStream.nullInputStream(); + } + + @Override + public int waitFor() { + return 0; + } + + @Override + public int exitValue() { + if (terminated) return 0; + throw new IllegalThreadStateException("still running"); + } + + @Override + public void destroy() { + } + }; + } + + private static String stripComments(String source) { + return source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//[^\n]*", " "); + } + + private static String read(String name) throws Exception { + Path path = Path.of(name); + if (!Files.exists(path)) path = Path.of("..").resolve(name).normalize(); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/lab/LabSettingsDialogThemeTest.java b/app/src/test/java/com/fongmi/android/tv/lab/LabSettingsDialogThemeTest.java new file mode 100644 index 0000000000..804b15e28b --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/lab/LabSettingsDialogThemeTest.java @@ -0,0 +1,268 @@ +package com.fongmi.android.tv.lab; + +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; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 实验室「配置源」设置弹窗必须内容与面板同源,并且必须跟随应用主题。 + * + *

缺陷实测(dev1 {@code 192.168.50.3:5555},mobile arm64 debug,浅色系统): + * {@code LabActivity.showSettings()} 用 Activity 的 LayoutInflater inflate + * {@code dialog_lab_settings.xml},其中的 {@code ?attr/colorOnSurface} 由固定深色的 + * {@code Theme.App.Lab}({@code colorOnSurface = #FFFFFFFF})解析成白字, + * 而弹窗面板来自 {@code ThemeOverlay.WebHTV.Dialog} 的日/夜双表(浅色 {@code #E7E8EF})。 + * 结果正文与面板对比度只有 1.05:1,默认主题下整页看不清。 + * + *

同时,这些固定白字/固定 {@code @color/accent} 都不等于冻结基线的语义角色值, + * {@link com.fongmi.android.tv.theme.ThemeBinder} 的精确匹配改写无法命中, + * 于是「主题色彩」对这个设置页完全没有作用。 + * + *

本测试锁定两条不变量:内容用弹窗主题上下文 inflate;弹窗主题的强调色是语义 token。 + * 面板/正文的对比度由设备像素实测覆盖(见任务文档),这里只锁住会导致回归的源码形态。 + */ +public class LabSettingsDialogThemeTest { + + private static final String ACTIVITY = "src/main/java/com/fongmi/android/tv/lab/LabActivity.java"; + private static final String STYLES = "src/main/res/values/lab_styles.xml"; + private static final String LAYOUT = "src/main/res/layout/dialog_lab_settings.xml"; + + /** {@code ?attr/} 颜色属性 → 该属性在语义 token 表中的角色资源。 */ + private static final Map ATTR_TO_TOKEN = new LinkedHashMap<>(); + + static { + ATTR_TO_TOKEN.put("colorOnSurface", "webhtv_color_on_surface"); + ATTR_TO_TOKEN.put("colorOnSurfaceVariant", "webhtv_color_on_surface_variant"); + ATTR_TO_TOKEN.put("colorPrimary", "webhtv_color_primary"); + } + + /** + * 弹窗内容必须用弹窗主题上下文 inflate。 + * + *

用 {@code getLayoutInflater()} 会落到 Activity 主题:{@code Theme.App.Lab} 是固定深色表, + * 正文永远是白字,而面板跟着日/夜表走 —— 这正是「默认主题下就看不清」的成因。 + */ + @Test + public void settingsContentIsInflatedWithTheDialogTheme() throws Exception { + String source = stripComments(read(ACTIVITY)); + assertTrue("the settings dialog must build a dialog-themed context", + source.contains("new ContextThemeWrapper(this, R.style.Theme_App_Lab_DayNight_Dialog)")); + assertTrue("the settings layout must be inflated from the dialog theme, not the activity", + source.contains("LayoutInflater.from(dialogContext).inflate(R.layout.dialog_lab_settings, null)")); + assertFalse("the activity inflater would re-resolve the layout against Theme.App.Lab", + source.contains("getLayoutInflater().inflate(R.layout.dialog_lab_settings")); + } + + /** + * 弹窗主题的强调色必须是语义 token。 + * + *

{@code @color/accent} 固定为 {@code #2196F3}:它既不能跟随深浅色, + * 也不满足 binder 的精确匹配改写,因此主题色永远作用不到这个弹窗的输入框描边、hint 与图标。 + */ + @Test + public void dialogThemeAccentIsASemanticToken() throws Exception { + String style = styleBody("Theme.App.Lab.DayNight.Dialog"); + assertFalse("the dialog accent must not be the fixed lab blue", + style.contains("@color/accent")); + assertTrue("the dialog accent must resolve the semantic primary token", + style.contains("@color/webhtv_color_primary")); + assertTrue("the activated control colour must be the semantic primary", + style.contains("@color/webhtv_color_primary")); + } + + /** + * 弹窗面板必须与内容读同一张表。 + * + *

面板由 {@code colorSurfaceContainerHigh}(M3 对话框容器色)决定,所以该槽必须也被 + * 映射到 {@code lab_surface},否则内容跟随 {@code lab_surface} 而面板回落到 Material 基线表, + * 又变成两套颜色。 + */ + @Test + public void dialogPanelAndContentShareOneTable() throws Exception { + String style = styleBody("Theme.App.Lab.DayNight.Dialog"); + assertTrue(style.contains("@color/lab_surface")); + assertTrue(style.contains("@color/lab_surface")); + assertTrue(style.contains("@color/lab_text_primary")); + assertTrue(style.contains("@color/lab_text_secondary")); + + String colors = read("src/main/res/values/lab_colors.xml"); + assertTrue("lab_surface must alias the semantic container role", + colors.contains("@color/webhtv_color_surface_container_high")); + assertTrue("lab_text_primary must alias the semantic foreground role", + colors.contains("@color/webhtv_color_on_surface")); + assertTrue("lab_text_secondary must alias the semantic variant role", + colors.contains("@color/webhtv_color_on_surface_variant")); + } + + /** + * 布局里的颜色属性必须都是语义角色。 + * + *

设备实测的对比度只有在「内容属性全部解析成语义 token」时成立:任何一条落回固定字面量 + * 都会重新引入一个只能在一张表里正确的颜色。 + */ + @Test + public void settingsLayoutOnlyUsesSemanticColorAttributes() throws Exception { + String layout = stripComments(read(LAYOUT)); + List unknown = new ArrayList<>(); + Matcher matcher = Pattern.compile("(?:android:)?(?:textColor|textColorHint|boxStrokeColor|hintTextColor|tint)=\"([^\"]+)\"") + .matcher(layout); + while (matcher.find()) { + String value = matcher.group(1); + if (value.startsWith("?attr/color")) { + String attr = value.substring("?attr/".length()); + if (!ATTR_TO_TOKEN.containsKey(attr)) unknown.add(value); + continue; + } + if (value.startsWith("?android:attr/")) continue; + unknown.add(value); + } + assertTrue("dialog_lab_settings.xml pins a colour outside the semantic roles: " + unknown, + unknown.isEmpty()); + } + + /** + * 语义角色必须在深浅两张表里都达到正文对比度门槛。 + * + *

这是对「面板与内容同源」这一修复的直接量化:只要两张表各自满足 4.5:1, + * 弹窗在跟随系统浅色或强制深色时都保持可读。 + */ + @Test + public void bothTablesClearTheBodyTextContrastFloor() throws Exception { + for (String file : new String[]{"src/main/res/values/webhtv_tokens.xml", + "src/main/res/values-night/webhtv_tokens.xml"}) { + Map colors = colorsOf(read(file)); + String surface = colors.get("webhtv_color_surface_container_high"); + for (String foreground : new String[]{"webhtv_color_on_surface", "webhtv_color_on_surface_variant"}) { + double ratio = contrast(colors.get(foreground), surface); + assertTrue(file + ": " + foreground + " on " + surface + " is only " + + String.format(Locale.ROOT, "%.2f:1", ratio), + ratio >= 4.5); + } + } + } + + // ------------------------------------------------------------------ helpers + + private static String styleBody(String name) throws Exception { + String styles = stripComments(read(STYLES)); + Matcher matcher = Pattern.compile("", Pattern.DOTALL) + .matcher(styles); + assertTrue(name + " is missing from lab_styles.xml", matcher.find()); + return matcher.group(1); + } + + private static Map colorsOf(String source) { + Map colors = new LinkedHashMap<>(); + Matcher matcher = Pattern.compile("([^<]+)").matcher(stripComments(source)); + while (matcher.find()) colors.put(matcher.group(1), matcher.group(2).trim()); + return colors; + } + + private static double contrast(String first, String second) { + double a = luminance(first); + double b = luminance(second); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + } + + private static double luminance(String hex) { + String digits = hex.startsWith("#") ? hex.substring(1) : hex; + if (digits.length() == 8) digits = digits.substring(2); + double[] channels = new double[3]; + for (int index = 0; index < 3; index++) { + double value = Integer.parseInt(digits.substring(index * 2, index * 2 + 2), 16) / 255.0; + channels[index] = value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); + } + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; + } + + /** + * Strips comments for the source-shape assertions. + * + *

Block comments are matched only at the start of a line: this file's production code + * contains {@code setType} with a star-slash-star pattern, whose block-comment opener would + * otherwise swallow the very method under test. + */ + private static String stripComments(String source) { + return source + .replaceAll("(?s)", "") + .replaceAll("(?sm)^\\s*/\\*.*?\\*/", "") + .replaceAll("(?m)^\\s*//.*$", ""); + } + + private static String read(String path) throws Exception { + Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + return Files.readString(root.resolve(path), StandardCharsets.UTF_8); + } + + /** + * 下拉菜单必须由 Material 的 AutoCompleteTextView 提供。 + * + *

{@code android.widget.AutoCompleteTextView} 不应用 + * {@code Widget.Material3.AutoCompleteTextView.OutlinedBox},因此既没有 + * {@code dropDownBackgroundTint}(= colorSurfaceContainer),也不会把 item 文字交给主题, + * 弹出列表落到 {@code @android:layout/simple_dropdown_item_1line} 的 Material 基线字色。 + * 设备实测(浅色系统、默认主题):弹出面板 {@code #F3EDF7},item 文字近白,1.76:1。 + */ + @Test + public void dropdownUsesTheThemedMaterialAutoComplete() throws Exception { + String layout = stripComments(read(LAYOUT)); + assertTrue("the dropdown must be the Material control so its popup follows the palette", + layout.contains("com.google.android.material.textfield.MaterialAutoCompleteTextView")); + assertFalse("a plain android.widget.AutoCompleteTextView would drop the themed popup", + layout.contains("{@code ArrayAdapter} 用自身 context 解析 item 布局。此前用的是 + * {@code android.R.layout.simple_dropdown_item_1line} + Activity 上下文 + * ({@code Theme.App.Lab},固定深色 → 白字),而弹出面板来自日/夜双表: + * 浅色系统下实测弹出面板 {@code #F3EDF7}、item 文字近白,1.19:1。 + */ + @Test + public void dropdownItemsFollowTheDialogPalette() throws Exception { + String activity = stripComments(read(ACTIVITY)); + assertTrue("the adapter must resolve its item layout against the dialog theme", + activity.contains("new ArrayAdapter<>(dialogContext, R.layout.item_lab_dropdown, items)")); + assertFalse("the framework item layout keeps Material's baseline text colour", + activity.contains("android.R.layout.simple_dropdown_item_1line")); + + String item = stripComments(read("src/main/res/layout/item_lab_dropdown.xml")); + assertTrue("the dropdown item must take its foreground from the semantic role", + item.contains("android:textColor=\"?attr/colorOnSurface\"")); + assertTrue("the dropdown item must expose the framework list item id", + item.contains("@android:id/text1")); + } + + /** + * 角色表必须覆盖布局真正用到的每一条颜色属性。 + * + *

上一条测试只对表内已知角色放行;这条把「表是否漏项」也锁住, + * 否则新增一条 {@code ?attr/colorX} 会被静默当成未知项而失败信息含糊。 + */ + @Test + public void attributeTableCoversEveryColourAttributeUsedByTheLayout() throws Exception { + String layout = stripComments(read(LAYOUT)); + Matcher matcher = Pattern.compile("\\?attr/(color[A-Za-z]+)").matcher(layout); + int seen = 0; + while (matcher.find()) { + seen++; + assertTrue("no expected token mapped for " + matcher.group(1), + ATTR_TO_TOKEN.containsKey(matcher.group(1))); + } + assertTrue("the layout must actually use semantic colour attributes", seen >= 5); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/lab/LabTvFocusSourceTest.java b/app/src/test/java/com/fongmi/android/tv/lab/LabTvFocusSourceTest.java new file mode 100644 index 0000000000..0ffac44155 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/lab/LabTvFocusSourceTest.java @@ -0,0 +1,295 @@ +package com.fongmi.android.tv.lab; + +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; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 实验室详情页必须能用遥控器操作焦点。 + * + *

缺陷实测(leanback arm64 debug,{@code 192.168.50.3:5561},{@code mInTouchMode=false}): + * {@code LabDetailActivity} 的 2 秒轮询调用 {@code updateButtons()} → {@code commandAdapter + * .notifyDataSetChanged()},RecyclerView 回收并重新挂载全部子 View,焦点所在的「运行」按钮被移除, + * 框架把焦点丢回工具栏返回键。实测按下方向键聚焦命令行后 1.2 秒内焦点即回到工具栏, + * 遥控器按不到「运行」——用户只能靠 lab 配置里的 {@code auto_execute} 启动命令。 + * + *

本测试锁住两条不变量:命令列表只做状态差异刷新;实验室自己的可聚焦控件有聚焦态。 + * 设备上的焦点保持由任务文档记录的遥控实测覆盖,这里只锁会导致回归的源码/资源形态。 + */ +public class LabTvFocusSourceTest { + + private static final String ACTIVITY = "src/main/java/com/fongmi/android/tv/lab/LabDetailActivity.java"; + private static final String ADAPTER = "src/main/java/com/fongmi/android/tv/lab/LabCommandAdapter.java"; + private static final String DETAIL_LAYOUT = "src/main/res/layout/activity_lab_detail.xml"; + + /** 实验室自己绘制的可聚焦控件:缺 state_focused 就等于遥控看不到焦点。 */ + private static final String[] FOCUS_VISIBLE_DRAWABLES = { + "src/main/res/drawable/shape_lab_card.xml", + "src/main/res/drawable/shape_lab_run_btn.xml", + "src/main/res/drawable/shape_lab_stop_btn.xml", + "src/main/res/drawable/shape_lab_click_btn.xml", + "src/main/res/drawable/selector_lab_group_tab.xml", + "src/main/res/drawable/selector_lab_icon_btn.xml" + }; + + /** + * 周期刷新不能再整表重建列表。 + * + *

{@code updateButtons()} 由 2 秒轮询驱动,任何整表刷新都会周期性抢走遥控焦点。 + */ + @Test + public void periodicRefreshUsesDiffUpdatesOnly() throws Exception { + String activity = stripComments(read(ACTIVITY)); + assertTrue("updateButtons() must refresh the command list through the diff path", + activity.contains("commandAdapter.refreshStates();")); + assertFalse("a full notifyDataSetChanged() on the 2s poll steals the remote focus", + activity.contains("commandAdapter.notifyDataSetChanged()")); + } + + /** + * 差异刷新必须按行状态判断,并且只做局部通知。 + * + *

{@code notifyItemChanged} 复用同一个 ViewHolder;整表刷新会回收焦点所在的视图。 + */ + @Test + public void diffRefreshSkipsUnchangedRowsAndNotifiesSingleItems() throws Exception { + String refresh = methodBody(stripComments(read(ADAPTER)), "public void refreshStates()"); + assertTrue("refreshStates() must compare per-row state before notifying", + refresh.contains("stateOf(rows.get(i))")); + assertTrue("unchanged rows must be skipped entirely", + refresh.contains("continue;")); + assertTrue("changed rows are refreshed in place", + refresh.contains("notifyItemChanged(i)")); + assertFalse("refreshStates() must never rebuild the whole list", + refresh.contains("notifyDataSetChanged")); + } + + /** 行状态签名必须覆盖 onBindViewHolder 会改变的全部可视差异。 */ + @Test + public void rowStateSignatureCoversRunningAndLabel() throws Exception { + String state = methodBody(stripComments(read(ADAPTER)), "private String stateOf("); + assertTrue("the running state drives the dot, tag and button", + state.contains("LabRunner.isRunning(")); + assertTrue("the label is the only other visible difference", + state.contains("command.name == null ? command.description : command.name")); + } + + /** + * 实验室自绘的可聚焦控件必须都有聚焦态。 + * + *

设备实测:命令行的「运行」按钮聚焦前后像素完全一致(仅有按下态), + * 遥控用户看不到焦点落在哪一行/哪个按钮上。 + */ + @Test + public void labFocusableControlsExposeAFocusedState() throws Exception { + for (String file : FOCUS_VISIBLE_DRAWABLES) { + assertTrue(file + " must render a focused state for remote navigation", + stripComments(read(file)).contains("android:state_focused=\"true\"")); + } + } + + /** + * 命令弹窗的初始焦点必须落在按钮上。 + * + *

BottomSheetDialog 默认聚焦第一个可聚焦控件(命令预览 EditText)。实测遥控方向键在单行输入框里 + * 出不去(DOWN / RIGHT / UP 都不移动焦点,只有 TAB 能到按钮),电视遥控器没有 TAB, + * 用户打开了弹窗却按不到「运行」。 + */ + @Test + public void commandSheetAnchorsFocusOnAButton() throws Exception { + String sheet = stripComments(read("src/main/java/com/fongmi/android/tv/lab/LabCommandSheet.java")); + assertTrue("the sheet must move the initial focus onto a button", + sheet.contains("requestSheetFocus();")); + assertTrue("the run button wins when it is usable, otherwise the edit button", + sheet.contains("sheetBtnRun.getVisibility() == View.VISIBLE && sheetBtnRun.isEnabled() ? sheetBtnRun : sheetBtnEdit")); + + // 锚点必须是轮询本体(mStateRunnable 匿名类),不能用 "public void run()": + // 那个签名在本文件里第一处命中的是轮询的 run()(约 60 行),第二处才是 + // LabCommandSheet 自己的 run()(约 319 行),靠 indexOf 的先后顺序太脆。 + String poll = methodBody(sheet, "private final Runnable mStateRunnable"); + assertFalse("the 1s state poll must not steal focus back while the user types", + poll.contains("requestSheetFocus")); + } + + /** + * 「导入包」弹窗必须能用遥控器操作。 + * + *

实测(leanback arm64 debug,1920x1080):弹窗可聚焦节点只有粘贴框、`选择离线包文件`、取消、导入; + * 初始焦点落在多行粘贴框,遥控 DOWN / UP / RIGHT 都不移动焦点(被当光标键吃掉),只有 TAB 能跳出去。 + * 电视遥控器没有 TAB,用户打开弹窗后什么都按不动。 + */ + @Test + public void importDialogIsReachableWithTheRemote() throws Exception { + String activity = stripComments(read("src/main/java/com/fongmi/android/tv/lab/LabActivity.java")); + assertTrue("the paste field must release the vertical D-pad keys to focus search", + activity.contains("class LabPasteEditText extends AppCompatEditText")); + assertTrue("only the vertical keys are released, horizontal cursor movement stays", + activity.contains("keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN")); + assertTrue("the file-picker link must be focusable", + activity.contains("select.setFocusable(true);")); + assertTrue("the link must render a focused state", + activity.contains("select.setBackgroundResource(R.drawable.selector_lab_link);")); + assertTrue("the dialog must anchor the initial focus on the file-picker link", + activity.contains("select.requestFocus();")); + assertTrue("the link must hand UP back to the paste field (geometry alone does not work)", + activity.contains("select.setNextFocusUpId(edit.getId());")); + assertTrue("the link drawable must define a focused state", + stripComments(read("src/main/res/drawable/selector_lab_link.xml")) + .contains("android:state_focused=\"true\"")); + // 环色必须与链接文字色同源(LabActivity 用 R.color.webhtv_color_primary)。用固定的 + // @color/accent 会在浅色弹窗面板 #E7E8EF 上掉到 2.56:1,低于仓库既有的「焦点环 ≥3:1」门。 + String link = stripComments(read("src/main/res/drawable/selector_lab_link.xml")); + assertTrue("the link focus ring must use the theme token (5.23:1 light / 7.86:1 night)", + link.contains("@color/webhtv_color_primary")); + assertFalse("the fixed lab accent is only 2.56:1 on the light dialog panel", + link.contains("@color/accent")); + } + + /** + * 「实验室配置源」弹窗必须能用遥控器操作。 + * + *

实测(leanback arm64 debug,1920x1080):打开后焦点落在「配置源」下拉上,遥控 DOWN / UP 全都不移动 + * (被 AutoCompleteTextView / 可编辑文本当光标键吃掉),到不了文件夹图标、URL、根目录、开关和取消/确定; + * 下拉展开后 DOWN 能移动高亮,但按 OK 只关闭列表、不回调选中项(点选同一行才生效)。 + */ + @Test + public void settingsDialogReleasesVerticalKeysAndAppliesTheDropdownPick() throws Exception { + String activity = stripComments(read("src/main/java/com/fongmi/android/tv/lab/LabActivity.java")); + assertTrue("the settings dialog must install the vertical-key escape", + activity.contains("escapeVerticalKeys(cycle, dropdown, input, rootInput, proxyPort, proxyNoProxy);")); + String escape = methodBody(activity, "private void escapeVerticalKeys("); + assertTrue("vertical keys move focus", + escape.contains("view.focusSearch(")); + assertTrue("the open dropdown list is dismissed before moving focus", + escape.contains("dropdown.dismissDropDown();")); + assertTrue("the confirm key cycles the dropdown instead of using the unusable popup", + escape.contains("cycle.run();")); + assertTrue("the cycle advances to the next option", + activity.contains("+ 1) % items.length")); + assertTrue("touch picking and the remote cycle share one code path", + activity.contains("dropdown.setOnItemClickListener((parent, view, position, id) -> pick.accept(position));")); + } + + /** + * 配置源下拉必须能直接 DOWN 到「选本地配置」的文件夹按钮,且两种配置源模式都要走对。 + * + *

实测(leanback arm64 debug,1920x1080):焦点搜索按加权距离比较,文件夹在右侧、 + * 根目录在正下方,于是 DOWN 直接从下拉跳到根目录,跳过了用户想点的文件按钮。 + * + *

{@code nextFocus*} 会顺着「目标控件自己的连线」继续走、并跳过不可见目标, + * 所以把链条写成 下拉 → 文件夹 → URL 输入框 → 根目录:本地配置模式下 URL 输入框是 GONE, + * 链条自然落到根目录;网络 URL 模式下文件夹是 GONE,链条落到 URL 输入框。 + */ + @Test + public void settingsDialogWiresTheDropdownToTheFileButton() throws Exception { + String layout = stripComments(read("src/main/res/layout/dialog_lab_settings.xml")); + assertTrue("the dropdown must hand focus down to the file button", + layout.contains("android:nextFocusDown=\"@+id/folder\"")); + assertTrue("the file button must lead back up to the dropdown", + layout.contains("android:nextFocusUp=\"@+id/sourceDropdown\"")); + assertTrue("the file button must chain through the URL field", + layout.contains("android:nextFocusDown=\"@+id/input\"")); + assertTrue("the URL field must chain down to the root input", + layout.contains("android:nextFocusDown=\"@+id/rootInput\"")); + } + + /** + * 配置源弹窗的取消/确定必须按「就近」回到上方控件,不能硬钉到某个固定控件。 + * + *

实测(leanback arm64 debug,1920x1080):两个按钮在 ScrollView 之外,焦点搜索按绝对坐标比较, + * 内容滚到底时按 UP 落到「不走代理的域名」——这正是用户视觉上方的控件,属于正确行为。 + * 曾一度把它硬钉到「根目录」,反而与所见不符(用户已指出),故锁住「不得钉死」。 + */ + @Test + public void settingsDialogButtonsKeepGeometricUpFocus() throws Exception { + String activity = stripComments(read("src/main/java/com/fongmi/android/tv/lab/LabActivity.java")); + assertFalse("no button-focus pinning helper should remain", + activity.contains("wireButtonFocus(")); + assertFalse("the dialog buttons must not be pinned to a fixed target", + activity.contains("getButton(AlertDialog.BUTTON_NEGATIVE).setNextFocusUpId") + || activity.contains("getButton(AlertDialog.BUTTON_POSITIVE).setNextFocusUpId")); + } + + /** 分组标签的聚焦态必须由 selector 通过 state_focused 接上(聚焦图形本身是纯 shape)。 */ + @Test + public void groupTabSelectorWiresTheFocusedShape() throws Exception { + String selector = stripComments(read("src/main/res/drawable/selector_lab_group_tab.xml")); + assertTrue("the focused branch must come first so it wins over activated", + selector.indexOf("state_focused") < selector.indexOf("state_activated")); + assertTrue("the focused branch must point at the focused shape", + selector.contains("android:drawable=\"@drawable/shape_lab_group_tab_focused\"")); + assertTrue("the focused shape must keep the tab padding so the label does not jump", + read("src/main/res/drawable/shape_lab_group_tab_focused.xml").contains("android:left=\"12dp\"")); + } + + /** + * 详情页图标按钮必须显式声明焦点邻居。 + * + *

实测(leanback arm64 debug,小 UI 缩放):从「下载安装」按 DOWN 会直接跳到第一条命令, + * 跳过终端/新增/刷新三个图标 —— 焦点搜索按加权距离(13×主轴² + 次轴²)比较, + * 图标在右侧且行高只有 32px 时,正下方的命令行更「近」。用 nextFocus* 固定顺序即可, + * 目标控件不可见时框架会自动回退到几何搜索。 + */ + @Test + public void detailHeaderIconsDeclareFocusNeighbours() throws Exception { + String layout = stripComments(read(DETAIL_LAYOUT)); + assertTrue("the download button must hand focus down to the icon row", + layout.contains("android:nextFocusDown=\"@+id/btnTerminal\"")); + assertTrue("the terminal icon must lead back up to the download button", + layout.contains("android:nextFocusUp=\"@+id/btnDownload\"")); + assertTrue("the three icons must chain left to right", + layout.contains("android:nextFocusRight=\"@+id/btnAddCommand\"") + && layout.contains("android:nextFocusRight=\"@+id/btnRefreshCommand\"") + && layout.contains("android:nextFocusLeft=\"@+id/btnAddCommand\"")); + } + + /** 详情页顶部图标按钮必须用带聚焦态的 selector,而不是只有按下态的 borderless ripple。 */ + @Test + public void detailHeaderIconButtonsUseTheFocusSelector() throws Exception { + String layout = stripComments(read(DETAIL_LAYOUT)); + Matcher matcher = Pattern.compile("android:background=\"([^\"]+)\"").matcher(layout); + int focusSelectors = 0; + while (matcher.find()) { + if ("@drawable/selector_lab_icon_btn".equals(matcher.group(1))) focusSelectors++; + } + assertTrue("terminal / add / refresh icons must use selector_lab_icon_btn, found " + focusSelectors, + focusSelectors == 3); + assertFalse("a borderless ripple has no focused state", + layout.contains("?attr/selectableItemBackgroundBorderless")); + } + + // ------------------------------------------------------------------ helpers + + /** 取出方法体,避免断言被同文件其它方法的文本意外满足。 */ + private static String methodBody(String source, String signature) { + int start = source.indexOf(signature); + assertTrue(signature + " is missing", start >= 0); + int open = source.indexOf('{', start); + int depth = 0; + for (int i = open; i < source.length(); i++) { + char c = source.charAt(i); + if (c == '{') depth++; + else if (c == '}' && --depth == 0) return source.substring(open, i + 1); + } + throw new AssertionError(signature + " has no closing brace"); + } + + private static String stripComments(String source) { + return source + .replaceAll("(?s)", "") + .replaceAll("(?sm)^\\s*/\\*.*?\\*/", "") + .replaceAll("(?m)^\\s*//.*$", ""); + } + + private static String read(String path) throws Exception { + Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + return Files.readString(root.resolve(path), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/model/SiteViewModelSubmitAfterClearTest.java b/app/src/test/java/com/fongmi/android/tv/model/SiteViewModelSubmitAfterClearTest.java new file mode 100644 index 0000000000..81ee1d7f92 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/model/SiteViewModelSubmitAfterClearTest.java @@ -0,0 +1,100 @@ +package com.fongmi.android.tv.model; + +import com.google.common.util.concurrent.ListenableFuture; + +import org.junit.Test; + +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.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * 播放页销毁会清掉 ViewModel,而宿主在销毁后仍可能发起请求: + * 播放页的 onRefresh()/自动选集、短剧队列的下一条预取、阅读器章节切换都会走到 + * {@link SiteViewModel} 的提交入口。此前这些提交会命中 shutdownNow() 后的单线程池并抛 + * {@code RejectedExecutionException: Task rejected from ThreadPoolExecutor [Terminated, ...]}, + * 异常顺着宿主的 UI 线程冒出去直接打崩 App。 + * + *

因此 onCleared() 之后的提交必须统一降级为「本次请求作废」,而不是把异常留给宿主。

+ */ +public class SiteViewModelSubmitAfterClearTest { + + @Test + public void foregroundEntriesAfterClearDoNotThrow() { + SiteViewModel model = new SiteViewModel(); + model.onCleared(); + + model.playerContent("key", "flag", "episode"); + model.playerContent("key", "flag", "episode", 0); + model.homeContent(); + model.categoryContent("key", "tid", "1", false, null); + model.detailContent("key", "id"); + model.action("key", "act"); + model.searchContent(List.of(), "keyword", true); + } + + @Test + public void isolatedResolverAfterClearReturnsCancelledFuture() { + SiteViewModel model = new SiteViewModel(); + model.onCleared(); + + ListenableFuture future = model.playerContentIsolated("key", "flag", "episode", 0, null, null); + + assertNotNull(future); + assertTrue("清理后的隔离取流必须直接作废", future.isCancelled()); + + model.playerContentIsolated("key", "flag", "episode", 0, null, null); + model.cancelPlayerContentIsolated(); + model.stopSearch(); + } + + @Test + public void lateForegroundRequestAfterClearIsDroppedInsteadOfDelivered() { + SiteViewModel model = new SiteViewModel(); + model.onCleared(); + + model.playerContent("key", "flag", "episode"); + + assertNull("已清理的 ViewModel 不应再向播放 LiveData 投递结果", model.getPlayer().getValue()); + assertNull(model.getResult().getValue()); + assertNull(model.getAction().getValue()); + } + + /** 覆盖「检查通过后池才被关闭」的竞态:try/catch 兜底必须生效,异常不得逃逸。 */ + @Test + public void concurrentSubmitDuringClearNeverPropagatesRejection() throws Exception { + AtomicReference escaped = new AtomicReference<>(); + for (int round = 0; round < 200 && escaped.get() == null; round++) { + SiteViewModel model = new SiteViewModel(); + CountDownLatch start = new CountDownLatch(1); + Thread submitter = new Thread(() -> { + try { + start.await(); + for (int i = 0; i < 50; i++) { + model.playerContent("key", "flag", "episode"); + model.playerContentIsolated("key", "flag", "episode", 0, null, null); + } + } catch (Throwable e) { + escaped.compareAndSet(null, e); + } + }, "submit-during-clear"); + submitter.setDaemon(true); + submitter.start(); + start.countDown(); + model.onCleared(); + submitter.join(TimeUnit.SECONDS.toMillis(10)); + } + Throwable error = escaped.get(); + assertNull(error == null ? null : String.valueOf(error) + "\n" + firstFrame(error), error); + } + + private static String firstFrame(Throwable error) { + StackTraceElement[] frames = error.getStackTrace(); + return frames.length == 0 ? "" : frames[0].toString(); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/node/NodePortSelectionTest.java b/app/src/test/java/com/fongmi/android/tv/node/NodePortSelectionTest.java index 28ffe8e98c..375e60a5c3 100644 --- a/app/src/test/java/com/fongmi/android/tv/node/NodePortSelectionTest.java +++ b/app/src/test/java/com/fongmi/android/tv/node/NodePortSelectionTest.java @@ -111,7 +111,20 @@ public class NodePortSelectionTest { assertTrue("NodeRuntime 启动必须有超时", runtime.contains("START_TIMEOUT_MS")); assertTrue("启动超时必须停止 NodeService", runtime.contains("NodeService.stop(context)")); assertTrue("启动超时必须复位 STARTING", runtime.contains("STARTING.compareAndSet(true, false)")); - assertTrue("CatSource 等待必须有时间上限", source.contains("latch.await(60, TimeUnit.SECONDS)")); + assertTrue("CatSource 等待必须使用集中定义的启动预算", source.contains("latch.await(NodeRuntime.START_TIMEOUT_MS, TimeUnit.MILLISECONDS)")); + assertTrue("NodeRuntime 必须给首次加载足够的启动预算", runtime.contains("START_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(12)")); + assertTrue("NodeService 必须给端口就绪探测独立预算", runtime.contains("READY_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(3)")); + assertTrue("NodeService 必须给单次 /config 探测独立短超时", runtime.contains("READY_PROBE_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(2)")); + } + + @Test + public void readyProbeUsesBoundedPerRequestTimeoutAndLongerOverallBudget() throws IOException { + String runtime = read("com/fongmi/android/tv/node/NodeRuntime.java"); + String service = read("com/fongmi/android/tv/node/NodeService.java"); + assertTrue("就绪探测必须使用独立单次超时", service.contains("callTimeout(NodeRuntime.READY_PROBE_TIMEOUT_MS") + && service.contains("probeClient.newCall") && service.contains("NodeRuntime.READY_PROBE_TIMEOUT_MS")); + assertTrue("就绪探测必须使用整体 deadline,不能固定短轮数", service.contains("NodeRuntime.READY_TIMEOUT_MS") && service.contains("SystemClock.elapsedRealtime()")); + assertTrue("NodeService 不得仍使用旧的 225 轮固定上限", !service.contains("for (int i = 0; i < 225; i++)")); } @Test @@ -181,6 +194,19 @@ public class NodePortSelectionTest { } } + @Test + public void remoteMetadataIsParallelAndBundleTransfersUseLongerTimeout() throws IOException { + String bundle = read("com/fongmi/android/tv/node/NodeBundle.java"); + String runtime = read("com/fongmi/android/tv/node/NodeRuntime.java"); + assertTrue("远端 md5 必须并行获取", bundle.contains("remoteMd5Pair") + && bundle.contains("cat-md5-bundle") && bundle.contains("cat-md5-config")); + assertTrue("每个 md5 请求必须有完整调用超时,不能只限制连接阶段", bundle.contains("callTimeout(METADATA_TIMEOUT_MS")); + assertTrue("bundle 下载必须使用独立传输预算", bundle.contains("NodeRuntime.TRANSFER_TIMEOUT_MS")); + assertTrue("NodeLib 下载必须使用更长的运行时传输预算", + read("com/fongmi/android/tv/node/NodeLib.java").contains("NodeRuntime.LIB_TRANSFER_TIMEOUT_MS")); + assertTrue("运行时必须定义传输预算", runtime.contains("TRANSFER_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(3)")); + } + @Test public void bundleMetadataAndFirstDownloadsUseBoundedFailClosedValidation() throws IOException { String bundle = read("com/fongmi/android/tv/node/NodeBundle.java"); diff --git a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackEpisodeIdTest.java b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackEpisodeIdTest.java new file mode 100644 index 0000000000..1a8f165daa --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackEpisodeIdTest.java @@ -0,0 +1,130 @@ +package com.fongmi.android.tv.playback; + +import com.google.gson.JsonObject; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * 集消歧字段({@code episodeId})的契约。 + * + *

背景:只读 API({@code apiSafe()})不输出 {@code episodeUrl}——那是带时效 + * 签名的播放地址。但对端要区分同一剧的不同集必须有稳定的集身份,否则同名集 + * (如两条线路都有"第01集")会塌缩成同一条记录、互相覆盖。 + * {@code episodeId} 就是 {@code episodeUrl} 的 SHA-256,只给指纹不给地址。 + */ +public class PlaybackEpisodeIdTest { + + @Test + public void emptyUrlProducesEmptyIdInsteadOfHashOfEmptyString() { + // 空串必须映射到空串:对端据此就能区分"没有单集地址"与"有一个地址", + // 不必再猜。若返回 sha256("") 的常量,对端会把它当成一个真实集身份。 + assertEquals("", PlaybackRecord.episodeId("")); + assertEquals("", PlaybackRecord.episodeId(null)); + } + + @Test + public void nonEmptyUrlProducesStableSixtyFourHexChars() { + String url = "https://cdn.example.com/live/abc.m3u8?sign=deadbeef&t=123"; + String id = PlaybackRecord.episodeId(url); + + assertEquals(64, id.length()); + assertTrue(id.matches("[0-9a-f]{64}")); + // 确定性:同一输入必须得到同一指纹(对端与本地各算一次也要一致)。 + assertEquals(id, PlaybackRecord.episodeId(url)); + } + + @Test + public void differentUrlsProduceDifferentIds() { + // 这是本字段存在的理由:不同集必须可区分。 + String first = PlaybackRecord.episodeId("https://cdn.example.com/ep01.m3u8"); + String second = PlaybackRecord.episodeId("https://cdn.example.com/ep02.m3u8"); + + assertFalse(first.equals(second)); + } + + @Test + public void apiSafeExposesEpisodeIdButNotEpisodeUrl() { + PlaybackFieldPolicy policy = PlaybackFieldPolicy.apiSafe(); + + // 集身份必须可得,否则对端无法消歧。 + assertTrue("apiSafe 必须暴露 episodeId", policy.includes("episodeId")); + // 播放地址必须不可得:它常带 CDN 签名/token,对端只需要"是不是同一集"。 + assertFalse("apiSafe 不得暴露 episodeUrl", policy.includes("episodeUrl")); + } + + @Test + public void recordSerializesEpisodeIdUnderApiSafe() { + PlaybackRecord record = new PlaybackRecord(); + record.siteKey = "site"; + record.vodId = "vod"; + record.episodeUrl = "https://cdn.example.com/ep01.m3u8"; + record.episodeId = PlaybackRecord.episodeId(record.episodeUrl); + + JsonObject json = record.toJson(PlaybackFieldPolicy.apiSafe()); + + assertTrue(json.has("episodeId")); + assertEquals(record.episodeId, json.get("episodeId").getAsString()); + assertFalse("apiSafe 序列化不得写出 episodeUrl", json.has("episodeUrl")); + } + + @Test + public void episodeIdIsDerivedFromEpisodeUrlAtConstructionSites() { + // 生产路径:PlaybackRecord.from() 会调 episodeId(episodeUrl) 自动补指纹。 + // 但 from() 依赖 Config.find()/VodConfig(Android 运行时),JVM 单测里不可用, + // 因此这里只锁"赋值契约":给定 url 时字段必须等于 episodeId(url)。 + // from() 的接线由源码断言锁(见下一条用例)。 + PlaybackRecord record = new PlaybackRecord(); + record.episodeUrl = "https://cdn.example.com/ep01.m3u8"; + record.episodeId = PlaybackRecord.episodeId(record.episodeUrl); + + assertEquals(PlaybackRecord.episodeId("https://cdn.example.com/ep01.m3u8"), record.episodeId); + assertFalse(record.episodeId.isEmpty()); + } + + @Test + public void fromWiresEpisodeIdFromEpisodeUrl() throws Exception { + // 源码级接线锁:from() 必须真的调用 episodeId(...)。 + // + // 为什么用源码断言而不是行为断言:from() 依赖 Config.find() 与 VodConfig, + // 两者都需要 Android 运行时;本仓库已有同类先例(如 + // GlobalHistoryResumeSourceTest 用源码断言锁接线)。 + // 断言前剥掉注释行,避免"把语句注释掉"也能通过。 + String source = readSource("app/src/main/java/com/fongmi/android/tv/playback/PlaybackRecord.java"); + assertTrue("from() 必须从 episodeUrl 派生 episodeId", + source.contains("record.episodeId = episodeId(record.episodeUrl);")); + assertTrue("toJson 必须在 apiSafe 下写出 episodeId", + source.contains("if (policy.includes(\"episodeId\") && !TextUtils.isEmpty(episodeId)) object.addProperty(\"episodeId\", episodeId);")); + } + + private static String readSource(String relative) throws Exception { + // 从 user.dir 逐级向上找项目根:Gradle 的 JVM 单测工作目录可能是仓库根, + // 也可能是模块目录(app/);写死单层回退会在换一种布局后静默失效。 + // 仓库既有先例:ExoVideoHardwareDecodeTest.readSource 用同样的向上查找。 + java.nio.file.Path root = java.nio.file.Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + while (root != null && !java.nio.file.Files.isRegularFile(root.resolve(relative))) root = root.getParent(); + if (root == null) throw new IllegalStateException("找不到项目根,无法读取 " + relative); + StringBuilder builder = new StringBuilder(); + for (String line : java.nio.file.Files.readAllLines(root.resolve(relative), java.nio.charset.StandardCharsets.UTF_8)) { + String trimmed = line.trim(); + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) continue; + builder.append(line).append('\n'); + } + return builder.toString(); + } + + @Test + public void inputParsesEpisodeIdAndDerivesItFromUrlWhenAbsent() { + // 对端可能只给 url(旧形态)或只给 id(apiSafe 形态),两种都必须能收。 + PlaybackProgressInput fromId = PlaybackProgressInput.fromJson( + "{\"episodeId\":\"abc123\",\"siteKey\":\"s\",\"vodId\":\"v\"}"); + assertEquals("abc123", fromId.episodeId); + + PlaybackProgressInput fromUrl = PlaybackProgressInput.fromJson( + "{\"episodeUrl\":\"https://cdn.example.com/ep01.m3u8\",\"siteKey\":\"s\",\"vodId\":\"v\"}"); + assertEquals(PlaybackRecord.episodeId("https://cdn.example.com/ep01.m3u8"), fromUrl.episodeId); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackHistoryDiagnosticsWiringTest.java b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackHistoryDiagnosticsWiringTest.java new file mode 100644 index 0000000000..8e8045a82d --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackHistoryDiagnosticsWiringTest.java @@ -0,0 +1,108 @@ +package com.fongmi.android.tv.playback; + +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * 「按站点读历史」这条新读取路径的失败必须同时留在「诊断日志」和「Logcat」两条通道上。 + * + *

为什么需要这个守卫:{@code SpiderDebug.log(tag, …)} 的每一条重载先是 + * {@code DebugLogStore.acceptsTag(tag)},而它等于 {@code enabled && …}; + * {@code DebugLogStore.enabled} 默认是 {@code false}(要用户在设置里打开「调试日志」)。 + * 因此只用 {@code SpiderDebug} 记录读库失败,在默认设置下等同于**完全静默**。 + * + *

而这条路径恰恰是「对端看到空历史却查不出原因」的唯一解释点: + * 空数组既可能是「这个站点真没历史」,也可能是「库挂了」或「单条记录坏了被跳过」。 + * 两者在响应里长得一模一样,只有日志能区分。仓库既有同类口径: + * {@code PyLoaderDiagnosticsWiringTest}(C73 修复)与 {@code JsLoader}。 + */ +public class PlaybackHistoryDiagnosticsWiringTest { + + private static final Path API = Path.of("app/src/main/java/com/fongmi/android/tv/playback/PlaybackApi.java"); + private static final Path ENDPOINT = Path.of("app/src/main/java/com/fongmi/android/tv/server/process/PlaybackHistoryApi.java"); + + /** + * 去掉注释行后再断言:否则把语句注释掉({@code // e.printStackTrace();})也能满足 + * 「有兜底日志」的断言,守卫就退化成恒真检查。 + * + *

路径从 {@code user.dir} 逐级向上找项目根:Gradle 的 JVM 单测工作目录可能是 + * 仓库根,也可能是模块目录({@code app/},见 {@code PyLoaderDiagnosticsWiringTest} + * 用模块相对路径),写死一种布局会在换布局后静默失效。 + */ + private static String code(Path relative) throws Exception { + Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + while (root != null && !Files.isRegularFile(root.resolve(relative))) root = root.getParent(); + if (root == null) throw new IllegalStateException("找不到项目根,无法读取 " + relative); + StringBuilder result = new StringBuilder(); + for (String line : Files.readString(root.resolve(relative)).replace("\r\n", "\n").split("\n", -1)) { + String trimmed = line.trim(); + if (!trimmed.startsWith("//") && !trimmed.startsWith("*") && !trimmed.startsWith("/*")) { + result.append(line).append('\n'); + } + } + return result.toString(); + } + + /** + * 取第 {@code index} 个 {@code catch (Throwable e) {} } 块体。 + * + *

为什么必须按块取:整批读库失败与单条坏记录是两个相邻的 catch。 + * 若只做「从某处往后找 printStackTrace」,第一个 catch 被删掉时断言会匹配到 + * **第二个** catch 的兜底日志而恒真——这正是本守卫第一版踩过的坑。 + */ + private static String catchBlock(String source, int index) { + int start = -1; + for (int i = 0; i <= index; i++) start = source.indexOf("catch (Throwable e) {", start + 1); + if (start < 0) throw new IllegalStateException("找不到第 " + index + " 个 catch 块"); + // 用花括号配平定位块尾,而不是匹配字面缩进(原先找 "\n }")。 + // 字面缩进有两个失效面:失配时回退到 source.length() 会让块体延伸到文件尾, + // 于是"块里含某段文本"对后续任何 catch 都恒真;即便改成显式失败,缩进一变 + // 也会匹配到**更靠后的**同缩进花括号,块体同样被撑大。配平扫描对缩进免疫。 + int open = source.indexOf('{', start); + int depth = 0; + for (int i = open; i < source.length(); i++) { + char c = source.charAt(i); + if (c == '{') depth++; + else if (c == '}' && --depth == 0) return source.substring(start, i + 1); + } + throw new IllegalStateException("第 " + index + " 个 catch 块缺少配对的结束花括号"); + } + + @Test + public void databaseReadFailureReachesLogcatWithoutDebugLoggingEnabled() throws Exception { + String block = catchBlock(code(API), 0); + + assertTrue("读库失败必须进诊断日志", block.contains("SpiderDebug.log(\"playback-api\", e);")); + assertTrue("读库失败还必须无条件进 Logcat(SpiderDebug 默认不落盘)", block.contains("e.printStackTrace();")); + } + + @Test + public void perRecordFailureReachesLogcatToo() throws Exception { + String block = catchBlock(code(API), 1); + + // 单条坏记录被跳过是「历史里少了一条」的唯一解释点,同样必须有兜底通道。 + assertTrue("单条坏记录必须进诊断日志", block.contains("SpiderDebug.log(\"playback-api\", e);")); + assertTrue("单条坏记录的跳过路径也必须留下无条件日志", block.contains("e.printStackTrace();")); + } + + @Test + public void endpointFailureReachesLogcat() throws Exception { + String block = catchBlock(code(ENDPOINT), 0); + + assertTrue("端点 500 必须进诊断日志", block.contains("SpiderDebug.log(\"playback-api\", e);")); + assertTrue("端点 500 还必须无条件进 Logcat", block.contains("e.printStackTrace();")); + } + + @Test + public void emptyResultIsStillDistinguishableFromFailureInTheResponse() throws Exception { + // 契约的另一半:读库失败返回空数组是**有意的**(对端据此不推进游标), + // 因此不能靠「把失败改成 500」来省掉日志——两者必须同时成立。 + String block = catchBlock(code(API), 0); + + assertTrue("读库失败后仍返回空数组,不抛给调用方", block.contains("return Collections.emptyList();")); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackHistoryLimitTest.java b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackHistoryLimitTest.java new file mode 100644 index 0000000000..b297bbdadd --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackHistoryLimitTest.java @@ -0,0 +1,41 @@ +package com.fongmi.android.tv.playback; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** + * 按站点读历史时的条数夹取契约。 + * + *

为什么值得单独锁:{@code limit} 是对端能直接控制的输入。若不做上限夹取, + * 一次 {@code limit=999999} 就能让本机一次性把整张表读进内存并序列化成响应体。 + */ +public class PlaybackHistoryLimitTest { + + @Test + public void missingOrNonPositiveLimitFallsBackToDefault() { + assertEquals(PlaybackApi.DEFAULT_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(0)); + assertEquals(PlaybackApi.DEFAULT_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(-1)); + assertEquals(PlaybackApi.DEFAULT_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(Integer.MIN_VALUE)); + } + + @Test + public void limitAboveMaximumIsClamped() { + assertEquals(PlaybackApi.MAX_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(PlaybackApi.MAX_HISTORY_LIMIT + 1)); + assertEquals(PlaybackApi.MAX_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(999_999)); + assertEquals(PlaybackApi.MAX_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(Integer.MAX_VALUE)); + } + + @Test + public void limitWithinRangeIsPreserved() { + assertEquals(1, PlaybackApi.effectiveHistoryLimit(1)); + assertEquals(PlaybackApi.DEFAULT_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(PlaybackApi.DEFAULT_HISTORY_LIMIT)); + assertEquals(PlaybackApi.MAX_HISTORY_LIMIT, PlaybackApi.effectiveHistoryLimit(PlaybackApi.MAX_HISTORY_LIMIT)); + } + + @Test + public void defaultIsNotAboveMaximum() { + // 常量本身必须自洽:默认值若超过上限,夹取逻辑就自相矛盾。 + org.junit.Assert.assertTrue(PlaybackApi.DEFAULT_HISTORY_LIMIT <= PlaybackApi.MAX_HISTORY_LIMIT); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackProgressWriterEpisodeTest.java b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackProgressWriterEpisodeTest.java index 2c82cd05f0..fc6a9cc1a4 100644 --- a/app/src/test/java/com/fongmi/android/tv/playback/PlaybackProgressWriterEpisodeTest.java +++ b/app/src/test/java/com/fongmi/android/tv/playback/PlaybackProgressWriterEpisodeTest.java @@ -4,6 +4,7 @@ import com.fongmi.android.tv.bean.History; import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -25,6 +26,99 @@ public class PlaybackProgressWriterEpisodeTest { assertFalse(PlaybackProgressWriter.isSameEpisode(local, input)); } + @Test + public void sourceBindingIsCopiedIntoPersistedHistory() { + History history = new History(); + PlaybackProgressInput input = new PlaybackProgressInput(); + input.sourceBindingKey = "stable-interface-key"; + + PlaybackProgressWriter.applySourceBindingKey(history, input); + + assertTrue("stable-interface-key".equals(history.getSourceBindingKey())); + assertTrue("stable-interface-key".equals(history.copy().getSourceBindingKey())); + } + + @Test + public void fingerprintProvesSameEpisodeWhenPeerHasNoUrl() { + // 只读 API 故意不输出 episodeUrl,只给指纹;对端原样回推时就是这种形态。 + // 没有季集号的内容(非 TMDB)只有指纹能证明「这是同一集」。 + History local = history("源站第1集", "https://cdn.example.com/ep01.m3u8", -1, -1); + PlaybackProgressInput input = input("源站第1集", ""); + input.episodeId = PlaybackRecord.episodeId("https://cdn.example.com/ep01.m3u8"); + + assertTrue(PlaybackProgressWriter.sameEpisodeFingerprint(local, input)); + } + + @Test + public void differentFingerprintIsNotTheSameEpisode() { + // 对端推来的可能是另一集:此时不能把本地地址当成新集的地址保留。 + History local = history("源站第1集", "https://cdn.example.com/ep01.m3u8", -1, -1); + PlaybackProgressInput input = input("源站第2集", ""); + input.episodeId = PlaybackRecord.episodeId("https://cdn.example.com/ep02.m3u8"); + + assertFalse(PlaybackProgressWriter.sameEpisodeFingerprint(local, input)); + } + + @Test + public void oneSidedFingerprintIsNotEnough() { + // 单侧有指纹无法证明同一集(对端可能是另一条线路的同名集),必须保守。 + History local = history("源站第1集", "https://cdn.example.com/ep01.m3u8", -1, -1); + PlaybackProgressInput input = input("源站第1集", ""); + + assertFalse(PlaybackProgressWriter.sameEpisodeFingerprint(local, input)); + assertFalse(PlaybackProgressWriter.sameEpisodeFingerprint(null, input)); + } + + @Test + public void peerRoundTripWithoutUrlKeepsTheLocalPlaybackUrl() { + // 端到端决策:对端把只读 API 读到的记录原样回推(没有 episodeUrl,只有指纹), + // 本地已落库的播放地址必须保留——它是「从历史继续播放」定位集条目的依据。 + String url = "https://cdn.example.com/ep01.m3u8"; + History local = history("源站第1集", url, -1, -1); + PlaybackProgressInput input = input("源站第1集", ""); + input.episodeId = PlaybackRecord.episodeId(url); + + assertEquals(url, PlaybackProgressWriter.resolveEpisodeUrl(local, input, false)); + } + + @Test + public void peerPushingAnotherEpisodeDoesNotKeepTheStaleUrl() { + // 反向约束:对端推的确是另一集时,不能把上一集的地址挂到新集的进度上。 + History local = history("源站第1集", "https://cdn.example.com/ep01.m3u8", -1, -1); + PlaybackProgressInput input = input("源站第2集", ""); + input.episodeId = PlaybackRecord.episodeId("https://cdn.example.com/ep02.m3u8"); + + assertEquals("", PlaybackProgressWriter.resolveEpisodeUrl(local, input, false)); + } + + @Test + public void incomingUrlAlwaysWins() { + // 入参带了地址就是权威(对端在播放、地址是新的),直接采用。 + History local = history("源站第1集", "https://cdn.example.com/old.m3u8", -1, -1); + PlaybackProgressInput input = input("源站第1集", "https://cdn.example.com/new.m3u8"); + + assertEquals("https://cdn.example.com/new.m3u8", + PlaybackProgressWriter.resolveEpisodeUrl(local, input, false)); + } + + @Test + public void sameEpisodeNameKeepsTheLocalUrl() { + // 既有容错语义(sameEpisode=true)也必须保留本地地址,不能只认指纹。 + History local = history("源站第1集", "https://cdn.example.com/ep01.m3u8", -1, -1); + PlaybackProgressInput input = input("源站第1集", ""); + + assertEquals("https://cdn.example.com/ep01.m3u8", + PlaybackProgressWriter.resolveEpisodeUrl(local, input, true)); + } + + @Test + public void firstTimeWriteWithoutUrlStaysEmpty() { + // 本地本来就没有记录:没有地址可保留,落空串而不是 null。 + PlaybackProgressInput input = input("源站第1集", ""); + + assertEquals("", PlaybackProgressWriter.resolveEpisodeUrl(null, input, false)); + } + private static History history(String remarks, String url, int season, int episode) { History history = new History(); history.setVodRemarks(remarks); diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoBufferingStallWatchdogTest.java b/app/src/test/java/com/fongmi/android/tv/player/BufferingStallWatchdogTest.java similarity index 75% rename from app/src/test/java/com/fongmi/android/tv/player/exo/ExoBufferingStallWatchdogTest.java rename to app/src/test/java/com/fongmi/android/tv/player/BufferingStallWatchdogTest.java index 360fb630a5..f6b76c3825 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoBufferingStallWatchdogTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/BufferingStallWatchdogTest.java @@ -1,28 +1,29 @@ -package com.fongmi.android.tv.player.exo; +package com.fongmi.android.tv.player; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import org.junit.Test; -public class ExoBufferingStallWatchdogTest { +public class BufferingStallWatchdogTest { - private static final long TIMEOUT = ExoBufferingStallWatchdog.STALL_TIMEOUT_MS; - private static final long STALL = ExoBufferingStallWatchdog.STALL_TIMEOUT_MS; + private static final long TIMEOUT = BufferingStallWatchdog.STALL_TIMEOUT_MS; + private static final long STALL = BufferingStallWatchdog.STALL_TIMEOUT_MS; private static final long SEGMENT_PROGRESS_MARGIN_MS = - ExoBufferingStallWatchdog.SEGMENT_PROGRESS_MARGIN_MS; - private static final long LOADING_TIMEOUT = ExoBufferingStallWatchdog.LOADING_STALL_TIMEOUT_MS; + BufferingStallWatchdog.SEGMENT_PROGRESS_MARGIN_MS; + private static final long LOADING_TIMEOUT = BufferingStallWatchdog.LOADING_STALL_TIMEOUT_MS; @Test public void staysQuietUntilArmed() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); assertFalse(watchdog.isArmed()); assertFalse(watchdog.shouldTimeout(LOADING_TIMEOUT * 10, 0, 0, false)); } @Test public void timesOutOnlyWhenNeitherPositionNorBufferAdvances() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); assertFalse(watchdog.shouldTimeout(TIMEOUT - 1, 5_000, 9_000, false)); assertTrue(watchdog.shouldTimeout(TIMEOUT, 5_000, 9_000, false)); @@ -30,7 +31,7 @@ public class ExoBufferingStallWatchdogTest { @Test public void growingBufferIsProgressEvenWhilePositionStandsStill() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // A normal rebuffer: position frozen, buffered end still climbing. watchdog.observe(TIMEOUT - 1, 5_000, 12_000); @@ -40,7 +41,7 @@ public class ExoBufferingStallWatchdogTest { @Test public void advancingPositionIsProgressEvenWhileBufferStandsStill() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); watchdog.observe(TIMEOUT - 1, 7_000, 9_000); assertFalse(watchdog.shouldTimeout(TIMEOUT, 7_000, 9_000, false)); @@ -48,7 +49,7 @@ public class ExoBufferingStallWatchdogTest { @Test public void loadingSourceGetsTheLongerCeiling() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // A remote Matroska Cues fetch produces no samples yet is not stalled. assertFalse(watchdog.shouldTimeout(TIMEOUT, 5_000, 9_000, true)); @@ -65,16 +66,28 @@ public class ExoBufferingStallWatchdogTest { @Test public void resetDisarms() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); watchdog.reset(); assertFalse(watchdog.isArmed()); assertFalse(watchdog.shouldTimeout(LOADING_TIMEOUT * 10, 5_000, 9_000, false)); } + @Test + public void stalledDurationMeasuresTimeSinceTheLastProgress() { + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); + assertTrue("a disarmed watchdog has no measured stall", watchdog.stalledDurationMs(50_000) < 0); + watchdog.arm(1_000, 5_000, 9_000); + assertEquals(0, watchdog.stalledDurationMs(1_000)); + // Progress moves the clock forward, so the reported stall restarts from there. + watchdog.observe(4_000, 5_000, 12_000); + assertEquals(0, watchdog.stalledDurationMs(4_000)); + assertEquals(TIMEOUT, watchdog.stalledDurationMs(4_000 + TIMEOUT)); + } + @Test public void observeArmsWhenNotYetArmed() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.observe(1_000, 5_000, 9_000); assertTrue(watchdog.isArmed()); assertFalse(watchdog.shouldTimeout(1_000 + TIMEOUT - 1, 5_000, 9_000, false)); @@ -83,14 +96,17 @@ public class ExoBufferingStallWatchdogTest { @Test public void timeoutMustOutlastMaxRebufferThreshold() { - // A LoadControl still filling its rebuffer threshold must never be killed. - assertTrue(ExoBufferingStallWatchdog.STALL_TIMEOUT_MS - > ExoPlaybackThresholdPolicy.MAX_STREAMING_REBUFFER_MS); + // The engine-specific thresholds are asserted where they are visible: + // Exo's MAX_STREAMING_REBUFFER_MS in ExoPlaybackThresholdPolicyTest, MPV's 8 s ceiling + // here. This test only pins the shared watchdog's own floor. + assertTrue(BufferingStallWatchdog.STALL_TIMEOUT_MS > 8_000L); + assertTrue(BufferingStallWatchdog.LOADING_STALL_TIMEOUT_MS + > BufferingStallWatchdog.STALL_TIMEOUT_MS); } @Test public void regressedSampleRearmsBecauseItIsADiscontinuity() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 300_000, 320_000); // A backward seek drops position and buffered end well below the baseline. Treating // that as "no progress" would make every later sample satisfy the criterion and time @@ -103,7 +119,7 @@ public class ExoBufferingStallWatchdogTest { @Test public void aLateBackwardSeekIsRebasedBeforeTheTimeoutCheck() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 300_000, 320_000); long now = TIMEOUT + 1_000; // This is the order used by PlayerManager's polling loop: observe the sample first, @@ -115,7 +131,7 @@ public class ExoBufferingStallWatchdogTest { @Test public void repeatingLargeRegressionCannotDeferTheTimeoutForever() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // Alternating growth and a >tolerance regression rebaselines the clock every tick. // Without an episode ceiling this defers the timeout indefinitely while the position @@ -132,18 +148,18 @@ public class ExoBufferingStallWatchdogTest { watchdog.observe(now, 5_000, buffered); } assertTrue("episode ceiling must bound a repeating regression cycle", fired); - assertTrue(now <= ExoBufferingStallWatchdog.EPISODE_CEILING_MS + 2_000); + assertTrue(now <= BufferingStallWatchdog.EPISODE_CEILING_MS + 2_000); } @Test public void ceilingSparesAGenuinelyProgressingEpisode() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // A large file on a slow link can spend well past the ceiling steadily filling its // rebuffer threshold. The ceiling must not kill it just because time elapsed. long now = 0; long buffered = 9_000; - while (now < ExoBufferingStallWatchdog.EPISODE_CEILING_MS + 2_000) { + while (now < BufferingStallWatchdog.EPISODE_CEILING_MS + 2_000) { now += 1_000; buffered += 200; assertFalse("fired at " + now + "ms despite progress", @@ -154,7 +170,7 @@ public class ExoBufferingStallWatchdogTest { @Test public void ceilingSparesProgressAfterABackwardSeek() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); // Start high, then seek far back. If the progress watermarks stayed at the pre-seek // values, every later sample would read as zero progress (clamped) and the ceiling // would kill a session that is actually advancing from the new position. @@ -162,7 +178,7 @@ public class ExoBufferingStallWatchdogTest { watchdog.observe(1_000, 30_000, 32_000); long position = 30_000; long buffered = 32_000; - for (long now = 2_000; now < ExoBufferingStallWatchdog.EPISODE_CEILING_MS + 5_000; + for (long now = 2_000; now < BufferingStallWatchdog.EPISODE_CEILING_MS + 5_000; now += 1_000) { position += 1_000; buffered += 1_000; @@ -174,7 +190,7 @@ public class ExoBufferingStallWatchdogTest { @Test public void stallAfterABackwardSeekStillFires() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 600_000, 620_000); watchdog.observe(1_000, 30_000, 32_000); // Frozen from the new position onward, so the normal criterion must still report it. @@ -183,17 +199,17 @@ public class ExoBufferingStallWatchdogTest { @Test public void ceilingStillFiresWhenNetProgressIsBelowTheMargin() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // Movement exists but is far below the margin, so this is not a working session. - long now = ExoBufferingStallWatchdog.EPISODE_CEILING_MS; - long buffered = 9_000 + ExoBufferingStallWatchdog.SEGMENT_PROGRESS_MARGIN_MS - 1_000; + long now = BufferingStallWatchdog.EPISODE_CEILING_MS; + long buffered = 9_000 + BufferingStallWatchdog.SEGMENT_PROGRESS_MARGIN_MS - 1_000; assertTrue(watchdog.shouldTimeout(now, 5_000, buffered, true)); } @Test public void oscillationWhollyAboveTheArmWatermarkStillTerminates() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // The severity case: a mid-playback rebuffer arms with buffered already high, and the // oscillation sits entirely above arm + margin. If the progress watermarks stayed pinned @@ -217,7 +233,7 @@ public class ExoBufferingStallWatchdogTest { @Test public void episodeCeilingDoesNotCountPausedTime() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); // A paused session re-arms every tick, so the ceiling never accumulates. long now = 0; for (int i = 0; i < 300; i++) { @@ -232,7 +248,7 @@ public class ExoBufferingStallWatchdogTest { @Test public void toleranceDipWithGrowthKeepsTheHigherBaseline() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_300); // Position grows while buffered dips within tolerance: the growth branch runs, and it // must not lower the buffered baseline, or the later rebound would count as progress @@ -245,7 +261,7 @@ public class ExoBufferingStallWatchdogTest { @Test public void oscillatingBufferedEndStillTimesOut() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); // Buffered end dithers within the jitter tolerance while position stays frozen. // Re-arming on each dip would reset the clock forever and never report the stall. @@ -259,16 +275,16 @@ public class ExoBufferingStallWatchdogTest { @Test public void jitterBelowToleranceDoesNotRearm() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 5_000, 9_000); - watchdog.observe(1_000, 5_000, 9_000 - (ExoBufferingStallWatchdog.DISCONTINUITY_TOLERANCE_MS - 1)); + watchdog.observe(1_000, 5_000, 9_000 - (BufferingStallWatchdog.DISCONTINUITY_TOLERANCE_MS - 1)); // Clock kept running from the original arm, so the original deadline still applies. assertTrue(watchdog.shouldTimeout(TIMEOUT, 5_000, 9_000, false)); } @Test public void aBackwardSeekDoesNotInheritTheOldBaseline() { - ExoBufferingStallWatchdog watchdog = new ExoBufferingStallWatchdog(); + BufferingStallWatchdog watchdog = new BufferingStallWatchdog(); watchdog.arm(0, 600_000, 620_000); // Seek from 10:00 back to 00:30 while buffering. watchdog.observe(1_000, 30_000, 32_000); diff --git a/app/src/test/java/com/fongmi/android/tv/player/LiveActivitySourceFallbackSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/LiveActivitySourceFallbackSourceTest.java index 78430555d1..3c6f421649 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/LiveActivitySourceFallbackSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/LiveActivitySourceFallbackSourceTest.java @@ -23,7 +23,7 @@ public class LiveActivitySourceFallbackSourceTest { for (Path path : List.of( Path.of("src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"), Path.of("src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"))) { - String source = Files.readString(path, StandardCharsets.UTF_8); + String source = read(path); assertTrue(path.toString(), source.contains("private boolean mFailedThisSession;")); assertTrue(path.toString(), source.contains("mFailedThisSession = true;")); assertTrue(path.toString(), source.contains("mFailedThisSession = false;")); @@ -35,7 +35,7 @@ public class LiveActivitySourceFallbackSourceTest { for (Path path : List.of( Path.of("src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"), Path.of("src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"))) { - String source = Files.readString(path, StandardCharsets.UTF_8); + String source = read(path); assertTrue(path.toString(), source.contains( "case Player.STATE_BUFFERING:\n mFailedThisSession = false;")); assertFalse(path.toString(), source.contains("if (mFailedThisSession) return;")); @@ -49,7 +49,7 @@ public class LiveActivitySourceFallbackSourceTest { @Test public void mobileSelectionWaitsForAsyncParseBeforeDecidingFallback() throws Exception { Path path = Path.of("src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"); - String source = Files.readString(path, StandardCharsets.UTF_8); + String source = read(path); String body = section(source, "private void getLive()", "private void renderLive(Live live)"); assertTrue(path.toString(), body.contains("Live live = getHome();")); @@ -63,18 +63,24 @@ public class LiveActivitySourceFallbackSourceTest { for (Path path : List.of( Path.of("src/leanback/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"), Path.of("src/mobile/java/com/fongmi/android/tv/ui/activity/LiveActivity.java"))) { - String source = Files.readString(path, StandardCharsets.UTF_8); + String source = read(path); assertFalse(path.toString(), source.contains("if (item.isSelected()) item.getGroups().clear();")); } } private static void assertFetchTimeoutIsReplaced(Path path) throws Exception { - String source = Files.readString(path, StandardCharsets.UTF_8); + String source = read(path); String expected = "App.removeCallbacks(mBufferingTimeout);\n" + " App.post(mBufferingTimeout, LIVE_BUFFERING_TIMEOUT);"; assertEquals(path.toString(), 2, occurrences(source, expected)); } + private static String read(Path path) throws Exception { + // 工作区行尾随平台而变(Windows autocrlf=true 时为 CRLF),归一化后再比对, + // 让多行 contains/occurrences 断言在 LF/CRLF 检出下行为一致(CI ubuntu 与本地 Windows 都能通过)。 + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8).replace("\r\n", "\n"); + } + private static int occurrences(String source, String target) { int count = 0; int offset = 0; diff --git a/app/src/test/java/com/fongmi/android/tv/player/LiveSourceFallbackPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/LiveSourceFallbackPolicyTest.java index 337334a118..fdd111a1ab 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/LiveSourceFallbackPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/LiveSourceFallbackPolicyTest.java @@ -9,28 +9,58 @@ import org.junit.Test; public class LiveSourceFallbackPolicyTest { + // ---------- 线路播放失败:decideLineFailure ---------- + @Test - public void regularLineFallbackUsesNextLineBeforeChangingSource() { - assertEquals(NEXT_LINE, LiveSourceFallbackPolicy.decide(true, true, true, false, false, true)); + public void playbackFailurePrefersNextLineOfCurrentChannel() { + assertEquals(NEXT_LINE, LiveSourceFallbackPolicy.decideLineFailure(true, true, true)); } @Test - public void disabledSourceFallbackDoesNothingAfterLineFallbackIsExhausted() { - assertEquals(NONE, LiveSourceFallbackPolicy.decide(false, false, true, true, false, true)); + public void playbackFailureOnTheLastLineStillRollsToTheNextLine() { + // 最后一条线路失败也要绕回换线(绕圈停止由 Activity 的 anchor 保证),绝不直接跳接口。 + assertEquals(NEXT_LINE, LiveSourceFallbackPolicy.decideLineFailure(true, true, true)); } @Test - public void enabledSourceFallbackUsesNextSourceWhenAvailable() { - assertEquals(NEXT_SOURCE, LiveSourceFallbackPolicy.decide(false, true, true, true, false, true)); + public void playbackFailureDoesNotJumpToAnotherLiveSource() { + // 播放失败没有任何参数组合可以产生 NEXT_SOURCE:即使接口回退开启且有下一个接口。 + assertEquals(NEXT_LINE, LiveSourceFallbackPolicy.decideLineFailure(true, true, true)); + assertEquals(NONE, LiveSourceFallbackPolicy.decideLineFailure(false, true, true)); + assertEquals(NONE, LiveSourceFallbackPolicy.decideLineFailure(true, false, true)); + assertEquals(NONE, LiveSourceFallbackPolicy.decideLineFailure(true, true, false)); } @Test - public void enabledSourceFallbackUsesAnotherChannelLineWhenNoNextSourceExists() { - assertEquals(NEXT_LINE, LiveSourceFallbackPolicy.decide(false, true, true, true, false, false)); + public void playbackFailureWithDisabledAutoLineChangeStops() { + assertEquals(NONE, LiveSourceFallbackPolicy.decideLineFailure(false, true, true)); } @Test - public void enabledSourceFallbackStopsWhenNeitherSourceNorLineIsAvailable() { - assertEquals(NONE, LiveSourceFallbackPolicy.decide(false, true, true, true, true, false)); + public void playbackFailureOnSingleLineChannelStops() { + assertEquals(NONE, LiveSourceFallbackPolicy.decideLineFailure(true, true, false)); + } + + @Test + public void playbackFailureWithoutChannelStops() { + assertEquals(NONE, LiveSourceFallbackPolicy.decideLineFailure(true, false, true)); + } + + // ---------- 节目列表拉取失败:decideSourceFailure ---------- + + @Test + public void sourceListFailureJumpsToNextLiveSourceWhenAvailable() { + assertEquals(NEXT_SOURCE, LiveSourceFallbackPolicy.decideSourceFailure(true, true)); + } + + @Test + public void sourceListFailureStopsWhenNoNextSourceExists() { + assertEquals(NONE, LiveSourceFallbackPolicy.decideSourceFailure(true, false)); + } + + @Test + public void sourceListFailureStopsWhenSourceFallbackDisabled() { + assertEquals(NONE, LiveSourceFallbackPolicy.decideSourceFailure(false, true)); + assertEquals(NONE, LiveSourceFallbackPolicy.decideSourceFailure(false, false)); } } diff --git a/app/src/test/java/com/fongmi/android/tv/player/MpvOutputShutterSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/MpvOutputShutterSourceTest.java index 5c565197c0..21aac232a4 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/MpvOutputShutterSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/MpvOutputShutterSourceTest.java @@ -13,8 +13,8 @@ import static org.junit.Assert.assertTrue; /** * The automatic MPV output shutter hides the direct probe frame before a GPU * rebuild. That frame only exists when automatic output can actually reach - * surface direct, so while the stability guard pins automatic mode to GPU the - * shutter would only delay the first frame while audio already plays. + * surface direct, so when the per-device zero-copy guard blocks direct output + * the shutter would only delay the first frame while audio already plays. */ public class MpvOutputShutterSourceTest { @@ -33,6 +33,26 @@ public class MpvOutputShutterSourceTest { method.contains("!mpvAutoOutputProbeGaveUp")); } + /** + * 快门必须与 {@code resolveSurfaceDirect} 同口径,否则会在手机版上白等。 + * + *

{@code isAutoSurfaceDirectEnabled()} 只回答「设备是否被零拷贝保护拦住」, + * 它**不**包含电视版与硬解两个条件;而 {@code resolveSurfaceDirect()} 要求 + * {@code leanback && autoEligible} 才可能返回 true。手机版上前者为真、后者恒假, + * 快门因此会遮住一个永远不会出现的探测帧——声音先出来、画面晚几秒, + * 正是 {@code 6a458ec11c} 修掉的回归(该提交当时靠全局开关恒 false 才遮住)。 + */ + @Test + public void shutterAsksTheSameGatesAsTheDirectOutputDecision() throws Exception { + String method = methodBody(readPlayerManager(), + "public boolean shouldKeepVideoShutterClosed()", "public boolean isExo()"); + + assertTrue("phones never reach surface direct, so the shutter must not close there", + method.contains("Util.isLeanback()")); + assertTrue("a software-decoded session cannot reach surface direct either", + method.contains("engine.isHard()")); + } + @Test public void newItemClosesShutterOnlyWhenTheShutterPolicyAgrees() throws Exception { String method = methodBody(readPlayerManager(), diff --git a/app/src/test/java/com/fongmi/android/tv/player/MpvStabilityGuardSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/MpvStabilityGuardSourceTest.java index 80ce0d6005..b7f5713ebf 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/MpvStabilityGuardSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/MpvStabilityGuardSourceTest.java @@ -13,7 +13,7 @@ import static org.junit.Assert.assertTrue; public class MpvStabilityGuardSourceTest { @Test - public void runtimeAutoEvaluationAppliesStabilityGuardBeforeTransition() throws Exception { + public void runtimeAutoEvaluationAppliesDeviceGuardBeforeTransition() throws Exception { String method = methodBody(readPlayerManager(), "private boolean evaluateMpvAutoOutput()", "private boolean hasRequestedSubtitle"); assertTrue(method.contains("boolean effectiveEligible = MpvPerformanceSetting.isAutoSurfaceDirectEnabled() && decision.eligible();")); @@ -21,12 +21,31 @@ public class MpvStabilityGuardSourceTest { } @Test - public void stickyAutoDirectRequiresStabilityGuardPermission() throws Exception { + public void stickyAutoDirectRequiresDeviceGuardPermission() throws Exception { String method = methodBody(readPlayerManager(), "private void prepareMpvOutputForNewItem()", "private void resetMpvOutputRuntime()"); assertTrue(method.contains("automaticOutput && MpvPerformanceSetting.isAutoSurfaceDirectEnabled()")); } + /** + * 全局暂停开关必须保持删除状态。 + * + *

该开关(`SURFACE_AUTO_STABILITY_GUARD_ENABLED`)会让自动档在电视上永久退化为 + * GPU 合成 + 拷贝,与 22f2333317「生产环境保留自动直出」的结论相反;只要它被重新 + * 引入,自动直出就会再次静默失效,因此这里做源码级回归保护。 + */ + @Test + public void automaticSurfaceDirectIsNotPermanentlyPausedByAGlobalSwitch() throws Exception { + String source = readMpvPerformanceSetting(); + + assertFalse("a global pause switch silently disables automatic TV direct output", + source.contains("SURFACE_AUTO_STABILITY_GUARD_ENABLED")); + String method = methodBody(source, + "public static boolean isAutoSurfaceDirectEnabled()", "public static boolean isZeroCopyBlocked()"); + assertTrue("the only remaining veto must be the per-device zero-copy guard", + method.contains("return !isZeroCopyBlocked();")); + } + @Test public void engineOverrideCannotBypassZeroCopyDeviceGuard() throws Exception { String method = methodBody(readMpvPlayerEngine(), "private MpvPlayerConfig buildConfig()", "private void applySoftDecodeOptions"); @@ -74,6 +93,13 @@ public class MpvStabilityGuardSourceTest { if (!Files.exists(source)) source = root.resolve(Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "player", "engine", "MpvPlayerEngine.java")); return Files.readString(source, StandardCharsets.UTF_8).replace("\r\n", "\n"); } + private static String readMpvPerformanceSetting() throws IOException { + Path root = Path.of("").toAbsolutePath(); + Path source = root.resolve(Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", "setting", "MpvPerformanceSetting.java")); + if (!Files.exists(source)) source = root.resolve(Path.of("src", "main", "java", "com", "fongmi", "android", "tv", "setting", "MpvPerformanceSetting.java")); + return Files.readString(source, StandardCharsets.UTF_8).replace("\r\n", "\n"); + } + private static String readPlayerManager() throws IOException { Path root = Path.of("").toAbsolutePath(); Path source = root.resolve(Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", "player", "PlayerManager.java")); diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlaybackErrorClassifierTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlaybackErrorClassifierTest.java index 402758ddfb..499ed35cac 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/PlaybackErrorClassifierTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/PlaybackErrorClassifierTest.java @@ -134,6 +134,27 @@ public class PlaybackErrorClassifierTest { error("Player stuck", null, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT), route).stage()); } + @Test + public void transientSourceFailuresAreSeparatedFromUnsupportedContent() { + // 取不到流(网络/本机端点/manifest 空体)→ 重试本项、不换内核。 + assertEquals(true, PlaybackErrorClassifier.isTransientSourceFailure( + PlaybackErrorClassifier.Stage.NETWORK_IO, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS)); + assertEquals(true, PlaybackErrorClassifier.isTransientSourceFailure( + PlaybackErrorClassifier.Stage.LOCAL_ENDPOINT, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED)); + assertEquals(true, PlaybackErrorClassifier.isTransientSourceFailure( + PlaybackErrorClassifier.Stage.MEDIA_PARSING, PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED)); + // 内容能力边界(容器不支持/损坏、编码器失败)必须保留换核机会。 + assertEquals(false, PlaybackErrorClassifier.isTransientSourceFailure( + PlaybackErrorClassifier.Stage.MEDIA_PARSING, PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED)); + assertEquals(false, PlaybackErrorClassifier.isTransientSourceFailure( + PlaybackErrorClassifier.Stage.MEDIA_PARSING, PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED)); + assertEquals(false, PlaybackErrorClassifier.isTransientSourceFailure( + PlaybackErrorClassifier.Stage.MEDIA_PARSING, PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED)); + assertEquals(false, PlaybackErrorClassifier.isTransientSourceFailure( + PlaybackErrorClassifier.Stage.DECODER, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED)); + assertEquals(false, PlaybackErrorClassifier.isTransientSourceFailure(null, PlaybackException.ERROR_CODE_UNSPECIFIED)); + } + private static PlaybackException error(String message, Throwable cause, int code) { return new TestPlaybackException(message, cause, code); } diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlaybackOwnershipKeysTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlaybackOwnershipKeysTest.java new file mode 100644 index 0000000000..37b46470e2 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/PlaybackOwnershipKeysTest.java @@ -0,0 +1,89 @@ +package com.fongmi.android.tv.player; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class PlaybackOwnershipKeysTest { + + private static final String HISTORY = "php_\u84dd\u8393@@@7694249839906786328@@@3"; + private static final String EPISODE = "VE:" + HISTORY + "|\u84dd\u8393\u1d38\u1d50\u00b7\u6807\u6e05|3"; + + @Test + public void identicalKeysBelongToTheSameSession() { + assertTrue(PlaybackOwnershipKeys.sameSession(HISTORY, HISTORY)); + assertTrue(PlaybackOwnershipKeys.sameSession(EPISODE, EPISODE)); + } + + @Test + public void episodeKeyBelongsToItsHistorySession() { + assertTrue("the browse-next episode key must stay the same playback session", + PlaybackOwnershipKeys.sameSession(HISTORY, EPISODE)); + assertTrue(PlaybackOwnershipKeys.sameSession(EPISODE, HISTORY)); + } + + @Test + public void episodeKeyIsNormalisedToItsHistoryKey() { + assertEquals(HISTORY, PlaybackOwnershipKeys.sessionKeyOf(EPISODE)); + assertEquals(HISTORY, PlaybackOwnershipKeys.sessionKeyOf(HISTORY)); + } + + @Test + public void anotherVodOrSiteIsNotTheSameSession() { + assertFalse(PlaybackOwnershipKeys.sameSession(HISTORY, "php_other@@@7694249839906786328@@@3")); + assertFalse(PlaybackOwnershipKeys.sameSession(HISTORY, "VE:php_other@@@7694249839906786328@@@3|flag|3")); + assertFalse(PlaybackOwnershipKeys.sameSession(HISTORY, HISTORY + "0")); + } + + @Test + public void episodeKeyForAnotherVodIsNotTheSameSession() { + String other = "VE:php_other@@@1@@@2|flag|3"; + assertFalse(PlaybackOwnershipKeys.sameSession(HISTORY, other)); + } + + @Test + public void barePrefixIsNotAcceptedAsTheSameSession() { + // 裸前缀匹配是错的:php_a 不能吃掉 php_ab 的会话。 + assertFalse(PlaybackOwnershipKeys.sameSession("VE:php_a", "VE:php_ab|flag|3")); + assertFalse(PlaybackOwnershipKeys.sameSession("php_a", "VE:php_ab|flag|3")); + } + + @Test + public void missingExpectedSideNeverReportsMismatch() { + // 既有语义:expected 为空 = 不限制归属;actual 为空但 expected 非空必须判否。 + assertTrue(PlaybackOwnershipKeys.sameSession(null, EPISODE)); + assertTrue(PlaybackOwnershipKeys.sameSession("", HISTORY)); + assertTrue(PlaybackOwnershipKeys.sameSession("", "")); + assertFalse(PlaybackOwnershipKeys.sameSession(HISTORY, null)); + assertFalse(PlaybackOwnershipKeys.sameSession(EPISODE, null)); + } + + @Test + public void recordKeyContainingSeparatorIsStillReversible() { + // 记录键(SiteKey@@@vodId@@@cid)本身不含 '|';线路名里出现 '|' 是这里的边界情况: + // 末两段被当作「线路名 + 集序号」,左侧整体即记录键。 + String historyKey = "php_x@@@1@@@2"; + String episodeKey = "VE:" + historyKey + "|a|b|7"; + assertEquals(historyKey + "|a", PlaybackOwnershipKeys.sessionKeyOf(episodeKey)); + } + + @Test + public void malformedEpisodeKeyStaysItsOwnKey() { + assertFalse(PlaybackOwnershipKeys.sameSession(HISTORY, "VE:" + HISTORY)); + assertNull(PlaybackOwnershipKeys.historyKeyOfEpisode("VE:" + HISTORY)); + // 结构不完整时退回原字符串,绝不能因为无法解析就把两个不同会话判成同一会话。 + assertEquals("VE:" + HISTORY, PlaybackOwnershipKeys.sessionKeyOf("VE:" + HISTORY)); + assertEquals("VE:|x|1", PlaybackOwnershipKeys.sessionKeyOf("VE:|x|1")); + } + + @Test + public void blankAndNonEpisodeKeysAreTheirOwnSession() { + assertNull(PlaybackOwnershipKeys.sessionKeyOf(null)); + assertNull(PlaybackOwnershipKeys.sessionKeyOf("")); + assertEquals("VP:php@@@1@@@2", PlaybackOwnershipKeys.sessionKeyOf("VP:php@@@1@@@2")); + assertNull(PlaybackOwnershipKeys.historyKeyOfEpisode("VP:php@@@1@@@2")); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerLifecycleSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerLifecycleSourceTest.java index 82d588511d..04e89160a4 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerLifecycleSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerLifecycleSourceTest.java @@ -65,20 +65,25 @@ public class PlayerManagerLifecycleSourceTest { } @Test - public void exoBufferingStateArmsOnlyTheExoStallWatchdog() throws Exception { + public void bufferingStateArmsTheStallWatchdogForEveryEngine() throws Exception { String source = readPlayerManager(); int buffering = source.indexOf("} else if (state == Player.STATE_BUFFERING) {"); int nextBranch = source.indexOf("\n } else {", buffering); assertTrue("state listener must contain the buffering branch", buffering >= 0); assertTrue("state listener buffering branch must have a closing boundary", nextBranch > buffering); - int arm = source.indexOf("if (isExo()) armBufferingStallWatchdog();", buffering); - assertTrue("entering Exo BUFFERING must start only the Exo stall watchdog", arm > buffering && arm < nextBranch); - assertTrue("watchdog arming must be restricted to Exo playback", - source.contains("if (!isExo() || player == null || spec == null) return;")); - assertTrue("watchdog polling must stop for non-Exo playback", - source.contains("if (!isExo() || player == null || spec == null) {")); - assertTrue("seeking must only re-arm the stall watchdog for Exo playback", - source.contains("player.seekTo(time);\n cancelBufferingStallWatchdog();\n if (isExo()) armBufferingStallWatchdog();")); + int arm = source.indexOf("armBufferingStallWatchdog();", buffering); + assertTrue("entering BUFFERING must start the stall watchdog", arm > buffering && arm < nextBranch); + // The watchdog is engine-independent: MPV reaches the same frozen-BUFFERING state and + // MpvPlayer.timeOutSeekBuffering() already defers to it. Restricting it to Exo left MPV + // with no bound at all, because the 15 s startup timer is cancelled by BUFFERING itself. + assertFalse("the stall watchdog must not be gated on a single engine", + source.contains("if (isExo()) armBufferingStallWatchdog();")); + assertTrue("watchdog arming must only require a live player and spec", + source.contains("private void armBufferingStallWatchdog() {\n if (player == null || spec == null) return;")); + assertTrue("watchdog polling must only require a live player and spec", + source.contains("private void checkBufferingStall() {\n if (player == null || spec == null) {")); + assertTrue("seeking must re-arm the stall watchdog for every engine", + source.contains("player.seekTo(time);\n cancelBufferingStallWatchdog();\n armBufferingStallWatchdog();")); int polling = source.indexOf("private void checkBufferingStall()"); int observe = source.indexOf("bufferingStallWatchdog.observe(", polling); int timeout = source.indexOf("bufferingStallWatchdog.shouldTimeout(", polling); @@ -87,6 +92,45 @@ public class PlayerManagerLifecycleSourceTest { observe > polling && timeout > observe); } + /** + * 超时日志里的实测停滞时长必须在停掉看门狗之前读出。 + * + *

{@code cancelBufferingStallWatchdog()} 会把看门狗置为未布防,而 + * {@code stalledDurationMs()} 在未布防时返回 {@code -1}。先取消再读,日志就永远写 + * {@code stalledMs=-1},实测时长这个字段等于不存在——排查时无法分辨「刚过 20s 就超时」 + * 与「卡了 90s 才超时」。 + */ + @Test + public void stallDurationIsMeasuredBeforeTheWatchdogIsDisarmed() throws Exception { + String source = stripComments(readPlayerManager()); + String body = methodBody(source, "private void onBufferingStall("); + int measured = body.indexOf("stalledDurationMs("); + int disarmed = body.indexOf("cancelBufferingStallWatchdog();"); + assertTrue("onBufferingStall must report the measured stall", measured >= 0); + assertTrue("onBufferingStall must disarm the watchdog", disarmed >= 0); + assertTrue("reading stalledDurationMs() after reset() always yields -1", measured < disarmed); + } + + /** + * Drops whole-line comments and trailing {@code //} comments. + * + *

Without this, an assertion that looks for a call by name can be satisfied by the same + * name inside the explanatory comment that documents it — the guard then passes no matter + * what the code does. Verified by mutation: restoring the buggy order while the comment + * still names {@code stalledDurationMs()} kept the assertion green until comments were + * stripped. + */ + private static String stripComments(String source) { + StringBuilder out = new StringBuilder(source.length()); + for (String line : source.split("\n", -1)) { + String trimmed = line.trim(); + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) continue; + int marker = line.indexOf("//"); + out.append(marker >= 0 ? line.substring(0, marker) : line).append('\n'); + } + return out.toString(); + } + private static String readPlayerManager() throws IOException { Path root = Path.of("").toAbsolutePath(); Path source = root.resolve(Path.of( diff --git a/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerSwitchCauseSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerSwitchCauseSourceTest.java new file mode 100644 index 0000000000..d0ac460656 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/PlayerManagerSwitchCauseSourceTest.java @@ -0,0 +1,216 @@ +package com.fongmi.android.tv.player; + +import org.junit.Test; + +import java.io.IOException; +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; + +/** + * 本类断言 {@code PlayerManager} 的**实现文本**(仓库既有的 source-text 约定),锁定 + * “每次内核/软硬解切换都必须能回答为什么换”的不变量。 + * + *

这些断言无法从行为上便宜地观测:切换发生在多个异步路径里,而这里要保证的恰恰是 + * “所有路径都经过同一个归因入口”。若日后重构掉这些方法,断言会变红 —— 那说明约定被破坏 + * 需要同步更新断言,**不是**测试坏了。确认行为未变后请重新对齐断言。 + */ +public class PlayerManagerSwitchCauseSourceTest { + + @Test + public void everyKernelSwitchFlowsThroughTheSingleCauseLogger() throws Exception { + String source = readPlayerManager(); + String engineSwitch = methodBody(source, + "private void switchEngine(int type, boolean chosen, boolean notifyPrepare, int decode, long position, float speed, boolean repeat, boolean wasPlayWhenReady, String cause)", + "private void rebuildPlayer()"); + + assertTrue("the kernel switch must log its cause", engineSwitch.contains("logSwitchCause(cause,")); + // Capture the previous identity before mutating it, or the log would report from==to. + int captureType = engineSwitch.indexOf("int previousType = playerType;"); + int assign = engineSwitch.indexOf("playerType = type;"); + assertTrue("the previous kernel must be captured before it is overwritten", + captureType >= 0 && assign > captureType); + int captureDecode = engineSwitch.indexOf("int previousDecode = engine == null ? -1 : engine.getDecode();"); + assertTrue("the previous decode must be captured before the engine is released", + captureDecode >= 0 && captureDecode < engineSwitch.indexOf("engine.release();")); + } + + @Test + public void switchKindIsDerivedFromWhatActuallyChanged() throws Exception { + String source = readPlayerManager(); + String derived = methodBody(source, + "private void logSwitchCause(String cause, int fromType, int toType,", + "private static String decodeText(int decode)"); + + // A decode-only switch (same kernel, hard->soft) must not be reported as a kernel switch. + assertTrue("kind must be derived from the actual change", + derived.contains("fromType != toType ? \"kernel\" : fromDecode != toDecode ? \"decode\" : \"none\"")); + } + + @Test + public void fallbackCausesCarryTheTriggeringErrorCode() throws Exception { + String source = readPlayerManager(); + String decodeFallback = methodBody(source, + "private boolean fallbackDecode(PlaybackException e)", + "private boolean fallbackPlayer(PlaybackException e)"); + String playerFallback = methodBody(source, + "private boolean fallbackPlayer(PlaybackException e)", + "private static String fallbackTrigger(PlaybackException e)"); + String trigger = methodBody(source, + "private static String fallbackTrigger(PlaybackException e)", + "private boolean ensurePlayerAvailableForPlayback()"); + + assertTrue("a decode fallback must state why it happened", + decodeFallback.contains("\"fallback-decode:\" + fallbackTrigger(e)")); + assertTrue("a kernel fallback must state why it happened", + playerFallback.contains("\"fallback-kernel:\" + fallbackTrigger(e)")); + // The mapping must not collapse every failure into one label, or the log cannot + // distinguish a timeout from a decoder failure. + assertTrue(trigger.contains("ERROR_CODE_TIMEOUT -> \"timeout\"")); + assertTrue(trigger.contains("ERROR_CODE_DECODER_INIT_FAILED -> \"decoder-init\"")); + assertTrue(trigger.contains("default -> \"code-\" + e.errorCode")); + } + + @Test + public void kernelUnavailableIsDistinguishedFromFailureFallback() throws Exception { + String source = readPlayerManager(); + String ensure = methodBody(source, + "private boolean ensurePlayerAvailableForPlayback()", + "private int nextFallbackPlayer()"); + + assertTrue("an unavailable kernel is not a playback failure and must say so", + ensure.contains("\"kernel-unavailable\"")); + } + + @Test + public void historyKernelAndPendingKernelAreDistinguishable() throws Exception { + String source = readPlayerManager(); + // force=true is only ever passed by the onError retry, so it doubles as the cause signal + // and no public signature had to change to record it. + assertTrue(source.contains("force ? \"history-kernel-retry-after-error\" : \"history-kernel-before-play\"")); + // preparePlayer is the only entry point that restores a remembered kernel before the + // play url is resolved, so its cause is unambiguous without a parameter. + String prepare = methodBody(source, "public void preparePlayer(int type, boolean force)", "public void switchPlayer(int type, PlaySpec freshSpec"); + assertTrue("restoring a remembered kernel must be logged", prepare.contains("logSwitchCause(")); + assertFalse("preparePlayer must not take a cause parameter; the call sites are all the same reason", + prepare.contains("String cause")); + } + + @Test + public void userDrivenSwitchesAreLabelledAsUserChoice() throws Exception { + String source = readPlayerManager(); + // Every switchPlayer/switchDecode caller is a UI entry point, so the label is derived + // inside PlayerManager instead of being threaded through five Activity files. + assertTrue(source.contains("switchEngine(type, true, true, true, engine.getDecode(), \"user-choice\")")); + assertTrue(source.contains("logSwitchCause(\"user-choice\", previousType, type, decode, decode, true, position)")); + assertTrue(source.contains("logSwitchCause(\"decode\", \"user-choice\", playerType, playerType, engine.getDecode(), next, false, position)")); + } + + @Test + public void chosenFlagSeparatesUserChoiceFromAutomaticFallback() throws Exception { + String source = readPlayerManager(); + String engineSwitch = methodBody(source, + "private void switchEngine(int type, boolean chosen, boolean notifyPrepare, int decode, long position, float speed, boolean repeat, boolean wasPlayWhenReady, String cause)", + "private void rebuildPlayer()"); + + assertTrue("the log must expose whether the user chose this kernel", + engineSwitch.contains("chosen,")); + // Automatic fallbacks pass chosen=false so they never look like a user selection. + assertTrue(source.contains("switchEngine(playerType, false, true, true, PlayerEngine.SOFT, \"fallback-decode:\"")); + assertTrue(source.contains("switchEngine(next, false, true, true, fallbackDecode(")); + } + + @Test + public void manualDecodeButtonLogsItsCauseToo() throws Exception { + String source = readPlayerManager(); + // toggleDecode() is the control bar's decode button - the most common manual + // soft/hard switch on both flavors. switchDecode(...) only covers the source-refresh + // path, so without a log here the everyday manual switch had no switch-cause row. + String toggle = methodBody(source, "public void toggleDecode()", "private void maybeFallbackToSoftDecode("); + assertTrue("the manual decode button must log its cause", + toggle.contains("logSwitchCause(\"decode\", \"user-choice\",")); + // The previous decode has to be read before engine.setDecode(next) overwrites it. + int capture = toggle.indexOf("int previousDecode = engine.getDecode();"); + int mutate = toggle.indexOf("engine.setDecode(next);"); + assertTrue("the previous decode must be captured before it is overwritten", + capture >= 0 && mutate > capture); + } + + @Test + public void switchCauseLogIsNotGatedOnSpiderDebug() throws Exception { + String source = readPlayerManager(); + String logger = methodBody(source, + "private void logSwitchCause(String kind, String cause, int fromType, int toType,", + "private void logSwitchCause(String cause, int fromType, int toType,"); + + // PlaybackTrace.log already gates on SpiderDebug.isEnabled(); a second gate here would + // make the cause invisible in exactly the exports users send for triage. + assertTrue(logger.contains("PlaybackTrace.log(\"switch-cause\"")); + assertFalse(logger.contains("SpiderDebug.isEnabled()")); + } + + /** + * 日志里的位置必须在引擎释放之前读出。 + * + *

{@code preparePlayer()} 的切换原因日志位于 {@code engine.release()} 之后;若那里 + * 直接写 {@code getPosition()},记录的就是已释放播放器的状态,而不是内核被换掉时播放 + * 真正所在的位置——排查「按剧集记住的内核」时这个字段正是关键。 + */ + @Test + public void switchCausePositionIsReadBeforeTheEngineIsReleased() throws Exception { + String source = stripComments(readPlayerManager()); + String prepare = methodBody(source, + "public void preparePlayer(int type, boolean force)", + "public void switchPlayer(int type, PlaySpec freshSpec"); + + int capture = prepare.indexOf("long positionBeforeRelease = Math.max(0, getPosition());"); + int release = prepare.indexOf("engine.release();"); + int log = prepare.indexOf("logSwitchCause("); + assertTrue("preparePlayer must read the position while the old engine is alive", capture >= 0); + assertTrue("preparePlayer must release the old engine", release >= 0); + assertTrue("preparePlayer must log the switch cause", log >= 0); + assertTrue("the read must precede the release", capture < release); + assertTrue("the log must use the captured value, not a post-release read", + prepare.indexOf("true, positionBeforeRelease)") > release && log > release); + } + + /** + * Drops whole-line comments and trailing {@code //} comments, so an assertion looking for a + * call by name cannot be satisfied by that name appearing in the comment documenting it. + * Verified by mutation: leaving the comment in place kept a broken ordering assertion green. + */ + private static String stripComments(String source) { + StringBuilder out = new StringBuilder(source.length()); + for (String line : source.split("\n", -1)) { + String trimmed = line.trim(); + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) continue; + int marker = line.indexOf("//"); + out.append(marker >= 0 ? line.substring(0, marker) : line).append('\n'); + } + return out.toString(); + } + + private static String readPlayerManager() throws IOException { + return read("app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java", + "src/main/java/com/fongmi/android/tv/player/PlayerManager.java"); + } + + private static String read(String moduleRelative, String rootRelative) throws IOException { + Path root = Path.of("").toAbsolutePath(); + Path source = root.resolve(moduleRelative); + if (!Files.exists(source)) source = root.resolve(rootRelative); + assertTrue("source must exist: " + source, Files.exists(source)); + return Files.readString(source, StandardCharsets.UTF_8).replace("\r\n", "\n"); + } + + private static String methodBody(String source, String startToken, String endToken) { + int start = source.indexOf(startToken); + assertTrue("Missing source token: " + startToken, start >= 0); + int end = source.indexOf(endToken, start); + assertTrue("Missing source token after " + startToken + ": " + endToken, end > start); + return source.substring(start, end); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/SurfaceRebindDiagnosticTest.java b/app/src/test/java/com/fongmi/android/tv/player/SurfaceRebindDiagnosticTest.java new file mode 100644 index 0000000000..0735f2ba68 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/SurfaceRebindDiagnosticTest.java @@ -0,0 +1,52 @@ +package com.fongmi.android.tv.player; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.github.catvod.crawler.diagnostics.DiagnosticEvent; + +import org.junit.Test; + +/** + * {@code surface.rebind} 的事件名与字段名必须登记在 catvod 的诊断白名单里。 + * + *

{@code PlaybackDiagnosticCollector.emit} 把白名单外名字抛出的 {@link IllegalArgumentException} + * 吞成 {@code collectorFailure}:现场既拿不到这条证据,采集器还会被记成故障。这里直接构造生产代码 + * 写入的同一批事实,把白名单契约钉住。 + */ +public class SurfaceRebindDiagnosticTest { + + @Test + public void surfaceRebindFactsAreRegisteredInTheSchema() { + DiagnosticEvent event = SurfaceDiagnosticCollector.rebindFacts( + new DiagnosticEvent(SurfaceDiagnosticCollector.EVENT_REBIND, "trace", "instance", 1, 1), + "state=2", false, null); + + String json = event.json(); + assertTrue("the event name must survive the schema allowlist", json.contains("\"event\":\"surface.rebind\"")); + assertTrue(json.contains("\"detachedFirst\"")); + assertTrue(json.contains("\"surfaceViewPresent\"")); + assertTrue(json.contains("\"reason\"")); + assertTrue(json.contains("\"physicalDisplay\"")); + assertTrue(json.contains("\"surfaceId\"")); + assertTrue(json.contains("\"valid\"")); + } + + @Test + public void theAllowlistGuardIsNotVacuous() { + try { + new DiagnosticEvent(SurfaceDiagnosticCollector.EVENT_REBIND + ".typo", "t", "i", 1, 1); + fail("unknown diagnostic events must be rejected"); + } catch (IllegalArgumentException expected) { + // 白名单外的名字确实会被拒,上面的用例才有意义。 + } + try { + SurfaceDiagnosticCollector.rebindFacts( + new DiagnosticEvent(SurfaceDiagnosticCollector.EVENT_REBIND, "t", "i", 1, 1), + "x", false, null) + .observed("unregisteredField", true); + fail("unknown diagnostic fields must be rejected"); + } catch (IllegalArgumentException expected) { + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/audio/PlaybackMediaSignalHubTest.java b/app/src/test/java/com/fongmi/android/tv/player/audio/PlaybackMediaSignalHubTest.java index 1b8675345b..cef5992be2 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/audio/PlaybackMediaSignalHubTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/audio/PlaybackMediaSignalHubTest.java @@ -89,14 +89,14 @@ public class PlaybackMediaSignalHubTest { public void captureLeaseIsReferenceCounted() { PlaybackMediaSignalHub hub = new PlaybackMediaSignalHub(4); PlaybackMediaSignalHub.CaptureLease first = hub.requestCapture( - PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO); + PlaybackMediaSignalHub.ConsumerKind.TEST); PlaybackMediaSignalHub.CaptureLease second = hub.requestCapture( - PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO); - assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + PlaybackMediaSignalHub.ConsumerKind.TEST); + assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.TEST)); first.close(); - assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + assertTrue(hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.TEST)); second.close(); - assertTrue(!hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.AD_AUDIO)); + assertTrue(!hub.isCaptureRequested(PlaybackMediaSignalHub.ConsumerKind.TEST)); } @Test diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/AutoPreloadPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/AutoPreloadPolicyTest.java index a9016a72dd..96739c5095 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/AutoPreloadPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/AutoPreloadPolicyTest.java @@ -39,7 +39,8 @@ public class AutoPreloadPolicyTest { input = new AutoPreloadPolicy.Inputs( input.session(), false, input.route(), input.bufferedMs(), input.mediaBitrateBitsPerSecond(), input.rebufferCount(), input.loading(), - input.trend(), input.throughput(), input.system(), false, false, 0); + input.trend(), input.throughput(), input.system(), false, false, + Long.MAX_VALUE, 0); AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate(input); @@ -206,7 +207,8 @@ public class AutoPreloadPolicyTest { AutoPreloadPolicy.Inputs pressured = new AutoPreloadPolicy.Inputs( safe.session(), safe.sessionMatches(), safe.route(), safe.bufferedMs(), safe.mediaBitrateBitsPerSecond(), safe.rebufferCount(), safe.loading(), - safe.trend(), safe.throughput(), safe.system(), true, false, 0); + safe.trend(), safe.throughput(), safe.system(), true, false, + Long.MAX_VALUE, 0); AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate(pressured); @@ -320,7 +322,8 @@ public class AutoPreloadPolicyTest { AutoPreloadPolicy.Inputs rebuffered = new AutoPreloadPolicy.Inputs( safe.session(), safe.sessionMatches(), safe.route(), safe.bufferedMs(), safe.mediaBitrateBitsPerSecond(), 1, safe.loading(), safe.trend(), - safe.throughput(), safe.system(), false, false, 0); + safe.throughput(), safe.system(), false, false, + Long.MAX_VALUE, 0); AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate(rebuffered); @@ -344,6 +347,7 @@ public class AutoPreloadPolicyTest { safeSystem(), false, false, + Long.MAX_VALUE, 0); AutoPreloadPolicy.Inputs recovered = new AutoPreloadPolicy.Inputs( SESSION, @@ -358,6 +362,7 @@ public class AutoPreloadPolicyTest { safeSystem(), false, false, + Long.MAX_VALUE, 15_000); AutoPreloadPolicy.Decision paused = policy.evaluate(low); @@ -515,6 +520,20 @@ public class AutoPreloadPolicyTest { ForwardBufferTrend.Snapshot trend, boolean memoryPaused, boolean preloadActive) { + return inputs(nowMs, route, bufferedMs, throughput, system, trend, memoryPaused, + preloadActive, Long.MAX_VALUE); + } + + private static AutoPreloadPolicy.Inputs inputs( + long nowMs, + PlaybackRoute route, + long bufferedMs, + AutoPreloadPolicy.ThroughputEvidence throughput, + AutoPreloadPolicy.SystemEvidence system, + ForwardBufferTrend.Snapshot trend, + boolean memoryPaused, + boolean preloadActive, + long foregroundEofRecoveryAgeMs) { return new AutoPreloadPolicy.Inputs( SESSION, true, @@ -528,9 +547,78 @@ public class AutoPreloadPolicyTest { system, memoryPaused, preloadActive, + foregroundEofRecoveryAgeMs, nowMs); } + /** + * The external-loopback route is the one whose throughput estimate is BLOCKED, so the ordinary + * throughput judgments are skipped and only buffer level protects the foreground. These cases + * pin the new yield-on-EOF behavior on exactly that route. + */ + @Test + public void foregroundEofRecoveryYieldsOnBlockedThroughputRoute() { + AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate( + inputs(0, PlaybackRoute.EXTERNAL_LOOPBACK_PROXY, 30_000, + AutoPreloadPolicy.ThroughputEvidence.unknown(), + safeSystem(), stableTrend(0, 30_000), false, false, 0)); + + assertFalse(decision.enabled()); + assertEquals("foreground-eof-recovery", decision.reason()); + } + + @Test + public void yieldOutlastsTheObservedRecoveryBurst() { + AutoPreloadPolicy policy = new AutoPreloadPolicy(); + + // The device burst ran 2-6 s; the window must still be closed well after that. + assertFalse(policy.evaluate( + inputs(0, PlaybackRoute.EXTERNAL_LOOPBACK_PROXY, 30_000, + AutoPreloadPolicy.ThroughputEvidence.unknown(), + safeSystem(), stableTrend(0, 30_000), false, false, 6_000)).enabled()); + assertFalse(policy.evaluate( + inputs(0, PlaybackRoute.EXTERNAL_LOOPBACK_PROXY, 30_000, + AutoPreloadPolicy.ThroughputEvidence.unknown(), + safeSystem(), stableTrend(0, 30_000), false, false, 14_999)).enabled()); + } + + @Test + public void preloadResumesOnceTheYieldWindowExpires() { + // A stale recovery must not suspend preload forever: the window is a delay, not a circuit. + AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate( + inputs(0, PlaybackRoute.EXTERNAL_LOOPBACK_PROXY, 30_000, + AutoPreloadPolicy.ThroughputEvidence.unknown(), + safeSystem(), stableTrend(0, 30_000), false, false, 15_001)); + + assertTrue(decision.enabled()); + assertTrue(decision.threads() >= 1); + } + + @Test + public void neverObservedRecoveryNeverYields() { + // Long.MAX_VALUE is the "no recovery in this process" sentinel. A healthy session must not + // be mistaken for a recovering one, or preload would never start at all. + AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate( + inputs(0, PlaybackRoute.EXTERNAL_LOOPBACK_PROXY, 30_000, + AutoPreloadPolicy.ThroughputEvidence.unknown(), + safeSystem(), stableTrend(0, 30_000), false, false, Long.MAX_VALUE)); + + assertTrue(decision.enabled()); + assertTrue(decision.threads() >= 1); + } + + @Test + public void healthyRouteWithTrustedThroughputIsUnaffectedByStaleRecovery() { + // The yield must not disturb the ordinary trusted path once its window has passed. + AutoPreloadPolicy.Decision decision = new AutoPreloadPolicy().evaluate( + inputs(0, PlaybackRoute.DIRECT_REMOTE_HTTP, 20_000, + trustedThroughput(0, 30, 30, 30, 200, false), + safeSystem(), stableTrend(0, 20_000), false, false, 60_000)); + + assertTrue(decision.enabled()); + assertTrue(decision.threads() >= 1); + } + private static AutoPreloadPolicy.ThroughputEvidence trustedThroughput( long observedAtMs, long effectiveMbps, diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/DiagnosticEventFieldAllowlistTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/DiagnosticEventFieldAllowlistTest.java new file mode 100644 index 0000000000..f5293e457e --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/DiagnosticEventFieldAllowlistTest.java @@ -0,0 +1,83 @@ +package com.fongmi.android.tv.player.exo; + +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.github.catvod.crawler.diagnostics.DiagnosticEvent; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Locks the join between a diagnostic producer and the schema allowlist. + * + *

{@link DiagnosticEvent#observed(String, Object)} throws {@code IllegalArgumentException} for + * any name missing from {@code DiagnosticEvent.FIELDS}, and the collector's {@code emit} catches + * that as a collector failure. A missing name therefore does not fail loudly: the whole event + * (including the fields that were valid) is dropped and the export silently loses the line. The + * E-SP11 input-side drop counters were added without the matching allowlist entries, which is + * exactly this failure — the very {@code video.output.summary} line the change was meant to enrich + * disappeared from every diagnostics-on session. + * + *

This test is a source+behaviour hybrid on purpose: the source scan is what notices a new + * {@code observed} literal, and constructing the real event is what notices the allowlist entry + * being absent. Neither half alone catches the regression. + */ +public class DiagnosticEventFieldAllowlistTest { + + private static final Pattern OBSERVED_CALL = Pattern.compile("\\.observed\\(\"([^\"]+)\""); + + @Test + public void everyFieldTheExoCollectorRecordsIsAcceptedByTheSchema() throws Exception { + String source = read(sourcePath( + "main", "java", "com", "fongmi", "android", "tv", "player", "exo", + "ExoDiagnosticCollector.java")); + Set names = new LinkedHashSet<>(); + Matcher matcher = OBSERVED_CALL.matcher(source); + while (matcher.find()) names.add(matcher.group(1)); + assertTrue("No observed() literals found in ExoDiagnosticCollector", names.size() > 5); + // Guard against a vacuous pass if the counters are removed from the collector later. + assertTrue(names.contains("droppedInputBuffers")); + assertTrue(names.contains("skippedInputBuffers")); + assertTrue(names.contains("droppedToKeyframe")); + for (String name : names) { + try { + new DiagnosticEvent("video.output.summary", "trace", "instance", 1, 1) + .observed(name, 0); + } catch (IllegalArgumentException error) { + fail("ExoDiagnosticCollector records \"" + name + + "\" but DiagnosticEvent rejects it, so the whole event is dropped: " + + error.getMessage()); + } + } + } + + @Test + public void theAllowlistCheckIsNotVacuous() { + // Self-proof that the fixture really reaches the validator. + assertThrows(IllegalArgumentException.class, + () -> new DiagnosticEvent("video.output.summary", "trace", "instance", 1, 1) + .observed("notARegisteredDiagnosticField", 0)); + } + + private static String read(Path path) throws Exception { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + + private static Path sourcePath(String... parts) { + Path path = Path.of("src", parts[0], parts[1]); + for (int i = 2; i < parts.length; i++) path = path.resolve(parts[i]); + if (Files.exists(path)) return path; + path = Path.of("app", "src", parts[0], parts[1]); + for (int i = 2; i < parts.length; i++) path = path.resolve(parts[i]); + return path; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/DroppedFrameAccountingTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/DroppedFrameAccountingTest.java new file mode 100644 index 0000000000..e603f83e1e --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/DroppedFrameAccountingTest.java @@ -0,0 +1,103 @@ +package com.fongmi.android.tv.player.exo; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class DroppedFrameAccountingTest { + + private static final long T0 = 1_000L; + + private final DroppedFrameAccounting accounting = new DroppedFrameAccounting(); + + @Test + public void pauseSettledReportKeepsTheTotalButNotTheRate() { + accounting.onPlayingStateChanged(true, T0); + // media3 settles the renderer accumulator from onStopped(), i.e. after playback + // stopped; the frames themselves were dropped during the 10s that just played. + accounting.onPlayingStateChanged(false, T0 + 10_000); + long total = accounting.onDroppedFramesReported(4, true); + + assertEquals(4, total); + assertEquals(4, accounting.pauseSettledDroppedFrames()); + assertEquals(1, accounting.pauseSettledReports()); + assertEquals(10_000, accounting.playTimeMs(T0 + 10_000)); + assertEquals(0.4d, accounting.droppedFramesPerSecond(T0 + 10_000), 1e-9); + } + + @Test + public void playedTimeAccumulatesAcrossPauses() { + accounting.onPlayingStateChanged(true, T0); + accounting.onPlayingStateChanged(false, T0 + 10_000); + accounting.onPlayingStateChanged(true, T0 + 30_000); + accounting.onPlayingStateChanged(false, T0 + 35_000); + + assertEquals(15_000, accounting.playTimeMs(T0 + 35_000)); + } + + @Test + public void openPlayingWindowIsCountedUpToNow() { + accounting.onPlayingStateChanged(true, T0); + + assertEquals(0, accounting.playTimeMs(T0 - 1)); + assertEquals(2_500, accounting.playTimeMs(T0 + 2_500)); + } + + @Test + public void redundantPlayingStateNeverDoubleBillsTheInterval() { + accounting.onPlayingStateChanged(true, T0); + accounting.onPlayingStateChanged(true, T0 + 5_000); + accounting.onPlayingStateChanged(false, T0 + 10_000); + + assertEquals(10_000, accounting.playTimeMs(T0 + 10_000)); + } + + @Test + public void rateIsZeroWithoutMeasuredPlayTime() { + // A single frame with no measured played time must not read as an infinite rate. + accounting.onDroppedFramesReported(1, false); + + assertEquals(0d, accounting.droppedFramesPerSecond(T0), 1e-9); + assertEquals(0d, accounting.droppedFramesPerSecond(T0 + 60_000), 1e-9); + } + + @Test + public void pauseSettledSplitSeparatesTheReportThatArrivedWhileStopped() { + accounting.onPlayingStateChanged(true, T0); + accounting.onDroppedFramesReported(3, false); + accounting.onPlayingStateChanged(false, T0 + 4_000); + accounting.onDroppedFramesReported(5, true); + accounting.onDroppedFramesReported(2, true); + + assertEquals(10, accounting.droppedFrames()); + assertEquals(7, accounting.pauseSettledDroppedFrames()); + assertEquals(2, accounting.pauseSettledReports()); + assertEquals(2.5d, accounting.droppedFramesPerSecond(T0 + 4_000), 1e-9); + } + + @Test + public void negativeReportNeverSubtracts() { + accounting.onPlayingStateChanged(true, T0); + accounting.onPlayingStateChanged(false, T0 + 1_000); + accounting.onDroppedFramesReported(-4, true); + + assertEquals(0, accounting.droppedFrames()); + assertEquals(0, accounting.pauseSettledDroppedFrames()); + assertEquals(1, accounting.pauseSettledReports()); + } + + @Test + public void resetClearsEveryAccumulator() { + accounting.onPlayingStateChanged(true, T0); + accounting.onDroppedFramesReported(9, false); + accounting.onPlayingStateChanged(false, T0 + 2_000); + + accounting.reset(); + + assertEquals(0, accounting.droppedFrames()); + assertEquals(0, accounting.pauseSettledDroppedFrames()); + assertEquals(0, accounting.pauseSettledReports()); + assertEquals(0, accounting.playTimeMs(T0 + 60_000)); + assertEquals(0d, accounting.droppedFramesPerSecond(T0 + 60_000), 1e-9); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicyTest.java index 1cb6d85e05..0ba636b750 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoCompressedAudioDirectPolicyTest.java @@ -27,6 +27,9 @@ import androidx.media3.exoplayer.analytics.PlayerId; import org.junit.Test; import org.junit.Ignore; +import com.github.catvod.crawler.diagnostics.DiagnosticEvent; + +import java.lang.reflect.Field; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -239,6 +242,7 @@ public class ExoCompressedAudioDirectPolicyTest { f.memory.nowMs.set(800); f.output.getPositionUs(); ExoPlaybackException error = assertThrows(ExoPlaybackException.class, () -> wrapped.render(0, 800_000)); + assertTrue(isRecoverable(error)); assertEquals(ExoPlaybackException.TYPE_RENDERER, error.type); assertEquals(PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, error.errorCode); assertTrue(error.getCause() instanceof ExoStartupAudioRenderer.StartupStallException); @@ -1090,6 +1094,136 @@ public class ExoCompressedAudioDirectPolicyTest { assertTrue(fixture.freshEngineUsesDirect(fixture.url, fixture.format, AudioAttributes.DEFAULT)); } + @Test + public void stalledOffloadOutputIsOnlyClaimedAfterAcceptedPayloadStopsBeingConsumed() + throws Exception { + OffloadStallFixture f = new OffloadStallFixture(true, false); + f.consumeFirstPayloadThenStall(392); + assertFalse(f.policy.hasStalledOffloadOutput()); + f.nowMs.addAndGet(1_999); + assertFalse(f.policy.hasStalledOffloadOutput()); + f.nowMs.addAndGet(1); + assertTrue(f.policy.hasStalledOffloadOutput()); + } + + @Test + public void advancingOrUnobservableOffloadHeadIsNeverAStall() throws Exception { + OffloadStallFixture advancing = new OffloadStallFixture(true, false); + advancing.consumeFirstPayloadThenStall(392); + advancing.environment.rawHead = 512; + advancing.nowMs.addAndGet(30_000); + assertFalse(advancing.policy.hasStalledOffloadOutput()); + assertFalse(advancing.policy.requestOffloadStallRecovery()); + + OffloadStallFixture unobservable = new OffloadStallFixture(true, false); + unobservable.consumeFirstPayloadThenStall(392); + unobservable.environment.rawHead = C.LENGTH_UNSET; + unobservable.nowMs.addAndGet(30_000); + assertFalse(unobservable.policy.hasStalledOffloadOutput()); + } + + @Test + public void refusedWritesWithoutAcceptedPayloadAreNotAnOffloadStall() throws Exception { + OffloadStallFixture f = new OffloadStallFixture(true, false); + f.raw.acceptWrites = false; + f.write(392); + f.write(392); + f.nowMs.addAndGet(30_000); + assertFalse(f.policy.hasStalledOffloadOutput()); + } + + @Test + public void pcmOutputsAreNotWatchedForOffloadStall() throws Exception { + OffloadStallFixture f = new OffloadStallFixture(false, false); + f.raw.acceptWrites = false; + f.write(392); + f.nowMs.addAndGet(30_000); + assertFalse(f.policy.hasStalledOffloadOutput()); + assertFalse(f.policy.requestOffloadStallRecovery()); + } + + @Test + public void offloadStallRecoveryIsOneShotAndWithdrawsOnlyThatConfiguration() throws Exception { + OffloadStallFixture f = new OffloadStallFixture(true, true); + f.consumeFirstPayloadThenStall(392); + f.nowMs.addAndGet(30_000); + assertTrue(f.policy.getAudioOffloadSupport(f.format, AudioAttributes.DEFAULT).isFormatSupported); + assertTrue(f.policy.requestOffloadStallRecovery()); + assertFalse(f.policy.requestOffloadStallRecovery()); + assertFalse(f.policy.getAudioOffloadSupport(f.format, AudioAttributes.DEFAULT).isFormatSupported); + // A different sample rate is a different output configuration and must stay untouched. + assertTrue(f.policy.getAudioOffloadSupport(aacStereo(), AudioAttributes.DEFAULT).isFormatSupported); + // Vendor-direct admission is a separate decision that this recovery must not consume. + assertEquals(AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY, + f.policy.wrapOutputProvider(new StandardAudioOutputProvider(f.config, f.raw.output)) + .getFormatSupport(formatConfig(f.format)).supportLevel); + } + + @Test + public void acceptedPayloadWithNoRefusedWriteIsNotAnOffloadStall() throws Exception { + // An output that keeps accepting everything is healthy however long it has been running. + // Only an accepted-then-refused sequence is evidence, so this guards the requirement that + // a refusal actually happened after the first acceptance. + OffloadStallFixture f = new OffloadStallFixture(true, false); + f.write(392); + f.write(392); + f.nowMs.addAndGet(30_000); + assertFalse(f.policy.hasStalledOffloadOutput()); + assertFalse(f.policy.requestOffloadStallRecovery()); + } + + @Test + public void flushDiscardsOffloadEvidenceFromBeforeTheSeek() throws Exception { + OffloadStallFixture f = new OffloadStallFixture(true, false); + f.consumeFirstPayloadThenStall(392); + f.nowMs.addAndGet(30_000); + assertTrue(f.policy.hasStalledOffloadOutput()); + // A seek flushes the output and resets the hardware head to zero, so counters from before + // the flush must not be replayed against a post-flush head of zero. + f.output.flush(); + assertFalse(f.policy.hasStalledOffloadOutput()); + assertFalse(f.policy.requestOffloadStallRecovery()); + // Fresh evidence recorded after the flush is still usable. + f.raw.acceptWrites = true; + f.consumeFirstPayloadThenStall(392); + f.nowMs.addAndGet(30_000); + assertTrue(f.policy.hasStalledOffloadOutput()); + } + + @Test + public void offloadStallDiagnosticUsesOnlyRegisteredEventAndFields() { + // The catvod diagnostics schema rejects unknown event and field names with an + // IllegalArgumentException, and PlaybackDiagnosticCollector.emit swallows that into a + // silent collectorFailure. Assert the exact event/field set the policy emits is registered, + // so this evidence can never be dropped invisibly again. + DiagnosticEvent event = new DiagnosticEvent("audio.output.write", "trace", "instance", 1, 1); + event.inferred() + .observed("stage", "offload-startup-stall") + .observed("encoding", C.ENCODING_AAC_LC) + .observed("sampleRate", 22_050) + .observed("channelMask", Util.getAudioTrackChannelConfig(aacStereo())) + .observed("acceptedBytes", 392L) + .observed("zeroWrites", 1338L) + .observed("headRaw", 0L) + .observed("windowMs", 15_000L) + .observed("reason", "offload-accepted-payload-never-consumed") + .observed("note", "restart-current-item-with-pcm") + .observed("metricScope", "AudioOutput payload consumption; retried requests included") + .unknown("audibility", DiagnosticEvent.Status.NOT_OBSERVABLE) + .pin("offload-startup-stall-media"); + String json = event.json(); + assertTrue(json.contains("offload-startup-stall")); + assertTrue(json.contains("\"evidenceClass\":\"inferred\"")); + // Self-proof that this guard is not vacuous: the validator really does reject the + // unregistered names, which is exactly why the original `audio.offload.stall` event and + // its `recovery` field were being dropped silently. + assertThrows(IllegalArgumentException.class, + () -> new DiagnosticEvent("audio.offload.stall", "trace", "instance", 1, 1)); + assertThrows(IllegalArgumentException.class, + () -> new DiagnosticEvent("audio.output.write", "trace", "instance", 1, 1) + .observed("recovery", "restart-current-item-with-pcm")); + } + private static ExoAudioDirectFailureMemory.Route route(int id) { return new ExoAudioDirectFailureMemory.Route(id, 2, 3, 4, 5); } @@ -1283,6 +1417,54 @@ public class ExoCompressedAudioDirectPolicyTest { } } + /** + * A standard (non-vendor-direct) audio output whose writes can be accepted and then refused, + * which is exactly the shape reported by the failing device log. + */ + private static final class OffloadStallFixture { + final ExoAudioDirectFailureMemory memory = new ExoAudioDirectFailureMemory(); + final TestEnvironment environment = new TestEnvironment(); + final AtomicLong nowMs = new AtomicLong(); + final Format format = aacStereo().buildUpon().setSampleRate(22_050).build(); + final FakeAudioOutput raw = new FakeAudioOutput(); + final AudioOutputProvider.OutputConfig config; + final ExoCompressedAudioDirectPolicy policy; + final AudioOutput output; + + OffloadStallFixture(boolean offload, boolean vendorDirectSupported) throws Exception { + config = new AudioOutputProvider.OutputConfig.Builder() + .setEncoding(C.ENCODING_AAC_LC) + .setSampleRate(22_050) + .setChannelMask(Util.getAudioTrackChannelConfig(aacStereo())) + .setBufferSize(4096) + .setAudioAttributes(AudioAttributes.DEFAULT) + .setIsOffload(offload) + .build(); + Clock clock = (Clock) Proxy.newProxyInstance(Clock.class.getClassLoader(), + new Class[]{Clock.class}, (proxy, method, args) -> { + if (method.getName().equals("elapsedRealtime")) return nowMs.get(); + throw new AssertionError("Unexpected Clock call: " + method.getName()); + }); + policy = new ExoCompressedAudioDirectPolicy( + (ignored, attributes) -> supported(false, false), + (ignored, attributes) -> vendorDirectSupported, + clock, ignored -> raw.output, memory, environment); + policy.prepareForPlayback("https://example.invalid/music?token=private", false); + output = policy.wrapOutputProvider(new StandardAudioOutputProvider(config, raw.output)) + .getAudioOutput(config); + } + + void write(int bytes) throws Exception { + output.write(ByteBuffer.allocateDirect(bytes), 1, 0); + } + + void consumeFirstPayloadThenStall(int bytes) throws Exception { + write(bytes); + raw.acceptWrites = false; + write(bytes); + } + } + private static final class FakeAudioOutput { long positionUs; boolean acceptWrites = true; @@ -1360,6 +1542,12 @@ public class ExoCompressedAudioDirectPolicyTest { .build(); } + private static boolean isRecoverable(ExoPlaybackException error) throws ReflectiveOperationException { + Field field = ExoPlaybackException.class.getDeclaredField("isRecoverable"); + field.setAccessible(true); + return field.getBoolean(error); + } + private static class UnsupportedAudioOutputProvider implements AudioOutputProvider { diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeProfilesSummaryTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeProfilesSummaryTest.java new file mode 100644 index 0000000000..bab39d44d5 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeProfilesSummaryTest.java @@ -0,0 +1,173 @@ +package com.fongmi.android.tv.player.exo; + +import org.junit.Test; + +import java.util.List; +import java.util.Locale; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Guards the user-visible hint for silent hardware-decoder exclusions: playback falls back to + * software decoding after two consecutive failures for one decoder+format combination, which looks + * like unexplained frame drops on 4K content unless the exclusion is surfaced. + */ +public class ExoDecoderRuntimeProfilesSummaryTest { + + @Test + public void summaryIsEmptyWithoutExclusions() { + ExoDecoderRuntimeProfiles profiles = profiles(new MemoryBackend()); + + assertEquals("", profiles.blacklistSummary()); + assertEquals(0, profiles.blacklistedCount()); + } + + @Test + public void summaryDescribesExclusionAfterTwoConsecutiveFailures() { + ExoDecoderRuntimeProfiles profiles = profiles(new MemoryBackend()); + long now = System.currentTimeMillis(); + failTwice(profiles, key(1, "video/hevc", 3840, 2160), now); + + assertEquals(1, profiles.blacklistedCount()); + String summary = profiles.blacklistSummary(); + assertTrue(summary, summary.startsWith("硬解已排除 1 条")); + assertTrue(summary, summary.contains("HEVC")); + assertTrue(summary, summary.contains("3840×2160")); + } + + @Test + public void singleFailureDoesNotProduceASummary() { + ExoDecoderRuntimeProfiles profiles = profiles(new MemoryBackend()); + long now = System.currentTimeMillis(); + profiles.recordFailure( + key(1, "video/hevc", 3840, 2160), + ExoDecoderRuntimeProfileStore.FailureKind.INIT, + ExoDecoderRuntimeProfileStore.Metrics.EMPTY, + now); + + assertEquals(0, profiles.blacklistedCount()); + assertEquals("", profiles.blacklistSummary()); + } + + @Test + public void summaryIgnoresExclusionsRecordedUnderAnotherEnvironment() { + ExoDecoderRuntimeProfiles profiles = profiles(new MemoryBackend()); + long now = System.currentTimeMillis(); + // The environment digest covers Build.FINGERPRINT plus the app/media3 versions, so an + // upgraded install can no longer match these keys. They stay in the store for 30 days and + // must not be reported as an active hardware-decode fallback. + failTwice(profiles, key(9, 1, "video/hevc", 3840, 2160), now); + failTwice(profiles, key(1, 2, "video/avc", 1920, 1080), now); + + assertEquals(1, profiles.blacklistedCount()); + String summary = profiles.blacklistSummary(); + assertTrue(summary, summary.startsWith("硬解已排除 1 条")); + assertTrue(summary, summary.contains("H.264")); + assertFalse(summary, summary.contains("HEVC")); + } + + @Test + public void activeBlacklistOrdersNewestFirstAndSkipsHealthyEntries() { + ExoDecoderRuntimeProfileStore store = new ExoDecoderRuntimeProfileStore(new MemoryBackend()); + long now = System.currentTimeMillis(); + failTwice(store, key(1, "video/hevc", 3840, 2160), now - 10_000); + failTwice(store, key(2, "video/avc", 1920, 1080), now); + store.recordFailure( + key(3, "video/hevc", 1280, 720), + ExoDecoderRuntimeProfileStore.FailureKind.INIT, + ExoDecoderRuntimeProfileStore.Metrics.EMPTY, + now); + + List active = store.activeBlacklist(now); + + assertEquals(2, active.size()); + assertEquals("video/avc", active.get(0).key().mimeType()); + assertEquals("video/hevc", active.get(1).key().mimeType()); + assertFalse(store.activeBlacklist(now).contains(store.lookup(key(3, "video/hevc", 1280, 720), now).entry())); + } + + private static ExoDecoderRuntimeProfiles profiles(MemoryBackend backend) { + return new ExoDecoderRuntimeProfiles( + backend, new ExoDecoderRuntimeKey.Environment(digest(1))); + } + + private static void failTwice( + ExoDecoderRuntimeProfiles profiles, + ExoDecoderRuntimeKey.Key key, + long now) { + profiles.recordFailure(key, ExoDecoderRuntimeProfileStore.FailureKind.INIT, + ExoDecoderRuntimeProfileStore.Metrics.EMPTY, now); + profiles.recordFailure(key, ExoDecoderRuntimeProfileStore.FailureKind.DECODE, + ExoDecoderRuntimeProfileStore.Metrics.EMPTY, now + 1); + } + + private static void failTwice( + ExoDecoderRuntimeProfileStore store, + ExoDecoderRuntimeKey.Key key, + long now) { + store.recordFailure(key, ExoDecoderRuntimeProfileStore.FailureKind.INIT, + ExoDecoderRuntimeProfileStore.Metrics.EMPTY, now); + store.recordFailure(key, ExoDecoderRuntimeProfileStore.FailureKind.DECODE, + ExoDecoderRuntimeProfileStore.Metrics.EMPTY, now + 1); + } + + private static ExoDecoderRuntimeKey.Key key( + int decoder, + String mimeType, + int width, + int height) { + return key(1, decoder, mimeType, width, height); + } + + private static ExoDecoderRuntimeKey.Key key( + int environment, + int decoder, + String mimeType, + int width, + int height) { + return new ExoDecoderRuntimeKey.Key( + digest(environment), + digest(decoder + 1000), + mimeType, + digest(decoder + 2000), + 2, + 153, + width, + height, + 25_000, + 6, + 2, + 6, + true, + false, + ExoDecoderRuntimeSession.OutputTarget.SURFACE, + false); + } + + private static String digest(int value) { + return String.format(Locale.US, "%024x", value); + } + + private static final class MemoryBackend + implements ExoDecoderRuntimeProfileStore.Backend { + + private String value = ""; + + @Override + public String read() { + return value; + } + + @Override + public void write(String value) { + this.value = value == null ? "" : value; + } + + @Override + public void clear() { + value = ""; + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeSessionTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeSessionTest.java index c09fabc230..be1214d1fe 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeSessionTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoDecoderRuntimeSessionTest.java @@ -171,7 +171,8 @@ public class ExoDecoderRuntimeSessionTest { ExoDecoderRuntimeSession session = session(profiles); ExoDecoderRuntimeSession.Evidence evidence = new ExoDecoderRuntimeSession.Evidence( - "decoder", format(3840, 2160), false, 24, 1); + "decoder", format(3840, 2160), false, 24, 1, + ExoDecoderRuntimeSession.STABLE_PLAYBACK_WINDOW_MS); session.recordStable( evidence, @@ -183,6 +184,66 @@ public class ExoDecoderRuntimeSessionTest { assertEquals(0, profiles.failureKeys.size()); } + @Test + public void slowDropsOverALongWindowStayHealthy() { + // 40 drops is well past the old fixed count of 23, but spread over two minutes it is + // 0.33/s, below the 0.77/s the 23-frames-per-30s budget has always meant. Reading the + // rate instead of the raw total is what keeps a long playback from being condemned for + // the tail media3 settles at every pause. + FakeProfiles profiles = new FakeProfiles(); + ExoDecoderRuntimeSession session = session(profiles); + ExoDecoderRuntimeSession.Evidence evidence = + new ExoDecoderRuntimeSession.Evidence( + "decoder", format(3840, 2160), false, 40, 0, 120_000L); + + session.recordStable( + evidence, + ELAPSED + ExoDecoderRuntimeSession.STABLE_PLAYBACK_WINDOW_MS, + EPOCH); + + assertEquals(1, profiles.successKeys.size()); + assertEquals(0, profiles.observationKeys.size()); + } + + @Test + public void shortWindowWithTheSameTotalIsUnhealthy() { + // Identical dropped-frame total in only 10 played seconds is 4/s: the rate holds the + // line the fixed count drew, instead of the window length deciding the verdict. + FakeProfiles profiles = new FakeProfiles(); + ExoDecoderRuntimeSession session = session(profiles); + ExoDecoderRuntimeSession.Evidence evidence = + new ExoDecoderRuntimeSession.Evidence( + "decoder", format(3840, 2160), false, 40, 0, 10_000L); + + session.recordStable( + evidence, + ELAPSED + ExoDecoderRuntimeSession.STABLE_PLAYBACK_WINDOW_MS, + EPOCH); + + assertEquals(0, profiles.successKeys.size()); + assertEquals(1, profiles.observationKeys.size()); + } + + @Test + public void unmeasuredPlayTimeFallsBackToTheFixedCount() { + // Played time is only measured while the analysis listener sees isPlaying; if that + // never happened the old fixed count still applies, so an unmeasured session cannot + // become healthier than it was before. + FakeProfiles profiles = new FakeProfiles(); + ExoDecoderRuntimeSession session = session(profiles); + ExoDecoderRuntimeSession.Evidence evidence = + new ExoDecoderRuntimeSession.Evidence( + "decoder", format(3840, 2160), false, 40, 0); + + session.recordStable( + evidence, + ELAPSED + ExoDecoderRuntimeSession.STABLE_PLAYBACK_WINDOW_MS, + EPOCH); + + assertEquals(0, profiles.successKeys.size()); + assertEquals(1, profiles.observationKeys.size()); + } + @Test public void healthyFallbackRecordsSuccessForFailedKey() { FakeProfiles profiles = new FakeProfiles(); diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoEofRecoveryWiringSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoEofRecoveryWiringSourceTest.java new file mode 100644 index 0000000000..9d2d59d5ee --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoEofRecoveryWiringSourceTest.java @@ -0,0 +1,82 @@ +package com.fongmi.android.tv.player.exo; + +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; + +/** + * Pins the wiring that decides which HTTP EOF recoveries are published as foreground. + * + *

This is a source guard rather than a behavioral test because building the real data source + * chain needs {@code App.get()}, which is unavailable in a plain JVM test. The contract is small and + * structural, so reading it is both sufficient and stable. + * + *

Both directions are load-bearing. If the preload path were marked foreground, a preload-side + * EOF would publish the signal that makes preload yield — preload would stall itself and never + * restart. If the foreground path were not marked, the yield would never fire at all and the change + * would be inert. + */ +public class ExoEofRecoveryWiringSourceTest { + + @Test + public void preloadPathIsMarkedNonForeground() throws Exception { + String factory = read("MediaSourceFactory.java"); + String preloadFactory = methodBody( + factory, + "static DataSource.Factory createUpstreamDataSourceFactory(", + "static synchronized Cache getCache()"); + + assertTrue("preload must build its recovery source explicitly", + preloadFactory.contains("new HttpEofRecoveryDataSource.Factory(upstream, false)")); + } + + @Test + public void foregroundPathIsMarkedForeground() throws Exception { + String factory = read("MediaSourceFactory.java"); + String cacheDataSource = methodBody( + factory, + "private CacheDataSource.Factory getCacheDataSource(\n DataSource.Factory upstreamFactory, Map headers)", + "private OkHttpDataSource.Factory getHttpDataSourceFactory()"); + + assertTrue("the cache-backed foreground path must publish recoveries", + cacheDataSource.contains("new HttpEofRecoveryDataSource.Factory(upstreamFactory, true)")); + } + + @Test + public void preloadNeverReachesTheForegroundDataSourceFactory() throws Exception { + // PreCache must build its chain only through the preload factory. If it ever reached + // getDataSourceFactory(headers) it would silently publish preload recoveries as foreground. + String preCache = read("PreCache.java"); + + assertTrue("PreCache is expected to use the preload upstream factory", + preCache.contains("MediaSourceFactory.createUpstreamDataSourceFactory(")); + assertFalse("PreCache must not build a foreground media source", + preCache.contains("MediaSourceFactory.createMediaSource(")); + } + + private static String methodBody(String source, String signature, String nextSignature) { + int start = source.indexOf(signature); + assertTrue("missing signature: " + signature, start >= 0); + int end = source.indexOf(nextSignature, start); + assertTrue("missing following signature: " + nextSignature, end > start); + return source.substring(start, end); + } + + private static String read(String fileName) throws Exception { + // Gradle may run from the repository root or the module directory; try both, and normalize + // line endings so the guards below are not defeated by CRLF. + Path root = Path.of("").toAbsolutePath(); + Path relative = Path.of("app", "src", "main", "java", "com", "fongmi", "android", "tv", + "player", "exo", fileName); + Path source = root.resolve(relative); + if (!Files.exists(source)) source = root.resolve(Path.of("src", "main", "java", "com", + "fongmi", "android", "tv", "player", "exo", fileName)); + assertTrue("missing source file: " + source, Files.exists(source)); + return Files.readString(source, StandardCharsets.UTF_8).replace("\r\n", "\n"); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoForegroundEofSignalTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoForegroundEofSignalTest.java new file mode 100644 index 0000000000..356904433a --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoForegroundEofSignalTest.java @@ -0,0 +1,137 @@ +package com.fongmi.android.tv.player.exo; + +import com.fongmi.android.tv.player.PlaybackAutoContext; +import com.fongmi.android.tv.player.PlaybackRoute; + +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ExoForegroundEofSignalTest { + + @After + public void clearSignal() { + // Process-level state: without this, cases would leak into one another. + ExoForegroundEofSignal.reset(); + } + + @Test + public void neverObservedRecoveryReportsUnreachableAge() { + ExoForegroundEofSignal.reset(); + // Must be far above any yield window. Returning 0 here would make a healthy session look + // like a recovering one and suspend preload permanently. + assertEquals(Long.MAX_VALUE, ExoForegroundEofSignal.ageMs(0)); + assertEquals(Long.MAX_VALUE, ExoForegroundEofSignal.ageMs(1_000_000)); + } + + @Test + public void ageGrowsFromTheRecordedMoment() { + ExoForegroundEofSignal.reset(); + ExoForegroundEofSignal.record(10_000); + + assertEquals(0, ExoForegroundEofSignal.ageMs(10_000)); + assertEquals(5_000, ExoForegroundEofSignal.ageMs(15_000)); + assertEquals(15_000, ExoForegroundEofSignal.ageMs(25_000)); + } + + @Test + public void laterRecoveryReplacesTheEarlierOne() { + ExoForegroundEofSignal.reset(); + ExoForegroundEofSignal.record(10_000); + ExoForegroundEofSignal.record(40_000); + + // A second burst must restart the window, not inherit the first one's age. + assertEquals(0, ExoForegroundEofSignal.ageMs(40_000)); + assertEquals(1_000, ExoForegroundEofSignal.ageMs(41_000)); + } + + @Test + public void clockGoingBackwardsIsClampedToZero() { + ExoForegroundEofSignal.reset(); + ExoForegroundEofSignal.record(50_000); + + // elapsedRealtime is monotonic in practice, but a negative age must never be reported as a + // value that could compare below a yield window by accident. + assertEquals(0, ExoForegroundEofSignal.ageMs(49_000)); + assertTrue(ExoForegroundEofSignal.ageMs(49_000) >= 0); + } + + /** + * End-to-end through the real capture path. The policy tests build {@code Inputs} directly, so + * they would still pass if {@code Inputs.capture()} never read the signal at all — the change + * would be silently inert in production. This pins the wiring itself. + */ + @Test + public void captureReadsTheSignalSoTheYieldCanActuallyFire() { + PlaybackAutoContext.SessionToken session = + new PlaybackAutoContext.SessionToken("p-eof-capture", 1); + PlaybackAutoContext context = new PlaybackAutoContext( + session, 0, 1, 0, + PlaybackAutoContext.Fact.forSession(PlaybackAutoContext.Kernel.EXO, + PlaybackAutoContext.ValueSource.PLAYER_MANAGER, PlaybackAutoContext.Confidence.HIGH, 0), + PlaybackAutoContext.Fact.unknown(PlaybackAutoContext.DecodeMode.UNKNOWN), + null, null, null, null); + + ExoForegroundEofSignal.reset(); + ExoForegroundEofSignal.record(1_000); + AutoPreloadPolicy.Inputs fresh = capture(session, context, 1_000); + assertEquals(0, fresh.foregroundEofRecoveryAgeMs()); + assertFalse("a fresh recovery must pause preload", + new AutoPreloadPolicy().evaluate(fresh).enabled()); + + // Same session, later clock: the window has passed and preload must be allowed again. + AutoPreloadPolicy.Inputs expired = capture(session, context, 1_000 + 60_000); + assertEquals(60_000, expired.foregroundEofRecoveryAgeMs()); + assertTrue("an expired recovery must not pause preload", + new AutoPreloadPolicy().evaluate(expired).enabled()); + } + + @Test + public void captureWithoutAnyRecoveryNeverYields() { + PlaybackAutoContext.SessionToken session = + new PlaybackAutoContext.SessionToken("p-eofcap-none", 1); + PlaybackAutoContext context = new PlaybackAutoContext( + session, 0, 1, 0, + PlaybackAutoContext.Fact.forSession(PlaybackAutoContext.Kernel.EXO, + PlaybackAutoContext.ValueSource.PLAYER_MANAGER, PlaybackAutoContext.Confidence.HIGH, 0), + PlaybackAutoContext.Fact.unknown(PlaybackAutoContext.DecodeMode.UNKNOWN), + null, null, null, null); + + ExoForegroundEofSignal.reset(); + AutoPreloadPolicy.Inputs inputs = capture(session, context, 5_000); + + assertEquals(Long.MAX_VALUE, inputs.foregroundEofRecoveryAgeMs()); + assertTrue("a healthy session must keep preloading", + new AutoPreloadPolicy().evaluate(inputs).enabled()); + } + + private static AutoPreloadPolicy.Inputs capture( + PlaybackAutoContext.SessionToken session, + PlaybackAutoContext context, + long nowElapsedMs) { + return AutoPreloadPolicy.Inputs.capture( + nowElapsedMs, + session, + PlaybackRoute.EXTERNAL_LOOPBACK_PROXY, + 30_000, + 15_000_000, + 0, + false, + ForwardBufferTrend.Snapshot.unknown(), + ExoThroughputEstimator.Snapshot.empty(), + context, + false, + false); + } + + @Test + public void resetReturnsToUnobserved() { + ExoForegroundEofSignal.record(10_000); + ExoForegroundEofSignal.reset(); + + assertEquals(Long.MAX_VALUE, ExoForegroundEofSignal.ageMs(10_000)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoFrameTimingMetricsTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoFrameTimingMetricsTest.java index edc88f576b..5b5467586b 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoFrameTimingMetricsTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoFrameTimingMetricsTest.java @@ -110,6 +110,28 @@ public class ExoFrameTimingMetricsTest { assertEquals(0, snapshot.processingOffsetBuckets().total()); } + @Test + public void bucketsSeparateLateFramesFromEarlyFrames() { + // media3 reports one offset sample per processed frame: positive = processed early enough, + // negative = the position had already passed the frame. These buckets are what turns a + // "掉帧" report into "frames delivered 30-100 ms late" — the measurement shape + // androidx/media#3347 needed to tell a skip apart from a hold. + ExoFrameTimingMetrics metrics = new ExoFrameTimingMetrics(); + metrics.observeProcessingOffset(-30_000, 1, true); + metrics.observeProcessingOffset(-5_000, 1, true); + metrics.observeProcessingOffset(10_000, 1, true); + metrics.observeProcessingOffset(60_000, 1, true); + + ExoFrameTimingMetrics.SignedTimingBuckets buckets = + metrics.snapshot().processingOffsetBuckets(); + assertEquals(1, buckets.earlierThanMinus20Ms()); + assertEquals(1, buckets.minus20MsToZero()); + assertEquals(1, buckets.zeroTo20Ms()); + assertEquals(1, buckets.fiftyTo75Ms()); + assertEquals(4, buckets.total()); + assertEquals("1/1/1/0/1/0/0", buckets.compact()); + } + @Test public void extremeTimestampsSaturateWithoutNegativeJitterOrCounts() { ExoFrameTimingMetrics metrics = new ExoFrameTimingMetrics(); diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoHlsAdblockDataSourceTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoHlsAdblockDataSourceTest.java index db06b3ee84..4ef4e28a1e 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoHlsAdblockDataSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoHlsAdblockDataSourceTest.java @@ -1,10 +1,12 @@ package com.fongmi.android.tv.player.exo; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import org.junit.Test; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -31,6 +33,31 @@ public class ExoHlsAdblockDataSourceTest { Map.of("Content-Type", List.of("video/mp2t")))); } + @Test + public void octetStreamDoesNotVetoARealPlaylist() { + // 不少静态服务器没配 .m3u8 的 MIME,真播放列表也返回 application/octet-stream; + // 若把它当分片证据,广告过滤会在这些源上整体失效。 + assertTrue(ExoHlsAdblockDataSource.isManifestRequest( + "https://example.test/live/index.m3u8", + Map.of("Content-Type", List.of("application/octet-stream")))); + } + + @Test + public void segmentBytesUnderAnM3u8SuffixAreNotPlaylists() { + // 实测本地包直播:分片地址顶着 .m3u8 后缀返回 TS 字节,必须按原样透传。 + assertFalse(ExoHlsAdblockDataSource.looksLikePlaylist(new byte[]{0x47, 0x40, 0x11, 0x10, 0x00})); + assertFalse(ExoHlsAdblockDataSource.looksLikePlaylist(new byte[0])); + assertFalse(ExoHlsAdblockDataSource.looksLikePlaylist(null)); + assertTrue(ExoHlsAdblockDataSource.looksLikePlaylist("#EXTM3U\n#EXTINF:1,\na.ts".getBytes(StandardCharsets.UTF_8))); + byte[] bom = "#EXTM3U\n#EXT-X-VERSION:3\n".getBytes(StandardCharsets.UTF_8); + byte[] withBom = new byte[bom.length + 3]; + withBom[0] = (byte) 0xEF; + withBom[1] = (byte) 0xBB; + withBom[2] = (byte) 0xBF; + System.arraycopy(bom, 0, withBom, 3, bom.length); + assertTrue(ExoHlsAdblockDataSource.looksLikePlaylist(withBom)); + } + @Test public void changedOutcomeProducesPlaybackNoticeText() { String manifest = "#EXTM3U\n#EXTINF:5.0,\nad/001.ts\n#EXTINF:8.0,\nvideo/002.ts\n#EXTINF:8.0,\nvideo/003.ts\n#EXTINF:8.0,\nvideo/004.ts\n#EXT-X-ENDLIST\n"; @@ -38,6 +65,6 @@ public class ExoHlsAdblockDataSourceTest { "https://example.test/live/index.m3u8", manifest); assertTrue(result.changed()); - assertTrue("Unexpected notice: [" + result.notice() + "]", result.notice().startsWith("已跳过 ")); + assertEquals("已跳过 1 个广告片段,总广告时长 5.0 秒", result.notice()); } } diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/ExoVideoHardwareDecodeTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoVideoHardwareDecodeTest.java new file mode 100644 index 0000000000..3e4b8e38cb --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/ExoVideoHardwareDecodeTest.java @@ -0,0 +1,143 @@ +package com.fongmi.android.tv.player.exo; + +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 androidx.media3.exoplayer.mediacodec.MediaCodecInfo; +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector; + +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; + +/** + * The video half of the "hard decode has no hardware path" proof, plus the source guards that keep + * the three participants of that contract aligned: the hardware predicate, the decoder list the + * hard-decode renderers are built with, and the verdict the OSD prints. + */ +public class ExoVideoHardwareDecodeTest { + + private static final String VIDEO_MIME = "video/hevc"; + private static final String EXO_UTIL_PATH = + "app/src/main/java/com/fongmi/android/tv/player/exo/ExoUtil.java"; + private static final String PLAYER_MANAGER_PATH = + "app/src/main/java/com/fongmi/android/tv/player/PlayerManager.java"; + private static final String OSD_PATH = + "app/src/main/java/com/fongmi/android/tv/ui/custom/PlayerOsdController.java"; + + @Test + public void softwareOnlyDecoderThatClaimsHardware_isNotAHardwarePath() { + assertFalse(ExoUtil.hasHardwareVideoDecoder( + fixed(List.of(codec("c2.vendor.hevc.decoder", true, true))), VIDEO_MIME)); + } + + @Test + public void hardwareDecoder_isAHardwarePath() { + assertTrue(ExoUtil.hasHardwareVideoDecoder( + fixed(List.of(codec("c2.vendor.hevc.decoder", true, false))), VIDEO_MIME)); + } + + @Test + public void allSoftwareDecoders_areNotAHardwarePath() { + assertFalse(ExoUtil.hasHardwareVideoDecoder( + fixed(List.of( + codec("c2.android.hevc.decoder", false, true), + codec("omx.google.hevc.decoder", false, true))), + VIDEO_MIME)); + } + + @Test + public void emptyDecoderList_isNotEvidenceOfSoftwareFallback() { + assertTrue(ExoUtil.hasHardwareVideoDecoder(fixed(List.of()), VIDEO_MIME)); + } + + @Test + public void nullAndNonVideoMime_areNotThisChecksBusiness() { + assertTrue(ExoUtil.hasHardwareVideoDecoder(fixed(List.of()), null)); + assertTrue(ExoUtil.hasHardwareVideoDecoder(fixed(List.of()), "audio/mp4a-latm")); + } + + @Test + public void failedQuery_keepsTheCurrentMode() throws Exception { + // DecoderQueryException has no accessible constructor, so this branch is guarded at the + // source level the way the other untestable branches in this area already are. + String source = readSource(EXO_UTIL_PATH); + + assertTrue(source.contains("} catch (androidx.media3.exoplayer.mediacodec.MediaCodecUtil.DecoderQueryException e) {")); + assertTrue(source.contains("// A failed query must never change the user-visible decode mode.")); + } + + @Test + public void videoSelector_reusesTheSharedHardwarePredicate() throws Exception { + String source = readSource(EXO_UTIL_PATH); + + assertTrue(source.contains("return info.hardwareAccelerated && !info.softwareOnly;")); + assertEquals(2, occurrences(source, "isHardwareDecoder(info)")); + assertFalse(source.contains("if (info.hardwareAccelerated) return true;")); + assertFalse(source.contains("if (info.hardwareAccelerated) hardwareInfos.add(info);")); + } + + @Test + public void platformSoftwareFallback_isGatedByTheDecoderFallbackSwitch() throws Exception { + String source = readSource(EXO_UTIL_PATH); + + assertTrue(source.contains("hardwareInfos.isEmpty() && PlaybackPerformanceSetting.isDecoderFallbackEnabled() ? infos : hardwareInfos")); + } + + @Test + public void autoFallback_decidesOnTheSelectedVideoTrack() throws Exception { + String source = readSource(PLAYER_MANAGER_PATH); + + assertTrue(source.contains("Format selected = getSelectedFormat(tracks, C.TRACK_TYPE_VIDEO);")); + assertTrue(source.contains("return !ExoUtil.hasHardwareVideoDecoder(selected.sampleMimeType);")); + assertTrue(source.contains("return hasTrack(tracks, C.TRACK_TYPE_VIDEO) && !hasHandledTrack(tracks, C.TRACK_TYPE_VIDEO);")); + assertFalse(source.contains("firstDecodableVideoFormat")); + } + + @Test + public void videoVerdict_coversEveryUndecodableSupportCode() throws Exception { + String source = readSource(OSD_PATH); + + assertTrue(source.contains("!videoTrack.selected() && !videoTrack.isHandled()")); + assertTrue(source.contains("supportText(videoTrack.support())")); + assertTrue(source.contains("C.FORMAT_UNSUPPORTED_DRM")); + } + + private static MediaCodecSelector fixed(List infos) { + return (mimeType, secure, tunneling) -> infos; + } + + private static MediaCodecInfo codec( + String name, boolean hardwareAccelerated, boolean softwareOnly) { + return MediaCodecInfo.newInstance( + name, + VIDEO_MIME, + VIDEO_MIME, + null, + hardwareAccelerated, + softwareOnly, + !softwareOnly, + false, + false); + } + + private static int occurrences(String source, String needle) { + int count = 0; + for (int i = source.indexOf(needle); i >= 0; i = source.indexOf(needle, i + needle.length())) { + count++; + } + return count; + } + + 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(); + assertNotNull("Unable to locate project root from " + System.getProperty("user.dir"), root); + return Files.readString(new File(root, relativePath).toPath(), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryPolicyTest.java index 8cdba76d73..d04ce855b0 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryPolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryPolicyTest.java @@ -35,4 +35,34 @@ public class HttpEofRecoveryPolicyTest { assertFalse(HttpEofRecoveryDataSource.canRecoverConsecutively(3)); assertFalse(HttpEofRecoveryDataSource.canRecoverConsecutively(-1)); } + + @Test + public void spacesOutConsecutiveReconnects() { + // The first attempt is the one currently failing; it must not be delayed, or a single + // transient EOF would cost latency for no benefit. + assertEquals(0, HttpEofRecoveryDataSource.backoffMs(0)); + assertEquals(0, HttpEofRecoveryDataSource.backoffMs(-1)); + // Attempts 1..3 are the retries, strictly increasing so each gives the peer more room. + long first = HttpEofRecoveryDataSource.backoffMs(1); + long second = HttpEofRecoveryDataSource.backoffMs(2); + long third = HttpEofRecoveryDataSource.backoffMs(3); + assertTrue("first retry must be delayed", first > 0); + assertTrue("backoff must increase", second > first); + assertTrue("backoff must increase", third > second); + // Beyond the retry budget the delay saturates instead of growing without bound. + assertEquals(third, HttpEofRecoveryDataSource.backoffMs(4)); + assertEquals(third, HttpEofRecoveryDataSource.backoffMs(99)); + } + + @Test + public void totalBackoffStaysWithinOneRecoveryBurst() { + // Guards the cost of the change: a full 3-retry burst must stay well under a second so a + // working stream is not made to feel slow, while still being far above the 23-123 ms + // spacing that produced the observed retry storm. + long total = HttpEofRecoveryDataSource.backoffMs(1) + + HttpEofRecoveryDataSource.backoffMs(2) + + HttpEofRecoveryDataSource.backoffMs(3); + assertTrue("burst must be perceptible to the peer: " + total, total >= 500); + assertTrue("burst must stay sub-second: " + total, total < 1_000); + } } diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/LiveManifestRetryPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/LiveManifestRetryPolicyTest.java new file mode 100644 index 0000000000..839b72778d --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/LiveManifestRetryPolicyTest.java @@ -0,0 +1,39 @@ +package com.fongmi.android.tv.player.exo; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.media3.common.C; + +import org.junit.Test; + +import java.io.IOException; + +public class LiveManifestRetryPolicyTest { + + @Test + public void emptyBodyAndParseFailuresAreTransient() { + assertTrue(LiveManifestRetryPolicy.isTransientManifestFailure(new IOException())); + assertTrue(LiveManifestRetryPolicy.isTransientManifestFailure(new IOException("empty body"))); + assertTrue(LiveManifestRetryPolicy.isTransientManifestFailure(new IOException("failed to parse manifest"))); + assertTrue(LiveManifestRetryPolicy.isTransientManifestFailure( + new IOException("load failed", new IOException("unexpected eof")))); + } + + @Test + public void definitiveHttpFailuresStayFatal() { + assertFalse(LiveManifestRetryPolicy.isTransientManifestFailure(new IOException("Response code: 404"))); + assertFalse(LiveManifestRetryPolicy.isTransientManifestFailure(new IOException("upstream reset"))); + assertFalse(LiveManifestRetryPolicy.isTransientManifestFailure(null)); + } + + @Test + public void longerRetryWindowIsScopedToManifests() { + LiveManifestRetryPolicy policy = new LiveManifestRetryPolicy(); + + assertTrue(policy.getMinimumLoadableRetryCount(C.DATA_TYPE_MANIFEST) >= 8); + // 点播分片不能跟着 manifest 一起延长重试,否则死链会晚十几秒才失败。 + assertTrue(policy.getMinimumLoadableRetryCount(C.DATA_TYPE_MEDIA) + < policy.getMinimumLoadableRetryCount(C.DATA_TYPE_MANIFEST)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/PreCachePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/PreCachePolicyTest.java index 084725321a..bcb04191b2 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/PreCachePolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/PreCachePolicyTest.java @@ -4,6 +4,9 @@ import androidx.media3.common.Player; import org.junit.Test; +import java.util.HashSet; +import java.util.Set; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -105,6 +108,108 @@ public class PreCachePolicyTest { assertEquals(0, PreCachePolicy.nextRangeDelayMs(false)); } + /** + * 硬暂停名单必须与 {@link AutoPreloadPolicy.Reason} 的语义一一对应。 + * + *

为什么需要这条守卫:{@code PreCache.effectiveAutoDecision()} 会把 + * {@code evaluate()} 的暂停结论**改回**「1 线程 / 10 s 继续预载」,唯一的否决点就是 + * {@code isHardAutomaticPause()}。新增一个语义上属于硬暂停的原因却忘了登记, + * 策略单测仍会全绿(它们直接调 {@code evaluate()},不经过 {@code PreCache}), + * 生产上却完全失效——{@code FOREGROUND_EOF_RECOVERY} 就是这样被漏掉的, + * 「让预加载给前台恢复让路」退化成「照旧抢同一条脆弱连接」。 + * + *

双向断言:枚举里的每个原因都必须被明确分类,既不能漏登记硬暂停,也不能把 + * 缓冲区/吞吐类原因误升为硬暂停(那会停掉既有的「降级继续跑」行为)。 + * + *

本用例把枚举的**每一个**原因都显式归入「硬暂停」或「降级继续跑」两张表, + * 再断言两表并集恰好覆盖枚举全集。因此新增原因时本用例会**立即**失败(新标签 + * 两张表都不在),迫使作者显式决定它属于哪一类——这正是漏登 + * {@code FOREGROUND_EOF_RECOVERY} 时缺失的保护。 + * + *

为什么不能只写「硬暂停表」再逐个比对:那样新增的原因在两张表里都是 + * {@code false},{@code false == false} 恒真,用例照样全绿,守卫形同虚设。 + */ + @Test + public void hardAutomaticPauseMatchesTheResourceReasonSet() { + Set hard = Set.of( + AutoPreloadPolicy.Reason.SESSION_MISMATCH.label(), + AutoPreloadPolicy.Reason.MEMORY_PRESSURE.label(), + AutoPreloadPolicy.Reason.NETWORK_UNAVAILABLE.label(), + AutoPreloadPolicy.Reason.NETWORK_UNVALIDATED.label(), + AutoPreloadPolicy.Reason.DATA_SAVER.label(), + AutoPreloadPolicy.Reason.POWER_SAVE.label(), + AutoPreloadPolicy.Reason.THERMAL_PRESSURE.label(), + AutoPreloadPolicy.Reason.FOREGROUND_EOF_RECOVERY.label()); + // 其余原因只降级(缩任务/降线程),不停磁盘预载。这张表同样必须显式维护。 + Set degraded = Set.of( + AutoPreloadPolicy.Reason.REBUFFER.label(), + AutoPreloadPolicy.Reason.NETWORK_CHANGED.label(), + AutoPreloadPolicy.Reason.METERED.label(), + AutoPreloadPolicy.Reason.ROAMING.label(), + AutoPreloadPolicy.Reason.DATA_SAVER_WHITELISTED.label(), + AutoPreloadPolicy.Reason.THERMAL_MODERATE.label(), + AutoPreloadPolicy.Reason.FOREGROUND_RECOVERY.label(), + AutoPreloadPolicy.Reason.FRONT_BUFFER_LOW.label(), + AutoPreloadPolicy.Reason.FRONT_BUFFER_MARGIN.label(), + AutoPreloadPolicy.Reason.BUFFER_DECLINING.label(), + AutoPreloadPolicy.Reason.BUFFER_EVIDENCE_UNKNOWN.label(), + AutoPreloadPolicy.Reason.TIME_TO_EMPTY.label(), + AutoPreloadPolicy.Reason.THROUGHPUT_DEFICIT.label(), + AutoPreloadPolicy.Reason.SHORT_WINDOW_DEFICIT.label(), + AutoPreloadPolicy.Reason.THROUGHPUT_MARGIN.label(), + AutoPreloadPolicy.Reason.SHORT_WINDOW_DECLINE.label(), + AutoPreloadPolicy.Reason.THROUGHPUT_EVIDENCE_UNKNOWN.label(), + AutoPreloadPolicy.Reason.MEDIA_BITRATE_UNKNOWN.label(), + AutoPreloadPolicy.Reason.PREDICTION_ERROR.label(), + AutoPreloadPolicy.Reason.PATH_LIMITED.label(), + AutoPreloadPolicy.Reason.VPN.label(), + AutoPreloadPolicy.Reason.PRELOAD_CONTENTION.label(), + AutoPreloadPolicy.Reason.SYSTEM_EVIDENCE_UNKNOWN.label(), + AutoPreloadPolicy.Reason.EXTERNAL_BUFFER_MARGIN.label(), + AutoPreloadPolicy.Reason.EVIDENCE_UNKNOWN.label(), + AutoPreloadPolicy.Reason.RECOVERY_STABILIZING.label(), + AutoPreloadPolicy.Reason.STABLE_NORMAL.label(), + AutoPreloadPolicy.Reason.STABLE_FAST.label(), + AutoPreloadPolicy.Reason.FAST_FALLBACK.label(), + AutoPreloadPolicy.Reason.DISRUPTED.label()); + + Set classified = new HashSet<>(hard); + classified.addAll(degraded); + assertEquals("every Reason must be classified as hard-pause or degraded", + AutoPreloadPolicy.Reason.values().length, classified.size()); + + for (AutoPreloadPolicy.Reason reason : AutoPreloadPolicy.Reason.values()) { + assertEquals("hard-pause classification of " + reason.label(), + hard.contains(reason.label()), PreCache.isHardAutomaticPause(reason.label())); + } + assertFalse(PreCache.isHardAutomaticPause(null)); + assertFalse(PreCache.isHardAutomaticPause("no-such-reason")); + } + + /** + * 前台 EOF 恢复期间必须真的停预载,而不是只降级。 + * + *

这条用例锁的是 {@code effectiveAutoDecision()} 里「降级为 1 线程 / 10 s 继续跑」 + * 那条分支的**否决条件**:只要原因没被登记为硬暂停,{@code evaluate()} 的暂停结论 + * 就会被改写成「1 线程 / 10 s / degraded / *-continuous」继续预载。因此这里断言 + * {@code isHardAutomaticPause} 对该原因返回 true,并同时用缓冲区/吞吐类原因作对照, + * 确保既有的「降级继续跑」行为没有被误伤。 + * + *

注意本用例不直接调用 {@code effectiveAutoDecision()}(它需要 Android 上下文与 + * 播放器实例,纯 JVM 单测无法构造),而是锁定它唯一的否决点。该否决点一旦失守, + * 生产上「让预加载给前台恢复让路」就退化成「照旧抢同一条脆弱连接」。 + */ + @Test + public void foregroundEofRecoveryIsNotDowngradedBackToContinuousPreload() { + assertTrue(PreCache.isHardAutomaticPause( + AutoPreloadPolicy.Reason.FOREGROUND_EOF_RECOVERY.label())); + // 对照组:缓冲区类原因必须仍然走「降级继续跑」,否则会停掉既有行为。 + assertFalse(PreCache.isHardAutomaticPause( + AutoPreloadPolicy.Reason.FRONT_BUFFER_LOW.label())); + assertFalse(PreCache.isHardAutomaticPause( + AutoPreloadPolicy.Reason.THROUGHPUT_DEFICIT.label())); + } + private static long target(boolean recovery, long remainingMs, double bitrateMbps, double capacityMib) { long bitrate = Math.round(bitrateMbps * 1_000_000); int capacity = (int) Math.round(capacityMib * 1024 * 1024); diff --git a/app/src/test/java/com/fongmi/android/tv/player/exo/TrackUtilTest.java b/app/src/test/java/com/fongmi/android/tv/player/exo/TrackUtilTest.java index e08b627713..d4e5b97e7b 100644 --- a/app/src/test/java/com/fongmi/android/tv/player/exo/TrackUtilTest.java +++ b/app/src/test/java/com/fongmi/android/tv/player/exo/TrackUtilTest.java @@ -1,13 +1,20 @@ 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.TrackGroup; +import androidx.media3.common.Tracks; import org.junit.Test; import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; public class TrackUtilTest { @@ -26,6 +33,111 @@ public class TrackUtilTest { assertNull(TrackUtil.onlySelectedFormat(List.of(low, high))); } + /** + * 自适应选轨会把整组候选都标成 selected(视轨列表因此整列高亮)。播放器报告的 + * 正在解码格式必须把范围收敛回快照里的那一条,且返回的必须是快照内的实例—— + * TrackDialog 按引用比对,equals 会把同规格的两条轨道一起点亮。 + */ + @Test + public void uniqueActiveFormatCollapsesAdaptiveCandidatesToTheDecodingRendition() { + Format low = rendition("v-low", 426, 240, 550_000); + Format mid = rendition("v-mid", 854, 480, 1_570_000); + Format high = rendition("v-high", 1280, 720, 2_970_000); + Tracks tracks = adaptiveVideo(low, mid, high); + Format decoding = rendition("v-high", 1280, 720, 2_970_000); + + Format active = TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_VIDEO, decoding); + + assertSame("must return the snapshot instance, not the player's copy", high, active); + } + + @Test + public void uniqueActiveFormatLeavesAnUnambiguousSelectionAlone() { + Format low = rendition("v-low", 426, 240, 550_000); + Format high = rendition("v-high", 1280, 720, 2_970_000); + Tracks tracks = new Tracks(List.of(new Tracks.Group( + new TrackGroup(low, high), true, + new int[]{C.FORMAT_HANDLED, C.FORMAT_HANDLED}, + new boolean[]{false, true}))); + + assertNull("a single selected candidate needs no narrowing", + TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_VIDEO, rendition("v-high", 1280, 720, 2_970_000))); + } + + /** 匹配不上时返回 null,让调用方退回播放器原始标记,而不是让列表一项都不高亮。 */ + @Test + public void uniqueActiveFormatFallsBackWhenTheDecodingRenditionIsNotInTheCandidates() { + Tracks tracks = adaptiveVideo( + rendition("v-low", 426, 240, 550_000), + rendition("v-mid", 854, 480, 1_570_000)); + + assertNull(TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_VIDEO, rendition("v-4k", 3840, 2160, 15_000_000))); + } + + @Test + public void uniqueActiveFormatIgnoresOtherTrackTypes() { + Format video = rendition("v-high", 1280, 720, 2_970_000); + Tracks tracks = new Tracks(List.of(new Tracks.Group( + new TrackGroup(video), true, + new int[]{C.FORMAT_HANDLED}, + new boolean[]{true}))); + + assertNull(TrackUtil.uniqueActiveFormat(tracks, C.TRACK_TYPE_AUDIO, rendition("v-high", 1280, 720, 2_970_000))); + } + + @Test + public void sameRenditionMatchesOnWidthHeightAndBitrate() { + assertTrue(TrackUtil.sameRendition( + rendition("v-high", 1280, 720, 2_970_000), + rendition("other-id", 1280, 720, 2_970_000))); + } + + @Test + public void sameRenditionMatchesOnTrackIdEvenWhenTheMetadataDisagrees() { + assertTrue(TrackUtil.sameRendition( + rendition("v-high", 1280, 720, 2_970_000), + rendition("v-high", 1920, 1080, 4_000_000))); + } + + @Test + public void sameRenditionRejectsTheSameResolutionWithADifferentCodec() { + assertFalse("同一分辨率可能同时提供 H.264 与 HEVC 两条轨道,编码能比就必须不一致", + TrackUtil.sameRendition( + rendition("v-avc", 1280, 720, 2_970_000, MimeTypes.VIDEO_H264), + rendition("v-hevc", 1280, 720, 2_970_000, MimeTypes.VIDEO_H265))); + } + + @Test + public void sameRenditionRejectsADifferentBitrateAtTheSameResolution() { + assertFalse(TrackUtil.sameRendition( + rendition("v-high", 1280, 720, 2_970_000), + rendition("v-low", 1280, 720, 1_570_000))); + } + + private static Tracks adaptiveVideo(Format... formats) { + boolean[] selected = new boolean[formats.length]; + int[] support = new int[formats.length]; + for (int i = 0; i < formats.length; i++) { + selected[i] = true; + support[i] = C.FORMAT_HANDLED; + } + return new Tracks(List.of(new Tracks.Group(new TrackGroup(formats), true, support, selected))); + } + + private static Format rendition(String id, int width, int height, int bitrate) { + return rendition(id, width, height, bitrate, MimeTypes.VIDEO_H264); + } + + private static Format rendition(String id, int width, int height, int bitrate, String mimeType) { + return new Format.Builder() + .setId(id) + .setSampleMimeType(mimeType) + .setWidth(width) + .setHeight(height) + .setAverageBitrate(bitrate) + .build(); + } + private static Format video(String mimeType, int width, int height) { return new Format.Builder() .setSampleMimeType(mimeType) diff --git a/app/src/test/java/com/fongmi/android/tv/player/extractor/MpdSanitizerMatcherMethodContractTest.java b/app/src/test/java/com/fongmi/android/tv/player/extractor/MpdSanitizerMatcherMethodContractTest.java new file mode 100644 index 0000000000..c61e881f29 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/player/extractor/MpdSanitizerMatcherMethodContractTest.java @@ -0,0 +1,87 @@ +package com.fongmi.android.tv.player.extractor; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Android 7-13 (API < 34) contract for MPD sanitizing. + * + *

{@code Matcher#appendReplacement/appendTail(StringBuilder, String)} only exists since API 34, + * and {@code desugar_jdk_libs_nio} does not back-port that overload, so those calls threw + * {@code NoSuchMethodError} on API 24-33 and {@code Source.fetch()} silently fell back to the raw + * URL. The whole pass must therefore keep using the API 1 {@code StringBuffer} overloads. + */ +public class MpdSanitizerMatcherMethodContractTest { + + private static final Pattern APPEND_REPLACEMENT = Pattern.compile("\\.appendReplacement\\s*\\(\\s*([A-Za-z_$][\\w$]*)\\s*,"); + private static final Pattern APPEND_TAIL = Pattern.compile("\\.appendTail\\s*\\(\\s*([A-Za-z_$][\\w$]*)\\s*\\)"); + + @Test + public void everyMatcherAppendUsesTheApi1StringBufferOverload() throws Exception { + String source = read("app/src/main/java/com/fongmi/android/tv/player/extractor/MpdSanitizer.java"); + List targets = appendTargets(source); + assertEquals("the sanitizer must keep all five replacement calls", 5, occurrences(source, ".appendReplacement(")); + assertEquals("the sanitizer must keep all five tail calls", 5, occurrences(source, ".appendTail(")); + assertTrue("the sanitizer must keep its matcher appends", targets.size() >= 2); + + StringBuilder stringBuilderTyped = new StringBuilder(); + for (String variable : targets) { + Matcher declaration = Pattern + .compile("\\b(StringBuilder|StringBuffer)\\s+" + Pattern.quote(variable) + "\\b") + .matcher(source); + assertTrue("every matcher target must be declared:" + variable, declaration.find()); + if ("StringBuilder".equals(declaration.group(1))) stringBuilderTyped.append(variable).append(' '); + } + assertFalse("no matcher append may target a StringBuilder (API 34 overload): " + stringBuilderTyped, + stringBuilderTyped.length() > 0); + assertTrue("the source must document why the StringBuffer overload is required", + source.contains("StringBuilder 重载需要 API 34")); + } + + @Test + public void theApi1StringBufferOverloadStillPerformsTheReplacement() { + StringBuffer buffer = new StringBuffer(); + Matcher matcher = Pattern.compile("([0-9]+)").matcher("a1b2c"); + while (matcher.find()) matcher.appendReplacement(buffer, "#"); + matcher.appendTail(buffer); + assertEquals("the API 1 StringBuffer overload must still rewrite the matches", "a#b#c", buffer.toString()); + } + + private static int occurrences(String source, String needle) { + int count = 0; + int from = source.indexOf(needle); + while (from >= 0) { + count++; + from = source.indexOf(needle, from + needle.length()); + } + return count; + } + + private static List appendTargets(String source) { + List targets = new ArrayList<>(); + for (Pattern pattern : new Pattern[]{APPEND_REPLACEMENT, APPEND_TAIL}) { + Matcher matcher = pattern.matcher(source); + while (matcher.find()) { + if (!targets.contains(matcher.group(1))) targets.add(matcher.group(1)); + } + } + return targets; + } + + private static String read(String name) throws Exception { + Path path = Path.of(name); + if (!Files.exists(path)) path = Path.of("..").resolve(name).normalize(); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/server/process/ConfigUseLiveSyncAcceptanceTest.java b/app/src/test/java/com/fongmi/android/tv/server/process/ConfigUseLiveSyncAcceptanceTest.java new file mode 100644 index 0000000000..0dce8ac713 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/server/process/ConfigUseLiveSyncAcceptanceTest.java @@ -0,0 +1,31 @@ +package com.fongmi.android.tv.server.process; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.fongmi.android.tv.setting.ConfigSyncPolicy; + +import org.junit.Test; + +public class ConfigUseLiveSyncAcceptanceTest { + + @Test + public void synchronizedPairingContinuesWhenUrlsMatch() { + assertTrue(ConfigSyncPolicy.shouldSyncLive("https://example.com/config", "https://example.com/config")); + } + + @Test + public void independentLiveConfigIsPreservedWhenUrlsDiffer() { + assertFalse(ConfigSyncPolicy.shouldSyncLive("https://vod.example.com/config", "https://live.example.com/config")); + } + + @Test + public void emptyInitialPairingContinuesWhenBothUrlsAreEmpty() { + assertTrue(ConfigSyncPolicy.shouldSyncLive("", "")); + } + + @Test + public void nullVodUrlIsNotTreatedAsSameIndependentLiveSource() { + assertFalse(ConfigSyncPolicy.shouldSyncLive(null, "https://live.example.com/config")); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/server/process/PlaybackHistoryApiTest.java b/app/src/test/java/com/fongmi/android/tv/server/process/PlaybackHistoryApiTest.java new file mode 100644 index 0000000000..c4e9b3ad0f --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/server/process/PlaybackHistoryApiTest.java @@ -0,0 +1,74 @@ +package com.fongmi.android.tv.server.process; + +import static org.junit.Assert.assertEquals; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +/** + * 按站点读历史端点的参数归一化契约。 + * + *

只测纯逻辑部分(参数解析与夹取)。HTTP 层(状态码、CORS、响应体形态、 + * 观影记录同步开关的 403 门控)**目前没有自动化覆盖**:实例化端点需要 + * {@code IHTTPSession} 与运行中的 {@code NanoHTTPD},纯 JVM 单测无法构造。 + * 那部分靠真机手工验证(开关关闭 → 403;开启 → 200 且 items 只含 apiSafe 字段)。 + * 本类不声称覆盖它。 + */ +public class PlaybackHistoryApiTest { + + @Test + public void siteKeyPrefersSiteKeyOverSiteAlias() { + Map params = new HashMap<>(); + params.put("siteKey", "csp_main"); + params.put("site", "csp_alias"); + + assertEquals("csp_main", PlaybackHistoryApi.siteKey(params)); + } + + @Test + public void siteKeyFallsBackToSiteAliasAndTrims() { + Map params = new HashMap<>(); + params.put("site", " csp_alias "); + + assertEquals("csp_alias", PlaybackHistoryApi.siteKey(params)); + } + + @Test + public void siteKeyIsEmptyWhenAbsent() { + // 空 siteKey 由调用方判 400;这里只保证不会返回 null 或抛异常。 + assertEquals("", PlaybackHistoryApi.siteKey(new HashMap<>())); + assertEquals("", PlaybackHistoryApi.siteKey(Map.of("siteKey", " "))); + } + + @Test + public void blankSiteKeyStillFallsBackToSiteAlias() { + // 回归锁:`?siteKey=%20&site=csp_x` 里调用方明确给了别名, + // 若只看 isEmpty 就返回空串,端点会报 400 而调用方拿不到解释。 + // 只有主参数与别名**都**空才返回空串。 + Map params = new HashMap<>(); + params.put("siteKey", " "); + params.put("site", "csp_alias"); + + assertEquals("csp_alias", PlaybackHistoryApi.siteKey(params)); + } + + @Test + public void limitFallsBackToDefaultOnMissingOrMalformed() { + // 坏参数一律回落默认(0 表示"用默认"),而不是报 400—— + // 对端传了一个坏 limit 不该让整次拉取失败。 + assertEquals(0, PlaybackHistoryApi.limit(new HashMap<>())); + assertEquals(0, PlaybackHistoryApi.limit(Map.of("limit", "abc"))); + assertEquals(0, PlaybackHistoryApi.limit(Map.of("limit", ""))); + assertEquals(0, PlaybackHistoryApi.limit(Map.of("limit", "1.5"))); + } + + @Test + public void limitParsesValidNumber() { + assertEquals(7, PlaybackHistoryApi.limit(Map.of("limit", "7"))); + assertEquals(7, PlaybackHistoryApi.limit(Map.of("limit", " 7 "))); + // 负值原样返回,由 effectiveHistoryLimit 负责回落默认。 + assertEquals(-3, PlaybackHistoryApi.limit(Map.of("limit", "-3"))); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/server/process/ProxyRedirectResponseTest.java b/app/src/test/java/com/fongmi/android/tv/server/process/ProxyRedirectResponseTest.java new file mode 100644 index 0000000000..87ddf64ac9 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/server/process/ProxyRedirectResponseTest.java @@ -0,0 +1,110 @@ +package com.fongmi.android.tv.server.process; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import fi.iki.elonen.NanoHTTPD; +import fi.iki.elonen.NanoHTTPD.Response; + +public class ProxyRedirectResponseTest { + + private static final String INVALID_RESPONSE = "Invalid proxy response"; + + @Test + public void redirectMayHaveAnEmptyBody() throws Exception { + assertTrue(canResponseHaveEmptyBody(301)); + assertTrue(canResponseHaveEmptyBody(302)); + assertTrue(canResponseHaveEmptyBody(307)); + } + + @Test + public void nonRedirectStillRequiresAStreamBody() throws Exception { + assertFalse(canResponseHaveEmptyBody(200)); + assertFalse(canResponseHaveEmptyBody(204)); + assertFalse(canResponseHaveEmptyBody(400)); + } + + @Test + public void shortResponsesReturnServerErrorWithoutThrowing() throws Exception { + assertEquals(INVALID_RESPONSE, responseBody(createResponse(new Object[] {302}))); + assertEquals(INVALID_RESPONSE, responseBody(createResponse(new Object[] {302, "text/html"}))); + } + + @Test + public void redirectWithEmptyBodyRequiresLocationHeader() throws Exception { + Map headers = new HashMap<>(); + headers.put("Location", "https://example.com/video"); + + Response response = createResponse(new Object[] {302, "text/html", null, headers}); + + assertEquals(Response.Status.FOUND, response.getStatus()); + assertEquals("https://example.com/video", response.getHeader("Location")); + assertEquals(0, responseBody(response).length()); + } + + @Test + public void redirectWithoutLocationHeaderIsRejected() throws Exception { + Response response = createResponse(new Object[] {302, "text/html", null}); + + assertEquals(INVALID_RESPONSE, responseBody(response)); + } + + @Test + public void streamResponseKeepsBodyAndHeader() throws Exception { + Map headers = new HashMap<>(); + headers.put("Content-Type", "video/mp4"); + InputStream stream = new ByteArrayInputStream("video".getBytes(StandardCharsets.UTF_8)); + + Response response = createResponse(new Object[] {200, "video/mp4", stream, headers}); + + assertEquals(Response.Status.OK, response.getStatus()); + assertEquals("video/mp4", response.getHeader("Content-Type")); + assertEquals("video", responseBody(response)); + } + + @Test + public void objectResponseIsReturnedDirectly() throws Exception { + Response response = NanoHTTPD.newFixedLengthResponse(Response.Status.OK, "text/plain", "ok"); + + assertSame(response, createResponse(new Object[] {response})); + } + + @Test + public void missingResponseReturnsServerError() throws Exception { + assertEquals(INVALID_RESPONSE, responseBody(createResponse(null))); + assertEquals(INVALID_RESPONSE, responseBody(createResponse(new Object[0]))); + } + + private static Response createResponse(Object[] rs) throws Exception { + Proxy proxy = new Proxy(); + Method method = Proxy.class.getDeclaredMethod("createResponse", Map.class, String.class, Object[].class); + method.setAccessible(true); + return (Response) method.invoke(proxy, new HashMap<>(), "/proxy", rs); + } + + private static String responseBody(Response response) throws Exception { + InputStream stream = response.getData(); + assertNotNull(stream); + byte[] buffer = new byte[8192]; + int length = stream.read(buffer); + return length < 0 ? "" : new String(buffer, 0, length, StandardCharsets.UTF_8); + } + + private static boolean canResponseHaveEmptyBody(int code) throws Exception { + Method method = Proxy.class.getDeclaredMethod("canResponseHaveEmptyBody", int.class); + method.setAccessible(true); + return (Boolean) method.invoke(null, code); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/server/process/VodApiTest.java b/app/src/test/java/com/fongmi/android/tv/server/process/VodApiTest.java new file mode 100644 index 0000000000..3a5556429d --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/server/process/VodApiTest.java @@ -0,0 +1,591 @@ +package com.fongmi.android.tv.server.process; + +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 com.fongmi.android.tv.api.config.VodConfig; +import com.fongmi.android.tv.bean.Class; +import com.fongmi.android.tv.bean.Flag; +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.bean.Rule; +import com.github.catvod.bean.Doh; +import com.github.catvod.crawler.Spider; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import org.junit.Test; + +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import fi.iki.elonen.NanoHTTPD; + +/** + * 本机 T4 网关的转换契约。 + * + *

参考对象是影视+ 实验室的 {@code T4Proxy.js} 与 {@code vod.php}:两者都把 + * Spider API 的返回重新包装成苹果CMS V10。这里断言同一份信封形状与字段名, + * 保证 T4 客户端在两套实现之间可以互换订阅。 + */ +public class VodApiTest { + + @Test + public void configListsEverySiteAsType4PointingBackAtGateway() { + List sites = new ArrayList<>(); + sites.add(Site.get("csp_Demo", "演示站")); + Site noSearch = Site.get("js_site", "JS站"); + noSearch.setSearchable(false); + sites.add(noSearch); + + JsonObject config = VodApi.configJson(sites, "http://192.168.1.9:9978/vod/api", null, null); + + assertEquals("", config.get("spider").getAsString()); + assertEquals("资源来自网络,仅供学习使用", config.get("warningText").getAsString()); + JsonArray out = config.getAsJsonArray("sites"); + assertEquals(2, out.size()); + + JsonObject first = out.get(0).getAsJsonObject(); + assertEquals("csp_Demo", first.get("key").getAsString()); + assertEquals("演示站", first.get("name").getAsString()); + assertEquals("http://192.168.1.9:9978/vod/api?key=csp_Demo", first.get("api").getAsString()); + assertEquals("type 必须是字符串 4,与 T4Proxy.js 一致", "4", first.get("type").getAsString()); + assertEquals(1, first.get("searchable").getAsInt()); + assertEquals(1, first.get("filterable").getAsInt()); + + assertEquals("不可搜索的站点要如实上报", 0, out.get(1).getAsJsonObject().get("searchable").getAsInt()); + assertTrue(config.has("doh")); + assertTrue(config.has("rules")); + assertTrue(config.has("lives")); + } + + @Test + public void macCmsEnvelopeMatchesT4ProxyShape() { + Result result = new Result(); + result.setList(List.of(vod("vod-1", "测试片"))); + result.setTypes(List.of(type("movie", "电影"))); + + JsonObject out = VodApi.macCms(result, 3); + + assertEquals(3, out.get("page").getAsInt()); + assertEquals(1, out.get("limit").getAsInt()); + assertEquals("站源没给总页数时按 9999 处理", 9999, out.get("pagecount").getAsInt()); + assertEquals(9999, out.get("total").getAsInt()); + assertEquals(1, out.getAsJsonArray("list").size()); + assertEquals(1, out.getAsJsonArray("class").size()); + assertEquals("movie", out.getAsJsonArray("class").get(0).getAsJsonObject().get("type_id").getAsString()); + } + + @Test + public void macCmsUsesSourcePageCountWhenPresent() { + Result result = new Result(); + setField(result, "pagecount", 7); + result.setList(List.of(vod("vod-1", "测试片"))); + + JsonObject out = VodApi.macCms(result, 2); + + assertEquals(7, out.get("pagecount").getAsInt()); + assertEquals(7, out.get("total").getAsInt()); + } + + @Test + public void macCmsNeverLeaksInternalSiteState() { + Vod item = vod("vod-1", "测试片"); + item.setSite(Site.get("csp_Secret", "内部站点")); + + JsonObject out = VodApi.macCms(result(item), 1).getAsJsonArray("list").get(0).getAsJsonObject(); + + assertFalse("T4 响应不能带出内部 Site 对象", out.has("site")); + assertFalse("T4 响应不能带出内部 flag 对象", out.has("vodFlags")); + assertFalse("T4 响应不能带出内部 TMDB 载荷", out.has("tmdb")); + } + + @Test + public void vodJsonMapsStandardAppleCmsFields() { + Vod item = vod("vod-9", "片名"); + item.setTypeName("动作片"); + item.setPic("https://img.example/9.jpg"); + item.setRemarks("更新至08集"); + item.setYear("2026"); + item.setArea("大陆"); + item.setDirector("导演"); + item.setActor("演员"); + item.setContent("简介"); + + JsonObject out = VodApi.vodJson(item); + + assertEquals("vod-9", out.get("vod_id").getAsString()); + assertEquals("片名", out.get("vod_name").getAsString()); + assertEquals("动作片", out.get("type_name").getAsString()); + assertEquals("https://img.example/9.jpg", out.get("vod_pic").getAsString()); + assertEquals("更新至08集", out.get("vod_remarks").getAsString()); + assertEquals("2026", out.get("vod_year").getAsString()); + assertEquals("大陆", out.get("vod_area").getAsString()); + assertEquals("导演", out.get("vod_director").getAsString()); + assertEquals("演员", out.get("vod_actor").getAsString()); + assertEquals("简介", out.get("vod_content").getAsString()); + } + + @Test + public void vodJsonFallsBackToFlagsWhenSpiderOnlyReturnsFlagArray() { + // T3 爬虫常见形态:只有 flags,没有 vod_play_from / vod_play_url + Vod item = new Vod(); + item.setId("vod-1"); + item.setName("测试片"); + Flag hd = Flag.create("HD"); + hd.setEpisodes("第01集$https://v.example/1.m3u8#第02集$https://v.example/2.m3u8"); + Flag sd = Flag.create("SD"); + sd.setEpisodes("第01集$https://v.example/sd1.m3u8"); + item.setFlags(new ArrayList<>(List.of(hd, sd))); + + JsonObject out = VodApi.vodJson(item); + + assertEquals("HD$$$SD", out.get("vod_play_from").getAsString()); + assertEquals("第01集$https://v.example/1.m3u8#第02集$https://v.example/2.m3u8$$$第01集$https://v.example/sd1.m3u8", + out.get("vod_play_url").getAsString()); + } + + @Test + public void vodJsonKeepsSourceProvidedPlayFields() { + Vod item = new Vod(); + item.setId("vod-1"); + item.setName("测试片"); + item.setPlayFrom("线路A"); + item.setPlayUrl("第01集$https://v.example/a.m3u8"); + + JsonObject out = VodApi.vodJson(item); + + assertEquals("线路A", out.get("vod_play_from").getAsString()); + assertEquals("第01集$https://v.example/a.m3u8", out.get("vod_play_url").getAsString()); + } + + @Test + public void playEnvelopeCarriesResolvedUrlAndClientHints() { + Result result = new Result(); + result.setUrl("https://v.example/final.m3u8"); + result.setFlag("HD"); + result.setParse(1); + result.setFormat("application/x-mpegURL"); + HashMap header = new HashMap<>(); + header.put("Referer", "https://v.example/"); + result.setHeader(header); + + JsonObject out = VodApi.play(result, "HD"); + + assertEquals("https://v.example/final.m3u8", out.get("url").getAsString()); + assertEquals("HD", out.get("flag").getAsString()); + assertEquals(1, out.get("parse").getAsInt()); + assertEquals("application/x-mpegURL", out.get("format").getAsString()); + assertEquals("https://v.example/", out.getAsJsonObject("header").get("Referer").getAsString()); + } + + @Test + public void playEnvelopeFallsBackToRequestedFlag() { + Result result = new Result(); + result.setUrl("https://v.example/final.m3u8"); + + JsonObject out = VodApi.play(result, "线路A"); + + assertEquals("线路A", out.get("flag").getAsString()); + } + + @Test + public void playEnvelopeListsMultipleResolvedUrls() { + Result result = new Result(); + // setUrl 会真正写入字段;之后 getUrl() 才返回同一个对象,add 才能生效。 + result.setUrl("https://v.example/a.m3u8"); + result.getUrl().add("https://v.example/b.m3u8"); + + JsonObject out = VodApi.play(result, "HD"); + + assertTrue(out.has("urls")); + assertEquals(2, out.getAsJsonArray("urls").size()); + assertEquals("首选仍是 position 指向的那条", "https://v.example/a.m3u8", out.get("url").getAsString()); + assertEquals("https://v.example/b.m3u8", out.getAsJsonArray("urls").get(1).getAsString()); + } + + @Test + public void playEnvelopeToleratesResultWithoutUrl() { + JsonObject out = VodApi.play(new Result(), "HD"); + + assertEquals("", out.get("url").getAsString()); + assertEquals("HD", out.get("flag").getAsString()); + } + + @Test + public void routePrefersConfigThenSearchThenExplicitAc() { + assertEquals("config", VodApi.route(parse("{\"ac\":\"config\"}"))); + assertEquals("config", VodApi.route(parse("{\"ac\":\"site\"}"))); + assertEquals("search", VodApi.route(parse("{\"wd\":\"关键词\"}"))); + assertEquals("search", VodApi.route(parse("{\"ac\":\"detail\",\"wd\":\"关键词\"}"))); + assertEquals("list", VodApi.route(parse("{\"ac\":\"detail\"}"))); + assertEquals("play", VodApi.route(parse("{\"ac\":\"play\"}"))); + assertEquals("list", VodApi.route(parse("{\"ac\":\"list\"}"))); + assertEquals("list", VodApi.route(parse("{}"))); + } + + @Test + public void routeInfersFromParametersWhenAcIsAbsent() { + assertEquals("detail", VodApi.route(parse("{\"ids\":\"1,2\"}"))); + assertEquals("play", VodApi.route(parse("{\"flag\":\"HD\",\"id\":\"1\"}"))); + assertEquals("play", VodApi.route(parse("{\"flag\":\"HD\",\"play\":\"1\"}"))); + assertEquals("list", VodApi.route(parse("{\"t\":\"1\",\"pg\":\"2\"}"))); + assertEquals("list", VodApi.route(parse("{\"flag\":\"HD\"}"))); + } + + @Test + public void siteKeyComesFromPathOrQuery() { + assertEquals("csp_Demo", VodApi.siteKeyFromPath("/vod/api/csp_Demo")); + // IHTTPSession.getUri() 已去掉查询部分并解码路径。 + assertEquals("中文/key?+", VodApi.siteKeyFromPath("/vod/api/中文/key?+")); + assertEquals("csp_Demo/detail", VodApi.siteKeyFromPath("/vod/api/csp_Demo/detail")); + assertEquals("", VodApi.siteKeyFromPath("/vod/api")); + assertEquals("", VodApi.siteKeyFromPath("/vod/api?ac=config")); + assertEquals("", VodApi.siteKeyFromPath("/spider")); + } + + @Test + public void extendMergesExplicitFilterFields() { + HashMap extend = VodApi.extendFrom(parse("{\"area\":\"大陆\",\"year\":\"2026\"}")); + + assertEquals("大陆", extend.get("area")); + assertEquals("2026", extend.get("year")); + assertFalse(extend.containsKey("type")); + } + + @Test + public void extendReadsJsonExtendParameter() { + HashMap extend = VodApi.extendFrom(parse("{\"extend\":\"{\\\"lang\\\":\\\"国语\\\"}\"}")); + + assertEquals("国语", extend.get("lang")); + } + + @Test + public void explicitFilterFieldsOverrideExtendJson() { + HashMap extend = VodApi.extendFrom( + parse("{\"extend\":\"{\\\"year\\\":\\\"2020\\\"}\",\"year\":\"2026\"}")); + + assertEquals("2026", extend.get("year")); + } + + @Test + public void blankExtendIsEmptyWithoutDecoding() { + assertTrue(VodApi.extendFrom(parse("{}")).isEmpty()); + assertEquals(null, VodApi.decodeExtend("")); + } + + @Test + public void idsAcceptsCommaListAndJsonArray() { + assertEquals("1,2", VodApi.csv(parse("{\"ids\":\"1,2\"}"), "ids")); + assertEquals("1,2", VodApi.csv(parse("{\"ids\":[\"1\",\"2\"]}"), "ids")); + assertEquals("", VodApi.csv(parse("{}"), "ids")); + } + + @Test + public void errorEnvelopeMatchesSpiderApiShape() { + JsonObject out = VodApi.error("site not found: demo"); + + assertEquals(-1, out.get("code").getAsInt()); + assertEquals("site not found: demo", out.get("msg").getAsString()); + } + + @Test + public void realT4CategoryRequestMustNotBeTreatedAsDetail() { + assertEquals("list", VodApi.route(parse("{\"ac\":\"detail\",\"t\":\"movie\",\"pg\":\"2\"}"))); + assertEquals("detail", VodApi.route(parse("{\"ac\":\"detail\",\"ids\":\"a,b\"}"))); + assertEquals("list", VodApi.route(parse("{\"ac\":\"detail\"}"))); + } + + @Test + public void routeDoesNotClaimAdjacentEndpoints() { + assertTrue(new VodApi().isRequest(null, "/vod/api")); + assertTrue(new VodApi().isRequest(null, "/vod/api/demo")); + assertFalse(new VodApi().isRequest(null, "/vod/apix")); + } + + @Test + public void urlSafeBase64FiltersMustReachCategoryUnchanged() { + String ext = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString( + "{\"year\":\"2026\",\"area\":\"大陆\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + JsonObject request = new JsonObject(); + request.addProperty("ext", ext); + assertEquals("2026", VodApi.extendFrom(request).get("year")); + assertEquals("大陆", VodApi.extendFrom(request).get("area")); + } + + @Test + public void httpRoundTripSupportsActualT4ClientRequests() throws Exception { + try (HttpFixture fixture = new HttpFixture()) { + JsonObject config = fixture.request("GET", "?ac=config", null).body; + String api = config.getAsJsonArray("sites").get(0).getAsJsonObject().get("api").getAsString(); + assertTrue(api, api.endsWith("?key=" + encode(HttpFixture.KEY))); + String route = "?key=" + encode(HttpFixture.KEY); + JsonObject home = fixture.request("GET", route, null).body; + assertEquals("7", home.getAsJsonArray("class").get(0).getAsJsonObject().get("type_id").getAsString()); + assertTrue(home.has("filters")); + assertEquals("推荐", home.getAsJsonArray("list").get(0).getAsJsonObject().get("vod_name").getAsString()); + + String ext = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString("{\"year\":\"2026\"}".getBytes(StandardCharsets.UTF_8)); + JsonObject category = fixture.request("GET", route + "&ac=detail&t=7&pg=2&ext=" + ext, null).body; + assertEquals("category:7:2:true", fixture.spider.lastCall); + assertEquals("2026", fixture.spider.extend.get("year")); + assertEquals(4, category.get("pagecount").getAsInt()); + assertEquals(10, category.get("limit").getAsInt()); + assertEquals(33, category.get("total").getAsInt()); + assertEquals("7", category.getAsJsonArray("list").get(0).getAsJsonObject().get("type_id").getAsString()); + + JsonObject detail = fixture.request("POST", route, "{\"ac\":\"detail\",\"ids\":[\"first\",\"second,opaque\"]}").body; + assertEquals(List.of("first", "second,opaque"), fixture.spider.ids); + assertEquals(2, detail.getAsJsonArray("list").size()); + assertFalse(detail.getAsJsonArray("list").get(0).getAsJsonObject().has("site")); + fixture.request("GET", route + "&wd=test&quick=true&pg=2", null); + assertEquals("search:test:true:2", fixture.spider.lastCall); + fixture.request("GET", route + "&wd=test", null); + assertEquals("search:test:false:1", fixture.spider.lastCall); + + JsonObject play = fixture.request("GET", route + "&play=opaque%2Bplay&flag=HD", null).body; + assertEquals("play:HD:opaque+play", fixture.spider.lastCall); + assertEquals(List.of("vip"), fixture.spider.flags); + assertEquals(1, play.get("parse").getAsInt()); + assertEquals(1, play.get("jx").getAsInt()); + assertEquals(fixture.origin + "/proxy?do=fixture&token=a%2Bb&siteKey=" + encode(HttpFixture.KEY), play.get("url").getAsString()); + assertEquals("https://example.test/", play.getAsJsonObject("header").get("Referer").getAsString()); + } + } + + @Test + public void httpPreflightAndErrorsDoNotExecuteSpiders() throws Exception { + try (HttpFixture fixture = new HttpFixture()) { + assertEquals(204, fixture.request("OPTIONS", "?key=" + encode(HttpFixture.KEY) + "&play=x&flag=HD", null).status); + assertEquals("", fixture.spider.lastCall); + assertEquals(-1, fixture.request("GET", "?key=unknown", null).body.get("code").getAsInt()); + assertEquals(-1, fixture.request("POST", "", "{malformed").body.get("code").getAsInt()); + assertEquals(-1, fixture.request("GET", "?key=" + encode(HttpFixture.KEY) + "&ac=play", null).body.get("code").getAsInt()); + assertEquals("", fixture.spider.lastCall); + } + } + + @Test + public void localUrlsKeepOpaqueQueryAndPinProxySource() { + String origin = "http://192.168.50.3:9978"; + assertEquals(origin + "/proxy?url=https%3A%2F%2Fa.test%2Fv&sig=a%2Bb&siteKey=source%2Bkey", + VodApi.remoteUrl("http://localhost:9978/proxy?url=https%3A%2F%2Fa.test%2Fv&sig=a%2Bb", origin, "source+key", 9978)); + String external = "https://cdn.test/v?next=http://127.0.0.1:9978/proxy"; + assertEquals(external, VodApi.remoteUrl(external, origin, "source", 9978)); + assertEquals("http://127.0.0.1:1234/v", VodApi.remoteUrl("http://127.0.0.1:1234/v", origin, "source", 9978)); + assertEquals(origin + "/proxy?siteKey=original", VodApi.remoteUrl("http://[::1]:9978/proxy?siteKey=original", origin, "other", 9978)); + } + + @Test + public void rawPlaybackKeepsAlternativesSubtitlesAndDrm() { + JsonObject raw = parse("{\"url\":[\"HD\",\"http://127.0.0.1:9978/proxy?do=x\",\"SD\",\"https://cdn.test/sd\"],\"parse\":1,\"playUrl\":\"https://parse.test/?url=\",\"header\":{\"Referer\":\"http://localhost:9978/unchanged\"},\"subs\":[{\"url\":\"http://localhost:9978/sub.vtt\"}],\"drm\":{\"server\":\"http://localhost:9978/license\"}}"); + JsonObject out = VodApi.playJson(raw, "HD", Site.get("s", "source")); + VodApi.externalize(out, "http://192.168.1.1:9978", "s", 9978); + assertEquals("HD", out.getAsJsonArray("url").get(0).getAsString()); + assertEquals("http://192.168.1.1:9978/proxy?do=x&siteKey=s", out.getAsJsonArray("url").get(1).getAsString()); + assertEquals("https://cdn.test/sd", out.getAsJsonArray("url").get(3).getAsString()); + assertEquals("https://parse.test/?url=", out.get("playUrl").getAsString()); + assertEquals("http://localhost:9978/unchanged", out.getAsJsonObject("header").get("Referer").getAsString()); + assertEquals("http://192.168.1.1:9978/sub.vtt", out.getAsJsonArray("subs").get(0).getAsJsonObject().get("url").getAsString()); + assertEquals("http://192.168.1.1:9978/license", out.getAsJsonObject("drm").get("server").getAsString()); + } + + @Test + public void emptyPagesStopAndLargeTotalsDoNotOverflow() { + assertEquals(3, VodApi.formatContent(parse("{\"list\":[]}"), 3).get("pagecount").getAsInt()); + JsonObject full = VodApi.formatContent(parse("{\"pagecount\":2147483647,\"limit\":20,\"list\":[]}"), 1); + assertEquals(42949672940L, full.get("total").getAsLong()); + } + + @Test + public void jsonObjectFiltersAndEmptyFlagSlotsArePreserved() { + assertEquals("2026", VodApi.extendFrom(parse("{\"extend\":{\"year\":\"2026\"}}")).get("year")); + JsonObject out = VodApi.formatContent(parse("{\"list\":[{\"vod_id\":\"a\",\"flags\":[{\"flag\":\"empty\",\"episodes\":[]},{\"flag\":\"HD\",\"episodes\":[{\"name\":\"01\",\"url\":\"id\"}]}]}]}"), 1); + JsonObject vod = out.getAsJsonArray("list").get(0).getAsJsonObject(); + assertEquals("empty$$$HD", vod.get("vod_play_from").getAsString()); + assertEquals("$$$01$id", vod.get("vod_play_url").getAsString()); + } + + @Test + public void selfSubscriptionIsRejectedWithoutBlockingOtherDevices() { + assertTrue(VodApi.isSelfGateway("http://127.0.0.1:9978/vod/api?key=x", "http://192.168.1.1:9978", 9978)); + assertTrue(VodApi.isSelfGateway("http://192.168.1.1:9978/vod/api/x", "http://192.168.1.1:9978", 9978)); + assertFalse(VodApi.isSelfGateway("http://192.168.1.2:9978/vod/api/x", "http://192.168.1.1:9978", 9978)); + } + + @Test + public void directProxyPinningDoesNotDisableLegacyJarProxy() { + assertFalse(VodApi.supportsDirectProxy(new Spider() { })); + assertTrue(VodApi.supportsDirectProxy(new FixtureSpider())); + } + + private static String encode(String value) throws Exception { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20"); + } + + private static final class FixtureSpider extends Spider { + volatile String lastCall = ""; + volatile Map extend = Map.of(); + volatile List ids = List.of(); + volatile List flags = List.of(); + + @Override public String homeContent(boolean filter) { + lastCall = "home:" + filter; + return "{\"class\":[{\"type_id\":\"7\",\"type_name\":\"电影\"}],\"filters\":{\"7\":[{\"key\":\"year\",\"name\":\"年份\",\"value\":[{\"n\":\"2026\",\"v\":\"2026\"}]}]}}"; + } + @Override public String homeVideoContent() { return "{\"list\":[{\"vod_id\":\"home\",\"vod_name\":\"推荐\"}]}"; } + @Override public String categoryContent(String tid, String pg, boolean filter, HashMap extend) { + lastCall = "category:" + tid + ":" + pg + ":" + filter; + this.extend = new HashMap<>(extend); + return "{\"page\":2,\"pagecount\":4,\"limit\":10,\"total\":33,\"list\":[{\"vod_id\":\"category\",\"type_id\":\"7\",\"vod_name\":\"分类\"}]}"; + } + @Override public String detailContent(List ids) { + lastCall = "detail"; + this.ids = new ArrayList<>(ids); + JsonArray list = new JsonArray(); + for (String id : ids) { + JsonObject item = new JsonObject(); + item.addProperty("vod_id", id); + item.addProperty("vod_name", id); + item.addProperty("vod_play_from", "HD"); + item.addProperty("vod_play_url", "01$opaque"); + item.add("site", parse("{\"key\":\"private\"}")); + list.add(item); + } + JsonObject result = new JsonObject(); + result.add("list", list); + return result.toString(); + } + @Override public String searchContent(String key, boolean quick) { return searchContent(key, quick, "1"); } + @Override public String searchContent(String key, boolean quick, String pg) { + lastCall = "search:" + key + ":" + quick + ":" + pg; + return "{\"list\":[]}"; + } + @Override public Object[] proxy(Map params) { + return new Object[]{200, "text/plain", new java.io.ByteArrayInputStream("fixture".getBytes(StandardCharsets.UTF_8))}; + } + @Override public String playerContent(String flag, String id, List flags) { + lastCall = "play:" + flag + ":" + id; + this.flags = new ArrayList<>(flags); + return "{\"url\":\"http://127.0.0.1:9978/proxy?do=fixture&token=a%2Bb\",\"parse\":1,\"jx\":1,\"header\":{\"Referer\":\"https://example.test/\"}}"; + } + } + + private static final class HttpFixture implements AutoCloseable { + static final String KEY = "中文/+?&key"; + final FixtureSpider spider = new FixtureSpider(); + final NanoHTTPD server; + final String origin; + final int previousPort; + + HttpFixture() throws Exception { + Site site = new Site() { + @Override public Spider spider() { return spider; } + @Override public Site recent() { throw new AssertionError("Gateway must not change the active loader"); } + }; + site.setKey(KEY); + site.setName("fixture"); + site.setApi("csp_Fixture"); + site.setType(3); + VodConfig sources = new VodConfig() { + @Override public List getSites() { return List.of(site); } + @Override public List getDoh() { return List.of(); } + @Override public List getRules() { return List.of(); } + @Override public List getFlags() { return List.of("vip"); } + }; + VodApi gateway = new VodApi(sources); + server = new NanoHTTPD("127.0.0.1", 0) { + @Override public Response serve(IHTTPSession session) { + if (!gateway.isRequest(session, session.getUri())) return newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found"); + Map files = new HashMap<>(); + try { + if (session.getMethod() == Method.POST) session.parseBody(files); + return gateway.doResponse(session, session.getUri(), files); + } catch (Exception e) { + throw new AssertionError(e); + } + } + }; + previousPort = com.github.catvod.Proxy.getPort(); + com.github.catvod.Proxy.set(9978); + try { server.start(5000, true); } catch (Exception e) { com.github.catvod.Proxy.set(previousPort); throw e; } + origin = "http://127.0.0.1:" + server.getListeningPort(); + } + + HttpResult request(String method, String suffix, String body) throws Exception { + HttpURLConnection connection = (HttpURLConnection) new URL(origin + "/vod/api" + suffix).openConnection(); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + connection.setRequestMethod(method); + try { + if (body != null) { + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + try (java.io.OutputStream output = connection.getOutputStream()) { output.write(body.getBytes(StandardCharsets.UTF_8)); } + } + int status = connection.getResponseCode(); + String data; + try (InputStream input = status >= 400 ? connection.getErrorStream() : connection.getInputStream()) { + data = input == null ? "" : new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + return new HttpResult(status, data.isEmpty() ? new JsonObject() : JsonParser.parseString(data).getAsJsonObject()); + } finally { + connection.disconnect(); + } + } + @Override public void close() { + server.stop(); + com.github.catvod.Proxy.set(previousPort); + } + } + + private record HttpResult(int status, JsonObject body) { } + + private static Result result(Vod vod) { + Result result = new Result(); + result.setList(List.of(vod)); + return result; + } + + private static Vod vod(String id, String name) { + Vod vod = new Vod(); + vod.setId(id); + vod.setName(name); + return vod; + } + + private static Class type(String id, String name) { + Class type = new Class(); + type.setTypeId(id); + type.setTypeName(name); + return type; + } + + private static JsonObject parse(String json) { + JsonObject out = VodApi.jsonObject(json); + assertNotNull("应能解析为 JSON 对象: " + json, out); + return out; + } + + private static void setField(Object target, String name, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new AssertionError("无法设置字段 " + name, e); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/server/proxy/MultiThreadProxyPlayerUiSourceTest.java b/app/src/test/java/com/fongmi/android/tv/server/proxy/MultiThreadProxyPlayerUiSourceTest.java index ac63e10a6c..b8dc9d060e 100644 --- a/app/src/test/java/com/fongmi/android/tv/server/proxy/MultiThreadProxyPlayerUiSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/server/proxy/MultiThreadProxyPlayerUiSourceTest.java @@ -37,6 +37,25 @@ public class MultiThreadProxyPlayerUiSourceTest { assertTrue(fusion.contains("detailActionView(R.id.multiThreadProxy, View.class).setOnClickListener")); assertTrue(fusion.contains("PlayerButtonSetting.MULTI_THREAD_PROXY, binding.playerMultiThreadProxy")); assertTrue(fusion.contains("player().reloadCurrentMediaItem()")); + // 回归:只登记 addActionButton 不足以让按钮可点,底部控制栏必须真正绑定点击监听器。 + assertTrue(mobile.contains("mBinding.control.action.multiThreadProxy.setOnClickListener(guarded(this::onMultiThreadProxy));")); + assertTrue(leanback.contains("mBinding.control.action.multiThreadProxy.setOnClickListener(guarded(this::onMultiThreadProxy));")); + assertTrue(fusion.contains("playerMultiThreadProxy.setOnClickListener")); + } + + @Test + public void multiThreadButtonIsHiddenByDefaultButStillUserConfigurable() throws Exception { + String catalog = read("main", "java", "com", "fongmi", "android", "tv", "setting", "PlayerButtonSetting.java"); + + assertTrue("多线程按钮必须在默认清单中标记为不显示", + catalog.contains("new Item(MULTI_THREAD_PROXY, R.string.multi_thread_proxy_button, false)")); + assertTrue("首次运行必须把默认不显示项写入隐藏偏好", + catalog.contains("Prefers.put(HIDDEN, join(hidden));")); + assertTrue("默认隐藏只能注入一次,之后以用户选择为准", + catalog.contains("if (Prefers.getBoolean(HIDDEN_SEEDED)) return hidden;") + && catalog.contains("Prefers.put(HIDDEN_SEEDED, true);")); + assertTrue("重置播放器按钮必须同时清除默认隐藏注入标记", catalog.contains("Prefers.remove(HIDDEN_SEEDED);")); + assertTrue("用户仍可通过 putVisible 打开该按钮", catalog.contains("public static void putVisible(String id, boolean visible)")); } @Test diff --git a/app/src/test/java/com/fongmi/android/tv/service/TmdbConfigTestServiceTest.java b/app/src/test/java/com/fongmi/android/tv/service/TmdbConfigTestServiceTest.java index d4e7fecada..348d1d3677 100644 --- a/app/src/test/java/com/fongmi/android/tv/service/TmdbConfigTestServiceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/service/TmdbConfigTestServiceTest.java @@ -95,4 +95,19 @@ public class TmdbConfigTestServiceTest { public void invalidBearerHeaderBecomesFailedCheck() { assertFalse(TmdbConfigTestService.testApi(client, "first.second.\nbad", server.url("/").toString()).success); } + @Test + public void proxyBaseRoutesApiAndImageChecksToMirrorEndpoint() throws Exception { + server.enqueue(new MockResponse().setHeader("Content-Type", "application/json").setBody("{\"images\":{}}")); + server.enqueue(new MockResponse().setHeader("Content-Type", "image/png") + .setBody(new okio.Buffer().write(Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB")))); + + String proxy = server.url("/tmdb-mirror").toString(); + TmdbConfigTestService.Result result = TmdbConfigTestService.test(client, "key", "https://api.tmdb.org", "https://images.tmdb.org", proxy, "", server.url("/").toString()); + + assertTrue(result.api.success); + assertTrue(result.image.success); + assertEquals("/tmdb-mirror/3/configuration?api_key=key", server.takeRequest().getPath()); + assertEquals("/tmdb-mirror/t/p/w342/wwemzKWzjKYJFfCeiB57q3r4Bcm.png", server.takeRequest().getPath()); + } + } diff --git a/app/src/test/java/com/fongmi/android/tv/service/TmdbDisambiguationDetailParityTest.java b/app/src/test/java/com/fongmi/android/tv/service/TmdbDisambiguationDetailParityTest.java new file mode 100644 index 0000000000..b500cb1b4e --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/service/TmdbDisambiguationDetailParityTest.java @@ -0,0 +1,136 @@ +package com.fongmi.android.tv.service; + +import com.fongmi.android.tv.bean.TmdbConfig; +import com.fongmi.android.tv.bean.TmdbPerson; +import com.fongmi.android.tv.ui.helper.TmdbMatchPolicy; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import org.junit.Test; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * 证明消歧 lite 详情与完整详情在**打分输入**上完全等价。 + * + *

消歧只用 {@code credits}/{@code aggregate_credits}(人物)与根对象基础字段(标题/国家/季数)。 + * 本测试对同一夹具构造两份 JSON:完整版与去掉 + * images/translations/external_ids/content_ratings/recommendations/similar 的 lite 版, + * 断言 {@link TmdbService#cast}、{@link TmdbService#creators} 与 + * {@link TmdbMatchPolicy#splitSeasonDetailScore} 的输出逐条相同。 + * 只要该等价成立,消歧的排序与阈值判定就不可能因瘦身而改变。 + */ +public class TmdbDisambiguationDetailParityTest { + + private static final String FULL = """ + { + "id": 1399, + "name": "测试剧", + "original_name": "测试剧", + "first_air_date": "2011-04-17", + "origin_country": ["US"], + "number_of_seasons": 2, + "seasons": [{"season_number": 1, "episode_count": 10}, {"season_number": 2, "episode_count": 10}], + "credits": { + "cast": [ + {"id": 1, "name": "演员甲", "character": "角色甲", "profile_path": "/a.jpg", "known_for_department": "Acting"}, + {"id": 3, "name": "演员丙", "character": "角色丙", "profile_path": "/c.jpg", "known_for_department": "Acting"} + ], + "crew": [ + {"id": 2, "name": "导演乙", "job": "Director", "department": "Directing", "profile_path": null} + ] + }, + "aggregate_credits": { + "cast": [ + {"id": 1, "name": "演员甲", "roles": [{"character": "角色甲"}], "profile_path": "/a.jpg", "known_for_department": "Acting"}, + {"id": 3, "name": "演员丙", "roles": [{"character": "角色丙"}], "profile_path": "/c.jpg", "known_for_department": "Acting"} + ], + "crew": [ + {"id": 2, "name": "导演乙", "jobs": [{"job": "Director"}], "department": "Directing"} + ] + }, + "images": {"posters": [{"file_path": "/p.jpg", "width": 2000, "height": 3000}], "backdrops": []}, + "translations": {"translations": [{"iso_639_1": "zh", "data": {"name": "测试剧"}}]}, + "external_ids": {"imdb_id": "tt0000001"}, + "content_ratings": {"results": [{"iso_3166_1": "US", "rating": "TV-MA"}]}, + "recommendations": {"page": 1, "results": [{"id": 9, "name": "推荐剧"}]}, + "similar": {"page": 1, "results": [{"id": 8, "name": "相似剧"}]} + } + """; + + @Test + public void liteDetailKeepsEveryScoringInputIdentical() throws Exception { + TmdbService service = new TmdbService(); + TmdbConfig config = config(); + + JsonObject full = JsonParser.parseString(FULL).getAsJsonObject(); + JsonObject lite = JsonParser.parseString(FULL).getAsJsonObject(); + for (String key : List.of("images", "translations", "external_ids", "content_ratings", "recommendations", "similar")) { + lite.remove(key); + } + + // cast 是 scoreTmdbPeople 的第一路输入;creators 走 TextUtils.join(JVM 单测里是 Android 桩, + // 会抛 NoSuchMethodError),因此它的输入由 disambiguationAppendKeepsOnlyScoringBlocks 从 + // 请求参数层面锁定:credits.crew 与 aggregate_credits.crew 必须同时保留。 + assertEquals(describe(service.cast(full, config)), describe(service.cast(lite, config))); + + for (String text : List.of("测试剧 第一季", "测试剧 分季版", "测试剧", "别名的剧 第2季")) { + assertEquals("split-season score must not depend on stripped blocks: " + text, + TmdbMatchPolicy.splitSeasonDetailScore(text, full), + TmdbMatchPolicy.splitSeasonDetailScore(text, lite)); + } + } + + /** + * lite 请求只能保留打分需要的块:TV 必须同时带 {@code credits}(creators 的 crew 来源)与 + * {@code aggregate_credits}(cast 的优先来源),电影带 {@code credits}; + * 并且绝不能带 images/translations/recommendations/similar。 + */ + @Test + public void disambiguationAppendKeepsOnlyScoringBlocks() throws Exception { + String source = Files.readString( + Paths.get("src/main/java/com/fongmi/android/tv/service/TmdbService.java"), StandardCharsets.UTF_8); + int start = source.indexOf("private String disambiguationAppend("); + assertTrue("disambiguationAppend must exist", start >= 0); + int end = source.indexOf('}', source.indexOf("return", start)); + String body = source.substring(start, end + 1); + + assertTrue(body.contains("\"credits,aggregate_credits\"")); + assertTrue(body.contains("\"credits\"")); + for (String stripped : List.of("images", "translations", "external_ids", "content_ratings", "recommendations", "similar")) { + assertFalse("lite detail must not request " + stripped, body.contains(stripped)); + } + assertTrue("the lite append must be wired into the request", + source.contains("disambiguation ? disambiguationAppend(item) : detailAppend(item, includeRelated)")); + } + + private static List describe(List people) { + List out = new ArrayList<>(people.size()); + for (TmdbPerson person : people) { + out.add(person.getPersonId() + "|" + person.getName() + "|" + person.getSubtitle() + "|" + person.getKnownForDepartment()); + } + return out; + } + + private static TmdbConfig config() throws Exception { + TmdbConfig config = new TmdbConfig(); + for (String[] pair : new String[][]{ + {"apiBase", "https://api.tmdb.org/3"}, + {"apiKey", "test-key"}, + {"language", "zh-CN"}}) { + Field field = TmdbConfig.class.getDeclaredField(pair[0]); + field.setAccessible(true); + field.set(config, pair[1]); + } + return config.sanitize(); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceAutoRouteTest.java b/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceAutoRouteTest.java new file mode 100644 index 0000000000..ee164efe3e --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceAutoRouteTest.java @@ -0,0 +1,37 @@ +package com.fongmi.android.tv.service; + +import com.fongmi.android.tv.utils.TmdbProxy; + +import org.junit.After; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class TmdbServiceAutoRouteTest { + + @After + public void clearHealth() { + TmdbProxy.RouteSelector.clearForTest(); + } + + @Test + public void lowerLatencyRouteComesFirst() { + TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.API, TmdbProxy.OFFICIAL_API, 300); + TmdbProxy.RouteSelector.success(TmdbProxy.RouteSelector.Kind.API, TmdbProxy.ITV666, 80); + + assertEquals(List.of(TmdbProxy.ITV666, TmdbProxy.OFFICIAL_API), + TmdbProxy.RouteSelector.order(TmdbProxy.RouteSelector.Kind.API, TmdbProxy.autoApiCandidates())); + } + + @Test + public void failedRouteIsTemporarilyMovedBehindHealthyRoute() { + TmdbProxy.RouteSelector.failure(TmdbProxy.RouteSelector.Kind.API, TmdbProxy.OFFICIAL_API); + + List routes = TmdbProxy.RouteSelector.order(TmdbProxy.RouteSelector.Kind.API, TmdbProxy.autoApiCandidates()); + assertEquals(TmdbProxy.ITV666, routes.get(0)); + assertFalse(routes.isEmpty()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceCacheKeyTest.java b/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceCacheKeyTest.java index 891434ac78..dbec15a41a 100644 --- a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceCacheKeyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceCacheKeyTest.java @@ -46,6 +46,36 @@ public class TmdbServiceCacheKeyTest { assertEquals(1, service.detailCacheKeys(item, config, true).size()); } + /** + * 消歧 lite 详情必须与完整/core 详情使用不同缓存条目,否则 lite 结果会把完整详情缓存 + * 覆盖成缺 images/translations/recommendations 的残缺对象;语言差异仍要分开。 + */ + @Test + public void disambiguationCacheKeyIsIsolatedFromDetailCaches() { + TmdbService service = new TmdbService(); + TmdbItem tv = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); + TmdbItem movie = new TmdbItem(123, "movie", "庆余年", "", "", "", ""); + TmdbConfig config = config("https://api.tmdb.org/3", "test-key", "zh-CN"); + + String lite = service.disambiguationCacheKey(tv, config); + assertNotEquals(service.detailCacheKey(tv, config, true), lite); + assertNotEquals(service.detailCacheKey(tv, config, false), lite); + assertNotEquals(lite, service.disambiguationCacheKey(movie, config)); + assertEquals(lite, service.disambiguationCacheKey(tv, config("https://mirror.example.com/3", "other-key", "ZH-Hans"))); + assertNotEquals(lite, service.disambiguationCacheKey(tv, config("https://api.tmdb.org/3", "test-key", "zh-TW"))); + } + + @Test + public void cacheKeysNormalizeLanguageCaseAndAliases() { + TmdbService service = new TmdbService(); + TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); + + assertEquals(service.detailCacheKey(item, config("https://api.tmdb.org/3", "key", "ZH-Hans"), true), + service.detailCacheKey(item, config("https://api.tmdb.org/3", "key", "zh-CN"), true)); + assertNotEquals(service.detailCacheKey(item, config("https://api.tmdb.org/3", "key", "zh-CN"), true), + service.detailCacheKey(item, config("https://api.tmdb.org/3", "key", "zh-TW"), true)); + } + @Test public void seasonAndEpisodeCacheKeysIgnoreAuthAndApiBase() { TmdbService service = new TmdbService(); diff --git a/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceTranslationTest.java b/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceTranslationTest.java new file mode 100644 index 0000000000..cca630cda4 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/service/TmdbServiceTranslationTest.java @@ -0,0 +1,73 @@ +package com.fongmi.android.tv.service; + +import com.fongmi.android.tv.bean.TmdbConfig; +import com.fongmi.android.tv.bean.TmdbItem; +import com.google.gson.JsonParser; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class TmdbServiceTranslationTest { + + @Test + public void preferredTitleSelectsChineseTranslationOverEnglishPrimary() { + TmdbService service = new TmdbService(); + TmdbItem item = new TmdbItem(1, "tv", "English Title", "", "", "", ""); + String detail = """ + {"name":"English Title", + "translations":{"translations":[ + {"iso_639_1":"en","data":{"name":"English Title"}}, + {"iso_639_1":"zh","iso_3166_1":"CN","data":{"name":"中文标题"}} + ]}} + """; + + assertEquals("中文标题", service.preferredTitle(item, JsonParser.parseString(detail).getAsJsonObject(), config("zh-CN"))); + } + + @Test + public void translatedOverviewSelectsChineseTranslationOverEnglishPrimary() { + TmdbService service = new TmdbService(); + String detail = """ + {"overview":"English overview", + "translations":{"translations":[ + {"iso_639_1":"en","data":{"overview":"English overview"}}, + {"iso_639_1":"zh","iso_3166_1":"CN","data":{"overview":"中文简介"}} + ]}} + """; + + assertEquals("中文简介", service.translatedOverview(JsonParser.parseString(detail).getAsJsonObject(), config("zh-CN"))); + } + + @Test + public void episodesUseChineseEpisodeTranslation() { + TmdbService service = new TmdbService(); + String season = """ + {"episodes":[{ + "episode_number":1, + "name":"Episode 1", + "overview":"English episode", + "translations":{"translations":[ + {"iso_639_1":"en","data":{"name":"Episode 1","overview":"English episode"}}, + {"iso_639_1":"zh","iso_3166_1":"CN","data":{"name":"第一集","overview":"中文单集"}} + ]} + }]} + """; + + assertEquals(1, service.episodes(JsonParser.parseString(season).getAsJsonObject(), config("zh-CN"), 1, 1).size()); + assertEquals("第一集", service.episodes(JsonParser.parseString(season).getAsJsonObject(), config("zh-CN"), 1, 1).get(0).getTitle()); + assertEquals("中文单集", service.episodes(JsonParser.parseString(season).getAsJsonObject(), config("zh-CN"), 1, 1).get(0).getOverview()); + } + + private static TmdbConfig config(String language) { + try { + TmdbConfig config = new TmdbConfig(); + var field = TmdbConfig.class.getDeclaredField("language"); + field.setAccessible(true); + field.set(config, language); + return config; + } catch (Exception e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/AppBrandingContractTest.java b/app/src/test/java/com/fongmi/android/tv/setting/AppBrandingContractTest.java index eb8b90ef11..dc9f85b242 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/AppBrandingContractTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/AppBrandingContractTest.java @@ -181,6 +181,70 @@ public class AppBrandingContractTest { if (leanback) assertTrue(manifest.contains("android.intent.category.LEANBACK_LAUNCHER")); } + @Test + public void tvBannerIsProvidedPerDensityAtFullResolution() throws Exception { + String manifest = read("app/src/leanback/AndroidManifest.xml"); + // 两个启动器入口(application 与 current alias)都必须带横幅, + // 只给 application 会让 TV 桌面的 alias 磁贴缺图。 + assertEquals(2, countOccurrences(manifest, "android:banner=\"@mipmap/ic_banner\"")); + + // 每档按 320x180 dp 出图,即官方 xhdpi 最小尺寸 320x180 px 的 2 倍。 + // 磁贴按 dp 画,只给最小尺寸时 1080p/4K 电视只能放大位图,字标出锯齿。 + int[][] expected = {{320, 180}, {480, 270}, {640, 360}, {960, 540}, {1280, 720}}; + String[] density = {"mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"}; + for (int i = 0; i < density.length; i++) { + Path file = banner(density[i]); + assertTrue("missing banner for " + density[i], Files.exists(file)); + int[] size = pngSize(Files.readAllBytes(file)); + assertEquals("width of " + density[i], expected[i][0], size[0]); + assertEquals("height of " + density[i], expected[i][1], size[1]); + // 16:9 是 Android TV 横幅的硬性规格,比例不对会被启动器拉伸。 + assertEquals("aspect of " + density[i], 16.0 / 9.0, + (double) size[0] / size[1], 0.001); + } + } + + @Test + public void tvBannerUsesBitmapDensitiesNotAdaptiveIcon() { + Path leanback = Path.of("app/src/leanback/res"); + if (!Files.exists(leanback)) leanback = Path.of("src/leanback/res"); + + // 关键防回归:mipmap-anydpi-v26/ic_banner.xml 是一个【包含 v26 的资源 + // 配置】,它会捕获所有 API>=26 的设备而不看屏幕密度,把 manifest 对 + // @mipmap/ic_banner 的引用从密度位图上抢走。它当时的内容是 108dp + // 方形自适应图标,启动器只能自行裁切进 16:9 磁贴,得到的就是用户 + // 报的“横幅糊成一团”与锯齿。横幅只能是位图。 + assertFalse(Files.exists(leanback.resolve("mipmap-anydpi-v26/ic_banner.xml"))); + assertFalse(Files.exists(leanback.resolve("drawable/ic_banner_foreground.xml"))); + // 同理,横幅不能落在 default(drawable) 桶里:xhdpi 电视会把 320x180 + // 拉大 2 倍显示,正是这次的模糊来源。 + assertFalse(Files.exists(leanback.resolve("drawable/ic_banner.png"))); + } + + private static Path banner(String density) { + Path path = Path.of("app/src/leanback/res") + .resolve("mipmap-" + density).resolve("ic_banner.png"); + if (!Files.exists(path)) { + path = Path.of("src/leanback/res") + .resolve("mipmap-" + density).resolve("ic_banner.png"); + } + return path; + } + + /** 只解析 PNG 头部的 IHDR,避免为一次断言引入图片解码依赖。 */ + private static int[] pngSize(byte[] bytes) { + byte[] signature = {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; + for (int i = 0; i < signature.length; i++) { + assertEquals("not a PNG", signature[i], bytes[i]); + } + return new int[]{readInt(bytes, 16), readInt(bytes, 20)}; + } + + private static int readInt(byte[] bytes, int offset) { + return ((bytes[offset] & 0xFF) << 24) | ((bytes[offset + 1] & 0xFF) << 16) + | ((bytes[offset + 2] & 0xFF) << 8) | (bytes[offset + 3] & 0xFF); + } + private static String read(String relative) throws Exception { Path path = Path.of(relative); if (!Files.exists(path) && relative.startsWith("app/")) path = Path.of(relative.substring(4)); diff --git a/app/src/test/java/com/fongmi/android/tv/setting/CustomCspSettingTest.java b/app/src/test/java/com/fongmi/android/tv/setting/CustomCspSettingTest.java new file mode 100644 index 0000000000..2e3f9967af --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/setting/CustomCspSettingTest.java @@ -0,0 +1,32 @@ +package com.fongmi.android.tv.setting; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class CustomCspSettingTest { + + @Test + public void blankSearchMatchesEveryItem() { + assertTrue(CustomCspSetting.matchesSearch(null, "星河影视", "source-key", "https://example.com/api")); + assertTrue(CustomCspSetting.matchesSearch(" ", "星河影视", "source-key", "https://example.com/api")); + } + + @Test + public void searchMatchesNameKeyAndUrlsIgnoringCase() { + String[] fields = {"影视仓", "movie-source", "https://Example.com/api", "https://example.com/home"}; + + assertTrue(CustomCspSetting.matchesSearch("影视", fields)); + assertTrue(CustomCspSetting.matchesSearch("MOVIE-SOURCE", fields)); + assertTrue(CustomCspSetting.matchesSearch("example.com/api", fields)); + assertTrue(CustomCspSetting.matchesSearch("EXAMPLE.COM/HOME", fields)); + assertFalse(CustomCspSetting.matchesSearch("not-found", fields)); + } + + @Test + public void searchMatchesLiveUrlAndOtherField() { + assertTrue(CustomCspSetting.matchesSearch("live.m3u8", "新闻直播", "https://tv.example.com/live.m3u8")); + assertTrue(CustomCspSetting.matchesSearch("HEADERS", "headers", "token")); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackPolicyTest.java b/app/src/test/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackPolicyTest.java new file mode 100644 index 0000000000..07a600a2b1 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackPolicyTest.java @@ -0,0 +1,21 @@ +package com.fongmi.android.tv.setting; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class FlagSwitchPlaybackPolicyTest { + + @Test + public void disabledToggleNeverDefersPlayback() { + assertFalse(FlagSwitchPlaybackPolicy.shouldDeferPlayback(false, false)); + assertFalse(FlagSwitchPlaybackPolicy.shouldDeferPlayback(false, true)); + } + + @Test + public void enabledToggleDefersManualLineSwitchOnly() { + assertTrue(FlagSwitchPlaybackPolicy.shouldDeferPlayback(true, false)); + assertFalse(FlagSwitchPlaybackPolicy.shouldDeferPlayback(true, true)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackWiringTest.java b/app/src/test/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackWiringTest.java new file mode 100644 index 0000000000..6d9376b728 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/setting/FlagSwitchPlaybackWiringTest.java @@ -0,0 +1,156 @@ +package com.fongmi.android.tv.setting; + +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; + +/** + * 钉住「切换线路不自动播放」开关的接线不变式:存储键、抑制点、程序化例外、设置页与布局。 + * + *

只钉不变式,不钉具体实现形态:抑制必须发生在 {@code onRefresh()} 的拆流与取流之前 + * (旧流继续播,直到用户点具体集数;同时仍在途的旧取流结果必须就地作废), + * 且程序化切线路(历史恢复、自动换源、TMDB 匹配)必须继续自动起播。 + */ +public class FlagSwitchPlaybackWiringTest { + + private static final String MOBILE = "app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"; + private static final String LEANBACK = "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"; + + @Test + public void settingStoresTheToggleUnderItsOwnKey() throws Exception { + String source = read(sourcePath("main", "java").resolve(Path.of("com", "fongmi", "android", "tv", "setting", "PlayerSetting.java"))); + + assertTrue("开关必须独立存储,默认关闭以保持升级后的既有行为", + source.contains("Prefers.getBoolean(\"player_defer_flag_switch\", false)")); + assertTrue("开关必须可写", source.contains("Prefers.put(\"player_defer_flag_switch\", defer)")); + assertTrue("必须暴露读取访问器", source.contains("public static boolean isDeferFlagSwitchPlayback()")); + assertTrue("必须暴露写入访问器", source.contains("public static void putDeferFlagSwitchPlayback(boolean defer)")); + } + + @Test + public void refreshKeepsTheCurrentStreamOnlyWhileTheFlagSwitchIsArmed() throws Exception { + for (String path : new String[]{MOBILE, LEANBACK}) { + String source = read(path); + String body = methodBody(source, "private void onRefresh()"); + int guard = body.indexOf("if (mDeferFlagSwitchPlayback)"); + int fetch = body.indexOf("getPlayer(getFlag(), getEpisode());"); + int release = body.indexOf("mDeferFlagSwitchPlayback = false;", guard); + int branchEnd = body.indexOf("return;", guard); + String guardBranch = branchEnd > guard ? body.substring(guard, branchEnd) : ""; + + assertTrue(path + " 的 onRefresh 必须在拆流前检查抑制标记", guard >= 0); + assertTrue(path + " 的取流调用必须仍然存在", fetch >= 0); + assertTrue(path + " 的抑制检查必须早于取流调用", guard < fetch); + assertTrue(path + " 的抑制必须早于 stop(),否则当前正在播的视频会被停掉", + guard < body.indexOf("player().stop();")); + assertTrue(path + " 的抑制必须早于 clear(),否则当前正在播的视频会被清掉", + guard < body.indexOf("player().clear();")); + assertTrue(path + " 的抑制分支必须在早退前作废仍在途的旧取流请求,否则旧线路稍后会自动起播", + guardBranch.contains("cancelPlayerContent()")); + assertTrue(path + " 的抑制分支必须在早退前复位取流上下文,否则旧结果会被当成当前请求", + guardBranch.contains("invalidatePlayerContent()")); + assertTrue(path + " 的抑制标记必须在 onRefresh 内消费即复位,否则后续播放键恢复会被永久吞掉", + release > guard && release < fetch); + } + } + + @Test + public void manualLineSwitchArmsTheDeferralAndKeepsThePinnedRefreshCall() throws Exception { + for (String path : new String[]{MOBILE, LEANBACK}) { + String source = read(path); + String body = methodBody(source, "public void onItemClick(Flag item)"); + + assertTrue(path + " 的切线路必须咨询共享策略", + body.contains("FlagSwitchPlaybackPolicy.shouldDeferPlayback(PlayerSetting.isDeferFlagSwitchPlayback(), mProgrammaticFlagSwitch)")); + assertTrue(path + " 的切线路必须在刷新前武装抑制标记", + body.indexOf("mDeferFlagSwitchPlayback =") < body.indexOf("boolean episodeChanged = seamless(resolved);")); + assertTrue(path + " 必须在方法末尾复位抑制标记,避免泄漏到后续播放操作", + body.indexOf("mDeferFlagSwitchPlayback = false;") > body.indexOf("loadTmdbRelatedVideosForCurrentEpisode();")); + assertTrue(path + " 必须保留既有的初始绑定刷新调用", + body.contains("if (initialBinding && !episodeChanged) onRefresh();")); + } + } + + @Test + public void programmaticLineSwitchStillAutoplays() throws Exception { + for (String path : new String[]{MOBILE, LEANBACK}) { + String source = read(path); + + assertProgrammatic(source, path, "private void checkFlag(Vod item)", "onItemClick(resolveHistoryPlaybackFlag(item.getFlags()));"); + assertProgrammatic(source, path, "private void resumeHistoryAfterTmdbMatch()", "onItemClick(targetFlag);"); + assertProgrammatic(source, path, "private void nextFlag(int position)", "onItemClick(flag);"); + } + } + + @Test + public void settingPagesAndLayoutsExposeTheToggle() throws Exception { + String mobilePage = read(sourcePath("mobile", "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "fragment", "SettingPlayerFragment.java"))); + String leanbackPage = read(sourcePath("leanback", "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "activity", "SettingPlayerActivity.java"))); + for (String source : new String[]{mobilePage, leanbackPage}) { + assertTrue("设置页必须显示开关当前值", source.contains("mBinding.deferFlagSwitchText.setText(getSwitch(PlayerSetting.isDeferFlagSwitchPlayback()));")); + assertTrue("设置页必须绑定开关点击", source.contains("mBinding.deferFlagSwitch.setOnClickListener(this::setDeferFlagSwitch);")); + assertTrue("设置页必须持久化开关", source.contains("PlayerSetting.putDeferFlagSwitchPlayback(!PlayerSetting.isDeferFlagSwitchPlayback());")); + } + + String mobileLayout = read(sourcePath("mobile", "res").resolve(Path.of("layout", "fragment_setting_player.xml"))); + String leanbackLayout = read(sourcePath("leanback", "res").resolve(Path.of("layout", "activity_setting_player.xml"))); + for (String layout : new String[]{mobileLayout, leanbackLayout}) { + assertTrue("布局必须提供开关行", layout.contains("@+id/deferFlagSwitch")); + assertTrue("布局必须提供开关取值文本", layout.contains("@+id/deferFlagSwitchText")); + assertTrue("布局必须引用开关文案", layout.contains("@string/player_defer_flag_switch")); + } + } + + @Test + public void everyLocaleCarriesTheToggleLabel() throws Exception { + for (String locale : new String[]{"values", "values-zh-rCN", "values-zh-rTW"}) { + String strings = read(sourcePath("main", "res").resolve(Path.of(locale, "strings.xml"))); + assertTrue(locale + " 缺少开关文案", strings.contains("")); + } + } + + private static void assertProgrammatic(String source, String path, String method, String click) { + String body = methodBody(source, method); + int call = body.indexOf(click); + assertTrue(path + " 的 " + method + " 必须仍调用 " + click, call >= 0); + int arm = body.lastIndexOf("mProgrammaticFlagSwitch = true;", call); + int reset = body.indexOf("mProgrammaticFlagSwitch = false;", call); + assertTrue(path + " 的 " + method + " 必须在调用前置位程序化标记", arm >= 0); + assertTrue(path + " 的 " + method + " 必须在调用后复位程序化标记", reset > call); + } + + private static String methodBody(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("missing method " + signature, start >= 0); + int open = source.indexOf('{', start); + int depth = 0; + for (int i = open; i < source.length(); i++) { + char c = source.charAt(i); + if (c == '{') depth++; + else if (c == '}' && --depth == 0) return source.substring(start, i + 1); + } + throw new AssertionError("unbalanced method " + signature); + } + + private static Path sourcePath(String sourceSet, String kind) { + return root().resolve(Path.of("app", "src", sourceSet, kind)); + } + + /** Gradle 单测的工作目录可能是仓库根或 app/,两种都要能解析。 */ + private static Path root() { + return Files.exists(Path.of("app", "src", "main")) ? Path.of(".") : Path.of(".."); + } + + private static String read(String path) throws Exception { + return read(root().resolve(path)); + } + + private static String read(Path path) throws Exception { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/FullscreenMenuKeyTest.java b/app/src/test/java/com/fongmi/android/tv/setting/FullscreenMenuKeyTest.java new file mode 100644 index 0000000000..6fe2c7fe6b --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/setting/FullscreenMenuKeyTest.java @@ -0,0 +1,109 @@ +package com.fongmi.android.tv.setting; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * 「全屏菜单键」新增「下一集」取值的源契约回归:三语言数组必须同长, + * 播放页与内嵌播放器都必须把新取值接到既有的下一集链路,存量值 0/1 语义不变。 + */ +public class FullscreenMenuKeyTest { + + @Test + public void everyLocaleOffersTheSameThreeOptions() throws Exception { + String[] locales = {"values", "values-zh-rCN", "values-zh-rTW"}; + String[] expected = {"Next episode", "下一集", "下一集"}; + for (int i = 0; i < locales.length; i++) { + String block = array(read(res(locales[i], "strings.xml")), "select_fullscreen_menu_key"); + assertEquals(locales[i] + " must offer exactly 3 fullscreen menu key options", + 3, count(block, "")); + assertTrue(locales[i] + " must keep the new option last so stored values 0/1 keep their meaning", + block.contains("" + expected[i] + "")); + } + } + + @Test + public void storedValueIsClampedToKnownOptions() throws Exception { + String setting = read(main("java", "com", "fongmi", "android", "tv", "setting", "Setting.java")); + + assertTrue(setting.contains("public static final int FULLSCREEN_MENU_KEY_CONTROL = 0;")); + assertTrue(setting.contains("public static final int FULLSCREEN_MENU_KEY_EPISODES = 1;")); + assertTrue(setting.contains("public static final int FULLSCREEN_MENU_KEY_NEXT_EPISODE = 2;")); + assertTrue("an unknown stored value must fall back to the control bar instead of indexing past the array", + setting.contains("menuKey > FULLSCREEN_MENU_KEY_NEXT_EPISODE ? FULLSCREEN_MENU_KEY_CONTROL : menuKey")); + } + + @Test + public void leanbackMenuKeyAdvancesThroughTheExistingNextEpisodePath() throws Exception { + String source = read(leanback("java", "com", "fongmi", "android", "tv", "ui", "activity", "VideoActivity.java")); + + String branch = window(source, "if (isFullscreen() && KeyUtil.isMenuKey(event)) {", 500); + assertTrue("the menu key must dispatch the new option", branch.contains("FULLSCREEN_MENU_KEY_NEXT_EPISODE")); + assertTrue("the new option must use its own entry point", branch.contains("onMenuNextEpisode()")); + assertTrue("the existing options must keep working", branch.contains("FULLSCREEN_MENU_KEY_EPISODES) onEpisodes()") + && branch.contains("else onToggle();")); + + String helper = window(source, "private void onMenuNextEpisode() {", 500); + assertTrue("advancing must reuse the control-bar/auto-advance path", helper.contains("checkNext();")); + assertTrue("a detail-less/fast playback page must not crash on a null flag", helper.contains("flag == null")); + assertTrue("an empty episode list must not fake a next episode", helper.contains("flag.getEpisodes().isEmpty()")); + assertFalse("advancing must go through checkNext() so reverse play still picks the previous index", helper.contains("onNext(")); + } + + @Test + public void inlinePlayerHonoursTheSameOption() throws Exception { + String source = read(main("java", "com", "fongmi", "android", "tv", "ui", "activity", "TmdbDetailActivity.java")); + + String branch = window(source, + "if (KeyUtil.isMenuKey(event) && (inlineFullscreen || isInlineControlsVisible())) {", 600); + assertTrue("the inline player must dispatch the new option", branch.contains("FULLSCREEN_MENU_KEY_NEXT_EPISODE")); + assertTrue("the inline player must reuse its null-safe next-episode path", branch.contains("checkInlineNext();")); + } + + private static String array(String strings, String name) { + String start = ""; + int from = strings.indexOf(start); + int to = strings.indexOf("", from); + assertTrue(name + " must stay a string-array", from >= 0 && to > from); + return strings.substring(from, to); + } + + private static int count(String source, String needle) { + int total = 0; + for (int at = source.indexOf(needle); at >= 0; at = source.indexOf(needle, at + 1)) total++; + return total; + } + + private static String window(String source, String marker, int size) { + int from = source.indexOf(marker); + assertTrue("missing source marker: " + marker, from >= 0); + return source.substring(from, Math.min(source.length(), from + size)); + } + + private static Path res(String values, String name) { + return root().resolve(Path.of("src", "main", "res", values, name)); + } + + private static Path main(String... rest) { + return root().resolve(Path.of("src", "main", String.join("/", rest))); + } + + private static Path leanback(String... rest) { + return root().resolve(Path.of("src", "leanback", String.join("/", rest))); + } + + private static String read(Path path) throws Exception { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + + private static Path root() { + return Files.exists(Path.of("src", "main", "java")) ? Path.of(".") : Path.of("app"); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/setting/MpvOutputModePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/setting/MpvOutputModePolicyTest.java index 708e46cb04..f3fb0c626b 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/MpvOutputModePolicyTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/MpvOutputModePolicyTest.java @@ -25,10 +25,20 @@ public class MpvOutputModePolicyTest { } @Test - public void autoModeStaysOnGpuDuringSurfaceStabilityGuard() { - assertFalse(MpvPerformanceSetting.resolveSurfaceDirect(MpvPerformanceSetting.OUTPUT_AUTO, true, true, true, false)); + public void autoModeEntersSurfaceDirectOnEligibleTv() { + assertTrue(MpvPerformanceSetting.resolveSurfaceDirect(MpvPerformanceSetting.OUTPUT_AUTO, true, true, true, false)); + } + + @Test + public void autoModeStillRefusesWhenAutoEligibilityOrTvOrHardwareIsMissing() { assertFalse(MpvPerformanceSetting.resolveSurfaceDirect(MpvPerformanceSetting.OUTPUT_AUTO, false, true, true, false)); assertFalse(MpvPerformanceSetting.resolveSurfaceDirect(MpvPerformanceSetting.OUTPUT_AUTO, true, false, true, false)); + assertFalse(MpvPerformanceSetting.resolveSurfaceDirect(MpvPerformanceSetting.OUTPUT_AUTO, true, true, false, false)); + } + + @Test + public void zeroCopyDeviceGuardStillBlocksAutoMode() { + assertFalse(MpvPerformanceSetting.resolveSurfaceDirect(MpvPerformanceSetting.OUTPUT_AUTO, true, true, true, true)); } @Test diff --git a/app/src/test/java/com/fongmi/android/tv/setting/PlaybackPerformanceSettingSourceTest.java b/app/src/test/java/com/fongmi/android/tv/setting/PlaybackPerformanceSettingSourceTest.java index 521ee2d81d..105cf6128c 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/PlaybackPerformanceSettingSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/PlaybackPerformanceSettingSourceTest.java @@ -110,6 +110,99 @@ public class PlaybackPerformanceSettingSourceTest { assertFalse(buildPlayer.contains("buildLoadControl()")); } + @Test + public void lateDropThresholdSwitchCanActuallyDisableDropping() throws Exception { + // media3 defaults lateThresholdToDropDecoderInputUs to 15 ms, which is exactly the value + // this build passes when the switch is on. If the wiring skipped the setter when the + // switch is off, the default would keep dropping input frames and the switch would lie. + String util = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoUtil.java")); + String renderer = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoRuntimeAwareVideoRenderer.java")); + + assertContainsAll(util, + "ENHANCED_LATE_THRESHOLD_TO_DROP_INPUT_US = 15_000L", + "PlaybackPerformanceSetting.isLateDropInputEnabled()", + "C.TIME_UNSET"); + // Both renderer paths must read the one shared accessor so they cannot drift apart. + assertContainsAll(renderer, "ExoUtil.effectiveLateDropThresholdUs()"); + assertDoesNotContainAny(renderer, "isLateDropInputEnabled()"); + } + + @Test + public void frameSchedulingConfigurationReachesTheDiagnosticLog() throws Exception { + // A dropped-frame report is unattributable while exported logs carry no configuration, + // so the player build must emit one line naming what actually reached the factory. + String util = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoUtil.java")); + String diagnostics = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoPlaybackDiagnostics.java")); + String settings = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoFrameSchedulingRendererSettings.java")); + + assertContainsAll(util, + "long lateDropThresholdUs = effectiveLateDropThresholdUs();", + "ExoPlaybackDiagnostics.logFrameScheduling(", + "frameSchedulingSettings.codecQueueModeLabel()", + "frameSchedulingSettings.dynamicSchedulingEnabled()"); + assertContainsAll(diagnostics, "static void logFrameScheduling(", "ExoFrameSchedulingRendererSettings.describe("); + assertContainsAll(settings, + "static String describe(", + "durationToProgress=", + "earlySchedulingUs=", + "lateDropInputUs=", + "\"unset\"", + "metricScope=settings-applied-only"); + } + + @Test + public void inputSideDropCountersReachTheDiagnosticLog() throws Exception { + // The 「输入丢帧阈值」 switch drops decoder input frames before they are decoded. Reporting + // only the output counters let that happen invisibly, which is the one question a + // dropped-frame report has to answer. + String collector = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoDiagnosticCollector.java")); + + assertContainsAll(collector, + "droppedInputBufferCount", + "skippedInputBufferCount", + "droppedToKeyframeCount", + "droppedInputBuffers are pre-decoder drops"); + } + + @Test + public void frameLatenessHistogramDoesNotRequireExperimentEnrollment() throws Exception { + // The per-frame lateness distribution used to be collected only for devices enrolled in + // the frame-scheduling A/B experiment, so an ordinary report could not say how late frames + // were. Diagnostics-on must be enough, and stop-only delivery must not be the only trigger: + // media3 reports the offset from onStopped() alone. + String listener = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "PlaybackAnalyticsListener.java")); + String collector = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "exo", "ExoDiagnosticCollector.java")); + + assertContainsAll(listener, + "frameSchedulingExperimentActive || SpiderDebug.isEnabled()", + "static void logFrameTimingIfDue()", + "\"exo-frame-timing\"", + "FRAME_TIMING_LOG_INTERVAL_MS"); + assertContainsAll(collector, "PlaybackAnalyticsListener.logFrameTimingIfDue();"); + } + + @Test + public void telemetryNamesTheRenderedFpsScope() throws Exception { + // 58.824 in the telemetry line is a presentation-timestamp cadence, not rendered + // throughput; a stuttering 60 fps stream still reports it. Name the scope in the line. + String mapper = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "player", "PlaybackTelemetryMapper.java")); + + assertContainsAll(mapper, "renderedFpsScope=pts-cadence-estimate;not-throughput"); + } + + @Test + public void durationProgressKeepsUpstreamDefaultUntilMigrated() throws Exception { + String source = read(sourcePath("main", "java", "com", "fongmi", "android", "tv", "setting", "PlaybackPerformanceSetting.java")); + String getter = methodBody(source, "public static boolean isVideoDurationProgressEnabled()", "public static void putVideoDurationProgressEnabled"); + String recommended = methodBody(source, "private static void putRecommendedFlags()", "private static int clampProfile(int profile)"); + String init = methodBody(source, "public static void ensureInitialized()", "public static int getProfile()"); + + assertTrue(getter.contains("Prefers.getBoolean(KEY_VIDEO_DURATION_PROGRESS, false)")); + assertFalse(recommended.contains("put(KEY_VIDEO_DURATION_PROGRESS, true)")); + assertTrue(recommended.contains("put(KEY_VIDEO_DURATION_PROGRESS, false)")); + assertTrue(init.contains("migrateExoFrameSchedulingParity();")); + } + private static void assertContainsAll(String source, String... values) { for (String value : values) assertTrue("Missing: " + value, source.contains(value)); } diff --git a/app/src/test/java/com/fongmi/android/tv/setting/PlayerDisplaySettingSyncTest.java b/app/src/test/java/com/fongmi/android/tv/setting/PlayerDisplaySettingSyncTest.java index 0eb1c063f6..92d7460fbe 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/PlayerDisplaySettingSyncTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/PlayerDisplaySettingSyncTest.java @@ -24,6 +24,20 @@ public class PlayerDisplaySettingSyncTest { assertPlayerOsdArrayIncludesAllDisplayOptions(read(mainResPath().resolve(Path.of("values-zh-rTW", "strings.xml")))); } + @Test + public void tvScreenDisplayDialogRowsUseTheThemeAwareContrastWiring() throws Exception { + for (String flavor : new String[]{"leanback", "mobile"}) { + String layout = read(sourcePath(flavor, "res").resolve(Path.of("layout", "adapter_player_osd.xml"))); + String dialog = read(sourcePath(flavor, "java").resolve(Path.of("com", "fongmi", "android", "tv", "ui", "dialog", "PlayerOsdDialog.java"))); + assertTrue(flavor + " 屏显行必须使用主题化行背景", layout.contains("@drawable/selector_player_settings_row")); + assertTrue(flavor + " 屏显眼睛按钮必须使用主题化按钮背景", layout.contains("@drawable/selector_player_settings_switch")); + assertTrue(flavor + " 屏显眼睛图标必须使用主题化前景", layout.contains("@color/player_settings_icon_tint")); + assertFalse(flavor + " 屏显行不得回退到固定浅色卡片", layout.contains("selector_git_cloud_card")); + assertTrue(flavor + " 屏显对话框必须应用活动主题行颜色", dialog.contains("PlayerSettingsRowTheme.apply(holder.binding.root, holder.binding.name")); + assertTrue(flavor + " 屏显对话框必须应用活动主题图标颜色", dialog.contains("PlayerSettingsRowTheme.applySwitch(holder.binding.visible")); + } + } + @Test public void playerOsdControllerHonorsResolutionDisplayToggle() throws Exception { String source = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "custom", "PlayerOsdController.java"))); @@ -63,6 +77,32 @@ public class PlayerDisplaySettingSyncTest { && source.indexOf("setDiagnosticsPanel(player);", render) > render); } + /** + * 诊断面板的重缓冲计数必须按「有没有 Exo analytics 快照」分派,而不是按是否 MPV。 + * + *

非 Exo 时 {@code snapshot} 是 {@code Snapshot.empty()},重缓冲恒为 0;若只对 MPV 分支, + * IJK 会落到快照分支而永远读到 0,排查时会被误导。 + */ + @Test + public void playerOsdDiagnosticsReadRebufferCountPerEngine() throws Exception { + String source = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "custom", "PlayerOsdController.java"))); + + int count = source.indexOf("int rebufferCount ="); + int total = source.indexOf("long rebufferTotalMs ="); + assertTrue("the diagnostics must compute a re-buffer count and total", count >= 0 && total > count); + String countExpr = source.substring(count, source.indexOf(';', count)); + String totalExpr = source.substring(total, source.indexOf(';', total)); + + assertTrue("re-buffer count must read the engine-agnostic tracker for every non-Exo engine, not only MPV", + countExpr.contains("player.isExo()") + && countExpr.contains("snapshot.rebufferCount()") + && countExpr.contains("player.getRebufferCount()")); + assertTrue("re-buffer total must read the engine-agnostic tracker for every non-Exo engine, not only MPV", + totalExpr.contains("player.isExo()") + && totalExpr.contains("snapshot.rebufferTotalMs()") + && totalExpr.contains("player.getRebufferTotalMs()")); + } + @Test public void backupIncludesPlaybackDisplayPreferences() throws Exception { String source = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "bean", "Backup.java"))); diff --git a/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackDefaultsTest.java b/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackDefaultsTest.java index 3056243143..7abb7d4705 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackDefaultsTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackDefaultsTest.java @@ -170,8 +170,6 @@ public class SettingPlaybackDefaultsTest { String leanbackPlayer = read(root.resolve(Path.of("src", "leanback", "res", "layout", "activity_setting_player.xml"))); for (String id : new String[]{"adblock", "aiAdDetection", "adRuleManage", "adBlockStats", - "adAudioFingerprint", "adAudioAutoSkip", "probeRuleSource", "probeRuleRefresh", - "speechAdEnabled", "speechAdKeywords", "speechAdSkipSeconds", "speechAdSkipMode", "autoSkipIntroOutro"}) { assertTrue(id, mobileAd.contains("@+id/" + id)); assertTrue(id, leanbackAd.contains("@+id/" + id)); diff --git a/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackOverlayTest.java b/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackOverlayTest.java index 73d441fcd1..98bb7613c9 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackOverlayTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/SettingPlaybackOverlayTest.java @@ -64,12 +64,14 @@ public class SettingPlaybackOverlayTest { private static void assertLeanbackPlayerAppliesOverlay(String source) { assertTrue(source.contains("applyPlaybackOverlay();")); - assertTrue(source.contains("mBinding.control.getRoot().setBackgroundResource(Setting.isPlaybackOverlayEnabled() ? R.drawable.shape_controller_scrim : R.color.transparent)")); + assertTrue(source.contains("mBinding.control.getRoot().setBackground(PlaybackMenuScrim.create(this))")); + assertTrue(source.contains("mBinding.control.getRoot().setBackgroundResource(R.color.transparent)")); } private static void assertPlayerAppliesOverlay(String source) { assertTrue(source.contains("mBinding.control.getRoot().setBackgroundResource(R.color.transparent)")); - assertTrue(source.contains("mBinding.control.bottom.setBackgroundResource(Setting.isPlaybackOverlayEnabled() ? R.drawable.shape_controller_scrim : R.color.transparent)")); + assertTrue(source.contains("mBinding.control.bottom.setBackground(PlaybackMenuScrim.create(this))")); + assertTrue(source.contains("mBinding.control.bottom.setBackgroundResource(R.color.transparent)")); } private static String read(Path path) throws Exception { diff --git a/app/src/test/java/com/fongmi/android/tv/setting/SiteHealthReportSourceTest.java b/app/src/test/java/com/fongmi/android/tv/setting/SiteHealthReportSourceTest.java index 0341e1bc26..a38ffe6a3a 100644 --- a/app/src/test/java/com/fongmi/android/tv/setting/SiteHealthReportSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/setting/SiteHealthReportSourceTest.java @@ -96,7 +96,7 @@ public class SiteHealthReportSourceTest { assertTrue(statsStoreSource.contains("hls.legacy-fallback")); assertTrue(statsStoreSource.contains("内置兜底规则")); String clearConfirmationBody = methodBody(reportSource, "private void showClearConfirmation("); - assertTrue(clearConfirmationBody.contains("R.style.Theme_WebHTV_LightDialog")); + assertTrue(clearConfirmationBody.contains("R.style.Theme_WebHTV_Dialog")); assertTrue(clearConfirmationBody.contains("LightDialog.apply(dialog)")); String refreshBody = methodBody(reportSource, "private void refreshReport()"); assertTrue(refreshBody.indexOf("report = SiteHealthStore.report()") < refreshBody.indexOf("render()")); @@ -138,8 +138,11 @@ public class SiteHealthReportSourceTest { String dialog = read(mainJavaPath().resolve(Path.of("com", "fongmi", "android", "tv", "ui", "dialog", "SiteHealthReportDialog.java"))); assertTrue(dialog.contains("int margin = ResUtil.dp2px(ResUtil.isLand(requireContext()) ? 24 : 16);")); - assertTrue(dialog.contains("ResUtil.getScreenWidth(requireContext()) - margin * 2")); - assertTrue(dialog.contains("ResUtil.getScreenHeight(requireContext()) - margin * 2")); + // The window is sized from the system-bar-safe area, not from the raw display: sizing from + // the raw display put this dialog's cancel button under the navigation bar on phones. + assertTrue(dialog.contains("DialogSafeArea.area(requireActivity(), window)")); + assertTrue(dialog.contains("ThemeDialogLayout.width(area.width(), margin)")); + assertTrue(dialog.contains("ThemeDialogLayout.height(area.height(), margin)")); assertTrue(dialog.contains("window.getDecorView().setPadding(0, 0, 0, 0)")); assertTrue(!dialog.contains("ResUtil.getScreenWidth(requireContext()) * (ResUtil.isLand(requireContext()) ? 0.62f : 0.94f)")); assertTrue(!dialog.contains("ResUtil.getScreenHeight(requireContext()) * (ResUtil.isLand(requireContext()) ? 0.78f : 0.82f)")); diff --git a/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizerTest.java b/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizerTest.java index deb0130c19..7092ecc7be 100644 --- a/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizerTest.java +++ b/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleRecognizerTest.java @@ -37,13 +37,6 @@ public class RealtimeSubtitleRecognizerTest { assertEquals(35_200, RealtimeSubtitleRecognizer.offlineFlushSamples(RealtimeSubtitleModelCatalog.find("yue"))); assertEquals(35_200, RealtimeSubtitleRecognizer.offlineFlushSamples(RealtimeSubtitleModelCatalog.find("ja"))); } - @Test - public void adProfileHasOneThreadAndSubtitleKeepsItsPreviousBudget() { - assertEquals(1, RealtimeSubtitleRecognizer.threadCount(SpeechRecognitionFactory.ExecutionProfile.AD_AUDIO)); - assertEquals(Math.max(1, Math.min(4, Runtime.getRuntime().availableProcessors() / 2)), - RealtimeSubtitleRecognizer.threadCount(SpeechRecognitionFactory.ExecutionProfile.SUBTITLE)); - } - @Test(timeout = 10_000) public void interruptedReleaseWaitsForTheActualDecodeWorkerToExit() throws Exception { ExecutorService decode = Executors.newSingleThreadExecutor(); diff --git a/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactoryTest.java b/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactoryTest.java deleted file mode 100644 index 491bc5005a..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/subtitle/RealtimeSubtitleSpeechRecognitionFactoryTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.fongmi.android.tv.subtitle; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -public class RealtimeSubtitleSpeechRecognitionFactoryTest { - - @Rule - public TemporaryFolder temporary = new TemporaryFolder(); - - @Test - public void missingModelIsReportedWithoutCreatingRecognizer() throws Exception { - RealtimeSubtitleSpeechRecognitionFactory factory = - RealtimeSubtitleSpeechRecognitionFactory.forRoot(temporary.newFolder()); - - assertFalse(factory.isReady()); - } - - @Test - public void contractExposesResetAcceptAndClose() { - assertNotNull(SpeechRecognitionFactory.Session.class); - assertNotNull(SpeechRecognitionFactory.Listener.class); - } -} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/theme/AppearanceRowThemeTest.java b/app/src/test/java/com/fongmi/android/tv/theme/AppearanceRowThemeTest.java new file mode 100644 index 0000000000..44c306eb0d --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/AppearanceRowThemeTest.java @@ -0,0 +1,196 @@ +package com.fongmi.android.tv.theme; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +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; + +/** + * The “Appearance & language” row must be readable on every shipped palette and + * must follow a user theme profile. + * + *

Regression under test (measured on device, {@code 192.168.50.3:5559}): the row kept + * the fixed light {@code selector_git_cloud_card} fill {@code #F8F9FA} while its text came + * from {@link ThemeController#current()}. The TV flavour compiles the dark token table, so + * the label rendered {@code #E2E2E9} at 1.16:1 and the value {@code #C4C6D0} at + * 1.36:1 - the reported "text is unreadable" in the TV appearance dialog. + * + *

These assertions are pure arithmetic on the shipped token tables plus source + * contracts, so they fail on the old wiring and pass on the fix without an Android device. + */ +public class AppearanceRowThemeTest { + + /** The role {@code AppearanceRowTheme} paints the resting row with. */ + private static int restingFill(ThemeTokens tokens) { + return tokens.colorSurfaceContainerHigh(); + } + + @Test + public void restingRowClearsWcagAaForBothTextRolesOnEveryShippedPalette() { + assertRowIsReadable(ThemeTokens.dark(), "dark"); + assertRowIsReadable(ThemeTokens.light(), "light"); + } + + private static void assertRowIsReadable(ThemeTokens tokens, String name) { + int fill = restingFill(tokens); + assertTrue(name + " label on the resting row", + ThemeContrast.ratio(tokens.colorOnSurface(), fill) >= 4.5); + assertTrue(name + " value on the resting row", + ThemeContrast.ratio(tokens.colorOnSurfaceVariant(), fill) >= 4.5); + // The row must not collapse into the dialog panel either, otherwise the inset list + // reads as one undifferentiated block. + assertNotEquals(name + " row fill must be distinguishable from the panel", + tokens.colorSurfaceContainerHigh(), tokens.colorSurfaceContainer()); + } + + @Test + public void focusedRowKeepsItsOwnTextReadableAndShowsARing() { + for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.dark(), ThemeTokens.light()}) { + // Focused rows keep their on-surface text (the row text is not restyled on + // focus), so the focus fill must still clear AA against that text. + assertTrue("focused row text on primaryContainer", + ThemeContrast.ratio(tokens.colorOnSurface(), tokens.colorPrimaryContainer()) >= 4.5); + // The focus indicator itself must be visible against both the row fill and the + // surrounding panel (WCAG non-text contrast floor of 3:1). + assertTrue("focus ring on the focused fill", + ThemeContrast.ratio(tokens.colorPrimary(), tokens.colorPrimaryContainer()) >= 3.0); + assertTrue("focus ring on the dialog panel", + ThemeContrast.ratio(tokens.colorPrimary(), tokens.colorSurfaceContainerHigh()) >= 3.0); + } + } + + /** + * The old fixed fill is exactly what the device screenshot measured, so this pins the + * regression numerically rather than only by source inspection. + */ + @Test + public void theRemovedFixedFillIsExactlyTheMeasuredUnreadableOne() { + int legacyFill = 0xFFF8F9FA; + assertEquals("the fixed selector fill the row used to paint", legacyFill, 0xFFF8F9FA); + assertTrue("label was unreadable on it", + ThemeContrast.ratio(ThemeTokens.dark().colorOnSurface(), legacyFill) < 2.0); + assertTrue("value was unreadable on it", + ThemeContrast.ratio(ThemeTokens.dark().colorOnSurfaceVariant(), legacyFill) < 2.0); + assertTrue("the fix must clear AA where the old fill did not", + ThemeContrast.ratio(ThemeTokens.dark().colorOnSurface(), restingFill(ThemeTokens.dark())) >= 4.5); + } + + @Test + public void bothFlavoursRouteTheRowThroughTheSharedPalette() throws Exception { + for (String flavour : new String[]{"mobile", "leanback"}) { + String dialog = codeOnly(read("src/" + flavour + "/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java")); + assertTrue(flavour + " must paint the row from the active palette", + dialog.contains("AppearanceRowTheme.apply(row, title, summary, ThemeController.current());")); + assertFalse(flavour + " must not keep the fixed light row selector", + dialog.contains("selector_git_cloud_card")); + assertFalse(flavour + " must not colour row text inline any more", + dialog.contains("setTextColor(ThemeController.current()")); + // A profile applied while the dialog is open must reach the rows. + assertTrue(flavour + " must re-apply the palette on show", + dialog.contains("applyRowTheme();")); + } + } + + @Test + public void theSharedRowRendererUsesOnlySemanticRoles() throws Exception { + String source = codeOnly(read("src/main/java/com/fongmi/android/tv/theme/AppearanceRowTheme.java")); + assertTrue(source.contains("safe.colorSurfaceContainerHigh()")); + assertTrue(source.contains("safe.colorOnSurface()")); + assertTrue(source.contains("safe.colorOnSurfaceVariant()")); + assertTrue(source.contains("safe.colorPrimaryContainer()")); + assertTrue(source.contains("safe.colorPrimary()")); + assertTrue(source.contains("safe.colorOutlineVariant()")); + // No fixed colours may creep back in. + assertFalse("the row renderer must not hard-code a fill", source.contains("#F8F9FA")); + assertFalse(source.contains("#DADCE0")); + assertFalse(source.contains("#E8F0FE")); + } + + @Test + public void theRowOwnsThePaletteItIsPaintedWith() throws Exception { + // The regression was a mismatch between a fixed row fill and token-driven text. + // Guard the invariant directly: every colour the row paints comes from the same + // ThemeTokens instance the row text is set from. + String source = codeOnly(read("src/main/java/com/fongmi/android/tv/theme/AppearanceRowTheme.java")); + assertTrue(source.contains("public static void apply(View row, TextView title, TextView summary, ThemeTokens tokens)")); + int apply = source.indexOf("public static void apply("); + String body = source.substring(apply, source.indexOf("private static GradientDrawable shape(")); + assertTrue("the row fill comes from the passed tokens", body.contains("background(row.getContext(), safe)")); + assertTrue("the title colour comes from the same tokens", body.contains("title.setTextColor(safe.colorOnSurface())")); + assertTrue("the value colour comes from the same tokens", body.contains("summary.setTextColor(safe.colorOnSurfaceVariant())")); + } + + /** + * The picker opened from these rows paints its title on the same dialog panel, so it + * had the identical defect: a fixed {@code #202124} title on the dark panel measured + * 1.00:1 on device. It must use the active palette too. + */ + @Test + public void thePickerOpenedFromTheseRowsAlsoUsesTheActivePalette() throws Exception { + String dialog = codeOnly(read("src/main/java/com/fongmi/android/tv/ui/dialog/ChoiceDialog.java")); + assertTrue(dialog.contains("titleView.setTextColor(ThemeController.current().colorOnSurface())")); + assertTrue(dialog.contains("messageView.setTextColor(ThemeController.current().colorOnSurfaceVariant())")); + assertTrue(dialog.contains("import com.fongmi.android.tv.theme.ThemeController;")); + assertTrue(dialog.contains("ThemeController.bindDialog(dialog)")); + // The title/message sit directly on the dialog panel, which is the part that was + // invisible. Item rows keep their own opaque card, so their colours are a separate + // concern and stay untouched here. + int title = dialog.indexOf("titleView.setTextColor("); + int message = dialog.indexOf("messageView.setTextColor("); + assertTrue(title > 0 && message > 0); + assertFalse("the panel-drawn title must not be a fixed colour", + dialog.substring(title, dialog.indexOf(";", title)).contains("parseColor")); + assertFalse("the panel-drawn message must not be a fixed colour", + dialog.substring(message, dialog.indexOf(";", message)).contains("parseColor")); + } + + @Test + public void choiceItemsUseTheActivePaletteInBothModes() throws Exception { + String dialog = codeOnly(read("src/main/java/com/fongmi/android/tv/ui/dialog/ChoiceDialog.java")); + assertTrue(dialog.contains("ThemeTokens tokens = ThemeController.current()")); + assertTrue(dialog.contains("tokens.colorSurfaceContainerHigh()")); + assertTrue(dialog.contains("tokens.colorOnPrimaryContainer()")); + assertTrue(dialog.contains("tokens.colorSurfaceContainer()")); + assertTrue(dialog.contains("tokens.colorOnSurface()")); + assertTrue(dialog.contains("ThemeEditorUi.shape(requireContext(), tokens.colorSurfaceContainerHigh(), 0, 0, 22)")); + assertFalse(dialog.contains("#9AA0A6")); + assertFalse(dialog.contains("#F1F3F4")); + assertFalse(dialog.contains("#202124")); + assertFalse(dialog.contains("#1A73E8")); + } + + /** + * Strips comments so a source contract matches executable code, not the explanatory + * prose that names the very colours and selectors the contract forbids. + */ + private static String codeOnly(String source) { + StringBuilder out = new StringBuilder(source.length()); + int index = 0; + while (index < source.length()) { + char current = source.charAt(index); + char next = index + 1 < source.length() ? source.charAt(index + 1) : '\0'; + if (current == '/' && next == '/') { + while (index < source.length() && source.charAt(index) != '\n') index++; + } else if (current == '/' && next == '*') { + index += 2; + while (index + 1 < source.length() && !(source.charAt(index) == '*' && source.charAt(index + 1) == '/')) index++; + index = Math.min(source.length(), index + 2); + } else { + out.append(current); + index++; + } + } + return out.toString(); + } + + private static String read(String path) throws Exception { + Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + return Files.readString(root.resolve(path), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/DialogForegroundTokenTest.java b/app/src/test/java/com/fongmi/android/tv/theme/DialogForegroundTokenTest.java new file mode 100644 index 0000000000..f497353b2f --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/DialogForegroundTokenTest.java @@ -0,0 +1,401 @@ +package com.fongmi.android.tv.theme; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Java-built dialogs must not pin their foreground to a fixed colour. + * + *

The dialogs below draw onto {@code shape_shell_proxy_dialog}, whose fill is + * {@code ?attr/colorSurfaceContainerHigh} - light in the day table + * ({@code #E7E8EF}) and dark in the night table ({@code #2A2F34}). A hard-coded + * foreground can therefore only be right in one of the two modes. + * + *

This is the Java-side twin of the layout guard in + * {@link ThemeBaseWiringTest#darkGlassSheetsUseAConstantLightForeground}. Layouts pin + * their colour in XML and can be checked by reading the file; these dialogs build their + * views in code and assign the colour at runtime, so the check is on the assignment + * source instead. The two guards cover the two ways a fixed colour gets in. + * + *

Measured on the day/night panel pair, the pinned literals these classes used were: + * {@code #5F6368} 4.95:1 day but 2.23:1 night, {@code #202124} 13.18:1 day but 1.19:1 + * night. The semantic replacements resolve to 5.23:1 or better in both tables. + */ +public class DialogForegroundTokenTest { + + /** The dialogs this guard was originally opened for. */ + private static final String[] BASE_DIALOG_SOURCES = { + "DebugLogDialog.java", + "MpvConfigDialog.java", + "PlaybackPerformanceDialog.java", + }; + + /** + * The viewing-record sync family: the top-level dialog and its two second-level managers. + * + *

They build their rows in code, so they are covered by the same foreground contract as + * the dialogs above, plus the per-method row/badge surface contract below. + */ + private static final String[] VIEWING_RECORD_SYNC_SOURCES = { + "ViewingRecordSyncDialog.java", + "PlaybackRemoteSyncDialog.java", + "PlaybackWebhookDialog.java", + }; + + /** Java-built dialogs that paint onto the palette-following dialog panel. */ + private static final String[] DIALOG_SOURCES = concat(BASE_DIALOG_SOURCES, VIEWING_RECORD_SYNC_SOURCES); + + /** + * Fixed foregrounds that must not appear: the legacy Material greys and blues these + * dialogs used to pin, plus the plain white/black literals that would equally break + * the other mode. + */ + private static final String[] FORBIDDEN_LITERALS = { + "#5F6368", "#202124", "#3C4043", "#174EA6", "#1A73E8", + "#8AB4F8", "#E8F0FE", "#C4C7C5", "#C5221F", + }; + + /** + * The only colour resources a guarded dialog may name directly. + * + *

All five are selector resources under {@code res/color/} that resolve to palette roles - + * four items per selector, naming {@code webhtv_color_*} roles plus a final mode-independent + * {@code @color/transparent} ({@code #00000000}) where the shape supplies the fill - so they + * already follow the palette and naming them is not a fixed colour. Anything else, such as a + * {@code res/values} literal like the legacy greys, would pin one mode and is rejected. + */ + private static final String[] ALLOWED_COLOR_RESOURCES = { + "dialog_tonal_button_bg", + "dialog_tonal_button_text", + "dialog_outlined_button_bg", + "dialog_outlined_button_text", + "dialog_outlined_button_stroke", + }; + + /** + * Every row/text surface the viewing-record sync family builds must take its colour from the + * token that belongs to that specific call site. + * + *

These are per-method contracts, not whole-file presence checks, because the defect this + * guard exists for is a single wrong assignment: {file, declaration, then one or more + * {@code ThemeController.current()} token suffixes the body must contain}. A whole-file check + * would stay green when one of several call sites in the same file silently went back to a + * literal, which is exactly how the original bug shipped. + */ + private static final String[][] REQUIRED_SITES = { + // ViewingRecordSyncDialog: the top-level list. + {"ViewingRecordSyncDialog.java", "private View sectionTitle(int text) {", + "colorOnSurfaceVariant()"}, + {"ViewingRecordSyncDialog.java", + "private View switchRow(String title, String detail, boolean enabled", + "colorOnSurface()"}, + {"ViewingRecordSyncDialog.java", + "private View entryRow(String title, String detail, int actionText", + "colorOnSurface()"}, + {"ViewingRecordSyncDialog.java", "private void addDetail(LinearLayoutCompat root, String value) {", + "colorOnSurfaceVariant()"}, + {"ViewingRecordSyncDialog.java", "private GradientDrawable rowBackground() {", + "colorSurfaceContainerLow()", "colorOutlineVariant()"}, + {"ViewingRecordSyncDialog.java", + "private MaterialButton actionButton(int text, boolean tonal, boolean danger) {", + "colorError()"}, + + // PlaybackRemoteSyncDialog: the remote-sync managers, each rendered as a card row. + {"PlaybackRemoteSyncDialog.java", "private View row(RemoteSyncConfig config) {", + "colorOnSurface()", "colorError()"}, + {"PlaybackRemoteSyncDialog.java", "private void addDetail(LinearLayoutCompat root, String value) {", + "colorOnSurfaceVariant()"}, + {"PlaybackRemoteSyncDialog.java", "private int statusColor(RemoteSyncConfig config) {", + "colorSuccess()", "colorOnSurfaceVariant()"}, + {"PlaybackRemoteSyncDialog.java", + "private MaterialButton actionButton(int text, boolean tonal, boolean danger) {", + "colorError()"}, + {"PlaybackRemoteSyncDialog.java", "private MaterialTextView badge(String value, int color) {", + "colorSurfaceContainerLowest()"}, + {"PlaybackRemoteSyncDialog.java", "private GradientDrawable rowBackground() {", + "colorSurfaceContainerLow()", "colorOutlineVariant()"}, + + // PlaybackWebhookDialog: adds the suspended row, which keeps an error fill and stroke. + {"PlaybackWebhookDialog.java", "private View row(WebhookConfig config) {", + "colorOnSurface()", "colorError()"}, + {"PlaybackWebhookDialog.java", "private void addDetail(LinearLayoutCompat root, String value) {", + "colorOnSurfaceVariant()"}, + {"PlaybackWebhookDialog.java", "private int statusColor(WebhookConfig config) {", + "colorError()", "colorSuccess()", "colorOnSurfaceVariant()"}, + {"PlaybackWebhookDialog.java", + "private MaterialButton actionButton(int text, boolean tonal, boolean danger) {", + "colorError()"}, + {"PlaybackWebhookDialog.java", "private MaterialTextView badge(String value, int color) {", + "colorSurfaceContainerLowest()"}, + {"PlaybackWebhookDialog.java", "private GradientDrawable rowBackground(WebhookConfig config) {", + "colorErrorContainer()", "colorError()", + "colorSurfaceContainerLow()", "colorOutlineVariant()"}, + }; + + @Test + public void javaBuiltDialogsDoNotPinAFixedForeground() throws Exception { + for (String name : DIALOG_SOURCES) { + String source = stripComments(read(dialogPath(name))); + for (String literal : FORBIDDEN_LITERALS) { + assertFalse(name + " pins the fixed foreground " + literal + + ", which cannot be legible in both the day and night palettes", + source.contains("\"" + literal + "\"")); + } + assertFalse(name + " must not pin a foreground through Color.parseColor", + source.contains("Color.parseColor(\"#")); + assertNoBareColorConstant(name, source); + assertNoColorConstructor(name, source); + assertNoIntegerHexLiteral(name, source); + assertNoFixedColorResource(name, source); + } + } + + /** + * The dialogs must take their foreground from the active semantic snapshot. + * + *

{@code Color.TRANSPARENT} is deliberately still allowed: it is not a foreground + * and carries no palette assumption (window backgrounds, tab ripples). + */ + @Test + public void javaBuiltDialogsReadTheActiveTokens() throws Exception { + for (String name : DIALOG_SOURCES) { + String source = stripComments(read(dialogPath(name))); + assertTrue(name + " must resolve its foreground from ThemeController.current()", + source.contains("ThemeController.current()")); + assertTrue(name + " must import ThemeController", + source.contains("import com.fongmi.android.tv.theme.ThemeController;")); + } + } + + /** + * The MPV action popup must keep its fill and its foreground in the same palette. + * + *

{@code MpvConfigDialog.actionItem} now paints its label with + * {@code colorOnSurface} / {@code colorError}. The popup background it sits on was a + * fixed white shape, so it had to migrate in the same change - otherwise night mode + * would render a near-black label on a still-white popup. Both halves are pinned so a + * later edit cannot move one without the other. + */ + @Test + public void mpvActionPopupFollowsTheSamePaletteAsItsForeground() throws Exception { + String popup = stripComments(read("src/main/res/drawable/shape_mpv_action_menu.xml")); + assertTrue("the popup fill must follow the palette", + popup.contains("?attr/colorSurfaceContainerLowest")); + assertTrue("the popup stroke must follow the palette", + popup.contains("?attr/colorOutlineVariant")); + assertFalse("the popup must not keep a fixed fill", popup.contains("#FFFFFF")); + assertFalse("the popup must not keep a fixed stroke", popup.contains("#E1E5EA")); + } + + /** + * Every row, badge and status surface of the viewing-record sync family follows the palette. + * + *

These rows are built in code and were filled with a fixed light card + * ({@code #F5F6F7}) on a fixed light hairline ({@code #DADCE0} / {@code #E2E5E8}), with the + * row title pinned to {@code Color.BLACK} and the badge to {@code #FFFFFF}. None of those + * values equals a {@code webhtv_color_*} role, so {@code ThemeBinder}'s exact-match rewrite + * skipped them by design: under the night table the dialog panel went dark while every row + * stayed a near-white card carrying black text, and the section labels/details stayed the + * fixed {@code #5F6368} grey that measures 2.23:1 on that panel. + * + *

Measured after the change against the real token values, every pair clears WCAG AA in + * both tables: row title 15.43:1 day / 13.29:1 night, detail text 8.39:1 / 10.06:1, + * badge text on its chip 6.53:1 / 11.78:1, and the suspended row's error text on its error + * container 5.14:1 / 5.51:1. + */ + @Test + public void viewingRecordSyncRowsUseTheActivePalette() throws Exception { + for (String[] site : REQUIRED_SITES) { + String name = site[0]; + String method = site[1]; + String body = methodBody(name, stripComments(read(dialogPath(name))), method); + for (int i = 2; i < site.length; i++) { + assertTrue(name + " " + method + " must take its colour from " + + "ThemeController.current()." + site[i] + + ", not a literal that can only be right in one palette", + body.contains("ThemeController.current()." + site[i])); + } + } + } + + /** + * The badge chip and the suspended row keep their own semantic surfaces. + * + *

The badge was a fixed {@code #FFFFFF} fill, which under the night table drew a white + * chip carrying the night success/error text. The suspended row was a fixed {@code #FFF7F7} + * pink that only read as "attention" in the day table, and its fill alone is a hue cue - the + * day/night error container differs from the normal card by only 1.15:1 / 1.83:1 - so it also + * keeps the paired {@code colorError} stroke. Both halves are pinned so a later edit cannot + * move one without the other. + */ + @Test + public void viewingRecordSyncBadgeAndSuspendedRowFollowThePalette() throws Exception { + String remote = stripComments( + read(dialogPath("PlaybackRemoteSyncDialog.java"))); + String remoteBadge = methodBody("PlaybackRemoteSyncDialog.java", remote, + "private MaterialTextView badge(String value, int color) {"); + assertTrue("the remote-sync badge fill must follow the palette", + remoteBadge.contains("ThemeController.current().colorSurfaceContainerLowest()")); + + String webhook = stripComments( + read(dialogPath("PlaybackWebhookDialog.java"))); + String webhookBadge = methodBody("PlaybackWebhookDialog.java", webhook, + "private MaterialTextView badge(String value, int color) {"); + assertTrue("the webhook badge fill must follow the palette", + webhookBadge.contains("ThemeController.current().colorSurfaceContainerLowest()")); + + String suspendedRow = methodBody("PlaybackWebhookDialog.java", webhook, + "private GradientDrawable rowBackground(WebhookConfig config) {"); + assertTrue("the suspended webhook row must use the semantic error container", + suspendedRow.contains("ThemeController.current().colorErrorContainer()")); + assertTrue("the suspended webhook row must keep an error stroke, so the cue is not hue-only", + suspendedRow.contains("ThemeController.current().colorError()")); + } + + /** + * Rejects {@code Color.BLACK}, {@code Color.WHITE}, {@code Color.GRAY} and friends. + * + *

The literal scan above only catches {@code "#RRGGBB"} strings, so a bare + * {@link android.graphics.Color} constant was a second way back to a pinned foreground. + * {@code Color.TRANSPARENT} is exempt: it is not a foreground. + */ + private static void assertNoBareColorConstant(String name, String source) { + Matcher matcher = Pattern.compile("Color\\.([A-Z][A-Z0-9_]*)").matcher(source); + while (matcher.find()) { + String constant = matcher.group(1); + if (!"TRANSPARENT".equals(constant)) { + fail(name + " must not pin a foreground through Color." + constant + + "; use ThemeController.current(), or Color.TRANSPARENT if it is not a colour"); + } + } + } + + /** + * Rejects {@code Color.rgb(32, 33, 36)} and friends - the fourth way to pin a literal. + * + *

This is not hypothetical: {@code MpvCustomButtonDialog} and + * {@code PlaybackPerformanceModal} pin exactly this way, and {@code Color.rgb(32, 33, 36)} + * is literally the forbidden {@code #202124}. Only the constructors that take numeric + * channel literals are rejected; the channel readers and {@code Color.valueOf} are + * conversions of an existing colour, not a way to introduce one. + */ + private static void assertNoColorConstructor(String name, String source) { + Matcher matcher = Pattern.compile("Color\\.(rgb|argb|HSVToColor)\\s*\\(").matcher(source); + if (matcher.find()) { + fail(name + " must not build a colour through Color." + matcher.group(1) + + "(...); use ThemeController.current() so both palettes stay legible"); + } + } + + /** + * Rejects {@code 0xFF5F6368}-style integer colours, a third way to pin a literal. + * + *

An 8-digit {@code 0x} literal is either an {@code AARRGGBB} colour or an Android + * resource id ({@code 0x7f......}); only the former is a pinned colour, so the package-id + * prefix is excluded to keep the rule from rejecting an unrelated packed constant. + */ + private static void assertNoIntegerHexLiteral(String name, String source) { + Matcher matcher = Pattern.compile("0x(?!7[fF])[0-9A-Fa-f]{8}").matcher(source); + if (matcher.find()) { + fail(name + " must not pin a foreground through the integer colour " + matcher.group() + + "; use ThemeController.current()"); + } + } + + /** Rejects {@code R.color.} except the palette-following selector resources. */ + private static void assertNoFixedColorResource(String name, String source) { + Matcher matcher = Pattern.compile("R\\.color\\.([A-Za-z0-9_]+)").matcher(source); + while (matcher.find()) { + String resource = matcher.group(1); + if (!List.of(ALLOWED_COLOR_RESOURCES).contains(resource)) { + fail(name + " must not name the colour resource R.color." + resource + + "; it is not one of the palette-following selectors " + + List.of(ALLOWED_COLOR_RESOURCES)); + } + } + } + + /** + * The body of the single method whose declaration contains {@code signature}. + * + *

Braces are balanced from the declaration's opening brace, so the returned text is only + * that method - which is what makes a per-call-site assertion possible. A missing or + * duplicated declaration fails loudly instead of silently matching the wrong body. + * + *

Assumption: this counts raw braces, so it is only correct while the guarded + * dialogs contain no {@code '{'} / {@code '}'} inside a string or character literal. A + * literal brace would mis-balance the scan and could silently return a truncated body, + * weakening every contract in {@link #REQUIRED_SITES}. {@link #assertNoBraceInLiteral} + * therefore rejects that shape outright rather than letting it corrupt the result. + */ + private static String methodBody(String name, String source, String signature) { + assertNoBraceInLiteral(name, source); + int start = source.indexOf(signature); + if (start < 0) { + fail(name + " no longer declares \"" + signature + + "\"; update this contract if the method was renamed or removed"); + } + if (source.indexOf(signature, start + 1) >= 0) { + fail(name + " declares \"" + signature + "\" more than once; the contract needs a" + + " signature that identifies one method"); + } + int open = source.indexOf('{', start); + int depth = 0; + for (int i = open; i < source.length(); i++) { + char c = source.charAt(i); + if (c == '{') depth++; + else if (c == '}' && --depth == 0) return source.substring(open, i + 1); + } + throw new AssertionError(name + " declares \"" + signature + "\" without a balanced body"); + } + + /** + * Rejects a brace inside a string or character literal, which {@link #methodBody} cannot + * count correctly. + * + *

This turns a silent weakening into a loud failure: without it, adding + * {@code text("{")} to a guarded dialog would mis-balance the scan, and the contracts below + * would start asserting against a truncated body while still reporting green. + */ + private static void assertNoBraceInLiteral(String name, String source) { + Matcher matcher = Pattern.compile("\"[^\"\\n]*[{}][^\"\\n]*\"|'[{}]'").matcher(source); + if (matcher.find()) { + fail(name + " contains a brace inside a string or character literal (" + + matcher.group() + "), which methodBody cannot brace-balance; move the brace" + + " out of the literal or replace the scanner with a real parser"); + } + } + + private static String[] concat(String[] first, String[] second) { + List all = new ArrayList<>(List.of(first)); + all.addAll(List.of(second)); + return all.toArray(new String[0]); + } + + private static String dialogPath(String name) { + return "src/main/java/com/fongmi/android/tv/ui/dialog/" + name; + } + + private static String stripComments(String source) { + String withoutXml = source.replaceAll("(?s)", ""); + return withoutXml + .replaceAll("(?s)/\\*.*?\\*/", "") + .replaceAll("(?m)//.*$", ""); + } + + private static String read(String path) throws Exception { + Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + return Files.readString(root.resolve(path), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/LeanbackForegroundContrastTest.java b/app/src/test/java/com/fongmi/android/tv/theme/LeanbackForegroundContrastTest.java new file mode 100644 index 0000000000..8151f73d26 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/LeanbackForegroundContrastTest.java @@ -0,0 +1,444 @@ +package com.fongmi.android.tv.theme; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The leanback counterpart of {@link ThemeBaseWiringTest}'s dark-glass-sheet guard. + * + *

TV resolves the same day/night token tables as mobile now that the flavour no longer + * ships a permanent dark {@code values/webhtv_tokens.xml}. That makes a + * palette-independent light foreground ({@code ?attr/webhtvColorOnWallpaper}, + * {@code @color/webhtv_color_player_control_muted}) correct only where the surface behind + * it is dark in both tables. On the app wallpaper, on the translucent glass + * sheets, on the {@code shape_vod_*} badges and on the video surface that holds; on a + * {@code ?attr/colorSurface*} panel or on one of the light card fills + * ({@code selector_git_cloud_card} {@code #F8F9FA}, {@code selector_light_dialog_item} + * {@code #FFFFFF}…) it turns into white-on-white in day mode. + * + *

That is exactly the mistake the first TV-light pass made: four leanback layouts were + * migrated wholesale to the wallpaper role although their own rows sit on a light card. + * Measured on {@code adapter_player_osd.xml} (over {@code #F8F9FA}) the pair was + * 1.05:1, and on {@code adapter_tmdb_item.xml} (over the focused + * {@code #D3E3FD}) 1.16:1 — both below the 3:1 floor for non-text UI and far below + * the 4.5:1 body-text floor. + * + *

The check is structural rather than a per-file list: for every leanback layout it + * walks the tag tree, and for each constant-light foreground it resolves the nearest + * ancestor (or self) that paints a background, then decides whether that background is + * light in the day palette. Palette-following backgrounds are rejected outright, because + * the day table is the light one. + */ +public class LeanbackForegroundContrastTest { + + /** Foregrounds that are light in every palette, with the value they resolve to. */ + private static final Map CONSTANT_LIGHT_FOREGROUNDS = new LinkedHashMap<>(); + + static { + CONSTANT_LIGHT_FOREGROUNDS.put("?attr/webhtvColorOnWallpaper", "#FFFFFF"); + CONSTANT_LIGHT_FOREGROUNDS.put("@color/webhtv_color_player_control_muted", "#CCFFFFFF"); + // 壁纸上的次级前景。与上面两个同族:两张表里都是浅色,因此只允许落在深色宿主上 + // (卡片信息条带、设置行色带),落在浅色日间表面会白到看不见。 + CONSTANT_LIGHT_FOREGROUNDS.put("?attr/webhtvColorOnWallpaperVariant", "#CCFFFFFF"); + } + + /** Luminance above which a day-mode fill can no longer carry a light foreground. */ + private static final double LIGHT_SURFACE_LUMINANCE = 0.6; + + /** Minimum contrast a light foreground needs against the fill behind it. */ + private static final double MIN_CONTRAST = 3.0; + + private static final Pattern TAG = Pattern.compile("<(/?)([A-Za-z0-9_.]+)((?:\"[^\"]*\"|[^>\"])*?)(/?)>", Pattern.DOTALL); + private static final Pattern BACKGROUND = Pattern.compile("android:background=\"([^\"]+)\""); + private static final Pattern TEXT_COLOR = Pattern.compile("android:textColor=\"([^\"]+)\""); + private static final Pattern SOLID = Pattern.compile("([^<]+)"); + private static final Pattern SELECTOR_COLOR = Pattern.compile("android:color=\"([^\"]+)\""); + private static final Pattern COMMENT = Pattern.compile("", Pattern.DOTALL); + private static final Pattern STROKE = Pattern.compile("]*/>|", Pattern.DOTALL); + private static final Pattern PADDING = Pattern.compile("]*/>|", Pattern.DOTALL); + private static final Pattern MASK_ITEM = Pattern.compile("]*@android:id/mask[^>]*>.*?", Pattern.DOTALL); + private static final Pattern RIPPLE_COLOR = Pattern.compile("]*android:color=\"[^\"]*\"[^>]*>"); + + /** Day-palette {@code webhtv_*} values; the night table is not needed to decide "light". */ + private static final Map DAY_TOKENS = new HashMap<>(); + + /** Alias table for the literal colours, so {@code @color/x -> @color/webhtv_...} resolves. */ + private static final Map LITERALS = new HashMap<>(); + + private static final List LAYOUTS = new ArrayList<>(); + + // ------------------------------------------------------------------ palette loading + + private static String readIfExists(String relative) { + try { + Path path = root().resolve(relative); + return Files.exists(path) ? Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n") : null; + } catch (Exception error) { + return null; + } + } + + private static Path root() { + return Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + } + + private static void loadPalette() { + if (!DAY_TOKENS.isEmpty()) return; + for (String file : new String[]{ + "src/main/res/values/webhtv_tokens.xml", + "src/main/res/values/colors.xml", + "src/leanback/res/values/colors.xml"}) { + String source = readIfExists(file); + if (source == null) continue; + Matcher matcher = COLOR_ENTRY.matcher(withoutComments(source)); + while (matcher.find()) { + String name = matcher.group(1); + String value = matcher.group(2).trim(); + if (file.contains("webhtv_tokens")) DAY_TOKENS.put(name, value); + LITERALS.put(name, value); + } + } + } + + private static void loadLayouts() throws Exception { + if (!LAYOUTS.isEmpty()) return; + Path directory = root().resolve("src/leanback/res/layout"); + try (var paths = Files.walk(directory)) { + paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".xml")) + .sorted() + .forEach(LAYOUTS::add); + } + } + + private static String withoutComments(String source) { + return COMMENT.matcher(source).replaceAll(""); + } + + /** Literal {@code #RRGGBB}/{@code #AARRGGBB} a {@code @color/} resolves to, or null when it follows the palette. */ + private static String literalOf(String name, Map seen) { + if (seen.containsKey(name)) return null; + seen.put(name, name); + String value = LITERALS.get(name); + if (value != null) { + if (value.startsWith("#")) return value; + if (value.startsWith("@color/")) return literalOf(value.substring("@color/".length()), seen); + } + // res/color/.xml selectors may be the only definition. + for (String directory : new String[]{"src/main/res/color", "src/leanback/res/color"}) { + String source = readIfExists(directory + "/" + name + ".xml"); + if (source == null) continue; + if (withoutComments(source).contains("?attr/")) return null; + Matcher matcher = SELECTOR_COLOR.matcher(withoutComments(source)); + while (matcher.find()) { + String entry = matcher.group(1); + if (entry.startsWith("#")) return entry; + if (entry.startsWith("@color/")) { + String resolved = literalOf(entry.substring("@color/".length()), seen); + if (resolved != null) return resolved; + } + } + } + return null; + } + + /** Relative luminance of {@code #RRGGBB}/{@code #AARRGGBB}; alpha is dropped. */ + private static double luminance(String hex) { + String digits = hex.startsWith("#") ? hex.substring(1) : hex; + if (digits.length() == 8) digits = digits.substring(2); + double[] channels = new double[3]; + for (int i = 0; i < 3; i++) { + double value = Integer.parseInt(digits.substring(i * 2, i * 2 + 2), 16) / 255.0; + channels[i] = value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); + } + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; + } + + private static int alpha(String hex) { + String digits = hex.startsWith("#") ? hex.substring(1) : hex; + return digits.length() == 8 ? Integer.parseInt(digits.substring(0, 2), 16) : 0xFF; + } + + private static double contrast(String first, String second) { + double a = luminance(first); + double b = luminance(second); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + } + + // ------------------------------------------------------------------ background resolution + + /** Outcome of resolving one background: how it behaves in the day palette. */ + private enum Surface {DARK, LIGHT, PALETTE, UNKNOWN} + + private static Surface surfaceOf(String background) { + if (background == null) return Surface.UNKNOWN; + if (background.startsWith("?attr/colorSurface") || background.startsWith("?attr/colorBackground") + || background.startsWith("?attr/android:colorBackground")) { + return Surface.PALETTE; // the day table is the light one + } + if (background.startsWith("?attr/")) return Surface.UNKNOWN; + if (background.startsWith("@color/")) return surfaceOfColor(background.substring("@color/".length())); + if (background.startsWith("@drawable/")) return surfaceOfDrawable(background.substring("@drawable/".length())); + return Surface.UNKNOWN; + } + + private static Surface surfaceOfColor(String name) { + String literal = literalOf(name, new HashMap<>()); + if (literal == null) return Surface.PALETTE; // palette-following selector + return luminance(literal) > LIGHT_SURFACE_LUMINANCE ? Surface.LIGHT : Surface.DARK; + } + + /** + * The day-mode fills of a drawable. + * + *

Strokes, padding and ripple mask items are dropped first: only a fill can sit + * behind text. A {@code ?attr/} fill or an unresolvable {@code @color/} makes the whole + * drawable palette-following. + */ + private static Surface surfaceOfDrawable(String name) { + boolean palette = false; + List fills = new ArrayList<>(); + for (String directory : new String[]{"src/main/res/drawable", "src/leanback/res/drawable"}) { + String source = readIfExists(directory + "/" + name + ".xml"); + if (source == null) continue; + String body = RIPPLE_COLOR.matcher(MASK_ITEM.matcher(PADDING.matcher(STROKE.matcher(withoutComments(source)) + .replaceAll("")).replaceAll("")).replaceAll("")).replaceAll(""); + palette |= body.contains("?attr/"); + Matcher solid = SOLID.matcher(body); + while (solid.find()) { + String value = solid.group(1); + if (value.startsWith("#")) fills.add(value); + else if (value.startsWith("@color/")) { + String literal = literalOf(value.substring("@color/".length()), new HashMap<>()); + if (literal == null) palette = true; + else fills.add(literal); + } else if (!value.contains("transparent")) { + palette = true; + } + } + Matcher gradient = GRADIENT.matcher(body); + while (gradient.find()) { + String value = gradient.group(1); + if (value.startsWith("#")) fills.add(value); + else if (value.startsWith("@color/")) { + String literal = literalOf(value.substring("@color/".length()), new HashMap<>()); + if (literal == null) palette = true; + else fills.add(literal); + } else { + palette = true; + } + } + } + if (palette) return Surface.PALETTE; + List visible = fills.stream().filter(fill -> alpha(fill) >= 0x66).toList(); + if (visible.isEmpty()) return Surface.UNKNOWN; // fully transparent: keep walking out + boolean light = visible.stream().allMatch(fill -> luminance(fill) > LIGHT_SURFACE_LUMINANCE); + return light ? Surface.LIGHT : Surface.DARK; + } + + // ------------------------------------------------------------------ the guard + + /** + * A palette-independent light foreground must not sit on a light day-mode surface. + * + *

Reported per file so a failure names the layout to fix; the message carries the + * offending tag, the background that was resolved and the contrast measured for the + * worst day-mode fill. + */ + @Test + public void constantLightForegroundsStayOnDarkSurfacesInDayMode() throws Exception { + loadPalette(); + loadLayouts(); + List failures = new ArrayList<>(); + for (Path layout : LAYOUTS) { + String source = Files.readString(layout, StandardCharsets.UTF_8).replace("\r\n", "\n"); + for (String failure : inspect(source)) { + failures.add(layout.getFileName() + ": " + failure); + } + } + if (!failures.isEmpty()) { + fail("leanback layouts paint a light foreground on a light day-mode surface, which is" + + " white-on-white in day mode:\n " + String.join("\n ", failures)); + } + } + + private static List inspect(String source) { + List failures = new ArrayList<>(); + Deque backgrounds = new ArrayDeque<>(); + Matcher matcher = TAG.matcher(source); + while (matcher.find()) { + boolean closing = !matcher.group(1).isEmpty(); + String tag = matcher.group(2); + String attributes = matcher.group(3); + boolean selfClosing = !matcher.group(4).isEmpty(); + if (closing) { + if (!backgrounds.isEmpty()) backgrounds.pop(); + continue; + } + Matcher backgroundMatcher = BACKGROUND.matcher(attributes); + String own = backgroundMatcher.find() ? backgroundMatcher.group(1) : null; + if (!selfClosing) backgrounds.push(own == null ? "" : own); + + Matcher colorMatcher = TEXT_COLOR.matcher(attributes); + if (!colorMatcher.find()) continue; + String foreground = CONSTANT_LIGHT_FOREGROUNDS.get(colorMatcher.group(1)); + if (foreground == null) continue; + + String nearest = own; + for (String ancestor : backgrounds) { + if (nearest == null || nearest.isEmpty()) nearest = ancestor; + if (nearest != null && !nearest.isEmpty()) break; + } + Surface surface = surfaceOf(nearest); + if (surface == Surface.DARK || surface == Surface.UNKNOWN) continue; + if (surface == Surface.PALETTE) { + failures.add(tag + " textColor=" + colorMatcher.group(1) + + " sits on the palette-following background " + nearest + + ", whose day value is a light surface"); + continue; + } + double worst = worstDayContrast(nearest, foreground); + if (worst < MIN_CONTRAST) { + failures.add(tag + " textColor=" + colorMatcher.group(1) + + " sits on " + nearest + " with " + String.format(Locale.ROOT, "%.2f:1", worst) + + " contrast in day mode"); + } + } + return failures; + } + + /** Worst day-mode contrast of a light foreground over the fills of a background. */ + private static double worstDayContrast(String background, String foreground) { + if (background == null) return Double.MAX_VALUE; + List fills = new ArrayList<>(); + if (background.startsWith("@color/")) { + String literal = literalOf(background.substring("@color/".length()), new HashMap<>()); + if (literal != null) fills.add(literal); + } else if (background.startsWith("@drawable/")) { + String name = background.substring("@drawable/".length()); + for (String directory : new String[]{"src/main/res/drawable", "src/leanback/res/drawable"}) { + String source = readIfExists(directory + "/" + name + ".xml"); + if (source == null) continue; + String body = RIPPLE_COLOR.matcher(MASK_ITEM.matcher(PADDING.matcher(STROKE.matcher(withoutComments(source)) + .replaceAll("")).replaceAll("")).replaceAll("")).replaceAll(""); + Matcher solid = SOLID.matcher(body); + while (solid.find()) { + String value = solid.group(1); + if (value.startsWith("#")) fills.add(value); + else if (value.startsWith("@color/")) { + String literal = literalOf(value.substring("@color/".length()), new HashMap<>()); + if (literal != null) fills.add(literal); + } + } + Matcher gradient = GRADIENT.matcher(body); + while (gradient.find()) { + String value = gradient.group(1); + if (value.startsWith("#")) fills.add(value); + else if (value.startsWith("@color/")) { + String literal = literalOf(value.substring("@color/".length()), new HashMap<>()); + if (literal != null) fills.add(literal); + } + } + } + } + return fills.stream().filter(fill -> alpha(fill) >= 0x66) + .mapToDouble(fill -> contrast(foreground, fill)) + .min().orElse(Double.MAX_VALUE); + } + + /** + * The four layouts that host their rows on a light card must keep the palette-following + * role, so a future "migrate the remaining TV foregrounds" sweep cannot put the light + * constant back on them. + * + *

Note this pins the day behaviour only. Those cards are fixed-light in both + * palettes, so their palette-following foreground is unreadable in night mode + * ({@code #E2E2E9} over {@code #F8F9FA} = 1.22:1). That is a pre-existing defect shared + * with mobile and present on {@code beta}, not something the TV-light pass introduced; + * it is deliberately not asserted here so it stays visible instead of being enshrined. + */ + @Test + public void lightCardRowsKeepThePaletteFollowingRole() throws Exception { + String[][] expectations = { + {"adapter_device.xml", "?attr/colorOnSurface", "?attr/colorOnSurfaceVariant"}, + {"adapter_player_osd.xml", "?attr/colorOnSurface"}, + {"adapter_recommendation_feedback.xml", "?attr/colorOnSurface", "?attr/colorOnSurfaceVariant"}, + {"adapter_tmdb_item.xml", "?attr/colorOnSurface", "?attr/colorOnSurfaceVariant"}, + }; + for (String[] expectation : expectations) { + String source = readIfExists("src/leanback/res/layout/" + expectation[0]); + assertTrue(expectation[0] + " is missing", source != null); + for (int i = 1; i < expectation.length; i++) { + assertTrue(expectation[0] + " must keep " + expectation[i] + + " for its rows over a light card", + source.contains("android:textColor=\"" + expectation[i] + "\"")); + } + for (String constant : CONSTANT_LIGHT_FOREGROUNDS.keySet()) { + assertTrue(expectation[0] + " must not paint its light-card rows with " + constant, + !source.contains("android:textColor=\"" + constant + "\"")); + } + } + } + + /** + * The guard is only meaningful while those cards really are light in day mode. + * + *

Pins the two fills the migration got wrong, so moving the card onto a palette role + * (which would make the constant foreground correct again) is a deliberate, visible edit + * rather than something the first test silently stops covering. + * + *

It also pins the absence of a {@code drawable-night} variant. That is the fact which + * forces these rows onto the palette-following role: a fixed-light card cannot carry a + * fixed-light foreground in night mode. When a night variant is added, this assertion is + * the place that announces the coupling has changed. + */ + @Test + public void theLightCardsAreStillLightInDayMode() throws Exception { + loadPalette(); + for (String drawable : new String[]{ + "selector_git_cloud_card", "selector_light_dialog_item", "selector_tmdb_search_item"}) { + assertTrue(drawable + " must keep its light day fill", + lightestFill(drawable) > LIGHT_SURFACE_LUMINANCE); + assertTrue(drawable + " must have no night variant, which is why its rows follow the palette", + readIfExists("src/main/res/drawable-night/" + drawable + ".xml") == null + && readIfExists("src/leanback/res/drawable-night/" + drawable + ".xml") == null); + } + } + + private static double lightestFill(String drawable) { + double darkest = Double.MAX_VALUE; + for (String directory : new String[]{"src/main/res/drawable", "src/leanback/res/drawable"}) { + String source = readIfExists(directory + "/" + drawable + ".xml"); + if (source == null) continue; + String body = RIPPLE_COLOR.matcher(MASK_ITEM.matcher(PADDING.matcher(STROKE.matcher(withoutComments(source)) + .replaceAll("")).replaceAll("")).replaceAll("")).replaceAll(""); + Matcher matcher = SOLID.matcher(body); + while (matcher.find()) { + String value = matcher.group(1); + String literal = value.startsWith("#") ? value + : value.startsWith("@color/") ? literalOf(value.substring("@color/".length()), new HashMap<>()) : null; + if (literal != null && alpha(literal) >= 0x66) darkest = Math.min(darkest, luminance(literal)); + } + } + assertTrue(drawable + " has no visible fill", darkest != Double.MAX_VALUE); + return darkest; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/PlaybackMenuScrimTest.java b/app/src/test/java/com/fongmi/android/tv/theme/PlaybackMenuScrimTest.java new file mode 100644 index 0000000000..960edc1b2b --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/PlaybackMenuScrimTest.java @@ -0,0 +1,62 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PlaybackMenuScrimTest { + + @Test + public void defaultStrengthKeepsTheMenuReadableOnBrightFrames() { + int[] colors = PlaybackMenuScrim.gradientColors(0x000000, PlaybackMenuScrim.DEFAULT_OPACITY); + assertEquals(0x99000000, colors[0]); + assertEquals(0xCC000000, colors[1]); + } + + @Test + public void theSliderScalesTheWholeGradientAndClamps() { + int[] full = PlaybackMenuScrim.gradientColors(0x000000, 1f); + assertEquals(0xBF000000, full[0]); + assertEquals(0xFF000000, full[1]); + int[] off = PlaybackMenuScrim.gradientColors(0x000000, 0f); + assertEquals(0x00000000, off[0]); + assertEquals(0x00000000, off[1]); + } + + @Test + public void unsetSlotFallsBackToTheShippedDefault() { + assertEquals(0.80f, PlaybackMenuScrim.resolveOpacity(null), 0.0001f); + assertEquals(0.35f, PlaybackMenuScrim.resolveOpacity(0.35f), 0.0001f); + assertEquals(1f, PlaybackMenuScrim.resolveOpacity(2f), 0.0001f); + assertEquals(0f, PlaybackMenuScrim.resolveOpacity(-1f), 0.0001f); + } + + @Test + public void theScrimFollowsTheThemeSlotInsteadOfAHardcodedDrawable() throws Exception { + String source = read("src/main/java/com/fongmi/android/tv/theme/PlaybackMenuScrim.java"); + assertTrue(source.contains("ThemeController.configuredPlayerScrimOpacity(")); + assertTrue(source.contains("ThemeController.current().colorPlayerScrim()")); + assertTrue(source.contains("GradientDrawable.Orientation.TOP_BOTTOM")); + assertFalse(source.contains("shape_controller_scrim")); + } + + @Test + public void theThemeControllerExposesTheBoundedSlot() throws Exception { + String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); + assertTrue(controller.contains("public static Float configuredPlayerScrimOpacity(boolean light)")); + String validator = read("src/main/java/com/fongmi/android/tv/theme/ThemeProfileValidator.java"); + assertTrue(validator.contains("MIN_PLAYER_SCRIM_OPACITY = 0.20f")); + assertTrue(validator.contains("MAX_PLAYER_SCRIM_OPACITY = 1f")); + } + + private static String read(String path) throws Exception { + Path root = Files.isDirectory(Path.of("src", "main", "java")) ? Path.of(".") : Path.of("app"); + return Files.readString(root.resolve(path), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeBaseWiringTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeBaseWiringTest.java new file mode 100644 index 0000000000..97c0c77fe2 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeBaseWiringTest.java @@ -0,0 +1,555 @@ +package com.fongmi.android.tv.theme; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +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; + +/** + * Layer 1 integration contract: both flavours inherit the WebHTV semantic theme and the + * Activity theme maps every Material color role used by layouts onto a webhtv token, so no + * screen silently falls back to the Material baseline palette. + */ +public class ThemeBaseWiringTest { + + private static final String[] ACTIVITY_ROLES = { + "colorPrimary", "colorOnPrimary", "colorPrimaryContainer", "colorOnPrimaryContainer", + "colorSecondary", "colorOnSecondary", "colorSecondaryContainer", "colorOnSecondaryContainer", + "colorTertiary", "colorOnTertiary", + "colorError", "colorOnError", "colorErrorContainer", "colorOnErrorContainer", + "colorSurface", "colorSurfaceDim", "colorSurfaceBright", + "colorSurfaceContainerLowest", "colorSurfaceContainerLow", "colorSurfaceContainer", + "colorSurfaceContainerHigh", "colorSurfaceContainerHighest", "colorSurfaceVariant", + "colorOnSurface", "colorOnSurfaceVariant", "colorOutline", "colorOutlineVariant", + "colorSurfaceInverse", "colorOnSurfaceInverse", "colorPrimaryInverse", + "colorControlNormal", "colorControlActivated", + }; + + private static final String[] DIALOG_ROLES = { + "colorPrimary", "colorOnPrimary", + "colorSecondaryContainer", "colorOnSecondaryContainer", + "colorError", "colorOnError", "colorErrorContainer", "colorOnErrorContainer", + "colorSurface", "colorSurfaceContainer", "colorSurfaceContainerHigh", "colorSurfaceContainerHighest", + "colorOnSurface", "colorOnSurfaceVariant", "colorOutline", "colorOutlineVariant", + "colorControlNormal", "colorControlActivated", + }; + + /** Layouts draw these over video, so they keep their own alpha instead of ?attr/colorOnSurface. */ + private static final String[] ON_SURFACE_ALPHA_ATTRS = { + "colorOnSurface_20", "colorOnSurface_70", "colorOnSurface_80", "colorOnSurface_90", + }; + + /** + * List/overlay layouts whose text is a flat colour over a fill that is dark in + * every state. + * + *

Upstream painted these with a flat light colour ({@code @color/white}, + * {@code @color/white_60/80}); the unification to {@code ?attr/colorOnSurface} + * resolved them to #1A1C1E in day mode and made them dark-on-dark over the + * wallpaper, the video surface and the translucent white glass panels. Upstream + * reused the very same item layouts inside {@code dialog_quick_search}, + * {@code dialog_episode_list} and {@code dialog_receive}, so a single always-light + * role is correct in both the page and the dialog host. + * + *

The fill of each of these is {@code shape_live} (#47000000 / #5C000000), + * {@code shape_vod_list} / {@code shape_vod_name} (#33000000), + * {@code shape_video_episode_grid} (#99000000), {@code shape_quick_search_item} or a + * {@code ?attr/colorSurfaceContainer*} panel - never a light one - so no state list + * is needed. Their adapters were checked for {@code setSelected}: none of them call + * it except through {@code shape_item}'s states, which this list excludes. + */ + private static final String[] WALLPAPER_ITEMS = { + "adapter_channel.xml", "adapter_epg_data.xml", "adapter_file.xml", + "adapter_group.xml", "adapter_search.xml", "adapter_search_hot_word.xml", + "adapter_search_record.xml", "adapter_search_word.xml", "adapter_vod.xml", + "adapter_vod_list.xml", "adapter_vod_oval.xml", "view_empty.xml", + "adapter_quick.xml", "view_progress.xml", + }; + + /** + * Selectable items whose fill turns light when selected, so a flat colour cannot + * serve both states. + * + *

{@code selector_item} is the shared fill of these items in the mobile flavour: + * {@code state_selected} paints {@code colorSecondaryContainer} at 0.65 alpha - a + * light grey in day mode (#DFE2E6 over the wallpaper measured #BAB9C5) - while the + * default entry paints 15% black over the wallpaper, which is dark. Measured on the + * home type chips: white text on the selected chip gave 1.94:1, dark text gave + * 8.82:1; on an unselected chip white gave 6.32:1 and dark 2.71:1. Neither a flat + * white nor a flat dark value can be right for both, which is why upstream shipped + * these as state lists and why dev3 carried them until the unification replaced them. + * + *

This is the exact set dev3 used before that change; restoring it is the fix. + */ + private static final String[][] WALLPAPER_STATE_ITEMS = { + // chips over shape_item / shape_item_round -> selector_item + {"adapter_type.xml", "@color/selector_text"}, + {"adapter_collect.xml", "@color/selector_text"}, + // episode / quality / flag rows over shape_video_item + {"adapter_episode_grid.xml", "@color/selector_video_text"}, + {"adapter_episode_group.xml", "@color/selector_video_text"}, + {"adapter_episode_hori.xml", "@color/selector_video_text"}, + {"adapter_flag.xml", "@color/selector_video_text"}, + {"adapter_quality.xml", "@color/selector_video_text"}, + }; + + /** Pages whose rows sit directly on the (dark) app wallpaper. */ + private static final String[] WALLPAPER_PAGES = { + "fragment_setting.xml", "fragment_setting_ad.xml", "fragment_setting_ai.xml", + "fragment_setting_danmaku.xml", "fragment_setting_enhance.xml", + "fragment_setting_personal.xml", "fragment_setting_player.xml", + "fragment_setting_subtitle.xml", "fragment_setting_tmdb.xml", + }; + + /** + * Player bottom sheets whose own root background is a dark translucent + * indigo glass panel, never a light one. + * + *

{@code shape_player_child_sheet_panel}, {@code shape_dialog_glass_panel}, + * {@code shape_dialog_control_glass_panel}, {@code shape_quick_search_panel}, + * {@code shape_danmaku_sheet_panel} and {@code shape_danmaku_setting_panel} are all + * {@code #E62F315E -> #D6282955 -> #CC303463} (or {@code #E61F1F22}); composited over + * any frame they stay dark, so their foreground must not follow the palette. + * + *

These are the mobile peers of the leanback sheets. Historically leanback used the + * identical drawables with {@code ?attr/colorOnSurface}, which worked on TV only because + * the leanback table was dark in every profile ({@code webhtv_color_on_surface} = + * {@code #E2E2E9}). Now that TV resolves the same day/night tables as mobile, the TV + * sheets carry the same palette-independent constants as these mobile ones, so both + * flavours stay readable in both modes. + * + *

{@code 0af2340d4} replaced the flat light values ({@code @color/white}, + * {@code white_70}, {@code white_90}, {@code white_50/60}) in exactly these files, and + * this list is that change's inverse. The fill is constant in every state, so a flat + * light foreground is correct and no state list is needed. + */ + private static final String[] DARK_GLASS_SHEETS = { + "dialog_danmaku.xml", "dialog_danmaku_search.xml", "dialog_danmaku_setting.xml", + "dialog_episode_list.xml", "dialog_live.xml", "dialog_live_epg.xml", + "dialog_offset.xml", "dialog_quick_search.xml", "dialog_timer.xml", + "dialog_title.xml", "dialog_track.xml", "dialog_video_content.xml", + }; + + /** + * Palette-independent foregrounds that are correct on a dark surface in every table. + * + *

{@code webhtv_on_wallpaper} and {@code webhtv_color_player_control_muted} are + * {@code #FFFFFF} / {@code #CCFFFFFF} in both token tables (the day and night files), + * which is what makes them safe on a surface whose darkness does not + * depend on the palette. {@code webhtv_color_overlay_light} is the matching constant + * ripple fill. They are semantic roles, not raw values, so the sheets stay inside the + * token system and need no allowlist exemption of their own. + */ + private static final String[] CONSTANT_LIGHT_FOREGROUNDS = { + "?attr/webhtvColorOnWallpaper", + "@color/webhtv_color_player_control_muted", + "@color/webhtv_color_overlay_light", + "@color/selector_control_sheet_text", + }; + + /** + * The sheet button state list must stay palette-independent too. + * + *

{@code dialog_danmaku_setting} and {@code dialog_timer} paint their option buttons + * with {@code @color/selector_control_sheet_text} over + * {@code selector_player_child_sheet_button}, a dark translucent fill. The list resolves + * to {@code @color/white} / {@code #B3FFFFFF} plus the black selected/activated pair + * that matches the light selected band, so it never follows the palette. Routing it + * through {@code ?attr/colorOnSurface} would put near-black text on the dark sheet in + * day mode, exactly like the layouts above. + */ + @Test + public void controlSheetButtonTextStaysPaletteIndependent() throws Exception { + String selector = withoutComments(read("src/mobile/res/color/selector_control_sheet_text.xml")); + assertFalse("the sheet button text must not follow the palette", + selector.contains("?attr/")); + assertTrue(selector.contains("@color/white")); + assertTrue(selector.contains("#B3FFFFFF")); + assertTrue("the selected state must stay dark on the light selected band", + selector.contains("#FF000000")); + } + + /** Attributes that paint a foreground onto the sheet's own dark panel. */ + private static final String[] SHEET_FOREGROUND_ATTRS = { + "android:textColor", "android:textColorHint", "app:tint", + "app:strokeColor", "app:rippleColor", "app:boxStrokeColor", + }; + + @Test + public void flavourBaseThemesInheritTheWebhtvSemanticTheme() throws Exception { + String mobile = read("src/mobile/res/values/styles.xml"); + assertTrue(mobile.contains("", start); + assertTrue(style + " is not closed", end > start); + return source.substring(start, end); + } + + private static String read(String path) throws Exception { + Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + return Files.readString(root.resolve(path), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeBinderContractTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeBinderContractTest.java new file mode 100644 index 0000000000..7ca5c95547 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeBinderContractTest.java @@ -0,0 +1,622 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ThemeBinderContractTest { + + @Test + public void explicitRoleTagsAreParsedCaseInsensitivelyAndBounded() { + assertEquals(ThemeRole.PRIMARY, ThemeRole.fromTag("webhtv:primary")); + assertEquals(ThemeRole.PRIMARY, ThemeRole.fromTag(" webhtv:PRIMARY ")); + assertEquals(ThemeRole.SURFACE_CONTAINER_HIGH, ThemeRole.fromTag("webhtv:surface_container_high")); + assertNull(ThemeRole.fromTag("webhtv:")); + assertNull(ThemeRole.fromTag("webhtv:playerControl")); + assertNull(ThemeRole.fromTag("something-else")); + assertNull(ThemeRole.fromTag(null)); + assertNull(ThemeRole.fromTag(42)); + } + + @Test + public void rolesExposeOnlyUserSlotsAndTheirDerivedForegrounds() { + int userSlots = 0; + for (ThemeRole role : ThemeRole.values()) if (role.isUserSlot()) userSlots++; + assertEquals(13, userSlots); + for (ThemeRole role : ThemeRole.values()) { + assertTrue(role.name(), role.name().matches("[A-Z_]+")); + } + // Player, media, health and brand roles must not be representable. + assertNull(ThemeRole.fromTag("webhtv:player_control")); + assertNull(ThemeRole.fromTag("webhtv:health_good")); + } + + @Test + public void indexResolvesUniqueBaselineColorsOnly() { + ThemeTokens baseline = ThemeTokens.light(); + ThemeColorIndex index = ThemeColorIndex.of(baseline); + assertFalse(index.isEmpty()); + assertEquals(ThemeRole.ERROR, index.uniqueRoleFor(baseline.colorError())); + assertEquals(ThemeRole.SURFACE, index.uniqueRoleFor(baseline.colorSurface())); + // The light palette deliberately shares one blue between primary and focus, + // so that value is ambiguous and the binder requires both to agree. + assertTrue(index.rolesFor(baseline.colorFocus()).contains(ThemeRole.PRIMARY)); + assertTrue(index.rolesFor(baseline.colorFocus()).contains(ThemeRole.FOCUS)); + assertNull(index.uniqueRoleFor(0xFF123456)); + assertNull(ThemeColorIndex.of(null).uniqueRoleFor(0xFF123456)); + assertTrue(ThemeColorIndex.of(null).isEmpty()); + } + + @Test + public void sharedBaselineColorsAreReportedAsAmbiguous() { + ThemeColorIndex index = ThemeColorIndex.of(ThemeTokens.light()); + Set roles = index.rolesFor(0xFFFFFFFF); + assertTrue("white must be shared by several foreground roles", roles.size() > 1); + assertNull(index.uniqueRoleFor(0xFFFFFFFF)); + } + + @Test + public void replacementIsReturnedOnlyWhenEverySharingRoleAgrees() { + ThemeTokens baseline = ThemeTokens.light(); + ThemeColorIndex index = ThemeColorIndex.of(baseline); + + ThemeProfile outlineProfile = ThemeProfile.defaultProfile(); + outlineProfile.light.outline = "#000000"; + ThemeTokens outlineActive = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, outlineProfile, null, false); + assertEquals(Integer.valueOf(0xFF000000), index.replacementFor(baseline.colorOutline(), outlineActive)); + + // White is shared by onPrimary/onSecondary/onTertiary; overriding only the + // primary makes those roles disagree, so the binder must keep the static color. + ThemeProfile primaryProfile = ThemeProfile.defaultProfile(); + primaryProfile.light.primary = "#FFFFFF"; + ThemeTokens primaryActive = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, primaryProfile, null, false); + assertNull(index.replacementFor(baseline.colorOnPrimary(), primaryActive)); + } + + @Test + public void unknownAndUnchangedColorsAreNeverRewritten() { + ThemeTokens baseline = ThemeTokens.light(); + ThemeColorIndex index = ThemeColorIndex.of(baseline); + assertNull(index.replacementFor(0xFF123456, ThemeTokens.dark())); + assertNull(index.replacementFor(baseline.colorPrimary(), baseline)); + assertNull(index.replacementFor(baseline.colorPrimary(), null)); + } + + @Test + public void binderIsControlledMainThreadOnlyAndPlayerSafe() throws Exception { + String source = read("src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java"); + assertTrue(source.contains("Looper.myLooper() != Looper.getMainLooper()")); + assertTrue(source.contains("baseline.equals(active)")); + assertTrue(source.contains("webhtv:ignore")); + assertTrue(source.contains("addOnChildAttachStateChangeListener")); + assertTrue(source.contains("isStateful()")); + assertFalse("no Resources/AssetManager reflection", source.contains("AssetManager")); + assertFalse("no reflection bypass", source.contains("setAccessible")); + assertFalse("no style application bypass", source.contains("applyStyle")); + // Class#getRecordComponents does not exist on Android API < 33 and crashed + // the app on API 28; the signature must stay reflection-free. + assertFalse("no Java-16 record reflection on the runtime path", + source.contains("getRecordComponents()") || source.contains("getDeclaredConstructor(")); + assertTrue(source.contains("tokens.hashCode()")); + assertTrue(source.contains("WeakHashMap")); + } + + @Test + public void everyActivityBindsTheThemeTreeTwice() throws Exception { + for (String flavour : new String[]{"mobile", "leanback"}) { + String source = read("src/" + flavour + "/java/com/fongmi/android/tv/ui/base/BaseActivity.java"); + int content = source.indexOf("View content = getBinding().getRoot();"); + int setContent = source.indexOf("setContentView(content);", content); + int first = source.indexOf("ThemeController.bindTheme(content);", setContent); + int second = source.indexOf("ThemeController.bindTheme(content);", first + 1); + assertTrue(flavour + " must resolve one inflated binding as the content view", content > 0); + assertTrue(flavour + " must bind after setContentView", setContent > content && first > setContent); + assertTrue(flavour + " must re-bind after initView", second > first); + assertTrue(flavour + " must bind before initEvent", + source.indexOf("initEvent();", second) > second); + } + } + + @Test + public void presetSeedsMustResolveToTheirOwnPaletteInsteadOfFallingBack() { + for (int seed : new int[]{0xFF0B57D0, 0xFF00897B, 0xFF146C2E, 0xFFFB8C00, 0xFFB3261E, 0xFF8E24AA}) { + String hex = "#" + Integer.toHexString(seed).toUpperCase(java.util.Locale.ROOT); + ThemeTokens tokens = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.EXPLICIT, seed, 0, null, null, false); + String diagnostic = ThemeResolver.lastDiagnostic(); + assertFalse("seed " + hex + " silently fell back: " + diagnostic, + diagnostic.startsWith("fallback")); + assertNotEquals("seed " + hex + " resolved to the frozen palette", + ThemeTokens.light().colorPrimary(), tokens.colorPrimary()); + } + } + + @Test + public void backgroundChannelCoversMaterialShapeAndInsetWrappers() throws Exception { + String source = read("src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java"); + assertTrue("must unwrap Material's inset window background", source.contains("instanceof InsetDrawable inset")); + assertTrue("must reopen the wrapped drawable", source.contains("bindDrawable(inset.getDrawable()")); + assertTrue("must cover Material shape panels", source.contains("instanceof MaterialShapeDrawable shape")); + assertTrue(source.contains("shape.getFillColor()")); + assertTrue(source.contains("shape.setFillColor(color)")); + // Still no reflection or hidden API on the widening path. + assertFalse(source.contains("setAccessible")); + assertFalse(source.contains("getDeclaredField")); + } + + /** + * Every Material alert dialog must be constructed through the themed builder, + * whose overridden {@code create()} binds the dialog window to the active tokens. + * Raw {@code MaterialAlertDialogBuilder} instances were never passed to + * {@code bindDialog}, which is why a custom theme used to leave nearly every + * dialog on the compiled palette. + * + /** + * A {@code themeResId} handed to a dialog builder must resolve {@code colorPrimary} + * to something concrete. {@code MaterialAlertDialogBuilder} validates the dialog + * context through {@code ThemeEnforcement.checkAppCompatTheme}, which only checks + * that {@code ?attr/colorPrimary} exists - so an overlay whose *nearest* definition + * of the role is the self-reference {@code ?attr/colorPrimary} throws inside the + * constructor. That is exactly how "cancel following" crashed with + * {@code ThemeOverlay.WebHTV.FollowingConfirmDialog}. + * + *

Inheritance is honoured: {@code ThemeOverlay.WebHTV.Dialog.NoInset} defines no + * primary of its own and safely inherits the concrete token from + * {@code ThemeOverlay.WebHTV.Dialog}. Only the nearest definition decides, because a + * child that re-declares the role as {@code ?attr/colorPrimary} shadows its ancestor + * and resolves to nothing. + */ + @Test + public void dialogThemeArgumentsResolveColorPrimaryConcretely() throws Exception { + Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + java.util.Map body = new java.util.HashMap<>(); + java.util.Map parent = new java.util.HashMap<>(); + java.util.regex.Pattern stylePattern = java.util.regex.Pattern + .compile("", + java.util.regex.Pattern.DOTALL); + for (Path res : Files.walk(root.resolve("src")).filter(p -> p.toString().endsWith(".xml")).toList()) { + java.util.regex.Matcher m = stylePattern.matcher(Files.readString(res, StandardCharsets.UTF_8)); + while (m.find()) { + body.put(m.group(1), m.group(3)); + if (m.group(2) != null) parent.put(m.group(1), m.group(2)); + } + } + java.util.List offenders = new java.util.ArrayList<>(); + java.util.regex.Pattern usage = java.util.regex.Pattern + .compile("(?:WebHtvAlertDialogBuilder|MaterialAlertDialogBuilder)\\([^;]*?R\\.style\\.(ThemeOverlay_[A-Za-z_]+)", + java.util.regex.Pattern.DOTALL); + for (Path sourceFile : Files.walk(root.resolve("src")).filter(p -> p.toString().endsWith(".java")).toList()) { + if (sourceFile.toString().contains("graphify-out")) continue; + java.util.regex.Matcher m = usage.matcher(Files.readString(sourceFile, StandardCharsets.UTF_8)); + while (m.find()) { + String name = m.group(1).replace('_', '.'); + String nearest = null; + for (int hop = 0; hop < 24 && name != null; hop++) { + String block = body.get(name); + if (block != null) { + java.util.regex.Matcher item = java.util.regex.Pattern + .compile("([^<]+)").matcher(block); + if (item.find()) { nearest = item.group(1).trim(); break; } + } + name = parent.get(name); + } + if (nearest == null || !nearest.startsWith("@color/")) { + offenders.add(sourceFile.getFileName() + " -> " + m.group(1) + " resolves colorPrimary to " + nearest); + } + } + } + assertTrue("these dialog themes cannot satisfy ThemeEnforcement: " + offenders, offenders.isEmpty()); + } + + @Test + public void materialAlertDialogsAreBuiltThroughTheThemedBuilder() throws Exception { + Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + // CrashActivity is deliberately exempt: the recovery screen runs in its own + // process with Theme.Crash and must render identically no matter what palette + // the user saved, so it must not depend on the theme contract at all. + Set exempt = Set.of("CrashActivity.java"); + // Matches `new AlertDialog.Builder(`, `new android.app.AlertDialog.Builder(` and + // `new androidx.appcompat.app.AlertDialog.Builder(` alike, but never the themed + // `new WebHtvAlertDialogBuilder(`. + java.util.regex.Pattern forbidden = java.util.regex.Pattern.compile( + "new\\s+(?:[A-Za-z_][\\w.]*\\.)?AlertDialog\\.Builder\\("); + // Same shape for the Material builder: the qualified `new com.google.android.material + // .dialog.MaterialAlertDialogBuilder(` must be caught too. The previous version used a + // literal `contains("new MaterialAlertDialogBuilder(")`, so every call site that spelled + // the class out in full slipped through - five of them in the two VideoActivity files + // were building dialogs that never reached ThemeController.bindDialog, which meant a + // user-chosen theme silently did not apply to them. + java.util.regex.Pattern forbiddenMaterial = java.util.regex.Pattern.compile( + "new\\s+(?:[A-Za-z_][\\w.]*\\.)?MaterialAlertDialogBuilder\\("); + java.util.List raw = new java.util.ArrayList<>(); + for (String sourceSet : new String[]{"main", "mobile", "leanback"}) { + Path base = root.resolve("src/" + sourceSet); + if (!Files.exists(base)) continue; + try (java.util.stream.Stream paths = Files.walk(base)) { + for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) { + if (path.toString().contains("graphify-out")) continue; + if (exempt.contains(path.getFileName().toString())) continue; + String text = Files.readString(path, StandardCharsets.UTF_8); + if (forbiddenMaterial.matcher(text).find()) { + raw.add(sourceSet + "/" + path.getFileName() + " -> MaterialAlertDialogBuilder"); + } + if (forbidden.matcher(text).find()) { + raw.add(sourceSet + "/" + path.getFileName() + " -> AlertDialog.Builder"); + } + } + } + } + assertTrue("build these through WebHtvAlertDialogBuilder instead: " + raw, raw.isEmpty()); + } + + /** + * The editor exposes a dialog-opacity slider, and the design record documents it as + * "the dialog/BottomSheet shell only, never the text alpha". It used to reach only the + * preview swatch and the Web snapshot, so on a real dialog the lower bound and the upper + * bound produced byte-identical screens - measured on device as 0 changed pixels. The + * window background is the shell, so it must carry the opacity. + */ + @Test + public void dialogOpacityReachesTheWindowShellAndNotTheText() throws Exception { + String binder = read("src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java"); + assertTrue("the window background must carry the shell opacity", + binder.contains("applyShellOpacity(drawable, active.dialogOpacity())")); + assertTrue("shell opacity must unwrap Material's inset wrapper", + binder.contains("applyShellOpacity(inset.getDrawable(), opacity)")); + // 1.0 is the shipped default: it must stay a strict no-op. + assertTrue("a fully opaque shell must be left untouched", binder.contains("if (drawable == null || opacity >= 1f) return false;")); + // Text alpha is governed by the view-tree pass, which this method never touches. + assertFalse("shell opacity must not be applied through the view lambda", + binder.contains("applyShellOpacity(view")); + + // Alpha arithmetic, including the documented 0.70..1.00 bounds. + assertEquals("default opacity must be a no-op", 0xFF7B1FA2, ThemeBinder.scaleAlpha(0xFF7B1FA2, 1.0f)); + assertEquals("lower bound", 0xB37B1FA2, ThemeBinder.scaleAlpha(0xFF7B1FA2, 0.70f)); + // 0.70 * 255 = 178.5, and Math.round takes it up to 179. + assertEquals("matches the clamped manual computation", 179, ThemeBinder.scaleAlpha(0xFF7B1FA2, 0.70f) >>> 24); + assertEquals("existing transparency must be preserved, not replaced", + 0x407B1FA2, ThemeBinder.scaleAlpha(0x807B1FA2, 0.50f)); + assertEquals("out-of-range values must clamp, never wrap", + 0xFF7B1FA2, ThemeBinder.scaleAlpha(0xFF7B1FA2, 2.0f)); + } + + @Test + public void dialogWindowBackgroundIsRecolouredOutsideTheViewTree() throws Exception { + String binder = read("src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java"); + assertTrue(binder.contains("public static boolean bindWindowBackground(Drawable drawable, ThemeTokens baseline, ThemeTokens active)")); + assertTrue("must reuse the shared drawable pass", + binder.contains("bindDrawable(drawable, null, ThemeColorIndex.of(baseline), active)")); + + String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); + assertTrue(controller.contains("public static void bindWindowBackground(Drawable background)")); + + String builder = read("src/main/java/com/fongmi/android/tv/theme/WebHtvAlertDialogBuilder.java"); + // The panel is the builder's own background field, which is the exact instance + // handed to Window.setBackgroundDrawable by MaterialAlertDialogBuilder. + assertTrue(builder.contains("ThemeController.bindWindowBackground(getBackground())")); + assertTrue(builder.contains("ThemeController.bindDialog(dialog)")); + } + + @Test + public void followingPageOptsIntoTheSharedAppearanceContract() throws Exception { + String source = read("src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); + int apply = source.indexOf("ThemeController.applyFromPreferences(this);"); + int setContent = source.indexOf("setContentView(binding.getRoot());"); + int first = source.indexOf("ThemeController.bindTheme(binding.getRoot());"); + int initView = source.indexOf("initView();"); + int second = source.indexOf("ThemeController.bindTheme(binding.getRoot());", first + 1); + assertTrue("must resolve tokens before the first content view", apply > 0 && apply < setContent); + assertTrue("must bind right after setContentView", first > setContent); + assertTrue("must re-bind after initView", second > first && initView > first && second > initView); + } + + /** + * Every Activity that hosts native views must participate in the theme contract. + * Activities outside BaseActivity are the exception and are pinned here so a new + * one cannot silently ship with a statically coloured tree. + */ + @Test + public void nativeActivitiesOutsideBaseActivityAreExplicitlyAccountedFor() throws Exception { + Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + Set exempt = Set.of( + "LabActivity", "LabDetailActivity", "LabOutputActivity", "LabTerminalActivity", + "CatWebActivity", "GameWebActivity", "WebReaderActivity"); + java.util.List unbound = new java.util.ArrayList<>(); + try (java.util.stream.Stream paths = Files.walk(root.resolve("src/main/java/com/fongmi/android/tv"))) { + for (Path path : paths.filter(p -> p.toString().endsWith(".java")).toList()) { + String source = Files.readString(path, StandardCharsets.UTF_8); + if (!source.contains("extends AppCompatActivity")) continue; + String name = path.getFileName().toString().replace(".java", ""); + if (exempt.contains(name)) continue; + if (!source.contains("ThemeController.bindTheme(")) unbound.add(name); + } + } + assertTrue("these activities host native views but never bind the theme: " + unbound, unbound.isEmpty()); + } + + @Test + public void binderRewritesTheMaterialButtonAndBackgroundTintChannels() throws Exception { + String source = read("src/main/java/com/fongmi/android/tv/theme/ThemeBinder.java"); + assertTrue(source.contains("instanceof MaterialButton button")); + assertTrue(source.contains("view.getBackgroundTintList()")); + assertTrue(source.contains("view.setBackgroundTintList(tint)")); + assertTrue(source.contains("view.setStrokeColor(stroke)")); + assertTrue(source.contains("view.setIconTint(icon)")); + // The visible contract behind this: native pages fill their primary + // buttons through app:backgroundTint, not through a GradientDrawable. + String following = read("src/main/res/layout/activity_following.xml"); + assertTrue(following.contains("app:backgroundTint=\"?attr/colorPrimary\"")); + } + + @Test + public void alertDialogFamiliesBindThroughTheSharedDialogChannel() throws Exception { + String light = read("src/main/java/com/fongmi/android/tv/ui/dialog/LightDialog.java"); + assertEquals("both LightDialog entry points must bind", + 2, light.split("ThemeController\\.bindDialog\\(dialog\\)", -1).length - 1); + assertTrue("LightDialog must bind the window shell as well as the view tree", + light.contains("ThemeController.bindWindowBackground(background)")); + assertTrue("custom LightDialog shells must use the active semantic surface", + light.contains("ThemeController.current().colorSurfaceContainerHigh()")); + // dialogOpacity has exactly one owner: ThemeBinder.bindWindowBackground. Pre-multiplying + // it into the shape scaled the value twice (0.70 -> 0.49) and made the fill stop matching + // its baseline role, so the shell no longer followed a surfaceContainerHigh override. + assertFalse("the shell must ship opaque so dialogOpacity is applied only once", + light.contains("withAlpha(tokens.colorSurfaceContainerHigh(), tokens.dialogOpacity())")); + assertFalse("the dialog title must not be a hard-coded hex colour", + light.contains("Color.parseColor(\"#202124\")")); + assertTrue(light.contains("titleView.setTextColor(ThemeController.current().colorOnSurface())")); + + String base = read("src/main/java/com/fongmi/android/tv/ui/dialog/BaseAlertDialog.java"); + assertTrue(base.contains("ThemeController.bindDialog(dialog)")); + assertFalse("MaterialAlertDialogBuilder owns the dialog OnShowListener", + base.contains("setOnShowListener")); + + // The channels the binder rewrites must really resolve through the + // semantic tokens, otherwise the exact-match guard could never fire. + for (String name : new String[]{"dialog_primary_button_bg", "dialog_primary_button_text", + "dialog_outlined_button_bg", "dialog_outlined_button_text", "dialog_outlined_button_stroke"}) { + String selector = read("src/main/res/color/" + name + ".xml"); + assertTrue(name + " must resolve through webhtv tokens", + selector.contains("@color/webhtv_color_")); + } + } + + @Test + public void seedDerivedTokensStayMappableFromTheFrozenBaseline() { + ThemeTokens baseline = ThemeTokens.light(); + ThemeTokens active = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0xFFB3261E, 0, null, null, false); + assertNotEquals("an explicit seed must really move the palette", + baseline.colorPrimary(), active.colorPrimary()); + // The seed derives focus from primary, so the shared-baseline ambiguity + // resolves and the view colour can be rewritten. + assertEquals(Integer.valueOf(active.colorPrimary()), + ThemeColorIndex.of(baseline).replacementFor(baseline.colorPrimary(), active)); + assertEquals(Integer.valueOf(active.colorSurface()), + ThemeColorIndex.of(baseline).replacementFor(baseline.colorSurface(), active)); + } + + @Test + public void aSeedDerivedBaselineWouldSilentlyDisableTheBinder() { + ThemeTokens staticPalette = ThemeTokens.light(); + ThemeTokens active = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0xFFB3261E, 0, null, null, false); + ThemeTokens seedBaseline = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0xFFB3261E, 0, null, null, false); + // Regression guard for the shipped defect: when the baseline was resolved + // through the legacy theme_color seed it matched no inflated view colour, + // so the binder bound nothing outside the legacy site dialog. + assertNull(ThemeColorIndex.of(seedBaseline) + .replacementFor(staticPalette.colorPrimary(), active)); + assertNull(ThemeColorIndex.of(seedBaseline) + .replacementFor(staticPalette.colorSurface(), active)); + } + + /** + * The TV flavour compiles the dark table into {@code values/} and ships no light + * table of its own, yet {@code frozenPalette()} used to derive the baseline from + * uiMode alone. On a light-mode device the baseline was therefore + * {@link ThemeTokens#light()} while inflation produced {@link ThemeTokens#dark()}; + * because every rewrite requires an exact baseline match, the whole TV theme + * channel bound nothing. Device evidence behind this guard: the same probe against + * the same build changed 0 pixels on TV while changing ~49k pixels on mobile, and + * switching the device to dark mode made TV respond (159174 px). + */ + @Test + public void tvFlavourBaselineMustDescribeTheCompiledResources() throws Exception { + ThemeTokens light = ThemeTokens.light(); + ThemeTokens compiled = ThemeTokens.dark(); + ThemeColorIndex fromLight = ThemeColorIndex.of(light); + ThemeColorIndex fromCompiled = ThemeColorIndex.of(compiled); + + // A light baseline cannot see a single colour the TV build actually inflated. + assertNull(fromLight.replacementFor(compiled.colorPrimary(), light)); + assertNull(fromLight.replacementFor(compiled.colorSurface(), light)); + // The compiled table does index them, so the corrected baseline can bind again. + assertFalse(fromCompiled.rolesFor(compiled.colorPrimary()).isEmpty()); + assertFalse(fromCompiled.rolesFor(compiled.colorSurface()).isEmpty()); + + // Pin the fix itself: the baseline must be read from the compiled resources + // instead of being inferred from uiMode, and it must be read through the context + // that inflates the views. Probing the application context instead re-broke the + // same invariant the other way round: on API < 31 a forced MODE_NIGHT_YES only + // reaches the activity's resources, so the application context still reported the + // light table while every inflated ?attr/color* was dark. + String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); + assertTrue(controller.contains("compiledDarkPalette(context)")); + assertTrue(controller.contains("R.color.webhtv_color_primary")); + assertTrue(controller.contains("R.color.webhtv_color_on_warning")); + assertTrue(controller.contains("resolvedDark(context)")); + assertTrue(controller.contains("baseline = frozenPalette(activity);")); + assertTrue(controller.contains("current = resolveWith(profile, activity);")); + assertFalse("the probe must not read the application context as its primary source", + controller.contains("Context context = App.get();")); + assertFalse("the frozen palette must not be derived from uiMode alone", + controller.contains("ThemeResolver.resolve(currentThemeMode(), ThemeSeed.NONE, 0, 0, null, null, systemDark)")); + } + + /** + * Pins the shared dark/light decision so the TV fix cannot silently re-break, and so + * the mobile answer is provably unchanged: + * + *

    + *
  • mobile light/dark: the compiled table equals the canonical table, so the + * result matches the pre-fix {@code mode}/{@code uiMode} answer; + *
  • TV: the compiled table is dark in every configuration, so TV stays dark even + * on a light-mode device or when the user explicitly picked light; + *
  • unidentifiable table: the historical rule is kept instead of guessing. + *
+ */ + @Test + public void darkPaletteDecisionKeepsMobileStableAndForcesTvOntoItsCompiledTable() { + // Mobile: compiled table matches the canonical one, so both branches agree and + // the pre-fix behaviour is preserved exactly. + assertFalse(ThemeController.darkPaletteFor(false, ThemeMode.SYSTEM, false)); + assertTrue(ThemeController.darkPaletteFor(true, ThemeMode.SYSTEM, true)); + assertTrue(ThemeController.darkPaletteFor(true, ThemeMode.DARK, false)); + + // TV: the compiled table wins over a light system or an explicit light choice. + assertTrue(ThemeController.darkPaletteFor(true, ThemeMode.SYSTEM, false)); + assertTrue(ThemeController.darkPaletteFor(true, ThemeMode.LIGHT, false)); + assertTrue(ThemeController.darkPaletteFor(true, ThemeMode.LIGHT, true)); + + // Unidentifiable table: keep the historical rule rather than guessing. + assertFalse(ThemeController.darkPaletteFor(null, ThemeMode.SYSTEM, false)); + assertTrue(ThemeController.darkPaletteFor(null, ThemeMode.SYSTEM, true)); + assertFalse(ThemeController.darkPaletteFor(null, ThemeMode.LIGHT, true)); + assertTrue(ThemeController.darkPaletteFor(null, ThemeMode.DARK, false)); + + // The canonical tables must stay identifiable from the flavour resources; a + // drift here is what silently disabled every TV rewrite. + assertNotEquals(ThemeTokens.light().colorPrimary(), ThemeTokens.dark().colorPrimary()); + assertNotEquals(ThemeTokens.light().colorSurface(), ThemeTokens.dark().colorSurface()); + } + + @Test + public void bottomSheetsBindThroughTheSharedDialogChannel() throws Exception { + String sheet = read("src/main/java/com/fongmi/android/tv/ui/dialog/BaseBottomSheetDialog.java"); + assertTrue(sheet.contains("ThemeController.bindDialog(dialog)")); + assertTrue(sheet.contains("bindDialogTheme();")); + String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); + assertTrue(controller.contains("public static void bindTheme(View root)")); + assertTrue(controller.contains("public static void bindDialog(Dialog dialog)")); + assertTrue(controller.contains("public static boolean hasProfileOverrides()")); + assertTrue(controller.contains("ThemeProfileStore.load()")); + } + + /** + * scrimOpacity has to stay a strict no-op while unset. + * + *

The modal scrims in this app do not use the token colour: the episode-detail and + * TMDB-person dialogs paint a translucent white scrim in light mode and translucent + * black in dark. Replacing those with {@code colorScrim()} (translucent black in both) + * was measured to invert the light-mode scrim, so the wiring keeps each dialog's own + * colour and only replaces its alpha when the user explicitly set the slot. This pins + * the two halves of that contract: an unset slot must return the shipped literal + * untouched, and a set slot must change only the alpha. + */ + @Test + public void scrimOpacityKeepsTheDialogOwnColourAndOnlyMovesAlphaWhenSet() throws Exception { + String episode = read("src/mobile/java/com/fongmi/android/tv/ui/dialog/EpisodeDetailDialog.java"); + String person = read("src/main/java/com/fongmi/android/tv/ui/dialog/TmdbPersonDialog.java"); + String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); + for (String source : new String[]{episode, person}) { + // The shipped literals must survive as the base colour... + assertTrue(source.contains("0x99F4F7FA")); + assertTrue(source.contains("applyScrimOpacity(")); + // ...the token must NOT be substituted for the dialog's own scrim colour... + assertFalse(source.contains("colorScrim()")); + // ...and the scrim maths must be delegated, not re-implemented. + assertTrue(source.contains( + "ThemeController.applyScrimOpacity(base, ThemeController.configuredScrimOpacity(light))")); + } + + // Behaviour of the shared maths, asserted directly rather than by string match. + int lightScrim = 0x99F4F7FA; + int darkScrim = 0xB3000000; + // Unset is a strict no-op: this is what keeps the shipped look byte-identical. + assertEquals(lightScrim, ThemeController.applyScrimOpacity(lightScrim, null)); + assertEquals(darkScrim, ThemeController.applyScrimOpacity(darkScrim, null)); + // Set replaces only the alpha, preserving hue. + assertEquals(0x00F4F7FA, ThemeController.applyScrimOpacity(lightScrim, 0f)); + // clamped low + assertEquals(0x00F4F7FA, ThemeController.applyScrimOpacity(lightScrim, -1f)); + assertEquals(0xFFF4F7FA, ThemeController.applyScrimOpacity(lightScrim, 1f)); + // clamped high + assertEquals(0xFFF4F7FA, ThemeController.applyScrimOpacity(lightScrim, 2f)); + assertEquals(0xD9F4F7FA, ThemeController.applyScrimOpacity(lightScrim, 0.85f)); + assertEquals(0xD9000000, ThemeController.applyScrimOpacity(darkScrim, 0.85f)); + // The accessor must report "unset" rather than the shipped default, otherwise the + // default rendering could change whenever the stored value equals the default. + assertTrue(controller.contains("public static Float configuredScrimOpacity(boolean light)")); + assertTrue(controller.contains("Float value = slots.scrimOpacity;")); + assertTrue(controller.contains("if (value == null || !Float.isFinite(value)) return null;")); + // The slot must be read from the same light/dark answer the dialog resolved. + assertTrue(controller.contains("ThemeProfile.SlotSet slots = light ? active.light : active.dark;")); + } + + /** + * The accessor must distinguish "user set the slot" from "the value happens to equal + * the shipped default", because the dialogs only move their own scrim alpha when the + * slot is genuinely set. Reads the private active profile via reflection rather than + * adding a test-only production setter. + */ + @Test + public void configuredScrimOpacityReportsUnsetSeparatelyFromAnyValue() throws Exception { + java.lang.reflect.Field field = ThemeController.class.getDeclaredField("profile"); + field.setAccessible(true); + Object previous = field.get(null); + try { + ThemeProfile profile = ThemeProfile.defaultProfile(); + + field.set(null, profile); + assertNull("an untouched profile must read as unset", ThemeController.configuredScrimOpacity(true)); + assertNull("an untouched profile must read as unset", ThemeController.configuredScrimOpacity(false)); + + profile.light.scrimOpacity = 0.85f; + assertEquals(Float.valueOf(0.85f), ThemeController.configuredScrimOpacity(true)); + assertNull("the dark half stays unset when only light was set", + ThemeController.configuredScrimOpacity(false)); + + // A value equal to the token baseline is still a user choice, not "unset". + profile.light.scrimOpacity = 0.322f; + assertEquals(Float.valueOf(0.322f), ThemeController.configuredScrimOpacity(true)); + + profile.dark.scrimOpacity = 0f; + assertEquals(Float.valueOf(0f), ThemeController.configuredScrimOpacity(false)); + } finally { + field.set(null, previous); + } + } + + /** + * overlayOpacity has no correct native landing site (its token is a divider colour and + * the only real image veil uses the frozen overlayDark), so it is documented in the + * editor as web-only instead of being wired to a wrong surface. + */ + @Test + public void overlayOpacityIsLabelledAsWebOnly() throws Exception { + assertTrue(read("src/main/res/values/strings.xml") + .contains("Overlay opacity (web theme only)")); + assertTrue(read("src/main/res/values-zh-rCN/strings.xml") + .contains("浮层透明度(仅 Web 主题生效)")); + assertTrue(read("src/main/res/values-zh-rTW/strings.xml") + .contains("浮層透明度(僅 Web 主題生效)")); + } + + private static String read(String path) throws Exception { + Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + return Files.readString(root.resolve(path), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeContractTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeContractTest.java new file mode 100644 index 0000000000..75db4d67f8 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeContractTest.java @@ -0,0 +1,209 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class ThemeContractTest { + + private static final Pattern COLOR = Pattern.compile("#([0-9A-Fa-f]{6,8})"); + + @Test + public void resourceMappingMatchesTheImmutableRecordForEveryColor() throws Exception { + assertResourceMapping(ThemeTokens.light(), Path.of("src/main/res/values/webhtv_tokens.xml")); + assertResourceMapping(ThemeTokens.dark(), Path.of("src/main/res/values-night/webhtv_tokens.xml")); + } + + @Test + public void allContrastPairsAreFrozenAndPass() { + ThemeTokens.light().requireContrast(); + ThemeTokens.dark().requireContrast(); + assertEquals(6.386, ThemeContrast.ratio(ThemeTokens.light().colorOnPrimary(), ThemeTokens.light().colorPrimary()), 0.002); + assertEquals(4.284, ThemeContrast.ratio(ThemeTokens.light().colorOutline(), ThemeTokens.light().colorSurface()), 0.002); + assertEquals(14.724, ThemeContrast.ratio(ThemeTokens.dark().colorPlayerControlActive(), 0xFF000000), 0.002); + } + + @Test + public void playerControlsAreIsolatedFromGeneralFocusAndPrimary() { + for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.light(), ThemeTokens.dark()}) { + assertNotEquals(tokens.colorPrimary(), tokens.colorPlayerControlActive()); + assertNotEquals(tokens.colorFocus(), tokens.colorPlayerControlActive()); + } + } + + @Test + public void allSemanticAttrsAreDeclared() throws Exception { + String attrs = Files.readString(Path.of("src/main/res/values/webhtv_attrs.xml"), StandardCharsets.UTF_8); + for (RecordComponent component : ThemeTokens.class.getRecordComponents()) { + if (!component.getType().equals(int.class)) continue; + assertTrue(component.getName(), attrs.contains("name=\"" + attrName(component.getName()) + "\"")); + } + assertFalse(attrs.contains("format=\"string\"")); + } + + /** + * A semantic attr that is declared and referenced but never assigned is a crash, not a + * silent fallback. + * + *

{@code ?attr/webhtvColorOnSurface} and {@code ?attr/webhtvColorOnSurfaceVariant} were + * declared in {@code webhtv_attrs.xml} and referenced by every {@code TextAppearance.WebHTV.*} + * style, but no theme ever assigned them - {@code Theme.WebHTV} only maps the standard + * Material attrs. Reading such a text appearance makes {@code TextView.readTextAppearance} + * call {@code TypedArray.getColorStateList} on an unresolvable attribute, which throws + * {@code UnsupportedOperationException} inside the constructor, so inflation dies. That is + * exactly how "TMDB data configuration" crashed on open (and with it every other dialog + * using {@code Widget.WebHTV.Label}/{@code Helper}/{@code SliderLabel}). + * + *

Guard: every referenced {@code webhtv*} attr must also be assigned by a theme, so the + * attrs and their assignments can never drift apart again. + */ + @Test + public void everyReferencedSemanticAttrIsAssignedByATheme() throws Exception { + Pattern reference = Pattern.compile("\\?attr/(webhtv[A-Za-z0-9_]+)"); + java.util.Set referenced = new java.util.TreeSet<>(); + for (String sourceSet : new String[]{"main", "mobile", "leanback"}) { + Path base = Path.of("src/" + sourceSet + "/res"); + if (!Files.exists(base)) continue; + try (java.util.stream.Stream paths = Files.walk(base)) { + for (Path path : paths.filter(p -> p.toString().endsWith(".xml")).toList()) { + Matcher matcher = reference.matcher(Files.readString(path, StandardCharsets.UTF_8)); + while (matcher.find()) referenced.add(matcher.group(1)); + } + } + } + + StringBuilder assignedSources = new StringBuilder(); + Path values = Path.of("src/main/res/values"); + try (java.util.stream.Stream paths = Files.walk(values)) { + for (Path path : paths.filter(p -> p.toString().endsWith(".xml")).toList()) { + if (path.getFileName().toString().equals("webhtv_attrs.xml")) continue; + assignedSources.append(Files.readString(path, StandardCharsets.UTF_8)); + } + } + String themes = assignedSources.toString(); + java.util.List unassigned = new java.util.ArrayList<>(); + for (String attr : referenced) { + // An assignment is an entry; the bare declaration is not enough. + if (!themes.contains("name=\"" + attr + "\">")) unassigned.add(attr); + } + assertTrue("these semantic attrs are referenced but no theme assigns them, so any view " + + "reading them crashes during inflation: " + unassigned, unassigned.isEmpty()); + } + + /** + * 与 {@link #everyReferencedSemanticAttrIsAssignedByATheme} 同一类陷阱,但针对 {@code tv*} 焦点语义属性。 + * + *

用户报告(2026-10-10):「现在选中播放器看不出选中的效果,好像是黑色的边框」。 + * 根因:{@code selector_control_sheet_button} 的焦点环从写死半透明白改成了 + * {@code ?attr/tvPlayerRing},而 {@code Theme.WebHTV.Dialog}(父主题 + * {@code Theme.Material3.DayNight.Dialog.Alert})与 BottomSheet 用的 Material 默认 + * {@code bottomSheetDialogTheme} 都**不继承** {@code Theme.WebHTV}。 + * 属性解析不到时,{@code ?attr/xxx} 会静默退化为黑色/透明,而且**不会崩溃**, + * 所以这种回归只有人眼才能发现。 + * + *

本测试把「每个被引用的 {@code tv*} 属性必须被至少一个主题 style 赋值」钉死, + * 并额外要求基底主题 {@code Theme.WebHTV} 自带全部五项——因为对话框主题的父主题链 + * 绕开了 flavour 的 {@code Theme.Base},只有在基底上绑定才能覆盖所有入口。 + */ + @Test + public void everyReferencedTvFocusAttrIsAssignedByATheme() throws Exception { + Pattern reference = Pattern.compile("\\?attr/(tv[A-Za-z0-9_]+)"); + java.util.Set referenced = new java.util.TreeSet<>(); + for (String sourceSet : new String[]{"main", "mobile", "leanback"}) { + Path base = Path.of("src/" + sourceSet + "/res"); + if (!Files.exists(base)) continue; + try (java.util.stream.Stream paths = Files.walk(base)) { + for (Path path : paths.filter(p -> p.toString().endsWith(".xml")).toList()) { + Matcher matcher = reference.matcher(Files.readString(path, StandardCharsets.UTF_8)); + while (matcher.find()) referenced.add(matcher.group(1)); + } + } + } + assertFalse("必须真的扫到 tv* 属性引用,否则本测试是空断言", referenced.isEmpty()); + + // 赋值来源:全部 values 目录(含 values-night / 各 flavour 的 styles)。 + StringBuilder assigned = new StringBuilder(); + for (String sourceSet : new String[]{"main", "mobile", "leanback"}) { + Path values = Path.of("src/" + sourceSet + "/res/values"); + if (!Files.exists(values)) continue; + try (java.util.stream.Stream paths = Files.walk(values)) { + for (Path path : paths.filter(p -> p.toString().endsWith(".xml")).toList()) { + assigned.append(Files.readString(path, StandardCharsets.UTF_8)); + } + } + } + String themes = assigned.toString(); + java.util.List unassigned = new java.util.ArrayList<>(); + for (String attr : referenced) { + if (!themes.contains("name=\"" + attr + "\">")) unassigned.add(attr); + } + assertTrue("these tv* focus attrs are referenced but no theme assigns them, so ?attr/xxx " + + "silently degrades to black/transparent (measured defect): " + unassigned, + unassigned.isEmpty()); + + // 基底主题必须自带全部五项,覆盖那些父主题链绕开 Theme.Base 的对话框。 + String base = Files.readString(Path.of("src/main/res/values/webhtv_styles.xml"), StandardCharsets.UTF_8); + Matcher baseTheme = Pattern.compile( + "", Pattern.DOTALL).matcher(base); + assertTrue("必须能读到 Theme.WebHTV 定义", baseTheme.find()); + String baseBody = baseTheme.group(1); + for (String attr : new String[]{"tvFocusRing", "tvCurrentRing", "tvNormalStroke", + "tvPlayerRing", "tvPlayerCurrentRing"}) { + assertTrue("Theme.WebHTV 必须绑定 " + attr + + "(对话框/BottomSheet 的父主题链不经过 Theme.Base,未绑定会静默退化)", + baseBody.contains("name=\"" + attr + "\">")); + } + } + + private static void assertResourceMapping(ThemeTokens tokens, Path path) throws Exception { + Map colors = colors(path); + // 计数只覆盖 webhtv_color_* 这一族(见上面的 COLOR 模式),且刻意冻结:新增语义色 + // 必须在这里显式承认一次,避免"顺手加个颜色"绕过角色契约。 + // 51 → 54:新增 webhtv_color_badge_{progress,site,year}(卡片角标底色, + // 见 docs/TV-CARD-BADGE-CONTRAST-20261012.md)。它们不属于 ThemeTokens 的 49 项 + // 角色契约,只是同一命名族里的固定语义色,与 webhtv_on_wallpaper 同类。 + // 54 → 55:新增 webhtv_color_on_wallpaper_error(壁纸上的错误前景色, + // 见 docs/TV-ERROR-STATE-20261012.md),同理不属于角色契约。 + // 55 → 56:新增 webhtv_color_error_value_bg(错误取值文字的实心深色底, + // 让错误正文稳过 4.5:1,见 docs/TV-ERROR-STATE-CLOSEOUT-20261013.md)。 + assertEquals(56, colors.size()); + for (RecordComponent component : ThemeTokens.class.getRecordComponents()) { + if (!component.getType().equals(int.class)) continue; + String name = component.getName(); + String resource = "webhtv_color_" + snakeCase(name.substring("color".length())); + Integer actual = colors.get(resource); + assertTrue(path + " missing " + resource, actual != null); + assertEquals(resource, component.getAccessor().invoke(tokens), actual); + } + } + + private static Map colors(Path path) throws Exception { + Map colors = new LinkedHashMap<>(); + Matcher matcher = COLOR.matcher(Files.readString(path, StandardCharsets.UTF_8)); + while (matcher.find()) { + long value = Long.parseLong(matcher.group(2), 16); + colors.put(matcher.group(1), matcher.group(2).length() == 6 ? (int) (0xFF000000L | value) : (int) value); + } + return colors; + } + + private static String attrName(String getter) { + return "webhtv" + Character.toUpperCase(getter.charAt(0)) + getter.substring(1); + } + + private static String snakeCase(String value) { + return value.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(java.util.Locale.US); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeControllerContractTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeControllerContractTest.java new file mode 100644 index 0000000000..9f1b8b07ee --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeControllerContractTest.java @@ -0,0 +1,215 @@ +package com.fongmi.android.tv.theme; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * The unified theme entry point must resolve the persisted WebHTV appearance + * preference into the shared semantic tokens so every module (following, lab, + * dialogs, web pages) reads one source of truth. + */ +public class ThemeControllerContractTest { + + @Test + public void controllerExposesPreferenceBackedResolution() throws Exception { + String source = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); + assertTrue(source.contains("public static ThemeTokens resolveFromPreferences()")); + assertTrue(source.contains("public static void applyFromPreferences(AppCompatActivity activity)")); + assertTrue(source.contains("Setting.getThemeColor()")); + assertTrue(source.contains("Setting.getWallColor()")); + assertTrue(source.contains("ThemeSeed.WALLPAPER")); + assertTrue(source.contains("ThemeSeed.EXPLICIT")); + } + + @Test + public void appearanceModeDrivesAppCompatNightMode() throws Exception { + String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); + assertTrue(controller.contains("public static void applyNightModeToApp()")); + assertTrue(controller.contains("AppCompatDelegate.MODE_NIGHT_NO")); + assertTrue(controller.contains("AppCompatDelegate.MODE_NIGHT_YES")); + assertTrue(controller.contains("AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM")); + assertTrue(controller.contains("ThemeMode themeMode = currentThemeMode();")); + assertTrue(read("src/main/java/com/fongmi/android/tv/App.java").contains("ThemeController.applyNightModeToApp();")); + for (String flavour : new String[]{"mobile", "leanback"}) { + String dialog = read("src/" + flavour + "/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java"); + assertTrue(dialog.contains("setting_theme_mode")); + assertTrue(dialog.contains("Setting.putThemeMode(mode);")); + assertTrue(dialog.contains("ThemeController.applyNightModeToApp();")); + } + assertTrue(read("src/main/java/com/fongmi/android/tv/setting/Setting.java").contains("public static int getThemeMode()")); + } + + @Test + public void everyActivityAppliesThePersistedThemeSnapshot() throws Exception { + String mobile = read("src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java"); + String leanback = read("src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java"); + assertTrue(mobile.contains("ThemeController.applyFromPreferences(this);")); + assertTrue(leanback.contains("ThemeController.applyFromPreferences(this);")); + } + + @Test + public void legacyPalettesDelegateToSemanticTokens() throws Exception { + String colors = read("src/main/res/values/colors.xml"); + assertTrue(colors.contains("@color/webhtv_color_surface")); + assertTrue(colors.contains("@color/webhtv_color_primary")); + assertTrue(colors.contains("@color/webhtv_color_error")); + assertTrue(colors.contains("@color/webhtv_color_health_good")); + assertTrue(colors.contains("@color/webhtv_color_health_bad")); + String lab = read("src/main/res/values/lab_colors.xml"); + assertTrue(lab.contains("@color/webhtv_color_surface_container_high")); + assertTrue(lab.contains("@color/webhtv_color_on_surface")); + } + + @Test + public void followingAndDetailSurfacesUseSemanticAttributes() throws Exception { + String following = read("src/main/res/layout/activity_following.xml"); + // 追更页改为壁纸背景:根布局必须透明,靠半透明面板承载文字。 + assertFalse(following.contains("android:background=\"?attr/colorSurface\"")); + assertTrue(following.contains("@drawable/shape_following_panel")); + assertTrue(following.contains("app:backgroundTint=\"?attr/colorPrimary\"")); + assertTrue(following.contains("android:textColor=\"?attr/colorOnSurfaceVariant\"")); + String card = read("src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbCastPresenter.java"); + assertTrue(card.contains("ThemeController.current()")); + assertTrue(card.contains("tokens.colorSurfaceContainerHigh()")); + // 焦点环已由前景 selector(?attr/tvFocusRing,取值 tv_item_focus_ring)统一绘制, + // presenter 只维护常态描边与卡面 token;不再自己画焦点描边以免双重描边。 + assertFalse(card.contains("tokens.colorFocus()")); + assertTrue(card.contains("STROKE_NORMAL")); + String video = read("src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbVideoPresenter.java"); + assertTrue(video.contains("selector_tmdb_media_focus")); + String dialog = read("src/mobile/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java"); + // 行的底色/描边与两个文字色必须来自同一个调色板:此前行底是固定浅色 + // selector_git_cloud_card,而文字已跟随 ThemeController.current(),在 TV 深色表上 + // 实测 1.16:1 / 1.36:1。现在两者都经由 AppearanceRowTheme 取当前 token。 + assertTrue(dialog.contains("AppearanceRowTheme.apply(row, title, summary, ThemeController.current())")); + String rowTheme = read("src/main/java/com/fongmi/android/tv/theme/AppearanceRowTheme.java"); + assertTrue(rowTheme.contains("safe.colorOnSurface()")); + assertTrue(rowTheme.contains("safe.colorOnSurfaceVariant()")); + } + + @Test + public void snapshotIsResolvedBeforeTheFirstContentView() throws Exception { + for (String flavour : new String[]{"mobile", "leanback"}) { + String source = read("src/" + flavour + "/java/com/fongmi/android/tv/ui/base/BaseActivity.java"); + int apply = source.indexOf("ThemeController.applyFromPreferences(this);"); + int content = source.indexOf("setContentView("); + assertTrue(flavour + " must apply the persisted snapshot", apply > 0); + assertTrue(flavour + " must set a content view", content > 0); + assertTrue(flavour + " must resolve the theme snapshot before its first content view", apply < content); + } + } + + @Test + public void controllerReadsTheV2ProfileAndKeepsABaselineSnapshot() throws Exception { + String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); + assertTrue(controller.contains("ThemeProfileStore.load()")); + assertTrue(controller.contains("private static ThemeTokens resolveWith(ThemeProfile profile, Context context)")); + assertTrue(controller.contains("baseline = frozenPalette(activity);")); + assertFalse("the binder baseline must never be seed-derived", + controller.contains("baseline = resolveWith(")); + assertTrue(controller.contains("private static ThemeTokens frozenPalette(Context context)")); + assertTrue(controller.contains("current = resolveWith(profile, activity);")); + assertTrue(controller.contains("ThemeBinder.bind(root, baseline, current);")); + assertTrue(controller.contains("public static ThemeTokens baseline()")); + } + + @Test + public void ordinaryUiNightDecisionComesFromTheThemeController() throws Exception { + String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); + assertTrue(controller.contains("public static boolean isNight(Context context)")); + String chrome = read("src/mobile/java/com/fongmi/android/tv/ui/activity/WebHomeChromeController.java"); + assertTrue(chrome.contains("ThemeController.isNight(activity)")); + assertFalse(chrome.contains("UI_MODE_NIGHT_MASK")); + } + + /** + * TV 产品默认深色(与上游一致:上游 TV 主题直接继承 {@code Theme.Material3.Dark})。 + * + *

理由:电视多在暗环境观看,大面积浅色亮底在夜间刺眼;而且 TV 常见的深色底上, + * 上游那套**白色焦点环**(对深色 surface 17–18:1)才能成立—— + * 白环对浅色表 surface 只有 1.05:1,等于看不见。 + * + *

“跟随系统”对 TV 不再等于“跟随一个通常恒为浅色的系统默认”,否则电视上会默认落到 + * 浅色表。用户仍可在外观设置里显式选浅色/深色;mobile 保持“跟随系统”不变。 + */ + @Test + public void tvDefaultsToDarkWhileMobileKeepsFollowingTheSystem() throws Exception { + String controller = read("src/main/java/com/fongmi/android/tv/theme/ThemeController.java"); + assertTrue("TV 默认必须落到深色:follow-system 分支要按 flavour 分流", + controller.contains("Util.isLeanback()") + && controller.contains("AppCompatDelegate.MODE_NIGHT_YES") + && controller.contains("AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM")); + // 显式选择仍然优先(浅色/深色都能生效) + assertTrue("显式浅色/深色必须仍然优先", + controller.contains("case 0 -> AppCompatDelegate.MODE_NIGHT_NO;") + && controller.contains("case 1 -> AppCompatDelegate.MODE_NIGHT_YES;")); + } + + /** + * 深色表焦点环是「近白」(与上游的白色焦点环观感一致),但不能是纯白。 + * + *

本仓库有 {@link ThemeBinder} 运行时改写通道,它按「颜色 → 语义角色」精确匹配; + * 而 {@code webhtv_on_wallpaper} 两张表恒为 {@code #FFFFFF}。若焦点也取纯白, + * {@code ThemeColorIndex} 会把纯白映射到 FOCUS 角色,用户切换/自定义主题时 + * 壁纸上的白色文字会被改写成焦点色({@code ThemeBaseWiringTest} 钉住该不变量)。 + * 所以这里断言:深色焦点必须亮(对 surface 高对比)且**不等于**纯白。 + */ + @Test + public void darkFocusRingIsNearWhiteButNeverPureWhite() throws Exception { + String night = read("src/main/res/values-night/webhtv_tokens.xml"); + Matcher matcher = java.util.regex.Pattern + .compile("#([0-9A-Fa-f]{6})") + .matcher(night); + assertTrue("深色表必须声明 webhtv_color_focus", matcher.find()); + int focus = (int) Long.parseLong(matcher.group(1), 16); + + assertFalse("深色焦点环不得为纯白:会与 webhtv_on_wallpaper 碰撞,binder 会把壁纸白字改写成焦点色", + focus == 0xFFFFFF); + double ratio = ThemeContrast.ratio(0xFF000000 | focus, ThemeTokens.dark().colorSurface()); + assertTrue("深色焦点环必须对 surface 保持高对比(实测 " + String.format("%.2f", ratio) + ":1)", + ratio >= 10.0); + // 与 ThemeTokens.dark() 的字面量保持一致 + assertEquals("深色表资源必须与 ThemeTokens.dark().colorFocus() 同值", + 0xFF000000 | focus, ThemeTokens.dark().colorFocus()); + } + + /** + * 追更页按钮的「填充 ↔ 文本」必须始终配对。 + * + *

这两个按钮的 backgroundTint 在 focused/pressed 时会换成 + * {@code following_button_focus}(= {@code webhtv_color_focus})。此前文本写死 + * {@code @android:color/white},在浅色表下凑巧可读,但深色表下填充是浅色而文本仍为白 + * (1.00:1,完全不可读);TV 默认改深色后该缺陷会直接显形。 + */ + @Test + public void followingButtonTextPairsWithItsFocusFill() throws Exception { + for (String file : new String[]{"following_button_primary_text.xml", "following_button_secondary_text.xml"}) { + // 去注释后再断言:注释里会引用历史值作说明,不是实际取值。 + String body = read("src/main/res/color/" + file).replaceAll("(?s)", ""); + assertFalse(file + " 不得再用写死白色文本(深色表下与浅色填充 1.00:1 不可读)", + body.contains("@android:color/white")); + assertTrue(file + " 的焦点/按下文本必须取与填充配对的 on-色", + body.contains("@color/webhtv_color_on_primary")); + } + // 两张表的配对都必须过对比度门(文本 4.5:1) + for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.light(), ThemeTokens.dark()}) { + double ratio = ThemeContrast.ratio(tokens.colorOnPrimary(), tokens.colorFocus()); + assertTrue("焦点填充配 on-色文本必须 ≥4.5:1(实测 " + String.format("%.2f", ratio) + ":1)", + ratio >= 4.5); + } + } + + private static String read(String path) throws Exception { + Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + return Files.readString(root.resolve(path), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeEditorContractTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeEditorContractTest.java new file mode 100644 index 0000000000..e1edd199ec --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeEditorContractTest.java @@ -0,0 +1,183 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +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 ThemeEditorContractTest { + + @Test + public void editorStartsFromThePersistedProfileAndIsNotDirty() { + ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); + assertFalse(editor.isDirty()); + assertNotNull(editor.draft()); + // The draft must be a copy: mutating it cannot touch the original. + ThemeProfile draft = editor.draft(); + draft.light.primary = "#123456"; + assertFalse(editor.isDirty()); + } + + @Test + public void everyColorSlotRoundTripsThroughTheDraft() { + for (ThemeEditor.Slot slot : ThemeEditor.Slot.values()) { + if (!slot.isColor()) continue; + ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); + ThemeEditor.Result result = editor.set(slot, false, "#12AB34", 0f); + assertTrue(slot.name(), result.success()); + assertEquals(slot.name(), "#12AB34", editor.valueOf(slot, false)); + } + } + + @Test + public void everyOpacitySlotEnforcesItsOwnRange() { + ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); + assertTrue(editor.set(ThemeEditor.Slot.SCRIM_OPACITY, false, null, 0.85f).success()); + assertTrue(editor.set(ThemeEditor.Slot.DIALOG_OPACITY, false, null, 0.70f).success()); + assertTrue(editor.set(ThemeEditor.Slot.OVERLAY_OPACITY, false, null, 0.05f).success()); + assertTrue(editor.set(ThemeEditor.Slot.PLAYER_SCRIM_OPACITY, false, null, 0.20f).success()); + assertTrue(editor.set(ThemeEditor.Slot.PLAYER_SCRIM_OPACITY, false, null, 1.00f).success()); + + assertFalse(editor.set(ThemeEditor.Slot.SCRIM_OPACITY, false, null, 0.86f).success()); + assertFalse(editor.set(ThemeEditor.Slot.DIALOG_OPACITY, false, null, 0.69f).success()); + assertFalse(editor.set(ThemeEditor.Slot.OVERLAY_OPACITY, false, null, 0.61f).success()); + assertFalse(editor.set(ThemeEditor.Slot.OVERLAY_OPACITY, false, null, Float.NaN).success()); + assertFalse(editor.set(ThemeEditor.Slot.PLAYER_SCRIM_OPACITY, false, null, 0.19f).success()); + assertFalse(editor.set(ThemeEditor.Slot.PLAYER_SCRIM_OPACITY, false, null, 1.01f).success()); + assertFalse(editor.set(ThemeEditor.Slot.PLAYER_SCRIM_OPACITY, false, null, Float.NaN).success()); + } + + @Test + public void invalidColorsAreRejectedWithoutTouchingTheDraft() { + ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); + assertFalse(editor.set(ThemeEditor.Slot.PRIMARY, false, "#12", 0f).success()); + assertFalse(editor.set(ThemeEditor.Slot.PRIMARY, false, "#80112233", 0f).success()); + assertFalse(editor.set(ThemeEditor.Slot.PRIMARY, false, "red", 0f).success()); + assertNull(editor.valueOf(ThemeEditor.Slot.PRIMARY, false)); + } + + @Test + public void clearingASlotRestoresInheritance() { + ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); + assertTrue(editor.set(ThemeEditor.Slot.SURFACE, false, "#101820", 0f).success()); + assertTrue(editor.clear(ThemeEditor.Slot.SURFACE, false).success()); + assertNull(editor.valueOf(ThemeEditor.Slot.SURFACE, false)); + assertFalse(editor.isDirty()); + } + + @Test + public void lightAndDarkSlotsAreIndependent() { + ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); + editor.set(ThemeEditor.Slot.PRIMARY, false, "#111111", 0f); + editor.set(ThemeEditor.Slot.PRIMARY, true, "#222222", 0f); + assertEquals("#111111", editor.valueOf(ThemeEditor.Slot.PRIMARY, false)); + assertEquals("#222222", editor.valueOf(ThemeEditor.Slot.PRIMARY, true)); + } + + @Test + public void modeAndSeedMutationsAreNormalised() { + ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); + assertEquals(ThemeProfile.MODE_DARK, editor.setMode("dark").profile().mode); + assertEquals(ThemeProfile.MODE_SYSTEM, editor.setMode("nonsense").profile().mode); + + ThemeEditor.Result custom = editor.setSeed(ThemeProfile.SEED_CUSTOM, "#0B57D0"); + assertTrue(custom.success()); + assertEquals("#0B57D0", custom.profile().seedColor); + assertFalse(editor.setSeed(ThemeProfile.SEED_CUSTOM, "nope").success()); + + ThemeEditor.Result none = editor.setSeed(ThemeProfile.SEED_NONE, null); + assertTrue(none.success()); + assertNull(none.profile().seedColor); + } + + @Test + public void previewResolvesTheDraftWithoutPersistingIt() { + ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); + editor.set(ThemeEditor.Slot.PRIMARY, false, "#123456", 0f); + ThemeTokens tokens = editor.preview(ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, false); + assertEquals(0xFF123456, tokens.colorPrimary()); + // A draft-only edit must not reach persistence. + assertEquals(-1, ThemeProfileStore.legacyThemeColor(ThemeProfileStore.load())); + assertEquals(ThemeProfile.SCHEMA_VERSION, editor.draft().schemaVersion); + } + + @Test + public void editorRejectsUnknownSlotAndNullColorInsteadOfCrashing() { + ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); + assertEquals(false, editor.set(null, false, "#FFFFFF", 0f).success()); + assertEquals(false, editor.clear(null, false).success()); + assertFalse(editor.set(ThemeEditor.Slot.PRIMARY, false, null, 0f).success()); + assertFalse(editor.set(ThemeEditor.Slot.PRIMARY, true, " ", 0f).success()); + assertNull(editor.valueOf(null, false)); + } + + @Test + public void dialogsExposeTheSharedEditorWithoutLegacyColorOnlyPath() throws Exception { + for (String flavour : new String[]{"mobile", "leanback"}) { + String dialog = read("src/" + flavour + "/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java"); + assertTrue(flavour, dialog.contains("ThemeEditor.load()")); + assertTrue(flavour, dialog.contains("ThemePreviewView.createPanel")); + assertTrue(flavour, dialog.contains("ThemeColorPickerDialog.create")); + assertTrue(flavour, dialog.contains("editor.apply()")); + assertTrue(flavour, dialog.contains("editor.reset()")); + assertFalse("the old 14-swatch dialog must be gone", dialog.contains("private static final int[] COLORS")); + String appearance = read("src/" + flavour + "/java/com/fongmi/android/tv/ui/dialog/AppearanceDialog.java"); + assertFalse(flavour + " must not write theme_color directly", appearance.contains("Setting.putThemeColor(")); + } + } + + @Test + public void previewAndPickerAvoidPlayerTokensAndReflection() throws Exception { + String preview = read("src/main/java/com/fongmi/android/tv/theme/ThemePreviewView.java"); + assertFalse(preview.contains("colorPlayerControl")); + assertFalse(preview.contains("colorHealth")); + String picker = read("src/main/java/com/fongmi/android/tv/theme/ThemeColorPickerDialog.java"); + assertTrue(picker.contains("ThemeProfileValidator.normalizeColor")); + assertFalse(picker.contains("setAccessible")); + assertFalse(picker.contains("getRecordComponents")); + } + + private static String read(String path) throws Exception { + Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + return Files.readString(root.resolve(path), StandardCharsets.UTF_8); + } + + /** + * The editor builds its panels from platform widgets ({@code new TextView(context)}, + * {@code new Button(...)}, {@code new EditText(...)}). Those carry no Material colour + * role, so they keep the framework default - measured on device as Material's + * {@code #49454F}, which stayed put no matter which surface the user picked and left + * the labels unreadable on a dark custom surface. Every programmatic text-bearing + * widget in the editor must therefore be given a token colour. + * + *

The check follows the declared variable, so colouring it anywhere in the class + * (for example the status line, which is recoloured on every update) satisfies it. + */ + @Test + public void programmaticEditorWidgetsAlwaysGetTokenColours() throws Exception { + java.util.regex.Pattern construction = java.util.regex.Pattern + .compile("(?:([A-Za-z_][\\w]*)\\s*=\\s*)?new\\s+(?:android\\.widget\\.)?(TextView|EditText|Button)\\("); + for (String path : new String[]{ + "src/main/java/com/fongmi/android/tv/theme/ThemeColorPickerDialog.java", + "src/mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java", + "src/leanback/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java"}) { + String source = read(path); + java.util.regex.Matcher m = construction.matcher(source); + while (m.find()) { + String variable = m.group(1); + assertNotNull(path + ": programmatic " + m.group(2) + " must be assigned to a named widget", + variable); + assertTrue(path + ": " + variable + " (" + m.group(2) + + ") must set an explicit token text colour", + source.contains(variable + ".setTextColor(")); + } + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeInlinePreviewContractTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeInlinePreviewContractTest.java new file mode 100644 index 0000000000..0aa36a23d2 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeInlinePreviewContractTest.java @@ -0,0 +1,83 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.*; + +/** Source-level boundaries complement the model tests and real view/device tests. */ +public class ThemeInlinePreviewContractTest { + + @Test + public void bothFlavoursOwnALocalSnapshotAndPutPresetsFirst() throws Exception { + String mobile = read("mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java"); + String tv = read("leanback/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java"); + assertNotEquals("TV mode persistence must remain an explicit TV-only contract", mobile, tv); + for (String source : new String[]{mobile, tv}) { + assertTrue(source.contains("editor.preview(dark, wallpaperColor)")); + assertTrue(source.contains("root.setTag(\"webhtv:ignore\")")); + assertTrue(source.indexOf("content.addView(buildPresetRow()") < source.indexOf("content.addView(buildPaletteRow()")); + assertFalse(source.contains("ThemeController.current()")); + assertFalse(source.contains("ThemeController.apply(")); + assertFalse(source.contains("removeAllViews()")); + assertTrue(source.contains("panel.render(editor, dark, tokens)")); + assertTrue(source.contains("preview_draft")); + assertTrue(source.contains("preview_dark")); + assertTrue(source.contains("preview_scroll")); + assertTrue(source.contains("previewTokens, hex ->")); + } + assertTrue(tv.contains("editor.setMode(dark ? ThemeProfile.MODE_DARK : ThemeProfile.MODE_LIGHT)")); + assertTrue(tv.contains("Setting.putThemeMode(dark ? 1 : 0)")); + assertTrue(tv.contains("ThemeController.applyNightModeToApp()")); + assertFalse(mobile.contains("editor.setMode(dark ? ThemeProfile.MODE_DARK : ThemeProfile.MODE_LIGHT)")); + } + + @Test + public void resetAndSelectionDoNotPublishAndSavingDoesNotDismissOnFailure() throws Exception { + String editor = read("main/java/com/fongmi/android/tv/theme/ThemeEditor.java"); + assertTrue(editor.contains("return replace(ThemeProfile.defaultProfile())")); + assertFalse(editor.contains("ThemeProfileStore.reset()")); + String dialog = read("mobile/java/com/fongmi/android/tv/ui/dialog/ThemeDialog.java"); + assertTrue(dialog.contains("resetButton.setOnClickListener(view -> { editor.reset(); render(); })")); + assertFalse(dialog.contains(".setPositiveButton(")); + int apply = dialog.indexOf("private void applyDraft()"); + int failed = dialog.indexOf("if (!result.success())", apply); + int returnOnFailure = dialog.indexOf("return;", failed); + int dismiss = dialog.indexOf("dismissAllowingStateLoss();", apply); + assertTrue(apply >= 0 && failed > apply && returnOnFailure < dismiss); + assertTrue(dialog.contains("else RefreshEvent.theme();")); + } + + @Test + public void realColorRowsReplaceTheBottomSampleAndShowResolvedValues() throws Exception { + String source = read("main/java/com/fongmi/android/tv/theme/ThemePreviewView.java"); + assertFalse(source.contains("theme_editor_preview_title")); + assertFalse(source.contains("theme_editor_preview_filled")); + assertFalse(source.contains("theme_editor_preview_tonal")); + assertFalse(source.contains("theme_editor_inherit")); + assertTrue(source.contains("theme_editor_value_auto")); + assertTrue(source.contains("theme_editor_value_adjusted")); + assertFalse(source.contains("ThemeController.current()")); + assertFalse(source.contains("removeAllViews()")); + assertTrue(source.contains("if (!fromUser || binding) return;")); + assertTrue(source.contains("screenWidthDp >= 700")); + assertTrue(source.contains("tokens.colorSuccess()")); + assertTrue(source.contains("tokens.colorWarning()")); + } + + @Test + public void localInteractiveControlsKeepExplicitFocusAndReadableText() throws Exception { + String source = read("main/java/com/fongmi/android/tv/theme/ThemeEditorUi.java"); + assertTrue(source.contains("android.R.attr.state_focused")); + assertTrue(source.contains("ThemeContrast.ratio")); + assertTrue(source.contains("dp(context, 48)")); + assertFalse(source.contains("ThemeController")); + } + + private static String read(String path) throws Exception { + Path root = Files.isDirectory(Path.of("src")) ? Path.of("src") : Path.of("app/src"); + return Files.readString(root.resolve(path)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemePaletteStyleTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemePaletteStyleTest.java new file mode 100644 index 0000000000..55c7cb1d32 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemePaletteStyleTest.java @@ -0,0 +1,46 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class ThemePaletteStyleTest { + + @Test + public void unknownOrMissingStylesKeepTheTonalSpotDefault() { + assertEquals(ThemePaletteStyle.TONAL_SPOT, ThemePaletteStyle.from(null)); + assertEquals(ThemePaletteStyle.TONAL_SPOT, ThemePaletteStyle.from("not-a-style")); + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.paletteStyle = "not-a-style"; + assertTrue(ThemeProfileValidator.validate(profile).valid()); + assertEquals(ThemePaletteStyle.TONAL_SPOT.id(), profile.paletteStyle); + } + + @Test + public void everyBuiltInStyleProducesAContrastingLightAndDarkScheme() { + for (ThemePaletteStyle style : ThemePaletteStyle.values()) { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.paletteStyle = style.id(); + ThemeTokens light = ThemeResolver.resolve(ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); + ThemeTokens dark = ThemeResolver.resolve(ThemeMode.DARK, ThemeSeed.NONE, 0, 0, profile, null, true); + light.requireContrast(); + dark.requireContrast(); + if (style != ThemePaletteStyle.TONAL_SPOT) { + assertNotEquals(style.name() + " should affect the light palette", + ThemeTokens.light().colorPrimary(), light.colorPrimary()); + assertNotEquals(style.name() + " should affect the dark palette", + ThemeTokens.dark().colorPrimary(), dark.colorPrimary()); + } + } + } + + @Test + public void profileRoundTripKeepsPaletteStyle() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.paletteStyle = ThemePaletteStyle.EXPRESSIVE.id(); + ThemeProfile parsed = ThemeProfileCodec.parse(ThemeProfileCodec.encode(profile)); + assertEquals(ThemePaletteStyle.EXPRESSIVE.id(), parsed.paletteStyle); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemePresetsTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemePresetsTest.java new file mode 100644 index 0000000000..bb4e122b27 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemePresetsTest.java @@ -0,0 +1,159 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.*; + +public class ThemePresetsTest { + + @Test + public void defaultIsFirstAndStaysByteIdenticalToTheFrozenPalette() { + assertEquals(ThemePresets.Preset.DEFAULT, ThemePresets.Preset.values()[0]); + ThemeProfile profile = ThemePresets.Preset.DEFAULT.profile(); + assertEquals(ThemeProfileCodec.encode(ThemeProfile.defaultProfile()), ThemeProfileCodec.encode(profile)); + ThemeEditor editor = new ThemeEditor(profile); + assertEquals(ThemeTokens.light(), editor.preview(false, 0)); + assertEquals(ThemeTokens.dark(), editor.preview(true, 0)); + } + + @Test + public void everyNamedPaletteDefinesAllThirteenColorsInBothModes() { + for (ThemePresets.Preset preset : ThemePresets.Preset.values()) { + if (!preset.isComplete()) continue; + ThemeProfile profile = preset.profile(); + ThemeEditor editor = new ThemeEditor(profile); + for (boolean dark : new boolean[]{false, true}) { + for (ThemeEditor.Slot slot : ThemeEditor.Slot.values()) { + if (slot.isColor()) assertNotNull(preset + "/" + dark + "/" + slot, editor.valueOf(slot, dark)); + } + ThemeTokens tokens = editor.preview(dark, 0); + assertFalse(preset + ": " + ThemeResolver.lastDiagnostic(), ThemeResolver.lastDiagnostic().startsWith("fallback")); + tokens.requireContrast(); + assertEquals(tokens.colorPrimary(), tokens.colorFocus()); + assertNotEquals(tokens.colorSurface(), tokens.colorSurfaceContainer()); + assertNotEquals(tokens.colorSurfaceContainer(), tokens.colorSurfaceContainerHigh()); + for (int background : new int[]{tokens.colorSurface(), tokens.colorSurfaceContainer(), tokens.colorSurfaceContainerHigh()}) { + assertTrue(preset + " secondary text contrast", ThemeContrast.ratio(tokens.colorOnSurfaceVariant(), background) >= 4.5); + } + } + String encoded = ThemeProfileCodec.encode(profile); + assertEquals(encoded, ThemeProfileCodec.encode(ThemeProfileCodec.parse(encoded))); + } + } + + @Test + public void palettesDifferInSurfacesSecondaryAndSemanticColorsNotOnlyPrimary() { + for (boolean dark : new boolean[]{false, true}) { + Set surfaces = new HashSet<>(); + Set secondary = new HashSet<>(); + Set success = new HashSet<>(); + Set warning = new HashSet<>(); + ThemeTokens baseline = dark ? ThemeTokens.dark() : ThemeTokens.light(); + for (ThemePresets.Preset preset : ThemePresets.Preset.values()) { + if (!preset.isComplete()) continue; + ThemeTokens tokens = new ThemeEditor(preset.profile()).preview(dark, 0); + surfaces.add(tokens.colorSurfaceContainerHigh()); + secondary.add(tokens.colorSecondaryContainer()); + success.add(tokens.colorSuccess()); + warning.add(tokens.colorWarning()); + assertNotEquals(preset + " success must be intentional", baseline.colorSuccess(), tokens.colorSuccess()); + assertNotEquals(preset + " warning must be intentional", baseline.colorWarning(), tokens.colorWarning()); + double greenHue = com.google.android.material.color.utilities.Hct.fromInt(tokens.colorSuccess()).getHue(); + double amberHue = com.google.android.material.color.utilities.Hct.fromInt(tokens.colorWarning()).getHue(); + assertTrue("success remains green: " + greenHue, greenHue >= 100 && greenHue <= 190); + assertTrue("warning remains amber: " + amberHue, amberHue >= 40 && amberHue <= 110); + } + assertEquals(7, surfaces.size()); + assertEquals(7, secondary.size()); + assertTrue(success.size() >= 6); + assertTrue(warning.size() >= 6); + } + } + + @Test + public void everyPaletteStyleIsSafeForEveryCompleteTemplate() { + for (ThemePresets.Preset preset : ThemePresets.Preset.values()) { + if (!preset.isComplete()) continue; + for (ThemePaletteStyle style : ThemePaletteStyle.values()) { + ThemeEditor editor = new ThemeEditor(preset.profile(style)); + for (boolean dark : new boolean[]{false, true}) { + ThemeTokens tokens = editor.preview(dark, 0); + assertFalse(preset + "/" + style + ": " + ThemeResolver.lastDiagnostic(), ThemeResolver.lastDiagnostic().startsWith("fallback")); + tokens.requireContrast(); + } + } + } + } + + @Test + public void changingStyleActuallyChangesTheFullTemplateAndKeepsManualOverrides() { + ThemeEditor editor = new ThemeEditor(ThemePresets.Preset.OCEAN.profile()); + editor.set(ThemeEditor.Slot.SUCCESS, false, "#126B42", 0); + editor.clear(ThemeEditor.Slot.OUTLINE, true); + editor.set(ThemeEditor.Slot.DIALOG_OPACITY, true, null, 0.85f); + ThemeProfile before = editor.draft(); + ThemeProfile after = ThemePresets.withPaletteStyle(before, ThemePaletteStyle.MONOCHROME); + assertNotEquals(before.light.primary, after.light.primary); + assertNotEquals(before.light.surfaceContainerHigh, after.light.surfaceContainerHigh); + assertEquals("#126B42", after.light.success); + assertNull(after.dark.outline); + assertEquals(Float.valueOf(0.85f), after.dark.dialogOpacity); + assertEquals(before.name, after.name); + assertEquals(before.mode, after.mode); + assertEquals("#126B42", before.light.success); + assertEquals(ThemePaletteStyle.VIBRANT.id(), before.paletteStyle); + } + + @Test + public void wallpaperRemainsDynamicAndUsesTheActualWallpaperColor() { + ThemeProfile profile = ThemePresets.Preset.WALLPAPER.profile(); + assertEquals(ThemeProfile.SEED_WALLPAPER, profile.seedSource); + assertNull(profile.light.surface); + ThemeEditor editor = new ThemeEditor(profile); + ThemeTokens blue = editor.preview(false, 0xFF1565C0); + ThemeTokens red = editor.preview(false, 0xFFAE315B); + assertNotEquals(blue.colorPrimary(), red.colorPrimary()); + assertFalse(ThemeResolver.lastDiagnostic().startsWith("fallback")); + } + + @Test + public void templatesAndDraftReplacementAreDeepCopiesAndKeepTheOriginalDirtyBaseline() { + ThemeProfile original = ThemePresets.Preset.OCEAN.profile(); + String snapshot = ThemeProfileCodec.encode(original); + ThemeEditor editor = new ThemeEditor(original); + assertFalse(editor.isDirty()); + ThemeProfile rose = ThemePresets.Preset.ROSE.profile(); + assertTrue(editor.replace(rose).success()); + assertTrue(editor.isDirty()); + rose.light.surface = "#000000"; + assertNotEquals(rose.light.surface, editor.draft().light.surface); + assertEquals(snapshot, ThemeProfileCodec.encode(original)); + assertTrue(editor.reset().success()); + assertTrue(editor.isDirty()); + assertEquals(ThemeTokens.light(), editor.preview(false, 0)); + editor.replace(original); + assertFalse(editor.isDirty()); + assertEquals(snapshot, ThemeProfileCodec.encode(ThemePresets.Preset.OCEAN.profile())); + } + + @Test + public void invalidReplacementCannotPoisonTheDraft() { + ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); + ThemeProfile invalid = ThemeProfile.defaultProfile(); + invalid.light.surface = "not-a-color"; + assertFalse(editor.replace(invalid).success()); + assertFalse(editor.replace(null).success()); + assertFalse(editor.isDirty()); + } + + @Test + public void readableTextUsesRealContrastNotWeightedRgbBrightness() { + for (int color : new int[]{0xFF6750A4, 0xFF007F7A, 0xFFFFCC55, 0xFFFCFAF8, 0xFF1F272D}) { + int on = ThemeEditorUi.readable(0xFF888888, color); + assertTrue(ThemeContrast.ratio(on, color) >= 4.5); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileCodecTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileCodecTest.java new file mode 100644 index 0000000000..e577518dd8 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileCodecTest.java @@ -0,0 +1,96 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ThemeProfileCodecTest { + + @Test + public void roundTripNormalizesShortAndLowercaseColors() { + String json = "{\"schemaVersion\":2,\"format\":\"webhtv-theme\",\"name\":\"Demo\"," + + "\"seedSource\":\"custom\",\"seedColor\":\"#abc\"," + + "\"light\":{\"primary\":\"#112233\",\"dialogOpacity\":0.8}}"; + ThemeProfile profile = ThemeProfileCodec.parse(json); + assertEquals("#AABBCC", profile.seedColor); + assertEquals("#112233", profile.light.primary); + assertEquals(0.8f, profile.light.dialogOpacity, 0.0001f); + assertEquals("#AABBCC", ThemeProfileCodec.parse(ThemeProfileCodec.encode(profile)).seedColor); + } + + @Test + public void missingFieldsFallBackToInheritedDefaults() { + ThemeProfile profile = ThemeProfileCodec.parse("{}"); + assertEquals(ThemeProfile.SCHEMA_VERSION, profile.schemaVersion); + assertEquals(ThemeProfile.FORMAT, profile.format); + assertEquals(ThemeProfile.MODE_SYSTEM, profile.mode); + assertEquals(ThemeProfile.SEED_NONE, profile.seedSource); + assertNull(profile.light.primary); + } + + @Test + public void unknownBenignFieldsAreIgnoredButDangerousFieldsAreRejected() { + ThemeProfile profile = ThemeProfileCodec.parse("{\"futureSlot\":\"#123456\"}"); + assertEquals(ThemeProfile.SEED_NONE, profile.seedSource); + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( + "{\"script\":\"alert(1)\"}")); + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( + "{\"light\":{\"onLoad\":\"x\"}}")); + } + + @Test + public void rejectsMalformedTransparentAndWrongTypedValues() { + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( + "{\"seedColor\":\"#GGG\"}")); + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( + "{\"seedSource\":\"wallpaper\",\"light\":{\"primary\":\"#80112233\"}}")); + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( + "{\"light\":\"not-an-object\"}")); + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse("[]")); + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse("")); + } + + @Test + public void rejectsOpacityOutsideTheSafeRangeAndWrongSchemaVersion() { + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( + "{\"light\":{\"dialogOpacity\":0.10}}")); + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( + "{\"light\":{\"scrimOpacity\":0.99}}")); + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( + "{\"light\":{\"overlayOpacity\":0.90}}")); + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( + "{\"schemaVersion\":1}")); + } + + @Test + public void rejectsOversizedAndDeeplyNestedPayloads() { + StringBuilder deep = new StringBuilder(); + for (int index = 0; index < 12; index++) deep.append('{'); + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse(deep.toString())); + String huge = "{\"name\":\"" + "x".repeat(ThemeProfileValidator.MAX_JSON_BYTES) + "\"}"; + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse(huge)); + } + + @Test + public void playbackMenuScrimOpacityRoundTripsAndRejectsOutOfRange() { + ThemeProfile profile = ThemeProfileCodec.parse("{\"light\":{\"playerScrimOpacity\":0.65}}"); + assertEquals(0.65f, profile.light.playerScrimOpacity, 0.0001f); + assertEquals(0.65f, ThemeProfileCodec.parse(ThemeProfileCodec.encode(profile)).light.playerScrimOpacity, 0.0001f); + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( + "{\"light\":{\"playerScrimOpacity\":0.19}}")); + assertThrows(IllegalArgumentException.class, () -> ThemeProfileCodec.parse( + "{\"light\":{\"playerScrimOpacity\":1.01}}")); + } + + @Test + public void encodeRejectsAnInvalidProfileInsteadOfWritingIt() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.primary = "#12"; + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> ThemeProfileCodec.encode(profile)); + assertTrue(error.getMessage().contains("light.primary")); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileMigrationTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileMigrationTest.java new file mode 100644 index 0000000000..2bef99cd1b --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileMigrationTest.java @@ -0,0 +1,88 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ThemeProfileMigrationTest { + + @Test + public void legacyOffMigratesToDisabledSeed() { + ThemeProfile profile = ThemeProfileStore.migrateLegacy(-1, null); + assertEquals(ThemeProfile.SEED_NONE, profile.seedSource); + assertNull(profile.seedColor); + assertEquals(-1, ThemeProfileStore.legacyThemeColor(profile)); + } + + @Test + public void legacyWallpaperKeepsWallpaperSeedMode() { + ThemeProfile profile = ThemeProfileStore.migrateLegacy(0, null); + assertEquals(ThemeProfile.SEED_WALLPAPER, profile.seedSource); + assertNull(profile.seedColor); + assertEquals(0, ThemeProfileStore.legacyThemeColor(profile)); + } + + @Test + public void legacyCustomSeedRoundTripsAsArgbInt() { + ThemeProfile profile = ThemeProfileStore.migrateLegacy(0xFF155DFC, null); + assertEquals(ThemeProfile.SEED_CUSTOM, profile.seedSource); + assertEquals("#155DFC", profile.seedColor); + assertEquals(0xFF155DFC, ThemeProfileStore.legacyThemeColor(profile)); + } + + @Test + public void legacyWallColorStaysOutOfTheProfileContract() { + // wall_color is not migrated into the profile: it remains the live wallpaper + // seed input owned by Setting.getWallColor(). + ThemeProfile profile = ThemeProfileStore.migrateLegacy(-1, null); + assertNull(profile.seedColor); + assertEquals(ThemeProfile.MODE_SYSTEM, profile.mode); + } + + @Test + public void legacyV1JsonContributesOnlyModeSeedAndFiveSafeSlots() { + String legacy = "{\"schemaVersion\":1,\"mode\":\"dark\",\"seedSource\":\"custom\",\"seedColor\":\"#155DFC\"," + + "\"colors\":{\"light\":{\"primary\":\"#112233\",\"surface\":\"#F8FAFD\",\"onSurface\":\"#1A1C1E\"," + + "\"outline\":\"#74777F\",\"error\":\"#B3261E\",\"onPrimary\":\"#FFFFFF\",\"script\":\"evil\"}}," + + "\"metadata\":{\"script\":\"evil\"}}"; + ThemeProfile profile = ThemeProfileStore.migrateLegacy(-1, legacy); + assertEquals(ThemeProfile.MODE_DARK, profile.mode); + assertEquals(ThemeProfile.SEED_CUSTOM, profile.seedSource); + assertEquals("#155DFC", profile.seedColor); + assertEquals("#112233", profile.slots(false).primary); + assertEquals("#F8FAFD", profile.slots(false).surface); + assertEquals("#1A1C1E", profile.slots(false).onSurface); + assertEquals("#74777F", profile.slots(false).outline); + assertEquals("#B3261E", profile.slots(false).error); + assertNull(profile.slots(false).primaryContainer); + } + + @Test + public void legacyMirrorWinsOverTheStaleV1Seed() { + String legacy = "{\"schemaVersion\":1,\"seedSource\":\"custom\",\"seedColor\":\"#111111\"}"; + ThemeProfile profile = ThemeProfileStore.migrateLegacy(0, legacy); + assertEquals(ThemeProfile.SEED_WALLPAPER, profile.seedSource); + assertNull(profile.seedColor); + + profile = ThemeProfileStore.migrateLegacy(0xFF0000FF, legacy); + assertEquals(ThemeProfile.SEED_CUSTOM, profile.seedSource); + assertEquals("#0000FF", profile.seedColor); + } + + @Test + public void malformedLegacyJsonFallsBackToTheDefaultProfile() { + ThemeProfile profile = ThemeProfileStore.migrateLegacy(-1, "{not json"); + assertTrue(profile != null); + assertEquals(ThemeProfile.SCHEMA_VERSION, profile.schemaVersion); + assertEquals(ThemeProfile.SEED_NONE, profile.seedSource); + } + + @Test + public void resetReturnsAnApplicableDefaultProfile() { + ThemeProfileValidator.Result result = ThemeProfileValidator.validate(ThemeProfile.defaultProfile()); + assertTrue(result.valid()); + assertEquals(-1, ThemeProfileStore.legacyThemeColor(result.profile())); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileValidatorTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileValidatorTest.java new file mode 100644 index 0000000000..9407bc8a4b --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeProfileValidatorTest.java @@ -0,0 +1,131 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ThemeProfileValidatorTest { + + @Test + public void acceptsTheEmptyDefaultProfile() { + ThemeProfileValidator.Result result = ThemeProfileValidator.validate(ThemeProfile.defaultProfile()); + assertTrue(result.message(), result.valid()); + assertEquals(ThemeProfile.SCHEMA_VERSION, result.profile().schemaVersion); + } + + @Test + public void rejectsNullAndWrongSchemaAndFormat() { + assertFalse(ThemeProfileValidator.validate(null).valid()); + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.schemaVersion = 1; + assertFalse(ThemeProfileValidator.validate(profile).valid()); + profile = ThemeProfile.defaultProfile(); + profile.format = "tweakcn"; + assertFalse(ThemeProfileValidator.validate(profile).valid()); + } + + @Test + public void normalizesModeAndSeedAndRequiresAColorForCustomSeed() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.mode = "DARK"; + profile.seedSource = "weird"; + assertTrue(ThemeProfileValidator.validate(profile).valid()); + assertEquals(ThemeProfile.MODE_SYSTEM, profile.mode); + assertEquals(ThemeProfile.SEED_NONE, profile.seedSource); + + profile.seedSource = ThemeProfile.SEED_CUSTOM; + ThemeProfileValidator.Result missingSeed = ThemeProfileValidator.validate(profile); + assertFalse(missingSeed.valid()); + assertTrue(missingSeed.message().contains("seedColor")); + } + + @Test + public void acceptsOnlyOpaqueRgbHexForEveryColorSlot() { + for (String value : new String[]{"#FFF", "#ABCDEF", "#abcdef"}) { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.primary = value; + ThemeProfileValidator.Result result = ThemeProfileValidator.validate(profile); + assertTrue(value, result.valid()); + assertTrue(value, result.profile().light.primary.startsWith("#")); + assertEquals(value, 7, result.profile().light.primary.length()); + } + for (String value : new String[]{"red", "#12", "#1234", "#80112233", "112233", " #123456 "}) { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.primary = value; + ThemeProfileValidator.Result result = ThemeProfileValidator.validate(profile); + if (value.startsWith(" ")) assertTrue(result.valid()); + else assertFalse(value, result.valid()); + } + } + + @Test + public void rejectsAlphaBearingColorsSoOpacityStaysInItsOwnSlots() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.surface = "#80112233"; + ThemeProfileValidator.Result result = ThemeProfileValidator.validate(profile); + assertFalse(result.valid()); + assertTrue(result.message().contains("light.surface")); + } + + @Test + public void enforcesOpacityRangesOnEveryModeIndependently() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.scrimOpacity = 0.85f; + profile.light.dialogOpacity = 0.70f; + profile.light.overlayOpacity = 0.05f; + assertTrue(ThemeProfileValidator.validate(profile).valid()); + + profile.dark.scrimOpacity = 0.86f; + assertFalse(ThemeProfileValidator.validate(profile).valid()); + profile.dark.scrimOpacity = 0f; + profile.dark.dialogOpacity = 0.69f; + assertFalse(ThemeProfileValidator.validate(profile).valid()); + profile.dark.dialogOpacity = 1.0f; + profile.dark.overlayOpacity = 0.61f; + assertFalse(ThemeProfileValidator.validate(profile).valid()); + ThemeProfile dark = ThemeProfile.defaultProfile(); + dark.dark.overlayOpacity = Float.NaN; + assertFalse(ThemeProfileValidator.validate(dark).valid()); + } + + @Test + public void normalizesNullSlotSetsAndTrimsText() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light = null; + profile.dark = null; + profile.id = " id "; + profile.name = " Name "; + ThemeProfileValidator.Result result = ThemeProfileValidator.validate(profile); + assertTrue(result.valid()); + assertEquals("id", result.profile().id); + assertEquals("Name", result.profile().name); + assertTrue(result.profile().light != null); + assertTrue(result.profile().dark != null); + } + + @Test + public void enforcesThePlaybackMenuScrimRangeOnEveryMode() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.playerScrimOpacity = ThemeProfileValidator.MIN_PLAYER_SCRIM_OPACITY; + profile.dark.playerScrimOpacity = ThemeProfileValidator.MAX_PLAYER_SCRIM_OPACITY; + assertTrue(ThemeProfileValidator.validate(profile).valid()); + + profile.light.playerScrimOpacity = 0.19f; + assertFalse(ThemeProfileValidator.validate(profile).valid()); + profile.light.playerScrimOpacity = 1.01f; + assertFalse(ThemeProfileValidator.validate(profile).valid()); + profile.light.playerScrimOpacity = Float.NaN; + assertFalse(ThemeProfileValidator.validate(profile).valid()); + } + + @Test + public void parsesAndFormatsOpaqueColorsForTheLegacyMirror() { + assertEquals(0xFF155DFC, ThemeProfileValidator.parseColor("#155DFC", -1)); + assertEquals(-1, ThemeProfileValidator.parseColor("#801155FC", -1)); + assertEquals("#155DFC", ThemeProfileValidator.formatColor(0xFF155DFC)); + assertNull(ThemeProfileValidator.normalizeColor("nope")); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeResolverOverrideTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeResolverOverrideTest.java new file mode 100644 index 0000000000..fd94eb5b6a --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeResolverOverrideTest.java @@ -0,0 +1,307 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import com.google.android.material.color.utilities.Hct; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class ThemeResolverOverrideTest { + + private static final int BLACK = 0xFF000000; + private static final int WHITE = 0xFFFFFFFF; + + @Test + public void absentOrEmptyProfileKeepsTheFrozenPalette() { + assertEquals(ThemeTokens.light(), ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, null, null, false)); + assertEquals(ThemeTokens.dark(), ThemeResolver.resolve( + ThemeMode.DARK, ThemeSeed.NONE, 0, 0, ThemeProfile.defaultProfile(), null, true)); + assertTrue(ThemeResolver.lastDiagnostic().startsWith("default")); + } + + @Test + public void eachColorSlotOverridesOnlyItsOwnToken() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + ThemeProfile.SlotSet slots = profile.light; + slots.primary = "#000000"; + slots.primaryContainer = "#000000"; + slots.secondaryContainer = "#000000"; + slots.focus = "#000000"; + slots.surface = "#FFFFFF"; + slots.surfaceContainer = "#F7F7F7"; + slots.surfaceContainerHigh = "#EFEFEF"; + slots.onSurface = "#000000"; + slots.onSurfaceVariant = "#000000"; + slots.outline = "#000000"; + slots.error = "#000000"; + slots.success = "#000000"; + slots.warning = "#000000"; + + ThemeTokens base = ThemeTokens.light(); + ThemeTokens tokens = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); + tokens.requireContrast(); + + assertEquals(BLACK, tokens.colorPrimary()); + assertEquals(BLACK, tokens.colorPrimaryContainer()); + assertEquals(BLACK, tokens.colorSecondaryContainer()); + assertEquals(BLACK, tokens.colorFocus()); + assertEquals(WHITE, tokens.colorSurface()); + assertEquals(0xFFF7F7F7, tokens.colorSurfaceContainer()); + assertEquals(0xFFEFEFEF, tokens.colorSurfaceContainerHigh()); + assertEquals(BLACK, tokens.colorOnSurface()); + assertEquals(BLACK, tokens.colorOnSurfaceVariant()); + assertEquals(BLACK, tokens.colorOutline()); + assertEquals(BLACK, tokens.colorError()); + assertEquals(BLACK, tokens.colorSuccess()); + assertEquals(BLACK, tokens.colorWarning()); + + // Slots the user did not touch keep the frozen palette. + assertEquals(base.colorTertiary(), tokens.colorTertiary()); + assertEquals(base.colorSecondary(), tokens.colorSecondary()); + assertEquals(base.colorErrorContainer(), tokens.colorErrorContainer()); + assertEquals(base.colorOutlineVariant(), tokens.colorOutlineVariant()); + assertEquals(base.colorInverseSurface(), tokens.colorInverseSurface()); + } + + @Test + public void playerAndHealthTokensAreNeverReachableFromAProfile() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + ThemeProfile.SlotSet slots = profile.light; + slots.primary = "#FF00AA"; + slots.surface = "#FAFAFA"; + slots.error = "#AA0000"; + slots.dialogOpacity = 0.75f; + slots.scrimOpacity = 0.80f; + slots.overlayOpacity = 0.55f; + + ThemeTokens base = ThemeTokens.light(); + ThemeTokens tokens = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); + assertEquals(base.colorPlayerControl(), tokens.colorPlayerControl()); + assertEquals(base.colorPlayerControlMuted(), tokens.colorPlayerControlMuted()); + assertEquals(base.colorPlayerControlActive(), tokens.colorPlayerControlActive()); + assertEquals(base.colorPlayerScrim(), tokens.colorPlayerScrim()); + assertEquals(base.colorHealthGood(), tokens.colorHealthGood()); + assertEquals(base.colorHealthWarn(), tokens.colorHealthWarn()); + assertEquals(base.colorHealthBad(), tokens.colorHealthBad()); + assertEquals(base.colorOverlayDark(), tokens.colorOverlayDark()); + assertEquals(base.focusScale(), tokens.focusScale(), 0.0001f); + } + + @Test + public void derivedOnColorsFollowBlackOrWhiteReadability() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.primary = "#FFFFFF"; + ThemeTokens light = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); + // Primary is also drawn as dialog action text, so a white primary on a near-white + // surface is repaired first. The on* pair must then stay readable against whatever + // primary the repair produced, and the accent contract must hold either way. + assertNotEquals("white primary on a near-white surface must be repaired", + 0xFFFFFFFF, light.colorPrimary()); + assertTrue(ThemeContrast.ratio(light.colorOnPrimary(), light.colorPrimary()) >= 4.5); + assertAccentLegible(light, "white primary request"); + + ThemeProfile darkProfile = ThemeProfile.defaultProfile(); + darkProfile.dark.primary = "#000000"; + ThemeTokens dark = ThemeResolver.resolve( + ThemeMode.DARK, ThemeSeed.NONE, 0, 0, darkProfile, null, true); + assertNotEquals("black primary on a near-black surface must be repaired", + 0xFF000000, dark.colorPrimary()); + assertTrue(ThemeContrast.ratio(dark.colorOnPrimary(), dark.colorPrimary()) >= 4.5); + assertAccentLegible(dark, "black primary request"); + } + + @Test + public void unreadableUserTextIsCorrectedInsteadOfRejected() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.surface = "#FFFFFF"; + profile.light.onSurface = "#FEFEFE"; + profile.light.onSurfaceVariant = "#FDFDFD"; + profile.light.outline = "#FBFBFB"; + + ThemeTokens tokens = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); + tokens.requireContrast(); + assertTrue(ThemeContrast.ratio(tokens.colorOnSurface(), tokens.colorSurface()) >= 4.5); + assertTrue(ThemeContrast.ratio(tokens.colorOnSurfaceVariant(), tokens.colorSurface()) >= 4.5); + assertTrue(ThemeContrast.ratio(tokens.colorOutline(), tokens.colorSurface()) >= 3.0); + assertTrue(ThemeResolver.lastDiagnostic().contains("profile")); + } + + @Test + public void identicalSurfaceLayersReceiveAMinimalDistinction() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.surface = "#F5F5F5"; + profile.light.surfaceContainer = "#F5F5F5"; + profile.light.surfaceContainerHigh = "#F5F5F5"; + + ThemeTokens tokens = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); + assertNotEquals(tokens.colorSurface(), tokens.colorSurfaceContainer()); + assertNotEquals(tokens.colorSurfaceContainer(), tokens.colorSurfaceContainerHigh()); + tokens.requireContrast(); + } + + @Test + public void lightAndDarkSlotsResolveIndependently() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + // #155DFC lands at 4.06:1 on surfaceContainerHighest and would be repaired, which + // would make this independence check assert repaired rather than chosen values. + profile.light.primary = "#1039B8"; + profile.dark.primary = "#FFCC00"; + + ThemeTokens light = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); + ThemeTokens dark = ThemeResolver.resolve( + ThemeMode.DARK, ThemeSeed.NONE, 0, 0, profile, null, true); + assertEquals(0xFF1039B8, light.colorPrimary()); + assertEquals(0xFFFFCC00, dark.colorPrimary()); + assertNotEquals(light.colorPrimary(), dark.colorPrimary()); + } + + @Test + public void opacitySlotsAreClampedToTheValidatorRange() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.scrimOpacity = 0.85f; + profile.light.dialogOpacity = 0.80f; + profile.light.overlayOpacity = 0.60f; + + ThemeTokens tokens = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); + assertEquals(217, (tokens.colorScrim() >>> 24) & 0xFF); + assertEquals(0.80f, tokens.dialogOpacity(), 0.0001f); + assertEquals(153, (tokens.colorOverlayLight() >>> 24) & 0xFF); + } + + @Test + public void profileLayersOnTopOfTheSeedInsteadOfReplacingIt() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.focus = "#FF00AA"; + + ThemeTokens seeded = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0xFF0B57D0, 0, profile, null, false); + assertEquals(0xFFFF00AA, seeded.colorFocus()); + assertTrue(ThemeResolver.lastDiagnostic().contains("profile")); + // The seed still owns every slot the profile did not override. + ThemeTokens seedOnly = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0xFF0B57D0, 0, null, null, false); + assertEquals(seedOnly.colorPrimary(), seeded.colorPrimary()); + assertEquals(seedOnly.colorSurface(), seeded.colorSurface()); + } + + @Test + public void invalidProfileFallsBackToLastGoodThenToTheFrozenDefault() { + ThemeProfile broken = ThemeProfile.defaultProfile(); + broken.light.dialogOpacity = 0.10f; + + ThemeProfile lastGood = ThemeProfile.defaultProfile(); + lastGood.light.primary = "#123456"; + + ThemeTokens recovered = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, broken, lastGood, false); + assertEquals(0xFF123456, recovered.colorPrimary()); + assertTrue(ThemeResolver.lastDiagnostic().startsWith("last-good")); + + ThemeTokens fallback = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, broken, null, false); + assertEquals(ThemeTokens.light(), fallback); + assertTrue(ThemeResolver.lastDiagnostic().startsWith("fallback")); + } + + @Test + public void invalidSeedStillShortCircuitsToTheDefaultPalette() { + assertEquals(ThemeTokens.light(), ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0x00123456, 0, null, null, false)); + assertTrue(ThemeResolver.lastDiagnostic().startsWith("fallback:invalid-seed")); + } + + /** + * The B-safe contract promises a profile can never create an unreadable pair. + * {@code colorPrimary} doubles as the AlertDialog action-button text colour, so a + * user-chosen dark surface with the default primary rendered those actions at + * 1.28:1 - measured on device. The resolver must restore Material's own guarantee + * (its baseline scheme measures 4.97-10.91:1 on the surface roles). + */ + @Test + public void customSurfaceKeepsPrimaryLegibleOnEverySurfaceRole() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.surface = "#7B1FA2"; + profile.light.surfaceContainer = "#7B1FA2"; + profile.light.surfaceContainerHigh = "#7B1FA2"; + + ThemeTokens tokens = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); + + assertAccentLegible(tokens, "purple surface"); + // Hue is preserved rather than snapped to black/white. + double hueBefore = Hct.fromInt(ThemeTokens.light().colorPrimary()).getHue(); + double hueAfter = Hct.fromInt(tokens.colorPrimary()).getHue(); + assertEquals("primary must keep its hue", hueBefore, hueAfter, 12.0); + assertNotEquals("primary must actually move", ThemeTokens.light().colorPrimary(), tokens.colorPrimary()); + } + + @Test + public void lightSurfaceAlsoRepairsADarkCustomPrimary() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.surface = "#FFFFFF"; + profile.light.primary = "#F2F2F2"; + + ThemeTokens tokens = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); + assertAccentLegible(tokens, "white surface with near-white primary"); + } + + @Test + public void repairedPrimaryKeepsItsForegroundReadable() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.surface = "#7B1FA2"; + profile.light.surfaceContainerHigh = "#7B1FA2"; + + ThemeTokens tokens = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); + assertTrue("onPrimary must stay readable on the repaired primary", + ThemeContrast.ratio(tokens.colorOnPrimary(), tokens.colorPrimary()) >= 4.5); + } + + /** The frozen palettes already satisfy the contract, so no repair may fire there. */ + @Test + public void shippedPalettesAlreadyClearTheAccentContract() { + assertAccentLegible(ThemeTokens.light(), "frozen light"); + assertAccentLegible(ThemeTokens.dark(), "frozen dark"); + } + + /** An empty profile must stay byte-identical: the repair may not perturb it. */ + @Test + public void emptyProfileStillResolvesToTheFrozenPalette() { + assertEquals(ThemeTokens.light(), ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, ThemeProfile.defaultProfile(), null, false)); + assertEquals(ThemeTokens.dark(), ThemeResolver.resolve( + ThemeMode.DARK, ThemeSeed.NONE, 0, 0, ThemeProfile.defaultProfile(), null, false)); + } + + /** Seed-derived palettes come from one tonal scheme, so no repair may fire. */ + @Test + public void seedDerivedPalettesAreNotRepaired() { + for (int seed : new int[]{0xFF0B57D0, 0xFF00897B, 0xFFFB8C00, 0xFF8E24AA}) { + ThemeTokens tokens = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.EXPLICIT, seed, 0, null, null, false); + assertAccentLegible(tokens, String.format("seed %08X", seed)); + } + } + + private static void assertAccentLegible(ThemeTokens tokens, String label) { + String[] roles = {"surface", "surfaceContainer", "surfaceContainerHigh", "surfaceContainerHighest"}; + int[] backgrounds = {tokens.colorSurface(), tokens.colorSurfaceContainer(), + tokens.colorSurfaceContainerHigh(), tokens.colorSurfaceContainerHighest()}; + for (int index = 0; index < backgrounds.length; index++) { + double ratio = ThemeContrast.ratio(tokens.colorPrimary(), backgrounds[index]); + assertTrue(label + ": primary/" + roles[index] + " = " + ratio + " must be >= 4.5", + ratio + 0.0001 >= 4.5); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeResolverTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeResolverTest.java new file mode 100644 index 0000000000..c83d47736b --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeResolverTest.java @@ -0,0 +1,80 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.RecordComponent; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class ThemeResolverTest { + + @Test + public void noSeedUsesTheFrozenPalettes() { + assertEquals(ThemeTokens.light(), ThemeResolver.resolve(ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, false)); + assertEquals(ThemeTokens.dark(), ThemeResolver.resolve(ThemeMode.DARK, ThemeSeed.NONE, 0, 0, false)); + } + + @Test + public void explicitSeedGeneratesAContrastingPaletteInsteadOfReturningTheSeed() { + int seed = 0xFF0B57D0; + ThemeTokens tokens = ThemeResolver.resolve(ThemeMode.LIGHT, ThemeSeed.EXPLICIT, seed, 0, false); + assertNotEquals(seed, tokens.colorPrimary()); + tokens.requireContrast(); + assertEquals("seed:explicit", ThemeResolver.lastDiagnostic()); + } + + @Test + public void wallpaperSeedRemainsADarkSurfaceAndNeverBecomesText() { + int wallpaper = 0xFFB7F7D8; + ThemeTokens tokens = ThemeResolver.resolve(ThemeMode.DARK, ThemeSeed.WALLPAPER, 0, wallpaper, false); + assertNotEquals(wallpaper, tokens.colorSurface()); + assertNotEquals(wallpaper, tokens.colorOnSurface()); + assertTrue(ThemeContrast.ratio(tokens.colorSurface(), 0xFF000000) < 2.0); + tokens.requireContrast(); + } + + @Test + public void androidNineFallbackUsesTheDefaultPalette() { + assertEquals(ThemeTokens.dark(), ThemeResolver.resolve(ThemeMode.SYSTEM, ThemeSeed.NONE, 0, 0, true)); + } + + @Test + public void invalidSeedFallsBackWithoutThrowing() { + assertEquals(ThemeTokens.light(), ThemeResolver.resolve(ThemeMode.LIGHT, ThemeSeed.EXPLICIT, 0x00123456, 0, false)); + assertTrue(ThemeResolver.lastDiagnostic().startsWith("fallback:invalid-seed")); + } + + @Test + public void lowContrastCandidateFallsBack() throws Exception { + ThemeTokens invalid = replacePrimary(ThemeTokens.light(), ThemeTokens.light().colorPrimary()); + assertEquals(invalid, ThemeResolver.requireOrFallback(invalid, ThemeTokens.light())); + + ThemeTokens bad = replacePrimary(ThemeTokens.light(), 0xFFFFFFFF); + assertEquals(ThemeTokens.light(), ThemeResolver.requireOrFallback(bad, ThemeTokens.light())); + assertTrue(ThemeResolver.lastDiagnostic().contains("seed-contrast")); + } + + @Test + public void webBridgeSnapshotIsBoundedAndReadOnly() { + String json = ThemeWebBridge.snapshotJson(ThemeTokens.dark()); + assertTrue(json.contains("\"primary\":")); + assertTrue(json.contains("\"focusScale\":1.1")); + assertTrue(!json.contains("seed") && !json.contains("wallpaper")); + } + + private static ThemeTokens replacePrimary(ThemeTokens source, int primary) throws Exception { + RecordComponent[] components = ThemeTokens.class.getRecordComponents(); + Class[] types = new Class[components.length]; + Object[] values = new Object[components.length]; + for (int i = 0; i < components.length; i++) { + types[i] = components[i].getType(); + values[i] = components[i].getAccessor().invoke(source); + } + values[0] = primary; + Constructor constructor = ThemeTokens.class.getDeclaredConstructor(types); + return constructor.newInstance(values); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeTweakCnAdapterTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeTweakCnAdapterTest.java new file mode 100644 index 0000000000..524b139b0c --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeTweakCnAdapterTest.java @@ -0,0 +1,53 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; + +public class ThemeTweakCnAdapterTest { + + @Test + public void importsTweakCnCssVarsIntoTheCurrentV2Profile() { + String json = "{\"name\":\"Nord-ish\",\"cssVars\":{\"light\":{" + + "\"--primary\":\"#5E81AC\",\"--background\":\"#ECEFF4\"," + + "\"--foreground\":\"#2E3440\",\"--ring\":\"oklch(0.55 0.12 245)\"}," + + "\"dark\":{\"--primary\":\"rgb(136,192,208)\",\"--background\":\"#2E3440\"}}}"; + ThemeTweakCnAdapter.Result result = ThemeTweakCnAdapter.parse(json); + assertEquals("Nord-ish", result.profile().name); + assertEquals("#5E81AC", result.profile().light.primary); + assertEquals("#88C0D0", result.profile().dark.primary); + assertEquals(ThemeProfile.SEED_CUSTOM, result.profile().seedSource); + assertTrue(result.warnings().isEmpty()); + ThemeResolver.resolve(ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, result.profile(), null, false).requireContrast(); + } + + @Test + public void ignoresUnsupportedColorTokensButRejectsExecutableKeysAndOversizedInput() { + ThemeTweakCnAdapter.Result result = ThemeTweakCnAdapter.parse( + "{\"--primary\":\"#123456\",\"--radius\":\"0.5rem\",\"--font\":\"Inter\"}"); + assertEquals("#123456", result.profile().light.primary); + assertFalse(result.warnings().isEmpty()); + assertThrows(IllegalArgumentException.class, () -> ThemeTweakCnAdapter.parse( + "{\"script\":\"alert(1)\",\"--primary\":\"#123456\"}")); + assertThrows(IllegalArgumentException.class, () -> ThemeTweakCnAdapter.parse( + "{\"--primary\":\"" + "x".repeat(ThemeProfileValidator.MAX_JSON_BYTES) + "\"}")); + } + + @Test + public void transportAcceptsOnlyPublicHttpsAndBoundsLocalReads() throws Exception { + assertTrue(ThemeTransfer.isHttps("https://example.com/theme.json")); + assertFalse(ThemeTransfer.isHttps("http://example.com/theme.json")); + assertFalse(ThemeTransfer.isHttps("https://127.0.0.1/theme.json")); + assertFalse(ThemeTransfer.isHttps("https://localhost/theme.json")); + assertEquals("{\"--primary\":\"#123456\"}", ThemeTransfer.read( + new ByteArrayInputStream("{\"--primary\":\"#123456\"}".getBytes(StandardCharsets.UTF_8)))); + byte[] oversized = new byte[ThemeTransfer.MAX_BYTES + 1]; + assertThrows(java.io.IOException.class, () -> ThemeTransfer.read(new ByteArrayInputStream(oversized))); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/ThemeWebBridgeTokenTest.java b/app/src/test/java/com/fongmi/android/tv/theme/ThemeWebBridgeTokenTest.java new file mode 100644 index 0000000000..fb00ff7531 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/ThemeWebBridgeTokenTest.java @@ -0,0 +1,87 @@ +package com.fongmi.android.tv.theme; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ThemeWebBridgeTokenTest { + + private static final String[] ORIGINAL_13 = { + "primary", "onPrimary", "surface", "surfaceContainer", "surfaceContainerHigh", + "onSurface", "onSurfaceVariant", "outline", "outlineVariant", "error", + "success", "warning", "focus", + }; + + @Test + public void originalFieldsRemainPresentAndUnchangedInShape() { + String json = ThemeWebBridge.snapshotJson(ThemeTokens.light()); + for (String key : ORIGINAL_13) { + assertTrue(key + " must stay in the snapshot", json.contains("\"" + key + "\":")); + } + // 焦点缩放已统一为与明暗模式无关的 1.10(见 ThemeTokens 与 TvFocusScale): + // 它是动效属性而不是配色属性,此前浅色表为 1.00 只是历史取值。 + assertTrue(json.contains("\"focusScale\":1.10")); + assertTrue(json.startsWith("{") && json.endsWith("}")); + } + + @Test + public void opacityFieldsAreAppendedWithStableFormatting() { + String light = ThemeWebBridge.snapshotJson(ThemeTokens.light()); + String dark = ThemeWebBridge.snapshotJson(ThemeTokens.dark()); + for (String key : new String[]{"scrimOpacity", "dialogOpacity", "overlayOpacity"}) { + assertTrue(key, light.contains("\"" + key + "\":")); + assertTrue(key, dark.contains("\"" + key + "\":")); + } + // Two decimals, no locale-dependent separators, no trailing zeros dropped. + assertTrue(light.matches(".*\"scrimOpacity\":\\d+\\.\\d{2}.*")); + assertTrue(light.matches(".*\"dialogOpacity\":\\d+\\.\\d{2}.*")); + assertTrue(light.matches(".*\"overlayOpacity\":\\d+\\.\\d{2}.*")); + } + + @Test + public void opacityValuesMatchTheResolvedTokens() { + ThemeTokens tokens = ThemeTokens.light(); + String json = ThemeWebBridge.snapshotJson(tokens); + // The snapshot contract is a stable 2-decimal fraction, so compare at that precision. + assertEquals(round2(alpha(tokens.colorScrim())), value(json, "scrimOpacity"), 0.001); + assertEquals(round2(tokens.dialogOpacity()), value(json, "dialogOpacity"), 0.001); + assertEquals(round2(alpha(tokens.colorOverlayLight())), value(json, "overlayOpacity"), 0.001); + } + + @Test + public void snapshotNeverLeaksProfileOrSeedInternals() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + profile.light.primary = "#123456"; + ThemeTokens active = ThemeResolver.resolve( + ThemeMode.LIGHT, ThemeSeed.NONE, 0, 0, profile, null, false); + String json = ThemeWebBridge.snapshotJson(active); + assertFalse(json.contains("seed")); + assertFalse(json.contains("wallpaper")); + assertFalse(json.contains("profile")); + assertFalse(json.contains("webhtv-theme")); + assertTrue(json.contains("#" + String.format("%08X", active.colorPrimary()))); + } + + @Test + public void nullTokensFallBackToTheFrozenLightSnapshot() { + assertEquals(ThemeWebBridge.snapshotJson(ThemeTokens.light()), ThemeWebBridge.snapshotJson(null)); + } + + private static float round2(float value) { + return Math.round(value * 100f) / 100f; + } + + private static float alpha(int color) { + return ((color >>> 24) & 0xFF) / 255f; + } + + private static float value(String json, String key) { + String marker = "\"" + key + "\":"; + int start = json.indexOf(marker) + marker.length(); + int end = start; + while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '.')) end++; + return Float.parseFloat(json.substring(start, end)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/TvFixedDarkSurfaceContrastTest.java b/app/src/test/java/com/fongmi/android/tv/theme/TvFixedDarkSurfaceContrastTest.java new file mode 100644 index 0000000000..4010bd71ae --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/TvFixedDarkSurfaceContrastTest.java @@ -0,0 +1,400 @@ +package com.fongmi.android.tv.theme; + +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 org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The TV mirror of {@link LeanbackForegroundContrastTest}'s "constant light foreground" guard. + * + *

{@code LeanbackForegroundContrastTest} catches a palette-independent light + * foreground on a light day-mode surface. This class catches the opposite, equally + * unreadable pair: a palette-following dark foreground on a surface that is + * palette-independent dark. + * + *

That combination cannot be seen while a flavour pins its token table, which is + * exactly why it went unnoticed: TV used to compile a permanent dark + * {@code leanback/res/values/webhtv_tokens.xml}, so {@code ?attr/colorOnSurface} was + * {@code #E2E2E9} in every configuration and painting it on a fixed dark panel was + * correct. Commit {@code 35a97894a2} ("真正打通 TV 浅色模式") deleted that table, so TV now + * resolves the same day/night tables as mobile and the same markup becomes near-black + * text on a dark panel. Measured on {@code 192.168.50.3:5559} (1920x1080, day mode) the + * exit-confirm primary button went from 4.95:1 to 2.18:1; {@code dialog_display} + * 15.53:1 to 1.16:1, {@code dialog_disc_menu} 12.81:1 to 1.04:1, + * {@code dialog_audio_comment} 13.48:1 to 1.02:1. + * + *

The check is structural rather than a file list: for every leanback-reachable layout + * it walks the tag tree, and for each palette-following foreground it resolves the nearest + * ancestor (or self) that paints a background. When that background has no + * {@code ?attr/} fill at all - i.e. it is the same colour in both tables - and its fills + * are dark, the foreground must still clear the body-text floor. + */ +public class TvFixedDarkSurfaceContrastTest { + + /** Foregrounds that follow the palette, with the value the day table resolves them to. */ + private static final Map PALETTE_FOREGROUNDS = new LinkedHashMap<>(); + + static { + PALETTE_FOREGROUNDS.put("?attr/colorOnSurface", "#1A1C1E"); + PALETTE_FOREGROUNDS.put("?attr/colorOnSurfaceVariant", "#44474F"); + } + + /** Minimum contrast for the body text these layouts carry. */ + private static final double MIN_CONTRAST = 4.5; + + /** + * Fills above this luminance can no longer be called "designed for light text". + * + *

The panels below composite to {@code #181A21} / {@code #1B1F27} / {@code #090A0D}; + * the threshold only needs to separate those from a day-mode surface role. + */ + private static final double DARK_SURFACE_LUMINANCE = 0.35; + + /** + * Layouts owned by the Lab module, which pins {@code colorOnSurface} to + * {@code #FFFFFFFF} in its own {@code Theme.App.Lab} / {@code Theme.App.Lab.Dialog} + * styles. Its {@code ?attr/colorOnSurface} therefore does not follow the + * app palette, and the module is already hard-coded dark by design + * ({@code android:colorBackground = #111318}); routing it through the app tokens is + * a separate concern from this defect class. + */ + private static final String[] LAB_LAYOUTS = { + "adapter_lab_command_compact.xml", "adapter_lab_package.xml", + "activity_lab_output.xml", "dialog_lab_command_sheet.xml", + }; + + private static final Pattern TAG = Pattern.compile("<(/?)([A-Za-z0-9_.]+)((?:\"[^\"]*\"|[^>\"])*?)(/?)>", Pattern.DOTALL); + private static final Pattern BACKGROUND = Pattern.compile("(?:android:background|app:cardBackgroundColor|android:cardBackgroundColor)=\"([^\"]+)\""); + private static final Pattern TEXT_COLOR = Pattern.compile("android:textColor=\"([^\"]+)\""); + private static final Pattern SOLID = Pattern.compile("([^<]+)"); + private static final Pattern SELECTOR_COLOR = Pattern.compile("android:color=\"([^\"]+)\""); + private static final Pattern COMMENT = Pattern.compile("", Pattern.DOTALL); + private static final Pattern STROKE = Pattern.compile("]*/>|", Pattern.DOTALL); + private static final Pattern PADDING = Pattern.compile("]*/>|", Pattern.DOTALL); + private static final Pattern MASK_ITEM = Pattern.compile("]*@android:id/mask[^>]*>.*?", Pattern.DOTALL); + private static final Pattern RIPPLE_COLOR = Pattern.compile("]*android:color=\"[^\"]*\"[^>]*>"); + + private static final Map LITERALS = new HashMap<>(); + + // ------------------------------------------------------------------ palette loading + + private static Path root() { + return Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + } + + private static String readIfExists(String relative) { + try { + Path path = root().resolve(relative); + return Files.exists(path) ? Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n") : null; + } catch (Exception error) { + return null; + } + } + + private static void loadLiterals() { + if (!LITERALS.isEmpty()) return; + for (String file : new String[]{ + "src/main/res/values/webhtv_tokens.xml", + "src/main/res/values/colors.xml", + "src/leanback/res/values/colors.xml"}) { + String source = readIfExists(file); + if (source == null) continue; + Matcher matcher = COLOR_ENTRY.matcher(withoutComments(source)); + while (matcher.find()) LITERALS.put(matcher.group(1), matcher.group(2).trim()); + } + } + + private static String withoutComments(String source) { + return COMMENT.matcher(source).replaceAll(""); + } + + /** Literal {@code #RRGGBB}/{@code #AARRGGBB} a {@code @color/} resolves to, or null when it follows the palette. */ + private static String literalOf(String name, Map seen) { + if (seen.containsKey(name)) return null; + seen.put(name, name); + String value = LITERALS.get(name); + if (value != null) { + if (value.startsWith("#")) return value; + if (value.startsWith("@color/")) return literalOf(value.substring("@color/".length()), seen); + } + for (String directory : new String[]{"src/main/res/color", "src/leanback/res/color"}) { + String source = readIfExists(directory + "/" + name + ".xml"); + if (source == null) continue; + if (withoutComments(source).contains("?attr/")) return null; + Matcher matcher = SELECTOR_COLOR.matcher(withoutComments(source)); + while (matcher.find()) { + String entry = matcher.group(1); + if (entry.startsWith("#")) return entry; + if (entry.startsWith("@color/")) { + String resolved = literalOf(entry.substring("@color/".length()), seen); + if (resolved != null) return resolved; + } + } + } + return null; + } + + private static double luminance(String hex) { + String digits = hex.startsWith("#") ? hex.substring(1) : hex; + if (digits.length() == 8) digits = digits.substring(2); + double[] channels = new double[3]; + for (int i = 0; i < 3; i++) { + double value = Integer.parseInt(digits.substring(i * 2, i * 2 + 2), 16) / 255.0; + channels[i] = value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); + } + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; + } + + private static int alpha(String hex) { + String digits = hex.startsWith("#") ? hex.substring(1) : hex; + return digits.length() == 8 ? Integer.parseInt(digits.substring(0, 2), 16) : 0xFF; + } + + private static double contrast(String first, String second) { + double a = luminance(first); + double b = luminance(second); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + } + + /** + * Composites a translucent fill over the app's dark content backdrop. + * + *

The panels under test are translucent by design (the "glass sheet" look), so their + * effective colour is darker than the raw value; the darkest shipped surface + * ({@code #101418}) is used as the backdrop, which is the least favourable case for a + * light foreground and the most favourable for the dark one. + */ + private static String compositeOver(String fill, String backdrop) { + double a = alpha(fill) / 255.0; + String top = fill.startsWith("#") ? fill.substring(1) : fill; + if (top.length() == 8) top = top.substring(2); + String bottom = backdrop.startsWith("#") ? backdrop.substring(1) : backdrop; + StringBuilder out = new StringBuilder("#"); + for (int i = 0; i < 3; i++) { + int front = Integer.parseInt(top.substring(i * 2, i * 2 + 2), 16); + int back = Integer.parseInt(bottom.substring(i * 2, i * 2 + 2), 16); + out.append(String.format(Locale.ROOT, "%02X", Math.round(front * a + back * (1 - a)))); + } + return out.toString(); + } + + /** + * The day-mode fills of a background, or {@code null} when it follows the palette. + * + *

A {@code ?attr/} fill, an unresolvable {@code @color/} or a non-colour value makes + * the whole drawable palette-following, which is the case this guard does not cover. + */ + private static List fixedFills(String name) { + List fills = new ArrayList<>(); + boolean palette = false; + for (String directory : new String[]{"src/main/res/drawable", "src/leanback/res/drawable"}) { + String source = readIfExists(directory + "/" + name + ".xml"); + if (source == null) continue; + String body = RIPPLE_COLOR.matcher(MASK_ITEM.matcher(PADDING.matcher(STROKE.matcher(withoutComments(source)) + .replaceAll("")).replaceAll("")).replaceAll("")).replaceAll(""); + if (body.contains("?attr/")) palette = true; + Matcher solid = SOLID.matcher(body); + while (solid.find()) { + String value = solid.group(1); + if (value.startsWith("#")) fills.add(value); + else if (value.startsWith("@color/")) { + String literal = literalOf(value.substring("@color/".length()), new HashMap<>()); + if (literal == null) palette = true; + else fills.add(literal); + } else if (!value.contains("transparent")) palette = true; + } + Matcher gradient = GRADIENT.matcher(body); + while (gradient.find()) { + String value = gradient.group(1); + if (value.startsWith("#")) fills.add(value); + else if (value.startsWith("@color/")) { + String literal = literalOf(value.substring("@color/".length()), new HashMap<>()); + if (literal == null) palette = true; + else fills.add(literal); + } else palette = true; + } + } + if (palette) return null; + List visible = fills.stream().filter(fill -> alpha(fill) >= 0x66).toList(); + if (visible.isEmpty()) return null; + return visible.stream().map(fill -> compositeOver(fill, "#101418")).toList(); + } + + // ------------------------------------------------------------------ the guard + + /** + * A palette-following foreground must not sit on a palette-independent dark surface. + * + *

Reported per file so a failure names the layout to fix, together with the resolved + * surface and the measured contrast. + */ + @Test + public void paletteForegroundsStayReadableOnFixedDarkSurfaces() throws Exception { + loadLiterals(); + List layouts = new ArrayList<>(); + for (String directory : new String[]{"src/leanback/res/layout", "src/main/res/layout"}) { + Path base = root().resolve(directory); + if (!Files.isDirectory(base)) continue; + try (var paths = Files.walk(base)) { + paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".xml")) + .sorted() + .forEach(layouts::add); + } + } + assertFalse("no layouts were found to inspect", layouts.isEmpty()); + + List failures = new ArrayList<>(); + for (Path layout : layouts) { + String name = layout.getFileName().toString(); + if (isLabLayout(name)) continue; + String source = Files.readString(layout, StandardCharsets.UTF_8).replace("\r\n", "\n"); + for (String[] hit : inspect(source)) { + String foreground = hit[0]; + String surface = hit[1]; + double worst = Double.parseDouble(hit[2]); + if (worst < MIN_CONTRAST) { + failures.add(name + ": " + foreground + " sits on the fixed dark surface " + surface + + " with " + String.format(Locale.ROOT, "%.2f:1", worst) + " in day mode"); + } + } + } + if (!failures.isEmpty()) { + fail("TV paints a palette-following foreground on a palette-independent dark surface," + + " which is near-black on dark in day mode:\n " + String.join("\n ", failures)); + } + } + + /** + * Proves the guard above is not vacuous. + * + *

Every real instance of this defect class has been fixed, so the tree walk now + * legitimately matches nothing. The probe feeds {@link #inspect} the exact markup that + * was measured on device before the fix and requires it to be flagged with a + * sub-threshold ratio, so a future regression cannot hide behind an empty match set. + */ + @Test + public void theGuardDetectsTheDefectItWasWrittenFor() { + loadLiterals(); + String defective = "" + + ""; + List hits = inspect(defective); + assertFalse("the guard must flag the measured pre-fix markup", hits.isEmpty()); + assertEquals("@drawable/shape_audio_playlist_panel", hits.get(0)[1]); + assertTrue("the probe must reproduce the unreadable ratio", Double.parseDouble(hits.get(0)[2]) < MIN_CONTRAST); + + // The mirror of the same markup: a palette-independent light foreground must be ignored here. + String alreadyCorrect = "" + + ""; + assertTrue("a palette-independent foreground is not this guard's concern", + inspect(alreadyCorrect).isEmpty()); + } + + private static boolean isLabLayout(String name) { + for (String lab : LAB_LAYOUTS) if (lab.equals(name)) return true; + return false; + } + + /** {@code {foreground, surface, worstDayContrast}} for every matched element. */ + private static List inspect(String source) { + List hits = new ArrayList<>(); + Deque backgrounds = new ArrayDeque<>(); + Matcher matcher = TAG.matcher(source); + while (matcher.find()) { + boolean closing = !matcher.group(1).isEmpty(); + String attributes = matcher.group(3); + boolean selfClosing = !matcher.group(4).isEmpty(); + if (closing) { + if (!backgrounds.isEmpty()) backgrounds.pop(); + continue; + } + Matcher backgroundMatcher = BACKGROUND.matcher(attributes); + String own = backgroundMatcher.find() ? backgroundMatcher.group(1) : null; + if (!selfClosing) backgrounds.push(own == null ? "" : own); + + Matcher colorMatcher = TEXT_COLOR.matcher(attributes); + if (!colorMatcher.find()) continue; + String foreground = PALETTE_FOREGROUNDS.get(colorMatcher.group(1)); + if (foreground == null) continue; + + String nearest = own != null && !own.isEmpty() ? own : null; + if (nearest == null) { + for (String ancestor : backgrounds) { + if (ancestor != null && !ancestor.isEmpty()) { + nearest = ancestor; + break; + } + } + } + if (nearest == null || !nearest.startsWith("@drawable/")) continue; + List fills = fixedFills(nearest.substring("@drawable/".length())); + if (fills == null) continue; + boolean dark = fills.stream().allMatch(fill -> luminance(fill) <= DARK_SURFACE_LUMINANCE); + if (!dark) continue; + double worst = fills.stream().mapToDouble(fill -> contrast(foreground, fill)).min().orElse(Double.MAX_VALUE); + hits.add(new String[]{colorMatcher.group(1), nearest, String.valueOf(worst)}); + } + return hits; + } + + /** + * Pins the four surfaces this defect was fixed on, so a later "migrate the TV + * foregrounds" sweep cannot put a palette-following role back on them. + */ + @Test + public void fixedDarkPanelsKeepPaletteIndependentForegrounds() throws Exception { + String[][] expectations = { + {"src/main/res/layout/dialog_display.xml", "shape_display_dialog_panel"}, + {"src/main/res/layout/dialog_disc_menu.xml", "shape_disc_menu_panel"}, + {"src/main/res/layout/dialog_audio_comment.xml", "shape_audio_playlist_panel"}, + {"src/leanback/res/layout/dialog_exit_confirm.xml", "selector_exit_confirm_primary"}, + }; + for (String[] expectation : expectations) { + String source = readIfExists(expectation[0]); + assertTrue(expectation[0] + " is missing", source != null); + assertTrue(expectation[0] + " must still paint its panel with " + expectation[1], + source.contains(expectation[1])); + for (String[] hit : inspect(source)) { + if (!expectation[1].equals(hit[1].substring("@drawable/".length()))) continue; + fail(expectation[0] + " must not paint " + hit[0] + " on " + hit[1] + + " (measured " + String.format(Locale.ROOT, "%.2f:1", Double.parseDouble(hit[2])) + ")"); + } + } + } + + /** + * The four panels must really be palette-independent and dark, otherwise the fix above + * would be protecting the wrong thing. + */ + @Test + public void theFixedPanelsAreStillPaletteIndependentAndDark() throws Exception { + loadLiterals(); + for (String drawable : new String[]{ + "shape_display_dialog_panel", "shape_disc_menu_panel", "shape_audio_playlist_panel", + "selector_exit_confirm_primary"}) { + List fills = fixedFills(drawable); + assertTrue(drawable + " must have no palette-following fill, or the guard is moot", fills != null); + assertTrue(drawable + " must be dark in both tables", + fills.stream().allMatch(fill -> luminance(fill) <= DARK_SURFACE_LUMINANCE)); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/UiTokenGatePortabilityContractTest.java b/app/src/test/java/com/fongmi/android/tv/theme/UiTokenGatePortabilityContractTest.java new file mode 100644 index 0000000000..05cf8b9d6d --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/UiTokenGatePortabilityContractTest.java @@ -0,0 +1,120 @@ +package com.fongmi.android.tv.theme; + +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; + +/** + * UI token 静态门槛的可执行性契约。 + * + *

设计文档 §13.1 把 {@code scripts/check_ui_tokens.sh} 列为**每阶段必做**的静态验证。 + * 但该脚本把全部检查体放在内嵌的 {@code python3} heredoc 里,真正依赖 bash 的只有参数解析与 + * {@code git rev-parse}——在没有可用 bash 的主机上(Windows + WSL 服务不可用)它**根本无法 + * 运行**,于是这个门槛在相当长一段 UI 工作里**静默地没有被执行过**,期间有固定色被引入而 + * 无人拦下(审计实测:{@code violations=1}、{@code hex_layouts=1})。 + * + *

本契约钉住三件事: + *

    + *
  1. 存在一个**不依赖 bash** 的等价入口;
  2. + *
  3. 两个入口的 CLI 契约一致(选项名、strict 推导规则、退出码语义);
  4. + *
  5. 门槛的五项检查一项都没少(防止有人把移植版简化成"只看对比度")。
  6. + *
+ * + *

注意:这里断言的是**脚本源码的内容契约**,不是运行结果——运行结果由 CI/人工执行 + * {@code python scripts/check_ui_tokens.py --strict} 得到。之所以用源码断言,是因为 JVM + * 单测里无法可靠地 spawn 一个 python 进程(且不该依赖宿主装了 python)。 + */ +public class UiTokenGatePortabilityContractTest { + + private static final String SH = "scripts/check_ui_tokens.sh"; + private static final String PY = "scripts/check_ui_tokens.py"; + + /** 必须存在一个不依赖 bash 的入口。 */ + @Test + public void aShellIndependentEntryPointExists() throws Exception { + Path py = resolve(PY); + assertTrue("必须存在 " + PY + "(无 bash 主机上的等价入口)", Files.isRegularFile(py)); + String source = Files.readString(py, StandardCharsets.UTF_8); + assertFalse("移植版不得再调用 bash/sh", source.contains("subprocess.run([\"bash\"")); + assertFalse("移植版不得依赖内嵌 heredoc", source.contains("<<'PY'")); + assertTrue("必须声明它是 .sh 的移植(否则下一个人会以为是第二套实现)", + source.contains("Python port of scripts/check_ui_tokens.sh")); + } + + /** + * CLI 契约必须一致:选项名、strict 推导规则、退出码。 + */ + @Test + public void bothEntryPointsShareTheSameCliContract() throws Exception { + String sh = read(SH); + String py = read(PY); + for (String option : new String[]{"--baseline", "--strict", "--stage", "--tokens-file", "--allowlist"}) { + assertTrue(".sh 必须接受 " + option, sh.contains(option)); + assertTrue(".py 必须接受 " + option, py.contains(option)); + } + // 退出码语义:PASS=0 / FAIL=1 / 用法错误=2 + assertTrue(".sh 必须用 1 表示失败", sh.contains("raise SystemExit(1 if failed else 0)")); + assertTrue(".py 必须用 1 表示失败", py.contains("return 1 if failed else 0")); + assertTrue(".py 必须用 2 表示用法错误", py.contains("return 2")); + // strict 推导:--baseline 关;stage 非 A 开 + for (String source : new String[]{sh, py}) { + assertTrue("必须保留「--baseline 关 strict」的规则", source.contains("baseline")); + assertTrue("必须保留「stage 非 A 开 strict」的规则", source.contains("\"A\"")); + } + } + + /** + * 五项检查一项都不能少。逐项按输出行断言——那些行是脚本对外的可观测契约。 + */ + @Test + public void allFiveChecksSurviveThePort() throws Exception { + String py = read(PY); + // 1) token 完整性 2) style 完整性 3) 对比度 4) 固定色扫描 5) 遗留主题名 + assertTrue("缺 token 完整性检查", py.contains("UI_TOKEN_STATUS FAIL missing tokens=")); + assertTrue("缺 style 完整性检查", py.contains("UI_TOKEN_STATUS FAIL missing styles=")); + assertTrue("缺对比度检查", py.contains("UI_TOKEN_CONTRAST_FAILURE")); + assertTrue("缺固定色扫描", py.contains("UI_TOKEN_VIOLATION")); + assertTrue("缺遗留主题名检查", py.contains("UI_TOKEN_LEGACY")); + // 基线行与状态行是既有记录引用的可观测输出,必须保持。 + // 注意按**前缀**断言:这些行是 f-string 拼接的(例如 f"UI_TOKEN_SCOPE {scope} ..."), + // 所以 "UI_TOKEN_SCOPE stage=" 这种含具体值的字面量在源码里并不存在。 + assertTrue("缺 UI_TOKEN_BASELINE 行", py.contains("UI_TOKEN_BASELINE layouts=")); + assertTrue("缺 UI_TOKEN_SCOPE 行", py.contains("UI_TOKEN_SCOPE ")); + assertTrue("缺 UI_TOKEN_CONTRAST 行", py.contains("UI_TOKEN_CONTRAST pairs=")); + assertTrue("缺 UI_TOKEN_STATUS 行", py.contains("UI_TOKEN_STATUS ")); + // 扫描范围必须与 .sh 一致(少了任何一类都会让门槛变松) + for (String root : new String[]{ + "app/src/main/res/layout", "app/src/mobile/res/layout", "app/src/leanback/res/layout", + "app/src/main/res/drawable", "app/src/mobile/res/drawable", "app/src/leanback/res/drawable", + "app/src/main/res/color", "app/src/main/assets"}) { + assertTrue("扫描范围缺 " + root, py.contains(root)); + } + } + + /** + * 规范必须指向新入口,否则下一个人仍然只会去跑 .sh 然后卡住。 + */ + @Test + public void theSpecPointsAtTheRunnableEntryPoint() throws Exception { + String spec = read("docs/webhtv-unified-visual-design-system-20260920.md"); + assertTrue("§13.1 必须给出无 bash 主机上的替代入口", + spec.contains("python scripts/check_ui_tokens.py")); + assertTrue("必须说明两者是同一门槛的两个入口", spec.contains("同一个门槛的两个入口")); + } + + // ------------------------------------------------------------------ helpers + + private static Path resolve(String path) { + Path direct = Path.of(path); + return Files.exists(direct) ? direct : Path.of("..").resolve(path); + } + + private static String read(String path) throws Exception { + return Files.readString(resolve(path), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/theme/UpdateVersionRowThemeTest.java b/app/src/test/java/com/fongmi/android/tv/theme/UpdateVersionRowThemeTest.java new file mode 100644 index 0000000000..e29a9d0ceb --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/theme/UpdateVersionRowThemeTest.java @@ -0,0 +1,240 @@ +package com.fongmi.android.tv.theme; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +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; + +/** + * The version cards in the “版本更新” dialog must be readable on every shipped palette + * and under a user theme profile. + * + *

Regression under test (user screenshot, 1080x2340, dark wallpaper, “主题色彩 = + * 壁纸取色”): the beta card rendered as a solid {@code #0F1D26} block carrying + * {@code #49454F} text - 1.5:1 - while the stable card beside it stayed legible. + * The two cards took their fill from the static + * {@code selector_update_version_item} drawable and their text from three separate + * {@code ColorStateList}s. {@link ThemeBinder} rewrites those text lists but leaves the + * card's {@code StateListDrawable} untouched, so on a derived palette the text and the + * fill drift apart. Most users never see it because the default profile resolves + * {@code ThemeSeed.NONE} and {@code ThemeBinder} is then a no-op. + * + *

These assertions are pure arithmetic on the shipped token tables plus source + * contracts, so they fail on the old wiring and pass on the fix without a device. + */ +public class UpdateVersionRowThemeTest { + + @Test + public void everyCardTextClearsWcagAaOnBothCardFillsForEveryShippedPalette() { + for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.dark(), ThemeTokens.light()}) { + for (boolean expanded : new boolean[]{false, true}) { + int fill = UpdateVersionRowTheme.fill(tokens, expanded); + String where = (tokens == ThemeTokens.dark() ? "dark" : "light") + (expanded ? "/expanded" : "/resting"); + assertTrue(where + " name", ThemeContrast.ratio(UpdateVersionRowTheme.nameColor(tokens, expanded), fill) >= 4.5); + assertTrue(where + " version", ThemeContrast.ratio(UpdateVersionRowTheme.versionColor(tokens, expanded), fill) >= 4.5); + assertTrue(where + " description", ThemeContrast.ratio(UpdateVersionRowTheme.descColor(tokens, expanded), fill) >= 4.5); + assertTrue(where + " expand affordance", ThemeContrast.ratio(UpdateVersionRowTheme.expandColor(tokens, expanded), fill) >= 4.5); + } + } + } + + /** + * The card keeps its text colours while the user toggles it open, so a colour that + * only clears AA on one of the two fills would go unreadable on the other. Pin that + * directly instead of trusting the two per-state assertions above. + */ + @Test + public void cardTextSurvivesTheRestingToExpandedFillChange() { + for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.dark(), ThemeTokens.light()}) { + int resting = UpdateVersionRowTheme.fill(tokens, false); + int expanded = UpdateVersionRowTheme.fill(tokens, true); + assertNotEquals("the two states must be distinguishable", resting, expanded); + for (int color : new int[]{ + UpdateVersionRowTheme.nameColor(tokens, false), + UpdateVersionRowTheme.versionColor(tokens, false), + UpdateVersionRowTheme.descColor(tokens, false), + UpdateVersionRowTheme.expandColor(tokens, false)}) { + assertTrue(ThemeContrast.ratio(color, resting) >= 4.5); + assertTrue(ThemeContrast.ratio(color, expanded) >= 4.5); + } + } + } + + @Test + public void thePillAndItsLabelStayReadable() { + for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.dark(), ThemeTokens.light()}) { + int fill = UpdateVersionRowTheme.pillFill(tokens); + assertTrue(ThemeContrast.ratio(UpdateVersionRowTheme.pillTextColor(tokens), fill) >= 4.5); + // The pill has to be visible on both card fills, otherwise it reads as part of + // the card instead of as a badge. + assertTrue(ThemeContrast.ratio(fill, UpdateVersionRowTheme.fill(tokens, false)) >= 1.2); + } + } + + @Test + public void selectedAndFocusedRingsStayVisibleAgainstTheirOwnFill() { + for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.dark(), ThemeTokens.light()}) { + assertTrue("resting ring", UpdateVersionRowTheme.ringIsVisible(tokens, false)); + assertTrue("expanded ring", UpdateVersionRowTheme.ringIsVisible(tokens, true)); + } + } + + /** + * The static selector separated "focused" from "selected" by swapping the fill and ring + * roles ({@code colorPrimary} vs {@code colorPrimaryContainer}). The runtime renderer has + * to keep that: the TV dialog always opens with {@code stableItem.requestFocus()}, so a + * focus fill of {@code colorPrimaryContainer} would make the focused card look like the + * expanded one and collapse the two clues into one. + */ + @Test + public void theFocusedCardStaysDistinguishableFromTheSelectedCard() { + for (ThemeTokens tokens : new ThemeTokens[]{ThemeTokens.dark(), ThemeTokens.light()}) { + String where = tokens == ThemeTokens.dark() ? "dark" : "light"; + int focused = UpdateVersionRowTheme.focusFill(tokens); + int selected = UpdateVersionRowTheme.fill(tokens, true); + int resting = UpdateVersionRowTheme.fill(tokens, false); + assertNotEquals(where + ": focus must not paint the selected fill", focused, selected); + assertTrue(where + ": the focus fill has to clear the non-text floor against the selected fill", + ThemeContrast.ratio(focused, selected) >= 3.0); + assertTrue(where + ": the focus fill has to differ from the resting fill", ThemeContrast.ratio(focused, resting) >= 3.0); + assertTrue(where + ": focus ink on the focus fill", + ThemeContrast.ratio(UpdateVersionRowTheme.focusInk(tokens), focused) >= 4.5); + // The selected card keeps the resting ink while it is not focused, so the + // selected fill still has to carry it. + assertTrue(where + ": resting ink on the selected fill", + ThemeContrast.ratio(UpdateVersionRowTheme.nameColor(tokens, true), selected) >= 4.5); + } + } + + /** + * The contract test in the leanback source set reads + * {@code selector_update_version_item.xml}, which this dialog no longer renders: the + * helper overrides {@code row.setBackground(...)}. Pin the runtime side here so focus and + * selected cannot silently collapse again while that test stays green. + */ + @Test + public void theCardFaceKeepsItsStatesAndTheSharedRingWidth() throws Exception { + String source = codeOnly(read("src/main/java/com/fongmi/android/tv/theme/UpdateVersionRowTheme.java")); + for (String state : new String[]{"android.R.attr.state_focused", "android.R.attr.state_pressed", "android.R.attr.state_selected"}) { + assertTrue("the card face must declare " + state, source.contains(state)); + } + assertTrue("the focused face must paint the primary fill", source.contains("faced(context, safe.colorPrimary(),")); + assertTrue("the selected face must paint the primary container fill", source.contains("faced(context, safe.colorPrimaryContainer(),")); + assertTrue("the ring width must come from the shared dimens token", source.contains("R.dimen.webhtv_focus_ring_width")); + assertTrue("the card text must flip on focus", source.contains("setTextColor(textColors(")); + assertTrue("focused and pressed must be matched before the default entry", + source.contains("new int[][]{new int[]{android.R.attr.state_focused}, new int[]{android.R.attr.state_pressed}, new int[]{}}")); + // The state list only reaches the text because the card nodes propagate the row + // state; without these declarations the focused branch would never be selected. + for (String flavour : new String[]{"mobile", "leanback"}) { + String layout = read("src/" + flavour + "/res/layout/dialog_update.xml"); + int declarations = layout.split("duplicateParentState", -1).length - 1; + assertTrue(flavour + ": every card text node must propagate the row focus state", declarations >= 8); + } + assertFalse("the ring width must not be a per-widget literal", source.contains(", 2, CORNER_DP)")); + } + + /** + * Pins the reported defect numerically. The user screenshot measured a + * {@code #0F1D26} card carrying {@code #49454F} text; a fix that still allowed that + * pair would pass every other assertion here. + */ + @Test + public void theReportedScreenshotPairIsExactlyTheUnreadableOne() { + int reportedFill = 0xFF0F1D26; + int reportedText = 0xFF49454F; + assertTrue("the reported pair must be the unreadable one", + ThemeContrast.ratio(reportedText, reportedFill) < 2.0); + assertTrue("the fix must not reproduce it", + ThemeContrast.ratio(UpdateVersionRowTheme.versionColor(ThemeTokens.dark(), false), + UpdateVersionRowTheme.fill(ThemeTokens.dark(), false)) >= 4.5); + } + + /** + * An arbitrary user palette can make {@code onSurfaceVariant} collide with the card + * fill - {@link ThemeContrast#require(ThemeTokens)} never checks that pair. The card + * has to fall back to a colour that does clear AA instead of painting an unreadable + * one. + */ + @Test + public void aCollidingProfileFallsBackToAReadableForeground() { + int fill = 0xFF0F1D26; + int colliding = 0xFF11202A; + assertEquals("white is the only fallback that clears AA on a dark fill", 0xFFFFFFFF, UpdateVersionRowTheme.readableOn(colliding, fill)); + assertTrue(ThemeContrast.ratio(UpdateVersionRowTheme.readableOn(colliding, fill), fill) >= 4.5); + int paleFill = 0xFFE1E2E9; + assertEquals("black is the only fallback that clears AA on a pale fill", 0xFF000000, UpdateVersionRowTheme.readableOn(0xFFDDDEE6, paleFill)); + // A colour that already clears AA on every fill must be kept, not replaced. + assertEquals(0xFF000000, UpdateVersionRowTheme.readableOn(0xFF000000, paleFill)); + assertEquals(0xFFFFFFFF, UpdateVersionRowTheme.readableOn(0xFFFFFFFF, fill)); + } + + @Test + public void bothFlavoursRouteTheCardsThroughTheSharedPalette() throws Exception { + for (String flavour : new String[]{"mobile", "leanback"}) { + String dialog = codeOnly(read("src/" + flavour + "/java/com/fongmi/android/tv/ui/dialog/UpdateDialog.java")); + assertTrue(flavour + " must paint the card from the active palette", + dialog.contains("UpdateVersionRowTheme.apply(binding.stableItem, binding.stableName, binding.stableVersion, binding.stableStatus, binding.stableExpand, binding.stableDesc, expanded, tokens);")); + assertTrue(flavour + " must paint the beta card too", + dialog.contains("UpdateVersionRowTheme.apply(binding.betaItem, binding.betaName, binding.betaVersion, binding.betaStatus, binding.betaExpand, binding.betaDesc, expanded, tokens);")); + assertTrue(flavour + " must read the active palette", dialog.contains("ThemeTokens tokens = ThemeController.current();")); + assertTrue(flavour + " must apply the theme while rendering", dialog.contains("applyCardTheme(channel, expanded);")); + // The old wiring let the card fill and the card text come from different graphs. + assertFalse(flavour + " must not colour card text inline from a fixed resource", + dialog.contains("setTextColor(R.color.update_version_item")); + } + } + + /** + * The helper must own the whole card, including the {@code webhtv:ignore} tag: the + * dialog still runs {@link ThemeController#bindDialog}, and without the tag the binder + * would re-colour the text from a different role mapping and undo the guarantee above. + */ + @Test + public void theHelperOwnsTheCardSubtreeAndUsesOnlySemanticRoles() throws Exception { + String source = codeOnly(read("src/main/java/com/fongmi/android/tv/theme/UpdateVersionRowTheme.java")); + assertTrue(source.contains("row.setTag(IGNORE_TAG)")); + assertTrue(source.contains("safe.colorSurfaceContainerHighest()")); + assertTrue(source.contains("safe.colorPrimaryContainer()")); + assertTrue(source.contains("safe.colorOnSurface()")); + assertTrue(source.contains("safe.colorOnSurfaceVariant()")); + assertTrue(source.contains("safe.colorPrimary()")); + assertTrue(source.contains("safe.colorOutlineVariant()")); + assertTrue(source.contains("colorSurface()")); + assertFalse("the card renderer must not hard-code a fill", source.contains("#0F1D26")); + assertFalse(source.contains("#E1E2E9")); + assertFalse(source.contains("#34393E")); + } + + /** Strips comments so a source contract matches code, not the prose naming the defect. */ + private static String codeOnly(String source) { + StringBuilder out = new StringBuilder(source.length()); + int index = 0; + while (index < source.length()) { + char current = source.charAt(index); + char next = index + 1 < source.length() ? source.charAt(index + 1) : '\0'; + if (current == '/' && next == '/') { + while (index < source.length() && source.charAt(index) != '\n') index++; + } else if (current == '/' && next == '*') { + index += 2; + while (index + 1 < source.length() && !(source.charAt(index) == '*' && source.charAt(index + 1) == '/')) index++; + index = Math.min(source.length(), index + 2); + } else { + out.append(current); + index++; + } + } + return out.toString(); + } + + private static String read(String path) throws Exception { + Path root = Files.exists(Path.of("src")) ? Path.of("") : Path.of("app"); + return Files.readString(root.resolve(path), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/tts/TtsHttpRuleTest.java b/app/src/test/java/com/fongmi/android/tv/tts/TtsHttpRuleTest.java new file mode 100644 index 0000000000..192ca56afb --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/tts/TtsHttpRuleTest.java @@ -0,0 +1,147 @@ +package com.fongmi.android.tv.tts; + +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; + +import org.junit.Test; + +public class TtsHttpRuleTest { + + @Test + public void encodesSpeakTextWithEncodeUriSemantics() { + String rendered = TtsHttpRule.render("https://a/b?text={{java.encodeURI(speakText)}}", "你好 世界", 5); + assertEquals("https://a/b?text=%E4%BD%A0%E5%A5%BD%20%E4%B8%96%E7%95%8C", rendered); + } + + @Test + public void encodeUriKeepsReservedCharacters() { + // JS encodeURI 不转义这些保留字符;换成 URLEncoder 语义会让部分接口取不到参数 + assertEquals("a-b_c.d!e~f'g(h)i/j:k?l,m;n", TtsHttpRule.encodeUri("a-b_c.d!e~f'g(h)i/j:k?l,m;n")); + } + + @Test + public void arithmeticSpeakSpeedIsEvaluated() { + // legado 百度规则:spd = (speakSpeed + 5) / 10 + 4(JS 语义,浮点除法) + assertEquals("5", TtsHttpRule.render("{{(speakSpeed + 5) / 10 + 4}}", "x", 5)); + assertEquals("4.6", TtsHttpRule.render("{{(speakSpeed + 5) / 10 + 4}}", "x", 1)); + assertEquals("6", TtsHttpRule.render("{{String((speakSpeed + 5) / 10 + 4)}}", "x", 15)); + assertEquals("500", TtsHttpRule.render("{{String((speakSpeed) * 20 - 400)}}", "x", 45)); + } + + @Test + public void nestedEncodeUriAndExpression() { + String rendered = TtsHttpRule.render( + "tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{speakSpeed}}", "中 文", 4); + // 双重编码后空格变成 %2520 + assertTrue(rendered, rendered.startsWith("tex=%25E4%25B8%25AD%2520%25E6%2596%2587&spd=4")); + } + + @Test + public void plainUrlWithoutOptionsParsesAsGet() { + TtsHttpRule rule = TtsHttpRule.parse( + "https://fanyi.baidu.com/gettts?lan=zh&text={{java.encodeURI(speakText)}}&spd={{speakSpeed}}&source=web", + "audio/mpeg"); + assertTrue(rule.isUsable()); + assertEquals("GET", rule.method()); + assertNull(rule.body("你好", 5)); + assertTrue(rule.url("你好", 5).contains("spd=5")); + } + + @Test + public void baiduStylePostRuleParsesBodyAndMethod() { + String raw = "http://tts.baidu.com/text2audio,{" + + "\"method\": \"POST\"," + + "\"body\": \"tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{(speakSpeed + 5) / 10 + 4}}&per=3\"" + + "}"; + TtsHttpRule rule = TtsHttpRule.parse(raw, "audio/wav"); + assertTrue(rule.unsupportedReason(), rule.isUsable()); + assertEquals("POST", rule.method()); + assertEquals("http://tts.baidu.com/text2audio", rule.url("文本", 5)); + String body = rule.body("文本", 5); + assertNotNull(body); + assertTrue(body, body.endsWith("&spd=5&per=3")); + } + + @Test + public void optionsWithHeadersAreParsed() { + String raw = "https://host/tts,{" + + "\"method\":\"POST\",\"body\":\"t={{speakText}}\"," + + "\"headers\":{\"User-Agent\":\"webhtv\",\"X-A\":\"1\"}}"; + TtsHttpRule rule = TtsHttpRule.parse(raw, "audio/mpeg"); + assertTrue(rule.isUsable()); + assertEquals("webhtv", rule.headers().get("User-Agent")); + assertEquals("1", rule.headers().get("X-A")); + } + + @Test + public void jsRuleIsRejectedWithReason() { + TtsHttpRule rule = TtsHttpRule.parse("@js:apiurl(speakText,speakSpeed);", "audio/mpeg"); + assertFalse(rule.isUsable()); + assertTrue(rule.unsupportedReason().contains("@js")); + } + + @Test + public void postWithoutBodyIsRejected() { + TtsHttpRule rule = TtsHttpRule.parse("https://host/tts,{\"method\":\"POST\"}", "audio/mpeg"); + assertFalse(rule.isUsable()); + } + + @Test + public void emptyRuleIsRejected() { + assertFalse(TtsHttpRule.parse("", "audio/mpeg").isUsable()); + assertFalse(TtsHttpRule.parse(null, "audio/mpeg").isUsable()); + } + + @Test + public void unknownExpressionIsLeftVerbatimSoItIsVisibleInLogs() { + assertEquals("{{foo}}", TtsHttpRule.render("{{foo}}", "x", 5)); + } + + @Test + public void engineConfigRoundTripsThroughJson() { + TtsEngineConfig config = new TtsEngineConfig(); + config.name = "我的引擎"; + config.url = "https://host/tts?t={{java.encodeURI(speakText)}}"; + config.contentType = "audio/mpeg"; + config.header = "{\"User-Agent\":\"webhtv\"}"; + TtsEngineConfig parsed = TtsEngineConfig.fromJson(config.toJson()); + assertNotNull(parsed); + assertEquals(config.name, parsed.name); + assertEquals(config.url, parsed.url); + assertEquals(config.contentType, parsed.contentType); + assertEquals("我的引擎", parsed.displayName()); + } + + @Test + public void engineConfigWithoutUrlIsRejected() { + assertNull(TtsEngineConfig.fromJson("{\"name\":\"x\"}")); + assertNull(TtsEngineConfig.fromJson("not json")); + } + + @Test + public void builtinBaiduRuleIsUsableAndMapsRateIntoEngineRange() { + // 内置百度音源规则必须可用,且语速映射落在接口有效区间 1..7 + TtsHttpRule rule = TtsHttpRule.parse( + "https://fanyi.baidu.com/gettts?lan=zh&text={{java.encodeURI(speakText)}}&spd={{speakSpeed}}&source=web", + "audio/mpeg"); + assertTrue(rule.isUsable()); + for (float rate : new float[]{0.5f, 1.0f, 1.5f, 2.0f}) { + int speed = Math.max(1, Math.min(7, Math.round(rate * 5f))); + assertTrue("rate=" + rate + " speed=" + speed, speed >= 1 && speed <= 7); + String url = rule.url("测试", speed); + assertTrue(url, url.contains("spd=" + speed)); + } + } + + @Test + public void optionsAreOrderedDescendingWhenMultipleCommasExist() { + // 真实场景:URL 模板里带逗号,选项在最后 + String raw = "https://host/tts?t={{java.encodeURI(speakText)}},{\"method\":\"GET\"}"; + TtsHttpRule rule = TtsHttpRule.parse(raw, "audio/mpeg"); + assertTrue(rule.unsupportedReason(), rule.isUsable()); + assertEquals("https://host/tts?t=a", rule.url("a", 5)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/tts/TtsTextSplitterTest.java b/app/src/test/java/com/fongmi/android/tv/tts/TtsTextSplitterTest.java new file mode 100644 index 0000000000..cc7d60da1e --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/tts/TtsTextSplitterTest.java @@ -0,0 +1,97 @@ +package com.fongmi.android.tv.tts; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +public class TtsTextSplitterTest { + + @Test + public void systemModeKeepsWholeParagraph() { + List chunks = TtsTextSplitter.split( + Arrays.asList("第一段。第二句!", "第二段很长很长很长。"), 0); + assertEquals(2, chunks.size()); + assertEquals("第一段。第二句!", chunks.get(0).text); + assertEquals(0, chunks.get(0).paragraph); + assertEquals("第二段很长很长很长。", chunks.get(1).text); + assertEquals(1, chunks.get(1).paragraph); + } + + @Test + public void onlineModeSplitsSentencesAndTracksParagraphIndex() { + List chunks = TtsTextSplitter.split( + Arrays.asList("甲。乙!丙?", "丁。"), TtsTextSplitter.MAX_CHUNK); + assertEquals(4, chunks.size()); + assertEquals("甲。", chunks.get(0).text); + assertEquals("乙!", chunks.get(1).text); + assertEquals("丙?", chunks.get(2).text); + assertEquals("丁。", chunks.get(3).text); + // 段落归属必须正确,否则高亮会跳到别的段落 + assertEquals(0, chunks.get(0).paragraph); + assertEquals(0, chunks.get(2).paragraph); + assertEquals(1, chunks.get(3).paragraph); + } + + @Test + public void punctuationOnlyParagraphsAreDropped() { + List chunks = TtsTextSplitter.split( + Arrays.asList("……", "——", " ", "正文。"), TtsTextSplitter.MAX_CHUNK); + assertEquals(1, chunks.size()); + assertEquals("正文。", chunks.get(0).text); + assertEquals(3, chunks.get(0).paragraph); + } + + @Test + public void longSentenceIsHardSplitUnderLimit() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 40; i++) sb.append("汉字测试,"); + sb.append("。"); + List chunks = TtsTextSplitter.split( + java.util.Collections.singletonList(sb.toString()), 20); + assertTrue("长句必须被切开", chunks.size() > 1); + for (TtsTextSplitter.Chunk chunk : chunks) { + assertTrue("单个片段不得超过上限: " + chunk.text.length(), chunk.text.length() <= 20); + } + } + + @Test + public void controlCharactersAreStripped() { + List chunks = TtsTextSplitter.split( + Arrays.asList("正文\u0000\u0007内容。"), 0); + assertEquals(1, chunks.size()); + assertFalse(chunks.get(0).text.contains("\u0000")); + assertFalse(chunks.get(0).text.contains("\u0007")); + assertEquals("正文内容。", chunks.get(0).text); + } + + @Test + public void silentDetectionMatchesSpeechEngineExpectation() { + assertTrue(TtsTextSplitter.isSilent("……")); + assertTrue(TtsTextSplitter.isSilent(" ")); + assertTrue(TtsTextSplitter.isSilent(null)); + assertFalse(TtsTextSplitter.isSilent("a")); + assertFalse(TtsTextSplitter.isSilent("字")); + assertFalse(TtsTextSplitter.isSilent(",。字。")); + } + + @Test + public void pitchMapsToSsmlOffset() { + assertEquals("+0Hz", TtsOptions.pitchOffsetHz(1.0f)); + assertEquals("+50Hz", TtsOptions.pitchOffsetHz(1.5f)); + assertEquals("-50Hz", TtsOptions.pitchOffsetHz(0.5f)); + // 越界值必须按面板范围夹取,避免把非法 pitch 丢给微软接口 + assertEquals("-50Hz", TtsOptions.pitchOffsetHz(0.1f)); + assertEquals("+50Hz", TtsOptions.pitchOffsetHz(9f)); + } + + @Test + public void emptyInputProducesNoChunks() { + assertTrue(TtsTextSplitter.split(null, 20).isEmpty()); + assertTrue(TtsTextSplitter.split(java.util.Collections.emptyList(), 20).isEmpty()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/C47SeekLoadingProgressSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/C47SeekLoadingProgressSourceTest.java new file mode 100644 index 0000000000..54b80ee477 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/C47SeekLoadingProgressSourceTest.java @@ -0,0 +1,162 @@ +package com.fongmi.android.tv.ui.activity; + +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; + +/** + * 拖拽进度时「大概率看不到加载中/网速」的回归契约。 + * + *

现象:拖动进度条后,加载圈(转圈 + 网速)基本不出现,或者一闪就没。 + * + *

根因:{@code onSeekStarted()} 会在 seek 命令发出前先亮圈,随后 {@code showProgress()} + * 立刻投递每秒一跳的网速 ticker({@code App.post(mR2/mR3, 0)})。那个 ticker 的兜底收口 + * {@code hidePlaybackProgressIfStale()} 读到引擎尚未开始 seek 的旧 READY,就在同一帧把圈 + * 收掉——圈因此连最小可见时长都活不到,第二跳网速采样(需要 1000ms 间隔)更没机会发生。 + * + *

修法:seek 期间打开一个「最小可见 + 必须确实可收」的窗口。窗口未关时任何收圈路径 + * (网速 ticker 兜底、READY 回调、控制器晚绑定补发)都不得收圈;窗口由显式收圈关闭。 + */ +public class C47SeekLoadingProgressSourceTest { + + private static final String MOBILE = + "app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"; + private static final String LEANBACK = + "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"; + private static final String[] SOURCES = {MOBILE, LEANBACK}; + + @Test + public void seekOpensTheMinimumVisibleWindowBeforeShowingProgress() throws Exception { + for (String path : SOURCES) { + String body = bodyToBrace(read(path), "protected void onSeekStarted()"); + + int pending = body.indexOf("mSeekProgressPending = true;"); + int startedAt = body.indexOf("mSeekProgressStartedAtMs = SystemClock.elapsedRealtime();"); + int show = body.indexOf("showProgress();"); + int arm = body.indexOf("App.post(mSeekProgressFallback, SEEK_PROGRESS_MIN_VISIBLE_MS);"); + + assertTrue(path + " must open the seek window", pending >= 0); + assertTrue(path + " must stamp the window start", startedAt > pending); + // showProgress() 会立刻投递网速 ticker,窗口必须先于它打开,否则第一跳就把圈收掉。 + assertTrue(path + " must open the window before showProgress() posts the traffic ticker", + show > startedAt); + assertTrue(path + " must arm the minimum-visible timer after showing progress", arm > show); + } + } + + @Test + public void theMinimumVisibleGateIsTheOnlyWayToCloseTheSeekWindow() throws Exception { + for (String path : SOURCES) { + String source = read(path); + String gate = bodyToBrace(source, "private boolean canHideSeekProgress()"); + String fallback = bodyToBrace(source, "private void hideSeekProgressIfReady()"); + String stale = bodyToBrace(source, "private void hidePlaybackProgressIfStale()"); + + // 最小可见时长:第二跳网速采样需要 1000ms 间隔,窗口不能被瞬间收掉。 + assertTrue(path + " must enforce a minimum visible duration", + gate.contains("SystemClock.elapsedRealtime() - mSeekProgressStartedAtMs < SEEK_PROGRESS_MIN_VISIBLE_MS")); + // 网速读数由相邻两次采样的字节差算出,而采样每秒一跳:窗口必须盖过一跳, + // 否则加载圈虽然出现了,用户仍然看不到网速(本次诉求的另一半)。 + assertTrue(path + " must keep the overlay visible long enough to render one speed sample", + seekMinVisibleMs(source) >= 1000L); + assertTrue(path + " must keep the readout ticker period at one second", + bodyToBrace(source, "private void setTraffic()").contains("App.post(mR2, 1000);") + || bodyToBrace(source, "private void setTraffic()").contains("App.post(mR3, 1000);")); + assertTrue(path + " must not treat an unopened window as blocking", + gate.contains("if (!mSeekProgressPending) return true;")); + // 已 READY 但仍在真实加载(且没在播)时不能收圈,那正是「卡画面」的那一帧。 + assertTrue(path + " must keep the spinner while the player is still loading", + gate.contains("player().getPlaybackState() == Player.STATE_READY") + && gate.contains("!player().isLoading() || player().isPlaying()")); + + assertTrue(path + " must close the window when the gate opens", + fallback.contains("if (!canHideSeekProgress()) return;") + && fallback.contains("mSeekProgressPending = false;")); + + // 网速 ticker 的兜底收口是本次症状的直接执行者,必须受同一道闸门约束。 + assertTrue(path + " must not let the traffic ticker hide a pending seek", + stale.contains("if (!canHideSeekProgress()) return;")); + assertTrue(path + " must keep the stale-spinner fallback owner-scoped", + stale.contains("if (!isOwner()) return;")); + assertTrue(path + " must only act while the spinner is up", + stale.contains("getVisibility() != View.VISIBLE")); + + // 控制器晚绑定补发收口同样不能越过闸门。 + String reconcile = bodyToBrace(source, "protected void onControllerReadyReconciled()"); + assertTrue(path + " must gate the late-controller reconciliation", + reconcile.contains("if (canHideSeekProgress()) showPlaybackContent();")); + } + } + + @Test + public void aStaleReadyReadingCannotCloseTheSeekWindow() throws Exception { + for (String path : SOURCES) { + String source = read(path); + int state = source.indexOf("protected void onStateChanged(int state)"); + int ready = source.indexOf("case Player.STATE_READY:", state); + int ended = source.indexOf("case Player.STATE_ENDED:", ready); + assertTrue(path + " must find the READY branch", state >= 0 && ready > state && ended > ready); + + String readyBody = source.substring(ready, ended); + assertTrue(path + " must route the READY reveal through the seek gate", + readyBody.contains("if (canHideSeekProgress()) showPlaybackContent();")); + // 未通过闸门时绝不能无条件收圈:那是把圈收在冻结画面上的原始缺陷。 + assertFalse(path + " must not reveal playback content unconditionally on READY", + readyBody.contains("\n showPlaybackContent();")); + } + } + + @Test + public void anyExplicitHideAlsoClosesTheWindow() throws Exception { + for (String path : SOURCES) { + String hide = bodyToBrace(read(path), "private void hideProgress()"); + assertTrue(path + " must clear the pending seek window whenever progress is hidden", + hide.contains("mSeekProgressPending = false;") + && hide.contains("mSeekProgressStartedAtMs = 0;")); + // 顺序:先关窗口再摘回调,避免关窗后仍有缓存计时器把状态又推回去。 + assertTrue(path + " must close the window before dropping its callback", + hide.indexOf("mSeekProgressPending = false;") < hide.indexOf("App.removeCallbacks(mSeekProgressFallback);")); + } + } + + @Test + public void showingProgressKeepsAnOpenSeekWindowArmed() throws Exception { + for (String path : SOURCES) { + String show = bodyToBrace(read(path), "private void showProgress()"); + // seek 窗口开着时那次 showProgress 属于 seek 自身(BUFFERING 分支), + // 不能顺手把窗口唯一的最小可见计时器摘掉。 + assertTrue(path + " must not cancel an open seek window's timer while showing progress", + show.contains("if (!mSeekProgressPending && mSeekProgressFallback != null) App.removeCallbacks(mSeekProgressFallback);")); + } + } + + /** 取源文件里 seek 加载圈最小可见时长常量的数值。 */ + private static long seekMinVisibleMs(String source) { + String marker = "private static final long SEEK_PROGRESS_MIN_VISIBLE_MS = "; + int start = source.indexOf(marker); + assertTrue("cannot locate SEEK_PROGRESS_MIN_VISIBLE_MS", start >= 0); + int end = source.indexOf(';', start + marker.length()); + assertTrue("cannot locate the end of SEEK_PROGRESS_MIN_VISIBLE_MS", end > start); + return Long.parseLong(source.substring(start + marker.length(), end).trim().replace("L", "").replace("l", "")); + } + + /** 取一个方法体:从签名到紧随其后的第一个方法级闭合花括号。 */ + private static String bodyToBrace(String source, String marker) { + int start = source.indexOf(marker); + assertTrue("cannot locate " + marker, start >= 0); + int end = source.indexOf("\n }", start + marker.length()); + assertTrue("cannot locate the end of " + marker, end > start); + return source.substring(start, end); + } + + 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(path.substring("app/".length())), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/FileBrowserEmptyStateContractTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/FileBrowserEmptyStateContractTest.java new file mode 100644 index 0000000000..c171ae2568 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/FileBrowserEmptyStateContractTest.java @@ -0,0 +1,124 @@ +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; + +/** + * 本地文件浏览器空态的契约(设计文档 §9.9)。 + * + *

这是审计 P0 里最后一项空态缺口:两端 {@code FileActivity} 此前没有接入 + * {@code setEmptyContent},空态只显示布局里的默认单行文案。 + * + *

主动作裁定为「重新扫描当前目录」(用户 2026-10-13 决定),理由: + *

    + *
  • 根目录没有上级——"返回上级"在根目录会退出页面,与按钮文案不符;
  • + *
  • "重新扫描"在**任何**目录都成立,且能反映外部变化(刚用别的应用放进来的文件), + * 这正是本地文件浏览器最需要的能力。
  • + *
+ * + *

本契约钉住四件事:三件套齐备;主动作调用 {@code update(dir)}(本页唯一的列表填充 + * 入口,既重算 list 又驱动状态机);**不得**把主动作接成返回/退出;三语字符串齐备。 + */ +public class FileBrowserEmptyStateContractTest { + + private static final String[] HOSTS = { + "app/src/leanback/java/com/fongmi/android/tv/ui/activity/FileActivity.java", + "app/src/mobile/java/com/fongmi/android/tv/ui/activity/FileActivity.java", + }; + private static final String STRINGS = "app/src/main/res/values/strings.xml"; + private static final String STRINGS_CN = "app/src/main/res/values-zh-rCN/strings.xml"; + private static final String STRINGS_TW = "app/src/main/res/values-zh-rTW/strings.xml"; + + @Test + public void bothFlavoursWireTheThreeParts() throws Exception { + for (String host : HOSTS) { + String source = read(host); + assertTrue(host + " 必须接入空态内容", source.contains("mBinding.progressLayout.setEmptyContent(")); + for (String string : new String[]{ + "file_empty_title", "file_empty_description", "file_empty_action"}) { + assertTrue(host + " 必须引用 " + string, source.contains(string)); + for (String table : new String[]{STRINGS, STRINGS_CN, STRINGS_TW}) { + assertTrue(table + " 必须声明 " + string, + read(table).contains("name=\"" + string + "\"")); + } + } + } + } + + /** + * 主动作必须复用 {@code update(dir)}——本页唯一的列表填充入口。 + * + *

若接成别的路径(例如只 {@code notifyDataSetChanged()}),"扫描后仍为空"就不会 + * 重新进入空态,用户会看到留白;而 {@code update} 既重算 list 又驱动状态机。 + */ + @Test + public void thePrimaryActionRescansThroughTheSingleListEntryPoint() throws Exception { + for (String host : HOSTS) { + String source = read(host); + String rescan = methodBody(source, "private void rescan()"); + assertTrue(host + " 的重新扫描必须走 update(...)", rescan.contains("update(")); + assertTrue(host + " 尚未拿到目录时必须退回根目录", + rescan.contains("dir == null ? Path.root() : dir")); + // update 必须仍然驱动状态机,否则"扫描后仍为空"会留白 + String update = methodBody(source, "private void update(File dir)"); + assertTrue(host + " 的 update 必须驱动状态机", update.contains("showContent(true,")); + } + } + + /** + * 反例守卫:主动作**不得**接成返回/退出。 + * + *

"返回上级"在根目录会退出页面,与"重新扫描"的按钮文案不符——这是本契约要防的 + * 具体错误,而不只是风格偏好。 + */ + @Test + public void thePrimaryActionDoesNotNavigateAway() throws Exception { + for (String host : HOSTS) { + String rescan = methodBody(read(host), "private void rescan()"); + assertFalse(host + " 的重新扫描不得调用 finish()", rescan.contains("finish()")); + assertFalse(host + " 的重新扫描不得调用 onBackPressed()", rescan.contains("onBackPressed")); + assertFalse(host + " 的重新扫描不得调用 getParentFile()(那是「返回上级」的做法)", + rescan.contains("getParentFile")); + } + } + + /** + * 空态文案必须解释"这里本来就没有可显示的项",而不是"加载失败"——两者是不同状态 + * (§9.9 把空态与错误态分开)。 + */ + @Test + public void theCopyDescribesAnEmptyFolderNotAFailure() throws Exception { + String cn = read(STRINGS_CN); + assertTrue("简中标题必须表达「这里没有内容」", cn.contains("这里没有内容")); + assertTrue("简中动作必须是「重新扫描」", cn.contains("重新扫描")); + assertFalse("空态文案不得使用「失败」措辞(那是错误态)", cn.contains("file_empty_description\">加载失败")); + } + + // ------------------------------------------------------------------ helpers + + private static String methodBody(String source, String signature) { + String cleaned = source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//.*$", " "); + int start = cleaned.indexOf(signature); + assertTrue("必须存在方法 " + signature, start > 0); + int open = cleaned.indexOf('{', start); + int depth = 0; + for (int i = open; i < cleaned.length(); i++) { + char c = cleaned.charAt(i); + if (c == '{') depth++; + else if (c == '}' && --depth == 0) return cleaned.substring(start, i + 1); + } + return cleaned.substring(start); + } + + 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/test/java/com/fongmi/android/tv/ui/activity/FollowingUiSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/FollowingUiSourceTest.java index d2973b3335..bdd61248d8 100644 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/FollowingUiSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/FollowingUiSourceTest.java @@ -5,12 +5,63 @@ import org.junit.Test; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertFalse; public class FollowingUiSourceTest { + @Test + public void followingTvHeaderScrollsWithContent() throws Exception { + String layout = read("app/src/leanback/res/layout/activity_following.xml"); + + assertTrue(layout.contains("androidx.core.widget.NestedScrollView")); + assertTrue(layout.contains("android:nestedScrollingEnabled=\"false\"")); + assertTrue(layout.contains("android:layout_height=\"wrap_content\"")); + } + + @Test + public void followingPageKeepsTvSafeTopInset() throws Exception { + String layout = read("app/src/main/res/layout/activity_following.xml"); + + assertTrue(layout.contains("android:paddingTop=\"28dp\"")); + assertFalse(layout.contains("android:paddingTop=\"12dp\"")); + } + + @Test + public void mobileFollowingHeaderWrapsControlsAndKeepsSummaryVisible() throws Exception { + // Windows 检出(core.autocrlf=true)下工作区是 CRLF;断言里的多行片段用 \n,需先归一化。 + String layout = read("app/src/mobile/res/layout/activity_following.xml").replace("\r\n", "\n"); + + assertTrue(layout.contains("com.google.android.flexbox.FlexboxLayout")); + assertTrue(layout.contains("android:id=\"@+id/actions\"")); + assertTrue(layout.contains("app:flexWrap=\"wrap\"")); + assertTrue(layout.contains("android:id=\"@+id/summary\"")); + assertTrue(layout.contains("android:layout_width=\"match_parent\"\n android:layout_height=\"wrap_content\"\n android:layout_marginTop=\"6dp\"")); + } + + @Test + public void mobileFollowingCardWrapsAllActionsInsteadOfClippingThem() throws Exception { + String layout = read("app/src/mobile/res/layout/item_following.xml"); + + assertTrue(layout.contains("com.google.android.flexbox.FlexboxLayout")); + assertTrue(layout.contains("app:flexWrap=\"wrap\"")); + assertTrue(layout.contains("app:justifyContent=\"flex_start\"")); + assertTrue(layout.contains("app:layout_flexGrow=\"0\"")); + assertTrue(layout.contains("app:layout_flexBasisPercent=\"@fraction/following_action_basis\"")); + assertTrue(read("app/src/mobile/res/values/following_fractions.xml").contains(">30%<")); + assertTrue(read("app/src/mobile/res/values-sw600dp/following_fractions.xml").contains(">18%<")); + assertTrue(layout.indexOf("@+id/error") < layout.indexOf("com.google.android.flexbox.FlexboxLayout")); + assertFalse(layout.contains("HorizontalScrollView")); + assertFalse(layout.contains("app:justifyContent=\"center\"")); + assertFalse(layout.contains("app:justifyContent=\"space_evenly\"")); + assertFalse(layout.contains("app:justifyContent=\"space_around\"")); + for (String id : List.of("nextSeason", "continuePlay", "check", "read", "notify", "sourceChange", "delete")) { + assertTrue(layout.contains("android:id=\"@+id/" + id + "\"")); + } + } + @Test public void mobileBottomNavigationPlacesFollowingBetweenLiveAndSettings() throws Exception { String menu = read("app/src/mobile/res/menu/menu_nav.xml"); @@ -39,15 +90,98 @@ public class FollowingUiSourceTest { assertTrue(home.contains("FollowingActivity.start(this, null)")); } + @Test + public void followingContinueUsesFlavorPlaybackRouterToPreserveSourceBinding() throws Exception { + String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); + int method = activity.indexOf("public void onContinue(Following item, FollowingSource source)"); + int end = activity.indexOf("public void onFollowNextSeason", method); + String body = activity.substring(method, end); + + assertTrue(body.contains("VideoActivity.startFromFollowingHistory(this, history)")); + assertFalse(body.contains("VideoActivity.startFromHistory(this, history)")); + assertFalse(body.contains("TmdbDetailActivity.startFromHistory(this, history)")); + + for (String path : List.of( + "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java", + "app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java")) { + String video = read(path); + int route = video.indexOf("public static void startFromFollowingHistory(Activity activity, History item)"); + int routeEnd = video.indexOf("public static void startFromHistory(Activity activity, History item)", route); + String routeBody = video.substring(route, routeEnd); + assertTrue(routeBody.contains("startFromHistory(activity, item)")); + } + } + @Test public void followingScreenExposesOfficialSourceAndUserStatesSeparately() throws Exception { String adapter = read("app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java"); assertTrue(adapter.contains("following_official")); + assertTrue(adapter.contains("following_season_total")); + assertTrue(adapter.contains("following_series_total")); + assertTrue(adapter.contains("following_weekday")); + assertTrue(adapter.contains("R.array.following_weekdays")); assertTrue(adapter.contains("following_source")); assertTrue(adapter.contains("following_watched")); assertTrue(adapter.contains("following_unwatched")); } + @Test + public void followingScreenFollowsTheGlobalWallpaperLikeOtherContentPages() throws Exception { + String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); + assertTrue("追更页必须复用全局壁纸视图", activity.contains("new CustomWallView(this, null)")); + assertTrue("壁纸必须插在内容根视图之下", activity.contains("content.addView(")); + assertTrue("壁纸必须早于页面初始化", activity.indexOf("addWallpaper();") < activity.indexOf("initView();")); + + String layout = read("app/src/main/res/layout/activity_following.xml"); + assertFalse("根布局必须保持透明,否则壁纸被整页盖住", + layout.contains("android:background=\"?attr/colorSurface\"")); + assertTrue("顶栏必须使用与首页按钮同源的 20% 黑纱", + layout.contains("android:background=\"@drawable/shape_following_bar\"")); + assertTrue("空态需要半透明面板承载文字", + layout.contains("android:background=\"@drawable/shape_following_panel\"")); + assertTrue("空态图标不能再是无条件白色 tint", + layout.contains("android:tint=\"?attr/colorOnSurfaceVariant\"")); + + String item = read("app/src/main/res/layout/item_following.xml"); + assertTrue("卡片必须改用半透明面板而不是不透明表面", + item.contains("app:cardBackgroundColor=\"@color/following_panel_bg\"")); + + String panel = read("app/src/main/res/color/following_panel_bg.xml"); + assertTrue("浅色模式卡片面板必须半透明", panel.contains("android:alpha=\"0.60\"")); + assertTrue("浅色模式卡片面板必须用语义浅色承载深色正文", + panel.contains("?attr/colorSurfaceContainerHigh")); + + String bar = read("app/src/main/res/color/following_bar_bg.xml"); + assertTrue("浅色模式顶栏必须半透明", bar.contains("android:alpha=\"0.45\"")); + assertTrue("浅色模式顶栏必须用语义浅色", bar.contains("?attr/colorSurfaceContainerHigh")); + + // 暗色模式必须回到与首页按钮同源的黑纱,否则深色模式下浅色面板会重新变成“贴板”。 + String darkPanel = read("app/src/main/res/color-night/following_panel_bg.xml"); + assertTrue("暗色模式卡片必须是低透明度黑纱", + darkPanel.contains("android:alpha=\"0.32\"") && darkPanel.contains("@color/black")); + String darkBar = read("app/src/main/res/color-night/following_bar_bg.xml"); + assertTrue("暗色模式顶栏黑纱必须与首页按钮 black_20 对齐", + darkBar.contains("android:alpha=\"0.20\"") && darkBar.contains("@color/black")); + + // 面板极性必须跟随模式,否则浅色深色会各坏一边。 + assertFalse("浅色面板不能使用黑纱", panel.contains("@color/black")); + assertFalse("暗色面板不能使用浅色语义表面", darkPanel.contains("colorSurfaceContainerHigh")); + } + + @Test + public void checkAllRefreshesEveryFollowingItemWithoutACountLimit() throws Exception { + String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); + String coordinator = read("app/src/main/java/com/fongmi/android/tv/following/FollowingUpdateCoordinator.java"); + String dao = read("app/src/main/java/com/fongmi/android/tv/following/FollowingDao.java"); + + assertTrue(activity.contains("for (Following item : items) success |= coordinator.checkNow(item.identityKey, true);")); + assertFalse(activity.contains("Math.min(5, items.size())")); + assertTrue(coordinator.contains("findDue(now)")); + assertFalse(coordinator.contains("BATCH_SIZE")); + assertTrue(dao.contains("ORDER BY next_check_at ASC\"")); + assertFalse(dao.contains("LIMIT :limit")); + } + @Test public void followingHasUpdateFilterAndUserControlledSchedulerSwitch() throws Exception { String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); @@ -109,9 +243,38 @@ public class FollowingUiSourceTest { assertTrue(activity.contains("following_cancel")); } + @Test + public void followingReadStateIsManualAndSupportsMarkAllRead() throws Exception { + String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); + String adapter = read("app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java"); + String leanbackLayout = read("app/src/leanback/res/layout/activity_following.xml"); + String mobileLayout = read("app/src/mobile/res/layout/activity_following.xml"); + String strings = read("app/src/main/res/values-zh-rCN/strings.xml"); + + assertFalse(activity.contains("VISIBLE_READ_DELAY_MS")); + assertFalse(activity.contains("markVisibleReadNow")); + assertFalse(activity.contains("addOnScrollListener(new RecyclerView.OnScrollListener()")); + assertTrue(activity.contains("binding.readAll.setOnClickListener(view -> markAllRead())")); + assertTrue(activity.contains("adapter.markReadLocally(keys)")); + assertTrue(activity.contains("FollowingPlaybackBridge.markReadAllAsync(keys")); + assertTrue(adapter.contains("getItems()")); + assertTrue(leanbackLayout.contains("@+id/readAll")); + assertTrue(mobileLayout.contains("@+id/readAll")); + int checkId = leanbackLayout.indexOf("android:id=\"@+id/check\""); + int readAllId = leanbackLayout.indexOf("android:id=\"@+id/readAll\"", checkId); + int filterId = leanbackLayout.indexOf("android:id=\"@+id/filter\"", readAllId); + assertTrue(leanbackLayout.substring(checkId, readAllId).contains("android:nextFocusRight=\"@id/readAll\"")); + assertTrue(leanbackLayout.substring(readAllId, filterId).contains("android:nextFocusLeft=\"@id/check\"")); + assertTrue(leanbackLayout.substring(readAllId, filterId).contains("android:nextFocusRight=\"@id/filter\"")); + assertTrue(leanbackLayout.substring(filterId).contains("android:nextFocusLeft=\"@id/readAll\"")); + assertTrue(strings.contains("全部已读")); + } + @Test public void detailAndPlaybackScreensWireFollowingActionsOffTheMainThread() throws Exception { String detail = read("app/src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); + // Windows 检出(core.autocrlf=true)下工作区是 CRLF,而断言里的多行片段用 \n:先归一化再比较。 + String detailLf = detail.replace("\r\n", "\n"); String store = read("app/src/main/java/com/fongmi/android/tv/following/FollowingStore.java"); String header = read("app/src/main/res/layout/view_tmdb_header.xml"); String mobile = read("app/src/mobile/res/layout/activity_video.xml") @@ -122,6 +285,8 @@ public class FollowingUiSourceTest { String mobileActivity = read("app/src/mobile/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); String leanbackActivity = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"); String followingActivity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/FollowingActivity.java"); + // 同上:多行片段断言必须先把 CRLF 归一化为 LF,否则 Windows 检出下会假失败。 + String followingActivityLf = followingActivity.replace("\r\n", "\n"); String followingAdapter = read("app/src/main/java/com/fongmi/android/tv/ui/adapter/FollowingAdapter.java"); String strings = read("app/src/main/res/values-zh-rCN/strings.xml"); @@ -142,11 +307,37 @@ public class FollowingUiSourceTest { assertTrue(followingInitiallyGone(read("app/src/main/res/layout/view_tmdb_header.xml"))); assertTrue(mobileActivity.contains("onFollowing()")); assertTrue(leanbackActivity.contains("onFollowing()")); + // 播放页的追更按钮是就地开关:已追更时再次点击必须立刻取消,绝不跳转追更页打断播放。 + for (String playback : List.of(mobileActivity, leanbackActivity)) { + assertTrue(playback.contains("private void cancelFollowing(String identityKey)")); + assertTrue(playback.contains("FollowingPlaybackBridge.deleteAsync(identityKey")); + assertTrue(playback.contains("FollowingScheduler.cancelNext(this, identityKey)")); + assertTrue(playback.contains("Notify.show(R.string.following_canceled)")); + assertFalse(playback.contains("FollowingActivity.start(this, existing.identityKey)")); + } + assertTrue(strings.contains("已取消追更")); + // 详情页与播放页同源:点「已追更」必须就地取消并立即生效,绝不跳转追更页。 + assertFalse(detail.contains("FollowingActivity.start")); + assertFalse(detail.contains("isInlineFollowingPlaybackSurface")); + assertTrue(detailLf.contains("if (isFollowed(existing)) {\n // 详情页与播放页统一为就地开关:再次点击「已追更」立即取消,绝不跳转追更页。\n cancelFollowing(identityKey);")); + assertTrue(detail.contains("private void cancelFollowing(String identityKey)")); + assertTrue(detail.contains("FollowingScheduler.cancelNext(this, identityKey)")); + assertTrue(detail.contains("FollowingPlaybackBridge.deleteAsync(identityKey")); + assertTrue(detail.contains("Notify.show(R.string.following_canceled)")); + // 取消落库成功后必须立刻刷新按钮:updateFollowingState 走 isFollowed,墓碑行视为未追更。 + assertTrue(detailLf.contains("updateFollowingState();\n FollowingPlaybackBridge.refreshUnreadCountAsync(null);\n Notify.show(R.string.following_canceled);")); + // 详情页走 TMDB 身份迁移时 resolveTmdb 会为防复活故意返回墓碑行(C9 语义), + // 因此“已追更”判定必须排除墓碑,否则取消后按钮仍显示“已追更”且永远无法重新追更。 + assertTrue(detail.contains("private boolean isFollowed(Following item)")); + assertTrue(detail.contains("return item != null && !item.isDeleted();")); + assertTrue(detail.contains("if (isFollowed(existing)) {")); + assertTrue(detail.contains("applyFollowingButtonState(true, isFollowed(item));")); + assertFalse(detail.contains("applyFollowingButtonState(true, item != null);")); assertTrue(followingActivity.contains("FollowingPlaybackBridge.deleteAsync")); assertFalse(followingActivity.contains("FollowingStore.delete(item.identityKey)")); - assertTrue(followingActivity.contains("Task.execute(() -> {\n List items = FollowingStore.list();")); - assertTrue(followingActivity.contains("VISIBLE_READ_DELAY_MS")); - assertTrue(followingActivity.contains("markVisibleReadNow()")); + assertTrue(followingActivityLf.contains("Task.execute(() -> {\n List items = FollowingStore.list();")); + assertFalse(followingActivity.contains("VISIBLE_READ_DELAY_MS")); + assertFalse(followingActivity.contains("markVisibleReadNow()")); assertTrue(followingAdapter.contains("item.hasUpdate && item.unwatchedCount > 0")); assertTrue(followingAdapter.contains("markReadLocally")); assertTrue(strings.contains("标记已读")); diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeFragmentCommitStateGuardTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeFragmentCommitStateGuardTest.java new file mode 100644 index 0000000000..bac6a4d0dc --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeFragmentCommitStateGuardTest.java @@ -0,0 +1,188 @@ +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.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * 首页(TV/leanback)在宿主已保存状态的窗口里不能再提交 Fragment 事务。 + * + *

真机崩溃证据(TCL tcl_m7642 / Android 9,dev3 基线): + * + *

+ * 08:19:15.710 [main] startup: home showContent start cost=231455ms
+ * 08:19:15.718 [main] av-diag: process.recovery … message="Can not perform this action after onSaveInstanceState"
+ * 08:19:15.737 [main] process-crash: java.lang.IllegalStateException: Can not perform this action
+ *   after onSaveInstanceState
+ *     at androidx.fragment.app.a.g            (FragmentTransaction.commit)
+ *     at HomeActivity.clearCategoryContent
+ *     at HomeActivity.syncCategorySite
+ *     at HomeActivity.getVideo
+ *     at HomeActivity.showContent
+ *     at HomeActivity$4.success             (VodConfig 加载回调)
+ *     at gd.run → Handler.handleCallback → Looper.loop
+ * 
+ * + *

配置加载回调来自进程级单例 {@code VodConfig},经 {@code App.post} 投递,与 Activity + * 生命周期解耦;而外观页改主题色/主题模式/UI 缩放都会 {@code recreate()}。两者叠加时, + * 这条延迟回调落在旧实例的已保存状态上,无条件 {@code commit()} 便抛异常。 + * + *

守卫拒绝的是「提交」,不是「工作」:实例仍存活(只是被保存了状态,例如用户在配置 + * 加载期间切到别的应用)时,分类页清理与首页内容刷新都要推迟到回到前台补做,否则会留下 + * 旧站点分类页与一个永远不再加载的空首页。 + */ +public class HomeFragmentCommitStateGuardTest { + + private static final String HOME = + "app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"; + + @Test + public void guardHelperRejectsFinishingDestroyedAndSavedState() throws Exception { + String home = read(HOME); + String helper = between(home, + "private boolean canCommitFragmentTransaction() {", + "private void clearCategoryContent()"); + + assertTrue("a saved-state guard must exist for delayed Fragment work", helper.contains("isFinishing()")); + assertTrue("a destroyed host must not commit Fragment work", helper.contains("isDestroyed()")); + assertTrue("a saved FragmentManager must reject the transaction", + helper.contains("getSupportFragmentManager().isStateSaved()")); + // 推迟只对「仍存活」的宿主成立;正在结束/已销毁的实例必须丢弃,不能再挂起工作。 + assertTrue("deferral must exist as a separate predicate", helper.contains("private boolean canDeferFragmentWork()")); + String defer = between(home, + "private boolean canDeferFragmentWork() {", + "private void clearCategoryContent()"); + assertTrue("deferral must reject a finishing host", defer.contains("!isFinishing()")); + assertTrue("deferral must reject a destroyed host", defer.contains("!isDestroyed()")); + } + + @Test + public void categoryStateIsClearedEvenWhenCommitIsSkipped() throws Exception { + String home = read(HOME); + String clear = between(home, + "private void clearCategoryContent() {", + "private void removeCategoryFragments() {"); + + int guard = clear.indexOf("if (!canCommitFragmentTransaction()) {"); + int remove = clear.indexOf("removeCategoryFragments();"); + assertTrue("clearing categories must consult the saved-state guard", guard >= 0); + assertTrue("the guard must be the statement that leads into the transaction", remove > guard); + assertTrue("a live host must defer the skipped removal instead of dropping it", + clear.contains("mPendingCategoryCleanup = true;")); + assertFalse("the guard must not fall back to state-loss commits", + clear.contains("commitAllowingStateLoss")); + + // 纯内存状态必须在守卫“之前”就已经丢弃:否则 isCurrentCategory() 会把一个即将 + // 消失的 Fragment 当成当前分类,重建窗口里到处误判。 + for (String reset : new String[]{ + "mCurrentType = null;", + "mFolder = null;", + "mBinding.categoryContainer.setVisibility(View.GONE);"}) { + int at = clear.indexOf(reset); + assertTrue("category reset must not be skipped with the transaction: " + reset, at >= 0); + assertTrue("category reset must happen before the commit guard: " + reset, at < guard); + } + } + + @Test + public void savedCategoryPageNeverCommitsWithoutTheGuard() throws Exception { + String home = read(HOME); + String clear = between(home, + "private void clearCategoryContent() {", + "private void removeCategoryFragments() {"); + String remove = between(home, + "private void removeCategoryFragments() {", + "private void clearStaleSiteTypes()"); + + assertEquals("clearCategoryContent must delegate the removal instead of committing itself", + 0, countOccurrences(clear, "transaction.commit();")); + assertEquals("the removal must hold exactly one commit", 1, countOccurrences(remove, "transaction.commit();")); + assertTrue("removing cached category pages must stay generation-safe", + remove.contains("folder.clearContentFocusRequest();")); + assertTrue("the removal must sweep every added page, not only hidden ones", + remove.contains("getSupportFragmentManager().getFragments()")); + } + + @Test + public void homeContentRefreshStopsBeforeReshapingASavedStateHost() throws Exception { + String home = read(HOME); + String getVideo = between(home, + "private void getVideo(boolean forceNative) {", + "private void showWebOverlay()"); + + int sync = getVideo.indexOf("syncCategorySite();"); + int guard = getVideo.indexOf("if (!canCommitFragmentTransaction()) {"); + int webLoad = getVideo.indexOf("WebHomeTarget.canLoad(getHome())"); + + assertTrue("the site key must still be committed before the guard", sync >= 0); + assertTrue("a delayed refresh must stop when the host state is already saved", guard > sync); + assertTrue("the guarded refresh must not start web or native content work", webLoad > guard); + assertTrue("a live host must not lose the refresh", getVideo.contains("mPendingContentRefresh = true;")); + assertTrue("a web-triggered refresh must keep its forceNative intent", + getVideo.contains("mPendingContentRefreshForceNative |= forceNative;")); + } + + @Test + public void deferredWorkIsResumedWhenTheHostReturnsToForeground() throws Exception { + String home = read(HOME); + String onResume = between(home, "protected void onResume() {", "protected void onPause() {"); + String resume = between(home, + "private void resumeDeferredFragmentWork() {", + "protected void onPause() {"); + + assertTrue("deferred work must run when the host returns to the foreground", + onResume.contains("resumeDeferredFragmentWork();")); + assertTrue("deferred work must still respect the commit guard", + resume.contains("if (!canCommitFragmentTransaction()) return;")); + for (String deferred : new String[]{ + "mPendingCategoryCleanup = false;", + "removeCategoryFragments();", + "mPendingContentRefresh = false;", + "getVideo(forceNative);"}) { + assertTrue("deferred work must be replayed once: " + deferred, resume.contains(deferred)); + } + } + + @Test + public void hidingTheHomeChromeNeverCommitsWithoutTheGuard() throws Exception { + String home = read(HOME); + String showHome = between(home, + "private void showHomeContent() {", + "private void showCategoryContent(Class item) {"); + + int commit = showHome.indexOf("beginTransaction().hide(mFolder).commit()"); + int guard = showHome.indexOf("if (canCommitFragmentTransaction())"); + + assertTrue("hiding the home chrome commit must remain present", commit >= 0); + assertTrue("hiding the home chrome must be guarded by the saved-state check", guard >= 0); + assertTrue("the guard must wrap the hide transaction", guard < commit); + assertTrue("local visibility state must still be cleared outside the transaction", + showHome.contains("mFolder.setUserVisibleHint(false);")); + } + + private static int countOccurrences(String source, String needle) { + int count = 0; + for (int at = source.indexOf(needle); at >= 0; at = source.indexOf(needle, at + needle.length())) count++; + return count; + } + + private static String between(String source, String start, String end) { + int from = source.indexOf(start); + assertTrue("Missing method: " + start, from >= 0); + int to = source.indexOf(end, from + start.length()); + assertTrue("Missing method boundary: " + end, to > from); + return source.substring(from, to); + } + + private static String read(String relative) throws Exception { + Path path = Path.of(relative); + if (!Files.exists(path) && relative.startsWith("app/")) path = Path.of(relative.substring(4)); + return Files.readString(path, StandardCharsets.UTF_8).replace("\r\n", "\n"); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeSiteSwitchClearsTypesTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeSiteSwitchClearsTypesTest.java new file mode 100644 index 0000000000..b7d93e7271 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/HomeSiteSwitchClearsTypesTest.java @@ -0,0 +1,69 @@ +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 HomeSiteSwitchClearsTypesTest { + + @Test + public void siteSwitchClearsOldTypeButtonsBeforeHomeReload() throws Exception { + String home = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"); + String config = read("app/src/main/java/com/fongmi/android/tv/api/config/VodConfig.java"); + + int setSite = method(home, "public void setSite(Site item)"); + String setHomeCall = "VodConfig.get().setHome(item);"; + int setSiteEnd = home.indexOf(setHomeCall, setSite) + setHomeCall.length(); + int configSetHome = method(config, "public void setHome(Site site)"); + String configRefreshCall = "RefreshEvent.home();"; + int configSetHomeEnd = config.indexOf(configRefreshCall, configSetHome) + configRefreshCall.length(); + int refresh = method(home, "public void onRefreshEvent(RefreshEvent event)"); + int refreshEnd = home.indexOf("@Subscribe", refresh + 1); + int getVideo = method(home, "private void getVideo(boolean forceNative)"); + int getVideoEnd = method(home, "private void showWebOverlay()"); + int syncCategory = method(home, "private void syncCategorySite()"); + int syncCategoryEnd = method(home, "private void clearCategoryContent()"); + int clear = method(home, "private void clearCategoryContent()"); + int clearEnd = method(home, "private void clearStaleSiteTypes()"); + int helper = method(home, "private void clearStaleSiteTypes()"); + int helperEnd = method(home, "private void updateToolbarVisibility(boolean visible)"); + + assertTrue(setSite >= 0); + assertTrue(setSiteEnd > setSite); + assertTrue(home.substring(setSite, setSiteEnd).contains("VodConfig.get().setHome(item);")); + assertTrue(configSetHome >= 0); + assertTrue(configSetHomeEnd > configSetHome); + assertTrue(config.substring(configSetHome, configSetHomeEnd).contains("RefreshEvent.home();")); + assertTrue(refresh >= 0); + assertTrue(refreshEnd > refresh); + assertTrue(home.substring(refresh, refreshEnd).contains("getVideo();")); + assertTrue(getVideo >= 0); + assertTrue(getVideoEnd > getVideo); + assertTrue(home.substring(getVideo, getVideoEnd).contains("syncCategorySite();")); + assertTrue(syncCategory >= 0); + assertTrue(syncCategoryEnd > syncCategory); + assertTrue(home.substring(syncCategory, syncCategoryEnd).contains("if (TextUtils.equals(mCategorySiteKey, key)) return;")); + assertTrue(home.substring(syncCategory, syncCategoryEnd).contains("clearCategoryContent();")); + assertTrue(clear >= 0); + assertTrue(clearEnd > clear); + assertTrue(helper >= 0); + assertTrue(helperEnd > helper); + assertTrue(home.substring(helper, helperEnd).contains("mTypeAdapter.addAll(Collections.emptyList());")); + assertTrue(home.substring(helper, helperEnd).contains("mPendingTypePosition = -1;")); + assertTrue(home.substring(helper, helperEnd).contains("mBinding.typeRecycler.setVisibility(View.GONE);")); + } + + private static int method(String source, String signature) { + return source.indexOf(signature); + } + + private static String read(String relative) throws Exception { + Path path = Path.of(relative); + if (!Files.exists(path) && relative.startsWith("app/")) path = Path.of(relative.substring(4)); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/PlaybackOwnershipSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/PlaybackOwnershipSourceTest.java index f165727cdb..f959b75ea2 100644 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/PlaybackOwnershipSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/PlaybackOwnershipSourceTest.java @@ -28,11 +28,13 @@ public class PlaybackOwnershipSourceTest { String source = read(PLAYBACK); int owner = source.indexOf("protected boolean isOwner()"); - int ownerEnd = source.indexOf('}', source.indexOf("return key == null", owner)); - String body = source.substring(owner, ownerEnd); + int ownerEnd = source.indexOf("\n }", owner); + String body = owner < 0 ? "" : source.substring(owner, ownerEnd); assertTrue("isOwner must exist", owner > 0); assertTrue("ownership must read the pinned session key", body.contains("activePlaybackKey()")); + assertTrue("ownership must compare sessions, not raw strings", + body.contains("PlaybackOwnershipKeys.sameSession(")); assertFalse("ownership must not recompute the key from the live intent", body.contains("getPlaybackKey()")); } @@ -130,6 +132,33 @@ public class PlaybackOwnershipSourceTest { assertTrue("TMDB detail must refresh the service navigation key after that reset", navigation > reset); } + @Test + public void kernelRebuildBindsTheNewEngineToThePlayerViewByInstanceIdentity() throws Exception { + // 自动下一集先 clear() 把 Manager.spec 置空,再 preparePlayer() 重建引擎;此时 + // isOwner() 为 false,onPlayerRebuild() 里的 setRender() 整段被跳过,render 值不变。 + // 只按“PlayerView.player 是否为空”判断,就会把已释放的旧引擎留在 View 上, + // 新引擎从未拿到 Surface —— 画面全黑、声音正常,手动切内核/软硬解才恢复。 + // AUTONEXT-02 起判定收敛到 VideoSurfaceBinding,attachSurface 只执行它的结论。 + String source = read(PLAYBACK); + int attach = source.indexOf("private void attachSurface(boolean restoreExoShutter)"); + int end = source.indexOf("\n }", attach); + assertTrue("attachSurface must exist", attach > 0 && end > attach); + String body = source.substring(attach, end); + + assertTrue("the surface rebind must compare the live engine instance, not emptiness", + body.contains("Player currentPlayer = getExoView().getPlayer();") + && body.contains("Player nextPlayer = player().getPlayer();") + && body.contains("VideoSurfaceBinding.resolve(currentPlayer, nextPlayer);")); + assertTrue("the identity branch must hand the view the new engine", + body.contains("getExoView().setPlayer(binding.player());")); + int detach = body.indexOf("if (binding.detachedFirst()) getExoView().setPlayer(null);"); + int rebind = body.indexOf("getExoView().setPlayer(binding.player());"); + assertTrue("the released engine must be detached before the replacement is bound", + detach > 0 && rebind > detach); + assertFalse("attachment must no longer depend on the view merely being empty", + body.contains("if (getExoView().getPlayer() == null)")); + } + @Test public void staleSpinnerFallbackCannotExposeAnotherItemsPlayback() throws Exception { for (String path : new String[] { @@ -309,6 +338,99 @@ public class PlaybackOwnershipSourceTest { mobile.contains("mPendingPlayerResult = null;")); } + /** + * 退出播放页必须确定性停播。 + * + *

旧实现只在「服务在且本页是归属方」时 shutdown,其余情况交给控制器。控制器是异步连上的, + * 播放又由服务侧直接起播,于是控制器未就绪时整次停播是空操作;播放器处于 IDLE(取流中退出)时 + * 会被会话里的 stop 直接忽略;这条路径也从不撤销前台通知。用户看到的就是 + * 「退回详情页后还有声音、通知栏还显示播放中」。 + */ + @Test + public void exitingThePlayerPageAlwaysStopsThroughTheService() throws Exception { + String source = read(PLAYBACK); + + int stop = source.indexOf("protected void stopPlayback()"); + assertTrue("stopPlayback must exist", stop > 0); + int end = source.indexOf("\n }", stop); + assertTrue("stopPlayback must be readable", end > stop); + String body = source.substring(stop, end); + + assertTrue("the owner must still shut the service down", body.contains("mService.shutdown();")); + assertTrue("a non-owner exit must stop through the service as well", + body.contains("mService.suspend();")); + assertTrue("the controller may only remain as the no-service fallback", + body.indexOf("mController.stop();") > body.indexOf("if (mService == null)")); + assertFalse("the non-owner stop must not stay gated on an asynchronous controller", + body.contains("else if (mController != null)")); + } + + /** + * 已经决定退出的播放页不能再起播。 + * + *

自动换源、换线路、取流结果这些链路最终都回到 startPlayer;迟到的一次起播会在服务里正常 + * 播放并挂上前台通知,而页面已经退出,没有人再停它。 + */ + @Test + public void anExitingPlayerPageNeverStartsPlaybackAgain() throws Exception { + String source = read(PLAYBACK); + + int startPlayer = source.indexOf("protected void startPlayer(String key, Result result, boolean useParse, long timeout,"); + assertTrue("the six-argument startPlayer must exist", startPlayer > 0); + int body = source.indexOf("MediaMetadata metadata, long startPositionMs) {", startPlayer); + assertTrue("the six-argument startPlayer body must be readable", body > startPlayer); + int route = source.indexOf("NovelRouter.isReaderUrl(result)", body); + assertTrue("the reader route must still be the first dispatch", route > body); + + String guard = source.substring(body, route); + assertTrue("startPlayer must refuse to start once the page is exiting", + guard.contains("isPlaybackExiting()")); + assertTrue("startPlayer must refuse to start once the page is finishing or destroyed", + guard.contains("isFinishing()") && guard.contains("isDestroyed()")); + } + + /** + * 归属判定两侧必须同源:页面与服务各用一份,任一侧退回字符串相等就会重现 AUTONEXT-02。 + */ + @Test + public void serviceNavigationOwnershipComparesTheSameSessions() throws Exception { + String service = read("app/src/main/java/com/fongmi/android/tv/service/PlaybackService.java"); + + int owner = service.indexOf("private boolean isNavigationOwner()"); + int ownerEnd = service.indexOf("\n }", owner); + assertTrue("isNavigationOwner must exist", owner > 0 && ownerEnd > owner); + assertTrue("service-side navigation ownership must compare sessions, not raw strings", + service.substring(owner, ownerEnd).contains("PlaybackOwnershipKeys.sameSession(")); + + // 选集键前缀在 browse 与 player 两个包里各写一份,必须同值,否则归属判定会静默失效。 + assertTrue("VodBrowse must keep the episode prefix", + read("app/src/main/java/com/fongmi/android/tv/browse/VodBrowse.java") + .contains("static final String VOD_EP = \"VE:\";")); + assertTrue("PlaybackOwnershipKeys must use the same episode prefix", + read("app/src/main/java/com/fongmi/android/tv/player/PlaybackOwnershipKeys.java") + .contains("static final String EPISODE_PREFIX = \"VE:\";")); + } + + /** + * Surface 补绑只能在用户实际在看的那一页执行,否则后台旧页面会把引擎抢走。 + */ + @Test + public void surfaceTopUpRunsOnlyWhileThePageIsResumedAndNeverUnbinds() throws Exception { + String source = read(PLAYBACK); + + int topUp = source.indexOf("private void ensureVideoSurfaceBound(String reason)"); + int end = source.indexOf("\n }", topUp); + assertTrue("ensureVideoSurfaceBound must exist", topUp > 0 && end > topUp); + String body = source.substring(topUp, end); + + assertTrue("only a RESUMED page may re-bind the shared engine", + body.contains("isAtLeast(Lifecycle.State.RESUMED)")); + assertTrue("an engine that is not ready must not unbind the view", + body.contains("if (nextPlayer == null) return;")); + assertTrue("the top-up must not drop the ASS/subtitle sessions", + !body.contains("detachSurface()")); + } + private static int countOccurrences(String text, String needle) { int count = 0; int index = 0; diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/ReaderPlaybackRoutingSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/ReaderPlaybackRoutingSourceTest.java index d22d0dfad4..f0235cd192 100644 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/ReaderPlaybackRoutingSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/ReaderPlaybackRoutingSourceTest.java @@ -178,8 +178,15 @@ public class ReaderPlaybackRoutingSourceTest { source.contains("if (mViewModel != null) mViewModel.cancelPlayerContent();")); assertTrue(path + " must track the result already applied to the player", source.contains("mAppliedPlayerResult")); + // 这里只钉「重复结果不得再起播一次」这个语义,不再钉死大括号风格: + // Leanback 需要在这个早退分支里顺带释放播放加载守卫(同一结果已在播就不会再有 + // READY 回调来收圈),所以它的分支是多行块;mobile 没有那套守卫状态,保持单行即可。 + // 两者共同且唯一必须成立的,是早退判定本身且它真的 return 掉了。 + int duplicate = source.indexOf("result == mAppliedPlayerResult && !player().isEmpty()"); + int applied = source.indexOf("mAppliedPlayerResult = result;", duplicate); assertTrue(path + " must ignore a duplicate player result while playback remains active", - source.contains("if (result == mAppliedPlayerResult && !player().isEmpty()) return;")); + duplicate >= 0 && applied > duplicate + && source.substring(duplicate, applied).contains("return")); } } diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/SpeechAdSettingSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/SpeechAdSettingSourceTest.java deleted file mode 100644 index 9dceb6b8c0..0000000000 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/SpeechAdSettingSourceTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.fongmi.android.tv.ui.activity; - -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 SpeechAdSettingSourceTest { - @Test - public void leanbackExposesAllSpeechAdControls() throws Exception { - String java = read("app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java"); - String xml = read("app/src/leanback/res/layout/activity_setting_ad.xml"); - String presenter = read("app/src/main/java/com/fongmi/android/tv/ui/dialog/AdSkipPromptPresenter.java"); - assertTrue(xml.contains("@+id/speechAdEnabled")); - assertTrue(xml.contains("@+id/speechAdKeywords")); - assertTrue(xml.contains("@+id/speechAdRules")); - assertTrue(xml.contains("@+id/speechAdBuiltin")); - assertTrue(xml.contains("@+id/speechAdSkipSeconds")); - assertTrue(xml.contains("@+id/speechAdSkipMode")); - assertTrue(java.contains("SpeechAdSetting.setEnabled")); - assertTrue(java.contains("SpeechAdSetting.setKeywords")); - assertTrue(java.contains("SpeechAdSetting.setSkipSeconds")); - assertTrue(java.contains("SpeechAdSetting.setMode")); - assertTrue(java.contains("speechAdRulePicker")); - assertTrue(java.contains("new String[]{\"text/plain\", \"text/*\"}")); - assertTrue(java.contains("SpeechAdSetting.setRulesText")); - assertTrue(java.contains("SpeechAdSetting.clearRules")); - assertTrue(java.contains("SpeechAdSetting.setBuiltinEnabled")); - assertTrue(java.contains("if (success) notifyAdAudioRuntime()")); - assertTrue(java.contains("speech_ad_rules_view_builtin")); - assertTrue(java.contains("reloadAdAudioSettings")); - assertTrue(isFocusable(xml, "speechAdRules")); - assertTrue(isFocusable(xml, "speechAdBuiltin")); - assertTrue(presenter.contains("SpeechAdSignalProvider.RULE_ID")); - assertTrue(presenter.contains("ad_audio_speech_candidate_message")); - assertTrue(!presenter.contains("prompt.ruleId(), prompt.skipDurationSeconds()")); - } - - @Test - public void mobileExposesAllSpeechAdControls() throws Exception { - String java = read("app/src/mobile/java/com/fongmi/android/tv/ui/fragment/SettingAdFragment.java"); - String xml = read("app/src/mobile/res/layout/fragment_setting_ad.xml"); - assertTrue(xml.contains("@+id/speechAdEnabled")); - assertTrue(xml.contains("@+id/speechAdKeywords")); - assertTrue(xml.contains("@+id/speechAdRules")); - assertTrue(xml.contains("@+id/speechAdBuiltin")); - assertTrue(xml.contains("@+id/speechAdSkipSeconds")); - assertTrue(xml.contains("@+id/speechAdSkipMode")); - assertTrue(xml.contains("@string/speech_ad_enabled")); - assertTrue(xml.contains("@string/speech_ad_keywords")); - assertTrue(xml.contains("@string/speech_ad_rules")); - assertTrue(xml.contains("@string/speech_ad_builtin")); - assertTrue(xml.contains("@string/speech_ad_skip_seconds")); - assertTrue(xml.contains("@string/speech_ad_skip_mode")); - assertTrue(java.contains("SpeechAdSetting.setEnabled")); - assertTrue(java.contains("SpeechAdSetting.setKeywords")); - assertTrue(java.contains("SpeechAdSetting.setSkipSeconds")); - assertTrue(java.contains("SpeechAdSetting.setMode")); - assertTrue(java.contains("speechAdRulePicker")); - assertTrue(java.contains("new String[]{\"text/plain\", \"text/*\"}")); - assertTrue(java.contains("SpeechAdSetting.setRulesText")); - assertTrue(java.contains("SpeechAdSetting.clearRules")); - assertTrue(java.contains("SpeechAdSetting.setBuiltinEnabled")); - assertTrue(java.contains("if (success) notifyAdAudioRuntime()")); - assertTrue(java.contains("reloadAdAudioSettings")); - } - - private static boolean isFocusable(String xml, String id) { - int start = xml.indexOf("@+id/" + id); - int end = xml.indexOf("/>", start); - return start >= 0 && end > start - && xml.substring(start, end).contains("android:focusable=\"true\""); - } - private static String read(String path) throws Exception { - Path direct = Path.of(path); - if (Files.exists(direct)) return Files.readString(direct, StandardCharsets.UTF_8); - String modulePath = path.startsWith("app/") ? path.substring(4) : path; - return Files.readString(Path.of(modulePath), StandardCharsets.UTF_8); - } -} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayTransitionSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayTransitionSourceTest.java index 5e838ba165..bb0da4f6a4 100644 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayTransitionSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDirectPlayTransitionSourceTest.java @@ -39,6 +39,64 @@ public class TmdbDetailDirectPlayTransitionSourceTest { assertTrue(closeMethod.contains("currentInlineResult = null;")); } + @Test + public void directPlaybackSnapshotUsesLanguageIdentityAndUnifiedOverviewPolicy() throws Exception { + String source = Files.readString( + Paths.get("src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"), StandardCharsets.UTF_8); + + assertTrue(source.contains("TmdbDetailCache.take(getIntent().getStringExtra(TmdbDetailCache.EXTRA_KEY), getTmdbItem(), currentTmdbLanguage())")); + assertFalse(source.contains("TmdbDetailCache.take(getIntent().getStringExtra(TmdbDetailCache.EXTRA_KEY), getTmdbItem())")); + assertTrue(source.contains("translatedOverview(detail, currentTmdbConfig())")); + assertFalse(source.contains("cachedTmdbOverviewForLanguage(translations, \"zh-CN\")")); + } + + /** + * 被作废的取流请求必须留着释放路径,不能留下永久加载标记。 + * + *

三条作废路径都没有结果回调可用来收圈:换内核/打开外部播放器会自增代际并取消任务 + * (回调可能根本不执行),选择面变更(详情重载/外部播放返回)会让回调因请求失效直接返回。 + * 标记残留会让详情页加载圈永久留在屏上(见 {@code updateInlineLoading} 的首个条件), + * 并让 {@code isSamePendingInlinePlayback} 恒真、同一集无法再次起播。 + */ + @Test + public void cancellingAnInlinePlaybackRequestReleasesItsLoadingFlag() throws Exception { + String source = Files.readString(SOURCE, StandardCharsets.UTF_8); + + // 自增代际、不接替请求的两处:必须在投递新请求之前同步释放 + String switchMethod = methodBody(source, "private boolean refreshAndSwitchInlinePlayer(int playerType)"); + assertFalse("refreshAndSwitchInlinePlayer must exist", switchMethod.isEmpty()); + int switchGeneration = switchMethod.indexOf("++inlinePlaybackGeneration"); + int switchRelease = switchMethod.indexOf("releaseInlinePlaybackPending();"); + int switchSubmit = switchMethod.indexOf("detailTasks.submit("); + assertTrue("refreshAndSwitchInlinePlayer must release the loading flag of the request it invalidates", + switchGeneration >= 0 && switchRelease > switchGeneration); + assertTrue("the release must happen synchronously, before the replacement request is submitted", + switchSubmit > switchRelease); + + String cancelMethod = methodBody(source, "private void cancelPendingInlinePlayerSwitch()"); + assertFalse("cancelPendingInlinePlayerSwitch must exist", cancelMethod.isEmpty()); + assertTrue("cancelPendingInlinePlayerSwitch must release the loading flag of the request it invalidates", + cancelMethod.contains("inlinePlaybackGeneration++;") + && cancelMethod.contains("releaseInlinePlaybackPending();")); + + // 选择面变更不自增代际,只能由回调按归属释放 + String playMethod = methodBody(source, "private void playInline(long resumePosition, String failedUrl, String failureMessage)"); + assertFalse("playInline must exist", playMethod.isEmpty()); + int markGeneration = playMethod.indexOf("++inlinePlaybackGeneration"); + assertTrue("playInline must record the generation that owns the pending flag", + markGeneration >= 0 + && playMethod.indexOf("inlinePlaybackPendingGeneration = generation;", markGeneration) > markGeneration); + int firstStaleRelease = playMethod.indexOf("releaseInlinePlaybackPending(generation);"); + assertTrue("both stale branches (success and failure) must release the flag they own", + firstStaleRelease >= 0 + && playMethod.indexOf("releaseInlinePlaybackPending(generation);", firstStaleRelease + 1) > firstStaleRelease); + + String ownedRelease = methodBody(source, "private void releaseInlinePlaybackPending(int generation)"); + assertFalse("the owned release must exist", ownedRelease.isEmpty()); + assertTrue("the owned release must refuse to clear a newer request's flag", + ownedRelease.contains("inlinePlaybackPendingGeneration != generation")); + } + private static String methodBody(String source, String signature) { int start = source.indexOf(signature); if (start < 0) return ""; diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDisambiguationPerfSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDisambiguationPerfSourceTest.java new file mode 100644 index 0000000000..02ba9ed8d7 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailDisambiguationPerfSourceTest.java @@ -0,0 +1,103 @@ +package com.fongmi.android.tv.ui.activity; + +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; +import java.nio.file.Paths; + +/** + * 锁定 TMDB 详情加载的两项性能契约(不涉及判定阈值): + *

    + *
  1. 自动匹配消歧只拉 lite 详情(credits/aggregate_credits)并有界并行;赢家才回到完整详情;
  2. + *
  3. rail 缩略图走官方小档位,hero/viewer/保存仍拿 original。
  4. + *
+ */ +public class TmdbDetailDisambiguationPerfSourceTest { + + private static final Path SOURCE = + Paths.get("src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); + + @Test + public void disambiguationFetchesLiteDetailsConcurrentlyAndUpgradesOnlyTheWinner() throws Exception { + String source = Files.readString(SOURCE, StandardCharsets.UTF_8); + + assertTrue("bounded concurrency constant must exist", + source.contains("private static final int DISAMBIGUATION_CONCURRENCY = 3;")); + + String fetch = methodBody(source, "private JsonObject[] fetchDisambiguationDetails("); + assertFalse("fetch helper must exist", fetch.isEmpty()); + assertTrue("candidate details must come from the lite endpoint", + fetch.contains("detailForDisambiguation(item, tmdbConfig)")); + assertTrue("candidate fetches must be bounded to the concurrency window", + fetch.contains("start += DISAMBIGUATION_CONCURRENCY")); + assertFalse("candidate fetches must not load the full bundle", fetch.contains("loadTmdbBundle(")); + + String strict = methodBody(source, "private TmdbBundle chooseTmdbBundle("); + assertFalse("chooseTmdbBundle must exist", strict.isEmpty()); + assertFalse("strict disambiguation must not fetch full details per candidate", strict.contains("loadTmdbBundle(")); + assertTrue(strict.contains("fetchDisambiguationDetails(candidates, count)")); + assertTrue(strict.contains("loadDisambiguationWinner(best.item)")); + + String smart = methodBody(source, "private TmdbBundle chooseSmartTmdbBundle("); + assertFalse("chooseSmartTmdbBundle must exist", smart.isEmpty()); + assertFalse("smart disambiguation must not fetch full details per candidate", smart.contains("loadTmdbBundle(")); + assertTrue(smart.contains("fetchDisambiguationDetails(candidates, count)")); + assertTrue(smart.contains("loadDisambiguationWinner(best.item)")); + + String upgrade = methodBody(source, "private TmdbBundle loadDisambiguationWinner("); + assertFalse("winner upgrade helper must exist", upgrade.isEmpty()); + assertTrue("the winner must be upgraded to the full detail bundle", upgrade.contains("loadTmdbBundle(item)")); + } + + @Test + public void railsUseOfficialSmallTiersWhileHeroViewerAndSavingKeepOriginal() throws Exception { + String source = Files.readString(SOURCE, StandardCharsets.UTF_8); + + String posters = methodBody(source, "private void setRailPosters("); + assertFalse("setRailPosters must exist", posters.isEmpty()); + assertTrue(posters.contains("TmdbImageSelector.posterTier(url)")); + + String photos = methodBody(source, "private void setRailPhotos("); + assertFalse("setRailPhotos must exist", photos.isEmpty()); + assertTrue(photos.contains("TmdbImageSelector.photoTier(url)")); + + String bundle = methodBody(source, "private void applyTmdbBundle("); + assertTrue("detail bundle must route rails through the tiering helpers", + bundle.contains("setRailPosters(") && bundle.contains("setRailPhotos(")); + + String initialArtwork = methodBody(source, "private void bindInitialArtwork("); + assertTrue("initial hero poster must use the tiered URL", initialArtwork.contains("heroPosterTier(")); + assertFalse("initial hero poster must not download the original", + initialArtwork.contains("TmdbImageSelector.originalUrl(")); + + String posterUrl = methodBody(source, "private String tmdbPosterUrl("); + assertTrue("hero poster URL must be tiered", posterUrl.contains("heroPosterTier(")); + + String highRes = methodBody(source, "private String highResTmdbImage("); + assertTrue("fullscreen viewer/save path must keep upgrading to original", + highRes.contains("TmdbImageSelector.originalUrl(url)")); + + String viewer = methodBody(source, "private void loadPhotoImage("); + assertTrue("photo viewer must still request the original", viewer.contains("highResTmdbImage(url)")); + assertTrue("saving must still request the original", + source.contains("TmdbImageSaver.save(this, highResTmdbImage(url)")); + } + + private static String methodBody(String source, String signature) { + int start = source.indexOf(signature); + if (start < 0) return ""; + int brace = source.indexOf('{', start); + int depth = 0; + for (int i = brace; i < source.length(); i++) { + char c = source.charAt(i); + if (c == '{') depth++; + if (c == '}' && --depth == 0) return source.substring(start, i + 1); + } + return ""; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailPoolBlockingSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailPoolBlockingSourceTest.java new file mode 100644 index 0000000000..3e7ae56abe --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailPoolBlockingSourceTest.java @@ -0,0 +1,72 @@ +package com.fongmi.android.tv.ui.activity; + +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; +import java.nio.file.Paths; + +/** + * 锁定 W2:详情页等待 TMDB 结果的阻塞段不得占用共享 5 线程池。 + * + *

{@code Task.executor()} 只有 5 个线程且被首页、搜索、站点加载等共用;详情页一次等待可达 + * 1–3 秒。协调任务只负责发起请求并派发收尾任务,真正阻塞在 {@code tmdbFuture.get()} 的 + * {@code completeTmdbLoad} 必须提交到 {@code Task.largeExecutor()}。 + */ +public class TmdbDetailPoolBlockingSourceTest { + + private static final Path SOURCE = + Paths.get("src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); + + @Test + public void coordinatorDoesNotBlockTheSharedPoolOnTheTmdbFuture() throws Exception { + String source = Files.readString(SOURCE, StandardCharsets.UTF_8); + String loadContent = methodBody(source, "private void loadContent("); + + assertFalse("loadContent must exist", loadContent.isEmpty()); + assertFalse("the coordinator must not wait on the TMDB future itself", + loadContent.contains("tmdbFuture.get()")); + assertTrue("the waiting continuation must be dispatched to the large executor", + loadContent.contains("detailTasks.submit(Task.largeExecutor(),")); + assertTrue("the dispatched continuation must be completeTmdbLoad", + loadContent.contains("completeTmdbLoad(generation, mode, finalVod, finalError, singlePassStandaloneTmdb, tmdbFuture, decision, loadStart)")); + } + + @Test + public void continuationKeepsGenerationAndCredentialGuards() throws Exception { + String source = Files.readString(SOURCE, StandardCharsets.UTF_8); + String complete = methodBody(source, "private void completeTmdbLoad("); + + assertFalse("completeTmdbLoad must exist", complete.isEmpty()); + assertTrue("the continuation owns the blocking wait", complete.contains("tmdbFuture.get()")); + assertTrue("every UI handoff must still check the load generation", + count(complete, "generation != loadGeneration") >= 4); + assertTrue("the single-pass branch must still preload the initial season", + complete.contains("preloadInitialStandaloneSeason(loadedResult, finalVod)")); + assertTrue("the failure path must still fall back to the source detail", + complete.contains("tmdb wait failed")); + } + + private static int count(String text, String token) { + int total = 0; + for (int i = text.indexOf(token); i >= 0; i = text.indexOf(token, i + token.length())) total++; + return total; + } + + private static String methodBody(String source, String signature) { + int start = source.indexOf(signature); + if (start < 0) return ""; + int brace = source.indexOf('{', start); + int depth = 0; + for (int i = brace; i < source.length(); i++) { + char c = source.charAt(i); + if (c == '{') depth++; + if (c == '}' && --depth == 0) return source.substring(start, i + 1); + } + return ""; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailPrefetchWiringSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailPrefetchWiringSourceTest.java new file mode 100644 index 0000000000..62fb69b087 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailPrefetchWiringSourceTest.java @@ -0,0 +1,98 @@ +package com.fongmi.android.tv.ui.activity; + +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; +import java.nio.file.Paths; + +/** + * 锁定 TMDB 详情页的 W1 契约(`docs/C16-tmdb-source-detail-contract.md` §6.3): + * identity 已知时 TMDB 详情在站点详情之前并行预热;预热结果只在需要 TMDB 详情的路径被消费, + * 源 payload 路径丢弃;页面销毁/重载/换源时取消单槽。 + */ +public class TmdbDetailPrefetchWiringSourceTest { + + private static final Path SOURCE = + Paths.get("src/main/java/com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); + + @Test + public void tmdbDetailPrefetchStartsBeforeTheSiteDetailRequest() throws Exception { + String source = Files.readString(SOURCE, StandardCharsets.UTF_8); + String loadContent = methodBody(source, "private void loadContent("); + + int startPrefetch = loadContent.indexOf("startTmdbDetailPrefetch(initialTmdbItem, reusableBundle);"); + int submit = loadContent.indexOf("detailTasks.submit("); + assertTrue("prefetch start must exist", startPrefetch >= 0); + assertTrue("prefetch must start before the worker that performs SiteApi.detailContent", submit > startPrefetch); + + String starter = methodBody(source, "private void startTmdbDetailPrefetch("); + assertFalse("startTmdbDetailPrefetch must exist", starter.isEmpty()); + assertTrue("must not prefetch without a known identity or with a reusable bundle", + starter.contains("if (item == null || reusableBundle != null) return;")); + assertTrue("must require a ready TMDB config", starter.contains("!config.isReady()")); + assertTrue("must respect the per-site TMDB switch", starter.contains("isTmdbAllowedForCurrentSite()")); + assertTrue("prefetch must use the shared single-slot prefetch", + starter.contains("detailPrefetch.start(item,")); + assertTrue("prefetch must load the detail by identity", starter.contains("tmdbService.detail(item, config)")); + } + + @Test + public void identityPathConsumesThePrefetchedDetailInsteadOfRequestingAgain() throws Exception { + String source = Files.readString(SOURCE, StandardCharsets.UTF_8); + String loadResult = methodBody(source, "private TmdbLoadResult loadTmdbResult()"); + + assertTrue("identity path must consume the prefetch", + loadResult.contains("tmdbBundle = loadPrefetchedTmdbBundle(initialTmdbItem);")); + assertFalse("identity path must not issue a second detail request", + loadResult.contains("loadTmdbBundle(initialTmdbItem)")); + + String consume = methodBody(source, "private TmdbBundle loadPrefetchedTmdbBundle("); + assertFalse("loadPrefetchedTmdbBundle must exist", consume.isEmpty()); + assertTrue("must take the single-slot future for this identity", + consume.contains("detailPrefetch.take(item)")); + assertTrue("missing/unusable prefetch must fall back to the regular fetch", + consume.contains("return loadTmdbBundle(item);")); + assertTrue("prefetched details must be assembled by the shared bundle builder", + consume.contains("toTmdbBundle(")); + + String bundle = methodBody(source, "private TmdbBundle loadTmdbBundle("); + assertTrue("regular fetch must delegate to the shared bundle builder", + bundle.contains("toTmdbBundle(item, tmdbService.detail(item, tmdbConfig))")); + } + + @Test + public void sourcePayloadPathDiscardsThePrefetchAndLifecycleCancelsIt() throws Exception { + String source = Files.readString(SOURCE, StandardCharsets.UTF_8); + String loadContent = methodBody(source, "private void loadContent("); + + int sourceBranch = loadContent.indexOf("if (sourceBundle != null) {"); + int cancel = loadContent.indexOf("detailPrefetch.cancel();", sourceBranch); + int merge = loadContent.indexOf("TmdbSourceMerger.merge(sourceBundle, sourcePayload, reusableBundle", sourceBranch); + assertTrue("source payload branch must exist", sourceBranch >= 0); + assertTrue("source payload path must discard the prefetch", cancel > sourceBranch); + assertTrue("source payload path must not consume the prefetch before merging", merge > cancel); + + String destroy = methodBody(source, "protected void onDestroy()"); + assertTrue("onDestroy must cancel the in-flight prefetch", destroy.contains("detailPrefetch.cancel();")); + assertTrue("every load must reset the single slot before starting", + loadContent.indexOf("detailPrefetch.cancel();") < loadContent.indexOf("startTmdbDetailPrefetch(initialTmdbItem, reusableBundle);")); + } + + private static String methodBody(String source, String signature) { + int start = source.indexOf(signature); + if (start < 0) return ""; + int brace = source.indexOf('{', start); + int depth = 0; + for (int i = brace; i < source.length(); i++) { + char c = source.charAt(i); + if (c == '{') depth++; + if (c == '}' && --depth == 0) return source.substring(start, i + 1); + } + return ""; + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailSourcePayloadTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailSourcePayloadTest.java index 42a0700d95..20fd2495b8 100644 --- a/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailSourcePayloadTest.java +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/TmdbDetailSourcePayloadTest.java @@ -30,7 +30,7 @@ public class TmdbDetailSourcePayloadTest { String body = loadContentBody(); int request = body.indexOf("tmdbService.detailForSource(initialBundle.item(), sourcePayload.getSeasonNumber(), tmdbConfig, plan.missing())"); int adapt = body.indexOf("TmdbSourceAdapter.fromNetwork(initialBundle.item(), detail, tmdbConfig)"); - int merge = body.indexOf("TmdbSourceMerger.fillOnly(initialBundle, sourcePayload, networkBundle)"); + int merge = body.indexOf("TmdbSourceMerger.fillOnly(initialBundle, sourcePayload, networkBundle"); int apply = body.indexOf("applyTmdbResultNow(new TmdbLoadResult(mergedBundle, List.of()));", merge); assertTrue("missing capabilities must request by the embedded TMDB identity", request >= 0 && adapt > request); diff --git a/app/src/test/java/com/fongmi/android/tv/ui/activity/VideoSurfaceBindingTest.java b/app/src/test/java/com/fongmi/android/tv/ui/activity/VideoSurfaceBindingTest.java new file mode 100644 index 0000000000..d301ccfbf9 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/activity/VideoSurfaceBindingTest.java @@ -0,0 +1,61 @@ +package com.fongmi.android.tv.ui.activity; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class VideoSurfaceBindingTest { + + @Test + public void sameEngineIsNotRebound() { + Object engine = new Object(); + VideoSurfaceBinding.Result result = VideoSurfaceBinding.resolve(engine, engine); + assertFalse("stable playback must not touch the binding", result.applied()); + assertSame(engine, result.player()); + } + + @Test + public void replacedEngineIsReboundAfterDetachingTheOldOne() { + Object oldEngine = new Object(); + Object newEngine = new Object(); + VideoSurfaceBinding.Result result = VideoSurfaceBinding.resolve(oldEngine, newEngine); + assertTrue("a rebuilt engine must be handed to the view", result.applied()); + assertTrue("the released engine must be detached first", result.detachedFirst()); + assertSame(newEngine, result.player()); + } + + @Test + public void emptyViewBindsTheFirstEngineWithoutDetaching() { + Object engine = new Object(); + VideoSurfaceBinding.Result result = VideoSurfaceBinding.resolve(null, engine); + assertTrue(result.applied()); + assertFalse("nothing to detach when the view is empty", result.detachedFirst()); + assertSame(engine, result.player()); + } + + @Test + public void emptiedSessionUnbindsTheStaleEngine() { + Object engine = new Object(); + VideoSurfaceBinding.Result result = VideoSurfaceBinding.resolve(engine, null); + assertTrue("a view must never keep an engine the session no longer has", result.applied()); + assertTrue(result.detachedFirst()); + assertNull(result.player()); + } + + @Test + public void emptyViewAndEmptySessionIsANoOp() { + VideoSurfaceBinding.Result result = VideoSurfaceBinding.resolve(null, null); + assertFalse(result.applied()); + assertNull(result.player()); + } + + @Test + public void boundEngineIsReportedAsItself() { + Object engine = new Object(); + assertEquals(engine, VideoSurfaceBinding.resolve(engine, engine).player()); + } +} \ No newline at end of file diff --git a/app/src/test/java/com/fongmi/android/tv/ui/adapter/ListStateConvergenceContractTest.java b/app/src/test/java/com/fongmi/android/tv/ui/adapter/ListStateConvergenceContractTest.java new file mode 100644 index 0000000000..788be337e1 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/adapter/ListStateConvergenceContractTest.java @@ -0,0 +1,117 @@ +package com.fongmi.android.tv.ui.adapter; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * 「列表数量变化 → 状态机」的收敛契约。 + * + *

为什么需要它。本仓库反复出现过同一类缺陷(共 5 处,跨电视端与手机端): + * 删除/清空之后宿主忘了驱动状态机,于是列表空了但页面**留白**——连空态都不出现。 + * 逐页发现是不可靠的,所以把这件事从"每个调用点都要记得写"改成"适配器统一通知": + * {@code BaseDiffAdapter} 在 diff 应用完成后回调 {@link BaseDiffAdapter.OnCountChanged}, + * 宿主只注册一次。 + * + *

本契约钉住三件事: + *

    + *
  1. 钩子必须从**两个** {@code setItems} 重载都会触发(少一个就仍有遗漏路径);
  2. + *
  3. 触发时机必须在 diff **应用之后**({@code AsyncListDiffer} 是异步的, + * 提前读 {@code getItemCount()} 会拿到旧值);
  4. + *
  5. 宿主必须注册钩子,且**不得再手写** {@code showContent(true, ...)}—— + * 手写正是此前漏掉"删除全部"那条路径的原因。
  6. + *
+ */ +public class ListStateConvergenceContractTest { + + private static final String BASE = "app/src/main/java/com/fongmi/android/tv/ui/adapter/BaseDiffAdapter.java"; + + /** 已接入该收敛的宿主:源文件(相对 app/src)。 */ + private static final String[] HOSTS = { + "app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java", + "app/src/leanback/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java", + "app/src/mobile/java/com/fongmi/android/tv/ui/activity/KeepActivity.java", + "app/src/mobile/java/com/fongmi/android/tv/ui/activity/HistoryActivity.java", + }; + + @Test + public void baseAdapterExposesACountChangedHook() throws Exception { + String source = read(BASE); + assertTrue("必须声明 OnCountChanged", source.contains("interface OnCountChanged")); + assertTrue("必须提供注册入口", source.contains("public void setOnCountChanged(OnCountChanged listener)")); + assertTrue("必须有一个统一的收尾方法", source.contains("private void afterUpdate(Runnable runnable)")); + } + + /** + * 两个 setItems 重载都必须经过 afterUpdate:漏掉任何一个就仍有"改了列表但状态没跟上"的路径。 + */ + @Test + public void bothSetItemsOverloadsFireTheHook() throws Exception { + String source = read(BASE); + int calls = count(source, "afterUpdate("); + // 一处定义 + 两处调用(Runnable 重载、Callback 重载的两个分支) + assertTrue("两个 setItems 重载都必须触发钩子,实测 afterUpdate 出现 " + calls + " 次", calls >= 4); + assertTrue("Runnable 重载必须在 diff 回调里触发", + source.contains("differ.submitList(Objects.requireNonNullElseGet(items, ArrayList::new), () -> afterUpdate(runnable))")); + assertTrue("Callback 重载的变化分支必须在 diff 回调里触发", + source.contains("differ.submitList(newItems, () -> {")); + assertTrue("Callback 重载的无变化分支也要触发", source.contains("afterUpdate(null);")); + // 钩子必须在回调里读数量,不能提前读。 + assertFalse("不得在 submitList 之前读取数量", source.contains("int count = getItemCount();")); + } + + @Test + public void hostsRegisterTheHookOnceAndNoLongerHandRollStateDriving() throws Exception { + for (String host : HOSTS) { + String source = withoutJavaComments(read(host)); + assertTrue(host + " 必须注册 OnCountChanged 钩子", + source.contains("mAdapter.setOnCountChanged(count -> mBinding.progressLayout.showContent(true, count))")); + // 唯一允许出现 showContent(true, ...) 的地方就是那行注册。 + assertEquals(host + " 不得再手写状态驱动(只允许注册钩子那一处)", + 1, count(source, "showContent(true,")); + } + } + + /** + * "删除全部"这条路径尤其容易漏:它此前是直接 {@code mAdapter.clear()}。 + * 现在它靠钩子自动驱动,宿主侧不应再有额外调用。 + */ + @Test + public void deleteAllReliesOnTheHookNotOnHandWrittenCalls() throws Exception { + for (String host : HOSTS) { + String source = withoutJavaComments(read(host)); + int at = source.indexOf("mAdapter.clear()"); + if (at < 0) continue; + // clear() 之后紧跟的应当是 lambda 收尾,而不是一串状态驱动语句。 + String after = source.substring(at, Math.min(source.length(), at + 120)); + assertFalse(host + " 的 mAdapter.clear() 之后不得再手写状态驱动", after.contains("showContent(true,")); + } + } + + // ------------------------------------------------------------------ helpers + + private static int count(String source, String needle) { + Matcher matcher = Pattern.compile(Pattern.quote(needle)).matcher(source); + int total = 0; + while (matcher.find()) total++; + return total; + } + + private static String withoutJavaComments(String source) { + return source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//.*$", " "); + } + + 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/test/java/com/fongmi/android/tv/ui/detail/EnhancedDetailControllerTest.java b/app/src/test/java/com/fongmi/android/tv/ui/detail/EnhancedDetailControllerTest.java new file mode 100644 index 0000000000..308665fc31 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/detail/EnhancedDetailControllerTest.java @@ -0,0 +1,16 @@ +package com.fongmi.android.tv.ui.detail; + +import static org.junit.Assert.assertFalse; + +import org.junit.Test; + +public class EnhancedDetailControllerTest { + + @Test + public void enhancedMode_hasNoInlinePlayerOrAutoPlay() { + TmdbDetailModeController controller = new EnhancedDetailController(null); + + assertFalse(controller.shouldShowInlinePlayer()); + assertFalse(controller.shouldAutoPlay()); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeControllerTest.java b/app/src/test/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeControllerTest.java new file mode 100644 index 0000000000..4b53acdb60 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/detail/TmdbDetailModeControllerTest.java @@ -0,0 +1,15 @@ +package com.fongmi.android.tv.ui.detail; + +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class TmdbDetailModeControllerTest { + + @Test + public void concreteControllers_implementModeControllerContract() { + assertTrue(TmdbDetailModeController.class.isAssignableFrom(FusionDetailController.class)); + assertTrue(TmdbDetailModeController.class.isAssignableFrom(EnhancedDetailController.class)); + assertTrue(TmdbDetailModeController.class.isAssignableFrom(PlayerDetailController.class)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/ui/dialog/DialogRoundedCornerSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/dialog/DialogRoundedCornerSourceTest.java index 55c411a171..ab09f789ab 100644 --- a/app/src/test/java/com/fongmi/android/tv/ui/dialog/DialogRoundedCornerSourceTest.java +++ b/app/src/test/java/com/fongmi/android/tv/ui/dialog/DialogRoundedCornerSourceTest.java @@ -39,19 +39,26 @@ public class DialogRoundedCornerSourceTest { @Test public void materialDialogShapeUsesTheUnifiedTwentyTwoDpRadius() throws Exception { - String styles = Files.readString(Path.of("src/main/res/values/styles.xml"), StandardCharsets.UTF_8); + String styles = Files.readString(Path.of("src/main/res/values/styles.xml"), StandardCharsets.UTF_8) + .replace("\r\n", "\n"); assertTrue("Material dialog shape should use the unified 22dp corner radius", styles.contains("")); 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)")); + assertTrue("BaseAlertDialog must apply a theme overlay, not leak widget tint into the floating toolbar", + baseDialog.contains("new WebHtvAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_WebHTV_Dialog)")); + + String themes = Files.readString(Path.of("src/main/res/values/webhtv_styles.xml"), StandardCharsets.UTF_8); + int overlayStart = themes.indexOf("") + .matches("(?s).*#[0-9A-Fa-f]{6,8}.*")); + assertTrue("真属性的 hex 必须保留(剔除不得过度)", + withoutComments("#FF123456") + .matches("(?s).*#[0-9A-Fa-f]{6,8}.*")); + assertTrue("剔除不得吃掉声明本身", + withoutComments("", + java.util.regex.Pattern.DOTALL) + .matcher(styles); + assertTrue("Widget.WebHTV.Input must exist", matcher.find()); + assertFalse("a TextInputLayout container style cannot be applied to a bare edit field", + matcher.group(1).contains("TextInputLayout")); + assertTrue("a standalone field must draw its own box", + matcher.group(2).contains("@drawable/selector_dialog_input")); + assertTrue("the standalone field must set its own text colours", + matcher.group(2).contains("android:textColor")); + } + + private static String value(String source, String name) { + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("(#[0-9A-Fa-f]{6,8})") + .matcher(source); + assertTrue("missing token " + name, matcher.find()); + return matcher.group(1); + } +} 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..8b8ca23fce 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 @@ -232,6 +232,48 @@ public class CatWebWiringTest { !source.contains("getOnBackPressedDispatcher().onBackPressed()")); } + @Test + public void tmdbExternalLinksPreferInAppBrowserOnTv() throws IOException { + String source = read("com/fongmi/android/tv/ui/activity/TmdbDetailActivity.java"); + int method = source.indexOf("private void openExternalLink(String url)"); + int nextMethod = source.indexOf("private void addRatingChip(String key", method); + String body = method >= 0 && nextMethod > method ? source.substring(method, nextMethod) : ""; + + assertTrue("TMDB 外链必须区分 TV 场景", method >= 0 && body.contains("Util.isLeanback()")); + assertTrue("TV 端必须先弹打开方式选择", body.contains("ChoiceDialog.showSingle(") + && body.contains("detail_external_open_builtin") + && body.contains("detail_external_open_external")); + assertTrue("TV 选择内置后才走 CatWebActivity", body.contains("openExternalLinkBuiltIn(url)") + && body.contains("WebViewUtil.support()") + && body.contains("CatWebActivity.browserIntent(")); + assertTrue("内置页启动失败后仍要保留系统浏览器兜底", + body.indexOf("Intent.ACTION_VIEW") > body.indexOf("openExternalLinkBuiltIn(url)")); + assertTrue("外部链接失败提示必须使用本地化资源", + body.contains("R.string.detail_external_open_failed")); + } + + @Test + public void catWebActivityUsesDesktopViewportForTvBrowser() throws IOException { + String source = read("com/fongmi/android/tv/ui/web/CatWebActivity.java"); + int configure = source.indexOf("private void configure()"); + int client = source.indexOf("private WebViewClient client()", configure); + String body = configure >= 0 && client > configure ? source.substring(configure, client) : ""; + + assertTrue("内置浏览器必须使用桌面 UA,避免 TMDB 等响应式站点按手机窄视口降级", + body.contains("DESKTOP_UA") && body.contains("s.setUserAgentString(DESKTOP_UA)")); + assertTrue("内置浏览器必须固定宽视口和 100% 文本缩放", + body.contains("s.setUseWideViewPort(true)") + && body.contains("s.setLoadWithOverviewMode(true)") + && body.contains("s.setTextZoom(100)")); + assertTrue("内置浏览器必须把初始缩放固定在 100%,避免 WebView 自动放大破坏布局", + body.contains("webView.setInitialScale(100)")); + assertTrue("内置浏览器必须按 WebView 能力关闭系统算法深色化,避免浅色网页的加载框变成黑底灰字", + body.contains("WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)") + && body.contains("WebSettingsCompat.setAlgorithmicDarkeningAllowed(s, false)") + && body.contains("WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)") + && body.contains("WebSettingsCompat.setForceDark(s, WebSettingsCompat.FORCE_DARK_OFF)")); + } + @Test public void bothFlavorsYieldDetailToWebview() throws IOException { for (String flavor : new String[]{"leanback", "mobile"}) { diff --git a/app/src/test/java/com/fongmi/android/tv/ui/web/ReaderTtsBridgeSourceTest.java b/app/src/test/java/com/fongmi/android/tv/ui/web/ReaderTtsBridgeSourceTest.java new file mode 100644 index 0000000000..e77e702669 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/ui/web/ReaderTtsBridgeSourceTest.java @@ -0,0 +1,149 @@ +package com.fongmi.android.tv.ui.web; + +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; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 小说朗读的 JS 桥契约测试。 + * + * 朗读功能横跨三处:reader.html(面板与段落收集)、WebReaderActivity(JS 桥)、 + * ReaderTtsService(后台服务与通知)。三者靠字符串名字相连,任何一处改名都不会 + * 被编译期发现,只会在真机上表现为「点了没反应」,因此这里用源码契约把它们钉住。 + */ +public class ReaderTtsBridgeSourceTest { + + private static final Pattern BRIDGE_CALL = Pattern.compile("AndroidReader\\.([A-Za-z0-9_]+)"); + private static final Pattern JAVA_BRIDGE = Pattern.compile("@JavascriptInterface\\s+public\\s+[\\w<>\\[\\].]+\\s+(\\w+)\\s*\\("); + private static final Pattern JS_CALLBACK_DEF = Pattern.compile("window\\.(__onTts\\w+)\\s*="); + private static final Pattern JAVA_CALLBACK_CALL = Pattern.compile("window\\.(__onTts\\w+)\\s*&&\\s*window\\.\\1"); + + @Test + public void everyReaderBridgeCallHasANativeImplementation() throws Exception { + String html = read("app/src/main/assets/reader.html"); + String activity = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); + + Set called = new LinkedHashSet<>(); + Matcher calls = BRIDGE_CALL.matcher(html); + while (calls.find()) called.add(calls.group(1)); + + Set implemented = new LinkedHashSet<>(); + Matcher methods = JAVA_BRIDGE.matcher(activity); + while (methods.find()) implemented.add(methods.group(1)); + + assertTrue("reader.html 必须调用朗读桥", called.contains("ttsStart")); + for (String name : called) { + assertTrue("JS 调用了 AndroidReader." + name + ",但原生没有 @JavascriptInterface 实现", + implemented.contains(name)); + } + } + + @Test + public void everyNativeCallbackIsHandledByTheReader() throws Exception { + String html = read("app/src/main/assets/reader.html"); + String activity = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); + + Set defined = new LinkedHashSet<>(); + Matcher defs = JS_CALLBACK_DEF.matcher(html); + while (defs.find()) defined.add(defs.group(1)); + + Set dispatched = new LinkedHashSet<>(); + Matcher calls = JAVA_CALLBACK_CALL.matcher(activity); + while (calls.find()) dispatched.add(calls.group(1)); + + assertTrue("原生必须回调朗读状态", dispatched.contains("__onTtsState")); + assertTrue("原生必须回调朗读进度", dispatched.contains("__onTtsProgress")); + assertTrue("原生必须回调章末事件", dispatched.contains("__onTtsChapterEnd")); + for (String name : dispatched) { + assertTrue("原生回调 window." + name + ",但 reader.html 没有定义", defined.contains(name)); + } + } + + @Test + public void readerHtmlNoLongerTreatsWebSpeechAsThePrimaryPath() throws Exception { + String html = read("app/src/main/assets/reader.html"); + + int nativeGate = html.indexOf("function ttsNative()"); + int webSpeak = html.indexOf("function ttsWebSpeak("); + int firstSpeakCall = html.indexOf("speechSynthesis.speak("); + + assertTrue("reader.html 必须有原生朗读开关", nativeGate >= 0); + assertTrue("必须有浏览器兜底实现", webSpeak >= 0); + assertTrue("speechSynthesis 只能出现在兜底实现里", + firstSpeakCall > webSpeak); + } + + @Test + public void sourceIdsMatchTheNativeRegistry() throws Exception { + String html = read("app/src/main/assets/reader.html"); + String engines = read("app/src/main/java/com/fongmi/android/tv/tts/TtsEngines.java"); + + for (String id : new String[]{"system", "edge", "baidu", "custom"}) { + assertTrue("reader.html 缺少音源入口: " + id, html.contains("data-tts-source=\"" + id + "\"")); + assertTrue("原生缺少音源常量: " + id, engines.contains("SOURCE_" + id.toUpperCase() + " = \"" + id + "\"")); + } + } + + @Test + public void readAloudServiceIsDeclaredForBackgroundPlayback() throws Exception { + String manifest = read("app/src/main/AndroidManifest.xml"); + String service = read("app/src/main/java/com/fongmi/android/tv/service/ReaderTtsService.java"); + + int declared = manifest.indexOf("android:name=\".service.ReaderTtsService\""); + assertTrue("必须在清单里声明朗读前台服务", declared >= 0); + int block = manifest.indexOf("/>", declared); + String declaration = manifest.substring(declared, block); + assertTrue("朗读服务必须是 mediaPlayback 前台服务: " + declaration, + declaration.contains("foregroundServiceType=\"mediaPlayback\"")); + + assertTrue("朗读服务必须上报前台通知", service.contains("startForeground(")); + assertTrue("朗读服务必须处理音频焦点", service.contains("requestAudioFocus")); + assertTrue("朗读服务必须注册拔耳机广播并在路由丢失时暂停", + service.contains("ACTION_AUDIO_BECOMING_NOISY") && service.contains("onAudioBecomingNoisy()")); + } + + @Test + public void controllerOwnsParagraphCursorAndChapterEnd() throws Exception { + String controller = read("app/src/main/java/com/fongmi/android/tv/tts/ReaderTtsController.java"); + + assertTrue("控制器必须持有朗读片段队列", controller.contains("TtsTextSplitter.split(")); + assertTrue("控制器必须支持上一段", controller.contains("public void previous()")); + assertTrue("控制器必须支持下一段", controller.contains("public void next()")); + assertTrue("控制器必须支持定时停止", controller.contains("timerTick")); + assertTrue("章末必须通知上层续读", controller.contains("listener.onTtsChapterEnd()")); + } + + @Test + public void readAloudControlBridgesDispatchToTheMainThread() throws Exception { + String activity = read("app/src/main/java/com/fongmi/android/tv/ui/web/WebReaderActivity.java"); + String[] names = {"ttsStart", "ttsPause", "ttsResume", "ttsToggle", "ttsPrev", "ttsNext", + "ttsSeek", "ttsSetRate", "ttsSetPitch", "ttsSetVoice", "ttsSetTimer", "ttsStop"}; + for (String name : names) { + int start = activity.indexOf("public void " + name + "("); + assertTrue("朗读桥缺少方法: " + name, start >= 0); + int end = activity.indexOf("@JavascriptInterface", start); + String body = activity.substring(start, end > start ? end : activity.length()); + // @JavascriptInterface 方法跑在 WebView 的 JavaBridge 线程,而控制器/前台服务 + // 要求主线程语义(引擎回调、前台通知、MediaPlayer 都在主线程),必须显式切回主线程。 + assertTrue("朗读桥 " + name + " 没有切回主线程(控制器状态会与主线程引擎回调并发)", + body.contains("postToService(") || body.contains("runOnUiThread(")); + } + } + + private static String read(String path) throws Exception { + // 单测工作目录随 Gradle 版本落在仓库根或 app/ 模块目录,两种都兼容 + Path direct = Path.of(path); + if (Files.exists(direct)) return new String(Files.readAllBytes(direct), StandardCharsets.UTF_8); + Path moduleRelative = Path.of(path.startsWith("app/") ? path.substring("app/".length()) : path); + assertTrue("缺少文件: " + path, Files.exists(moduleRelative)); + return new String(Files.readAllBytes(moduleRelative), StandardCharsets.UTF_8); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/AppVersionBuildStampTest.java b/app/src/test/java/com/fongmi/android/tv/utils/AppVersionBuildStampTest.java new file mode 100644 index 0000000000..3924ebb7dc --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/utils/AppVersionBuildStampTest.java @@ -0,0 +1,81 @@ +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; + +/** + * Regression coverage for the box update loop: the stable and beta channel manifests of one release + * line share a {@code versionCode}, so their build stamps are the only way to tell which one is the + * newer build. Treating "different tag" as "newer" made the app offer the older stable package to a + * beta install (and the other way round), which overwrote data forever. + */ +public class AppVersionBuildStampTest { + + private static final String STABLE = "v5.6.0-202609221328"; + private static final String BETA = "v5.6.0-beta-202610091755"; + + @Test + public void buildStampReadsTheTrailingReleaseMoment() { + assertEquals("202609221328", AppVersion.buildStamp(STABLE)); + assertEquals("202609221328", AppVersion.buildStamp(AppVersion.stripPrefix(STABLE))); + assertEquals("202610091755", AppVersion.buildStamp(BETA)); + assertEquals("202610111200", AppVersion.buildStamp("5.6.0-fast-202610111200")); + assertEquals("", AppVersion.buildStamp("5.6.0")); + assertEquals("", AppVersion.buildStamp(null)); + } + + @Test + public void newerBetaIsAnUpdateForAStableInstall() { + assertTrue(AppVersion.isNewer(STABLE, BETA)); + } + + @Test + public void olderStableIsNeverAnUpdateForABetaInstall() { + assertFalse("An older stable package must not be offered over a newer beta install", + AppVersion.isNewer(BETA, STABLE)); + } + + @Test + public void identicalBuildIsNotAnUpdate() { + assertFalse(AppVersion.isNewer(BETA, BETA)); + assertFalse(AppVersion.isNewer(STABLE, AppVersion.stripPrefix(STABLE))); + assertFalse(AppVersion.isNewer(BETA, "")); + assertFalse(AppVersion.isNewer(BETA, null)); + } + + @Test + public void newerStampWinsAcrossChannels() { + assertTrue(AppVersion.isNewer(STABLE, "v5.6.0-beta-202609221329")); + assertTrue(AppVersion.isNewer(BETA, "v5.6.1-202610101755")); + assertFalse(AppVersion.isNewer(BETA, "v5.6.1-202610081755")); + } + + @Test + public void stampsOfDifferentLengthsAreStillOrdered() { + String minuteInstall = "v5.6.0-beta-202610091755"; + String secondInstall = "v5.6.0-beta-20261009175522"; + + assertFalse("An older minute stamp must not look newer than a second-resolution install", + AppVersion.isNewer(secondInstall, "v5.6.0-202609221328")); + assertTrue(AppVersion.isNewer(secondInstall, "v5.6.0-20261009175530")); + assertTrue(AppVersion.isNewer(minuteInstall, "v5.6.0-20261009175530")); + assertFalse("The same minute without seconds is not a later build", + AppVersion.isNewer(secondInstall, minuteInstall)); + } + + @Test + public void tagsWithoutAComparableStampKeepTheHistoricalBehavior() { + assertTrue(AppVersion.isNewer("5.6.0", "5.6.1")); + assertFalse(AppVersion.isNewer("5.6.0", "5.6.0")); + } + + @Test + public void runningBuildNameIsReadFromTheBuildTag() { + assertFalse(AppVersion.fullName().startsWith("v")); + assertTrue(AppVersion.isCurrent(AppVersion.fullName())); + assertTrue(AppVersion.isCurrent("v" + AppVersion.fullName())); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/CrashLogTextTest.java b/app/src/test/java/com/fongmi/android/tv/utils/CrashLogTextTest.java new file mode 100644 index 0000000000..7687f12645 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/utils/CrashLogTextTest.java @@ -0,0 +1,154 @@ +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 org.junit.Test; + +import java.util.List; + +public class CrashLogTextTest { + + private static CrashLogText.Facts facts(String stack) { + return new CrashLogText.Facts( + "5.6.0-test", + "2026-10-21T20:00:00.000+0800", + 12345L, + "com.silent.android.webhtv:error_activity", + 4242, + 10123, + "main", + false, + "Xiaomi TV", + "Android 14 (sdk 34)", + "java.lang.IllegalStateException", + "boom", + stack); + } + + @Test + public void reportCarriesIdentityContextAndStack() { + String report = CrashLogText.format(facts("java.lang.IllegalStateException: boom\n\tat a.b.C.d(C.java:12)\n\tat e.f.G.h(G.java:3)")); + + assertTrue(report.startsWith(CrashLogText.REPORT_HEADER)); + assertTrue(report.contains("appVersion=5.6.0-test")); + assertTrue(report.contains("elapsedMs=12345")); + assertTrue(report.contains("process=com.silent.android.webhtv:error_activity")); + assertTrue(report.contains("pid=4242")); + assertTrue(report.contains("thread=main")); + assertTrue(report.contains("debugLogEnabled=false")); + assertTrue(report.contains("javaClass=java.lang.IllegalStateException")); + assertTrue(report.contains("--- stack ---")); + assertTrue(report.contains("at a.b.C.d(C.java:12)")); + assertTrue(report.contains("at e.f.G.h(G.java:3)")); + } + + @Test + public void reportRedactsUrlsPathsAndSecrets() { + String report = CrashLogText.format(facts( + "at leak(Secret.java:1)\n or http://secret.example/path?token=abc123\n at /data/user/0/app/files/report.txt\n socket 192.168.1.5:9978")); + + assertFalse(report.contains("secret.example")); + assertFalse(report.contains("abc123")); + assertFalse(report.contains("/data/user/0/")); + assertFalse(report.contains("192.168.")); + } + + @Test + public void oversizedStackIsBoundedAndMarked() { + StringBuilder stack = new StringBuilder(); + while (stack.length() < 300_000) stack.append("at big.Stack.line(File.java:1)\n"); + + String report = CrashLogText.format(facts(stack.toString())); + + assertTrue(report.length() <= CrashLogText.MAX_REPORT_CHARS + 4096); + assertTrue(report.contains("[truncated")); + } + + @Test + public void reportElapsedAndPidAreParsedFromHeader() { + String report = CrashLogText.format(facts("at a.b(C.java:1)")); + + assertEquals(12345L, CrashLogText.reportElapsed(report)); + assertEquals(4242, CrashLogText.reportPid(report)); + assertEquals(-1L, CrashLogText.reportElapsed("no header")); + assertEquals(-1, CrashLogText.reportPid(null)); + } + + @Test + public void freshnessRequiresAPositiveBoundedWindow() { + assertTrue(CrashLogText.isFresh(1_000L, 2_000L, 5_000L)); + assertTrue(CrashLogText.isFresh(1_000L, 6_000L, 5_000L)); + assertFalse(CrashLogText.isFresh(1_000L, 6_001L, 5_000L)); + assertFalse(CrashLogText.isFresh(0L, 2_000L, 5_000L)); + assertFalse(CrashLogText.isFresh(1_000L, 500L, 5_000L)); + assertFalse(CrashLogText.isFresh(1_000L, 2_000L, 0L)); + } + + @Test + public void logcatFilterKeepsOnlyTheCrashedProcessTail() { + List lines = List.of( + "10-21 20:15:01.100 100 200 I Other: a", + "10-21 20:15:01.200 4242 200 I Crash: b", + "10-21 20:15:01.300 4242 200 E Crash: c"); + + List kept = CrashLogText.filterLogcat(lines, 4242, 2, 4096); + assertEquals(2, kept.size()); + assertTrue(kept.get(0).contains("Crash: b")); + assertTrue(kept.get(1).contains("Crash: c")); + + assertEquals(1, CrashLogText.filterLogcat(lines, 4242, 1, 4096).size()); + assertEquals(2, CrashLogText.filterLogcat(lines, -1, 2, 4096).size()); + assertTrue(CrashLogText.filterLogcat(lines, 4242, 2, 10).size() <= 1); + assertEquals(4242, CrashLogText.linePid(lines.get(1))); + assertEquals(-1, CrashLogText.linePid("--------- beginning of main")); + } + + @Test + public void sessionStatesMissingEvidenceInsteadOfClaimingCoverage() { + String body = CrashLogText.composeSession(null, List.of(), false, false, false); + + assertTrue(body.startsWith(CrashLogText.SESSION_HEADER)); + assertTrue(body.contains("report=missing-or-stale")); + assertTrue(body.contains("logcat=unavailable")); + assertTrue(body.contains("debugLog=off")); + assertTrue(body.contains("调试日志在崩溃前未开启")); + assertTrue(body.contains("未找到本次崩溃的堆栈记录")); + assertTrue(body.contains("# 系统日志不可用。")); + } + + @Test + public void sessionIncludesFreshReportAndLogcat() { + String report = CrashLogText.format(facts("at a.b(C.java:1)")); + String body = CrashLogText.composeSession(report, List.of("10-21 20:15:01.200 4242 200 E Crash: x"), true, true, true); + + assertTrue(body.contains("report=fresh")); + assertTrue(body.contains("logcat=own-uid-tail")); + assertTrue(body.contains("debugLog=on")); + assertFalse(body.contains("调试日志在崩溃前未开启")); + assertTrue(body.contains("Crash: x")); + assertTrue(body.contains(CrashLogText.REPORT_HEADER)); + } + + @Test + public void oversizedSessionIsTruncated() { + String[] lines = new String[2000]; + for (int i = 0; i < lines.length; i++) lines[i] = "10-21 20:15:01.200 4242 200 I Crash: " + "x".repeat(200); + + String body = CrashLogText.composeSession(null, List.of(lines), true, false, true); + + assertTrue(body.length() <= CrashLogText.MAX_SESSION_CHARS + 4096); + assertTrue(body.contains("# [truncated crash session]")); + } + + @Test + public void displayUrlKeepsTheRealAddressCopyable() { + String url = "http://192.168.1.5:9978/debug/logs"; + String display = CrashLogText.wrapUrlForDisplay(url); + + assertTrue(display.contains("\u200B")); + assertEquals(url, display.replace("\u200B", "")); + assertEquals("", CrashLogText.wrapUrlForDisplay(null)); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/CrashLogWiringTest.java b/app/src/test/java/com/fongmi/android/tv/utils/CrashLogWiringTest.java new file mode 100644 index 0000000000..8e5786ebad --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/utils/CrashLogWiringTest.java @@ -0,0 +1,106 @@ +package com.fongmi.android.tv.utils; + +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; + +/** Source contracts for the crash-time LAN log session; the flow spans processes and XML. */ +public class CrashLogWiringTest { + + @Test + public void crashWriterIsInstalledRightAfterTheCrashHandlerConfiguration() throws Exception { + String startup = read("app/src/main/java/com/fongmi/android/tv/Startup.java"); + int config = startup.indexOf("CaocConfig.Builder.create()"); + int install = startup.indexOf("CrashReport.install(context)"); + + assertTrue("Startup must install the crash report writer", install >= 0); + assertTrue("the writer must wrap the handler installed by CustomActivityOnCrash", config >= 0 && install > config); + } + + @Test + public void crashScreenStartsTheSessionBeforeServingTheAddress() throws Exception { + String activity = read("app/src/main/java/com/fongmi/android/tv/ui/activity/CrashActivity.java"); + int prepare = activity.indexOf("CrashLogSession.prepare(this)"); + int start = activity.indexOf("Server.get().start()", prepare); + + assertTrue(activity.contains("mBinding.copyLogAddress.setOnClickListener")); + assertTrue(activity.contains("mBinding.openLog.setOnClickListener")); + assertTrue(activity.contains("Server.get().getAddress(false)")); + assertTrue("the crash screen must keep the log service available", activity.contains("Server.get().shutdown()")); + assertTrue("back must not leave the LAN service running unseen", activity.contains("protected void onBackInvoked()")); + assertTrue("the crash session must be prepared before the address is offered", prepare >= 0 && start > prepare); + } + + @Test + public void bothCrashLayoutsExposeTheSameLogAddressViews() throws Exception { + for (String file : new String[]{ + "app/src/leanback/res/layout/activity_crash.xml", + "app/src/mobile/res/layout/activity_crash.xml"}) { + String layout = read(file); + assertTrue(file + " must scroll, so a small or landscape screen still reaches every entry", + layout.contains("= 0 && append > begin); + assertFalse("a crash session must not change the user's debug-log preference", + store.substring(begin, append).contains("Prefers.put")); + } + + @Test + public void debugLogPageLabelsTheCrashSessionAndHidesItsControls() throws Exception { + String page = read("app/src/main/java/com/fongmi/android/tv/server/process/DebugLogs.java"); + + assertTrue(page.contains("DebugLogStore.isCrashSessionTemporary()")); + assertTrue(page.contains("崩溃会话")); + assertTrue(page.contains("crash-banner")); + assertTrue("the poll refresh must not overwrite the crash-session label with 记录中", + page.contains("crashSession") && page.contains("'崩溃会话 · 自动开启'")); + assertTrue(page.contains("崩溃会话临时日志:本页自动开启,重启后恢复原设置。")); + } + + @Test + public void crashSessionCapturesTheSystemLogcatTail() throws Exception { + String session = read("app/src/main/java/com/fongmi/android/tv/utils/CrashLogSession.java"); + + assertTrue(session.contains("CrashReport.readRecent(context)")); + assertTrue(session.contains("\"logcat\"")); + assertTrue("coverage must not claim a logcat tail when the tail is empty", + session.contains("!logcat.isEmpty()")); + assertTrue(session.contains("DebugLogStore.appendCrashContext")); + assertTrue(session.contains("CrashLogText.filterLogcat")); + } + + @Test + public void newCrashStringsExistInEveryLocale() throws Exception { + String[] keys = {"crash_log_title", "crash_log_hint", "crash_log_preparing", "crash_log_unavailable", + "crash_log_no_lan", "crash_log_service_failed", "crash_log_copy", "crash_log_copied", "crash_log_open"}; + for (String file : new String[]{ + "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"}) { + String strings = read(file); + for (String key : keys) assertTrue(file + " must define " + key, strings.contains("name=\"" + key + "\"")); + } + } + + 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/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 index 882d499fbd..b93c7606ee 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockNoticeTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/HlsAdblockNoticeTest.java @@ -2,6 +2,7 @@ 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; @@ -23,4 +24,59 @@ public class HlsAdblockNoticeTest { assertTrue(HlsAdblockNotice.shouldNotify("https://a.example.com/live.m3u8", now)); assertTrue(HlsAdblockNotice.shouldNotify("https://b.example.com/live.m3u8", now)); } + + @Test + public void noticeReportsSegmentCountAndTotalAdDuration() { + String message = HlsAdblockNotice.message(3, 27.5); + + assertEquals("已跳过 3 个广告片段,总广告时长 27.5 秒", message); + } + + @Test + public void noticeKeepsSegmentCountWhenDurationIsUnknown() { + assertEquals("已跳过 2 个广告片段", HlsAdblockNotice.message(2, 0)); + assertEquals("已跳过 2 个广告片段", HlsAdblockNotice.message(2, Double.NaN)); + assertEquals("已跳过 2 个广告片段", HlsAdblockNotice.message(2, -1)); + } + + @Test + public void noticeSwitchesToMinutesForLongAdBreaks() { + assertEquals("已跳过 30 个广告片段,总广告时长 1 分钟", HlsAdblockNotice.message(30, 60)); + assertEquals("已跳过 45 个广告片段,总广告时长 2 分 5 秒", HlsAdblockNotice.message(45, 125)); + } + + @Test + public void noticeRoundsToTenthsBeforeChoosingTheUnit() { + // 0.1 秒粒度先进位再选单位:59.96 秒必须是「1 分钟」,不能出现自相矛盾的「60.0 秒」。 + assertEquals("59.9 秒", HlsAdblockNotice.durationText(59.94)); + assertEquals("1 分钟", HlsAdblockNotice.durationText(59.96)); + assertEquals("1 分 30 秒", HlsAdblockNotice.durationText(90)); + assertEquals("已跳过 12 个广告片段,总广告时长 1 分钟", HlsAdblockNotice.message(12, 59.96)); + } + + @Test + public void noticeHidesDurationsBelowTheDisplayGranularity() { + assertEquals("", HlsAdblockNotice.durationText(0.04)); + assertEquals("", HlsAdblockNotice.durationText(0)); + assertEquals("已跳过 2 个广告片段", HlsAdblockNotice.message(2, 0.04)); + assertEquals("0.2 秒", HlsAdblockNotice.durationText(0.16)); + assertEquals("已跳过 2 个广告片段,总广告时长 0.2 秒", HlsAdblockNotice.message(2, 0.16)); + } + + @Test + public void everyHlsAdblockChannelUsesTheSharedDurationNotice() throws Exception { + java.nio.file.Path root = java.nio.file.Paths.get("src", "main", "java"); + java.util.List channels = java.util.List.of( + root.resolve("com/fongmi/android/tv/server/process/M3u8.java"), + root.resolve("com/fongmi/android/tv/player/exo/ExoHlsAdblockDataSource.java"), + root.resolve("androidx/media3/mpvplayer/MpvHlsProxy.java")); + for (java.nio.file.Path channel : channels) { + String source = new String(java.nio.file.Files.readAllBytes(channel), + java.nio.charset.StandardCharsets.UTF_8); + assertTrue("channel must route through the shared notice: " + channel, + source.contains("HlsAdblockNotice.message(")); + assertFalse("channel must not keep the duration-less inline notice: " + channel, + source.contains("个广告片段(")); + } + } } 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..af5e4cf763 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 @@ -36,6 +36,9 @@ public class HlsAdblockPipelineTest { assertTrue(outcome.structured()); assertFalse(outcome.manifest().contains("ad.ts")); + // The structured rule path must also report the total ad time, not just the segment count. + assertEquals("已跳过 1 个广告片段,总广告时长 7.0 秒", + HlsAdblockNotice.message(outcome.removedSegments(), outcome.removedDurationSec())); } @Test @@ -62,6 +65,10 @@ public class HlsAdblockPipelineTest { 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); + // Legacy fallback carries a real duration, so the playback notice must show total ad time too. + assertEquals("已跳过 2 个广告片段,总广告时长 12.0 秒", + HlsAdblockNotice.message(removed.size(), + removed.stream().mapToDouble(HlsManifestCleaner.RemovedSegment::durationSec).sum())); } @Test diff --git a/app/src/test/java/com/fongmi/android/tv/utils/TmdbDetailCacheTest.java b/app/src/test/java/com/fongmi/android/tv/utils/TmdbDetailCacheTest.java index fb39b9fc81..44638218cf 100644 --- a/app/src/test/java/com/fongmi/android/tv/utils/TmdbDetailCacheTest.java +++ b/app/src/test/java/com/fongmi/android/tv/utils/TmdbDetailCacheTest.java @@ -1,19 +1,35 @@ package com.fongmi.android.tv.utils; +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 org.junit.Test; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.LinkedHashMap; 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.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +/** + * 锁定「详情页已解析的季集随详情快照交接给播放页」这条契约的两端。 + * + *

前半部分是缓存层({@link TmdbDetailCache})的行为用例;末尾三条用源码守卫锁定消费端 + * ({@code TmdbUIAdapter.seedSeasonEpisodes} 与 {@code loadSource} 的补绑定)的形状。 + * 两端合起来才是「详情页有卡片、播放页也有卡片」,所以放在同一个测试类里;mobile 源集 + * 已经存在同名的 {@code TmdbUIAdapterTest},共享源集不能再建同名类。 + */ public class TmdbDetailCacheTest { @Test @@ -33,10 +49,10 @@ public class TmdbDetailCacheTest { List cast = new ArrayList<>(); cast.add(new TmdbPerson(1, "张若昀", "范闲", "", "Acting", "")); - String key = TmdbDetailCache.put(item, detail, cast); + String key = TmdbDetailCache.put(item, detail, cast, "zh-Hans"); cast.clear(); - TmdbDetailCache.Entry entry = TmdbDetailCache.take(key, item); + TmdbDetailCache.Entry entry = TmdbDetailCache.take(key, item, "zh-CN"); assertTrue(!key.isEmpty()); assertSame(item, entry.getItem()); @@ -46,14 +62,193 @@ public class TmdbDetailCacheTest { assertNull(TmdbDetailCache.take(key, item)); } + @Test + public void takeRejectsSnapshotFromAnotherLanguage() { + TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); + JsonObject detail = new JsonObject(); + + String key = TmdbDetailCache.put(item, detail, List.of(), "en-US"); + + assertNull(TmdbDetailCache.take(key, item, "zh-CN")); + } + + @Test + public void takeAcceptsEquivalentLanguageIdentity() { + TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); + JsonObject detail = new JsonObject(); + + String key = TmdbDetailCache.put(item, detail, List.of(), "zh-Hans"); + + assertEquals("zh-CN", TmdbDetailCache.take(key, item, "zh-CN").getLanguage()); + } + + @Test + public void takeRejectsRegionalChineseVariantForSimplifiedTarget() { + TmdbItem item = new TmdbItem(123, "tv", "慶餘年", "", "", "", ""); + JsonObject detail = new JsonObject(); + + String key = TmdbDetailCache.put(item, detail, List.of(), "zh-TW"); + + assertNull(TmdbDetailCache.take(key, item, "zh-CN")); + } + @Test public void takeReturnsNullForMismatchedTmdbItem() { TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); TmdbItem other = new TmdbItem(456, "tv", "庆余年 第二季", "", "", "", ""); JsonObject detail = new JsonObject(); - String key = TmdbDetailCache.put(item, detail, List.of()); + String key = TmdbDetailCache.put(item, detail, List.of(), "zh-CN"); - assertNull(TmdbDetailCache.take(key, other)); + assertNull(TmdbDetailCache.take(key, other, "zh-CN")); + } + + @Test + public void takeCarriesResolvedSeasonEpisodesForPlaybackReuse() { + TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); + JsonObject detail = new JsonObject(); + Map> seasons = new LinkedHashMap<>(); + List seasonOne = new ArrayList<>(); + seasonOne.add(new TmdbEpisode(1, "初入京都", "2026-10-06", "", "", 0d, 44, 123, 1)); + seasons.put(1, seasonOne); + + String key = TmdbDetailCache.put(item, detail, List.of(), "zh-CN", seasons); + seasonOne.clear(); + + TmdbDetailCache.Entry entry = TmdbDetailCache.take(key, item, "zh-CN"); + + assertEquals(1, entry.getSeasonEpisodes().size()); + assertEquals(1, entry.getSeasonEpisodes().get(1).size()); + assertEquals("初入京都", entry.getSeasonEpisodes().get(1).get(0).getTitle()); + } + + @Test + public void seasonEpisodesSnapshotIsDefensiveAndDropsEmptySeasons() { + TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); + JsonObject detail = new JsonObject(); + Map> seasons = new LinkedHashMap<>(); + seasons.put(1, List.of(new TmdbEpisode(1, "初入京都", "", "", "", 0d, 0, 123, 1))); + seasons.put(2, List.of()); + + String key = TmdbDetailCache.put(item, detail, List.of(), "zh-CN", seasons); + TmdbDetailCache.Entry entry = TmdbDetailCache.take(key, item, "zh-CN"); + Map> handed = entry.getSeasonEpisodes(); + + handed.clear(); + + assertEquals(1, entry.getSeasonEpisodes().size()); + assertFalse(entry.getSeasonEpisodes().containsKey(2)); + } + + @Test + public void negativeSeasonKeysAreDropped() { + TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); + JsonObject detail = new JsonObject(); + Map> seasons = new LinkedHashMap<>(); + seasons.put(-1, List.of(new TmdbEpisode(1, "错季", "", "", "", 0d, 0, 123, -1))); + seasons.put(1, List.of(new TmdbEpisode(1, "初入京都", "", "", "", 0d, 0, 123, 1))); + + TmdbDetailCache.Entry entry = TmdbDetailCache.take( + TmdbDetailCache.put(item, detail, List.of(), "zh-CN", seasons), item, "zh-CN"); + + assertEquals(1, entry.getSeasonEpisodes().size()); + assertFalse(entry.getSeasonEpisodes().containsKey(-1)); + } + + @Test + public void handedEpisodeListsAreImmutable() { + TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); + JsonObject detail = new JsonObject(); + Map> seasons = new LinkedHashMap<>(); + List mutable = new ArrayList<>(); + mutable.add(new TmdbEpisode(1, "初入京都", "", "", "", 0d, 0, 123, 1)); + seasons.put(1, mutable); + + TmdbDetailCache.Entry entry = TmdbDetailCache.take( + TmdbDetailCache.put(item, detail, List.of(), "zh-CN", seasons), item, "zh-CN"); + // 详情页在交出快照后继续改自己的列表,不得影响播放页看到的内容 + mutable.add(new TmdbEpisode(2, "外部追加", "", "", "", 0d, 0, 123, 1)); + + assertEquals(1, entry.getSeasonEpisodes().get(1).size()); + assertThrows(UnsupportedOperationException.class, + () -> entry.getSeasonEpisodes().get(1).add(new TmdbEpisode(2, "内部追加", "", "", "", 0d, 0, 123, 1))); + } + + @Test + public void takeWithoutSeasonEpisodesReportsNoHandoff() { + TmdbItem item = new TmdbItem(123, "tv", "庆余年", "", "", "", ""); + JsonObject detail = new JsonObject(); + + String key = TmdbDetailCache.put(item, detail, List.of(), "zh-CN"); + + assertNull(TmdbDetailCache.take(key, item, "zh-CN").getSeasonEpisodes()); + } + + // ── 交接契约的消费端(TmdbUIAdapter 源码守卫) ── + + @Test + public void adapterSeedsHandedEpisodesWithTheSameKeySeasonEpisodesReads() throws Exception { + String source = readAdapterSource(); + String load = method(source, "public void load(TmdbItem item, Vod vod) {", "private void attachPrefetch"); + String loadCached = method(source, "public void load(TmdbItem item, Vod vod, TmdbDetailCache.Entry cached)", "private void seedSeasonEpisodes"); + String seed = method(source, "private void seedSeasonEpisodes", "public void loadSource"); + + // 两条详情快照入口都要预填,漏掉任意一条都会让对应路径退化成纯文本选集 + assertTrue(load.contains("seedSeasonEpisodes(cached);")); + assertTrue(loadCached.contains("seedSeasonEpisodes(cached);")); + // 键必须与 seasonEpisodes() 的读取键同源,否则填了也命中不了 + assertTrue(seed.contains("seasonEpisodeKey(tmdbItem, entry.getKey())")); + assertTrue(seed.contains("seasonEpisodeCache.put(")); + assertTrue(seed.contains("List.copyOf(entry.getValue())")); + assertTrue(seed.contains("cached.getSeasonEpisodes()")); + // 交接只复用详情页快照,不得在播放页再发一次同样的请求 + assertFalse(seed.contains("tmdbService.")); + } + + @Test + public void adapterRebindsEmbeddedEpisodesWhenLoadingFromSourceBundle() throws Exception { + String source = readAdapterSource(); + String loadSource = method(source, "public void loadSource(TmdbBundle bundle", "private void applySourceBundle"); + + // applySourceBundle 只把内嵌分季写进缓存,并不会给 Episode 挂 tmdbEpisode; + // 不补一次绑定,内嵌 payload 的站点在播放页永远是纯文本选集。 + assertTrue(loadSource.contains("if (tmdbItem != null && tmdbItem.isTv()) scheduleStartupEpisodeLoad(sourceVod, tmdbItem, generation);")); + assertFalse(loadSource.contains("tmdbService.")); + } + + @Test + public void adapterSingleSeasonFallbackIsLimitedToEvidenceFreeSources() throws Exception { + String source = readAdapterSource(); + String current = method(source, "private Integer currentEpisodeMetadataSeason()", "private Integer soleOrdinarySeason()"); + String sole = method(source, "private Integer soleOrdinarySeason()", "private boolean isMultiSliceResolution()"); + + // 解析器已经给出结论时直接采用 + assertTrue(current.contains("TmdbSeasonResolver.Status.RESOLVED")); + assertTrue(current.contains("return soleOrdinarySeason();")); + // 恰好一个正片季才兜底;多季剧返回 null,行为与旧实现一致 + assertTrue(sole.contains("if (sole != null) return null;")); + assertTrue(sole.contains("option.getSeasonNumber() <= 0")); + // MULTI_SLICE 的分段结论优先:兜底若返回非 null,调度侧会改走单季绑定, + // 而切片路径的季号一致性校验期望 null,最终整个分段一集都绑不上 + assertTrue(sole.contains("if (isMultiSliceResolution()) return null;")); + // 兜底只允许「解析器无结论且无相反证据」的来源: + // MANUAL_FLAT 是用户「保持原始剧集列表」的显式选择,覆盖后播放页会挂 TMDB 集标题并原地重排选集; + // TITLE/EXPLICIT 冲突表示源站剧名或集名明确指向 TMDB 不存在的季,覆盖会静默绑错季。 + assertTrue(sole.contains("if (source != TmdbSeasonResolver.Source.REQUEST && source != TmdbSeasonResolver.Source.NONE) return null;")); + } + + private static String readAdapterSource() 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/utils/TmdbImageSelectorTierTest.java b/app/src/test/java/com/fongmi/android/tv/utils/TmdbImageSelectorTierTest.java new file mode 100644 index 0000000000..c83cf65273 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/utils/TmdbImageSelectorTierTest.java @@ -0,0 +1,47 @@ +package com.fongmi.android.tv.utils; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class TmdbImageSelectorTierTest { + + private static final String POSTER_ORIGINAL = "https://image.tmdb.org/t/p/original/abc.jpg"; + + @Test + public void tierUrlReplacesTmdbSizeSegment() { + assertEquals("https://image.tmdb.org/t/p/w500/abc.jpg", + TmdbImageSelector.tierUrl(POSTER_ORIGINAL, "w500")); + assertEquals("https://image.tmdb.org/t/p/w1280/abc.jpg", + TmdbImageSelector.tierUrl("https://image.tmdb.org/t/p/w780/abc.jpg", "w1280")); + } + + @Test + public void tierUrlLeavesUnknownHostsAndEmptyValuesAlone() { + assertEquals("https://cdn.example.com/poster.jpg", + TmdbImageSelector.tierUrl("https://cdn.example.com/poster.jpg", "w500")); + assertEquals("", TmdbImageSelector.tierUrl("", "w500")); + assertEquals(POSTER_ORIGINAL, TmdbImageSelector.tierUrl(POSTER_ORIGINAL, "")); + } + + /** + * 代理线路(wsrv 等)把原始地址放在 query 里,仍包含 /t/p/ 段,必须同样能换档, + * 否则开启图片代理后所有 rail 会退回原图。 + */ + @Test + public void tierUrlAlsoWorksForProxyWrappedTmdbUrls() { + String wrapped = "https://wsrv.nl/?url=https://image.tmdb.org/t/p/original/abc.jpg"; + assertEquals("https://wsrv.nl/?url=https://image.tmdb.org/t/p/w780/abc.jpg", + TmdbImageSelector.tierUrl(wrapped, "w780")); + } + + @Test + public void railTiersUseOfficialSizesAndStayRestorableToOriginal() { + assertEquals("https://image.tmdb.org/t/p/w500/abc.jpg", TmdbImageSelector.posterTier(POSTER_ORIGINAL)); + assertEquals("https://image.tmdb.org/t/p/w780/abc.jpg", TmdbImageSelector.photoTier(POSTER_ORIGINAL)); + assertEquals("https://image.tmdb.org/t/p/w780/abc.jpg", TmdbImageSelector.heroPosterTier(POSTER_ORIGINAL)); + assertEquals(POSTER_ORIGINAL, TmdbImageSelector.originalUrl(TmdbImageSelector.posterTier(POSTER_ORIGINAL))); + assertEquals(POSTER_ORIGINAL, TmdbImageSelector.originalUrl(TmdbImageSelector.photoTier(POSTER_ORIGINAL))); + assertEquals(POSTER_ORIGINAL, TmdbImageSelector.originalUrl(TmdbImageSelector.heroPosterTier(POSTER_ORIGINAL))); + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/TmdbLanguagePolicyTest.java b/app/src/test/java/com/fongmi/android/tv/utils/TmdbLanguagePolicyTest.java new file mode 100644 index 0000000000..bb3ed47398 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/utils/TmdbLanguagePolicyTest.java @@ -0,0 +1,70 @@ +package com.fongmi.android.tv.utils; + +import com.fongmi.android.tv.bean.TmdbConfig; +import com.google.gson.JsonParser; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TmdbLanguagePolicyTest { + + @Test + public void normalizesChineseAliasesAndCase() { + assertEquals("zh-CN", TmdbLanguagePolicy.normalize("ZH-Hans")); + assertEquals("zh-CN", TmdbLanguagePolicy.normalize(" zh-sg ")); + assertEquals("zh-TW", TmdbLanguagePolicy.normalize("zh-Hant")); + assertEquals("en-US", TmdbLanguagePolicy.normalize("en_us")); + assertEquals("zh-CN", TmdbLanguagePolicy.requestLanguage(config("zh-CN"))); + } + + @Test + public void chineseTranslationWinsWhenPrimaryIsEnglish() { + String json = """ + [ + {"iso_639_1":"en","data":{"overview":"English overview"}}, + {"iso_639_1":"zh","iso_3166_1":"CN","data":{"overview":"中文简介"}} + ] + """; + TmdbLanguagePolicy.TranslatedValue value = TmdbLanguagePolicy.bestValue( + "English overview", JsonParser.parseString(json).getAsJsonArray(), "overview", "zh-CN"); + + assertEquals("中文简介", value.value()); + assertEquals("zh-CN", value.language()); + assertTrue(value.exact()); + } + + @Test + public void englishIsAnExplicitFallbackWhenChineseIsMissing() { + String json = """ + [{"iso_639_1":"en","data":{"overview":"English overview"}}] + """; + assertEquals("English overview", TmdbLanguagePolicy.bestDisplayValue( + "English overview", JsonParser.parseString(json).getAsJsonArray(), "overview", "zh-CN")); + } + + @Test + public void displayCompatibilityRequiresExactLanguageOrRootOnlyCandidate() { + assertTrue(TmdbLanguagePolicy.isDisplayLanguageCompatible("zh-CN", "zh")); + assertTrue(TmdbLanguagePolicy.isDisplayLanguageCompatible("zh-CN", "zh-Hans")); + assertTrue(TmdbLanguagePolicy.isDisplayLanguageCompatible("zh-TW", "zh")); + assertFalse(TmdbLanguagePolicy.isDisplayLanguageCompatible("zh-CN", "zh-TW")); + assertFalse(TmdbLanguagePolicy.isDisplayLanguageCompatible("zh-CN", "zh-Hant")); + assertFalse(TmdbLanguagePolicy.isDisplayLanguageCompatible("en-US", "en-GB")); + assertFalse(TmdbLanguagePolicy.isDisplayLanguageCompatible("zh-CN", "en-US")); + } + + private static TmdbConfig config(String language) { + try { + TmdbConfig config = new TmdbConfig(); + var field = TmdbConfig.class.getDeclaredField("language"); + field.setAccessible(true); + field.set(config, language); + return config; + } catch (Exception e) { + throw new AssertionError(e); + } + } +} diff --git a/app/src/test/java/com/fongmi/android/tv/utils/TmdbProxyTest.java b/app/src/test/java/com/fongmi/android/tv/utils/TmdbProxyTest.java new file mode 100644 index 0000000000..f05cd20ae9 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/utils/TmdbProxyTest.java @@ -0,0 +1,56 @@ +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 TmdbProxyTest { + + @Test + public void normalizesValidEndpointAndAddressPool() { + assertEquals("https://mirror.example.com/tmdb", TmdbProxy.normalizeConfig("mirror.example.com/tmdb/3")); + assertEquals("https://a.example.com", + TmdbProxy.normalizeConfig("https://a.example.com;https://a.example.com, b.example.com")); + } + + @Test + public void removesKnownInvalidRoutes() { + assertEquals("", TmdbProxy.normalizeConfig("worker-pool")); + assertEquals("", TmdbProxy.normalizeConfig("https://tmdb.nastool.org")); + assertEquals("", TmdbProxy.normalizeConfig("ftp://mirror.example.com")); + assertTrue(TmdbProxy.isRemovedRoute("worker-pool")); + assertTrue(TmdbProxy.isRemovedRoute("https://tmdb.nastool.org/")); + } + + @Test + public void exposesOnlyTestedBuiltInApiAndImageRoutes() { + assertEquals(3, TmdbProxy.apiOptions().size()); + assertEquals(4, TmdbProxy.imageOptions().size()); + assertEquals(TmdbProxy.AUTO, TmdbProxy.apiOptions().get(0).value); + assertEquals(TmdbProxy.AUTO, TmdbProxy.imageOptions().get(0).value); + assertEquals(TmdbProxy.ITV666, TmdbProxy.valueForInput("itv666 API 代理", TmdbProxy.apiOptions())); + assertEquals("itv666 图片代理", TmdbProxy.displayImage(TmdbProxy.ITV666)); + assertEquals("wsrv.nl 图片代理", TmdbProxy.displayImage(TmdbProxy.WSRV_IMAGE)); + assertEquals("https://wsrv.nl/?url=https://image.tmdb.org/t/p/w342/poster.png", + TmdbProxy.imageUrl(TmdbProxy.imageBaseFor(TmdbProxy.WSRV_IMAGE, "w342"), "/poster.png")); + } + + @Test + public void recognizesOfficialApiAndImageHosts() { + assertTrue(TmdbProxy.isOfficialApiHost("https://api.tmdb.org/3")); + assertTrue(TmdbProxy.isOfficialApiHost("https://api.themoviedb.org")); + assertTrue(TmdbProxy.isOfficialImageHost("https://image.tmdb.org/t/p/w342")); + assertFalse(TmdbProxy.isOfficialApiHost("https://mirror.example.com")); + assertFalse(TmdbProxy.isOfficialImageHost("https://mirror.example.com")); + } + @Test + public void autoAndWsrvRoutesNormalizeForRuntime() { + assertTrue(TmdbProxy.isAuto(TmdbProxy.AUTO)); + assertEquals("https://api.tmdb.org/3", TmdbProxy.apiBaseFor(TmdbProxy.OFFICIAL_API)); + assertEquals("https://wsrv.nl/?url=https://image.tmdb.org/t/p/w342/poster.png", + TmdbProxy.imageUrl(TmdbProxy.imageBaseFor(TmdbProxy.WSRV_IMAGE, "w342"), "/poster.png")); + } + +} diff --git a/app/src/test/java/com/fongmi/android/tv/web/WebThemeTokenSourceTest.java b/app/src/test/java/com/fongmi/android/tv/web/WebThemeTokenSourceTest.java new file mode 100644 index 0000000000..bd3f59b902 --- /dev/null +++ b/app/src/test/java/com/fongmi/android/tv/web/WebThemeTokenSourceTest.java @@ -0,0 +1,77 @@ +package com.fongmi.android.tv.web; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class WebThemeTokenSourceTest { + + private static final Pattern HEX = Pattern.compile("#[0-9A-Fa-f]{3}(?:[0-9A-Fa-f]{3})?\\b"); + + @Test + public void sharedStylesExposeCanonicalTokensAndLegacyAliases() throws Exception { + String css = read("app/src/main/assets/css/ui.css"); + for (String token : new String[]{ + "--webhtv-primary", "--webhtv-on-primary", "--webhtv-surface", + "--webhtv-surface-container", "--webhtv-surface-container-high", + "--webhtv-on-surface", "--webhtv-on-surface-variant", "--webhtv-outline", + "--webhtv-outline-variant", "--webhtv-error", "--webhtv-success", + "--webhtv-warning", "--webhtv-focus", "--webhtv-shape-small", + "--webhtv-space-1" + }) { + assertTrue(token + " must be defined", css.contains(token + ":")); + } + assertTrue("legacy --md-primary must remain an alias", css.contains("--md-primary: var(--webhtv-primary);")); + assertTrue("explicit light mode hook missing", css.contains(":root[data-theme=\"light\"]")); + assertTrue("explicit dark mode hook missing", css.contains(":root[data-theme=\"dark\"]")); + } + + @Test + public void assetStylesDoNotUseRawHexOutsideControlledDeclarations() throws Exception { + for (String file : new String[]{ + "app/src/main/assets/css/ui.css", "app/src/main/assets/index.html", + "app/src/main/assets/manage.html", "app/src/main/assets/parse.html", + "app/src/main/assets/webhome/eclipse-detail.html" + }) { + for (String line : read(file).split("\\R")) { + if (!HEX.matcher(line).find()) continue; + String stripped = line.trim(); + assertTrue(file + " raw hex outside token declaration: " + stripped, + stripped.startsWith("--") || stripped.contains("= 0); + int end = exoUtil.indexOf("\n }", start); + String mode = exoUtil.substring(start, end > start ? end : exoUtil.length()); + // 兜底开启:硬解 = ON(MediaCodec 先接管、FFmpeg 排最后);兜底关闭:仍为 OFF。 + assertTrue(mode, mode.contains("isDecoderFallbackEnabled()")); + assertTrue(mode, mode.contains("EXTENSION_RENDERER_MODE_ON")); + assertTrue(mode, mode.contains("EXTENSION_RENDERER_MODE_OFF")); assertTrue(exoUtil.contains("if (videoRenderMode == EXTENSION_RENDERER_MODE_OFF) return;")); - assertFalse(exoUtil.contains("buildHardwareOnlyFfmpegVideoRenderer")); + assertTrue(exoUtil.contains("buildFfmpegVideoRenderer(")); assertFalse(exoUtil.contains("new CompatFfmpegVideoRenderer(")); } diff --git a/app/src/test/resources/probe-rules-v1-community.json b/app/src/test/resources/probe-rules-v1-community.json deleted file mode 100644 index d2930e706a..0000000000 --- a/app/src/test/resources/probe-rules-v1-community.json +++ /dev/null @@ -1,420 +0,0 @@ -{ - "format": "ad-audio-probe-rules", - "schemaVersion": 1, - "revision": 2, - "algorithm": "spectral-sequence-v1", - "rules": [ - { - "id": "ad-1786989757783-1", - "durationMs": 47000, - "anchorOffsetMs": 0, - "anchorDurationMs": 5000, - "fingerprints": [ - { - "phaseMs": 0, - "hashes": [ - "687f0807", - "4f9601d7", - "28960097", - "0e7b001f", - "00000000", - "7b9200ff", - "2e99039f", - "7e6503ff", - "77f401f6", - "5ff5007f", - "59b900ff", - "79e500ff", - "7fe501ff", - "63f9007f", - "7bf100ff", - "7b9500ff", - "1efb00ff", - "79f500ff" - ] - }, - { - "phaseMs": 64, - "hashes": [ - "4e370617", - "6f9601d7", - "28d6081f", - "4e7b001f", - "37e3003b", - "6f9201ff", - "2e79033f", - "7e7507fd", - "7bf501f7", - "5bfb003f", - "79a900ff", - "7be500ff", - "67f501ff", - "6bf900ff", - "79a100fd", - "5fb500ff", - "7afb00ff", - "79b500fd" - ] - }, - { - "phaseMs": 128, - "hashes": [ - "4f960397", - "2d9600d3", - "28f2081f", - "00000000", - "5feb000b", - "2f9201df", - "2e79037f", - "7e3507fd", - "7bf501ff", - "5bfb003f", - "79a900fd", - "7fe500ff", - "67f901ff", - "7bf900ff", - "79a100fd", - "1feb007f", - "79f700ff", - "79b500fd" - ] - }, - { - "phaseMs": 192, - "hashes": [ - "4f9601d7", - "299600d3", - "2873001f", - "00000000", - "4bfb001f", - "2f9203df", - "7e6d03ff", - "753407f6", - "7bf500ff", - "59bb00ff", - "79a500fd", - "7fe500ff", - "63f900ff", - "7bf100ff", - "7b8500fd", - "1ffb00ff", - "79f500ff" - ] - } - ], - "test": { - "url": "https://oss.douyinbit.com/m3u8/c3680ce0df8971d518be669d84ef9eef.m3u8", - "adStartMs": 15000 - } - }, - { - "id": "auto-ad-3626f1b6eb5c7e1a", - "durationMs": 14466, - "anchorOffsetMs": 0, - "anchorDurationMs": 5000, - "fingerprints": [ - { - "phaseMs": 0, - "hashes": [ - "1176003f", - "1175003f", - "6ea62ff0", - "6ca42fa4", - "75941cf6", - "33961bf6", - "bd961fd2", - "33d9005f", - "3b3303bf", - "6f760ff6", - "6ea40fb6", - "6aa42be6", - "2b130b83", - "219f001f", - "69bb081f", - "6ea62fb7", - "6ca62fb6", - "69a42db7" - ] - }, - { - "phaseMs": 64, - "hashes": [ - "1177103f", - "1377003f", - "6ea62ff0", - "6ca42fa4", - "71841cf6", - "b79617f6", - "bd961fd2", - "335b01f9", - "2b3403bf", - "6f760ff6", - "e8a62fb6", - "6aa62be6", - "29f73c1f", - "259e009f", - "6abb081f", - "6eb627b7", - "6ca62fb6", - "6b2401b7" - ] - }, - { - "phaseMs": 128, - "hashes": [ - "11771837", - "62532353", - "6ea42fe0", - "5caa00be", - "31c61cf6", - "b79607d6", - "bdd60fc2", - "3b3303fb", - "6b2403f6", - "6ea40ff6", - "eaa62fb6", - "6a8603f6", - "21f73c1f", - "2bbe009f", - "6aab23bf", - "6ab62bb7", - "6ca62fb6", - "6b2603b7" - ] - }, - { - "phaseMs": 192, - "hashes": [ - "1175183f", - "669623d2", - "6ca42fa4", - "759a07da", - "31f619f6", - "b7961fd2", - "39d601c3", - "3b3303fb", - "6f640ff6", - "6ea40fb6", - "6aa62bf6", - "6a0703f3", - "21df301f", - "2bb7001f", - "6ea627b7", - "6ab62fb7", - "68a62fa6" - ] - } - ], - "test": { - "url": "https://v13.wsyzym3u8.com/202607/15/R3Brjnm0gx27/video/index.m3u8", - "adStartMs": 342000 - } - }, - { - "id": "auto-ad-530805f02f930909", - "durationMs": 11866, - "anchorOffsetMs": 0, - "anchorDurationMs": 5000, - "fingerprints": [ - { - "phaseMs": 0, - "hashes": [ - "5cff0007", - "14569417", - "74fb1c1f", - "39d9007f", - "3ad900cf", - "3cca0ee2", - "7de60467", - "00000000", - "00000000", - "36ff0007", - "23d500fd", - "2bf300ff", - "6cdb007f", - "28db005b", - "68fc080f", - "7df9000d", - "00000000", - "ad542556" - ] - }, - { - "phaseMs": 64, - "hashes": [ - "903ffc00", - "145e941f", - "7cf91c1f", - "31d9007f", - "3ad902cf", - "7cca0ee0", - "00000000", - "00000000", - "00000000", - "23fd001d", - "2bd500fd", - "29f3007f", - "6cd3047b", - "385b001f", - "68fc000f", - "00000000", - "00000000", - "ad5627f2" - ] - }, - { - "phaseMs": 128, - "hashes": [ - "983ef802", - "347a141f", - "3cf90c1d", - "39d9007f", - "3ec90eed", - "7cea0e60", - "00000000", - "00000000", - "00000000", - "23dd005d", - "2bd500fd", - "29f3003f", - "6cdb047b", - "397c001f", - "68fc000f", - "00000000", - "00000000", - "ee5607f2" - ] - }, - { - "phaseMs": 192, - "hashes": [ - "9856d807", - "34fb141f", - "38d90c5f", - "3bd9005f", - "3ecb0ee1", - "3ceb0061", - "00000000", - "00000000", - "00000000", - "23dd005d", - "2bd100ff", - "29fb007f", - "6cdb007b", - "387c000f", - "68fc000e", - "00000000", - "00000000" - ] - } - ], - "test": { - "url": "https://v13.wsyzym3u8.com/202607/15/R3Brjnm0gx27/video/index.m3u8", - "adStartMs": 342000 - } - }, - { - "id": "auto-ad-ed09bbb9f1dd3c87", - "durationMs": 15132, - "anchorOffsetMs": 0, - "anchorDurationMs": 5000, - "fingerprints": [ - { - "phaseMs": 0, - "hashes": [ - "7af4001f", - "6ffe0007", - "6c7c001f", - "78fb003f", - "7f6a007f", - "3ce2007f", - "56c3007f", - "778503e0", - "00000000", - "00000000", - "00000000", - "00000000", - "00000000", - "00000000", - "00000000", - "649f0007", - "76f2007f", - "7cfb001f" - ] - }, - { - "phaseMs": 64, - "hashes": [ - "6fdd0007", - "69ee0807", - "6c7c001f", - "797a003f", - "7f63007f", - "3cc200ff", - "56d1007f", - "00000000", - "00000000", - "00000000", - "00000000", - "00000000", - "00000000", - "00000000", - "65cf00c7", - "46f1003f", - "76f3007f", - "78f9001f" - ] - }, - { - "phaseMs": 128, - "hashes": [ - "6fde0007", - "687c6c0f", - "6cf8001f", - "797a003f", - "3d62007f", - "34c2007f", - "36d503f5", - "00000000", - "00000000", - "00000000", - "00000000", - "00000000", - "00000000", - "00000000", - "658f0087", - "56f2003f", - "76fb007f", - "7ef9007f" - ] - }, - { - "phaseMs": 192, - "hashes": [ - "6ffe0007", - "687c001f", - "7cf9001f", - "7b7a003f", - "3ce2007b", - "74c3007f", - "368503e0", - "00000000", - "00000000", - "00000000", - "00000000", - "00000000", - "00000000", - "00000000", - "649f0007", - "76f2003f", - "76fb003f" - ] - } - ], - "test": { - "url": "https://v13.wsyzym3u8.com/202607/15/R3Brjnm0gx27/video/index.m3u8", - "adStartMs": 342000 - } - } - ] -} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryCancellationTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryCancellationTest.java new file mode 100644 index 0000000000..339001a4b8 --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/player/exo/HttpEofRecoveryCancellationTest.java @@ -0,0 +1,141 @@ +package com.fongmi.android.tv.player.exo; + +import android.app.Application; +import android.net.Uri; + +import androidx.media3.common.C; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.TransferListener; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.IOException; +import java.net.ProtocolException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Cancellation behavior of the EOF recovery backoff, which needs a real {@link Uri}. + * + *

The backoff sleep sits between {@code read()}'s own {@code dataSpec == null} guard and the + * dereference of {@code dataSpec}. Media3 cancels a load by calling {@link DataSource#close()} + * from another thread, so a close landing inside that sleep would let the source reopen a + * closed connection at a stale offset — or dereference a null spec. The shared JVM test set cannot + * build a {@link DataSpec} because {@code android.net.Uri} is not mocked there, hence Robolectric. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, application = Application.class) +public class HttpEofRecoveryCancellationTest { + + /** + * Only the foreground source may publish the yield signal. If preload published it too, preload + * would yield to itself and never restart; if the foreground did not publish, the yield would + * never fire. Both halves are load-bearing, so both are pinned. + */ + @Test + public void onlyForegroundSourcePublishesTheYieldSignal() throws Exception { + ExoForegroundEofSignal.reset(); + driveOneRecovery(true); + long afterForeground = ExoForegroundEofSignal.ageMs(0); + assertTrue("foreground recovery must publish the signal", + afterForeground != Long.MAX_VALUE); + + ExoForegroundEofSignal.reset(); + driveOneRecovery(false); + assertEquals("preload-side recovery must not publish the signal", + Long.MAX_VALUE, ExoForegroundEofSignal.ageMs(0)); + } + + private static void driveOneRecovery(boolean foreground) throws Exception { + DataSource upstream = new DataSource() { + @Override public void addTransferListener(TransferListener listener) {} + + @Override public long open(DataSpec dataSpec) { return C.LENGTH_UNSET; } + + @Override public int read(byte[] buffer, int offset, int length) throws IOException { + throw new ProtocolException("unexpected end of stream"); + } + + @Override public Uri getUri() { return Uri.EMPTY; } + + @Override public void close() {} + }; + HttpEofRecoveryDataSource source = new HttpEofRecoveryDataSource(upstream, foreground); + source.open(new DataSpec(Uri.parse("http://example.invalid/a.mp4"), 0, C.LENGTH_UNSET)); + try { + source.read(new byte[16], 0, 16); + } catch (IOException expected) { + // The recovery runs (and publishes) before the retry budget is exhausted. + } + } + + /** First-attempt backoff is 100 ms; this delay must land well inside it. */ + private static final long CLOSE_INSIDE_BACKOFF_DELAY_MS = 30L; + + @Test + public void closeDuringBackoffCancelsCleanlyInsteadOfReopening() throws Exception { + CountDownLatch readThrew = new CountDownLatch(1); + AtomicInteger opens = new AtomicInteger(); + AtomicReference readerFailure = new AtomicReference<>(); + + DataSource upstream = new DataSource() { + @Override public void addTransferListener(TransferListener listener) {} + + @Override public long open(DataSpec dataSpec) { + opens.incrementAndGet(); + return C.LENGTH_UNSET; + } + + @Override public int read(byte[] buffer, int offset, int length) throws IOException { + readThrew.countDown(); + throw new ProtocolException("unexpected end of stream"); + } + + @Override public Uri getUri() { return Uri.EMPTY; } + + @Override public void close() {} + }; + + HttpEofRecoveryDataSource source = new HttpEofRecoveryDataSource(upstream); + source.open(new DataSpec(Uri.parse("http://example.invalid/a.mp4"), 0, C.LENGTH_UNSET)); + + Thread reader = new Thread(() -> { + try { + source.read(new byte[16], 0, 16); + } catch (Throwable error) { + readerFailure.set(error); + } + }); + reader.start(); + + assertTrue("upstream read should have been reached", + readThrew.await(2, TimeUnit.SECONDS)); + // The catch block's own null check runs within microseconds of the throw, so sleeping here + // guarantees close() lands after that check and inside the backoff sleep. + Thread.sleep(CLOSE_INSIDE_BACKOFF_DELAY_MS); + source.close(); + reader.join(5_000); + + assertFalse("reader thread must terminate", reader.isAlive()); + Throwable failure = readerFailure.get(); + assertNotNull("read() must surface a failure, not return normally", failure); + // The discriminator: a clean cancellation is an IOException. Without the post-backoff + // re-check the stale spec is dereferenced and this becomes a NullPointerException. + assertTrue("close during backoff must surface IOException, got " + failure, + failure instanceof IOException); + assertFalse("close during backoff must not NPE", + failure instanceof NullPointerException); + assertEquals("close during backoff must not reopen", 1, opens.get()); + } +} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/LightDialogShellOpacityTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/LightDialogShellOpacityTest.java new file mode 100644 index 0000000000..40e3dcd3ed --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/theme/LightDialogShellOpacityTest.java @@ -0,0 +1,98 @@ +package com.fongmi.android.tv.theme; + +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; + +import androidx.appcompat.app.AppCompatActivity; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.ui.dialog.LightDialog; +import com.github.catvod.utils.Prefers; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * The dialog shell has exactly one owner for each half of its contract. + * + *

{@code LightDialog.apply} is the shell owner for its whole call-site family. It + * builds that shell from the active palette and then hands the very same drawable to + * {@link ThemeController#bindWindowBackground}, which applies {@code dialogOpacity} + * itself. Baking the opacity into the shape as well scaled it twice - a profile that + * set {@code 0.70} produced {@code 0.49} - and, because a translucent fill no longer + * matches its baseline role in {@link ThemeColorIndex}, it also stopped the shell from + * following a user {@code surfaceContainerHigh} override. + * + *

Both halves are asserted on the real drawable the production path passes to the + * binder, so the guard cannot pass by reading the source instead of the behaviour. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, application = App.class) +public class LightDialogShellOpacityTest { + + public static class Host extends AppCompatActivity { + @Override + protected void onCreate(android.os.Bundle state) { + setTheme(R.style.Theme_App); + super.onCreate(state); + } + } + + @Before + public void reset() { + Prefers.getPrefers().edit().clear().commit(); + } + + @Test + public void shellStartsOpaqueAndOnTheSemanticSurfaceRole() { + try (var host = Robolectric.buildActivity(Host.class).setup()) { + ThemeController.applyFromPreferences(host.get()); + Drawable shell = LightDialog.shell(host.get()); + assertEquals("the shell must ship opaque so dialogOpacity has a single owner", + 0xFF, fill(shell) >>> 24); + assertEquals("the shell fill must be the semantic surfaceContainerHigh value", + ThemeController.current().colorSurfaceContainerHigh(), fill(shell)); + } + } + + @Test + public void dialogOpacityReachesTheShellExactlyOnce() { + ThemeProfile profile = ThemeProfile.defaultProfile(); + // 两份表都设:TV 现在产品默认深色(与上游一致),而 mobile/显式浅色会解析 light 槽。 + // 本测试验证的是「opacity 只到达 shell 一次」这个机制,与哪张表生效无关。 + profile.light.dialogOpacity = 0.70f; + profile.dark.dialogOpacity = 0.70f; + assertTrue("the fixture profile must validate", ThemeProfileStore.apply(profile).success()); + + try (var host = Robolectric.buildActivity(Host.class).setup()) { + ThemeController.applyFromPreferences(host.get()); + assertEquals("the fixture must actually activate the profile opacity", + 0.70f, ThemeController.current().dialogOpacity(), 0.0001f); + + Drawable shell = LightDialog.shell(host.get()); + assertEquals("the shell must ship opaque, so only the binder scales it", + 0xFF, fill(shell) >>> 24); + ThemeController.bindWindowBackground(shell); + + // 0.70 * 255 = 178.5, and Math.round takes it up to 179. Baking the opacity into + // the shape as well used to produce 125 here (0.70 * 0.70). + assertEquals("dialogOpacity must reach the shell exactly once", 179, fill(shell) >>> 24); + } + } + + private static int fill(Drawable drawable) { + assertTrue("unexpected shell drawable: " + drawable, drawable instanceof GradientDrawable); + GradientDrawable shape = (GradientDrawable) drawable; + assertNotNull("the shell fill must exist", shape.getColor()); + return shape.getColor().getDefaultColor(); + } +} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeColorPickerPrecisionTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeColorPickerPrecisionTest.java new file mode 100644 index 0000000000..c79d81b140 --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeColorPickerPrecisionTest.java @@ -0,0 +1,94 @@ +package com.fongmi.android.tv.theme; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; + +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.R; + +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.shadows.ShadowLooper; + +import static org.junit.Assert.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, application = App.class) +public class ThemeColorPickerPrecisionTest { + + @Test + public void openingAndConfirmingDoesNotRoundAnExactHexThroughHsv() { + ActivityController controller = activity(); + String[] picked = {null}; + AlertDialog dialog = ThemeColorPickerDialog.create(controller.get(), "Primary", "#DAB9FF", ThemeTokens.dark(), hex -> picked[0] = hex); + dialog.show(); + ShadowLooper.idleMainLooper(); + EditText input = findInput(dialog.getWindow().getDecorView()); + assertEquals("#DAB9FF", input.getText().toString()); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick(); + assertEquals("#DAB9FF", picked[0]); + assertFalse(dialog.isShowing()); + controller.pause().stop().destroy(); + } + + @Test + public void useHexIsAlsoLosslessAndInvalidConfirmationKeepsThePickerOpen() { + ActivityController controller = activity(); + String[] picked = {null}; + AlertDialog dialog = ThemeColorPickerDialog.create(controller.get(), "Primary", "#123456", ThemeTokens.light(), hex -> picked[0] = hex); + dialog.show(); + ShadowLooper.idleMainLooper(); + View root = dialog.getWindow().getDecorView(); + EditText input = findInput(root); + input.setText("#DAB9FF"); + Button use = findButton(root, controller.get().getString(R.string.theme_editor_hex_apply)); + assertNotNull(use); + use.performClick(); + assertEquals("#DAB9FF", input.getText().toString()); + input.setText("#ZZZZZZ"); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick(); + assertTrue(dialog.isShowing()); + assertNull(picked[0]); + input.setText("#a5c9f0"); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick(); + assertEquals("#A5C9F0", picked[0]); + controller.pause().stop().destroy(); + } + + private static ActivityController activity() { + ActivityController controller = Robolectric.buildActivity(AppCompatActivity.class); + controller.get().setTheme(R.style.Theme_App); + return controller.setup(); + } + + private static EditText findInput(View view) { + if (view instanceof EditText input) return input; + if (view instanceof ViewGroup group) { + for (int i = 0; i < group.getChildCount(); i++) { + EditText found = findInput(group.getChildAt(i)); + if (found != null) return found; + } + } + return null; + } + + private static Button findButton(View view, String label) { + if (view instanceof Button button && label.contentEquals(button.getText())) return button; + if (view instanceof ViewGroup group) { + for (int i = 0; i < group.getChildCount(); i++) { + Button found = findButton(group.getChildAt(i), label); + if (found != null) return found; + } + } + return null; + } +} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeDialogModeTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeDialogModeTest.java new file mode 100644 index 0000000000..2762b852a8 --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeDialogModeTest.java @@ -0,0 +1,108 @@ +package com.fongmi.android.tv.theme; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.setting.Setting; +import com.fongmi.android.tv.ui.dialog.ThemeDialog; +import com.github.catvod.utils.Prefers; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowLooper; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, application = App.class) +public class ThemeDialogModeTest { + public static class Host extends AppCompatActivity { + @Override protected void onCreate(android.os.Bundle state) { + setTheme(R.style.Theme_App); + super.onCreate(state); + } + } + + @Before public void reset() { + Prefers.getPrefers().edit().clear().commit(); + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + } + + @Test public void saveDarkThenLightPersistsBothDirectionsAndReopensSelected() { + saveAndReopen(true); + saveAndReopen(false); + } + + private void saveAndReopen(boolean dark) { + try (var host = Robolectric.buildActivity(Host.class).setup()) { + ThemeDialog dialog = open(host.get()); + click(dialog, dark ? R.string.theme_editor_dark : R.string.theme_editor_light); + assertEquals(dark ? -1 : 1, Setting.getThemeMode()); + click(dialog, R.string.theme_editor_apply); + assertEquals(dark ? 1 : 0, Setting.getThemeMode()); + assertEquals(dark ? "dark" : "light", ThemeProfileStore.load().mode); + ThemeController.applyFromPreferences(null); + assertEquals(dark ? ThemeTokens.dark().colorSurface() : ThemeTokens.light().colorSurface(), + ThemeController.current().colorSurface()); + assertEquals(dark ? ThemeTokens.dark().colorPrimary() : ThemeTokens.light().colorPrimary(), + ThemeController.current().colorPrimary()); + ShadowLooper.idleMainLooper(); + } + var host = Robolectric.buildActivity(Host.class).setup(); + ThemeDialog reopened = open(host.get()); + assertTrue(find(reopened.getDialog().getWindow().getDecorView(), + host.get().getString(dark ? R.string.theme_editor_dark : R.string.theme_editor_light)).isSelected()); + reopened.dismissNow(); + host.pause().stop().destroy(); + } + + @Test public void cancelDoesNotPublishModeOrProfile() { + String original = ThemeProfileCodec.encode(ThemeProfileStore.load()); + try (var host = Robolectric.buildActivity(Host.class).setup()) { + ThemeDialog dialog = open(host.get()); + click(dialog, R.string.theme_editor_dark); + click(dialog, R.string.theme_editor_cancel); + assertEquals(-1, Setting.getThemeMode()); + assertEquals(original, ThemeProfileCodec.encode(ThemeProfileStore.load())); + } + } + + @Test public void savingColorsWithoutModeSelectionPreservesFollowSystem() { + try (var host = Robolectric.buildActivity(Host.class).setup()) { + ThemeDialog dialog = open(host.get()); + click(dialog, R.string.theme_editor_apply); + assertEquals(-1, Setting.getThemeMode()); + } + } + + private static ThemeDialog open(Host host) { + ThemeDialog dialog = new ThemeDialog(); + dialog.showNow(host.getSupportFragmentManager(), "theme"); + return dialog; + } + + private static void click(ThemeDialog dialog, int label) { + View view = find(dialog.getDialog().getWindow().getDecorView(), dialog.getString(label)); + assertNotNull(view); + view.performClick(); + } + + private static View find(View view, String text) { + if (view instanceof TextView label && text.contentEquals(label.getText())) return view; + if (view instanceof ViewGroup group) for (int i = 0; i < group.getChildCount(); i++) { + View found = find(group.getChildAt(i), text); + if (found != null) return found; + } + return null; + } +} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeEditorPanelTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeEditorPanelTest.java new file mode 100644 index 0000000000..a97af594bf --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/theme/ThemeEditorPanelTest.java @@ -0,0 +1,104 @@ +package com.fongmi.android.tv.theme; + +import android.content.Context; +import android.view.ContextThemeWrapper; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.R; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, application = App.class) +public class ThemeEditorPanelTest { + + @Test + public void rebindingPreservesTheSliderAndFocusWhileRemoteKeysKeepEditing() { + Context context = new ContextThemeWrapper(App.get(), R.style.Theme_App); + ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); + ThemePreviewView[] panel = new ThemePreviewView[1]; + int[] changes = {0}; + panel[0] = ThemePreviewView.createPanel(context, editor, true, editor.preview(true, 0), new ThemePreviewView.Callbacks() { + @Override public void onColorSlotClicked(ThemeEditor.Slot slot, boolean dark) { } + @Override public void onDraftChanged() { + changes[0]++; + panel[0].render(editor, true, editor.preview(true, 0)); + } + }); + layout(panel[0]); + SeekBar bar = panel[0].findViewWithTag("theme-opacity:SCRIM_OPACITY"); + assertNotNull(bar); + assertTrue("automatic scrim uses the resolved value, not the minimum", bar.getProgress() > 0); + SeekBar playerScrim = panel[0].findViewWithTag("theme-opacity:PLAYER_SCRIM_OPACITY"); + assertNotNull("the playback menu overlay slider must exist", playerScrim); + assertEquals("auto strength is 80% of the 20..100 range", 75, playerScrim.getProgress()); + assertTrue(bar.requestFocus()); + int before = bar.getProgress(); + bar.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT)); + bar.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_RIGHT)); + assertTrue(bar.getProgress() > before); + assertEquals(1, changes[0]); + assertTrue(editor.isDirty()); + assertSame(bar, panel[0].findViewWithTag("theme-opacity:SCRIM_OPACITY")); + assertSame(bar, panel[0].findFocus()); + bar.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT)); + assertEquals(2, changes[0]); + } + + @Test + public void everyRowShowsResolvedColorsAndSwitchingModesDoesNotPublishTheDraft() { + Context context = new ContextThemeWrapper(App.get(), R.style.Theme_App); + String persisted = ThemeProfileCodec.encode(ThemeProfileStore.load()); + ThemeTokens global = ThemeController.current(); + ThemeEditor editor = new ThemeEditor(ThemeProfile.defaultProfile()); + ThemeEditor.Slot[] clicked = {null}; + ThemePreviewView panel = ThemePreviewView.createPanel(context, editor, false, editor.preview(false, 0), new ThemePreviewView.Callbacks() { + @Override public void onColorSlotClicked(ThemeEditor.Slot slot, boolean dark) { clicked[0] = slot; } + @Override public void onDraftChanged() { } + }); + View success = panel.findViewWithTag("theme-slot:SUCCESS"); + success.performClick(); + assertEquals(ThemeEditor.Slot.SUCCESS, clicked[0]); + List texts = texts(panel); + assertTrue(texts.stream().anyMatch(text -> text.contains("#146C2E"))); + assertFalse(texts.contains(context.getString(R.string.theme_editor_preview))); + assertFalse(texts.contains(context.getString(R.string.theme_editor_inherit))); + editor.replace(ThemePresets.Preset.ROSE.profile()); + ThemeTokens dark = editor.preview(true, 0); + panel.render(editor, true, dark); + assertSame(success, panel.findViewWithTag("theme-slot:SUCCESS")); + assertTrue(texts(panel).contains(ThemeEditorUi.hex(dark.colorSuccess()))); + editor.reset(); + panel.render(editor, false, editor.preview(false, 0)); + assertEquals(persisted, ThemeProfileCodec.encode(ThemeProfileStore.load())); + assertEquals(global, ThemeController.current()); + } + + private static void layout(View view) { + view.measure(View.MeasureSpec.makeMeasureSpec(1100, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(3000, View.MeasureSpec.AT_MOST)); + view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); + } + + private static List texts(View view) { + List result = new ArrayList<>(); + if (view instanceof TextView text) result.add(text.getText().toString()); + if (view instanceof ViewGroup group) { + for (int i = 0; i < group.getChildCount(); i++) result.addAll(texts(group.getChildAt(i))); + } + return result; + } +} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/theme/TvDialogPaletteModeTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/theme/TvDialogPaletteModeTest.java new file mode 100644 index 0000000000..24266e2293 --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/theme/TvDialogPaletteModeTest.java @@ -0,0 +1,127 @@ +package com.fongmi.android.tv.theme; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import androidx.core.content.ContextCompat; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.R; +import com.fongmi.android.tv.ui.dialog.LightDialog; +import com.github.catvod.utils.Prefers; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** + * TV 对话框面板必须与它自己的前景取自同一张表。 + * + *

{@code LightDialog} 家族(「新增点播」{@code ConfigDialog}、{@code AppearanceDialog}、 + * {@code SpeedDialog} …)的面板由 {@link LightDialog#shell} 从 + * {@link ThemeController#current()} 画出,而面板里的文字来自 XML 的 {@code ?attr/color*} + * 与 {@code @color/webhtv_color_*},即由 Activity 解析出的编译表。两者一旦取自不同的表, + * 就是「浅底浅字」:设备实测(1920×1080,系统日间模式,TV 产品默认强制深色)面板 + * {@code #E7E8EF}(日表)配夜表前景,正文 1.06:1、提示 1.39:1、描边按钮 1.41:1。 + * + *

根因是取表来源:{@code AppCompatDelegate.setDefaultNightMode} 改写的是它代理的 + * Activity 的资源,API < 31 不会改写 Application 自己的资源;而 TV 侧 + * {@code applyNightModeToApp()} 按产品默认强制 {@code MODE_NIGHT_YES}。于是 + * 「应用上下文 + 原始系统夜间位」在日间模式的电视上判成浅色,而实际 inflate 出来的全是深色。 + * 这里断言的就是「必须按 Activity 实际解析出的表来取色」这条不变量。 + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, application = App.class) +public class TvDialogPaletteModeTest { + + public static class Host extends AppCompatActivity { + @Override + protected void onCreate(android.os.Bundle state) { + setTheme(R.style.Theme_App); + super.onCreate(state); + } + } + + @Before + public void reset() { + Prefers.getPrefers().edit().clear().commit(); + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + } + + @Test + public void dialogShellMatchesTheTableTheActivityInflated() { + // TV 产品默认强制深色:设备本身处于日间模式时,Activity 仍解析 values-night。 + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + try (var host = Robolectric.buildActivity(Host.class).setup()) { + ThemeController.applyFromPreferences(host.get()); + int inflated = ContextCompat.getColor(host.get(), R.color.webhtv_color_surface_container_high); + assertEquals("fixture: the TV activity must inflate the dark table", + ThemeTokens.dark().colorSurfaceContainerHigh(), inflated); + assertEquals("the dialog shell must come from the table its foregrounds come from", + inflated, fill(LightDialog.shell(host.get()))); + } + } + + @Test + public void addVodDialogForegroundsMeetWcagAaOnTheShell() { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + try (var host = Robolectric.buildActivity(Host.class).setup()) { + ThemeController.applyFromPreferences(host.get()); + int panel = fill(LightDialog.shell(host.get())); + assertAtLeastAa(host.get(), panel, R.color.webhtv_color_on_surface, "info text"); + assertAtLeastAa(host.get(), panel, R.color.webhtv_color_on_surface_variant, "field hints"); + assertAtLeastAa(host.get(), panel, R.color.webhtv_color_primary, "outlined action text"); + } + } + + /** + * 取表必须跟着「真正 inflate 的那个 context」走,而不是跟着进程级的应用上下文走。 + * + *

Robolectric 会把 {@code setDefaultNightMode} 也应用到应用上下文,真机 API < 31 不会, + * 所以只靠上面的 Activity 断言在 JVM 里复现不出设备现象。这条用显式构造的日/夜 context + * 把「探针跟着入参 context」这条机制钉死,与设备实测互为补充。 + */ + @Test + public void paletteDecisionFollowsTheInflatingContext() { + Context app = App.get(); + assertEquals("fixture: the day context must resolve the light table", + ThemeTokens.light().colorSurfaceContainerHigh(), + ContextCompat.getColor(withNight(app, false), R.color.webhtv_color_surface_container_high)); + assertEquals("fixture: the night context must resolve the dark table", + ThemeTokens.dark().colorSurfaceContainerHigh(), + ContextCompat.getColor(withNight(app, true), R.color.webhtv_color_surface_container_high)); + assertEquals(Boolean.TRUE, ThemeController.compiledDarkPalette(withNight(app, true))); + assertEquals(Boolean.FALSE, ThemeController.compiledDarkPalette(withNight(app, false))); + assertTrue(ThemeController.resolvedDark(withNight(app, true))); + assertFalse(ThemeController.resolvedDark(withNight(app, false))); + } + + private static Context withNight(Context base, boolean night) { + Configuration configuration = new Configuration(base.getResources().getConfiguration()); + int mode = night ? Configuration.UI_MODE_NIGHT_YES : Configuration.UI_MODE_NIGHT_NO; + configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) | mode; + return base.createConfigurationContext(configuration); + } + + private static void assertAtLeastAa(AppCompatActivity host, int panel, int foreground, String what) { + double ratio = ThemeContrast.ratio(ContextCompat.getColor(host, foreground), panel); + assertTrue(what + " must reach WCAG AA 4.5:1 on the dialog shell, measured " + ratio + ":1", ratio >= 4.5); + } + + private static int fill(Drawable drawable) { + assertTrue("unexpected shell drawable: " + drawable, drawable instanceof GradientDrawable); + GradientDrawable shape = (GradientDrawable) drawable; + return shape.getColor().getDefaultColor(); + } +} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/NativeEnhancedPlaybackStyleFocusTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/NativeEnhancedPlaybackStyleFocusTest.java new file mode 100644 index 0000000000..6959158a55 --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/NativeEnhancedPlaybackStyleFocusTest.java @@ -0,0 +1,378 @@ +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; + +/** + * 原生增强播放页的视觉统一与焦点链路契约。 + * + * 用户报告的三类问题: + * 1. 同一页存在多套按钮风格(4dp/8dp/14dp/28dp 圆角、白/黄两套焦点环、部分卡片无描边); + * 2. 选中后的高亮颜色不统一(白色、黄色、以及完全没有描边); + * 3. 焦点不完善:聚焦在分段(分组)按钮上按向下到不了季度按钮,评分与数据卡片无法选中。 + * + * 本测试把「唯一规范」固化成契约:焦点 3dp tv_item_focus_ring,当前态 2dp tv_item_current_ring, + * 常态 1dp tv_item_normal_stroke,圆角 8dp;并锁定纵向焦点链的顺序。 + */ +public class NativeEnhancedPlaybackStyleFocusTest { + + private static final String CHIP_SELECTOR = "app/src/leanback/res/drawable/selector_video_item.xml"; + private static final String EPISODE_CARD_SELECTOR = "app/src/main/res/drawable/selector_episode_card.xml"; + private static final String CAST_FOCUS_SELECTOR = "app/src/main/res/drawable/selector_tmdb_cast_focus.xml"; + private static final String COLORS = "app/src/main/res/values/colors.xml"; + private static final String ATTRS = "app/src/main/res/values/attrs.xml"; + private static final String LEANBACK_STYLES = "app/src/leanback/res/values/styles.xml"; + private static final String MOBILE_STYLES = "app/src/mobile/res/values/styles.xml"; + private static final String VIDEO_ACTIVITY = "app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java"; + private static final String VIDEO_LAYOUT = "app/src/leanback/res/layout/activity_video.xml"; + + // ---------------------------------------------------------------- 统一规范本身 + + @Test + public void unifiedFocusTokensAreDeclaredOnceAndThemeable() throws Exception { + String colors = read(COLORS); + assertTrue("焦点环色必须只有一个来源且必须跟随主题焦点槽", + colors.contains("@color/webhtv_color_focus")); + assertTrue("当前播放环色必须只有一个来源", colors.contains("#2CC56F")); + assertTrue("常态描边色必须只有一个来源", colors.contains("#33FFFFFF")); + + // 焦点语义必须可被主题覆写,而不是散落的字面量:属性定义 + 两个 flavor 的绑定都要在。 + String attrs = read(ATTRS); + assertTrue("必须声明 tvFocusRing 主题属性", attrs.contains("")); + assertTrue("必须声明 tvCurrentRing 主题属性", attrs.contains("")); + assertTrue("必须声明 tvNormalStroke 主题属性", attrs.contains("")); + for (String styles : new String[]{LEANBACK_STYLES, MOBILE_STYLES}) { + String body = read(styles); + assertTrue(styles + " 必须把 tvFocusRing 绑到统一取值", body.contains("@color/tv_item_focus_ring")); + assertTrue(styles + " 必须把 tvCurrentRing 绑到统一取值", body.contains("@color/tv_item_current_ring")); + assertTrue(styles + " 必须把 tvNormalStroke 绑到统一取值", body.contains("@color/tv_item_normal_stroke")); + } + } + + @Test + public void everySelectableSurfaceSharesOneFocusSpec() throws Exception { + // 2026-10-10 第三轮(用户报告「播放页按钮边框太粗、按钮与选中态边框颜色不统一」): + // 播放页焦点环与当前态环统一到同一宽度 token。 + // + // 分层(仓库既有「宿主分层规则」): + // 视频层环(叠在视频画面/固定深靛玻璃上)→ tvPlayerRing / tvPlayerCurrentRing + // 浅色表取值 = ThemeResolver 在固定玻璃背景上夹取到 ≥3:1 的派生色 + // (浅色表 FOCUS 深蓝在玻璃上仅 1.83:1,不可直接用); + // 深色表两类宿主的底色都是深色,直接取与应用表面焦点环同值的近白。 + // 应用表面环(叠在调色板表面/页面背景上,含与详情页/手机版共用的卡片) + // → tvFocusRing / tvCurrentRing + String episode = values(read(EPISODE_CARD_SELECTOR)); + assertTrue("选集卡(应用表面,与详情页/手机版共用)焦点环必须继续走 tvFocusRing", + episode.contains("android:color=\"?attr/tvFocusRing\"")); + assertTrue("选集卡当前态必须继续走 tvCurrentRing", + episode.contains("android:color=\"?attr/tvCurrentRing\"")); + + String chip = values(read(CHIP_SELECTOR)); + assertTrue("播放页芯片焦点态必须引用统一宽度 token", + chip.contains("android:width=\"@dimen/webhtv_focus_ring_width\"")); + // 2026-10-11(用户报告「播放页存在多个不同颜色的高亮效果」):这批芯片的宿主是播放页 + // 内容区(@+id/scroll 里的线路/清晰度/数组分段/分段/选集/快捷源行与按钮行), + // 画在页面背景上而不是叠在视频或固定玻璃上,所以归「应用表面」族,环色取调色板 + // FOCUS 槽 ?attr/tvFocusRing(此前错用视频层灰蓝 #7695C5,与页面其它按钮不同色)。 + assertTrue("播放页芯片(应用表面)焦点环必须走 tvFocusRing(受主题控制)", + chip.contains("android:color=\"?attr/tvFocusRing\"")); + assertFalse("播放页内容区芯片不允许再用视频层环色(会造成同页两种高亮色)", + chip.contains("?attr/tvPlayerRing") || chip.contains("?attr/tvPlayerCurrentRing")); + assertFalse("播放页芯片不允许再出现写死白色焦点环", + chip.contains("android:color=\"@color/white\"") || chip.contains("android:color=\"#FFFFFF\"")); + assertFalse("播放页芯片不允许再出现硬编码焦点色,必须走主题属性", + chip.contains("#FFD166") || chip.contains("#FFE16A") || chip.contains("#0077FF")); + assertTrue(CHIP_SELECTOR + " 的圆角必须统一为 8dp", + chip.contains("")); + + // 复用同一份芯片布局的固定深色玻璃对话框(dialog_episode_list / dialog_quick_search): + // 浅色表 FOCUS 深蓝 #0B57D0 画在固定玻璃 #303463 上仅 1.83:1,所以这两处在子树内 + // 用 GlassFocusRings 覆盖层把两项重绑回玻璃可读的视频层派生色。 + String glassOverlay = values(read("app/src/leanback/res/values/styles.xml")); + assertTrue("玻璃宿主必须把 tvFocusRing 重绑到视频层派生色", + glassOverlay.contains("@color/tv_player_focus_ring")); + + // 控制面板按钮(dialog_control)也在视频层固定玻璃上,同样统一到播放页派生色。 + String sheet = values(read("app/src/leanback/res/drawable/selector_control_sheet_button.xml")); + assertTrue("控制面板按钮焦点环必须走 tvPlayerRing", sheet.contains("?attr/tvPlayerRing")); + assertTrue("控制面板按钮当前态环必须走 tvPlayerCurrentRing", sheet.contains("?attr/tvPlayerCurrentRing")); + + // 演员卡是圆角卡片(应用表面层,详情页),本轮只保证圆角对齐宿主。 + String cast = values(read(CAST_FOCUS_SELECTOR)); + assertTrue("演员卡宿主卡片圆角仍必须走共享尺寸 token", + read("app/src/main/res/layout/adapter_tmdb_cast.xml").contains("app:cardCornerRadius=\"@dimen/webhtv_card_radius_large\"")); + assertTrue("演员卡焦点环声明圆角必须小于宿主圆角(实测 9.5dp 四档密度 0 溢出)", + cast.contains("")); + assertFalse("演员卡不允许再出现写死白色焦点环", cast.contains("android:color=\"@color/white\"")); + } + + @Test + public void chipSelectorKeepsCurrentStateAtTheUnifiedWidthAndNormalStateAtOneDp() throws Exception { + String body = squeeze(read(CHIP_SELECTOR)); + // 2026-10-10 第三轮:当前态也收进统一宽度 token(此前 2dp)。 + // 2026-10-11:芯片改归「应用表面」族,当前态同步收回到 ?attr/tvCurrentRing。 + assertTrue("当前播放环必须引用统一宽度 token", + body.contains("android:width=\"@dimen/webhtv_focus_ring_width\"")); + assertTrue("常态必须补上 1dp 常态描边,消除「没有边框颜色」的芯片", + body.contains("android:width=\"1dp\" android:color=\"?attr/tvNormalStroke\"")); + assertTrue("state_selected 仍然只承担跑马灯开关,不能加描边", + body.contains("跑马灯开关")); + assertTrue("chip selector 的 activated 分支(selector_chip→shape_chip_activated)必须走 tvPlayerCurrentRing", + values(read("app/src/leanback/res/drawable/shape_chip_activated.xml")).contains("?attr/tvPlayerCurrentRing")); + } + + @Test + public void playbackCardsOwnTheUnifiedRingInLeanbackPresentersWithoutTouchingSharedLayouts() throws Exception { + // adapter_tmdb_*.xml 与独立详情页共用,详情页有自己的焦点契约 + // (TmdbDetailActivityLayoutTest 会断言这些共享布局的原样取值), + // 所以播放页的统一焦点环必须落在 leanback 专属的 presenter 上,不能改共享布局。 + String[] shared = { + "app/src/main/res/layout/adapter_tmdb_photo.xml", + "app/src/main/res/layout/adapter_tmdb_video.xml", + "app/src/main/res/layout/adapter_tmdb_cast.xml", + "app/src/main/res/layout/adapter_tmdb_recommendation.xml", + "app/src/main/res/layout/adapter_tmdb_recommendation_landscape.xml", + }; + for (String card : shared) { + assertFalse(card + " 是详情页共用布局,不允许把播放页的统一描边写进去", + read(card).contains("tv_item_normal_stroke")); + } + + String cast = read("app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbCastPresenter.java"); + assertTrue("演员卡必须由 presenter 在绑定时挂上统一焦点环 selector", + cast.contains("card.setForeground(card.getContext().getDrawable(R.drawable.selector_tmdb_cast_focus))")); + assertTrue("演员卡常态描边必须是 1dp 统一取值", + cast.contains("STROKE_NORMAL = 0x33FFFFFF") && cast.contains("STROKE_WIDTH_NORMAL_DP = 1")); + assertFalse("演员卡不允许再自己画白色焦点描边(会与 foreground 双重描边)", + cast.contains("STROKE_FOCUSED")); + + String recommendation = read("app/src/leanback/java/com/fongmi/android/tv/ui/presenter/TmdbRecommendationPresenter.java"); + assertTrue("推荐卡此前没有任何焦点描边,必须补上统一焦点环", + recommendation.contains("FOCUS_WIDTH_DP = 3") + && recommendation.contains("applyUnifiedFocus") + && recommendation.contains("R.color.tv_item_focus_ring") + && recommendation.contains("R.color.tv_item_normal_stroke")); + } + + @Test + public void episodeHeaderToolsUseTheSameChipSelectorAsRouteAndSegmentRows() throws Exception { + String body = read(VIDEO_LAYOUT); + int header = body.indexOf("android:id=\"@+id/episodeHeader\""); + int headerEnd = body.indexOf("", " ")); + } + + 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/StateSurfaceScrimContractTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/StateSurfaceScrimContractTest.java new file mode 100644 index 0000000000..fb05aa85fd --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/StateSurfaceScrimContractTest.java @@ -0,0 +1,104 @@ +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 java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * 空态/错误态浮层的 scrim 契约(设计文档 §5.3 / §5.4)。 + * + *

§5.3 要求「图片上的文字必须有 scrim/gradient」,§5.4 要求「所有内容表面必须有不透明或 + * 可控的 surface 层」。空态/错误态的文字此前**直接压在壁纸(一张图)上、没有任何 scrim**。 + * + *

靠调色无解:实测壁纸合成亮度 L=0.1217/0.1476/0.2019,正文(16sp)门槛 4.5:1, + * 80% 白文字只有 4.61/4.08/3.30:1;**即使换成纯白,最亮壁纸上限也只有 4.17:1**。 + * 加 scrim 后(overlay_dark ≈32% 黑)为 6.02/5.43/4.53:1,全部达标。 + */ +public class StateSurfaceScrimContractTest { + + private static final String PROGRESS_LAYOUT = + "app/src/main/java/com/fongmi/android/tv/ui/custom/ProgressLayout.java"; + private static final String STYLES = "app/src/main/res/values/webhtv_styles.xml"; + private static final String TOKENS = "app/src/main/res/values/webhtv_tokens.xml"; + private static final String NIGHT_TOKENS = "app/src/main/res/values-night/webhtv_tokens.xml"; + + /** 空态/错误态必须带 scrim,否则文字直接压在壁纸上(违反 §5.3/§5.4)。 */ + @Test + public void emptyAndErrorStatesCarryAScrim() throws Exception { + String source = read(PROGRESS_LAYOUT); + String scrim = methodBody(source, "private void applyScrim(State state)"); + assertTrue("空态/错误态必须设 scrim", scrim.contains("setBackgroundColor(themeColor(R.attr.webhtvColorOverlayDark))")); + assertTrue("必须同时覆盖 EMPTY 与 ERROR", scrim.contains("State.EMPTY") && scrim.contains("State.ERROR")); + assertTrue("其余状态必须清掉背景", scrim.contains("setBackground(null)")); + assertTrue("必须在状态切换处调用", source.contains("applyScrim(state)")); + } + + /** + * PROGRESS 态刻意不加 scrim:它是短暂的加载瞬间,加 scrim 会让页面闪一下暗。 + * 这条是**边界守卫**——防止有人"顺手"把它也加进去。 + */ + @Test + public void progressStateDeliberatelyHasNoScrim() throws Exception { + String scrim = methodBody(read(PROGRESS_LAYOUT), "private void applyScrim(State state)"); + assertFalse("PROGRESS 不得进入加 scrim 的分支", scrim.contains("State.PROGRESS")); + assertFalse("CONTENT 不得进入加 scrim 的分支", scrim.contains("State.CONTENT")); + } + + /** + * scrim 角色必须真的接上线。此前 {@code webhtvColorScrim} / {@code webhtvColorOverlayDark} / + * {@code webhtvColorOverlayLight} 三个属性都只声明、从未赋值——任何想用它们的布局都取不到值。 + */ + @Test + public void theScrimRoleIsActuallyWiredToATheme() throws Exception { + assertTrue("必须声明 webhtvColorOverlayDark 属性", + read("app/src/main/res/values/webhtv_attrs.xml").contains("name=\"webhtvColorOverlayDark\"")); + assertTrue("Theme.WebHTV 必须真的给它赋值(只声明不接线等于没有)", + read(STYLES).contains("@color/webhtv_color_overlay_dark")); + String light = read(TOKENS); + assertTrue("浅色表必须有该 token", light.contains("name=\"webhtv_color_overlay_dark\"")); + assertTrue("深色表必须有该 token", read(NIGHT_TOKENS).contains("name=\"webhtv_color_overlay_dark\"")); + } + + /** + * 空态/错误态的描述文字必须仍是正文档(16sp),因为 scrim 是按正文门槛算的。 + * 若有人把字号降到 14sp 以下而仍按 4.5:1 验收,这条会提醒重新核算。 + */ + @Test + public void descriptionStaysBodySizedSoTheScrimMathHolds() throws Exception { + String layout = read("app/src/leanback/res/layout/view_empty.xml"); + int at = layout.indexOf("@+id/description"); + assertTrue("空态必须有 description 槽位", at > 0); + String block = layout.substring(at, Math.min(layout.length(), at + 600)); + assertTrue("描述文字必须是 16sp(scrim 的对比度按正文门槛核算)", block.contains("android:textSize=\"16sp\"")); + assertTrue("描述文字必须用壁纸次级前景角色", block.contains("?attr/webhtvColorOnWallpaperVariant")); + } + + // ------------------------------------------------------------------ helpers + + private static String methodBody(String source, String signature) { + String cleaned = source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//.*$", " "); + int start = cleaned.indexOf(signature); + assertTrue("必须存在方法 " + signature, start > 0); + int open = cleaned.indexOf('{', start); + int depth = 0; + for (int i = open; i < cleaned.length(); i++) { + char c = cleaned.charAt(i); + if (c == '{') depth++; + else if (c == '}' && --depth == 0) return cleaned.substring(start, i + 1); + } + return cleaned.substring(start); + } + + 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/TmdbDetailChipFillTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TmdbDetailChipFillTest.java new file mode 100644 index 0000000000..9568e46980 --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TmdbDetailChipFillTest.java @@ -0,0 +1,143 @@ +package com.fongmi.android.tv.ui.activity; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; +import android.graphics.drawable.LayerDrawable; +import android.graphics.drawable.RippleDrawable; +import android.text.TextUtils; +import android.view.ContextThemeWrapper; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.R; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.shape.MaterialShapeDrawable; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.GraphicsMode; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; + +/** + * 用户报告:电视版「线路」和「选集」chip 只剩描边、没有底色,压在亮色剧照上看不清。 + * + *

根因是芯片填充走的通道不同: + *

    + *
  • 「线路」「选集」走 {@code setBackgroundColor},它只把颜色写进当时那个 + * {@code MaterialShapeDrawable} 实例,并不更新 Material 的 {@code backgroundTint} 字段;
  • + *
  • 同一页的「第 N 季」按钮走 {@code setBackgroundTintList},写的是那个持久字段, + * 所以它一直有可见底色。
  • + *
+ * + *

Material 会在 inset 变化({@code setInsetTop/Bottom})、圆角变化或测量等时机 + * 用 {@code backgroundTint} 字段重建背景。此时 {@code setBackgroundColor} 写进旧实例的填充 + * 会被丢掉并退化为全透明,而 {@code setBackgroundTintList} 的填充会保留。 + * + *

本测试用真实渲染像素把这条差异固定成契约:它既证明当前实现(tint 通道)在背景重建后 + * 仍有底色,也证明旧的 {@code setBackgroundColor} 写法会掉色——避免日后有人"简化"回去。 + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, application = App.class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +public class TmdbDetailChipFillTest { + + /** 与 {@code TmdbDetailActivity#createChipButton} 保持一致的构造方式。 */ + private MaterialButton chip(Context context) { + MaterialButton button = new MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle); + button.setText("线路01"); + button.setCheckable(false); + button.setMinHeight(0); + button.setMinimumHeight(0); + button.setInsetTop(0); + button.setInsetBottom(0); + button.setMaxWidth(320); + button.setSingleLine(true); + button.setTextColor(0xFFFFFFFF); + button.setPadding(24, 12, 24, 12); + return button; + } + + private MaterialShapeDrawable fillOf(MaterialButton button) { + Drawable background = button.getBackground(); + if (background instanceof RippleDrawable ripple && ripple.getNumberOfLayers() > 0) { + Drawable layer = ripple.getDrawable(0); + if (layer instanceof InsetDrawable inset) { + Drawable inner = inset.getDrawable(); + if (inner instanceof LayerDrawable layers && layers.getNumberOfLayers() > 1) { + return (MaterialShapeDrawable) layers.getDrawable(1); + } + } + } + return null; + } + + private int fillColor(MaterialButton button) { + MaterialShapeDrawable fill = fillOf(button); + assertNotNull("chip must expose a Material fill drawable", fill); + ColorStateList tint = fill.getTintList(); + return tint == null ? 0 : tint.getDefaultColor(); + } + + /** 真实渲染一个已测量的 chip,返回其填充区域中心像素。 */ + private int renderedPixel(MaterialButton button) { + LinearLayout parent = new LinearLayout(button.getContext()); + parent.addView(button); + parent.measure(View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(120, View.MeasureSpec.EXACTLY)); + parent.layout(0, 0, 600, 120); + Bitmap bitmap = Bitmap.createBitmap(600, 120, Bitmap.Config.ARGB_8888); + parent.draw(new Canvas(bitmap)); + return bitmap.getPixel(button.getLeft() + 6, button.getTop() + button.getHeight() / 2); + } + + private void applyChipFill(MaterialButton button, int color, boolean viaTint) { + if (viaTint) button.setBackgroundTintList(ColorStateList.valueOf(color)); + else button.setBackgroundColor(color); + } + + @Test + public void persistentTintChannelKeepsChipFillThroughMaterialBackgroundRebuild() { + Context context = new ContextThemeWrapper(App.get(), R.style.Theme_App); + int chip = 0xFFEAF0F5; + + MaterialButton button = chip(context); + applyChipFill(button, chip, true); + assertEquals("tint channel must reach the fill immediately", chip, fillColor(button)); + + // Material rebuilds the background on inset changes; this is what dropped the old fill. + button.setInsetTop(0); + button.setInsetBottom(0); + + assertEquals("chip fill must survive a Material background rebuild", chip, fillColor(button)); + assertEquals("rendered chip must stay visibly filled", chip, renderedPixel(button)); + } + + @Test + public void legacySetBackgroundColorLosesItsFillWhenMaterialRebuildsTheBackground() { + Context context = new ContextThemeWrapper(App.get(), R.style.Theme_App); + int chip = 0xFFEAF0F5; + + MaterialButton button = chip(context); + applyChipFill(button, chip, false); + assertEquals("setBackgroundColor reaches the current fill instance", chip, fillColor(button)); + + button.setInsetTop(0); + button.setInsetBottom(0); + + // This is the regression the user reported: the fill is gone and only the stroke remains. + assertNotEquals("setBackgroundColor must not be used for chip fill: the rebuild drops it", + chip, fillColor(button)); + assertEquals("and the fill degrades to fully transparent", 0, fillColor(button)); + } +} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvAppSurfaceFocusRingDeviceTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvAppSurfaceFocusRingDeviceTest.java new file mode 100644 index 0000000000..cd40bc021d --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvAppSurfaceFocusRingDeviceTest.java @@ -0,0 +1,551 @@ +package com.fongmi.android.tv.ui.activity; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.util.TypedValue; + +import androidx.appcompat.view.ContextThemeWrapper; + +import com.fongmi.android.tv.App; +import com.fongmi.android.tv.R; + +import org.junit.After; +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.GraphicsMode; + +/** + * 电视端「应用表面」焦点环的像素级证据(Robolectric 真实栅格化)。 + * + *

用户报告(两张实机截图): + *

TV端上面两行按钮是 蓝色边框,下面的是 白色边框,边框粗细也貌似不一样, + * 请统一风格并受到主题色彩控制
+ * + *

静态契约测试({@code TvFocusRingContractTest})只能证明 XML 文本写对了。本测试把 + * drawable 真正栅格化到 {@link Bitmap},再逐像素量出**实际**的描边厚度与颜色——也就是 + * 用户肉眼看到的那两条边框。因此它能抓出「XML 看着对、渲染出来其实不同」的情况, + * 例如 1.5dp 与 3dp 在不同密度下取整到不同像素数。 + * + *

断言分三条: + *

    + *
  1. 首页两族焦点环(功能按钮 vs 内容卡片)的实测厚度与颜色必须字节相同;
  2. + *
  3. 全部应用表面焦点环的实测厚度必须等于 {@code @dimen/webhtv_focus_ring_width};
  4. + *
  5. 环色必须等于主题 FOCUS 槽,并在浅色/深色两张色板下各自跟随变化(真正的主题可控性)。
  6. + *
+ * + *

说明:{@code ?attr/tvFocusRing} 是编译期静态资源,用户档案覆写在运行期由 + * {@code ThemeBinder} 作用于视图树(另由 {@code TvFocusRingResolutionDeviceTest} 覆盖)。 + * 本测试证明的是「环色来自主题色板」——同一 drawable 在 day / night 两张表下解析出不同颜色。 + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28, application = App.class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +public class TvAppSurfaceFocusRingDeviceTest { + + /** 首页功能按钮行(搜索/历史):adapter_func -> selector_item -> shape_item_focused。 */ + private static final int BUTTON_SELECTOR = R.drawable.selector_item; + /** 首页内容卡片行(最近观看/更新推荐):adapter_vod -> selector_vod -> shape_vod_focused。 */ + private static final int CARD_SELECTOR = R.drawable.selector_vod; + + /** 全部「应用表面」焦点环(宿主为调色板表面或对话框面板)。 */ + private static final int[] APP_SURFACE_RINGS = { + R.drawable.selector_item, + R.drawable.selector_item_round, + R.drawable.selector_vod, + R.drawable.selector_vod_oval, + R.drawable.selector_keyboard, + R.drawable.selector_search_hot_word, + }; + + /** 渲染尺寸:足够大,四条边都不会被裁掉。 */ + private static final int SIZE = 120; + + /** 判定「这个像素属于环」的 alpha 门槛(排除抗锯齿边缘的半透明过渡)。 */ + private static final int OPAQUE = 200; + + @After + public void tearDown() { + RuntimeEnvironment.setQualifiers(""); + } + + private static Context themedContext() { + return new ContextThemeWrapper(RuntimeEnvironment.getApplication(), R.style.Theme_App); + } + + /** 取 selector 的 focused 分支(这些 selector 的 state_focused 都是第一项)。 */ + private static Drawable focusedDrawable(Context context, int resId) { + Drawable drawable = context.getDrawable(resId); + assertTrue(resId + " 必须能解析出 drawable", drawable != null); + if (drawable instanceof StateListDrawable states) { + states.setState(new int[]{android.R.attr.state_focused}); + Drawable current = states.getCurrent(); + assertTrue(resId + " 的 focused 状态必须解析出一个 drawable", current != null); + return current; + } + return drawable; + } + + /** 把 focused 分支栅格化。 */ + private static Bitmap render(Context context, int resId) { + return render(context, resId, SIZE); + } + + private static Bitmap render(Context context, int resId, int size) { + return rasterize(focusedDrawable(context, resId), size); + } + + /** + * 实测环的厚度(像素):从图像中部左侧起,数连续的不透明像素个数。 + * 矩形/圆形的左边框中点都是竖直的直线段,所以这是一段干净的游程。 + */ + private static int ringThicknessPx(Bitmap bitmap) { + int y = SIZE / 2; + int x = 0; + while (x < SIZE && alpha(bitmap, x, y) < OPAQUE) x++; + int run = 0; + while (x < SIZE && alpha(bitmap, x, y) >= OPAQUE) { + run++; + x++; + } + assertTrue("中部左侧必须能找到一段不透明的焦点环(实际为 0)", run > 0); + return run; + } + + /** 实测环的颜色:取环游程中点那个像素。 */ + private static int ringColour(Bitmap bitmap) { + int y = SIZE / 2; + int x = 0; + while (x < SIZE && alpha(bitmap, x, y) < OPAQUE) x++; + int run = 0; + int start = x; + while (x < SIZE && alpha(bitmap, x, y) >= OPAQUE) { + run++; + x++; + } + assertTrue("中部左侧必须能找到一段不透明的焦点环(实际为 0)", run > 0); + return bitmap.getPixel(start + run / 2, y); + } + + private static int alpha(Bitmap bitmap, int x, int y) { + return (bitmap.getPixel(x, y) >>> 24) & 0xFF; + } + + private static int expectedWidthPx(Context context) { + return context.getResources().getDimensionPixelSize(R.dimen.webhtv_focus_ring_width); + } + + /** 主题 FOCUS 槽:{@code ?attr/tvFocusRing} 的取值来源。 */ + private static int focusSlot(Context context) { + TypedValue value = new TypedValue(); + assertTrue("主题必须声明 tvFocusRing", + context.getTheme().resolveAttribute(R.attr.tvFocusRing, value, true)); + if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT) { + return value.data; + } + return context.getColor(value.resourceId); + } + + private static String name(Context context, int resId) { + return context.getResources().getResourceEntryName(resId); + } + + // ------------------------------------------------------------------ R1 两族同款 + + /** + * 用户报告的核心:首页上面两行按钮是蓝框、下面的内容是白框,粗细也不同。 + * 两族实测的厚度与颜色必须完全一致。 + */ + @Test + public void homeFunctionButtonsAndContentCardsRenderTheSameRing() { + Context context = themedContext(); + Bitmap button = render(context, BUTTON_SELECTOR); + Bitmap card = render(context, CARD_SELECTOR); + + assertEquals("首页功能按钮与内容卡片的焦点环厚度必须相同(用户报告粗细不一样)", + ringThicknessPx(button), ringThicknessPx(card)); + assertEquals("首页功能按钮与内容卡片的焦点环颜色必须相同(用户报告蓝色/白色两种)", + ringColour(button), ringColour(card)); + } + + @Test + public void everyAppSurfaceRingRendersAtTheSingleWidthToken() { + Context context = themedContext(); + int expected = expectedWidthPx(context); + for (int resId : APP_SURFACE_RINGS) { + int measured = ringThicknessPx(render(context, resId)); + assertTrue(name(context, resId) + " 的实测焦点环厚度 " + measured + + "px 必须等于 @dimen/webhtv_focus_ring_width (" + expected + "px)", + Math.abs(measured - expected) <= 1); + } + } + + // ------------------------------------------------------------------ R2 主题可控 + + @Test + public void appSurfaceRingsRenderInTheThemeFocusColour() { + Context context = themedContext(); + int slot = focusSlot(context); + assertEquals("焦点环色必须来自 webhtv_color_focus,而不是别的角色", + context.getColor(R.color.webhtv_color_focus), slot); + + for (int resId : new int[]{BUTTON_SELECTOR, CARD_SELECTOR}) { + assertEquals(name(context, resId) + " 的实测焦点环颜色必须等于主题 FOCUS 槽", + slot, ringColour(render(context, resId))); + } + } + + /** + * 真正的「受到主题色彩控制」:同一个 drawable 在浅色表与深色表下必须渲染出不同的环色。 + * 只看 XML 文本无法证明这一点,只有栅格化后取像素才能证明。 + */ + @Test + public void appSurfaceRingsFollowTheThemePaletteAcrossDayAndNight() { + Context day = themedContext(); + int dayColour = ringColour(render(day, CARD_SELECTOR)); + int daySlot = focusSlot(day); + + RuntimeEnvironment.setQualifiers("+night"); + Context night = themedContext(); + int nightColour = ringColour(render(night, CARD_SELECTOR)); + int nightSlot = focusSlot(night); + + assertEquals("浅色表下环色必须等于浅色 FOCUS 槽", daySlot, dayColour); + assertEquals("深色表下环色必须等于深色 FOCUS 槽", nightSlot, nightColour); + assertNotEquals("浅色表与深色表的 FOCUS 槽本身就不同,测试前提必须成立", daySlot, nightSlot); + assertNotEquals("换色板后焦点环颜色必须跟着变,否则主题控制无效", dayColour, nightColour); + + // 回归钉子:修复前这里写死 @color/white,两张表都会渲染成同一个白色。 + assertNotEquals("焦点环不允许再渲染成与调色板无关的固定白色", + 0xFFFFFFFF, nightColour); + } + + /** 环必须完全不透明,否则焦点不可见。 */ + @Test + public void focusedRingRendersFullyOpaque() { + Context context = themedContext(); + for (int resId : new int[]{BUTTON_SELECTOR, CARD_SELECTOR}) { + int colour = ringColour(render(context, resId)); + assertEquals(name(context, resId) + " 的焦点环必须完全不透明,否则焦点不可见", + 0xFF, (colour >>> 24) & 0xFF); + } + } + + /** + * 变异检验:本测试的度量方法必须真的能区分「细环」与「粗环」。 + * 直接渲染 1.5dp 与 3dp 两种描边,实测厚度必须不同——否则上面的断言就是空断言。 + */ + @Test + public void theMeasurementCanActuallyTellThinRingsFromThickOnes() { + Context context = themedContext(); + float density = context.getResources().getDisplayMetrics().density; + + Bitmap thin = renderStroke(context, Math.round(1.5f * density)); + Bitmap thick = renderStroke(context, Math.round(3f * density)); + + int thinPx = ringThicknessPx(thin); + int thickPx = ringThicknessPx(thick); + assertTrue("度量方法必须能区分细环与粗环(实测 thin=" + thinPx + "px thick=" + thickPx + "px)", + thickPx > thinPx); + } + + /** 造一个与生产 drawable 同构的描边,用于度量方法的变异检验。 */ + private static Bitmap renderStroke(Context context, int widthPx) { + android.graphics.drawable.GradientDrawable shape = new android.graphics.drawable.GradientDrawable(); + shape.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); + shape.setCornerRadius(0f); + shape.setStroke(widthPx, 0xFFFFFFFF); + Bitmap bitmap = Bitmap.createBitmap(SIZE, SIZE, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + shape.setBounds(0, 0, SIZE, SIZE); + shape.draw(canvas); + return bitmap; + } + + // ------------------------------------------------------------ R4 卡片环圆角对齐卡片自身圆角 + + /** + * 卡片宿主的可见表面圆角:{@code @style/Vod.Grid}(图片上圆角)、{@code shape_vod_name} + * (标题块下圆角)、{@code shape_vod_list}(列表型宿主四角)都是一律 8dp。 + */ + private static final float CARD_SURFACE_RADIUS_DP = 8f; + + /** 角落几何用的栅格尺寸:密度 3 下为 80dp,四条边都远离角落弧,不会被裁掉。 */ + private static final int CORNER_SIZE = 240; + + /** 电视端主档密度:要求严格 0 溢出,最接近 dev1 实机(`wm density`=280 → 2.35px/dp)。 */ + private static final String TV_PRIMARY_DENSITY = "xxhdpi"; + + /** 其余密度的亚像素容差:每角最多 2px;用户报告的缺陷特征 ≥25px,不会漏过。 */ + private static final int SUBPIXEL_LEAK_TOLERANCE_PX = 8; + + /** + * 用户报告(2026-10-10 dev1 实机):「边框在卡片上的圆角部分匹配不完美,感觉是圆角比卡片小, + * 导致卡片的圆角还露出一节了」。 + * + *

实机像素复核(1920x1080 / 密度 280,应用内有效缩放 ≈ 2.35px/dp)量到:卡片表面右边界 + * 396.5px、环外边界 403.46px(环带 7.06px = 3dp),而环的 外边界圆角弧半径 ≈ 21.2px + * ≈ 9dp = 声明 8dp + w/2;卡片自身弧半径 8dp。两者中心都在角上 8–9.5dp 处,沿对角线卡片 + * 弧反而更靠外 1.03px,也就是实机上那 1–2px 米色月牙。栅格化扫描(density 1/1.75/2/2.353/3/4) + * 也复现了同一个方向:声明 8dp 时卡片溢出高数十倍于修复后。 + * + *

本测试不看 XML,直接把真实 {@code selector_vod} 焦点分支与卡片表面栅格化后逐像素验证: + * 卡片不能有任何像素落在环的 外边界之外。断言覆盖电视端实际存在的四档密度 + * (hdpi/xhdpi/xxhdpi/xxxhdpi),因为描边宽度是按整数像素向上取整的,同一 dp 取值在不同 + * 密度下取整结果不同。栅格化扫描实测卡片溢出像素:声明 8dp 在四档下为 25/52/114/181, + * 声明 7dp 在 1.5/2/3/4 四档下为 0(含全量套件下的重跑)。 + * + *

容差:主档密度(xxhdpi,与 dev1 实机 ~2.35px/dp 最接近的档位)要求**严格 0**; + * 其余密度允许每角最多 2px 的亚像素平局(实测 xhdpi 下 5px)——卡片的 16px 弧与环外边界 + * 的 15.5px 弧只差半个像素,栅格化在切线处会把这一点点像素判给任一侧;而用户报告的缺陷 + * 特征是最少 25px,所以这个容差不会让缺陷漏过(变异检验会验证这一点)。 + * + *

密度限定符必须用**绝对**写法({@code "xhdpi"} 而不是 {@code "+xhdpi"}): + * {@code RuntimeEnvironment.setQualifiers("+xhdpi")} 是「在现有限定符上追加」, + * 全量套件里前面别的测试类留下的密度限定符会一起参与解析,使实际密度不确定。 + * + *

说明:真实卡片轮廓是四角 8dp 的圆角矩形(顶部来自图片、底部来自标题块,列表型宿主 + * 四角同圆角),所以这里用统一 8dp 的圆角矩形当卡片表面;单环覆盖全部宿主的前提由 + * {@code TvFocusRingContractTest.cardRingCornerRadiusIsTheHostRadiusMinusHalfTheRingWidth} 钉住。 + */ + @Test + public void cardRingOuterBoundaryContainsEveryCardSurfacePixel() { + for (String density : new String[]{"hdpi", "xhdpi", "xxhdpi", "xxxhdpi"}) { + RuntimeEnvironment.setQualifiers(density); + Context context = themedContext(); + int outside = cardPixelsOutsideRing( + render(context, CARD_SELECTOR, CORNER_SIZE), + cardSurface(context, CARD_SURFACE_RADIUS_DP)); + String where = "(限定符 " + density + ",实际密度 " + + context.getResources().getDisplayMetrics().density + ")"; + if (TV_PRIMARY_DENSITY.equals(density)) { + assertEquals("卡片表面不允许有任何像素落在焦点环外边界之外" + where + + "——这正是用户报告的「卡片的圆角还露出一节」,声明圆角必须取 宿主圆角 - 环宽/2", + 0, outside); + } else { + assertTrue("卡片溢出 " + outside + "px 必须落在亚像素上限 " + SUBPIXEL_LEAK_TOLERANCE_PX + + "px 内" + where, outside <= SUBPIXEL_LEAK_TOLERANCE_PX); + } + } + } + + // ------------------------------------------------------------ R4b 真实宿主(详情页/播放页) + + /** + * 把「环外边界必须包住宿主真实轮廓」这条要求,直接跑在**真实布局**上。 + * + *

与上面 R4 的区别:R4 用代码搭一个 8dp 圆角矩形当卡片表面;这里直接 inflate 生产布局 + * (adapter_episode_card / adapter_tmdb_video / adapter_tmdb_photo / adapter_tmdb_cast / + * dialog_episode_detail 的 stillCard),取它们的真实 background 作为「宿主表面」,再把真实环 + * 栅格化后逐像素判定。宿主圆角、内缩、自带描边全部是生产值,没有任何测试侧假设。 + * + *

宿主与环的对应关系(详情页/播放页,2026-10-10 新增): + *

    + *
  • {@code adapter_episode_card} ↔ {@code selector_episode_card}(8dp 宿主)
  • + *
  • {@code adapter_tmdb_video} / {@code adapter_tmdb_photo} ↔ {@code selector_tmdb_media_focus}(8dp)
  • + *
  • {@code adapter_tmdb_cast} ↔ {@code selector_tmdb_cast_focus}(12dp)
  • + *
  • {@code dialog_episode_detail#stillCard} ↔ {@code selector_episode_photo}(8dp)
  • + *
  • 播放页画面框 {@code @id/video} 是直角 FrameLayout ↔ {@code selector_video}(0dp)
  • + *
+ * + *

容差与 R4 同口径:主档密度严格 0,其余档位允许每角 2px 的亚像素平局。 + */ + @Test + public void realDetailAndPlayerHostsContainTheirFocusRingCorner() { + for (String density : new String[]{"hdpi", "xhdpi", "xxhdpi", "xxxhdpi"}) { + RuntimeEnvironment.setQualifiers(density); + Context context = themedContext(); + float d = context.getResources().getDisplayMetrics().density; + + // 1) 详情页选集卡:CardView 8dp + selector_episode_card + checkHost(context, "adapter_episode_card/selector_episode_card", density, + R.layout.adapter_episode_card, 0, R.drawable.selector_episode_card, + (int) (280 * d), (int) (160 * d)); + // 2) 详情页相关视频 / 剧照:MaterialCardView 8dp + selector_tmdb_media_focus + checkHost(context, "adapter_tmdb_video/selector_tmdb_media_focus", density, + R.layout.adapter_tmdb_video, 0, R.drawable.selector_tmdb_media_focus, + (int) (276 * d), (int) (156 * d)); + checkHost(context, "adapter_tmdb_photo/selector_tmdb_media_focus", density, + R.layout.adapter_tmdb_photo, 0, R.drawable.selector_tmdb_media_focus, + (int) (220 * d), (int) (124 * d)); + // 3) 详情页演员卡:MaterialCardView 12dp + selector_tmdb_cast_focus + checkHost(context, "adapter_tmdb_cast/selector_tmdb_cast_focus", density, + R.layout.adapter_tmdb_cast, 0, R.drawable.selector_tmdb_cast_focus, + (int) (90 * d), (int) (150 * d)); + // 4) 剧照详情卡:CardView 8dp + selector_episode_photo + checkHost(context, "dialog_episode_detail#stillCard/selector_episode_photo", density, + R.layout.dialog_episode_detail, R.id.stillCard, R.drawable.selector_episode_photo, + (int) (400 * d), (int) (225 * d)); + // 5) 播放页画面框:直角 FrameLayout + selector_video(声明 0dp) + checkHost(context, "activity_video#video/selector_video", density, + R.layout.activity_video, R.id.video, R.drawable.selector_video, + (int) (400 * d), (int) (225 * d)); + } + } + + /** + * inflate 真实宿主(可指定内部 id),用它的 background 当表面,与真实环逐像素比。 + * 表面为 null 时(直角宿主如播放页画面框)改用「整块不透明矩形」当表面, + * 这样仍能抓到「环把直角宿主包出圆角」的错位。 + */ + private static void checkHost(Context context, String label, String density, int layoutRes, int innerId, + int ringRes, int w, int h) { + android.view.View root = android.view.LayoutInflater.from(context).inflate(layoutRes, null, false); + android.view.View host = innerId == 0 ? root : root.findViewById(innerId); + assertTrue(label + " 必须能在真实布局里找到宿主", host != null); + host.measure(android.view.View.MeasureSpec.makeMeasureSpec(w, android.view.View.MeasureSpec.EXACTLY), + android.view.View.MeasureSpec.makeMeasureSpec(h, android.view.View.MeasureSpec.EXACTLY)); + host.layout(0, 0, w, h); + + Drawable ring = focusedDrawable(context, ringRes); + Bitmap surfaceBitmap = hostSurface(host, w, h); + int outside = cardPixelsOutsideRing(rasterize(ring, w, h), surfaceBitmap, w, h); + + String where = label + "(限定符 " + density + ",实际密度 " + + context.getResources().getDisplayMetrics().density + ")"; + if (TV_PRIMARY_DENSITY.equals(density)) { + assertEquals("宿主表面不允许有任何像素落在焦点环外边界之外 " + where + + "——环声明圆角必须取 宿主圆角 − 环宽/2", 0, outside); + } else { + assertTrue(where + " 的溢出 " + outside + "px 必须落在亚像素上限 " + + SUBPIXEL_LEAK_TOLERANCE_PX + "px 内", outside <= SUBPIXEL_LEAK_TOLERANCE_PX); + } + } + + /** + * 取宿主的**真实**可见轮廓:{@code MaterialCardView} 不暴露 {@code getBackground()} + * (它用内部 ShapeThemingDrawable),必须从 {@code getShapeAppearanceModel()} 重建; + * 其它视图直接用 background;直角宿主(如播放页画面框)退化为整块矩形。 + */ + private static Bitmap hostSurface(android.view.View host, int w, int h) { + Drawable background = host.getBackground(); + if (background != null) return rasterize(background, w, h); + if (host instanceof com.google.android.material.card.MaterialCardView card) { + com.google.android.material.shape.MaterialShapeDrawable shape = + new com.google.android.material.shape.MaterialShapeDrawable(card.getShapeAppearanceModel()); + shape.setTint(0xFFF3E7D9); + return rasterize(shape, w, h); + } + return solidRect(w, h); + } + + /** 直角宿主的表面:整块不透明矩形。 */ + private static Bitmap solidRect(int w, int h) { + Bitmap bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + canvas.drawColor(0xFFF3E7D9); + return bitmap; + } + + /** + * 变异检验:把用户报告时的旧取值(声明 8dp + 3dp 环宽)搭回来,上面的度量必须能抓到卡片 + * 圆角溢出,否则 {@code cardRingOuterBoundaryContainsEveryCardSurfacePixel} 就是空断言。 + */ + @Test + public void theCornerMeasurementCatchesTheReportedCardCornerBleed() { + RuntimeEnvironment.setQualifiers("xxhdpi"); + Context context = themedContext(); + + int outside = cardPixelsOutsideRing( + renderLegacyCardRing(context, 8f, 3f), + cardSurface(context, CARD_SURFACE_RADIUS_DP)); + + assertTrue("度量方法必须能抓到「环外边界圆角大于卡片圆角」时卡片露出的月牙" + + "(实测落在环外的卡片像素 = " + outside + "px)", + outside > 0); + } + + /** 栅格化一张卡片表面:统一圆角的圆角矩形(几何与 ShapeableImageView 的 8dp 轮廓等价)。 */ + private static Bitmap cardSurface(Context context, float radiusDp) { + float density = context.getResources().getDisplayMetrics().density; + android.graphics.drawable.GradientDrawable surface = new android.graphics.drawable.GradientDrawable(); + surface.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); + surface.setCornerRadius(radiusDp * density); + surface.setColor(0xFFF3E7D9); + return rasterize(surface, CORNER_SIZE); + } + + /** 用户报告时的旧卡片环:声明圆角 8dp(= 宿主圆角,没减 w/2)、环宽 3dp。 */ + private static Bitmap renderLegacyCardRing(Context context, float radiusDp, float widthDp) { + float density = context.getResources().getDisplayMetrics().density; + android.graphics.drawable.GradientDrawable ring = new android.graphics.drawable.GradientDrawable(); + ring.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE); + ring.setCornerRadius(radiusDp * density); + ring.setStroke(Math.round(widthDp * density), focusSlot(context)); + return rasterize(ring, CORNER_SIZE); + } + + private static Bitmap rasterize(Drawable drawable, int size) { + return rasterize(drawable, size, size); + } + + private static Bitmap rasterize(Drawable drawable, int width, int height) { + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, width, height); + drawable.draw(canvas); + return bitmap; + } + + /** + * 数出有多少卡片像素落在环的 外边界之外。做法:从图像四边向内扩散所有「非环」像素, + * 扩散得到的就是环外侧的区域(环内部被不透明环带封住,扩散不进去)。 + */ + private static int cardPixelsOutsideRing(Bitmap ring, Bitmap card) { + return cardPixelsOutsideRing(ring, card, CORNER_SIZE, CORNER_SIZE); + } + + /** 同上,但接受任意尺寸(详情页/播放页的真实宿主不是正方形)。 */ + private static int cardPixelsOutsideRing(Bitmap ring, Bitmap card, int width, int height) { + int total = width * height; + boolean[] cardPixel = new boolean[total]; + boolean[] ringPixel = new boolean[total]; + boolean[] outside = new boolean[total]; + int[] queue = new int[total]; + int head = 0; + int tail = 0; + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int at = y * width + x; + ringPixel[at] = alpha(ring, x, y) >= OPAQUE; + cardPixel[at] = alpha(card, x, y) >= OPAQUE; + boolean border = x == 0 || y == 0 || x == width - 1 || y == height - 1; + if (border && !ringPixel[at] && !outside[at]) { + outside[at] = true; + queue[tail++] = at; + } + } + } + + while (head < tail) { + int at = queue[head++]; + int x = at % width; + int y = at / width; + for (int[] step : new int[][]{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) { + int nx = x + step[0]; + int ny = y + step[1]; + if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue; + int next = ny * width + nx; + if (ringPixel[next] || outside[next]) continue; + outside[next] = true; + queue[tail++] = next; + } + } + + int leaked = 0; + for (int at = 0; at < total; at++) { + if (cardPixel[at] && outside[at]) leaked++; + } + return leaked; + } +} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvCardBadgeContrastTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvCardBadgeContrastTest.java new file mode 100644 index 0000000000..3f323dc2a8 --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvCardBadgeContrastTest.java @@ -0,0 +1,211 @@ +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 java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * TV 卡片角标(进度 / 片源 / 年份)的语义与可读性契约。 + * + *

改动前,这三个角标是 leanback 独有的裸十六进制:{@code #177535}、{@code #F44336}、 + * {@code #2196F3},且都带 {@code android:alpha="0.7"}。手机端同一组角标早就是语义色 + * ({@code ?attr/colorSecondaryContainer} / {@code ?attr/colorErrorContainer} / + * {@code ?attr/colorSurfaceVariant}),只有 TV 端落后。 + * + *

透明度带来的问题比"没走 token"更严重:角标上的文字恒为 + * {@code ?attr/webhtvColorOnWallpaper}(纯白),而 0.7 的底色叠在**浅色海报**上会被提亮, + * 白字对比度跌到约 2.0–3.7:1。海报内容不受控,所以这个组合在真实内容上是不可读的。 + * + *

{@code LeanbackForegroundContrastTest} 抓不到它:那里的 {@code luminance()} 会丢弃 + * alpha 通道,把半透明底色当成不透明来算,于是 {@code #177535} 被判定为"深色表面,通过"。 + * 本测试补上这一步——**把角标底色按 alpha 合成到最坏底(纯白海报)上再算对比度**, + * 因此将来有人重新引入透明度,这里会先失败。 + * + *

角标刻意用直接的 {@code @color} 引用而不是 {@code ?attr}:可读性依赖"底色足够暗", + * 做成可被主题改写的角色就等于放弃这个保证;同时 {@code ?attr} 填充会被 + * {@code LeanbackForegroundContrastTest} 判为"调色板跟随背景(日间为浅色)"而误报。 + */ +public class TvCardBadgeContrastTest { + + private static final String TOKENS = "app/src/main/res/values/webhtv_tokens.xml"; + private static final String TOKENS_NIGHT = "app/src/main/res/values-night/webhtv_tokens.xml"; + private static final String ADAPTER = "app/src/leanback/res/layout/adapter_vod.xml"; + private static final String LEANBACK_COLOR = "app/src/leanback/res/color/"; + + /** 角标上的文字恒为纯白({@code ?attr/webhtvColorOnWallpaper} 两张表同值)。 */ + private static final String BADGE_FOREGROUND = "#FFFFFF"; + + /** 正文门槛;角标文字是 14sp,不属于大字号豁免。 */ + private static final double MIN_CONTRAST = 4.5; + + /** 角标文件名(不含 .xml) → { 应引用的 token, 对应布局里的 id }。 */ + private static final Map BADGES = new LinkedHashMap<>(); + + static { + BADGES.put("bg_remark", new String[]{"webhtv_color_badge_progress", "playback"}); + BADGES.put("bg_site", new String[]{"webhtv_color_badge_site", "site"}); + BADGES.put("bg_year", new String[]{"webhtv_color_badge_year", "year"}); + } + + private static final Pattern COLOR_ENTRY = Pattern.compile("([^<]+)"); + private static final Pattern SELECTOR_ITEM = Pattern.compile("]*)/?>"); + private static final Pattern ATTRIBUTE = Pattern.compile("android:(alpha|color)=\"([^\"]+)\""); + private static final Pattern RAW_HEX = Pattern.compile("#[0-9A-Fa-f]{3,8}"); + + // ------------------------------------------------------------------ 语义 + + @Test + public void cardBadgesUseTokensInsteadOfRawHex() throws Exception { + for (Map.Entry badge : BADGES.entrySet()) { + String name = badge.getKey(); + String token = badge.getValue()[0]; + String source = read(LEANBACK_COLOR + name + ".xml"); + assertFalse(name + " 不允许再出现裸十六进制", + RAW_HEX.matcher(withoutComments(source)).find()); + assertTrue(name + " 必须引用 @" + token, source.contains("@color/" + token)); + assertFalse(name + " 不应再带透明度:半透明底色叠在浅色海报上会让白字跌破 4.5:1", + withoutComments(source).contains("android:alpha")); + assertFalse(name + " 不得改成 ?attr 角色:那会让可读性依赖主题取值", + withoutComments(source).contains("?attr/")); + } + } + + @Test + public void badgeTokensAreDeclaredInBothPalettesWithTheSameValue() throws Exception { + String light = read(TOKENS); + String night = read(TOKENS_NIGHT); + for (String[] badge : BADGES.values()) { + String token = badge[0]; + String lightValue = tokenValue(light, token); + assertTrue(token + " 必须声明在日间表", lightValue != null); + assertEquals(token + " 两张表必须同值:角标画在海报上,与调色板无关", + lightValue, tokenValue(night, token)); + } + } + + // ------------------------------------------------------------------ 可读性 + + /** + * 角标白字必须在**任意海报**上都达标。 + * + *

做法是按角标色表里声明的 alpha,把底色合成到最坏底(纯白海报)上再算对比度; + * 角标一旦不透明,合成结果就是它自身,对比度与海报无关。 + */ + @Test + public void badgeTextStaysReadableOverAnyPoster() throws Exception { + String tokens = read(TOKENS); + for (Map.Entry badge : BADGES.entrySet()) { + String name = badge.getKey(); + String token = badge.getValue()[0]; + String literal = tokenValue(tokens, token); + assertTrue(token + " 必须是可直接解析的十六进制取值", literal != null && literal.startsWith("#")); + + double alpha = declaredAlpha(read(LEANBACK_COLOR + name + ".xml")); + double ratio = contrast(BADGE_FOREGROUND, hex(compositeOver(literal, alpha, 0xFFFFFF))); + + assertTrue(name + " 的白字在纯白海报上只有 " + + String.format(Locale.ROOT, "%.2f:1", ratio) + + "(alpha=" + alpha + "),低于 " + MIN_CONTRAST + ":1", + ratio >= MIN_CONTRAST); + } + } + + /** + * 角标文字必须走壁纸前景角色。 + * + *

如果哪天把角标文字换成调色板跟随的角色(例如 {@code ?attr/colorOnSurface}), + * 上面那条"白字对深色角标"的保证就不再成立,这里先把配对钉住。 + */ + @Test + public void badgeLabelsUseTheWallpaperForegroundRole() throws Exception { + String layout = read(ADAPTER); + for (String[] badge : BADGES.values()) { + assertEquals(badge[1] + " 角标文字必须走 ?attr/webhtvColorOnWallpaper", + "?attr/webhtvColorOnWallpaper", textColourOf(layout, badge[1])); + } + } + + // ------------------------------------------------------------------ helpers + + private static String tokenValue(String tokens, String token) { + Matcher matcher = COLOR_ENTRY.matcher(withoutComments(tokens)); + while (matcher.find()) { + if (matcher.group(1).equals(token)) return matcher.group(2).trim(); + } + return null; + } + + /** 角标色表声明的 alpha;未声明即不透明。 */ + private static double declaredAlpha(String source) { + Matcher item = SELECTOR_ITEM.matcher(withoutComments(source)); + assertTrue("角标色表必须至少有一段 ", item.find()); + Matcher attribute = ATTRIBUTE.matcher(item.group(1)); + while (attribute.find()) { + if ("alpha".equals(attribute.group(1))) return Double.parseDouble(attribute.group(2)); + } + return 1.0; + } + + /** 把 {@code foreground} 按 {@code alpha} 合成到 {@code backdrop} 上(逐通道 sRGB)。 */ + private static int compositeOver(String foreground, double alpha, int backdrop) { + int value = Integer.parseInt(foreground.substring(1), 16); + return (mix((value >> 16) & 0xFF, (backdrop >> 16) & 0xFF, alpha) << 16) + | (mix((value >> 8) & 0xFF, (backdrop >> 8) & 0xFF, alpha) << 8) + | mix(value & 0xFF, backdrop & 0xFF, alpha); + } + + private static int mix(int channel, int backdrop, double alpha) { + return (int) Math.round(alpha * channel + (1 - alpha) * backdrop); + } + + private static String hex(int value) { + return String.format("#%06X", value & 0xFFFFFF); + } + + private static double contrast(String first, String second) { + double a = luminance(first); + double b = luminance(second); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + } + + private static double luminance(String hex) { + String digits = hex.startsWith("#") ? hex.substring(1) : hex; + if (digits.length() == 8) digits = digits.substring(2); + double[] channels = new double[3]; + for (int i = 0; i < 3; i++) { + double value = Integer.parseInt(digits.substring(i * 2, i * 2 + 2), 16) / 255.0; + channels[i] = value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4); + } + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; + } + + private static String textColourOf(String layout, String id) { + Matcher block = Pattern.compile("@\\+id/" + Pattern.quote(id) + "\"(.*?)(?:/>|", " "); + } + + 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/TvContentInsetContractTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvContentInsetContractTest.java new file mode 100644 index 0000000000..50593ef6c9 --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvContentInsetContractTest.java @@ -0,0 +1,100 @@ +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; + +/** + * 电视端内容区必须等于整块窗口:底部不允许再出现整条黑带。 + * + *

实机证据(模拟器 5554,1920x1080/280dpi):该屏带底部 overscan 90px + * ({@code dumpsys window displays} → {@code overscan (0,0,0,90)}),系统把这条内边距 + * 下发给窗口。AppCompat 的 decor 里有两层各自应用了一次 fitsSystemWindows 内边距 + * (外层 window content 与内层 {@code android:id/content}),于是内容区被扣掉 + * 2×90=180px —— 实测 {@code android:id/content} 只有 {@code 0,0-1920,900} 而窗口是 + * {@code 0,0-1920,1080};同一块屏上 launcher 的内容区是 990(只扣一次)。表现就是 + * 首页卡片标题被齐腰切断、{@code 900..1080} 整条是窗口背景黑。 + * + *

修复走 leanback {@code BaseActivity#bindFullWindowContent()}:在 DecorView 上装 + * "只消费、不加内边距"的监听器,inset 不再下发给任何一层,内容铺满窗口。 + * 本守卫锁住三件事,防止以后回退或被复制到手机端: + *

    + *
  1. leanback 基类确实按上面的方式消费 inset;
  2. + *
  3. 它没有把 inset 转成 padding/margin(那就又变成黑带);
  4. + *
  5. 手机端基类不消费 inset(手机需要系统栏内边距,行为必须分开)。
  6. + *
+ */ +public class TvContentInsetContractTest { + + private static final String LEANBACK_BASE = "app/src/leanback/java/com/fongmi/android/tv/ui/base/BaseActivity.java"; + private static final String MOBILE_BASE = "app/src/mobile/java/com/fongmi/android/tv/ui/base/BaseActivity.java"; + + @Test + public void leanbackActivitiesFillTheWholeWindow() throws Exception { + String source = read(LEANBACK_BASE); + + assertTrue("电视端必须有一个统一的内容铺满窗口入口", + source.contains("private void bindFullWindowContent()")); + assertTrue("setContentView 之后必须立即调用该入口", + source.contains("setContentView(content);\n bindFullWindowContent();")); + assertTrue("必须显式关闭 decor 的 fitsSystemWindows 布局(否则系统仍会把内容压进安全区)", + source.contains("WindowCompat.setDecorFitsSystemWindows(getWindow(), false)")); + assertTrue("必须在 DecorView 上只消费 inset,不再向下分发", + source.contains("ViewCompat.setOnApplyWindowInsetsListener(getWindow().getDecorView()") + && source.contains("WindowInsetsCompat.CONSUMED")); + assertTrue("内层 android:id/content 不能再自行应用一次内边距", + source.contains("decorContent.setFitsSystemWindows(false)")); + } + + @Test + public void theInsetConsumerNeverTurnsInsetsIntoPadding() throws Exception { + String source = sourceOfMethod(read(LEANBACK_BASE), "bindFullWindowContent"); + + // 正向锚点:方法体必须真的被切出来,否则下面两条否定断言会对着空串空转。 + assertTrue("必须能定位到 bindFullWindowContent 的方法体", + source.contains("void bindFullWindowContent()") + && source.contains("setDecorFitsSystemWindows")); + assertFalse("消费 inset 后不允许再用 bottom inset 设置 padding(那会把黑带换个形式留下)", + source.contains("getSystemWindowInsetBottom") + || source.contains("Type.systemBars()") + || source.contains("setPadding(")); + } + + @Test + public void mobileKeepsItsSystemBarInsets() throws Exception { + String source = read(MOBILE_BASE); + + assertFalse("手机端不能被改成消费 inset:状态栏/导航栏区域会盖住内容", + source.contains("WindowInsetsCompat.CONSUMED")); + assertFalse("手机端不能复用电视端的铺满窗口入口", + source.contains("bindFullWindowContent")); + } + + private static String sourceOfMethod(String source, String name) { + // 必须从方法声明起切,不能从调用点起:否则先撞上 javadoc 里的 {@link ...} 花括号, + // 返回的是一段注释片段,否定断言就永远对着空串空转。 + int start = source.indexOf("void " + name + "("); + if (start < 0) return ""; + int brace = source.indexOf('{', start); + if (brace < 0) return ""; + int depth = 0; + for (int i = brace; i < source.length(); i++) { + char c = source.charAt(i); + if (c == '{') depth++; + else if (c == '}' && --depth == 0) return source.substring(start, i + 1); + } + return source.substring(start); + } + + private static String read(String path) throws Exception { + Path root = Files.exists(Path.of(path)) ? Path.of(".") : Path.of(".."); + // 仓库里的 Java 源码是 CRLF,多行断言必须先归一化,否则永远匹配不上。 + return new String(Files.readAllBytes(root.resolve(path)), StandardCharsets.UTF_8) + .replace("\r\n", "\n"); + } +} diff --git a/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvErrorStateContractTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvErrorStateContractTest.java new file mode 100644 index 0000000000..bb12f8c378 --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvErrorStateContractTest.java @@ -0,0 +1,380 @@ +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 java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * 电视端「错误态」的规格契约。 + * + *

依据是本仓库自己的设计文档 §9.9:「错误态:错误色 + 原因 + 重试动作」。 + * + *

改动前 TV 上没有任何错误态:配置加载失败只弹一个会消失的吐司(`Notify.show`), + * 然后 `showContent()` 留一个没有任何内容源的空首页——用户既不知道原因,也没有重试入口; + * 设置页则把 `error_config_get` 写进普通的取值 TextView,与正常值**颜色完全相同**。 + * + *

本测试同时把该角色在真机上实测的对比度钉住(见 {@link #MEASURED_*})。数字来自 + * 192.168.50.3:5561(1920×1080 / 280dpi)的壁纸与行色带取样,取的是**最不利**样本, + * 而不是平均值——平均值会掩盖最亮处的问题。 + */ +public class TvErrorStateContractTest { + + private static final String TOKENS = "app/src/main/res/values/webhtv_tokens.xml"; + private static final String TOKENS_NIGHT = "app/src/main/res/values-night/webhtv_tokens.xml"; + private static final String ATTRS = "app/src/main/res/values/webhtv_attrs.xml"; + private static final String STYLES = "app/src/main/res/values/webhtv_styles.xml"; + private static final String PROGRESS_LAYOUT = + "app/src/main/java/com/fongmi/android/tv/ui/custom/ProgressLayout.java"; + private static final String HOME = "app/src/leanback/java/com/fongmi/android/tv/ui/activity/HomeActivity.java"; + private static final String ENHANCE = + "app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingEnhanceActivity.java"; + private static final String AD = + "app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java"; + private static final String KEEP = + "app/src/leanback/java/com/fongmi/android/tv/ui/activity/KeepActivity.java"; + + /** 实机实测的最不利底材亮度(sRGB 相对亮度)。 */ + private static final double MEASURED_WALLPAPER_WORST = 0.2019; + private static final double MEASURED_BAND_WORST = 0.1506; + + private static final String ERROR_ROLE = "webhtv_color_on_wallpaper_error"; + private static final int ERROR_VALUE = 0xFFDAD6; + /** 错误取值的实心深色底(TV-6 底材改造)。 */ + private static final String ERROR_BG_ROLE = "webhtv_color_error_value_bg"; + private static final int ERROR_BG_VALUE = 0x4A3438; + + // ------------------------------------------------------------------ 角色本身 + + @Test + public void errorRoleIsPaletteIndependentAndSharedByBothTables() throws Exception { + assertEquals("错误前景色在两个调色板里必须同值(壁纸在两种模式下都是深色)", + tokenValue(TOKENS), tokenValue(TOKENS_NIGHT)); + assertEquals("取值必须与注释记录的 #FFDAD6 一致", ERROR_VALUE, tokenValue(TOKENS)); + } + + @Test + public void errorRoleIsWiredAsItsOwnAttributeNotBorrowingColorError() throws Exception { + assertTrue("必须声明 webhtvColorOnWallpaperError", + read(ATTRS).contains("webhtvColorOnWallpaperError")); + assertTrue("必须在 Theme.WebHTV 里接线", + read(STYLES).contains("webhtvColorOnWallpaperError\">@color/" + ERROR_ROLE)); + // 关键点:不能用跟随调色板的 colorError——浅色表的深红压在深色壁纸上不可读。 + for (String source : new String[]{read(ATTRS), read(STYLES)}) { + assertFalse("错误前景色不得复用 colorError(它是跟随调色板的角色)", + source.contains("webhtvColorOnWallpaperError\">?attr/colorError")); + } + } + + /** + * 稳定满足的是**大字号**门槛:错误态标题按 §6.2 属 Display 档,门槛 3:1。 + */ + @Test + public void errorRoleMeetsTheLargeTextFloorOnEveryMeasuredSubstrate() { + double luminance = luminance(ERROR_VALUE); + double onBand = contrast(luminance, MEASURED_BAND_WORST); + double onWallpaper = contrast(luminance, MEASURED_WALLPAPER_WORST); + assertTrue("对行色带最亮处应满足大字号 3:1,实测 " + round(onBand), onBand >= 3.0); + assertTrue("对壁纸最亮处应满足大字号 3:1,实测 " + round(onWallpaper), onWallpaper >= 3.0); + } + + /** + * 如实钉住**正文**对比度,包括那个达不到 AA 的缺口。 + * + *

壁纸/色带是中亮度(L≈0.13–0.15),任何带明显红相的前景色要达到正文门槛 4.5:1 + * 就必须接近纯白(L≥0.86),那时"错误色"在观感上已与普通白字无异、失去语义。 + * 所以当前取值停在 4.05–4.50:1。这里断言的是"不许再低",而不是"已经达标"—— + * 后者是假的,写进去只会掩盖问题。 + */ + @Test + public void errorRoleKeepsItsDocumentedBodyContrastFloor() { + double luminance = luminance(ERROR_VALUE); + double onBand = contrast(luminance, MEASURED_BAND_WORST); + assertTrue("正文对比度不得低于已记录的 4.0:1(当前实测 " + round(onBand) + + ",正文门槛 4.5:1,缺口与替代方案见 tokens 注释与任务文档 §7)", onBand >= 4.0); + } + + // ------------------------------------------------------------------ 错误态本身 + + @Test + public void progressLayoutExposesAnErrorStateWithReasonAndRetry() throws Exception { + String source = read(PROGRESS_LAYOUT); + assertTrue("状态机必须有 ERROR", source.contains("ERROR")); + assertTrue("必须提供 showError", source.contains("public void showError(")); + assertTrue("错误标题必须用错误角色", source.contains("R.attr.webhtvColorOnWallpaperError")); + assertTrue("错误态必须隐藏空态插画(空箱子表达的是'没有内容',用在错误上会误导)", + source.contains("R.id.illustration")); + assertTrue("重试再次失败要能刷新原因文案", + source.contains("if (mState == State.ERROR) applyErrorContent();")); + } + + @Test + public void emptyStateLayoutsExposeTheIllustrationSlotForTheErrorState() throws Exception { + for (String layout : new String[]{ + "app/src/leanback/res/layout/view_empty.xml", + "app/src/mobile/res/layout/view_empty.xml"}) { + assertTrue(layout + " 必须给插画加 @+id/illustration(错误态要隐藏它)", + read(layout).contains("@+id/illustration")); + } + } + + /** + * 首页配置加载失败必须进入错误态,而不是只弹吐司后留一个空页面。 + */ + @Test + public void homeConfigFailureEntersTheErrorStateWithAWorkingRetry() throws Exception { + String source = read(HOME); + assertTrue("失败回调必须进入错误态", source.contains("showConfigError(msg)")); + assertTrue("错误态标题必须是 error_config_load", + source.contains("R.string.error_config_load")); + assertTrue("必须有重试按钮文案", source.contains("R.string.error_retry")); + assertTrue("重试必须真的重新加载配置", source.contains("initConfig();")); + // 吐司是既有行为,不删。 + assertTrue("应保留原有的吐司提示", source.contains("Notify.show(msg);")); + // 失败后不能再走 showContent(),否则又回到"空页面"。 + int handler = source.indexOf("public void error(String msg)"); + assertTrue("必须存在配置失败回调", handler > 0); + // 注释里会解释"改动前是 showContent()",所以先剥掉注释再查, + // 否则负向断言会命中说明文字(本仓库已踩过两次同类坑)。 + String body = withoutJavaComments(source.substring(handler, source.indexOf("};", handler))); + assertFalse("失败回调不得再调用 showContent()", body.contains("showContent()")); + } + + /** + * 「还没配接口」必须与「加载失败」分开:前者不是失败。 + * + *

重置应用、重装或恢复出厂后的首次启动就是这个状态。改动前 {@code BaseConfig} 把它 + * 当成 {@code error("")} 抛出来,首页显示「内容源加载失败 / 重试」——一个不存在的故障 + * 加一个点了永远没用的按钮(用户实机截图确认:只有标题与重试,没有原因行)。 + */ + @Test + public void missingConfigIsNotTreatedAsAFailure() throws Exception { + String base = read("app/src/main/java/com/fongmi/android/tv/api/config/BaseConfig.java"); + assertFalse("BaseConfig 不得再把'没配地址'当成 error(\"\")", base.contains("callback.error(\"\")")); + assertTrue("没有配置地址必须走 callback.noConfig()", base.contains("callback::noConfig")); + assertTrue("Callback 必须提供 noConfig 钩子", + read("app/src/main/java/com/fongmi/android/tv/impl/Callback.java").contains("public void noConfig()")); + + String home = read(HOME); + assertTrue("HomeActivity 必须实现 noConfig 回调", home.contains("public void noConfig()")); + assertTrue("未配置必须走 noConfig 分支", home.contains("showNoConfig()")); + String showNoConfig = methodBody(home, "private void showNoConfig()"); + // 未配置 = 正常首页:点播/直播/设置这一行是首页列表的第一行,任何铺满首页的状态浮层 + // 都会把它整块隐藏(ProgressLayout 把内容视图置为 INVISIBLE),新装用户就只剩一个按钮。 + assertTrue("未配置必须回到内容态(导航可用)", showNoConfig.contains("showContent()")); + assertFalse("未配置不得再用全屏空态接管首页", showNoConfig.contains("showEmpty()")); + assertFalse("未配置不得调用 showError", showNoConfig.contains("showError(")); + // 导航行只由既有按钮构成:未配置时不再额外插入任何入口。 + String setFunc = methodBody(home, "private void setFunc()"); + assertFalse("未配置不得在导航行里插入额外按钮", setFunc.contains("home_no_config")); + assertFalse("未配置态的主动作不得是重试", showNoConfig.contains("error_retry")); + } + + /** + * 配置在**别的路径**上变得可用后,首页不能继续顶着状态卡。 + * + *

用户反馈:"卸载后重新安装再通过备份恢复也会出现类似情况,必现;杀进程后重进才行"。 + * 恢复备份走的是 {@code RefreshEvent.home()} 而不是重新走首页启动({@code showContent()}), + * 而状态浮层只被启动成功与点重试收掉——于是配置明明已经可用,首页仍停在状态卡上。 + */ + @Test + public void aConfiguredSourceDismissesAStaleStateOverlay() throws Exception { + String home = read(HOME); + String handler = methodBody(home, "public void onRefreshEvent(RefreshEvent event)"); + assertTrue("HOME 刷新时必须检查并收起过期的状态卡", handler.contains("dismissStateIfConfigured()")); + String dismiss = methodBody(home, "private void dismissStateIfConfigured()"); + assertTrue("只有配置真的可用(有站点)时才收起", dismiss.contains("getSites().isEmpty()")); + assertTrue("收起动作必须回到内容态", dismiss.contains("showContent()")); + // 只能收「空态 / 错误态」:加载中(PROGRESS)与内容态都不得被这条顺手收掉, + // 否则 HOME 刷新会把还在转圈的首页直接变成空白。 + assertTrue("必须能识别空态", dismiss.contains("isEmpty()")); + assertTrue("必须能识别错误态", dismiss.contains("isError()")); + } + + /** + * 未配置时宿主不得停在加载中:{@code Callback.noConfig()} 的默认实现必须保留改动前的收尾行为。 + * + *

{@code noConfig()} 是本次新增的**终态**分支,而既有宿主是拿 {@code start()} 与 + * {@code error(String)} 当「加载开始 / 加载结束」的一对在用。默认留空实现,所有没来得及 + * 覆写它的宿主在无配置时就会永远停在加载中。实测可达路径:TV 标题长按走 + * {@code reloadConfig()},它先 {@code progressLayout.showProgress()},加载失败后只有 + * {@code error("")} 能收掉转圈(空消息在 {@code Notify.show} 里直接返回,不会弹空吐司)。 + */ + @Test + public void defaultNoConfigStillTerminatesTheLoadingState() throws Exception { + String callback = read("app/src/main/java/com/fongmi/android/tv/impl/Callback.java"); + String body = methodBody(callback, "public void noConfig()"); + assertTrue("默认实现必须结束加载态(转调 error(\"\")),否则既有宿主会永久停在加载中", + body.contains("error(\"\")")); + // 可达性证据:reloadConfig() 先进加载态,且它的匿名回调只覆写了 error 作为收尾。 + String reload = methodBody(read(HOME), "public void reloadConfig()"); + assertTrue("reloadConfig 必须先进加载态", reload.contains("progressLayout.showProgress()")); + assertTrue("该回调以 error(String) 作为收尾", reload.contains("public void error(String msg)")); + } + + /** + * 设置页的内联错误必须与正常取值**可区分**,且失败过的行在后续刷新成功时能复位。 + */ + /** + * 底材改造(TV-6):错误取值换上一块**实心深底**后,正文必须稳过 4.5:1。 + * + *

这是 TV-5 记录的那个缺口的收口方式——缺口在壁纸/色带这种中亮度底材上无法靠调色 + * 解决(要达标就得接近纯白,错误色随之失去语义),只能改底材。 + */ + @Test + public void errorValueSubstrateGivesTheBodyTextARealMargin() throws Exception { + for (String table : new String[]{TOKENS, TOKENS_NIGHT}) { + Matcher matcher = Pattern.compile("#([0-9A-Fa-f]{6})") + .matcher(read(table)); + assertTrue(table + " 必须声明 " + ERROR_BG_ROLE, matcher.find()); + assertEquals("错误底材必须与明暗无关(它是实心色,不跟随调色板)", + ERROR_BG_VALUE, Integer.parseInt(matcher.group(1), 16)); + } + double onChip = contrast(luminance(ERROR_VALUE), luminance(ERROR_BG_VALUE)); + assertTrue("错误正文在实心底上必须稳过正文门槛 4.5:1,实测 " + round(onChip), onChip >= 4.5); + } + + @Test + public void settingsInlineErrorsUseTheErrorRoleAndRecoverOnSuccess() throws Exception { + for (String path : new String[]{ENHANCE, AD}) { + String source = read(path); + assertTrue(path + " 的 setError 必须使用错误角色", + source.contains("R.attr.webhtvColorOnWallpaperError")); + assertTrue(path + " 的 setError 必须换上错误底材", + source.contains("setBackgroundResource(R.drawable.shape_error_value)")); + assertTrue(path + " 必须把成功路径复位到常规壁纸前景色", + source.contains("R.attr.webhtvColorOnWallpaper)")); + // 复位必须发生在写入摘要的同一条路径上,否则某行失败一次后会一直是红的。 + assertTrue(path + " 的成功路径必须走 setSummary", source.contains("setSummary(view, supplier.get())")); + int reset = source.indexOf("private void setSummary("); + assertTrue(path + " 必须有 setSummary 实现", reset > 0); + String impl = source.substring(reset, source.indexOf("}", reset)); + assertTrue(path + " 的 setSummary 必须复位颜色", + impl.contains("setTextColor(themeColor(R.attr.webhtvColorOnWallpaper))")); + assertTrue(path + " 的 setSummary 必须撤掉错误底材(错误态是临时的)", + impl.contains("setBackground(null)")); + } + } + + /** + * 收藏页点击条目时的配置加载失败必须进入错误态并可重试。 + * + *

TV-5 的文档 §7.3 原本把"其余宿主"泛泛列成历史/收藏/文件三处;本批实际侦察后发现 + * **历史与文件的加载没有失败分支**(本地读取不会抛错),搜索/采集也只有提示性吐司—— + * 唯一真实的页面级远端失败是收藏页的 `loadConfig`。所以这里只钉这一处, + * 不为了凑数量去臆造错误态。 + */ + @Test + public void favoritesConfigLoadFailureEntersTheErrorStateWithARetry() throws Exception { + String source = read(KEEP); + int handler = source.indexOf("public void error(String msg)"); + assertTrue("收藏页必须有配置加载失败回调", handler > 0); + String body = withoutJavaComments(source.substring(handler, source.indexOf("});", handler))); + assertTrue("失败必须进入错误态", body.contains("showError(")); + assertTrue("错误态标题必须是 error_config_load", body.contains("R.string.error_config_load")); + assertTrue("必须有重试按钮文案", body.contains("R.string.error_retry")); + assertTrue("重试必须复用同一条加载路径", body.contains("loadConfig(config, item);")); + assertTrue("应保留原有的吐司提示", body.contains("Notify.show(msg);")); + } + + /** + * `setSummary` 会用 `setBackground(null)` 撤掉错误底材——前提是这些取值 TextView + * **原本就没有背景**。一旦有人给其中任何一个加上背景,复位就会把它一并抹掉。 + * 这里把前提钉住,让那种改动在这里先失败,而不是变成"设置页某个取值突然没底了"。 + */ + @Test + public void summaryTextViewsHaveNoBackgroundSoClearingTheErrorSubstrateIsSafe() throws Exception { + for (String layout : new String[]{ + "app/src/leanback/res/layout/activity_setting_enhance.xml", + "app/src/leanback/res/layout/activity_setting_ad.xml"}) { + String source = read(layout); + Matcher ids = Pattern.compile("@\\+id/(\\w*[Tt]ext\\w*)\"").matcher(source); + int checked = 0; + while (ids.find()) { + String id = ids.group(1); + String block = tagBlockAfter(source, ids.end()); + assertFalse(layout + " 的 " + id + + " 在布局里带了背景:setSummary 的 setBackground(null) 会把它抹掉", + block.contains("android:background=")); + checked++; + } + assertTrue(layout + " 应至少含一个取值 TextView", checked > 0); + } + } + + // ------------------------------------------------------------------ helpers + + /** 取某个 id 匹配位置之后、当前标签结束({@code />} 或 {@code >})之前的属性文本。 */ + private static String tagBlockAfter(String source, int from) { + int end = source.length(); + int selfClosing = source.indexOf("/>", from); + if (selfClosing >= 0) end = Math.min(end, selfClosing); + int closing = source.indexOf(">", from); + if (closing >= 0) end = Math.min(end, closing); + return source.substring(from, end); + } + + private static int tokenValue(String path) throws Exception { + Matcher matcher = Pattern.compile("#([0-9A-Fa-f]{6})") + .matcher(read(path)); + assertTrue(path + " 必须声明 " + ERROR_ROLE, matcher.find()); + return Integer.parseInt(matcher.group(1), 16); + } + + /** sRGB 相对亮度,与 WCAG 2.2 定义一致。 */ + private static double luminance(int rgb) { + double r = channel((rgb >> 16) & 0xFF); + double g = channel((rgb >> 8) & 0xFF); + double b = channel(rgb & 0xFF); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + private static double channel(int value) { + double v = value / 255.0; + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + } + + private static double contrast(double first, double second) { + double hi = Math.max(first, second); + double lo = Math.min(first, second); + return (hi + 0.05) / (lo + 0.05); + } + + private static String round(double value) { + return String.format(java.util.Locale.ROOT, "%.2f:1", value); + } + + /** 剥掉 Java 的行注释与块注释,避免负向断言命中说明文字。 */ + private static String withoutJavaComments(String source) { + return source.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("(?m)//.*$", " "); + } + + /** 取一个方法的完整方法体(含签名),用花括号配平切分。 */ + private static String methodBody(String source, String signature) { + String cleaned = withoutJavaComments(source); + int start = cleaned.indexOf(signature); + assertTrue("必须存在方法 " + signature, start > 0); + int open = cleaned.indexOf('{', start); + assertTrue("方法签名后必须有方法体", open > 0); + int depth = 0; + for (int i = open; i < cleaned.length(); i++) { + char c = cleaned.charAt(i); + if (c == '{') depth++; + else if (c == '}' && --depth == 0) return cleaned.substring(start, i + 1); + } + return cleaned.substring(start); + } + + 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/TvFocusRingContractTest.java b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvFocusRingContractTest.java new file mode 100644 index 0000000000..6cb3102dd9 --- /dev/null +++ b/app/src/testLeanback/java/com/fongmi/android/tv/ui/activity/TvFocusRingContractTest.java @@ -0,0 +1,1482 @@ +package com.fongmi.android.tv.ui.activity; + +import com.fongmi.android.tv.theme.ThemeTokens; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +/** + * 电视版「焦点 = 主题色边框环」契约。 + * + * 用户报告(两张实机截图): + * 1. 关于 WebHomeTV 弹窗的 `检查更新 / 加速源 / 我已知悉` 三个按钮和右下角齿轮图标, + * D-pad 焦点移上去基本看不出来; + * 2. 追更页顶栏 `检查更新/全部已读/显示全部/导入订阅` 与卡片内 + * `继续看/检查/标记已读/提醒开/换源/取消追更` 同样看不出焦点位置。 + * + * 根因是"焦点态只改填充色,而填充色本身就是主题色": + * `ThemeTokens` 冻结色板里 `colorFocus == colorPrimary`(light `#0B57D0` / dark `#A8C7FA`), + * 于是 `dialog_primary_button_bg.xml` 的 focused 与 default 解析结果字节相同, + * `following_button_primary_bg.xml` 的 focused(`webhtv_color_focus`) 与 + * default(`following_accent` = `webhtv_color_primary`) 在 TV 上也字节相同。 + * + * 2026-10-19 第二轮(用户反馈"高亮边框颜色和按钮本身的颜色太接近"):只补环仍然不够。 + * 环色对浅色填充确实有 7.50:1,但环的**外沿**贴在弹窗面板上只有 1.05:1(夜间)/ + * 1.22:1(浅色),看上去是按钮自带的深边而不是焦点高亮;而"既要对浅色填充 ≥3:1、 + * 又要对深色面板 ≥3:1"的环色取值空间是**空集**。所以修法是让**聚焦态填充与默认填充 + * 不同**:聚焦时填充翻成深色 `primaryContainer`,环取配对的浅色 `onPrimaryContainer`, + * 两个方向同时成立,焦点因此有「填充翻转」+「浅色环」两个独立线索。 + * 主按钮专用环色见 `color/focus_ring_primary_container.xml`。 + * + * 本测试把三条要求固化成契约: + * - R1 自带主题色填充的控件,聚焦时必须出现边框环(不能只靠填充变化); + * - R2 电视版只允许一套机制、一个宽度来源; + * - R3 焦点环色必须受主题色板控制(FOCUS 用户槽 / 填充的配对 on-色),不得写死。 + * - R4 主按钮聚焦态填充必须与默认填充可分辨(不能只靠环这一个线索)。 + */ +public class TvFocusRingContractTest { + + private static final String COLORS = "app/src/main/res/values/colors.xml"; + private static final String DIMENS = "app/src/main/res/values/webhtv_dimens.xml"; + private static final String ABOUT_LAYOUT = "app/src/main/res/layout/dialog_about.xml"; + private static final String ABOUT_ICON = "app/src/main/res/drawable/about_primary_icon_button.xml"; + private static final String FOLLOWING_ITEM = "app/src/main/res/layout/item_following.xml"; + private static final String FOLLOWING_LEANBACK = "app/src/leanback/res/layout/activity_following.xml"; + + private static final String RING_PRIMARY = "app/src/main/res/color/focus_ring_primary.xml"; + private static final String RING_PRIMARY_CONTAINER = "app/src/main/res/color/focus_ring_primary_container.xml"; + private static final String RING_SECONDARY = "app/src/main/res/color/focus_ring_secondary.xml"; + private static final String RING_ERROR = "app/src/main/res/color/focus_ring_error.xml"; + + private static final String PRIMARY_BG = "app/src/main/res/color/dialog_primary_button_bg.xml"; + private static final String PRIMARY_TEXT = "app/src/main/res/color/dialog_primary_button_text.xml"; + private static final String VERSION_ITEM = "app/src/main/res/drawable/selector_update_version_item.xml"; + + private static final String WIDTH = "@dimen/webhtv_focus_ring_width"; + + private static final String TOKENS_LIGHT = "app/src/main/res/values/webhtv_tokens.xml"; + private static final String TOKENS_NIGHT = "app/src/main/res/values-night/webhtv_tokens.xml"; + + private static final String LEANBACK_DRAWABLE = "app/src/leanback/res/drawable/"; + + private static final String UPDATE_LEANBACK = "app/src/leanback/res/layout/dialog_update.xml"; + private static final String UPDATE_MOBILE = "app/src/mobile/res/layout/dialog_update.xml"; + private static final String ABOUT_ICON_TINT = "app/src/main/res/color/about_primary_icon_tint.xml"; + + // ------------------------------------------------------------ R3 主题控制 + + @Test + public void focusRingColourIsThemeControlledNotHardCoded() throws Exception { + String colors = read(COLORS); + assertTrue("tvFocusRing 的取值必须接到主题 FOCUS 用户槽,不能再写死十六进制", + colors.contains("@color/webhtv_color_focus")); + assertFalse("tv_item_focus_ring 不允许再出现硬编码黄色", + colors.contains("#FFD166")); + + // 自带主题色填充的控件,环色必须是该填充的配对 on-色:这些角色由主题解析器 + // 从用户 seed 重新生成并做过对比度校验,所以换任何主题环都仍然可见。 + assertTrue("primary 填充的环色必须取配对 on-色", + colors.contains("@color/webhtv_color_on_primary")); + assertTrue("secondary container 填充的环色必须取配对 on-色", + colors.contains("@color/webhtv_color_on_secondary_container")); + assertTrue("error container 填充的环色必须取配对 on-色", + colors.contains("@color/webhtv_color_on_error_container")); + assertFalse("不得再用 on_error 去配 error container 填充(两者仅 1.40:1,环看不见)", + colors.contains("")); + } + + /** + * 环色只能配对**焦点态实际填充色**。这是实机验收暴露出的一类真缺陷: + * `following_button_*_bg` 聚焦时填充会变成 FOCUS 色(冻结色板里 == primary), + * 若环仍取 `on_secondary_container`,环与填充只有 1.33:1,焦点依旧看不见; + * 同理 `?attr/colorErrorContainer` 填充配 `on_error` 只有 1.40:1。 + * + *

逐对计算对比度,取 light / night / leanback 三套生效 token 各验一遍, + * 避免以后换色板或换环色时静默退化到“环贴在近似色上”。门槛取 WCAG 2.2 + * SC 1.4.11 非文本对比度的 3:1。 + */ + @Test + public void everyRingColourKeepsNonTextContrastAgainstItsFocusFill() throws Exception { + // {说明, 焦点态填充 token, 环色别名} + String[][] pairs = { + {"primary / 焦点填充", "webhtv_color_primary", "focus_ring_on_primary"}, + {"FOCUS 填充", "webhtv_color_focus", "focus_ring_on_primary"}, + // 主按钮聚焦态填充翻成 primary_container,环随之取 on_primary_container。 + // 这一对必须同时满足"对填充 ≥3:1"和"对外层面板 ≥3:1",否则焦点环会被 + // 看成按钮自带的深边(旧 on_primary 环对外层面板只有 1.05:1,即用户报告)。 + {"primary container 填充", "webhtv_color_primary_container", "focus_ring_on_primary_container"}, + {"secondary container 填充", "webhtv_color_secondary_container", "focus_ring_on_secondary_container"}, + {"error container 填充", "webhtv_color_error_container", "focus_ring_on_error_container"}, + }; + String colors = read(COLORS); + // TV 不再有独立的 leanback 色板,day/night 两张表就是全部生效表。 + for (String tokens : new String[]{TOKENS_LIGHT, TOKENS_NIGHT}) { + for (String[] pair : pairs) { + String fill = tokenHex(read(tokens), pair[1]); + String alias = aliasTarget(colors, pair[2]); + assertTrue(pair[2] + " 必须指向一个主题 token 而不是写死十六进制", alias.startsWith("@color/")); + String ring = tokenHex(read(tokens), alias.substring("@color/".length())); + double ratio = contrast(fill, ring); + assertTrue(tokens + " 中 " + pair[0] + " 的环色 " + ring + " 与填充 " + fill + + " 只有 " + String.format("%.2f", ratio) + ":1,低于 3:1,焦点会看不见", + ratio >= 3.0); + } + } + } + + /** + * 不变量:焦点环的**外沿**必须与它实际贴着的弹窗面板形成 ≥3:1。 + * + *

这是用户报告的直接回归门。旧方案让主按钮聚焦时填充保持浅色 primary、 + * 环取深色 on_primary:环对填充有 7.50:1,但环的外沿贴在面板 + * surface_container_high 上只有 1.05:1(夜间)/ 1.22:1(浅色),看上去 + * 就是按钮自带的深边而不是焦点高亮 —— 即「高亮边框颜色和按钮本身的颜色 + * 太接近」。 + * + *

只对「聚焦填充 = *container」的环生效,因为只有这些环的外沿落在弹窗 + * 面板上。默认填充 primary 的那一行被 + * {@link #noSingleRingColourCanSatisfyBothTheLightFillAndTheDarkPanel()} + * 证明为不可满足,所以主按钮的聚焦填充必须翻成 container + * (见 color/dialog_primary_button_bg.xml)。 + */ + @Test + public void everyRingThatSitsOnADialogPanelClearsItAgainstThatPanel() throws Exception { + // {说明, 焦点态填充 token, 环色别名} + String[][] pairs = { + {"primary container 填充", "webhtv_color_primary_container", "focus_ring_on_primary_container"}, + {"secondary container 填充", "webhtv_color_secondary_container", "focus_ring_on_secondary_container"}, + {"error container 填充", "webhtv_color_error_container", "focus_ring_on_error_container"}, + }; + // 弹窗面板的三个实际取值都必须满足,不能只验最有利的那一个。 + String[] panels = { + "webhtv_color_surface_container", + "webhtv_color_surface_container_high", + "webhtv_color_surface_container_highest", + }; + String colors = read(COLORS); + for (String tokens : new String[]{TOKENS_LIGHT, TOKENS_NIGHT}) { + String body = read(tokens); + for (String[] pair : pairs) { + String alias = aliasTarget(colors, pair[2]).substring("@color/".length()); + String ring = tokenHex(body, alias); + for (String panelToken : panels) { + String panel = tokenHex(body, panelToken); + double ratio = contrast(ring, panel); + assertTrue(tokens + " 中 " + pair[0] + " 的环色 " + ring + " 贴在面板 " + + panel + " 上只有 " + String.format("%.2f", ratio) + + ":1,低于 3:1,环会被看成按钮自带的深边", + ratio >= 3.0); + } + } + } + } + + /** + * 回归钉:证明「保持聚焦填充 = 默认填充、只靠环」在本色板下不可实现。 + * + *

环必须同时对浅色默认填充和深色弹窗面板 ≥3:1。把 WCAG 相对亮度记作 L, + * 两个约束分别给出环亮度的上界 {@code (L_fill+0.05)/3-0.05} 与下界 + * {@code 3*(L_panel+0.05)-0.05}。当前色板下上界 < 下界,取值空间是空集 —— + * 所以主按钮的聚焦填充**必须**与默认填充不同。这不是审美取舍,而是硬约束; + * 本测试把这条推理固化下来,避免以后有人"简化"回单线索方案。 + * + *

反向断言:若将来换色板后区间不再为空,本测试会失败并提示可以重新评估 + * 是否还需要翻转填充 —— 那时这是一个真实的设计选择,而不是被忽略的缺陷。 + */ + @Test + public void noSingleRingColourCanSatisfyBothTheLightFillAndTheDarkPanel() throws Exception { + for (String tokens : new String[]{TOKENS_LIGHT, TOKENS_NIGHT}) { + String body = read(tokens); + double fill = luminance(tokenHex(body, "webhtv_color_primary")); + double panel = luminance(tokenHex(body, "webhtv_color_surface_container_high")); + double maxDark = (fill + 0.05) / 3.0 - 0.05; // 对填充 ≥3:1 的环亮度上界 + double minLight = 3.0 * (panel + 0.05) - 0.05; // 对面板 ≥3:1 的环亮度下界 + assertTrue(tokens + ":默认填充 L=" + String.format("%.4f", fill) + " 与面板 L=" + + String.format("%.4f", panel) + " 之间居然存在可用的单一环色" + + "(上界 " + String.format("%.4f", maxDark) + " ≥ 下界 " + + String.format("%.4f", minLight) + ")。" + + "此时可以重新评估是否还需要翻转聚焦填充。", + minLight > maxDark); + } + } + + @Test + public void everyRingColourListResolvesThroughThemeTokens() throws Exception { + String[][] cases = { + {RING_PRIMARY, "focus_ring_on_primary"}, + {RING_PRIMARY_CONTAINER, "focus_ring_on_primary_container"}, + {RING_SECONDARY, "focus_ring_on_secondary_container"}, + {RING_ERROR, "focus_ring_on_error_container"}, + }; + for (String[] item : cases) { + String body = values(read(item[0])); + // 属性顺序不属于语义差异,按状态+取值分别断言。 + assertTrue(item[0] + " 聚焦态必须使用主题环色 " + item[1], + body.contains("android:state_focused=\"true\"") + && body.contains("android:color=\"@color/" + item[1] + "\"")); + assertTrue(item[0] + " 按下态必须使用主题环色", + body.contains("android:state_pressed=\"true\"") + && body.contains("android:color=\"@color/" + item[1] + "\"")); + // 全透明擦除态:strokeWidth 是常量,只切换颜色值可避免任何布局变化。 + assertTrue(item[0] + " 非聚焦态必须是全透明而不是移除描边", + body.contains("android:color=\"@android:color/transparent\"")); + // 环色只允许来自主题 token,不允许写死十六进制。 + assertFalse(item[0] + " 不允许出现硬编码环色", body.matches(".*android:color=\"#.*")); + } + } + + // ------------------------------------------------------------ R2 应用表面唯一宽度 + + /** + * 用户报告(两张实机截图):同一个电视首页上,「搜索/历史」两行功能按钮是**蓝色**边框, + * 「最近观看」内容卡片是**白色**边框,且两边粗细不同。 + * + *

根因是应用表面的焦点环分裂成两族: + *

    + *
  • {@code shape_item_focused} / {@code shape_item_round_focused} 写 + * {@code 1.5dp ?attr/colorPrimary}(蓝);
  • + *
  • {@code shape_vod_focused} / {@code shape_vod_oval_focused} / {@code shape_keyboard_focused} / + * {@code shape_search_hot_word_focused} / {@code shape_chip_*_focused} 写 + * {@code 1.5dp/2dp @color/white}(白,完全不随主题走);
  • + *
  • {@code shape_config_history_item_focused} / {@code shape_site_item_*} / + * {@code shape_group_button_focused} 写 {@code 2dp}。
  • + *
+ * + *

本测试把这些应用表面(宿主为调色板表面或对话框面板)的焦点环钉死到统一取值: + * 宽度必须引用 {@code @dimen/webhtv_focus_ring_width},环色必须走 {@code ?attr/tvFocusRing} + * 或它配对的主题角色({@code ?attr/colorOnPrimary} / {@code ?attr/colorPrimary} / + * {@code ?attr/colorPrimaryContainer}),不得再写死 {@code @color/white} 或 {@code #FFFFFF}。 + * + *

视频层刻意不在本约束内:{@code shape_chip_focused}(播放页解析线路)、 + * {@code shape_chip_round_focused}(直播源弹窗)、{@code shape_video_focused}(画面框)、 + * {@code shape_subtitle_*_focused}(字幕工具栏)、{@code shape_live_focused}(直播抽屉)、 + * {@code selector_control_sheet_button} 与 {@code selector_exit_confirm_*} + * 叠在视频画面或固定明暗的玻璃面板上,改走调色板会让浅色表的深蓝环贴在深底上(实测 + * 1.91:1)或在夜间表的浅底上消失。它们只统一宽度,环色保持与调色板无关。 + */ + @Test + public void appSurfaceFocusRingsShareOneWidthTokenAndAThemeColour() throws Exception { + // 应用表面焦点环:宿主是调色板表面/对话框面板,环色必须跟随主题。 + String[][] themed = { + {"shape_item_focused.xml", "?attr/tvFocusRing"}, + {"shape_item_round_focused.xml", "?attr/tvFocusRing"}, + {"shape_item_selected.xml", "?attr/tvFocusRing"}, + {"shape_vod_focused.xml", "?attr/tvFocusRing"}, + {"shape_vod_oval_focused.xml", "?attr/tvFocusRing"}, + {"shape_keyboard_focused.xml", "?attr/tvFocusRing"}, + {"shape_search_hot_word_focused.xml", "?attr/tvFocusRing"}, + // 自带主题色填充的控件:环色取焦点态填充的配对角色,同样由主题解析器保证对比度。 + {"shape_config_history_item_focused.xml", "?attr/colorOnPrimary"}, + {"shape_site_item_focused.xml", "?attr/colorOnPrimary"}, + {"shape_site_item_selected.xml", "?attr/colorPrimary"}, + {"shape_group_button_focused.xml", "?attr/colorOnPrimary"}, + }; + for (String[] entry : themed) { + String path = LEANBACK_DRAWABLE + entry[0]; + String body = values(read(path)); + assertTrue(path + " 的焦点环必须引用唯一宽度 token " + WIDTH, + body.contains("android:width=\"" + WIDTH + "\"")); + assertTrue(path + " 的环色必须取主题角色 " + entry[1] + ",不能写死", + body.contains("android:color=\"" + entry[1] + "\"")); + assertFalse(path + " 不允许再写死白色焦点环", + body.contains("android:color=\"@color/white\"") || body.contains("android:color=\"#FFFFFF\"")); + assertFalse(path + " 不允许再写死描边宽度(1.5dp/2dp/3dp)", + body.matches("(?s).*android:width=\"[0-9.]+dp\".*")); + } + + // 视频层/固定明暗宿主:只统一宽度,环色保持与调色板无关。 + String[] paletteIndependent = { + "shape_chip_focused.xml", + "shape_chip_round_focused.xml", + "shape_live_focused.xml", + "shape_video_focused.xml", + "shape_subtitle_focused.xml", + "shape_subtitle_pressed.xml", + "selector_control_sheet_button.xml", + "selector_episode_dialog_item.xml", + "selector_episode_dialog_page.xml", + "selector_danmaku_result_item.xml", + "selector_danmaku_search_action.xml", + "shape_video_item_focused.xml", + }; + for (String name : paletteIndependent) { + String path = LEANBACK_DRAWABLE + name; + String body = values(read(path)); + assertTrue(path + " 的焦点环必须引用唯一宽度 token " + WIDTH, + body.contains("android:width=\"" + WIDTH + "\"")); + // 只约束**焦点态**的描边宽度:这些文件里还带有 1dp 的常态轮廓, + // 那是「未聚焦轮廓」而不是焦点环,套用 3dp 反而会模糊常态与焦点的区分。 + java.util.List widths = focusStateStrokeWidths(body, name); + assertFalse(path + " 的焦点态描边不允许写死宽度,实际为 " + widths, + widths.stream().anyMatch(w -> !WIDTH.equals(w))); + } + } + + /** 抽出 {@code body} 里焦点态描边的宽度(含单 shape 的 focused 文件)。 */ + private static java.util.List focusStateStrokeWidths(String body, String name) { + java.util.List widths = new java.util.ArrayList<>(); + java.util.regex.Matcher item = java.util.regex.Pattern + .compile("]*state_focused=\"true\"[^>]*>(.*?)", java.util.regex.Pattern.DOTALL) + .matcher(body); + while (item.find()) { + java.util.regex.Matcher stroke = java.util.regex.Pattern + .compile("]*android:width=\"([^\"]+)\"") + .matcher(item.group(1)); + while (stroke.find()) widths.add(stroke.group(1)); + } + if (!body.contains("]*android:width=\"([^\"]+)\"") + .matcher(body); + while (stroke.find()) widths.add(stroke.group(1)); + } + return widths; + } + + /** + * 全库不变量:leanback 里**任何**焦点态描边都不得写死宽度,必须引用唯一 token。 + * + *

上一条测试是白名单式断言(只检查名单内的文件),新增文件不会被覆盖。本测试反过来 + * 扫描整个 {@code leanback/res/drawable},所以以后新增一个焦点环、或把旧的 1.5dp/2dp + * 写回来,都会直接转红——这正是用户报告「边框粗细也貌似不一样」需要被永久挡住的一类改动。 + * + *

仅两个文件豁免,且各有明确理由(豁免列表本身就是契约): + *

    + *
  • {@code selector_video_item.xml} —— 它是电视版统一焦点环规范的**样板文件** + * (其他文件都在注释里指向它),其字面量 {@code 3dp} 与 token 取值完全相同, + * 且 {@code NativeEnhancedPlaybackStyleFocusTest} 把它当作规范基准逐字断言; + * 改它只会引入无视觉收益的跨任务测试改动。
  • + *
  • {@code shape_audio_action_icon_focused.xml} —— 它不是容器焦点环,而是播放页音频 + * 按钮上带 {@code inset=3dp} 的**图标内描边环**(40dp 图标 / 17dp 圆角), + * 角色与尺寸均不同,套 3dp 会把图标糊成一团。
  • + *
+ */ + @Test + public void noLeanbackFocusRingHardcodesItsWidth() throws Exception { + java.util.Set exempt = java.util.Set.of( + "shape_audio_action_icon_focused.xml"); + // 已知未收敛的 main 源集文件(2026-10-13 审计发现,见 + // docs/TV-FOCUS-RING-MAIN-SOURCE-20261013.md)。它们仍是 3dp 字面量 + 与注释公式 + // 不符的圆角,但**改宽度必须重做像素级月牙复核**(TvAppSurfaceFocusRingDeviceTest 的 + // Robolectric 栅格化扫描),属独立批次。这里显式列出而不是静默跳过: + // 一旦有人修好其中任一文件,下面的断言会失败并提醒把它从名单里删掉。 + java.util.Set knownUnconverged = java.util.Set.of( + "selector_episode_card.xml", + "selector_tmdb_cast_focus.xml", + "selector_tmdb_media_focus.xml", + "shape_episode_photo_focused.xml"); + java.util.List dirs = new java.util.ArrayList<>(); + for (String candidate : new String[]{ + "app/src/leanback/res/drawable", "app/src/main/res/drawable"}) { + java.nio.file.Path dir = java.nio.file.Path.of(candidate); + if (!java.nio.file.Files.isDirectory(dir)) dir = java.nio.file.Path.of("../" + candidate); + assertTrue(candidate + " 必须存在", java.nio.file.Files.isDirectory(dir)); + dirs.add(dir); + } + + java.util.List violations = new java.util.ArrayList<>(); + java.util.Set stillUnconverged = new java.util.TreeSet<>(); + int checked = 0; + for (java.nio.file.Path dir : dirs) { + boolean leanback = dir.toString().replace('\\', '/').contains("/leanback/"); + try (java.util.stream.Stream paths = java.nio.file.Files.list(dir)) { + for (java.nio.file.Path path : paths.sorted().toList()) { + String name = path.getFileName().toString(); + if (!name.endsWith(".xml") || exempt.contains(name)) continue; + String body = values(read(path.toString().replace('\\', '/'))); + // main 源集是两端共享的,里面绝大多数 drawable 与电视焦点环无关 + // (输入框/开关的 1.5dp 描边、手机端自己的 2dp 卡片描边等)。 + // 只收口**真正的电视焦点环**:引用 ?attr/tvFocusRing / tvCurrentRing 的那些。 + // 这正是漏掉 selector_episode_card / selector_tmdb_*_focus / + // shape_episode_photo_focused 的原因——它们在 main 里,用 3dp 字面量, + // 而同族的 leanback 文件早已改用 token。 + if (!leanback && !body.contains("?attr/tvFocusRing") && !body.contains("?attr/tvCurrentRing")) { + continue; + } + for (String width : focusStateStrokeWidths(body, name)) { + checked++; + if (WIDTH.equals(width)) continue; + if (knownUnconverged.contains(name)) { + stillUnconverged.add(name); + continue; + } + violations.add(dir.getFileName() + "/" + name + " -> " + width); + } + } + } + } + assertTrue("扫描必须真的读到焦点环,否则本测试是空断言", checked >= 15); + assertTrue("电视焦点环宽度只允许有一个来源 " + WIDTH + ",以下仍写死:" + violations, + violations.isEmpty()); + // 名单必须是「活的」:修好的文件要立刻从名单里移除,否则名单会腐烂成第二套豁免。 + assertEquals("以下文件已收敛到 token,请从 knownUnconverged 名单里删掉:" + stillUnconverged, + knownUnconverged, stillUnconverged); + } + + /** + * 回归门:同一个电视页面上的两族焦点环不得再分叉。 + * + *

首页同时渲染功能按钮行({@code adapter_func} → {@code selector_item})与内容卡片行 + * ({@code adapter_vod} → {@code selector_vod}),两者就是用户截图里「上面蓝色、下面白色」 + * 的那一对。它们的焦点态环色必须来自同一个主题属性,宽度来自同一个 token。 + */ + @Test + public void homeFunctionButtonsAndContentCardsShareTheSameRingSpec() throws Exception { + String button = values(read(LEANBACK_DRAWABLE + "shape_item_focused.xml")); + String card = values(read(LEANBACK_DRAWABLE + "shape_vod_focused.xml")); + for (String body : new String[]{button, card}) { + assertTrue("首页两族焦点环必须同宽", body.contains("android:width=\"" + WIDTH + "\"")); + assertTrue("首页两族焦点环必须同色(主题 FOCUS 槽)", body.contains("android:color=\"?attr/tvFocusRing\"")); + } + // 消费方必须仍然分别指向这两个 selector,否则本断言就不再覆盖用户报告的控件。 + assertTrue(read("app/src/leanback/res/drawable/selector_item.xml").contains("@drawable/shape_item_focused")); + assertTrue(read("app/src/leanback/res/drawable/selector_vod.xml").contains("@drawable/shape_vod_focused")); + } + + // ------------------------------------------------------------ R2 卡片环圆角必须对齐宿主表面 + + /** + * 用户报告(2026-10-10 dev1 实机):「边框在卡片上的圆角部分匹配不完美,感觉是圆角比卡片小, + * 导致卡片的圆角还露出一节了」。 + * + *

根因是几何而非颜色:{@code shape_vod_focused} 是卡片的 foreground,而卡片的可见 + * 表面另有自己的 8dp 圆角(图片走 {@code @style/Vod.Grid} 上圆角 8dp、标题块走 + * {@code shape_vod_name} 下圆角 8dp;列表型宿主走 {@code shape_vod_list} 四角 8dp)。 + * 描边型 {@code }({@code GradientDrawable})把描边中心线画在「内缩 w/2、圆角半径仍取 + * 声明值」的路径上,所以环的 外边界圆角半径 = 声明值 + w/2。声明 8dp 时外边界为 + * 8dp + w/2 > 8dp,比卡片自身的弧更大,沿角落对角线卡片弧反而更靠外,露出 1–2px 月牙。 + * + *

所以卡片环的声明圆角必须是 {@code 宿主圆角 - w/2}(理论值 8 - 0.75 = 7.25dp),再向下留 + * 一点像素取整余量:描边宽度按整数像素向上取整(1.5dp @ density 3 = 4.5px → 5px), + * 若写满 7.25dp,在部分密度下环外边界会超出宿主弧 0.25–0.5px,仍会露出亚像素月牙; + * 生产取值 7dp 在 density 1/1.75/2/2.353/3/4 下均为 0 溢出(见 + * {@code TvAppSurfaceFocusRingDeviceTest.cardRingOuterBoundaryContainsEveryCardSurfacePixel})。 + * 由于全部 6 个 {@code selector_vod} 宿主的可见表面都是 8dp,单一环仍可覆盖全部宿主—— + * 这里的断言就是在钉死这条几何关系,并在宿主圆角分叉时立刻报警。 + */ + @Test + public void cardRingCornerRadiusIsTheHostRadiusMinusHalfTheRingWidth() throws Exception { + double width = dpValue(read(DIMENS), "webhtv_focus_ring_width"); + double ringRadius = dpValue(values(read(LEANBACK_DRAWABLE + "shape_vod_focused.xml")), "android:radius"); + + // 宿主的可见表面圆角:图片型宿主的上圆角、标题块的下圆角、列表型宿主的四角。 + double imageTop = dpValue(values(read("app/src/main/res/values/styles.xml")), "cornerSizeTopLeft"); + double nameBottom = dpValue(values(read(LEANBACK_DRAWABLE + "shape_vod_name.xml")), "android:bottomLeftRadius"); + double listAll = dpValue(values(read(LEANBACK_DRAWABLE + "shape_vod_list.xml")), "android:radius"); + + assertTrue("selector_vod 的 6 个宿主表面必须同圆角,否则一个环无法同时对齐(图片上圆角=" + + imageTop + "dp,标题块下圆角=" + nameBottom + "dp,列表四角=" + listAll + "dp)", + imageTop == nameBottom && nameBottom == listAll); + double ideal = imageTop - width / 2; + assertTrue("卡片环的声明圆角必须取 宿主圆角 - 环宽/2(" + ideal + "dp)再向下留像素取整余量," + + "且不得超过理论值:实际 " + ringRadius + "dp(环宽 " + width + "dp)", + ringRadius <= ideal + 0.01 && ringRadius >= ideal - 0.5); + } + + /** 从 XML 文本里取出某个属性/dimen 的 dp 数值。 */ + private static double dpValue(String xml, String key) { + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile(java.util.regex.Pattern.quote(key) + "[^>]*?>?([0-9.]+)dp") + .matcher(xml); + assertTrue("必须能在 XML 里找到 " + key + " 的 dp 取值", matcher.find()); + return Double.parseDouble(matcher.group(1)); + } + + // ------------------------------------------------------------ R5 前景环圆角必须对齐宿主表面 + + /** + * 同一类缺陷在详情页/播放页的实例(2026-10-10 实机复核):环作为卡片的 foreground 时, + * 环外边界圆角半径 = 声明值 + w/2(见 §12.2 的几何推导)。若声明值直接写宿主圆角, + * 外边界就会比宿主大 w/2,宿主圆角在环外露出一段;宿主是直角时更明显。 + * + *

本测试把「声明值 = 宿主圆角 − w/2(下限 0)」钉死到四个宿主: + *

    + *
  • {@code selector_episode_card}(焦点 3dp)→ 宿主 {@code adapter_episode_card} 的 + * CardView {@code @dimen/webhtv_card_radius_default} = 8dp;
  • + *
  • {@code selector_tmdb_media_focus}(焦点 3dp)→ 宿主 {@code adapter_tmdb_video}/ + * {@code adapter_tmdb_photo} 的 MaterialCardView 8dp;
  • + *
  • {@code selector_tmdb_cast_focus}(焦点 3dp)→ 宿主 {@code adapter_tmdb_cast} 的 + * {@code @dimen/webhtv_card_radius_large} = 12dp;
  • + *
  • {@code shape_episode_photo_focused}(焦点 3dp)→ 宿主 {@code dialog_episode_detail} + * 的 {@code @id/stillCard} CardView 8dp;
  • + *
  • {@code shape_video_focused}(宽度走 token)→ 宿主是普通 FrameLayout(直角),故为 0dp。
  • + *
+ * + *

栅格化端到端断言(真实宿主 inflate + 环栅格化后数溢出像素)在 + * {@code TvAppSurfaceFocusRingDeviceTest.cardRingOuterBoundaryContainsItsHostSurface}。 + */ + @Test + public void foregroundRingsDeclareTheirHostCornerMinusHalfTheRingWidth() throws Exception { + // {环文件, 该状态的环宽 dp, 宿主圆角 dp, 宿主来源说明, 理想声明值 dp} + // 理想值 = 宿主圆角 − 环宽/2;实测(Robolectric 栅格化,真实宿主 inflate)在各档密度下 + // 0 溢出的最小声明值就是该理想值(例如 8dp 宿主 + 3dp 环 = 6.5dp)。 + Object[][] rings = { + {"app/src/main/res/drawable/selector_episode_card.xml", 3.0, 8.0, 6.0, + "adapter_episode_card CardView cardCornerRadius=webhtv_card_radius_default"}, + {"app/src/main/res/drawable/selector_tmdb_media_focus.xml", 3.0, 8.0, 6.0, + "adapter_tmdb_video/photo MaterialCardView cardCornerRadius=webhtv_card_radius_default"}, + {"app/src/main/res/drawable/selector_tmdb_cast_focus.xml", 3.0, 12.0, 9.5, + "adapter_tmdb_cast MaterialCardView cardCornerRadius=webhtv_card_radius_large"}, + {"app/src/main/res/drawable/shape_episode_photo_focused.xml", 3.0, 8.0, 6.0, + "dialog_episode_detail stillCard CardView cardCornerRadius=8dp"}, + }; + for (Object[] entry : rings) { + String path = (String) entry[0]; + double width = (Double) entry[1]; + double host = (Double) entry[2]; + double expected = (Double) entry[3]; + double declared = firstCornerRadiusDp(values(read(path))); + double ideal = Math.max(host - width / 2, 0); + assertEquals(path + " 的焦点态声明圆角必须等于 宿主圆角 − 环宽/2 = " + ideal + "dp(宿主 " + + host + "dp 来自 " + entry[4] + "),实测 0 溢出的最小取值;实际 " + declared + "dp", + expected, declared, 0.01); + } + + // 当前态(2dp)同样要减自己的 w/2:8dp 宿主 → 7dp 理论、实测 6dp 起 0 溢出(取 6dp); + // 12dp 宿主 → 11dp 理论、实测 10dp 起 0 溢出(取 10dp)。 + assertEquals("选集卡当前态声明圆角", 6.0, + secondCurrentStateCornerRadiusDp(values(read("app/src/main/res/drawable/selector_episode_card.xml"))), 0.01); + assertEquals("剧照/相关视频当前态声明圆角", 6.0, + secondCurrentStateCornerRadiusDp(values(read("app/src/main/res/drawable/selector_tmdb_media_focus.xml"))), 0.01); + assertEquals("演员卡当前态声明圆角", 9.5, + secondCurrentStateCornerRadiusDp(values(read("app/src/main/res/drawable/selector_tmdb_cast_focus.xml"))), 0.01); + + // 直角宿主:播放页画面框是普通 FrameLayout(无圆角),环必须是 0dp, + // 否则环外边界圆角(w/2 + 声明值)比黑底直角还大,黑角会露在环外。 + double videoRing = firstCornerRadiusDp(values(read(LEANBACK_DRAWABLE + "shape_video_focused.xml"))); + assertEquals("播放页画面框是直角宿主,环声明圆角必须是 0dp", 0.0, videoRing, 0.01); + } + + /** 取 XML 里第一个 {@code android:radius} 的 dp 值(缺省视为 0,例如只写了 per-corner)。 */ + private static double firstCornerRadiusDp(String xml) { + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("android:radius=\"([0-9.]+)dp\"").matcher(xml); + return matcher.find() ? Double.parseDouble(matcher.group(1)) : 0.0; + } + + /** 取 XML 里第二个 {@code android:radius} 的 dp 值(这些 selector 的第二段就是当前态)。 */ + private static double secondCurrentStateCornerRadiusDp(String xml) { + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("android:radius=\"([0-9.]+)dp\"").matcher(xml); + assertTrue("必须至少有两段带圆角的 shape", matcher.find() && matcher.find()); + return Double.parseDouble(matcher.group(1)); + } + + /** + * 集数名可读性 + 卡片两行文字的层级。 + * + *

历史:这一行此前用 {@code ?attr/colorOnSurfaceVariant}(浅色表 #44474F)画在深色 + * {@code shape_vod_name} 条带上,实机对比度只有 1.35:1,用户报告「集数名称一栏字体 + * 颜色不对看不清」,于是被改成与刷名相同的纯白(条带上 6.89:1)。 + * + *

代价是卡片两行完全同色,标题与集数只剩字号差别。本次用「先把承载色带压暗、再给次级 + * 文字一个语义角色」解决:条带由 20% 黑改为 40% 黑后,刷名(纯白)升到 9.82:1,集数行 + * ({@code webhtvColorOnWallpaperVariant})为 6.99:1——仍高于被替换掉的那个纯白取值, + * 因此这是「既分层级、又不降低可读性」的改动,不是把 1.35:1 的缺陷放回来。 + * + *

本测试钉住三件事:刷名保持纯白、集数行走次级角色、条带保持 40% 黑。最后一条尤其重要: + * 一旦有人把条带调回 20%,集数行的对比度会掉到 5.12:1,必须在这里改断言并重新核算, + * 而不是悄悄放行。 + */ + @Test + public void leanbackCardEpisodeLineIsTheSecondaryRoleOnTheDarkenedBand() throws Exception { + String layout = read("app/src/leanback/res/layout/adapter_vod.xml"); + String nameColour = textColourOf(layout, "name"); + String remarkColour = textColourOf(layout, "remark"); + assertEquals("刷名必须保持纯白(40% 条带上 9.82:1)", + "?attr/webhtvColorOnWallpaper", nameColour); + assertEquals("集数行必须走壁纸次级角色(40% 条带上 6.99:1)", + "?attr/webhtvColorOnWallpaperVariant", remarkColour); + assertFalse("集数行不允许再用 colorOnSurfaceVariant(深色条带上仅 1.35:1)", + remarkColour.contains("colorOnSurfaceVariant")); + assertFalse("集数行不得与刷名同色,否则两行又会失去层级", nameColour.equals(remarkColour)); + assertTrue("卡片信息条带必须保持 40% 黑,否则集数行的次级色会跌破 4.5:1", + read("app/src/leanback/res/drawable/shape_vod_name.xml").contains("@color/black_40")); + } + + /** 取某个 {@code @+id/xxx} 控件上的 {@code android:textColor}。 */ + private static String textColourOf(String layout, String id) { + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("@\\+id/" + java.util.regex.Pattern.quote(id) + "\"(.*?)(?:/>|播放页按钮的边框太粗了,按钮和播放器选中后的边框颜色也不统一, + * 请统一且都受到主题色彩控制 + * + *

实机实测(1920×1080,2.35px/dp):播放页同时存在两族环—— + * {@code selector_chip}(14+26 个 Control 按钮)走 1.5dp 写死白, + * {@code selector_video_item}(11 个文本按钮)走 3dp {@code ?attr/tvFocusRing}, + * 而「当前播放」走 2dp 写死绿 {@code #2CC56F};同一屏上三种宽度、三种颜色。 + * + *

本测试把播放页的两族环钉死(宿主分层:谁叠在视频/固定玻璃上,谁在页面背景上): + *

    + *
  1. 所有播放页焦点/当前态环的宽度都引用 {@code @dimen/webhtv_focus_ring_width};
  2. + *
  3. 视频层族(控制条芯片、控制面板按钮、画面框、字幕/直播环)走 + * {@code ?attr/tvPlayerRing} / {@code ?attr/tvPlayerCurrentRing};
  4. + *
  5. 应用表面族({@code selector_video_item}:简介/短显/搜索按钮行、集数表头、 + * 线路/清晰度/数组分段/分段/选集/快捷源芯片)走 {@code ?attr/tvFocusRing} / + * {@code ?attr/tvCurrentRing},因为它们画在页面背景上而不是视频/玻璃上;
  6. + *
  7. 复用这批芯片布局的两处固定玻璃对话框在子树内用 + * {@code ThemeOverlay.WebHTV.GlassFocusRings} 把两项重绑回玻璃可读色。
  8. + *
+ * + *

为什么视频层族不能用 {@code ?attr/tvFocusRing}:控制条/控制面板是固定深靛 + * 玻璃({@code #E62F315E→#CC303463},两张表里都是深色),浅色表 FOCUS 深蓝 + * {@code #0B57D0} 画在它上面只有 1.83:1(dev1 实机实测)。所以 {@code tvPlayerRing} + * 在**浅色表**是 {@code ThemeResolver} 用 {@code readableAccent} 在三档玻璃上夹取到 + * ≥3:1(WCAG 2.2 SC 1.4.11 非文本门槛)后的派生色,保持用户 FOCUS / playerCurrent 槽 + * 的色相与彩度、只走明度;深色表两类宿主的底色都是深色,因此直接取与应用表面 + * 焦点环同值的近白——否则同一页会同时出现「应用表面近白 + 视频层灰蓝」两种高亮 + * (用户报告 2026-10-11「播放页存在多个不同颜色的高亮效果」)。 + */ + @Test + public void playerFocusRingsShareOneWidthAndAThemeControlledColour() throws Exception { + // {文件, 必须出现的环色属性} + // 视频层族:宿主是叠在视频画面或固定深色玻璃上的控件。 + String[][] rings = { + {"shape_chip_focused.xml", "?attr/tvPlayerRing"}, + {"shape_chip_round_focused.xml", "?attr/tvPlayerRing"}, + {"shape_video_focused.xml", "?attr/tvPlayerRing"}, + {"shape_subtitle_focused.xml", "?attr/tvPlayerRing"}, + {"shape_subtitle_pressed.xml", "?attr/tvPlayerRing"}, + {"shape_live_focused.xml", "?attr/tvPlayerRing"}, + {"selector_control_sheet_button.xml", "?attr/tvPlayerRing"}, + }; + for (String[] entry : rings) { + String path = LEANBACK_DRAWABLE + entry[0]; + String body = values(read(path)); + assertTrue(path + " 的焦点环宽度必须引用唯一 token " + WIDTH, + body.contains("android:width=\"" + WIDTH + "\"")); + assertTrue(path + " 的焦点环色必须走视频层主题属性 " + entry[1], body.contains(entry[1])); + assertFalse(path + " 不允许再写死白色焦点环", + body.contains("android:color=\"@color/white\"") || body.contains("android:color=\"#FFFFFF\"")); + } + + // 应用表面族:selector_video_item 的宿主是播放页内容区的按钮/芯片(简介/短显/搜索 + // 按钮行、集数表头、线路/清晰度/数组分段/分段/选集/快捷源行),它们画在页面背景上, + // 不是叠在视频或固定玻璃上——2026-10-11 用户报告「播放页存在多个不同颜色的高亮效果」, + // 这批芯片此前错用了视频层灰蓝环(#7695C5),现已归到调色板 FOCUS 槽。 + String chip = values(read(LEANBACK_DRAWABLE + "selector_video_item.xml")); + assertTrue(LEANBACK_DRAWABLE + "selector_video_item.xml 的焦点环必须走应用表面属性 ?attr/tvFocusRing", + chip.contains("?attr/tvFocusRing")); + assertTrue(LEANBACK_DRAWABLE + "selector_video_item.xml 的当前态环必须走应用表面属性 ?attr/tvCurrentRing", + chip.contains("?attr/tvCurrentRing")); + assertFalse("播放页内容区芯片不允许再用视频层环色(会造成同页两种高亮色)", + chip.contains("?attr/tvPlayerRing") || chip.contains("?attr/tvPlayerCurrentRing")); + + // 宿主是固定深色玻璃的两处对话框(复用同一批芯片布局)必须在子树内把两项重绑回玻璃可读色, + // 否则浅色表(FOCUS = 深蓝 #0B57D0)的芯片环画在固定玻璃上只有 1.83:1。 + String overlay = "ThemeOverlay.WebHTV.GlassFocusRings"; + String leanbackStyles = read("app/src/leanback/res/values/styles.xml"); + assertTrue("leanback styles.xml 必须定义玻璃宿主覆盖层 " + overlay, leanbackStyles.contains("