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 4d3ba2598d5..7a898d458d9 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 @@ -269,8 +269,7 @@ public void onCategoryContentHorizontalEdge(Class item, int contentRow, boolean mBinding.typeRecycler.removeCallbacks(mTypeSwitch); int position = mTypeAdapter.indexOf(item); mBinding.typeRecycler.setSelectedPosition(position); - if (contentRow == 0) focusFirstCard(item); - else focusCategoryButton(item); + focusCategoryButton(item); } private Class getAdjacentCategory(Class item, boolean towardEnd) { @@ -288,10 +287,23 @@ private void focusFirstCard(Class item) { } private void focusCategoryButton(Class item) { - mBinding.typeRecycler.setVisibility(View.VISIBLE); - updateToolbarVisibility(true); - mBinding.typeRecycler.requestFocus(); showCategoryContent(item); + // Complete the switch before checking the new page; otherwise the posted callback can + // run while the target fragment is not added yet and drop the header/focus restoration. + getSupportFragmentManager().executePendingTransactions(); + mBinding.typeRecycler.post(() -> { + if (isFinishing() || isDestroyed() || !isCurrentCategory(item)) return; + int position = mTypeAdapter.indexOf(item); + if (position < 0 || mBinding.typeRecycler.getSelectedPosition() != position) return; + mBinding.typeRecycler.setVisibility(View.VISIBLE); + updateToolbarVisibility(true); + mFolder.scrollContentToTop(); + mBinding.typeRecycler.setSelectedPosition(position, holder -> { + if (isCurrentCategory(item) && mBinding.typeRecycler.getSelectedPosition() == position) { + holder.itemView.requestFocus(); + } + }); + }); } private void showHomeContent() { 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 7f6036e6217..f2e9dc48b19 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 @@ -82,6 +82,11 @@ public class LiveActivity extends PlaybackActivity implements GroupAdapter.OnCli private static final long PLAYBACK_END_RETRY_DELAY = 500; + @Override + protected boolean shouldAutoPlay() { + return true; + } + private ActivityLiveBinding mBinding; private ChannelAdapter mChannelAdapter; private EpgDataAdapter mEpgDataAdapter; 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 ff9e4868bcc..201a9f48ca4 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/SettingAdActivity.java @@ -53,6 +53,8 @@ public class SettingAdActivity extends BaseActivity { private volatile int probeRuleCount; private final ActivityResultLauncher adAudioRulePicker = registerForActivityResult(new ActivityResultContracts.OpenDocument(), this::importAdAudioRules); + private final ActivityResultLauncher speechAdRulePicker = + registerForActivityResult(new ActivityResultContracts.OpenDocument(), this::importSpeechAdRules); public static void start(Activity activity) { activity.startActivity(new Intent(activity, SettingAdActivity.class)); @@ -89,6 +91,8 @@ protected void initEvent() { 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); @@ -126,8 +130,11 @@ private void setText() { 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)); @@ -347,6 +354,145 @@ private void toggleSpeechAdEnabled(View view) { setText(); } + private String getSpeechAdRulesText(SpeechAdSetting.RuleSnapshot snapshot) { + if (snapshot.hasError()) return getString(R.string.speech_ad_rules_error, snapshot.error()); + if (snapshot.rules().isEmpty()) return getString(R.string.speech_ad_rules_none); + return getString(R.string.speech_ad_rules_summary, + snapshot.rules().rules().size(), getSpeechAdRuleSourceText(snapshot)); + } + + private String getSpeechAdBuiltinText(SpeechAdSetting.RuleSnapshot snapshot) { + int count = Math.max(snapshot.builtinRules().rules().size(), SpeechAdSetting.builtinRuleCount()); + return getString(R.string.speech_ad_builtin_value, + getSwitch(snapshot.builtinEnabled()), count); + } + + private String getSpeechAdRuleSourceText(SpeechAdSetting.RuleSnapshot snapshot) { + boolean hasCustom = !snapshot.customRules().isEmpty(); + boolean hasBuiltin = snapshot.builtinEnabled() && !snapshot.builtinRules().isEmpty(); + if (hasCustom && hasBuiltin) return getString(R.string.speech_ad_rules_source_combined); + if (hasBuiltin) return getString(R.string.speech_ad_rules_source_builtin); + if (snapshot.source() == SpeechAdSetting.RuleSource.IMPORTED) { + return getString(R.string.speech_ad_rules_source_imported); + } + if (snapshot.source() == SpeechAdSetting.RuleSource.USER) { + return getString(R.string.speech_ad_rules_source_user); + } + return getString(R.string.speech_ad_rules_source_none); + } + + private void manageSpeechAdRules(View view) { + new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.speech_ad_rules_manage) + .setItems(new String[]{ + getString(R.string.speech_ad_rules_edit), + getString(R.string.speech_ad_rules_import), + getString(R.string.speech_ad_rules_clear), + getString(R.string.speech_ad_rules_view_builtin) + }, (dialog, which) -> { + if (which == 0) editSpeechAdRules(); + else if (which == 1) speechAdRulePicker.launch(new String[]{"text/plain", "text/*"}); + else if (which == 2) confirmClearSpeechAdRules(); + else showBuiltinSpeechAdRules(); + }) + .setNegativeButton(R.string.dialog_negative, null) + .show(); + } + + private void showBuiltinSpeechAdRules() { + try { + new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.speech_ad_rules_view_builtin) + .setMessage(SpeechAdSetting.builtinRulesText()) + .setPositiveButton(R.string.dialog_positive, null) + .show(); + } catch (RuntimeException error) { + Notify.show(getString(R.string.speech_ad_rules_error, SpeechAdSetting.safeError(error))); + } + } + + private void editSpeechAdRules() { + EditText input = new EditText(this); + input.setGravity(Gravity.TOP | Gravity.START); + input.setMinLines(8); + input.setMaxLines(16); + input.setSingleLine(false); + input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + input.setText(SpeechAdSetting.customRulesText()); + input.setSelection(input.length()); + AlertDialog dialog = new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.speech_ad_rules_edit) + .setView(input) + .setNegativeButton(R.string.dialog_negative, null) + .setPositiveButton(R.string.dialog_positive, null) + .create(); + dialog.setOnShowListener(ignored -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(button -> { + try { + String text = input.getText().toString(); + if (text.trim().isEmpty()) SpeechAdSetting.clearRules(); + else SpeechAdSetting.setRulesText(text, SpeechAdSetting.RuleSource.USER); + Notify.show(R.string.speech_ad_rules_saved); + notifyAdAudioRuntime(); + setText(); + dialog.dismiss(); + } catch (RuntimeException error) { + input.setError(SpeechAdSetting.safeError(error)); + input.requestFocus(); + } + })); + dialog.show(); + LightDialog.apply(dialog); + } + + private void importSpeechAdRules(Uri uri) { + if (uri == null) return; + Task.execute(() -> { + boolean imported = false; + String message; + try { + SpeechAdSetting.importUri(getContentResolver(), uri); + imported = true; + message = getString(R.string.speech_ad_rules_imported, + SpeechAdSetting.ruleSnapshot().customRules().rules().size()); + } catch (Exception error) { + message = getString(R.string.speech_ad_rules_import_failed, + SpeechAdSetting.safeError(error)); + } + final boolean success = imported; + String result = message; + runOnUiThread(() -> { + if (success) notifyAdAudioRuntime(); + if (!canSetText()) return; + Notify.show(result); + setText(); + }); + }); + } + + private void confirmClearSpeechAdRules() { + new MaterialAlertDialogBuilder(this, R.style.Theme_WebHTV_LightDialog) + .setTitle(R.string.speech_ad_rules_clear) + .setMessage(R.string.speech_ad_rules_clear_confirm) + .setPositiveButton(R.string.dialog_positive, (dialog, which) -> { + SpeechAdSetting.clearRules(); + Notify.show(R.string.speech_ad_rules_clear_done); + notifyAdAudioRuntime(); + setText(); + }) + .setNegativeButton(R.string.dialog_negative, null) + .show(); + } + + private void toggleSpeechAdBuiltin(View view) { + try { + SpeechAdSetting.setBuiltinEnabled(!SpeechAdSetting.isBuiltinEnabled()); + notifyAdAudioRuntime(); + } catch (RuntimeException error) { + Notify.show(getString(R.string.speech_ad_rules_error, SpeechAdSetting.safeError(error))); + } + setText(); + } + private void editSpeechAdKeywords(View view) { SpeechAdConfig speech = SpeechAdSetting.snapshot(); EditText input = new EditText(this); diff --git a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VideoActivity.java index 5bda1ac3b35..8d1d2cb23fc 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 @@ -48,11 +48,8 @@ import com.fongmi.android.tv.api.SiteApi; import com.fongmi.android.tv.api.config.AdBlockStatsStore; import com.fongmi.android.tv.api.config.VodConfig; -import com.fongmi.android.tv.ad.feedback.AdFeedbackController; -import com.fongmi.android.tv.ad.feedback.AdFeedbackHostAdapter; -import com.fongmi.android.tv.ad.feedback.AdFeedbackSession; -import com.fongmi.android.tv.ad.feedback.AdRulePlanApplier; -import com.fongmi.android.tv.ui.dialog.AdFeedbackDialog; +import com.fongmi.android.tv.bean.AdDetectionRequest; +import com.fongmi.android.tv.bean.AdDetectionResult; import com.fongmi.android.tv.bean.AiConfig; import com.fongmi.android.tv.bean.Danmaku; import com.fongmi.android.tv.bean.Episode; @@ -87,6 +84,7 @@ import com.fongmi.android.tv.player.PlayerManager; import com.fongmi.android.tv.player.lut.LutPreset; import com.fongmi.android.tv.player.lut.LutStore; +import com.fongmi.android.tv.service.AiAdDetectionService; import com.fongmi.android.tv.service.PlaybackService; import com.fongmi.android.tv.service.IntroSkipService; import com.fongmi.android.tv.service.OmdbService; @@ -416,11 +414,7 @@ public class VideoActivity extends PlaybackActivity implements CustomKeyDownVod. private final Task.Scope mPersonalRecommendationTasks = new Task.Scope(Task.recommendationExecutor()); private final Task.Scope mTmdbRatingTasks = new Task.Scope(Task.recommendationExecutor()); private String mTmdbRatingContextKey = ""; - private AdFeedbackController mAdFeedback; - private AdFeedbackHostAdapter mAdFeedbackHost; - private AdFeedbackDialog mAdFeedbackDialog; - /** 标记模式下已打的起点,-1 表示未进入标记模式。 */ - private long mAdMarkStartMs = -1; + private int mAdFeedbackGeneration; // TMDB 模式相关字段 private com.fongmi.android.tv.ui.helper.TmdbUIAdapter mTmdbUIAdapter; @@ -1462,7 +1456,6 @@ protected void initEvent() { mBinding.control.action.danmaku.setOnClickListener(guarded(this::onDanmaku)); mBinding.control.action.danmaku.setOnLongClickListener(view -> onDanmakuToggle()); mBinding.control.action.adFeedback.setOnClickListener(view -> onAdFeedback()); - mBinding.control.action.adFeedback.setOnLongClickListener(view -> onAdFeedbackLongPress()); mBinding.control.action.opening.setOnClickListener(guarded(this::onOpening)); if (mBinding.control.action.immersiveAudio != null) mBinding.control.action.immersiveAudio.setOnClickListener(view -> toggleImmersiveAudioMode()); if (mBinding.control.action.cast != null) mBinding.control.action.cast.setOnClickListener(view -> onCast()); @@ -2004,8 +1997,6 @@ private void startPendingFastTmdbPlayback() { } playerStartTime = System.currentTimeMillis(); beginPlayHealth(); - // 快速 TMDB 起播绕过 getPlayer,仍需作废上一条播放的反馈状态 - resetAdFeedback(); prepareFastTmdbPlaybackHistory(item, flag, episode); SpiderDebug.log("video-flow", "fast tmdb playback start cost=%dms key=%s flag=%s episode=%s url=%s", System.currentTimeMillis() - start, getKey(), flag.getFlag(), episode.getName(), episode.getUrl()); beginPlayerContentRequest(getKey(), flag.getFlag(), episode.getUrl()); @@ -2799,7 +2790,6 @@ public boolean labPlayEpisode(String chapterUrl) { } private void getPlayer(Flag flag, Episode episode) { - resetAdFeedback(); mBinding.widget.title.setText(getPlaybackControlTitle(episode)); playerStartTime = System.currentTimeMillis(); beginPlayHealth(); @@ -3227,8 +3217,6 @@ private void changeQuality(Result result, int position) { updateActionQuality(result); return; } - // 换画质同集换 URL:切片结构与在途归因都属于上一画质 - resetAdFeedback(); mQualityAdapter.setPosition(position); updateActionQuality(result); onItemClick(result); @@ -3236,8 +3224,6 @@ private void changeQuality(Result result, int position) { @Override public void onItemClick(Result result) { - // 画质列表直点也会换 URL,且不经 changeQuality - resetAdFeedback(); updateActionQuality(result); beginPlayHealth(); // 切清晰度也会重建 spec,字幕列表跟着重置,所以这里同样要恢复一次。 @@ -4364,7 +4350,7 @@ private void onAdFeedback() { return; } if (!isAdFeedbackEnabled()) { - Notify.show(R.string.ad_interval_invalid); + Notify.show(R.string.ad_feedback_ai_disabled); return; } hideControl(); @@ -4372,10 +4358,15 @@ private void onAdFeedback() { } private boolean isAdFeedbackEnabled() { - // AI 不再是硬门槛:本地归因通道(切片结构/域名/既有规则)不依赖 AI 配置。 - // 只要求可定位的时间轴 —— 直播和时长未知的内容无法框选区间。 - if (player() == null || TextUtils.isEmpty(player().getUrl())) return false; - return Setting.isAdblock() && !player().isLive() && player().getDuration() > 0; + // 功能开关 + 仅支持解析的格式(HLS/m3u8)才可反馈,因为去广分析依赖切片列表 + return Setting.isAiConfigReady() && Setting.isAdblock() && Setting.isAiAdDetection() && isAdFeedbackSupportedFormat(); + } + + private boolean isAdFeedbackSupportedFormat() { + if (player() == null) return false; + String url = player().getUrl(); + if (TextUtils.isEmpty(url)) return false; + return com.fongmi.android.tv.player.exo.MediaSourceFactory.isHlsUrl(url); } private void setAdFeedbackVisible() { @@ -4383,171 +4374,74 @@ private void setAdFeedbackVisible() { applyActionButtonVisibility(); } - private AdFeedbackController adFeedback() { - if (mAdFeedback == null) { - mAdFeedbackHost = new AdFeedbackHostAdapter( - new AdFeedbackPlayback(), new AdFeedbackMetadata(), new AdFeedbackUi()); - mAdFeedback = new AdFeedbackController(mAdFeedbackHost); - } - return mAdFeedback; - } - - /** 换源换集:作废在途归因、清标记模式与上一集的切片证据缓存。 */ - private void resetAdFeedback() { - mAdMarkStartMs = -1; - if (mAdFeedback != null) mAdFeedback.invalidate(); - if (mAdFeedbackHost != null) mAdFeedbackHost.invalidateEvidence(); - // 作废后归因回调不再刷新界面,对话框会永久停在「分析中」 - if (mAdFeedbackDialog != null) mAdFeedbackDialog.close(); - mAdFeedbackDialog = null; - } - - /** - * 起播后记录本站域名,供域名信誉通道建立基线。 - * - *

必须强制初始化适配器:它原本只在用户首次按下「有广告」时懒创建, - * 若这里跳过未初始化的情况,首次反馈时基线仍为空,域名通道会因 - * 「无基线不断言」而弃权 —— 整条通道等于没接。 - */ - private void recordAdFeedbackHost() { - // 关掉去广告或播直播时不建基线:这些域名会挤掉 LRU 里真正有用的条目 - if (!isAdFeedbackEnabled()) return; - adFeedback(); - mAdFeedbackHost.recordPlaybackHost(); - } - - /** 短按:标记模式下第二次按下确定终点,否则以当前位置为终点回溯推断起点。 */ private void submitAdFeedback() { - AdFeedbackController controller = adFeedback(); - AdFeedbackSession session; - if (mAdMarkStartMs >= 0) { - // 先取位置再清标记:播放器已释放时保留标记,用户可重试而不必重新打点 - long end = mAdFeedbackHost.safePositionMs(); - if (end < 0) { - Notify.show(R.string.ad_interval_invalid); - return; - } - long start = mAdMarkStartMs; - mAdMarkStartMs = -1; - session = controller.onMarkedInterval(start, end); - } else { - session = controller.onQuickReport(mAdFeedbackHost.cachedEvidence()); - } - if (session == null) { - Notify.show(R.string.ad_interval_invalid); + AdDetectionRequest request = buildAdDetectionRequest(); + if (request == null) { + Notify.show(R.string.ad_feedback_no_url); return; } - // 统计放在成交之后,未成立的反馈不计入 - AdBlockStatsStore.recordFeedback(getKey()); - } + // 记录 AI 反馈统计 + AdBlockStatsStore.recordFeedback(request.getSiteKey()); - /** 长按:打起点进入标记模式,再次长按取消。 */ - private boolean onAdFeedbackLongPress() { - if (!isAdFeedbackEnabled()) return false; - if (mAdMarkStartMs >= 0) { - mAdMarkStartMs = -1; - Notify.show(R.string.ad_interval_mark_cancelled); - } else { - mAdMarkStartMs = player().getPosition(); - Notify.show(R.string.ad_interval_mark_start); - } - return true; - } - - private void showAdFeedbackSession(AdFeedbackSession session) { - if (isFinishing() || isDestroyed()) return; - if (mAdFeedbackDialog == null) mAdFeedbackDialog = new AdFeedbackDialog(this, this::applyAdRulePlan); - mAdFeedbackDialog.show(session); - } - - private void applyAdRulePlan(com.fongmi.android.tv.ad.feedback.AdAttribution plan) { - Site site = getSite(); - AdRulePlanApplier.Outcome outcome = AdRulePlanApplier.apply( - plan, site == null ? getKey() : site.getKey()); - Notify.show(switch (outcome) { - case APPLIED -> R.string.ad_interval_rule_saved; - case SKIPPED -> R.string.ad_interval_rule_skipped; - case FAILED -> R.string.ad_interval_rule_failed; + Notify.show(R.string.ad_feedback_analyzing); + int generation = ++mAdFeedbackGeneration; + AiConfig config = AiConfig.objectFrom(Setting.getAiConfig()); + Task.execute(() -> { + enrichRequestWithM3u8Evidence(request); + AdDetectionResult result = new AiAdDetectionService(config).analyze(request); + runOnUiThread(() -> { + if (isFinishing() || isDestroyed() || generation != mAdFeedbackGeneration) return; + onAdDetectionResult(request, result); + }); }); } - private final class AdFeedbackPlayback implements AdFeedbackHostAdapter.Playback { - @Override - public long positionMs() { - return player() == null ? 0 : player().getPosition(); - } - - @Override - public long durationMs() { - return player() == null ? 0 : player().getDuration(); - } - - @Override - public String playUrl() { - return player() == null ? "" : player().getUrl(); - } - - @Override - public java.util.Map headers() { - return player() == null ? java.util.Map.of() : player().getHeaders(); - } - - @Override - public boolean hls() { - return player() != null - && com.fongmi.android.tv.player.exo.MediaSourceFactory.isHlsUrl(player().getUrl()); - } - - @Override - public boolean skipInterval(long startMs, long endMs, String feedbackId) { - return player() != null && player().skipUserAdInterval(startMs, endMs, feedbackId); - } - } - - private final class AdFeedbackMetadata implements AdFeedbackHostAdapter.Metadata { - @Override - public String siteKey() { - Site site = getSite(); - return site == null ? getKey() : site.getKey(); - } - - @Override - public String siteName() { - Site site = getSite(); - return site == null ? "" : site.getName(); - } - - @Override - public String vodName() { - return mHistory == null ? getName() : mHistory.getVodName(); - } - - @Override - public String flagName() { - return getFlag() == null ? "" : getFlag().getFlag(); - } - - @Override - public String episodeName() { - return getEpisode() == null ? "" : getEpisode().getName(); + private AdDetectionRequest buildAdDetectionRequest() { + if (player() == null || TextUtils.isEmpty(player().getUrl())) return null; + String url = player().getUrl(); + Uri uri = Uri.parse(url); + AdDetectionRequest request = new AdDetectionRequest(); + Site site = getSite(); + request.setSiteKey(site == null ? getKey() : site.getKey()); + request.setSiteName(site == null ? "" : site.getName()); + request.setVodName(mHistory == null ? getName() : mHistory.getVodName()); + request.setFlagName(getFlag() == null ? "" : getFlag().getFlag()); + request.setEpisodeName(getEpisode() == null ? "" : getEpisode().getName()); + request.setUrlHost(uri.getHost()); + request.setUrlPath(uri.getPath()); + return request; + } + + private void enrichRequestWithM3u8Evidence(AdDetectionRequest request) { + if (player() == null || TextUtils.isEmpty(player().getUrl())) return; + String url = player().getUrl(); + if (!url.contains(".m3u8")) return; + try { + java.util.Map headers = player().getHeaders(); + com.fongmi.android.tv.bean.M3u8Evidence evidence = com.fongmi.android.tv.utils.M3u8Parser.parse(url, headers); + request.setEvidence(evidence); + } catch (Exception e) { + // Ignore parsing failures } } - private final class AdFeedbackUi implements AdFeedbackHostAdapter.Ui { - @Override - public void runBackground(Runnable task) { - Task.execute(task); - } + private void onAdDetectionResult(AdDetectionRequest request, AdDetectionResult result) { + // 记录 AI 分析结果统计 + AdBlockStatsStore.recordAiAnalysis(result != null && !result.isError()); - @Override - public void runOnUi(Runnable task) { - runOnUiThread(task); + if (result == null || result.isError()) { + Notify.show(result == null ? getString(R.string.ad_feedback_failed) : result.getErrorMessage()); + return; } - - @Override - public void showSession(AdFeedbackSession session) { - showAdFeedbackSession(session); + if (result.isEmpty()) { + Notify.show(R.string.ad_feedback_no_ad); + return; } + com.fongmi.android.tv.ui.dialog.AdRulePreviewDialog.create(result).show(this, confirmedResult -> { + UserAdRule rule = UserAdRule.fromAiResult(confirmedResult, request.getSiteKey()); + com.fongmi.android.tv.api.config.UserAdRuleStore.add(rule); + Notify.show(R.string.ad_feedback_saved); + }); } private boolean onDanmakuToggle() { @@ -5409,7 +5303,6 @@ protected void onStateChanged(int state) { requestIntroSkipPlan(); if (!pendingResumeSeekApplied) applyAutoIntroSkip(); setAdFeedbackVisible(); // 播放地址确定后按格式刷新"有广告"按钮 - recordAdFeedbackHost(); // 播放地址已确定,记入本站域名基线 break; case Player.STATE_ENDED: checkEnded(true); 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 bece0710623..f7acea50b4a 100644 --- a/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java +++ b/app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java @@ -34,11 +34,12 @@ import java.util.Optional; -public class VodActivity extends BaseActivity implements TypeAdapter.OnClickListener, FolderFragment.FilterHost { +public class VodActivity extends BaseActivity implements TypeAdapter.OnClickListener, FolderFragment.FilterHost, FolderFragment.CategoryEdgeHost { private ActivityVodBinding mBinding; private TypeAdapter mAdapter; private View mOldView; + private boolean mPendingCategoryFocus; public static void start(Activity activity, Result result) { start(activity, VodConfig.get().getHome().getKey(), result); @@ -114,6 +115,20 @@ protected void initEvent() { @Override public void onPageSelected(int position) { mBinding.recycler.setSelectedPosition(position); + if (mPendingCategoryFocus) { + mPendingCategoryFocus = false; + mBinding.recycler.post(() -> { + if (isFinishing() || isDestroyed() || mBinding.pager.getCurrentItem() != position) return; + // A newly loaded page may have no cards yet; the host must reveal its header. + mBinding.recycler.setVisibility(View.VISIBLE); + mBinding.recycler.requestFocus(); + getFragment().scrollContentToTop(); + mBinding.recycler.setSelectedPosition(position, holder -> { + if (mBinding.pager.getCurrentItem() == position && mBinding.recycler.getSelectedPosition() == position) holder.itemView.requestFocus(); + }); + }); + return; + } mBinding.recycler.requestFocus(); } }); @@ -190,6 +205,16 @@ public void onRefresh(Class item) { getFragment().onRefresh(); } + @Override + public void onCategoryContentHorizontalEdge(Class item, int contentRow, boolean towardEnd) { + int position = mAdapter.indexOf(item); + int target = position + (towardEnd ? 1 : -1); + if (position != mBinding.pager.getCurrentItem() || contentRow < 0 || target < 0 || target >= mAdapter.getItemCount()) return; + App.removeCallbacks(mRunnable); + mPendingCategoryFocus = true; + mBinding.pager.setCurrentItem(target); + } + @Override public boolean dispatchKeyEvent(KeyEvent event) { if (KeyUtil.isMenuKey(event)) updateFilter(); 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 629100e9c7e..d10c7a7667e 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 @@ -24,8 +24,6 @@ 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.theme.ThemeTokens; import com.fongmi.android.tv.ui.custom.CustomWallView; import com.fongmi.android.tv.ui.helper.TouchOptimizationHelper; import com.fongmi.android.tv.utils.Util; @@ -51,20 +49,14 @@ protected void attachBaseContext(Context newBase) { @Override protected void onCreate(Bundle savedInstanceState) { - ThemeController.applyNightMode(this); super.onCreate(savedInstanceState); registerFragmentLifecycleCallbacks(); setContentView(getBinding().getRoot()); - ThemeController.apply(this); - ThemeController.applyLeanback(this); EventBus.getDefault().register(this); initView(savedInstanceState); Util.hideSystemUI(this); setBackCallback(); initEvent(); - // Some detail/player controls are inflated during initView; bind them after the Activity tree is complete. - ThemeController.apply(this); - ThemeController.applyLeanback(this); } @Override @@ -210,12 +202,7 @@ public void onFragmentStarted(@NonNull FragmentManager fragmentManager, @NonNull if (!(fragment instanceof DialogFragment dialog) || dialog.getDialog() == null) return; Window window = dialog.getDialog().getWindow(); if (window == null) return; - window.getDecorView().post(() -> { - ThemeTokens tokens = ThemeController.resolve(BaseActivity.this); - ThemeController.apply(window.getDecorView(), tokens); - ThemeController.applyLeanback(window.getDecorView(), tokens); - TouchOptimizationHelper.sync(window.getDecorView()); - }); + window.getDecorView().post(() -> TouchOptimizationHelper.sync(window.getDecorView())); } }, true); } 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 b90ac7db8de..c48f152108b 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 @@ -477,29 +477,15 @@ private static void setupDialogKeyNavigation(AlertDialog dialog, if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_DOWN) { // 从大海报往下:跳到第一个有数据的网格 - if (focus == stillCard || (focus != null && focus.getParent() == stillCard)) { - if (photosGrid != null && photosGrid.getVisibility() == View.VISIBLE - && photosGrid.getAdapter() != null && photosGrid.getAdapter().getItemCount() > 0) { - photosGrid.requestFocus(); - return true; - } - if (guestsGrid != null && guestsGrid.getVisibility() == View.VISIBLE - && guestsGrid.getAdapter() != null && guestsGrid.getAdapter().getItemCount() > 0) { - guestsGrid.requestFocus(); - return true; - } + if (isSameOrDescendantOf(focus, stillCard)) { + if (requestGridFocus(photosGrid) || requestGridFocus(guestsGrid)) return true; } } if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_UP) { // 客串卡片先回到剧照,剧照再回到大海报;焦点实际在网格 item 上,需要检查父级关系 if (focus != null && (focus == guestsGrid || isDescendantOf(focus, guestsGrid))) { - if (photosGrid != null && photosGrid.getVisibility() == View.VISIBLE - && photosGrid.getAdapter() != null && photosGrid.getAdapter().getItemCount() > 0) { - photosGrid.requestFocus(); - } else { - stillCard.requestFocus(); - } + if (!requestGridFocus(photosGrid)) stillCard.requestFocus(); return true; } if (focus != null && (focus == photosGrid || isDescendantOf(focus, photosGrid))) { @@ -512,6 +498,25 @@ private static void setupDialogKeyNavigation(AlertDialog dialog, }); } + /** + * 将焦点落到横向网格的实际卡片,而不是只把焦点留在网格容器上。 + * RecyclerView/HorizontalGridView 只有在 holder 回调里请求子项焦点时, + * 才能可靠地触发 ScrollView 的垂直滚动和后续遥控导航。 + */ + private static boolean requestGridFocus(androidx.leanback.widget.HorizontalGridView grid) { + if (grid == null || grid.getVisibility() != View.VISIBLE + || grid.getAdapter() == null || grid.getAdapter().getItemCount() == 0) { + return false; + } + int position = Math.max(0, grid.getSelectedPosition()); + grid.setSelectedPosition(position, holder -> holder.itemView.requestFocus()); + return true; + } + + private static boolean isSameOrDescendantOf(View view, View ancestor) { + return view != null && (view == ancestor || isDescendantOf(view, ancestor)); + } + /** * 检查 view 是否是 ancestor 的子孙视图 */ diff --git a/app/src/leanback/res/color/config_history_icon.xml b/app/src/leanback/res/color/config_history_icon.xml index e8b7791cb6a..535afaebafb 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 cde6c4bb42a..cc1716050b7 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/selector_group_text.xml b/app/src/leanback/res/color/selector_group_text.xml index cf5695c19c3..82475bcbc68 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 f5f5c0159a0..ad0b6d872e6 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/text.xml b/app/src/leanback/res/color/text.xml index 99e71a354c8..8d0cdae5ed2 100644 --- a/app/src/leanback/res/color/text.xml +++ b/app/src/leanback/res/color/text.xml @@ -1,8 +1,8 @@ - - - - - + + + + + diff --git a/app/src/leanback/res/drawable/selector_video_item.xml b/app/src/leanback/res/drawable/selector_video_item.xml index 8412b34bc1d..a1aa0eb093f 100644 --- a/app/src/leanback/res/drawable/selector_video_item.xml +++ b/app/src/leanback/res/drawable/selector_video_item.xml @@ -11,7 +11,7 @@ android:top="8dp" /> + android:color="#FFD166" /> @@ -25,7 +25,7 @@ android:top="8dp" /> + android:color="#2CC56F" /> R["AdFeedbackRequest 构造"] - R --> IMM["立即执行:本次跳过 (AdSkipCoordinator)"] - R --> COL["证据采集 AdEvidenceCollector"] - COL --> E["AdIntervalEvidence 快照"] - E --> C1["HlsSegmentClassifier"] - E --> C2["DomainReputationClassifier"] - E --> C3["ExistingRuleClassifier"] - E --> C4["AudioFingerprintClassifier"] - E --> C5["SpeechKeywordClassifier"] - E --> C6["AiClassifier (可选)"] - C1 --> A["AdAttributionArbiter 仲裁"] - C2 --> A - C3 --> A - C4 --> A - C5 --> A - C6 --> A - A --> P["AdRemediationPlan 最优方案"] - P --> UI["确认对话框第二屏"] - UI -->|用户确认| S["写入 UserAdRule / HlsAdRule / AudioFingerprintRule"] - S --> INV["RuleConfig.invalidate() + HlsRuleConfig.invalidate()"] -``` - -### 5.1 模块职责 - -| 模块 | 职责 | 不负责 | -|---|---|---| -| `AdFeedbackController` | 三端共用的编排器:接收区间、触发立即跳过、调度分类、驱动对话框 | 具体分类逻辑 | -| `AdEvidenceCollector` | 采集区间证据快照,一次性、幂等、可缓存 | 判断是不是广告 | -| `AdIntervalClassifier`(接口) | 单通道分类,输出 0..n 个 `AdAttribution` 或弃权 | 跨通道比较 | -| `AdAttributionArbiter` | 合并、去重、置信度融合、成本排序,选出最优方案 | 写规则 | -| `AdRemediationPlanner` | 把仲裁结论翻译成具体规则对象(`HlsAdRule` / `UserAdRule` / `AudioFingerprintRule`) | 落盘 | -| `AdFeedbackDiagnostics` | 固定枚举计数,不记 URL / 规则正文 | 上报 | - -### 5.2 三端去重 - -现有三份重复实现是这次改造的最大阻力。新逻辑**全部放进 `app/src/main/` 的 `AdFeedbackController`**,三端 Activity 只保留: - -```java -// 每端约 15 行 -mAdFeedback = new AdFeedbackController(new AdFeedbackController.Host() { - public long positionMs() { return mPlayer.getPosition(); } - public long durationMs() { return mPlayer.getDuration(); } - public String playUrl() { return mPlayer.getUrl(); } - public Map headers() { return mPlayer.getHeaders(); } - public AdSkipCoordinator skipCoordinator() { return mPlayer.adSkipCoordinator(); } - public void showDialog(AdFeedbackSession s) { AdRulePreviewDialog.create(s).show(this); } - public String vodName() { /* 三端唯一真正不同的地方 */ } -}); -``` - -现有 `testMobile` 下的 `PlayerPlaybackRegressionSourceTest` / `TmdbDetailActivityLayoutTest` 用**源码文本断言**锁定了三端的方法名,改动时必须同步更新这两个测试的期望字符串,否则构建失败。 - -## 6. 数据契约 - -### 6.1 区间证据 - -```java -public record AdIntervalEvidence( - long startMs, long endMs, StartOrigin startOrigin, - // 播放上下文(沿用 AdDetectionRequest 的去敏原则) - String siteKey, String siteName, String vodName, - String flagName, String episodeName, - String playlistHost, String urlPath, boolean hls, - // HLS 切片证据(区间内 + 区间外对照) - List inside, List outside, - boolean boundedByDiscontinuity, boolean crossDomain, - // 已有机制的自述 - List alreadyRemovedByStructuredRuleIds, - boolean legacyHeuristicActive, - List matchedExistingHosts, - // 音频/语音 - AudioIntervalFact audio, SpeechIntervalFact speech) {} - -public record SegmentFact(int index, String host, String path, - double durationSec, boolean discontinuityBefore) {} -``` - -`StartOrigin` 枚举:`USER_MARKED` / `DISCONTINUITY` / `CROSS_DOMAIN` / `AUDIO_CANDIDATE` / `FALLBACK_WINDOW`。仲裁时 `USER_MARKED` 与 `DISCONTINUITY` 的证据权重高于 `FALLBACK_WINDOW`。 - -`alreadyRemovedByStructuredRuleIds` 与 `legacyHeuristicActive` 直接回答 2.3 的问题:如果用户报告的区间**在净化后的 manifest 里已经不存在**,说明广告来自别处(例如 WebView 层没拦住的贴片、或播放器外层),归因结论应当是「HLS 通道弃权」而不是「再加一条 HLS 规则」。 - -### 6.2 归因与方案 - -```java -public record AdAttribution( - String channelId, // "hls" / "domain" / "existing-rule" / "audio" / "speech" / "ai" - AdCategory category, - float confidence, // 0..1,通道内自评 - RiskLevel risk, // LOW / MEDIUM / HIGH,沿用 V2 总设计的三级 - List evidence, // 人类可读,进 UI - RemediationKind remediation) {} - -public enum AdCategory { - THIRD_PARTY_CDN_SEGMENT, // 切片来自非主流/非当前适配域名 - FIXED_DURATION_BLOCK, // 固定时长硬插块 - DISCONTINUITY_BLOCK, // 断点包裹的独立块 - KNOWN_AD_HOST, // 命中已知广告域名 - AUDIO_FINGERPRINT, // 音频频谱指纹匹配 - SPEECH_KEYWORD, // 语音关键词 - IN_STREAM_BURNED_IN, // 压制进正片,无结构特征 - ALREADY_HANDLED, // 已被现有规则处理,用户看到的是别的东西 - UNKNOWN -} - -public enum RemediationKind { - HLS_STRUCTURED_RULE, // → HlsAdRule - HOST_BLACKLIST, // → UserAdRule.hosts - URL_REGEX_RULE, // → UserAdRule.regex - AUDIO_FINGERPRINT_RULE,// → AudioFingerprintRule - SPEECH_KEYWORD, // → SpeechAdSetting keywords - SESSION_SKIP_ONLY, // 只能本次跳过,无法泛化 - NONE -} -``` - -`AdRemediationPlan` = 首选 `AdAttribution` + 生成好的规则对象 + 备选方案列表。UI 展示首选,「更多方式」可展开备选。 - -## 7. 各通道分类逻辑 - -### 7.1 HlsSegmentClassifier(Phase 1,零新增成本) - -输入已有:`M3u8Parser.parse()` 已经产出 `segments` / `discontinuities` / `durations` / `domainSwitches`。缺的只是**把播放时间映射到切片下标** —— 用 `durations` 前缀和即可,不需要新的网络请求。 - -``` -cum[0]=0; cum[i+1]=cum[i]+durations[i] -区间内切片 = { i : cum[i+1] > startSec && cum[i] < endSec } -``` - -判定信号(沿用 `HlsManifestCleaner.Rule` 已支持的六项,保证生成的规则一定能被现有引擎执行): - -| 信号 | 判据 | 权重 | -|---|---|---| -| 跨域 | 区间内切片 host ≠ playlist host,且区间外切片 host = playlist host | 0.35 | -| 断点包裹 | 区间起止各有 `#EXT-X-DISCONTINUITY` | 0.25 | -| 时长离群 | 区间内切片时长标准差 < 0.05s 且与区间外众数时长差 > 0.5s | 0.20 | -| 路径特征 | 区间内切片路径命中 `/ads?/`、`/preroll/`、`/creative/` 等保守模式 | 0.15 | -| 位置 | 区间落在 playlist 头部 15% 以内 | 0.05 | - -置信度 = 命中权重之和。`minimumSignals` 按命中信号数取 `max(2, 命中数)`,**永不生成 `minimumSignals: 1` 的规则** —— 内置规则策略明确禁止只凭固定时长删片。 - -生成的 `HlsAdRule` 必须带 `playlistHostSuffixes`(限定到当前站点的 playlist 域名),把规则作用域收窄,避免污染其他站点。 - -**弃权条件**:非 HLS;区间内切片数为 0;`alreadyRemovedByStructuredRuleIds` 非空且覆盖了整个区间;置信度 < 0.30。 - -### 7.2 DomainReputationClassifier(Phase 1,零新增成本) - -「非当前适配的主流域名」这条需求落在这里。三层比对: - -1. **命中现有黑名单**:区间内切片 host 命中 `RuleConfig.get().getAds()`(VOD `ads` + Live `ads` + 用户规则 hosts)→ `KNOWN_AD_HOST`,置信度 0.95,但同时标记 `ALREADY_HANDLED`:既然已在黑名单里,用户还是看到了广告,说明拦截路径没覆盖到播放器直连的切片请求(黑名单目前主要在 `CustomWebView.shouldInterceptRequest` 生效,不拦播放器的切片)。这个结论本身就有价值 —— 建议改用 `HLS_STRUCTURED_RULE`。 -2. **与站点适配域名对比**:从当前站点最近若干次成功播放的 playlist host 归纳出「本站正常域名集合」(新增一个小的本地 LRU,见 8.3)。区间内 host 不在其中 → `THIRD_PARTY_CDN_SEGMENT`。 -3. **命中接口学习候选**:比对 `ImportedAdRuleCandidateStore` 里状态为待审的候选。若命中,说明接口维护者也认为这是广告域名 → 置信度 +0.15,并在证据里注明来源接口名。这条把 `InterfaceAdRuleAnalyzer` 的既有产出接进了反馈闭环。 - -生成 `UserAdRule.hosts`。风险等级 LOW(域名黑名单是最安全的机制)。 - -### 7.3 ExistingRuleClassifier(Phase 1,零新增成本) - -回答「为什么现有机制没拦住」。检查四件事并写进证据,本身不产出规则(`RemediationKind.NONE`),但会**否决**其他通道的错误结论: - -1. 内置 3 条 HLS 规则是否因 `enabledByDefault: false` 而未启用,且其 `hostSuffixes` 恰好匹配本区间 → 建议「启用规则 X」而不是新建规则。这是成本最低的修复。 -2. `legacyHeuristicActive` 为真且区间内切片是被启发式删掉后**又出现**的 → 启发式判错,需要 exclude 保护。 -3. 是否有 `UserAdRule.exclude`(正片保护正则)误保护了广告切片。 -4. 区间是否已被某条规则处理过(查 `AdBlockStats.ruleCounts` 的最近命中)。 - -### 7.4 AudioFingerprintClassifier(Phase 2) - -Phase 1 只**读**已有匹配结果(若音频指纹已开启且区间内有 `MatchEvent`,直接采信,置信度 0.90,`RemediationKind.AUDIO_FINGERPRINT_RULE` 但规则已存在故降为证据)。 - -Phase 2 才做**新指纹采集**,这是真正的能力增量:用户框选的区间就是天然的指纹样本源。 - -``` -1. AdSkipCoordinator 在 UNDO_WINDOW / 提交瞬间,向 PlaybackMediaSignalHub - 申请 ConsumerKind.AD_AUDIO 的 CaptureLease(若尚未持有) -2. 若区间已过去 → 不重放,走「下次遇到再采集」:把区间描述存为 - PendingFingerprintCapture,下次同一 siteKey + 同一 flagName 播放 - 到相近位置时自动采集 -3. 若区间尚未播完(长按标记模式下常见)→ 直接从 Hub 的 PcmFrame 流采集 -4. SpectralFingerprint.extractVariants(samples, 16000, 1, ...) 产出 4 个相位变体 -5. 组装 AudioFingerprintRule{id, durationMs, anchorOffsetMs, anchorDurationMs, - fingerprint, variants},经 AudioFingerprintRuleCodec 严格校验后 - 写入 AdAudioRuleStore(files/ad-audio-rules.json,2MiB 上限) -``` - -约束(全部继承自现有实现,不放松): -- 仅 Exo;仅点播;仅时长已知且可 seek -- 采集不改变现有「必须用户确认才 seek」的策略 -- 锚点长度取区间前 3 秒(`anchorDurationMs` 上限),不存整段广告音频 -- **不存原始 PCM**,只存 32-bit hash 序列。这一点对隐私和体积都关键 -- 新指纹默认 `PROMPT` 模式,不自动跳 - -跨规则碰撞检查:新指纹与已有规则做汉明距离比对,过近则拒绝写入并提示「已有相同规则」,避免规则库膨胀。 - -### 7.5 SpeechKeywordClassifier(Phase 2) - -若语音通道已开启且区间内有识别文本,提取候选关键词。**但不自动加入关键词表** —— 关键词是全局生效的,一个错误关键词会在所有片子上乱跳。只做建议,且: -- 只建议在多个不同片源的反馈中重复出现的词(需要一个本地计数,阈值 3 次) -- 建议词展示时不显示完整识别文本(现有实现刻意不打印命中关键词,保持一致) - -### 7.6 AiClassifier(Phase 3,可选) - -复用 `AiAdDetectionService`,但输入升级: - -``` -现在只送:站点/剧名/线路/集名/域名/路径 + 全 playlist 切片摘要 -升级后送:上述 + 用户框选区间 + 区间内外切片对照 + 其他通道的初步结论 -``` - -关键变化是 **AI 从「唯一裁判」变成「兜底解释者」**。它只在以下情况被调用: -- 其他所有通道置信度均 < 0.50,或 -- 用户在第二屏主动点「让 AI 再看看」 - -理由:本地通道有确定性证据时,大模型的输出只会引入噪声和延迟;而本地通道全部弃权时(`IN_STREAM_BURNED_IN` 场景),AI 至少能给出人类可读的解释。 - -`AiConfig` 未配置时静默弃权,不报错。AI 输出的规则**永远进待审**,绝不自动启用(决策 D 不变)。 - -## 8. 仲裁与置信度 - -### 8.1 成本-可靠性排序 - -仲裁不是简单取最高置信度,而是在**同等置信度下优先选成本更低、风险更小的机制**: - -| 机制 | 运行时成本 | 风险 | 泛化能力 | 排序权重 | -|---|---|---|---|---| -| 启用已有内置规则 | 零 | 低 | 高 | 1(最优) | -| 域名黑名单 | 极低 | 低 | 中 | 2 | -| HLS 结构化规则 | 低(P95 < 50ms) | 中 | 高 | 3 | -| URL 正则规则 | 低 | 中 | 中 | 4 | -| 音频指纹 | 中(PCM 管线 + FFT) | 中 | 仅同一广告素材 | 5 | -| 语音关键词 | 高(Sherpa-ONNX 常驻) | 高(全局误伤) | 低 | 6 | -| 本次跳过 | 零 | 低 | 无 | 7(兜底) | - -最终得分 = `confidence × 0.7 + (1 - 归一化排序权重) × 0.3`。`ALREADY_HANDLED` 类别强制降权到最后。 - -### 8.2 冲突处理 - -- 多通道指向**同一** `AdCategory`:置信度取 `1 - Π(1 - cᵢ)`(概率或),证据合并 -- 多通道指向**不同** category:全部保留,按 8.1 排序,UI 展示首选 + 「更多方式」 -- 任一通道给出 `ALREADY_HANDLED`:整体降级为「诊断结论」,首选动作变成「本次跳过 + 上报诊断到本地日志」,不新增规则。避免规则库被无效规则污染 - -### 8.3 站点域名基线(新增最小存储) - -`SitePlaylistHostBaseline`,SharedPreferences,每站点保留最近 8 个成功播放的 playlist host + 切片 host,LRU 淘汰。仅用于 7.2 的第 2 层比对。 - -不存 URL 全文、不存 query、不存 token,只存 host 字符串。单站点上限 8,全局上限 200 站点,超限按最久未用淘汰。 - -## 9. 立即跳过的执行 - -这是「本次立即跳过」决策的落点。复用 `AdSkipCoordinator` 而不是新写 seek 逻辑,因为它已经处理了所有难的部分:session/generation 校验、时钟新鲜度、直播拒绝、不可 seek 拒绝、5 秒撤销窗口。 - -新增一条入口: - -```java -// AdSkipCoordinator 新增 -public synchronized boolean onUserInterval(long startMs, long endMs, String feedbackId); -``` - -与现有 `onCandidate` / `onAutoCandidate` 的区别: -- 用户已经显式授权,跳过 `PROMPT_PENDING` 直接进 `SEEKING` -- 但**仍然执行全部安全校验**:session/generation 匹配、`endMs < durationMs`、可 seek、非直播。任一失败则 `diagnostics.record(SEEK_REJECTED)` 并降级为「已记录,未跳过」 -- 保留 5 秒 `UNDO_WINDOW`。用户框错了要能立刻回去 -- 若 `endMs` 已过(短按场景,广告已放完)→ 不 seek,只记录,第一屏文案改为「已记录该区间」 - -**同一区间的重复提交**要幂等:以 `(sessionId, startMs/1000, endMs/1000)` 为键去重,避免用户连按导致多条候选。 - -## 10. 安全与隐私 - -继承现有边界,并针对本次新增的数据做收紧: - -1. **区间时间戳不出设备**。除了用户自配的 AI 端点(Phase 3,且用户显式开启),无任何网络出口。 -2. **AI 输入去敏**:只送 host + 去参数 path + 时长数组 + 断点下标。不送完整 URL、query、token、Cookie、Authorization。现有 `AdDetectionRequest` 已遵守此约定,扩展字段沿用。 -3. **`AiDebugLog` 的落盘风险需要处理**。现有 `AiAdDetectionService.java:44,50,58` 会把完整 prompt 与响应写本地日志。区间反馈会让 prompt 包含更多播放上下文。建议:debug 日志只在 debug 构建启用,或对 host 做哈希化。这是一个既有问题,本设计不扩大它。 -4. **音频指纹不存 PCM**,只存 hash 序列(7.4)。 -5. **正则安全**:任何生成的正则在写入前必须过 `InterfaceAdRuleAnalyzer` 已有的危险模式检查(嵌套量词、反向引用),并预编译验证。复用现有实现,不新写。 -6. **规则作用域强制收窄**:反馈生成的 `HlsAdRule` 必须带 `playlistHostSuffixes`,`UserAdRule` 必须带 `siteKey`。禁止生成全局生效的规则。 - -## 11. 可观测性 - -沿用 `AdAudioDiagnostics` 的「固定枚举计数,不记敏感内容」模式,新增 `AdFeedbackDiagnostics.Code`: - -``` -INTERVAL_SUBMITTED, START_INFERRED_FROM_DISCONTINUITY, -START_INFERRED_FROM_FALLBACK, IMMEDIATE_SKIP_APPLIED, -IMMEDIATE_SKIP_REJECTED, EVIDENCE_COLLECT_FAILED, -CHANNEL_ABSTAINED, ARBITER_NO_PLAN, PLAN_ACCEPTED, -PLAN_DISCARDED, DUPLICATE_SUBMISSION, ALREADY_HANDLED_DETECTED -``` - -`AdBlockStats` 扩展三个计数(沿用现有 SharedPreferences 存储,不建表): -- `intervalFeedbackCount` — 区间反馈次数 -- `intervalSkipApplied` — 成功立即跳过次数 -- `planAcceptedByKind` — 按 `RemediationKind` 统计被采纳的方案分布 - -最后一项是**衡量本设计是否成功的核心指标**:如果一年后 `SESSION_SKIP_ONLY` 占比仍然最高,说明归因基本没用,应当回退简化。 - -## 12. 测试策略 - -### 12.1 单元测试(Phase 1 必须) - -- `AdEvidenceCollectorTest`:时间→切片下标映射,含边界(区间恰好落在切片边界、区间跨越 `EXT-X-DISCONTINUITY`、`durations` 缺失) -- `HlsSegmentClassifierTest`:五种信号的正样本各一、反样本各一;「区间内外同域名」必须弃权而非误判 -- `DomainReputationClassifierTest`:三层比对;`ALREADY_HANDLED` 的识别 -- `ExistingRuleClassifierTest`:内置规则未启用时建议启用而不是新建 -- `AdAttributionArbiterTest`:同 category 概率或、跨 category 排序、`ALREADY_HANDLED` 强制降权 -- `AdSkipCoordinatorTest` 扩展:`onUserInterval` 的 session/generation 拒绝路径、`endMs` 已过的降级、重复提交幂等 - -### 12.2 样本回归(Phase 1 必须) - -复用现有 `app/src/test/resources` 的 manifest 样本模式,为三条内置实验规则(暴风 / 量子 / 非凡)各准备一组:真实 manifest + 用户区间 + 期望归因结论。这些样本已有正/反样本,成本低。 - -### 12.3 Phase 2 追加 - -- `SpectralFingerprint` 黄金 PCM 向量端到端比对(`docs/ad-audio-fingerprint-sdk-evaluation.md` 4.1 节的既有待办,Phase 2 必须先关掉) -- 指纹碰撞检查:新采集指纹与已有规则过近时拒绝写入 -- `PendingFingerprintCapture` 的跨会话恢复 - -### 12.4 三端一致性 - -现有 `PlayerPlaybackRegressionSourceTest` / `TmdbDetailActivityLayoutTest` 靠源码文本断言锁定三端方法名。改造为 `AdFeedbackController` 后,这两个测试的期望字符串必须同步更新,且新增一条断言:三端**不得**各自持有 `submitAdFeedback` / `buildAdDetectionRequest` 的实现(防止重复实现回归)。 - -## 13. 分阶段实施 - -按「先零成本,再重成本」决策拆分。每个 Phase 独立可用、独立可回滚。 - -### Phase 1:区间输入 + 纯本地归因(无新增运行时成本) - -范围:`AdFeedbackController`、`AdEvidenceCollector`、`HlsSegmentClassifier`、`DomainReputationClassifier`、`ExistingRuleClassifier`、`AdAttributionArbiter`、`AdSkipCoordinator.onUserInterval`、对话框两屏、三端去重、`SitePlaylistHostBaseline`。 - -不含任何音频采集、不含 AI 改动(AI 保持现状可用)。 - -验收: -1. 长按可框选区间,短按可回溯推断起点且起点来源可见可调 -2. 提交后当次播放立即跳过(`endMs` 未过时),5 秒内可撤销 -3. 三条内置实验规则的样本 manifest 上,归因结论与人工标注一致 -4. 区间已被现有规则处理时,输出 `ALREADY_HANDLED` 而非新增规则 -5. 关闭 AI 时按钮可用且归因正常 -6. 三端不再存在重复的反馈实现 - -回滚:`AdFeedbackController` 的入口加一个 Setting 开关,关闭后按钮回到现有单通道 AI 行为。 - -### Phase 2:音频指纹采集闭环 - -范围:`AudioFingerprintClassifier` 的采集路径、`PendingFingerprintCapture`、指纹碰撞检查、`SpeechKeywordClassifier` 的建议(不自动启用)。 - -**前置条件**:黄金 PCM 向量比对完成(12.3)。未完成不得进入 Phase 2 —— 否则采集出的指纹可能与匹配器不兼容,规则库会被污染。 - -验收:用户框选正在播放的广告区间后,同一广告在下一集出现时能被指纹匹配到并按 `PROMPT` 模式提示。 - -### Phase 3:AI 兜底 + 输入升级 - -范围:`AiClassifier`、`AiAdDetectionService` 的 prompt 升级、`AiDebugLog` 的敏感信息收紧。 - -验收:本地通道全部弃权时 AI 被调用并给出可读解释;本地通道有强证据时 AI **不被**调用(省 token、省延迟)。 - -### 明确不做 - -- 服务端上报与众包聚合 -- 视觉 / OCR / 帧分析 -- 直播区间反馈 -- IJK / MPV 音频指纹 -- 自动启用高风险规则 - -## 14. 风险与缓解 - -| 风险 | 影响 | 缓解 | -|---|---|---| -| 用户框选不准,生成错误规则 | 正片被误删 | 规则强制带 `playlistHostSuffixes` + `siteKey` 收窄作用域;`minimumSignals ≥ 2`;`HlsManifestCleaner` 的既有安全阈值(删除比例 > 35%、时长 > 90s 一律回退)仍然兜底 | -| 归因结论全是 `SESSION_SKIP_ONLY` | 功能沦为手动跳过按钮 | 用 11 节的 `planAcceptedByKind` 度量;若一年后仍如此则回退简化 | -| 规则库膨胀 | 每 manifest 匹配耗时上升 | 碰撞检查拒绝重复;`HlsRuleConfig` 已有编译快照缓存;单条规则可禁用 | -| `legacyHeuristicActive` 干扰归因 | 证据链错误 | `ExistingRuleClassifier` 显式检测并在证据里注明;不改 AAR | -| 三端改造引入回归 | 播放器功能受损 | 源码断言测试锁定;Phase 1 带开关可整体回退 | -| 音频指纹与匹配器不兼容 | 采集的规则永不命中 | 黄金向量比对作为 Phase 2 硬前置 | - -## 15. 待确认决策 - -1. **短按的默认回溯窗口取 90 秒是否合适?** 太短会漏掉长贴片,太长会把正片框进来。建议做成设置项,默认 90s。 -2. **`AiDebugLog` 是否改为仅 debug 构建启用?** 这是既有问题,本设计只是让它暴露更多上下文。倾向于改,但属于扩大范围,需批准。 -3. **`legacyFallback=true` 是否要改为可配置?** 2.3 指出当前实际主力是无规则启发式。让用户能关掉它有利于归因准确性,但可能降低开箱去广效果。建议 Phase 1 只做诊断展示,不改行为。 -4. **`SitePlaylistHostBaseline` 的全局 200 站点上限是否足够?** 重度用户可能超。可调。 - -## 16. 架构决策摘要 - -### 决策 A:区间提交同时授权「本次执行」与「候选生成」,二者分离 - -用户框选区间这个动作本身就是对该片段的明确授权,当次跳过无需二次确认;但把它变成长期规则会影响未来所有播放,必须单独确认。因此第一屏的「本次立即跳过」默认勾选并立即执行,第二屏的「保存为规则」默认不选。 - -这样既解决了「反馈完什么都没得到」的体验问题,又不违反「AI/自动检测不得直接执行」的既有约束 —— 因为执行的授权来自用户的显式框选,不是来自任何检测器。 - -### 决策 B:AI 从裁判降级为兜底解释者 - -本地通道(切片结构、域名、已有规则)有确定性证据,大模型在这些场景只会引入噪声、延迟和 token 成本。只在本地全部弃权时才调 AI。副作用是按钮不再依赖 AI 配置,可见性门槛大幅降低,这本身是收益。 - -### 决策 C:仲裁按成本-风险排序,不按置信度裸排 - -「最好的去广告方式」不等于「置信度最高的方式」。启用一条已有的内置规则永远优于新建规则;域名黑名单永远优于音频指纹。排序权重把这个偏好编码进仲裁器。 - -### 决策 D:新逻辑全部下沉到 `main` 源集,三端只留 Host 适配 - -现有三份重复实现是维护负担的根源(改行为要同步改三处,测试靠字符串匹配锁定)。本次改造顺带解决,但不做无关重构 —— 只搬「有广告」这一条链路。 - -### 决策 E:不为区间反馈引入任何新的持久化后端 - -`SitePlaylistHostBaseline` 用 SharedPreferences,候选复用 `ImportedAdRuleCandidate` 的字段结构(它已有 `classification` / `confidence` / `riskLevel` / `reasons` / `status`),指纹复用 `AdAudioRuleStore` 的文件。全项目广告相关数据零 Room 表,这个性质保持不变。 diff --git a/docs/beta-sync-review-20260908.md b/docs/beta-sync-review-20260908.md deleted file mode 100644 index 777f47e70bf..00000000000 --- a/docs/beta-sync-review-20260908.md +++ /dev/null @@ -1,41 +0,0 @@ -# dev1 合并 beta 最新代码与复评(2026-09-08) - -## 恢复锚点 - -- **目标**:在保留 dev1 已提交未推送主题阶段 A-E 改动的前提下,合并 `origin/beta` 最新代码,审查合并后的全部本地改动与 beta 增量,修复问题、验证、复评通过后提交并推送 dev1,创建中文 PR 到 beta,最后重新拉取远端。 -- **任务守卫**:`beta-sync-review-dev1-20260908`,模式 `standard`;开始时工作树干净,无受保护脏路径;范围 `app`、`docs`。 -- **时间与基线**:2026-09-08 16:09 CST;开始时 `dev1@32ace88636f90f507288df859c325afd493b3e89`,远端 `origin/beta@253ffaa4f7908d0b90fa092f020cb20611aaadb5`;已执行 `git fetch --prune origin beta`。 -- **合并证据**:`git merge --no-ff --no-commit origin/beta` 自动完成,无冲突;`git diff --cached --check` 通过;合并结果相对 beta 为本地主题阶段 A-E 和既有审查记录。 -- **回滚**:合并提交前可 `git merge --abort`;完成后使用本任务 guard 创建的 recovery annotated tag 回退原子提交。 - -## 首轮审查结论 - -- beta 带入的 TV 触摸、TMDB 人物/详情和播放器自定义按钮改动已存在于 beta 历史中的独立评审记录(`docs/beta-sync-review-20260907-dev3-round2.md`、`docs/beta-sync-review-dev2-20260907-round3.md`);本次按最终合并树复核文件边界、合并树无冲突和现有测试覆盖,未发现新的阻断问题。 -- 本地主题阶段 A-E 覆盖 profile codec/validator、TweakCN 导入、传输边界、resolver、controller、mobile editor、TV selector/catalog/cache。首轮发现两个安全阻断:`ThemeTweakCnAdapter.parse` 直接解析输入,绕过 `ThemeProfileCodec` 的大小/嵌套上限;`ThemeTransfer.lookupPublic` 未拒绝 RFC 4193 IPv6 ULA(以及映射 IPv4/保留地址),分别可能导致导入解析资源耗尽和 DNS 解析到内网 IPv6 服务的 SSRF。 - -## 修复与验证 - -- 修复 `ThemeProfileCodec.validateJsonBounds`:在 JSON 树构造前统一限制 UTF-8 大小和括号嵌套深度;`ThemeTweakCnAdapter` 复用该边界检查。 -- 修复 `ThemeTransfer.isPublicAddress`:拒绝 RFC 4193 ULA、IPv4-mapped 私网、CGNAT、保留/文档地址,并继续拒绝本地、链路本地、站点本地和组播地址。 -- 新增 `ThemeImportExportTest` 覆盖超大/深嵌套导入、ULA、映射 IPv4、CGNAT、保留 IPv4/IPv6 与公网地址。 -- 定向验证(修复后最终一遍): - - `bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest --tests com.fongmi.android.tv.theme.ThemeImportExportTest --tests com.fongmi.android.tv.theme.ThemeProfileCodecTest --tests com.fongmi.android.tv.theme.ThemeProfileMigrationTest --tests com.fongmi.android.tv.theme.ThemeImportSourceTest --no-daemon --console=plain`:通过(`BUILD SUCCESSFUL`)。 - - 修复前合并索引主题相关测试及 `:app:compileLeanbackArm64_v8aDebugJavaWithJavac`:通过;修复仅影响共享主题安全边界,移动端最终测试已重新编译共享 Java。 - -## 修复后复评 - -- 修复后复评逐行覆盖 codec/TweakCN 两条输入路径、大小/深度限制、DNS 地址过滤、主题 profile 解析,以及合并后 beta 改动的无冲突最终树;两项首轮阻断均已闭环,未发现剩余阻断问题。 -- 当前索引已暂存本次修复与本文档,待执行 guard finish 原子提交。 - -## 下一步 - -执行 `bash .codex/scripts/task_guard.sh finish`,随后推送 `dev1`、创建中文 PR 到 `beta`,最后 fetch 远端并核验 PR/分支状态。 - -## 关闭证据 - -- `task_guard.sh finish` 已创建提交:`e4b6e9e169ac155bb55540ed6de551c71d9287a0`。 -- 已创建并推送恢复标签:`recovery/beta-sync-review-dev1-20260908/20260908173812-e4b6e9e169ac`。 -- 已推送 `dev1` 至 `origin`;最终 `HEAD == origin/dev1 == e4b6e9e169ac155bb55540ed6de551c71d9287a0`。 -- 已创建中文 PR:[#238](https://github.com/Silent1566/webhtv/pull/238),目标 `beta`,当前状态 `OPEN`,GitHub `mergeStateStatus=CLEAN`。 -- 已执行 `git fetch --prune origin` 和 `git pull --ff-only origin dev1`,结果为 `Already up to date`;工作树干净。 -- **当前状态:** 本任务代码与审查交付已完成;无后续动作。 diff --git a/docs/beta-sync-review-dev3-20260908-round4.md b/docs/beta-sync-review-dev3-20260908-round4.md new file mode 100644 index 00000000000..35af0bc5f93 --- /dev/null +++ b/docs/beta-sync-review-dev3-20260908-round4.md @@ -0,0 +1,61 @@ +# dev3 合并 beta 最新代码并复评全部未推送改动(2026-09-08) + +## Recovery anchor + +- **目标:** 在不丢失 dev3 已提交未推送的 TV 详情焦点修复的前提下,合并远端 `beta` 最新代码,复评合并后的全部本地改动与 beta 增量;如发现问题则最小修复并再次验证、复评,随后提交、推送 dev3、创建中文 PR 到 beta,最后拉取远端最新代码。 +- **任务守卫:** `beta-sync-review-dev3-20260908-round4`,模式 `standard`;开始时工作树干净,无受保护脏路径;范围 `app`、`docs`、`gradle`、`gradlew`、`scripts`、`third_party`。 +- **时间与基线:** 2026-09-08 20:08 CST 开始;本地基线 `dev3@f89c301d65c0c8d4bf6e44a3eab11e6c9c9266dd`;共同祖先 `e09e785d87b95f9da249825a79f7ad0a213f61b6`;执行了 `git fetch origin --prune`,当时 `origin/beta@74e572173fcc9ab7e274a94b6245b8de90b217c1`。 +- **本地已提交未推送改动:** `1c0bd8e1d75e0c8d3a2f420a39fcb5b23e96f48b`(剧集详情照片网格焦点)、`400c059369ead1041e1afbd9fc589bddd51490c3`(炫彩详情关闭后焦点)、`f89c301d65c0c8d4bf6e44a3eab11e6c9c9266dd`(长按详情关闭后焦点)。 +- **beta 合并证据:** `git merge --no-ff --no-commit origin/beta` 自动完成,无冲突;合并暂存树相对 `origin/beta` 仅保留上述本地 5 个焦点相关文件,beta 增量与本地文件无路径交集。 +- **回滚:** 提交前可执行 `git merge --abort`;提交后使用本任务创建的 recovery annotated tag 回退该原子合并提交。 + +## 评审范围与复用证据 + +### beta 增量 + +- beta 从共同祖先至 `74e572173fcc9ab7e274a94b6245b8de90b217c1` 的主题系统、TV 主题资源、导入导出、目录缓存和测试已在 beta 自带的 `docs/beta-sync-review-20260908.md` 中完成首轮发现、修复、定向验证和修复后复评。 +- 该记录明确闭环了两项安全问题:TweakCN 输入复用统一 JSON 大小/深度边界;主题传输拒绝 ULA、映射 IPv4、CGNAT、保留/文档地址等非公网解析结果,并记录了对应测试与 `BUILD SUCCESSFUL`。 +- 本轮确认该评审记录随 beta 原样进入最终合并树;beta 文件与本地 TV 焦点文件无交集,且合并无未解决路径。依据用户约定,对已覆盖且未发生冲突的 beta 改动不重复展开无效复评,仅核对最终树、血缘和测试覆盖。 + +### dev3 未推送改动 + +- `EpisodeDetailDialog.java` 与 `dialog_episode_detail.xml`:TV 剧集详情从海报/剧照/客串网格之间的方向键切换,会识别嵌套焦点并将焦点落到实际卡片;没有可用数据时保留后备路径,并通过 `nextFocus` 补强布局焦点链。 +- `TmdbDetailActivity.java`:炫彩详情弹窗关闭后,按返回 RecyclerView 精确定位被长按的集数;主选集 RecyclerView 在 pending adapter update 时强制恢复祖先布局,并在真实 pre-draw 后恢复焦点;焦点请求在布局计算或 holder 尚未附着时按动画帧有限重试,避免卡在旧 position 或丢焦点。 +- 两个对应测试文件新增了源码/布局契约,覆盖实际调用顺序、生命周期保护、精确 adapter position、重试路径、网格父子焦点和照片网格下导航。 + +## 评审发现与处理 + +### 首轮复评 + +- 未发现 beta 与本地改动的文件交集或合并冲突。 +- 逐处检查了 dismiss 回调的 `binding`、Activity 生命周期、RecyclerView 附着/可见状态,确认无效页面不会抢焦点;独立选集面板保留原有恢复路径,主选集才使用 pre-draw 恢复和 pending-layout 修复。 +- 检查了 `focusTmdbRecyclerItem` 的目标边界、`isComputingLayout` 分支、holder 查找、`requestFocus`/`requestFocusFromTouch` 结果和最大重试次数;未发现无限 post、越界或将 key-up 当作移动的风险。 +- 检查了 Leanback 网格空数据/不可见数据回退、选中位置边界和海报嵌套子焦点;未发现空指针或焦点落在容器而无法继续导航的问题。 + +### 修复后复评 + +- 本轮没有发现需要修复的问题,因此没有新增生产代码或测试修复;复评结论为**通过**。 +- `git diff --cached --check`、`git diff --check` 通过,`git ls-files -u` 无输出,任务守卫检查通过。 + +## 验证 + +- 定向命令: + + ```text + bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest \ + --tests 'com.fongmi.android.tv.theme.*' \ + --tests 'com.fongmi.android.tv.ui.activity.TmdbDetailActivityLayoutTest' \ + --tests 'com.fongmi.android.tv.ui.adapter.TmdbEpisodeAdapterTest' \ + --tests 'com.fongmi.android.tv.ui.dialog.EpisodeDetailDialogThemeTest' \ + :app:compileLeanbackArm64_v8aDebugJavaWithJavac \ + --no-daemon --console=plain + ``` + +- 结果:`BUILD SUCCESSFUL`;主题测试 34 项、本地 TV 焦点测试 139 项,共 173 项测试均 `failures=0`、`errors=0`、`skipped=0`;Mobile Java 编译和 Leanback Java 编译均通过。 +- 构建输出中的既有 XML 命名空间、字符串格式及 Room 查询提示为 warning,不构成本轮失败,也未修改无关资源。 +- 验证期间再次 `git fetch origin beta --prune`,`origin/beta` 仍为 `74e572173fcc9ab7e274a94b6245b8de90b217c1`,无需重新合并或重复验证。 + +## 关闭动作 + +- 下一动作:执行 `bash .codex/scripts/task_guard.sh finish --verified ... --commit-message ...`,由守卫原子提交合并及本任务记录并创建恢复标签。 +- 提交后:推送当前 `dev3` 分支和新恢复标签;用中文创建目标为 `beta` 的 PR;最后执行 `git fetch --prune origin` 与 `git pull --ff-only origin dev3`,确认工作树和远端分支状态。 diff --git a/docs/beta-sync-review-dev4-20260909-round3.md b/docs/beta-sync-review-dev4-20260909-round3.md new file mode 100644 index 00000000000..ee4dbf79e4b --- /dev/null +++ b/docs/beta-sync-review-dev4-20260909-round3.md @@ -0,0 +1,57 @@ +# dev4 合并 beta 最新代码与复评(2026-09-09) + +## Recovery anchor + +- **目标:** 拉取远端 `beta` 最新代码,合并后评审全部当前改动(包括已提交未推送的 dev4 改动),发现问题则修复、验证并复评至通过,随后提交、推送 dev4、创建中文 PR 到 beta,最后再次拉取远端最新代码。 +- **任务守卫:** `beta-sync-review-dev4-20260909-round3`,模式 `standard`,范围 `app`、`docs`;初始未跟踪文件 `app/src/leanback/java/com/fongmi/android/tv/ui/activity/VodActivity.java.bak` 已显式保护,不修改、不提交。 +- **开始状态:** 2026-09-09 08:52 CST;当前分支 `dev4`,基线 `f5096d437576891ff387670593b72750163c4fc6`;开始前仅有上述一个预-existing dirty path。 +- **beta 状态:** 已执行 `git fetch origin beta`,目标 `origin/beta@242c089b06ccd330933533b856f14a9321921a11`;共同祖先 `0f37b489f7f35719cf572da32a11701f5445a6be`;`git merge --no-commit --no-ff origin/beta` 自动完成、无冲突。 +- **回滚:** 提交前使用 `git merge --abort`;提交后使用本任务生成的恢复 annotated tag 回退原子提交。 +- **实际收口:** 合并提交 `6fd26879a1c2ceba36344cc57d14d9c6ab04e1ab` 已创建恢复 tag `recovery/beta-sync-review-dev4-20260909-round3/20260909093714-6fd26879a1c2` 并推送;发现预-existing 的 `VodActivity.java.bak` 曾被错误纳入后,未改写已发布历史,使用修正提交 `41133f85db4062f4a72e353ce61774cb2850531f` 删除仓库中的误提交文件,并创建/推送恢复 tag `recovery/beta-sync-review-dev4-20260909-bak-correction/20260909095428-41133f85db40`。该 `.bak` 已恢复为本地未跟踪保护文件。 + +## 当前范围与证据 + +- beta 合并暂存树包含 24 个 task-owned 路径;相对 `origin/beta` 的最终本地差异仅为 `HomeActivity.java`、`VodActivity.java` 及其焦点测试,另有本地 6 个已提交焦点修复承载。 +- beta 本轮新增的焦点、TMDB 长按映射、触摸适配、播放器手势、MPV 脚本按钮、速度预设和 WebHome 历史续播均已有 beta/dev 任务文档与提交验证;本轮已核对最终树未发生路径冲突或未合并残留。 +- 首轮静态评审已检查:Leanback `BaseGridView.OnTouchInterceptListener` API 与反射调用、触摸 ACTION_DOWN/UP/CANCEL 生命周期、站源搜索位置/绑定 token、首页/独立 VOD 分类边界焦点、TMDB 长按绑定集上下文与 dismiss 后焦点恢复、WebHome History 直达续播及动态 MPV 按钮可达性。 + +## 进度 + +- [x] fetch beta、启动 task guard、无冲突合并 +- [x] 保存完整差异并完成首轮静态评审 +- [x] 定向测试、Leanback/Mobile 编译与验证后复评 +- [x] 原子提交与恢复 tag(含误提交备份文件的修正提交) +- [x] 推送 dev4、创建中文 PR 到 beta +- [ ] 最后 fetch/pull 并核对状态 + +## 验证记录 + +- 已执行一次覆盖最终合并索引的定向命令: + + ```text + bash ./gradlew :app:testLeanbackArm64_v8aDebugUnitTest \ + --tests 'com.fongmi.android.tv.ui.helper.TouchOptimizationHelperSourceTest' \ + --tests 'com.fongmi.android.tv.ui.activity.VodActivityCategoryEdgeTest' \ + --tests 'com.fongmi.android.tv.ui.activity.HomeCategoryNavigationSourceTest' \ + --tests 'com.fongmi.android.tv.ui.dialog.EpisodeDetailDialogThemeTest' \ + :app:testMobileArm64_v8aDebugUnitTest \ + --tests 'com.fongmi.android.tv.ui.activity.TmdbDetailActivityLayoutTest' \ + --tests 'com.fongmi.android.tv.ui.adapter.TmdbEpisodeAdapterTest' \ + :app:compileLeanbackArm64_v8aDebugJavaWithJavac \ + :app:compileMobileArm64_v8aDebugJavaWithJavac \ + :app:assembleLeanbackArm64_v8aDebug --no-daemon --console=plain + ``` + +- 结果:`BUILD SUCCESSFUL`,耗时 1 分 41 秒;Leanback/Mobile Java 编译和 Leanback Arm64 APK 组装通过。生成的 XML 结果中,`TouchOptimizationHelperSourceTest` 11 项、`VodActivityCategoryEdgeTest` 2 项、`TmdbDetailActivityLayoutTest` 119 项、`TmdbEpisodeAdapterTest` 14 项均为 `failures=0`、`errors=0`、`skipped=0`;同一命令包含的其余定向测试也随任务成功完成。 +- 验证后安全检查:`git diff --cached --check`、`git diff --check`、`bash .codex/scripts/task_guard.sh check` 均通过;`git ls-files -u` 无输出。 + +## 验证后最终复评 + +- 针对验证后的最终索引重新检查了本地 `HomeActivity`/`VodActivity` 分类边界切换、fragment transaction 完成后的焦点恢复、触摸选择抑制自动切换,以及 beta 合并引入的 Collect/TMDB/剧集详情/播放器触摸和 WebHome 历史直达路径。 +- 最终索引相对 `origin/beta` 仅保留本地首页/独立点播分类焦点修复及对应测试;beta 新增路径与已有 dev/beta 评审记录一致,合并无未解决路径或索引冲突。 +- 未发现需要修复的生产问题,最终复评结论为**通过**。本轮不重复成功的 Gradle 构建/测试。 +- 验证边界:未执行真实设备逐帧焦点、触摸视觉、网络站源和 HDMI/音频 passthrough 回归;这些不属于本次源码/布局/单测/编译与 APK 组装的最小决定性验证范围。 + +## 下一动作 + +PR 已创建:[#242](https://github.com/Silent1566/webhtv/pull/242),标题为“合并 beta 最新代码并完成 dev4 复评”,目标 `beta`、来源 `dev4`,远端 head 为 `41133f85db4062f4a72e353ce61774cb2850531f`。唯一下一动作:推送本收口文档提交后,执行 `git fetch --prune origin beta dev4` 与 `git pull --ff-only origin dev4`,确认 `.bak` 仍为本地未跟踪文件且工作树无其他未预期改动。 diff --git a/docs/beta-sync-review-dev4-20260909-round4.md b/docs/beta-sync-review-dev4-20260909-round4.md new file mode 100644 index 00000000000..fe237028667 --- /dev/null +++ b/docs/beta-sync-review-dev4-20260909-round4.md @@ -0,0 +1,61 @@ +# dev4 合并 beta 最新代码与复评(2026-09-09 round4) + +## Recovery anchor + +- **目标:** 拉取远端 `beta` 最新代码,合并后评审全部当前改动(包括已提交未推送的 dev4 改动),发现问题则修复、验证并复评至通过,随后提交、推送 dev4、创建中文 PR 到 beta,最后再次拉取远端最新代码。 +- **任务守卫:** `beta-sync-review-dev4-20260909-round4`,模式 `standard`,范围 `app`、`docs`。 +- **开始状态:** 2026-09-09 10:39 CST;分支 `dev4`,开始前 HEAD 为 `9769ca8a084600285887f7d150446f12379bf5c0`,工作树无 dirty 路径,无需保护 pre-existing dirty path。 +- **远端状态:** 已执行 `git fetch --prune origin beta dev4`;`origin/beta` 为 `a65c7320d9213b3af01f121d9d68412dd527f380`,其父提交为 `242c089b06ccd330933533b856f14a9321921a11` 与 `515d1e04e5f6685d507c9667831cee4bc460e364`;`origin/dev4` 为 `515d1e04e5f6685d507c9667831cee4bc460e364`。 +- **beta 合入证据:** `origin/beta` 的树为 `fdeaa3b8b724eceb6a865058b31d8888651777a4`,与其第二父提交 `515d1e04e5f6685d507c9667831cee4bc460e364` 的树一致;`git diff --exit-code 515d1e04e5f6685d507c9667831cee4bc460e364 a65c7320d9213b3af01f121d9d68412dd527f380` 通过,`git merge --no-commit --no-ff origin/beta` 无冲突且不产生内容变更。远端 PR #242 已将 beta 最新线与 `515d1e04…` 汇合,因此没有伪造空 merge commit。 +- **本轮待审本地提交:** `9769ca8a084600285887f7d150446f12379bf5c0`(直播播放不受点播自动播放设置影响),相对 beta 只新增 6 个生产/资源文件;本轮另修正对应的 source regression test 并增加覆盖断言。 +- **回滚:** 本轮新提交使用 task guard 自动创建的恢复 annotated tag;若需仅回退本轮文档/测试修正,回退其新提交即可,`9769ca8a…` 保留为独立原子提交。 + +## 评审范围与结论 + +### 1. `PlaybackActivity` 自动播放策略 + +- `protected boolean shouldAutoPlay()` 默认继续返回 `PlayerSetting.isAutoPlay()`,保持点播、音频、Cast、TMDB 等既有调用方的设置行为。 +- `startPlayer` 的解析路径和直连路径都改为使用 `shouldAutoPlay()`,没有遗漏一条播放入口。 +- 移动端和 Leanback 端 `LiveActivity` 都精确覆写为 `true`,频道初次播放、切台及重新拉流均通过同一个 `startPlayer` 入口恒自动播放。 +- 没有修改 `PlayerManager` 的播放状态机、暂停/恢复或后台策略;变更只改变宿主传入的 `playWhenReady`,范围与需求一致。 + +### 2. 设置文案 + +- 默认、简体中文、繁体中文三份 `player_auto_play` 均明确为“点播自动播放”,与直播恒自动播放的实际策略一致。 + +### 3. 测试契约 + +- 初轮运行既有 `PlayerPlaybackRegressionSourceTest` 时,第 254 行的旧断言仍寻找 `PlayerSetting.isAutoPlay()` 直接传参,确认是本次抽取 `shouldAutoPlay()` 后的测试滞后。 +- 已将该断言更新为 `shouldAutoPlay()`,并加入对默认点播策略、解析/直连两条调用路径、移动端/Leanback 直播覆写的精确 source assertions。 +- 同轮另外两项失败(`panDiagnosticRespectsTheConfiguredPlayerButtonOrder` 第 82 行、`runtimePlayerActionsRefreshFocusAfterVisibilityChanges` 第 263 行)依赖的 Leanback 播放器按钮/焦点源文件未被本轮提交修改;测试文件 blame 显示其逻辑来自更早提交,归类为既有基线失败,不在本任务范围内修复。 + +## 验证记录 + +- 初轮守卫检查通过;初轮全量 `PlayerPlaybackRegressionSourceTest` 结果为 9 项完成、3 项失败,失败分类见上。 +- 修正测试后执行一次聚焦验证: + + ```text + bash .codex/scripts/task_guard.sh check + bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest \ + --tests 'com.fongmi.android.tv.ui.activity.PlayerPlaybackRegressionSourceTest.livePlaybackAlwaysAutoplaysWhileVodUsesTheConfiguredPolicy' \ + --tests 'com.fongmi.android.tv.ui.activity.LiveActivityLayoutTest' \ + :app:compileMobileArm64_v8aDebugJavaWithJavac \ + :app:compileLeanbackArm64_v8aDebugJavaWithJavac \ + --no-daemon --console=plain + ``` + +- 结果:`BUILD SUCCESSFUL`,98 个 actionable tasks 中 8 个执行;直播自动播放新断言、`LiveActivityLayoutTest`、移动端 Java 编译和 Leanback Java 编译均通过。 +- 修正后最终静态复评:重新核对 `9769ca8a…` 的 6 个生产/资源路径、两种 Live source set、`parse`/直连 `start` 调用链、三份资源文案及测试断言;未发现遗漏、行为回退或超出需求的改动。 +- `git diff --check` 与 task guard `check` 通过。未执行真实设备逐帧直播切台、网络站源和音视频硬件回归;这些不属于本轮源码策略变更的最小决定性验证范围。 + +## 当前状态与下一动作 + +- [x] fetch 远端 beta/dev4 并确认 beta 合入树无冲突 +- [x] 评审已提交未推送的直播/点播自动播放改动 +- [x] 修正测试契约并完成验证 +- [x] 修正后再次复评改动路径 +- [ ] 使用 task guard 原子提交本轮测试与文档,随后推送 `dev4` +- [ ] 创建目标为 `beta` 的中文 PR +- [ ] 最后拉取远端最新代码并核对分支、PR 和工作树状态 + +**唯一下一动作:** 在不扩大范围的前提下执行 task guard `finish`,生成本轮提交与恢复 tag。 diff --git a/docs/theme-color-system-design-20260907.md b/docs/theme-color-system-design-20260907.md deleted file mode 100644 index ebf895286ea..00000000000 --- a/docs/theme-color-system-design-20260907.md +++ /dev/null @@ -1,767 +0,0 @@ -# TweakCN 风格主题配色系统设计 - -> 状态:阶段 A+B 已实施(mobile 首版);阶段 C 已实施(mobile/leanback 运行时绑定);阶段 D 已实施(mobile 导入/导出闭环);阶段 E 实施中(TV 焦点语义与本地 catalog) -> 设计日期:2026-09-07 -> 适用范围:Android mobile 优先;共享主题模型可供 leanback/TV 后续消费 -> 参考: 以及用户提供的五张界面截图 -> 关联现有能力:`Setting.theme_color`、Material 3 Dynamic Color、应用壁纸、WebHome 主题系统 - -## Recovery anchor - -- 目标:为 WebHTV 设计一个可持久化、可预览、可导入/导出的原生主题配色系统,达到截图所示的“主题配色”编辑体验,同时保持现有首页、详情页、播放页和站源功能可回退。 -- 验收标准:阶段 A+B 已具备可持久化 profile、旧设置迁移、校验/解析、last-good 回退、备份白名单、mobile 草稿编辑器、实时预览、取消/应用和三个核心颜色编辑;阶段 C 已具备运行时明暗模式、语义控件绑定、站源弹窗 token、壁纸 scrim 和播放器控制层隔离;阶段 D 已具备 TweakCN/JSON/HTTPS/SAF 导入、预览警告、导出和系统分享;阶段 E 仍需单独实施和验证。 -- 当前文件/符号:`app/src/main/java/com/fongmi/android/tv/theme/`、`app/src/mobile/java/com/fongmi/android/tv/ui/dialog/ThemeEditorDialog.java`、`ThemeColorPickerDialog.java`、`ThemePreviewView.java`、`AppearanceDialog.java`、`app/src/main/java/com/fongmi/android/tv/setting/Setting.java`、`BaseActivity`、`CustomWallView`、`SiteDialogTheme`。 -- 已完成证据:已查看五张参考截图;已核对当前主题色、动态壁纸、Material 3 和刷新事件实现;阶段 A+B 的主题/备份 JVM 单测、mobile Java 编译和 mobile 资源处理通过;阶段 C 的 mobile/leanback Java 编译、主题单测和源代码契约测试通过;阶段 D 的 25 项主题定向测试、mobile Java 编译和 mobile 资源处理通过;当前工作树无需保护的预存脏路径。 -- 未验证项:未进行连接设备交互、视觉截图验收;阶段 E 的 TV/社区索引未实现。 -- 阶段 E 任务文档:`docs/THEME-COLOR-E-20260908-tv-catalog.md`;本阶段先实施 leanback 焦点语义绑定,再补本地 SHA-256 索引与缓存验证;不与阶段 D 混合。 - ---- - -## 1. 背景与目标 - -### 1.1 用户希望解决的问题 - -参考截图展示的是一个独立的“主题配色”编辑器,主要能力包括: - -- 在同一个编辑页面实时预览主题效果; -- 选择壁纸、着色壁纸或纯色背景; -- 选择跟随系统、浅色或深色模式; -- 从预设色板快速选择强调色; -- 编辑高亮色、应用背景色、内容表面色等核心颜色,并直接看到十六进制值; -- 导入主题(浏览 TweakCN 社区、粘贴链接或 JSON、选择 JSON 文件); -- 导出主题; -- 点击“应用”后,设置页、首页、详情页等原生页面统一换色。 - -本项目需要的是**原生 Android 应用的设计变量主题系统**,而不是把 TweakCN 网页直接嵌入 WebView,也不是引入一套新的 CSS 运行时。 - -### 1.2 设计目标 - -1. 让用户可以在应用内编辑并应用一套可验证的主题配色。 -2. 保留现有 `theme_color`、壁纸和 Material 3 动态色行为,升级过程不丢设置。 -3. 在 Android 9 等无法依赖系统动态色的设备上,也能让关键原生界面使用自定义颜色。 -4. 主题编辑期间只改变临时草稿;点击“应用”后一次性持久化并刷新,点击“取消”不影响当前主题。 -5. 支持 JSON 导入/导出,允许后续接入 TweakCN 兼容映射,但不执行远程脚本、不加载任意 CSS。 -6. 对文字/背景对比度、导入大小、来源和回滚做确定性校验。 -7. 保持播放器内核、解析器、内容源数据和业务权限不受主题影响。 - -### 1.3 非目标 - -本阶段不做: - -- 不把首页、详情页或播放器整体迁移到 WebView; -- 不让远程主题执行 JavaScript、加载任意插件或修改原生布局; -- 不直接抓取 TweakCN 社区页面并自动执行其 CSS; -- 不在第一版建设带账号、点赞、评论、在线发布能力的主题市场; -- 不重新设计播放器解码、DRM、字幕、音轨和播放状态机; -- 不同步修改 leanback 的全部视觉资源;TV 只先共享数据模型和最低限度消费契约。 - ---- - -## 2. 当前实现盘点 - -### 2.1 已有主题色入口 - -| 位置 | 当前行为 | 对新设计的影响 | -| --- | --- | --- | -| `Setting.getThemeColor()` / `putThemeColor(int)` | 以整数保存主题色;`-1` 表示关闭,`0` 表示跟随壁纸,其他值为自定义种子色 | 保留作为旧设置兼容字段和快速回退字段 | -| `Setting.getDynamicColor()` | 将主题色解析为 `0`、自定义色或壁纸色 | 可作为新主题的 seed 默认值,但不能承担所有核心 token | -| `ThemeDialog` | 只有一组固定颜色圆点,点击后立即写入设置并触发主题刷新 | 由新的编辑器替代入口;保留旧行为作为迁移/回退 | -| `AppearanceDialog` | “主题色彩”行打开 `ThemeDialog` | 行点击改为打开主题编辑器,摘要显示主题名/背景模式 | -| `BaseActivity.enableDynamicColor()` | 使用 Material 3 `DynamicColors.applyToActivityIfAvailable()` | Android 12+ 可继续使用;低版本需增加应用侧 token 绑定 | -| `SiteDialogTheme` | 使用 Material 1.14.0 `ColorRoles`,不依赖系统动态色支持 | 可复用同一套 token/对比度规则 | -| `CustomWallView` | 读取现有壁纸设置并绘制壁纸/纯色/动效壁纸 | 新系统不直接破坏壁纸设置,使用主题背景层和内容 surface 控制可读性 | -| `RefreshEvent.theme()` | 当前主题改变后重建 Activity | 作为应用主题应用后的统一刷新路径 | -| `Backup.APP_PREFS` | 已备份 `theme_color`、`wall_color` 等字段 | 新增主题配置字段时必须加入备份白名单并兼容旧备份 | - -### 2.2 参考截图拆解 - -| 截图 | 观察到的交互/视觉 | 本设计对应方案 | -| --- | --- | --- | -| `QQ20260907-215135.png` | 主题编辑器、实时预览、背景/明暗分段控件、预设色板、核心颜色列表、取消/应用 | `ThemeEditorDialog` 草稿状态 + `ThemePreviewView` | -| `QQ20260907-215204.png` | 导入弹窗包含浏览社区、粘贴链接或 JSON、选择 JSON 文件 | `ThemeImportDialog`;第一版浏览社区使用系统浏览器,导入走 JSON | -| `QQ20260907-215232.png` | 设置页显示主题摘要和已应用提示 | `AppearanceDialog` 摘要 + 应用后统一刷新提示 | -| `QQ20260907-215313.png` | 首页卡片、分类和底部面板使用同一强调色 | 首页的语义 token 消费和选中态颜色统一 | -| `QQ20260907-215333.png` | 详情页工具栏、线路、选集和正文表面共享强调色/背景色 | 详情动作、标签、选集和正文 surface 接入 token | - ---- - -## 3. 总体方案 - -### 3.1 核心原则 - -采用“**主题配置 → 解析为语义 token → 原生页面消费 token**”三层结构: - -```text -ThemeProfile JSON / 旧设置 - | - v -ThemeProfileStore + ThemeProfileValidator - | - v -ThemeResolver(模式、seed、显式覆盖、对比度修正) - | - v -ThemeTokens(canvas / surface / text / accent / focus ...) - | - +--> BaseActivity / ThemeContextBinder - +--> Home / Detail / Setting semantic resources - +--> SiteDialogTheme - +--> WebHome chrome / WebTheme token bridge(只读) -``` - -主题只描述外观,不描述页面业务、数据源、播放地址或权限。页面继续使用现有原生数据和事件链路。 - -### 3.2 为什么不直接把 `theme_color` 扩展成多个整数 - -单独增加多个 `theme_*_color` 偏好值会造成: - -- 明色/暗色没有成对数据,系统模式切换时颜色可能不可读; -- 不能表达主题名称、来源、版本和导入信息; -- 导入/导出缺少稳定 schema; -- 新增 token 时需要不断增加独立偏好键; -- 回滚和校验难以做到原子化。 - -因此使用一个带版本的主题 JSON 作为新数据源,同时保留 `theme_color` 等旧键作为兼容镜像和故障回退。 - -### 3.3 应用边界 - -- **设置编辑器**:完整支持主题草稿、预览、导入和导出。 -- **原生页面**:第一阶段覆盖设置、首页、站源弹窗、详情页关键表面和动作;播放器仅覆盖控制层可着色区域,不改变视频画面和播放内核。 -- **WebHome**:向内置/受信主题提供只读 token 快照;远程主题不能调用写入接口改变原生主题。 -- **leanback**:读取同一 `ThemeProfile`,先映射 seed、背景和选中/焦点色;完整 TV 版资源替换作为后续任务。 - ---- - -## 4. 主题数据模型 - -### 4.1 顶层 schema - -建议文件格式为 `webhtv-theme/v1`,颜色统一采用不带透明度的 `#RRGGBB`;解析时接受 `#RGB`、`#RRGGBB` 和 `#AARRGGBB`,但应用 token 拒绝非 `FF` alpha,避免透明色污染文字和 surface。 - -```json -{ - "schemaVersion": 1, - "format": "webhtv-theme", - "id": "webhtv.claude-plus", - "name": "Claude +", - "author": "optional", - "source": { - "type": "local|tweakcn|url", - "url": "https://example.com/theme.json" - }, - "mode": "system|light|dark", - "background": { - "type": "wallpaper|tinted-wallpaper|solid", - "color": "#F8FAFC", - "scrimAlpha": 0.0 - }, - "seedColor": "#155DFC", - "colors": { - "light": { - "primary": "#155DFC", - "appBackground": "#F8FAFC", - "surface": "#FFFFFF", - "surfaceElevated": "#FFFFFF", - "onSurface": "#182230", - "onSurfaceVariant": "#526071", - "outline": "#CBD5E1", - "focus": "#155DFC", - "error": "#BA1A1A" - }, - "dark": { - "primary": "#A9C7FF", - "appBackground": "#111827", - "surface": "#1F2937", - "surfaceElevated": "#273449", - "onSurface": "#E5EDF8", - "onSurfaceVariant": "#B7C4D6", - "outline": "#455468", - "focus": "#A9C7FF", - "error": "#FFB4AB" - } - }, - "metadata": { - "tags": ["blue", "minimal"], - "previewVersion": 1 - } -} -``` - -### 4.2 字段语义 - -- `schemaVersion`:只用于文件解析兼容,不等同于应用版本。 -- `id`:稳定主题 ID;导入相同 ID 时通过版本/来源确认是否覆盖。 -- `name`:设置摘要和主题编辑器标题使用的可见名称。 -- `mode`:`system`、`light`、`dark`,对应截图中的明暗模式分段。 -- `background.type`: - - `wallpaper`:保留当前壁纸,内容表面按 token 叠加; - - `tinted-wallpaper`:保留壁纸并叠加主题色 scrim; - - `solid`:使用 `background.color` 的不透明 canvas 覆盖壁纸视觉。 -- `seedColor`:Material 角色生成和旧版兼容使用的强调色种子。 -- `colors.light/dark`:最终消费的语义色。缺少某个角色时由 `ThemeResolver` 从 `seedColor` 和背景推导。 -- `metadata`:只保存展示和兼容信息,不允许嵌入脚本、资源路径或任意 Android 属性。 - -### 4.3 可编辑核心颜色 - -第一版编辑器只暴露三个核心色,避免把 Material 角色表变成难以理解的专业配置: - -| UI 名称 | token | 用途 | -| --- | --- | --- | -| 高亮色 | `primary` | 选中态、主操作、导航选中、线路/选集当前项、焦点 ring | -| App 背景色 | `appBackground` | 页面 canvas、设置页和列表页底层背景 | -| 内容表面色 | `surface` | 卡片、面板、内容区域和弹窗主体 | - -以下颜色自动派生或在高级导入中接受: - -- `onSurface`、`onSurfaceVariant`; -- `surfaceElevated`; -- `primaryContainer`、`onPrimaryContainer`; -- `outline`、`focus`、`error`。 - -如果用户导入完整 token 集,则保留已验证的显式值;如果只提供 seed/三种核心颜色,则补齐缺失角色。 - -### 4.4 TweakCN/shadcn token 映射 - -第一版不依赖 TweakCN 的内部 API,只提供通用 CSS token 到 WebHTV token 的离线映射: - -| 常见 TweakCN token | WebHTV token | -| --- | --- | -| `--primary` | `primary` | -| `--primary-foreground` | `onPrimary` | -| `--background` | `appBackground` | -| `--card` / `--popover` | `surface` / `surfaceElevated` | -| `--foreground` | `onSurface` | -| `--muted-foreground` | `onSurfaceVariant` | -| `--border` / `--input` | `outline` | -| `--ring` | `focus` | -| `--destructive` | `error` | - -未识别 token 忽略并记录导入警告,不影响当前主题。若未来需要直接支持社区主题页面,增加独立适配器,不把网页 CSS 直接当成 Android 主题输入。 - ---- - -## 5. 颜色解析与可读性规则 - -### 5.1 解析顺序 - -`ThemeResolver` 按以下顺序生成当前运行态 `ThemeTokens`: - -1. 读取并校验本地 `ThemeProfile`; -2. 根据 `mode` 确定 light/dark 分支; -3. 以显式 token 为最高优先级; -4. 用 `seedColor` 调用 Material 1.14.0 的 `ColorRoles` 补齐强调色相关角色; -5. 缺失背景/表面时读取当前壁纸色或稳定默认值; -6. 计算 `on*`、outline 和 focus 的对比度; -7. 对不满足最低对比度的自动派生值进行修正; -8. 生成不可变的 `ThemeTokens`,供 Activity 生命周期内使用。 - -### 5.2 可读性约束 - -- 正文文字与其背景目标对比度至少 4.5:1; -- 大字号、图标、焦点边框目标对比度至少 3:1; -- 主按钮前景和背景至少 4.5:1; -- 禁止透明核心色; -- 用户显式输入对比度不足时,编辑器显示明确警告并禁止“应用”,而不是静默改变用户颜色; -- 自动派生的 `onSurface` 等角色可以在计算时调整,但预览必须反映最终值; -- TV 焦点态不能仅依赖颜色变化,还应保留边框/形状/亮度差异。 - -### 5.3 壁纸可读性 - -壁纸不是 token 的替代品: - -- `wallpaper` 模式必须加一层根据当前 token 透明度计算的内容 surface; -- `tinted-wallpaper` 的 scrimAlpha 限制在 `0.0..0.85`; -- `solid` 模式的 canvas 必须完全不透明,确保文字和卡片不会受底层动态图影响; -- 动态壁纸、视频壁纸和 GIF 壁纸不参与主题 JSON 导入,仍由现有 `CustomWallView` 管理。 - ---- - -## 6. 用户体验设计 - -### 6.1 设置入口 - -保留现有设置层级: - -```text -设置 → 外观与语言 → 主题配色 -``` - -点击后打开 `ThemeEditorDialog`(mobile 可使用全屏/大尺寸 Material Dialog;不新建独立 Activity),避免用户离开设置上下文。 - -设置行摘要建议: - -```text -Claude + · 纯色 -Claude + · 着色壁纸 -默认 · 跟随壁纸 -``` - -应用成功后显示一次轻量提示,并触发现有 `RefreshEvent.theme()`。刷新/重建期间不修改用户当前导航、历史、收藏和播放数据。 - -### 6.2 编辑器布局 - -按截图顺序设计: - -1. 顶部标题“主题配色”;右侧操作:重置、导入、导出; -2. “实时预览”卡片:应用名、搜索图标、收藏图标、主按钮、次按钮、集数标签; -3. “背景模式”:壁纸 / 着色壁纸 / 纯色; -4. “明暗模式”:跟随系统 / 浅色 / 深色; -5. “高亮色预设”:横向可滚动圆点,保留现有预设色并增加主题 profile 的预设; -6. “核心颜色”:三行可点击的色块、名称和 HEX; -7. 底部“取消 / 应用”。 - -所有控件最小触摸目标 48dp;预设色横向滚动不抢占核心颜色列表滚动;HEX 文本应支持复制但不要求用户直接编辑文本。 - -### 6.3 实时预览 - -预览只使用内存中的 draft `ThemeProfile`: - -- 改变模式、背景或颜色时,只重新解析 `ThemeTokens` 并刷新预览; -- 不写 `SharedPreferences`; -- 不重建宿主 Activity; -- 预览覆盖至少:页面背景、卡片、主操作、次操作、焦点/选中态、正文和弱化文字; -- 若 draft 对比度不足,预览顶部显示“不可应用”的原因。 - -### 6.4 颜色编辑器 - -颜色编辑器支持: - -- 预设色选择; -- HEX 输入(规范化为大写 `#RRGGBB`); -- Android 原生可访问的色相/饱和度/亮度选择控件(若项目现有组件不足,第一版以 HEX + 预设为主); -- 显示当前颜色与建议前景色; -- 显示对比度结果; -- 取消时回到编辑器 draft,不影响已应用主题。 - -### 6.5 导入弹窗 - -对应截图的三个入口: - -1. **浏览 TweakCN 社区**:使用系统浏览器打开社区页面;第一版不在应用内抓取社区页面。 -2. **粘贴链接或 JSON**:支持本项目 schema JSON、TweakCN 常见 token JSON;HTTPS 链接下载后经过大小、内容和 schema 校验。 -3. **选择 JSON 文件**:使用 Storage Access Framework `ACTION_OPEN_DOCUMENT`,不申请整盘存储权限。 - -导入流程: - -```text -读取 -> 限制大小 -> JSON 解析 -> schema/颜色校验 - -> token 映射 -> 对比度检查 -> 预览导入结果 - -> 用户确认 -> 替换编辑器 draft -``` - -导入不会直接应用;用户仍需在编辑器点击“应用”。 - -### 6.6 导出 - -导出内容为脱敏后的 `webhtv-theme/v1` JSON: - -- 通过 `ACTION_CREATE_DOCUMENT` 保存,或调用系统分享; -- 不包含站点 URL、Cookie、播放记录、用户配置和设备信息; -- 保留主题名、颜色、模式、背景模式和来源元数据; -- 导出失败不改变当前主题。 - ---- - -## 7. 原生架构设计 - -### 7.1 建议新增组件 - -| 组件 | 职责 | -| --- | --- | -| `ThemeProfile` | 不可变主题配置模型;负责 schema 字段,不负责 UI | -| `ThemeProfileCodec` | Gson JSON 读写、版本兼容、旧格式映射 | -| `ThemeProfileValidator` | 字段、颜色、范围、大小和来源校验 | -| `ThemeProfileStore` | 读写当前 profile、last-known-good 和迁移状态 | -| `ThemeResolver` | 根据系统模式、壁纸 seed 和显式 token 生成 `ThemeTokens` | -| `ThemeTokens` | 运行时不可变语义颜色集合 | -| `ThemeController` | 启动时读取主题、应用模式、发出刷新事件 | -| `ThemeEditorDialog` | 编辑器 UI、draft、应用/取消 | -| `ThemeImportDialog` | 社区、链接、文件导入入口 | -| `ThemeColorPickerDialog` | 单个核心颜色编辑和对比度提示 | -| `ThemePreviewView` | 与实际应用无业务耦合的预览组件 | -| `ThemeExport` | SAF 创建文件与系统分享 | -| `ThemeAware`(可选) | 需要在 Android 低版本运行时接收 token 的原生 View/容器契约 | - -首版不新增第三方依赖:项目已经使用 Gson 和 Material 1.14.0,优先复用现有实现。 - -### 7.2 Activity 应用时机 - -```text -Application.onCreate - -> 读取并验证 profile - -> 设置 AppCompat night mode(system/light/dark) - -BaseActivity.onCreate - -> 解析当前 ThemeTokens - -> API 31+ 继续尝试 Material DynamicColors - -> setContentView - -> 对需要低版本运行时换色的语义容器绑定 ThemeTokens - -> 创建 CustomWallView / 其他内容 -``` - -现有 `RefreshEvent.THEME` 继续作为应用后的统一刷新信号。应用配置时先写入 profile 和兼容字段,再发事件;若写入失败则不发事件。 - -### 7.3 低版本兼容策略 - -Material Dynamic Color 的系统入口不能作为 Android 9 等设备的唯一方案。建议分两层: - -1. **资源层**:把新增/维护中的原生布局从固定白/黑色迁移到语义属性或语义 color selector,例如 `colorPrimary`、`colorSurface`、`colorOnSurface`、`colorOutline`。 -2. **运行时层**:对无法通过主题属性解析、但必须支持自定义色的页面容器实现 `ThemeAware`,从 `ThemeTokens` 设置背景、文字、按钮、图标 tint 和状态列表。 - -以下区域继续保持独立,不强行套用普通页面主题: - -- 视频内容本身; -- 播放器上需要保证可见性的黑色渐变和控制层; -- 健康状态点、错误/警告等语义色; -- 站点内容图片和源数据中的颜色。 - -### 7.4 页面消费优先级 - -第一阶段按以下顺序替换: - -1. 主题编辑器预览和设置页; -2. 首页导航、分类、选中卡片、底部面板和站源弹窗; -3. 详情页顶部操作、线路/选集、正文 surface; -4. 播放器控制层的强调色和焦点色; -5. 其他低风险原生弹窗和列表。 - -每个页面都必须保留旧资源回退,不允许主题失败导致页面不可进入。 - ---- - -## 8. 持久化、迁移与回滚 - -### 8.1 新旧设置关系 - -新增建议键: - -- `theme_profile_json`:当前完整 profile; -- `theme_profile_last_good`:最近一次验证通过且成功应用的 profile; -- `theme_profile_schema`:可选,仅用于快速判断迁移版本。 - -现有键继续保留: - -- `theme_color`:写入 profile 的 `seedColor` 镜像; -- `wall_color`、`wall`、`wall_type`:继续由现有壁纸系统管理;主题 profile 只记录编辑器选择的背景模式和必要覆盖。 - -如果主题使用 `solid` 背景,不应直接删除用户现有壁纸;切回 `wallpaper` 时恢复原壁纸设置。 - -### 8.2 首次迁移 - -当 `theme_profile_json` 不存在时: - -- `theme_color == -1`:生成默认 profile,`seedColor` 为空,模式跟随系统; -- `theme_color == 0`:生成 seed 来源为壁纸的 profile; -- `theme_color > 0`:生成对应 seed 的 profile; -- 当前 `wall/wall_type` 只用于解析预览背景,不自动改变已有壁纸; -- 不在启动时立即触发第二次重建,只在用户打开/应用编辑器或下一次正常 Activity 创建时完成迁移。 - -### 8.3 应用事务 - -应用按钮按以下顺序执行: - -```text -validate(draft) - -> serialize(draft) - -> write theme_profile_json atomically - -> update legacy mirrors - -> write last_good - -> publish RefreshEvent.theme() -``` - -任何一步失败: - -- 保留旧 profile; -- 不更新兼容镜像; -- 不刷新页面; -- 向用户显示“应用失败,当前主题未改变”。 - -### 8.4 回滚 - -- 编辑器“重置”:只将 draft 恢复为内置默认 profile,等待用户点击应用; -- 设置中的“恢复默认”:删除当前 profile,恢复 `theme_color=-1`,模式跟随系统,保留用户壁纸; -- profile JSON 损坏:读取 `theme_profile_last_good`;若也损坏,回退旧 `theme_color` + 系统默认色; -- 任何导入主题都必须先进入 draft,不允许远程输入直接覆盖 last-good; -- 回滚不删除导出文件,不修改备份中的其他偏好。 - -### 8.5 备份兼容 - -新增 profile 键加入 `Backup.APP_PREFS`。恢复旧备份时缺少 profile 字段则走迁移逻辑;恢复新备份到旧版本时,旧版本忽略未知键并继续使用 `theme_color` 镜像。 - ---- - -## 9. 安全设计 - -### 9.1 本地 JSON - -- 最大大小:建议 256 KiB; -- 最大嵌套深度:建议 8; -- 只接受对象,不执行字符串中的代码; -- 颜色、枚举、数值范围严格校验; -- 忽略未知字段,拒绝危险/模糊字段(脚本、资源路径、Intent、类名、任意 CSS); -- `source.url` 只作为展示元数据保存,不在离线导入时自动访问。 - -### 9.2 HTTPS 链接 - -- 只接受 `https://`;拒绝 HTTP、文件 URI、私有 IP、环回地址和本地路径; -- 首次访问显示域名并要求用户确认; -- 使用现有网络栈,设置连接/读取超时和响应上限; -- 不发送 Cookie、Authorization、站点请求头或用户配置; -- 不允许重定向到非 HTTPS、私网或不同来源而不重新确认; -- 下载完成后按本地 JSON 相同规则校验; -- 网络失败只影响导入,不影响现有主题。 - -### 9.3 TweakCN 社区 - -“浏览 TweakCN 社区”第一版只是打开用户可见的系统浏览器页面。应用不保存登录态、不注入脚本、不自动抓取页面、不把社区 CSS 当作 Android 样式执行。 - -如果后续提供内置主题索引,必须采用静态 JSON + 预览图 + schema 校验,增加签名/哈希、版本、来源和回滚机制后才能进入应用内主题列表。 - ---- - -## 10. 分阶段实施计划 - -### 阶段 A:模型与迁移(建议先做) - -范围: - -- `ThemeProfile`、`ThemeTokens`、codec、validator、store、resolver; -- 旧 `theme_color` 迁移; -- `Backup.APP_PREFS` 新字段; -- 无 UI 大改,现有 `ThemeDialog` 仍可工作; -- 单元测试覆盖 JSON、颜色规范化、迁移、对比度和回滚。 - -完成标志:任何旧安装启动后都能得到有效 profile;无 profile/corrupt profile 不阻塞启动。 - -### 阶段 B:主题编辑器与实时预览 - -范围: - -- `AppearanceDialog` 入口替换; -- `ThemeEditorDialog`、预览、背景/明暗分段、预设色和三个核心颜色; -- 取消/应用/重置; -- 颜色编辑器和对比度提示。 - -完成标志:不点击应用不会改变当前界面;应用后主题摘要和关键页面发生预期变化。 - -### 阶段 C:原生页面 token 接入 - -范围: - -- BaseActivity / ThemeController 应用模式; -- 设置、首页、站源弹窗、详情关键 surface、播放器控制层逐步迁移到 semantic tokens; -- 低版本 `ThemeAware` 绑定; -- 保留现有 `SiteDialogTheme` 的对比度行为并统一 token 来源。 - -完成标志:Android 9+ 在不依赖平台 Dynamic Color 的情况下,至少设置/首页/详情关键控件能切换明暗和强调色;失败页面仍能回退。 - -### 阶段 D:导入/导出与 TweakCN 映射 - -范围: - -- 浏览社区、粘贴 JSON/HTTPS 链接、SAF 文件导入; -- TweakCN/shadcn 常见 token 映射; -- SAF 导出/系统分享; -- 导入预览、警告和失败回滚。 - -完成标志:导出的文件可在另一台安装同版本应用中导入;不支持的 token 不会导致崩溃或执行代码。 - -### 阶段 E:TV 与社区索引(实施中) - -范围: - -- leanback 焦点和遥控交互; -- TV 语义资源完整迁移; -- 受签名/哈希保护的静态主题索引和预览图; -- 主题版本回滚和缓存。 - -任务文档:`docs/THEME-COLOR-E-20260908-tv-catalog.md`。本阶段仍不与阶段 A-D 混合实施;社区索引只做 APK 内置静态 catalog,不发起在线请求。 - ---- - -## 11. 验证计划 - -### 11.1 单元/源代码测试 - -- `ThemeProfileCodecTest`:合法、缺字段、未知字段、版本号、颜色格式; -- `ThemeProfileValidatorTest`:透明色、范围、枚举、大小和对比度; -- `ThemeProfileMigrationTest`:`theme_color=-1/0/自定义`、旧备份和损坏 profile; -- `ThemeResolverTest`:system/light/dark、显式覆盖、seed 补全和壁纸模式; -- `ThemeTweakCnAdapterTest`:常见 CSS token 映射及未知 token; -- `ThemeStoreRollbackTest`:写入失败和 last-good 回滚; -- `Backup` 源码契约测试:新偏好键包含在备份集合中。 - -### 11.2 Android 编译验证 - -最小验证顺序: - -1. `:app:compileMobileArm64_v8aDebugJavaWithJavac`; -2. `:app:processMobileArm64_v8aDebugResources`; -3. `:app:assembleMobileArm64_v8aDebug`; -4. 若修改 TV 共享模型,再运行对应 leanback Java 编译/assemble。 - -不因主题 UI 任务运行全 ABI 矩阵,除非共享代码或资源触及所有变体。 - -### 11.3 设备场景 - -至少验证: - -- Android 9:默认主题、蓝色主题、暗色主题、纯色/壁纸主题; -- Android 12+:系统动态色与应用显式 profile 的优先级; -- 打开编辑器修改但取消,当前页面颜色不变; -- 应用主题后设置、首页、站源弹窗、详情页能进入且颜色一致; -- 应用重启后 profile 不丢失; -- 导入无效 JSON、超大 JSON、低对比度颜色不改变当前主题; -- 导出后删除当前 profile,再导入导出文件恢复; -- 播放页进入、暂停、切换线路、返回均不受主题系统影响; -- 动态壁纸刷新和主题切换不泄漏播放器/壁纸资源; -- 断网时打开主题编辑器仍可编辑本地主题,只有链接导入不可用。 - -### 11.4 视觉验收 - -以用户提供的截图为验收参考,不要求像素级复制,但应满足: - -- 编辑器信息层级、分段控件、色板、颜色行和底部操作可辨识; -- 预览中的主色、surface、正文和选中态同步变化; -- 主题应用后设置摘要、首页和详情页的强调色一致; -- 深浅模式下正文、按钮、焦点态均满足对比度规则; -- 窄屏手机滚动、输入法、返回键和系统字体放大不裁切关键操作。 - ---- - -## 12. 风险、取舍与回滚策略 - -| 风险 | 影响 | 缓解 | -| --- | --- | --- | -| 当前大量布局使用固定白/黑色 | 低版本无法全局换色 | 先迁移关键页面到语义 token;播放器和特殊遮罩保留专用资源 | -| Material Dynamic Color 在低版本不可用 | Android 9 主题色不生效 | 使用 `ThemeTokens` + `ThemeAware`,不把系统动态色当唯一实现 | -| 自定义颜色对比度不足 | 文字不可读 | 应用前验证;自动角色派生;编辑器显示对比度 | -| 壁纸动效与文字冲突 | 闪烁/不可读 | scrim 和 opaque solid canvas;保留现有壁纸生命周期 | -| 旧设置与新 profile 不一致 | 升级后主题变化 | 单向迁移、兼容镜像、last-good 和原子写入 | -| 导入远程 JSON 不可信 | 资源消耗或恶意输入 | HTTPS、来源确认、大小/范围/schema 校验,不执行脚本 | -| 全量 Activity 重建造成闪白 | 用户体验下降 | 草稿只更新预览;应用一次重建;启动读取已验证缓存 | -| TV 资源未同步 | 电视端显示不一致 | 第一阶段 TV 只消费共享模型和最低 token,完整迁移单独排期 | -| 主题改动影响播放层 | 播放回归 | 明确播放器内核非目标;播放场景单独回归 | - -回滚方式:撤销本任务对应实现提交即可;数据层保留 `theme_color` 和旧壁纸键,不要求数据库迁移。若实现阶段发现全局 token 改造范围超出批准边界,应先回滚到模型/编辑器独立阶段,不把半套全局颜色接入留在主分支。 - ---- - -## 13. 建议与审批点 - -### 建议 - -建议采用 **A → B → C → D → E** 的顺序,但将 A/B 作为第一期可独立验收单元:先建立可迁移、可验证的 profile 和编辑器,再逐步扩大原生页面消费范围。第一期不直接建设在线主题市场,也不直接抓取 TweakCN 页面。 - -### 需要用户确认的范围 - -1. 是否先只实施 mobile,还是 A/B 阶段同时制作 leanback 编辑器? -2. 是否接受“第一版只支持 TweakCN 常见 JSON/CSS token 映射,社区浏览通过系统浏览器完成”,而不是在应用内直接展示社区列表? -3. 是否接受现有播放器画面/特殊黑色遮罩不参与普通页面主题换色? -4. 是否批准先实施阶段 A+B,完成后再单独审批阶段 C 的全局原生页面接入? - -阶段 A+B 已获实施并完成;阶段 C/D/E 仍需单独确认范围后再修改对应生产代码、资源或运行时行为。 - ---- - -## 14. 实施记录:阶段 A+B(2026-09-07) - -### 已实现 - -- 新增 `com.fongmi.android.tv.theme` 共享数据层:`ThemeProfile`、`ThemeTokens`、`ThemeColorUtil`、`ThemeProfileCodec`、`ThemeProfileValidator`、`ThemeProfileStore`、`ThemeResolver`。 -- `App.onCreate()` 启动时执行旧 `theme_color` 到 profile 的无阻塞迁移;保留 `theme_color` 镜像,保存 `theme_profile_json`、`theme_profile_last_good` 和 `theme_profile_schema`。 -- profile JSON 限制 256 KiB、最大嵌套深度 8、只接受对象、拒绝脚本/Intent/类名/CSS 等危险字段,颜色统一规范化为大写不透明 `#RRGGBB`,显式角色执行对比度校验。 -- 将三个 profile 字段加入 `Backup.APP_PREFS`,兼容旧备份的 `theme_color`。 -- mobile 的“主题色彩”入口改为 `ThemeEditorDialog`:草稿态实时预览、壁纸/着色壁纸/纯色、系统/浅色/深色、预设高亮色、`primary`/`appBackground`/`surface` 三个核心颜色、HEX 编辑、重置、取消和应用。 -- 应用使用一次 `SharedPreferences.Editor.commit()` 写入当前 profile、旧镜像和 last-good,成功后发布既有 `RefreshEvent.theme()`;失败不发布刷新事件。 - -### 明确未实现 - -- 阶段 C:BaseActivity/语义资源/首页/详情/播放器控制层的全局 token 消费尚未接入;现有 Dynamic Color、壁纸和旧页面资源仍是运行时主路径。 -- 阶段 D:已实施 JSON/HTTPS/SAF 导入导出、TweakCN/shadcn 常见 token 映射、社区浏览器入口、导入预览/警告与 draft-only 回滚;尚未进行设备交互和视觉截图验收。 -- 阶段 E:leanback 完整焦点主题和社区索引尚未实施。 - -### 验证 - -执行: - -```text -bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest \ - --tests com.fongmi.android.tv.theme.ThemeProfileCodecTest \ - --tests com.fongmi.android.tv.theme.ThemeProfileValidatorTest \ - --tests com.fongmi.android.tv.theme.ThemeProfileMigrationTest \ - --tests com.fongmi.android.tv.theme.ThemeResolverTest \ - --tests com.fongmi.android.tv.bean.BackupPreferenceFilterTest \ - :app:compileMobileArm64_v8aDebugJavaWithJavac \ - :app:processMobileArm64_v8aDebugResources \ - --no-daemon --console=plain -``` - -结果:`BUILD SUCCESSFUL`;本轮指定的主题/备份测试通过;mobile Java 编译和资源处理通过。未进行设备交互和视觉截图验收。预存的 `TouchOptimizationHelper.java` 改动未被修改或纳入本任务。 - -### Recovery anchor(实施后) - -- 目标:完成主题系统阶段 A+B 的可回滚首版,不扩大到阶段 C/D/E。 -- 当前文件:`app/src/main/java/com/fongmi/android/tv/theme/**`、mobile 主题编辑器/预览/布局、备份与字符串、主题单测。 -- 已完成:代码、资源、测试均在 task guard 声明范围;上述验证通过。 -- 未完成:设备/视觉验收,以及阶段 C/D/E。 -- 下一步唯一动作:记录最终 diff 后执行 `task_guard.sh finish`,原子提交并创建本地恢复 tag。 - -## 15. 实施记录:阶段 C(2026-09-08) - -### 已实现 - -- 新增 `ThemeController`,在 Application 和 mobile/leanback `BaseActivity` 启动时读取已验证 profile,设置 `system/light/dark` 对应的 AppCompat 夜间模式;Activity 树完成后再次绑定,覆盖初始化期间动态创建的详情/播放器控件。 -- mobile 继续使用 Material Dynamic Color 作为有 seed profile 的增强路径;没有 seed 或低版本不可用时,由 `ThemeTokens` 直接绑定 Material toolbar、底部导航、TabLayout、FAB、MaterialButton、MaterialCardView、语义文字和语义图标。 -- 站源弹窗改为直接消费 `ThemeTokens`,不再以旧 `theme_color` seed 重新生成另一套颜色;旧的整数入口保留为兼容调用。 -- `CustomWallView` 增加独立、不可点击的 `themeScrim` 层:纯色 profile 使用不透明 canvas,着色壁纸使用受限 scrim,动态壁纸仍沿用原有 Exo/GIF 生命周期。 -- 播放器控制层显式标识为 `playerControlRoot`/`detailControlHost`,只应用 primary/focus 图标和进度条颜色,保留视频画面、黑色遮罩和控制文字的既有可读性;不对播放器内核或视频内容做主题改写。 - -### 验证 - -```text -bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest \ - --tests com.fongmi.android.tv.theme.* \ - --tests com.fongmi.android.tv.ui.dialog.SiteDialogThemeSourceTest \ - --no-daemon --console=plain - -bash ./gradlew :app:compileMobileArm64_v8aDebugJavaWithJavac \ - :app:compileLeanbackArm64_v8aDebugJavaWithJavac \ - --no-daemon --console=plain -``` - -结果:两次定向主题测试/源代码契约测试通过;mobile 与 leanback Java 编译通过。第一次阶段 C 编译曾因错误的 `TabLayout` 方法名失败,修正为 `setSelectedTabIndicatorColor` 后重跑通过。尚未进行连接设备、视觉截图和全量 assemble 验收。 - -### 回滚与剩余范围 - -- 回滚:撤销阶段 C 提交即可回到阶段 A+B;profile、`theme_color` 和壁纸键仍保持兼容。 -- 剩余:阶段 D 的 JSON/HTTPS/SAF 导入导出、TweakCN token 映射和社区浏览入口;阶段 E 的 TV 完整焦点资源与签名社区索引。 -- 下一步:执行本任务 guard finish;新阶段 D 必须使用独立 task ID、独立 guard 和独立验证。 - -## 16. 实施记录:阶段 D(2026-09-08) - -### 已实现 - -- 新增 `ThemeColorUtil.normalizeCss`,支持 allowlist 范围内的 HEX、RGB、HSL 和 Oklch 颜色转换;透明色、CSS/字体/圆角等非颜色字段不进入原生主题。 -- `ThemeTweakCnAdapter` 支持 WebHTV profile、扁平 shadcn token 和真实 TweakCN registry 的 `cssVars.light`/`cssVars.dark` 结构;缺失 dark 角色从扁平主题复制,未支持 token 进入最多 32 条警告。 -- `ThemeTransfer` 使用无 Cookie、无代理、禁自动跳转的 HTTPS 客户端;解析 DNS 后拒绝私有/特殊地址,响应体和 SAF 输入均限制为 256 KiB,并关闭响应资源。 -- 新增 `ThemeImportDialog`:支持粘贴 JSON、HTTPS 链接、`OpenDocument` SAF 文件、TweakCN 社区浏览器入口、导入预览和 warning;导入只回传 draft,不直接写入 `ThemeProfileStore`。 -- `ThemeEditorDialog` 接入导入、SAF `CreateDocument` 导出和系统文本分享;只有既有“应用”操作才持久化并发布主题刷新事件。 - -### 验证 - -```text -bash ./gradlew :app:testMobileArm64_v8aDebugUnitTest \ - --tests 'com.fongmi.android.tv.theme.*' \ - :app:compileMobileArm64_v8aDebugJavaWithJavac \ - :app:processMobileArm64_v8aDebugResources \ - --no-daemon --console=plain -``` - -结果:`BUILD SUCCESSFUL`;主题相关测试 25 项全部通过,mobile Java 编译和资源处理通过。首次验证捕获并修复了 `OpenDocument` 的 `String[]` 输入类型错误,以及契约测试工作目录和未支持 token warning 的问题;修复后未再出现失败。未进行连接设备和视觉截图验收。 - -### 回滚与剩余范围 - -- 回滚:撤销阶段 D 提交即可回到阶段 C;profile schema、`theme_profile_json`、`theme_profile_last_good`、旧 `theme_color` 镜像和播放器路径保持兼容。 -- 剩余:阶段 E 的 leanback 完整焦点主题、TV 语义资源完整迁移,以及受签名/哈希保护的静态主题索引、预览图、版本回滚和缓存。 -- 下一步:建立 `THEME-COLOR-E-20260908` 独立 guard;不把 TV/社区索引与阶段 D 的 mobile 导入导出混写。