From 0bfda292bdddcb0cb63c846f736eab866981ae1b Mon Sep 17 00:00:00 2001 From: AritxOnly Date: Sat, 14 Nov 2026 16:52:31 +0800 Subject: [PATCH 1/8] feat(settings): add JSON settings index and adaptive HTML prototype (SET-01) --- AGENTS.md | 4 + app/src/main/assets/settings-index.json | 430 ++++++++++++++++ .../com/aritxonly/bdfun/data/SettingsStore.kt | 13 + .../application/IndexedSettingBindings.kt | 82 +++ .../application/IndexedSettingsStateHolder.kt | 58 +++ .../IndexedSettingsStateHolderTest.kt | 52 ++ .../bdfun/core/domain/SettingsIndex.kt | 59 +++ .../core/model/settings/SettingsIndex.kt | 15 + docs/SETTINGS_INDEX_DESIGN.md | 36 ++ docs/prototypes/settings/index.html | 465 ++++++++++++++++++ .../infra/storage/JsonSettingsIndexCodec.kt | 51 ++ .../storage/JsonSettingsIndexCodecTest.kt | 53 ++ 12 files changed, 1318 insertions(+) create mode 100644 app/src/main/assets/settings-index.json create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingBindings.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt create mode 100644 app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolderTest.kt create mode 100644 core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt create mode 100644 core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsIndex.kt create mode 100644 docs/SETTINGS_INDEX_DESIGN.md create mode 100644 docs/prototypes/settings/index.html create mode 100644 infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodec.kt create mode 100644 infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt diff --git a/AGENTS.md b/AGENTS.md index cf48ff9f..4334c943 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -581,3 +581,7 @@ MainTabPager位于主壳layerBackdrop的录制树内,因此MineScreen及其资 HyperOS 中性色以 Deadliner 的 HyperOS 适配为准:surface 为页面层(浅色 F3F3F3 / 深色 000000),surfaceContainer 为卡片层(FFFFFF / 242424)。应用层旧 surface/surfaceContainer 引用及配对前景已交换,TeakUI 标准组件仍遵循自身语义;不要再次把两个 token 的定义反转。独立 Activity、直播与主壳 Material 色板统一由 appMaterialColors 投影,粉/蓝品牌设置保持。 原本显式启用平面背景的玻璃控件统一通过 TeakGlassDefaults.flatBackgroundAlpha 读取深色 0.9 / 浅色 0.96;无平面背景的默认值仍为 0,不要为纯图标按钮补背景。 + +### 2026-11-14 SET-01 设置索引与原型 + +设置索引位于 app/src/main/assets/settings-index.json,JSON 解析在 infra/storage,类型约束与搜索在 core/domain,写入通过 feature/settings/application 的显式白名单绑定调用原设置控制器,不能按任意 pref_name 直接落盘。当前索引包含首批36项开关/固定枚举;动态 CDN、文件选择、账号动作、我的页与推荐独立存储等仍沿用旧页,未宣称全量替换。HTML 原型在 docs/prototypes/settings,Android 页面尚未替换。 diff --git a/app/src/main/assets/settings-index.json b/app/src/main/assets/settings-index.json new file mode 100644 index 00000000..4b0cf782 --- /dev/null +++ b/app/src/main/assets/settings-index.json @@ -0,0 +1,430 @@ +[ + { + "name": "外观与显示", + "route": "settings/appearance", + "icon": "◈", + "groups": [ + { + "title": "主题与风格", + "settings": [ + { + "pref_name": "dark_mode", + "type": "select", + "title": "主题", + "default": 0, + "options": [ + { + "title": "跟随系统", + "value": 0 + }, + { + "title": "浅色", + "value": 1 + }, + { + "title": "深色", + "value": 2 + } + ] + }, + { + "pref_name": "color_style", + "type": "select", + "title": "色彩风格", + "default": 0, + "options": [ + { + "title": "AC 粉", + "value": 0 + }, + { + "title": "Hyper 蓝", + "value": 1 + } + ] + }, + { + "pref_name": "interface_style", + "type": "select", + "title": "界面风格", + "default": 0, + "options": [ + { + "title": "官方", + "value": 0 + }, + { + "title": "经典", + "value": 1 + }, + { + "title": "HyperOS 4", + "value": 2 + } + ] + } + ] + }, + { + "title": "界面与动效", + "settings": [ + { + "pref_name": "blur", + "type": "boolean", + "title": "模糊效果", + "default": true + }, + { + "pref_name": "scroll_top_enabled", + "type": "boolean", + "title": "回到顶部控件", + "default": true + }, + { + "pref_name": "animations_enabled", + "type": "boolean", + "title": "动态效果", + "default": true + }, + { + "pref_name": "bottom_bar_raise", + "type": "boolean", + "title": "抬高底栏", + "default": false, + "subtitle": "解决未开启安卓导航条时底栏位置过低的问题" + }, + { + "pref_name": "scroll_top_side", + "type": "select", + "title": "回到顶部控件位置", + "default": 1, + "options": [ + { + "title": "左侧", + "value": 0 + }, + { + "title": "右侧", + "value": 1 + } + ] + } + ] + } + ] + }, + { + "name": "播放与声音", + "route": "settings/playback", + "icon": "▷", + "groups": [ + { + "title": "播放偏好", + "settings": [ + { + "pref_name": "immersive_status", + "type": "boolean", + "title": "沉浸状态栏", + "default": false + }, + { + "pref_name": "sponsor_skip", + "type": "boolean", + "title": "空降助手", + "default": true + }, + { + "pref_name": "dynamic_auto_play", + "type": "boolean", + "title": "动态视频自动播放", + "default": true + }, + { + "pref_name": "resume_playback", + "type": "boolean", + "title": "播放进度记忆", + "default": true + }, + { + "pref_name": "gesture_brightness_volume", + "type": "boolean", + "title": "亮度/音量划动手势", + "default": true + }, + { + "pref_name": "gesture_center_fullscreen", + "type": "boolean", + "title": "视频中部上下划屏切换/退出全屏模式", + "default": true + }, + { + "pref_name": "gesture_boost_swipe", + "type": "boolean", + "title": "长按倍速划动调速", + "default": true + }, + { + "pref_name": "show_fullscreen_time", + "type": "boolean", + "title": "全屏显示时间", + "default": true + }, + { + "pref_name": "show_fullscreen_actions", + "type": "boolean", + "title": "全屏显示互动按钮", + "default": true + }, + { + "pref_name": "loudness_eq", + "type": "boolean", + "title": "响度均衡", + "default": false + }, + { + "pref_name": "auto_rotate", + "type": "boolean", + "title": "自动旋转", + "default": true + }, + { + "pref_name": "auto_play", + "type": "boolean", + "title": "进入视频自动播放", + "default": true + }, + { + "pref_name": "bt_latency_enabled", + "type": "boolean", + "title": "蓝牙耳机延迟补偿", + "default": false, + "subtitle": "检测到蓝牙耳机设备时自动校准播放延迟" + }, + { + "pref_name": "quality", + "type": "select", + "title": "默认清晰度", + "default": 0, + "options": [ + { + "title": "自动", + "value": 0 + }, + { + "title": "HDR", + "value": 125 + }, + { + "title": "4K", + "value": 120 + }, + { + "title": "1080P+", + "value": 112 + }, + { + "title": "1080P", + "value": 80 + }, + { + "title": "720P", + "value": 64 + } + ] + }, + { + "pref_name": "dynamic_auto_play_quality", + "type": "select", + "title": "动态视频清晰度", + "default": 64, + "options": [ + { + "title": "1080P", + "value": 80 + }, + { + "title": "720P", + "value": 64 + }, + { + "title": "480P", + "value": 32 + } + ] + }, + { + "pref_name": "decoder_mode", + "type": "select", + "title": "解码方式", + "default": 0, + "options": [ + { + "title": "自动", + "value": 0 + }, + { + "title": "硬件解码", + "value": 1 + }, + { + "title": "软件解码", + "value": 2 + }, + { + "title": "硬件解码(禁回退)", + "value": 3 + } + ] + }, + { + "pref_name": "codec_preference", + "type": "select", + "title": "编码偏好", + "default": "auto", + "options": [ + { + "title": "自动", + "value": "auto" + }, + { + "title": "AVC (H.264)", + "value": "avc" + }, + { + "title": "HEVC (H.265)", + "value": "hevc" + }, + { + "title": "AV1", + "value": "av1" + }, + { + "title": "DVH1", + "value": "dvh1" + } + ] + } + ] + }, + { + "title": "后台与退出行为", + "settings": [ + { + "pref_name": "exit_video_control", + "type": "select", + "title": "退出视频后的控件", + "default": 1, + "options": [ + { + "title": "画中画", + "value": 0 + }, + { + "title": "唱片", + "value": 1 + } + ] + }, + { + "pref_name": "background_play", + "type": "boolean", + "title": "后台播放", + "default": true, + "subtitle": "允许离开视频页后继续播放" + }, + { + "pref_name": "stop_on_video_page_exit", + "type": "boolean", + "title": "退出视频页时停止播放", + "default": false + }, + { + "pref_name": "show_disc_after_video", + "type": "boolean", + "title": "退出视频后显示唱片", + "default": true + } + ] + } + ] + }, + { + "name": "弹幕与评论", + "route": "settings/danmaku", + "icon": "☷", + "groups": [ + { + "title": "弹幕与评论", + "settings": [ + { + "pref_name": "hot_danmaku", + "type": "boolean", + "title": "弹幕计数", + "default": true + }, + { + "pref_name": "dm_low_refresh", + "type": "boolean", + "title": "降低弹幕刷新率", + "default": true + } + ] + } + ] + }, + { + "name": "通用与搜索", + "route": "settings/general", + "icon": "⌘", + "groups": [ + { + "title": "通用与搜索", + "settings": [ + { + "pref_name": "show_hot_search", + "type": "boolean", + "title": "显示热搜", + "default": true + }, + { + "pref_name": "show_viewer_count", + "type": "boolean", + "title": "显示正在观看人数", + "default": true + }, + { + "pref_name": "smart_refresh", + "type": "boolean", + "title": "智能刷新率", + "default": true, + "subtitle": "根据画面状态自动调整屏幕刷新率" + } + ] + } + ] + }, + { + "name": "消息", + "route": "settings/message", + "icon": "✉", + "groups": [ + { + "title": "消息", + "settings": [ + { + "pref_name": "show_ip_location", + "type": "boolean", + "title": "显示评论 IP 属地", + "default": true + }, + { + "pref_name": "sent_history_entry", + "type": "boolean", + "title": "显示「我的评论与弹幕」入口", + "default": true + } + ] + } + ] + } +] diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/data/SettingsStore.kt b/app/src/main/kotlin/com/aritxonly/bdfun/data/SettingsStore.kt index fd534dbf..cd142202 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/data/SettingsStore.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/data/SettingsStore.kt @@ -11,5 +11,18 @@ import kotlinx.coroutines.* class SettingsStore private constructor(context: Context, scope: CoroutineScope) : SettingsBindings( scope, SettingsStateHolder.create(AndroidSettingsPreferences(context), JsonSearchHistoryCodec(), scope), ) { + internal val indexed = com.aritxonly.bdfun.feature.settings.application.IndexedSettingsStateHolder( + loadSettingsIndex(context), this, state, scope, + ) + constructor(context: Context) : this(context, CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)) } + +private fun loadSettingsIndex(context: Context): com.aritxonly.bdfun.core.domain.SettingsIndex { + val json = context.assets.open("settings-index.json").bufferedReader().use { it.readText() } + return when (val result = com.aritxonly.bdfun.infra.storage.JsonSettingsIndexCodec().decode(json)) { + is com.aritxonly.bdfun.core.domain.SettingsIndexImport.Success -> + com.aritxonly.bdfun.core.domain.SettingsIndex(result.categories) + is com.aritxonly.bdfun.core.domain.SettingsIndexImport.Invalid -> error(result.reason) + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingBindings.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingBindings.kt new file mode 100644 index 00000000..63396635 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingBindings.kt @@ -0,0 +1,82 @@ +package com.aritxonly.bdfun.feature.settings.application + +import com.aritxonly.bdfun.core.domain.SettingsRepository +import com.aritxonly.bdfun.core.model.settings.SettingValue + +internal data class IndexedSettingBinding(val read: () -> SettingValue, val write: (SettingValue) -> Unit) + +/** 写操作仍经过原控制器,保留约束、节流与逐字段通知;不能另写一份 SharedPreferences。 */ +internal fun indexedSettingBindings(repository: SettingsRepository): Map = mapOf( + "blur" to IndexedSettingBinding({ SettingValue.Flag(repository.appearance.blurEnabled) }, + { repository.appearance.blurEnabled = (it as SettingValue.Flag).value }), + "scroll_top_enabled" to IndexedSettingBinding({ SettingValue.Flag(repository.appearance.scrollTopEnabled) }, + { repository.appearance.scrollTopEnabled = (it as SettingValue.Flag).value }), + "animations_enabled" to IndexedSettingBinding({ SettingValue.Flag(repository.appearance.animationsEnabled) }, + { repository.appearance.animationsEnabled = (it as SettingValue.Flag).value }), + "bottom_bar_raise" to IndexedSettingBinding({ SettingValue.Flag(repository.appearance.bottomBarRaise) }, + { repository.appearance.bottomBarRaise = (it as SettingValue.Flag).value }), + "dark_mode" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.darkMode) }, + { repository.appearance.darkMode = (it as SettingValue.Number).value }), + "color_style" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.colorStyle) }, + { repository.appearance.colorStyle = (it as SettingValue.Number).value }), + "interface_style" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.interfaceStyle) }, + { repository.appearance.interfaceStyle = (it as SettingValue.Number).value }), + "scroll_top_side" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.scrollTopSide) }, + { repository.appearance.scrollTopSide = (it as SettingValue.Number).value }), + "immersive_status" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.videoImmersiveStatusBar) }, + { repository.playback.videoImmersiveStatusBar = (it as SettingValue.Flag).value }), + "sponsor_skip" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.sponsorSkipEnabled) }, + { repository.playback.sponsorSkipEnabled = (it as SettingValue.Flag).value }), + "dynamic_auto_play" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.dynamicAutoPlay) }, + { repository.playback.dynamicAutoPlay = (it as SettingValue.Flag).value }), + "resume_playback" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.resumePlayback) }, + { repository.playback.resumePlayback = (it as SettingValue.Flag).value }), + "gesture_brightness_volume" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.gestureBrightnessVolume) }, + { repository.playback.gestureBrightnessVolume = (it as SettingValue.Flag).value }), + "gesture_center_fullscreen" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.gestureCenterFullscreen) }, + { repository.playback.gestureCenterFullscreen = (it as SettingValue.Flag).value }), + "gesture_boost_swipe" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.gestureBoostSwipe) }, + { repository.playback.gestureBoostSwipe = (it as SettingValue.Flag).value }), + "show_fullscreen_time" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.showFullscreenTime) }, + { repository.playback.showFullscreenTime = (it as SettingValue.Flag).value }), + "show_fullscreen_actions" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.showFullscreenActions) }, + { repository.playback.showFullscreenActions = (it as SettingValue.Flag).value }), + "loudness_eq" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.loudnessEq) }, + { repository.playback.loudnessEq = (it as SettingValue.Flag).value }), + "auto_rotate" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.autoRotate) }, + { repository.playback.autoRotate = (it as SettingValue.Flag).value }), + "auto_play" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.autoPlay) }, + { repository.playback.autoPlay = (it as SettingValue.Flag).value }), + "bt_latency_enabled" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.bluetoothLatencyEnabled) }, + { repository.playback.bluetoothLatencyEnabled = (it as SettingValue.Flag).value }), + "quality" to IndexedSettingBinding({ SettingValue.Number(repository.playback.qualityPreference) }, + { repository.playback.qualityPreference = (it as SettingValue.Number).value }), + "dynamic_auto_play_quality" to IndexedSettingBinding({ SettingValue.Number(repository.playback.dynamicAutoPlayQuality) }, + { repository.playback.dynamicAutoPlayQuality = (it as SettingValue.Number).value }), + "decoder_mode" to IndexedSettingBinding({ SettingValue.Number(repository.playback.decoderMode) }, + { repository.playback.decoderMode = (it as SettingValue.Number).value }), + "codec_preference" to IndexedSettingBinding({ SettingValue.Text(repository.playback.codecPreference) }, + { repository.playback.codecPreference = (it as SettingValue.Text).value }), + "exit_video_control" to IndexedSettingBinding({ SettingValue.Number(repository.playback.exitVideoControl) }, + { repository.playback.exitVideoControl = (it as SettingValue.Number).value }), + "hot_danmaku" to IndexedSettingBinding({ SettingValue.Flag(repository.danmaku.hotDanmakuEnabled) }, + { repository.danmaku.hotDanmakuEnabled = (it as SettingValue.Flag).value }), + "dm_low_refresh" to IndexedSettingBinding({ SettingValue.Flag(repository.danmaku.danmakuLowRefresh) }, + { repository.danmaku.danmakuLowRefresh = (it as SettingValue.Flag).value }), + "show_hot_search" to IndexedSettingBinding({ SettingValue.Flag(repository.general.showHotSearch) }, + { repository.general.showHotSearch = (it as SettingValue.Flag).value }), + "show_viewer_count" to IndexedSettingBinding({ SettingValue.Flag(repository.general.showViewerCount) }, + { repository.general.showViewerCount = (it as SettingValue.Flag).value }), + "smart_refresh" to IndexedSettingBinding({ SettingValue.Flag(repository.general.smartRefresh) }, + { repository.general.smartRefresh = (it as SettingValue.Flag).value }), + "show_ip_location" to IndexedSettingBinding({ SettingValue.Flag(repository.message.showIpLocation) }, + { repository.message.showIpLocation = (it as SettingValue.Flag).value }), + "sent_history_entry" to IndexedSettingBinding({ SettingValue.Flag(repository.message.sentHistoryEntry) }, + { repository.message.sentHistoryEntry = (it as SettingValue.Flag).value }), + "background_play" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.backgroundPlay) }, + { repository.playback.backgroundPlay = (it as SettingValue.Flag).value }), + "stop_on_video_page_exit" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.stopOnVideoPageExit) }, + { repository.playback.stopOnVideoPageExit = (it as SettingValue.Flag).value }), + "show_disc_after_video" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.showDiscAfterVideo) }, + { repository.playback.showDiscAfterVideo = (it as SettingValue.Flag).value }), +) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt new file mode 100644 index 00000000..c5cca47d --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt @@ -0,0 +1,58 @@ +package com.aritxonly.bdfun.feature.settings.application + +import com.aritxonly.bdfun.core.domain.SettingsIndex +import com.aritxonly.bdfun.core.domain.SettingsRepository +import com.aritxonly.bdfun.core.model.settings.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +internal data class IndexedSettingsState( + val values: Map = emptyMap(), + val query: String = "", + val route: String? = null, + val hits: List = emptyList(), +) +internal sealed interface SettingUpdate { + data object Updated : SettingUpdate + data class Rejected(val reason: String) : SettingUpdate +} + +internal class IndexedSettingsStateHolder( + val index: SettingsIndex, + repository: SettingsRepository, + changes: StateFlow, + scope: CoroutineScope, +) { + private val bindings = indexedSettingBindings(repository) + private val mutable = MutableStateFlow(IndexedSettingsState()) + val state = mutable.asStateFlow() + + init { + require(index.settings.keys.all { it in bindings }) { "索引包含未注册的存储键" } + refresh() + scope.launch { changes.collect { refresh() } } + } + + fun search(query: String, route: String? = null) { + mutable.update { it.copy(query = query, route = route, hits = index.search(query, route)) } + } + + fun update(prefName: String, value: SettingValue): SettingUpdate { + val setting = index.settings[prefName] ?: return SettingUpdate.Rejected("未知设置") + if (!index.accepts(setting, value)) return SettingUpdate.Rejected("值与设置类型或选项不匹配") + bindings.getValue(prefName).write(value) + refresh() + return SettingUpdate.Updated + } + + fun reset(prefName: String): SettingUpdate { + val setting = index.settings[prefName] ?: return SettingUpdate.Rejected("未知设置") + return update(prefName, setting.default) + } + + private fun refresh() { + val snapshot = index.settings.keys.associateWith { bindings.getValue(it).read() } + mutable.update { it.copy(values = snapshot) } + } +} diff --git a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolderTest.kt b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolderTest.kt new file mode 100644 index 00000000..5bdd16a6 --- /dev/null +++ b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolderTest.kt @@ -0,0 +1,52 @@ +package com.aritxonly.bdfun.feature.settings.application + +import com.aritxonly.bdfun.core.domain.SettingsIndex +import com.aritxonly.bdfun.core.model.settings.* +import kotlinx.coroutines.* +import org.junit.Assert.* +import org.junit.Test + +class IndexedSettingsStateHolderTest { + @Test fun preservesOldValuesAndWritesThroughOriginalController() { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + try { + val prefs = MemorySettingsPreferences(mutableMapOf("background_play" to false)) + val original = SettingsStateHolder.create(prefs, TestHistoryCodec, scope) + val setting = Setting("background_play", "boolean", "后台播放", null, SettingValue.Flag(true)) + val index = SettingsIndex(listOf(SettingsCategory("播放", "settings/playback", null, + listOf(SettingsGroup("播放行为", listOf(setting)))))) + val controller = IndexedSettingsStateHolder(index, original, original.state, scope) + assertEquals(SettingValue.Flag(false), controller.state.value.values["background_play"]) + assertEquals(SettingUpdate.Updated, controller.update("background_play", SettingValue.Flag(true))) + assertTrue(original.backgroundPlay) + assertEquals(true, prefs.values["background_play"]) + original.backgroundPlay = false + assertEquals(SettingValue.Flag(false), controller.state.value.values["background_play"]) + controller.search("后台") + assertEquals(1, controller.state.value.hits.size) + assertEquals(SettingUpdate.Updated, controller.reset("background_play")) + assertTrue(original.backgroundPlay) + } finally { scope.cancel() } + } + + @Test fun invalidValueOrUnknownKeyNeverWritesPreferences() { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + try { + val prefs = MemorySettingsPreferences() + val original = SettingsStateHolder.create(prefs, TestHistoryCodec, scope) + val setting = Setting("quality", "select", "清晰度", null, SettingValue.Number(64), + listOf(SettingOption("720P", SettingValue.Number(64)), SettingOption("1080P", SettingValue.Number(80)))) + val index = SettingsIndex(listOf(SettingsCategory("播放", "settings/playback", null, + listOf(SettingsGroup("画质", listOf(setting)))))) + val controller = IndexedSettingsStateHolder(index, original, original.state, scope) + val writes = prefs.applies + assertTrue(controller.update("quality", SettingValue.Number(1)) is SettingUpdate.Rejected) + assertTrue(controller.update("quality", SettingValue.Text("80")) is SettingUpdate.Rejected) + assertTrue(controller.update("unknown", SettingValue.Flag(true)) is SettingUpdate.Rejected) + assertEquals(writes, prefs.applies) + controller.update("quality", SettingValue.Number(80)) + assertEquals(80, original.qualityPreference) + assertEquals(80, prefs.values["quality"]) + } finally { scope.cancel() } + } +} diff --git a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt new file mode 100644 index 00000000..6dadc0f5 --- /dev/null +++ b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt @@ -0,0 +1,59 @@ +package com.aritxonly.bdfun.core.domain + +import com.aritxonly.bdfun.core.model.settings.* + +interface SettingsIndexCodec { + /** 非法索引整体拒绝,不能部分导入后改变用户配置。 */ + fun decode(json: String): SettingsIndexImport +} +sealed interface SettingsIndexImport { + data class Success(val categories: List) : SettingsIndexImport + data class Invalid(val reason: String) : SettingsIndexImport +} + +class SettingsIndex(val categories: List) { + val settings = categories.flatMap { it.groups }.flatMap { it.settings }.associateBy { it.prefName } + + init { + require(categories.isNotEmpty()) { "索引不能为空" } + require(categories.map { it.route }.distinct().size == categories.size) { "路由重复" } + val entries = categories.flatMap { it.groups }.flatMap { it.settings } + require(entries.size == settings.size) { "pref_name 重复" } + categories.forEach { category -> + require(category.name.isNotBlank() && category.route.isNotBlank()) + category.groups.forEach { group -> + require(group.title.isNotBlank()) + group.settings.forEach { setting -> + require(setting.prefName.matches(Regex("[a-z][a-z0-9_]*")) && setting.title.isNotBlank()) + require(setting.options.map { it.value }.distinct().size == setting.options.size) + require(accepts(setting, setting.default)) { "默认值与类型或选项不匹配:${setting.prefName}" } + } + } + } + } + + fun search(query: String, route: String? = null): List { + val words = query.trim().split(Regex("\\s+")).filter { it.isNotBlank() } + if (words.isEmpty()) return emptyList() + return categories.filter { route == null || it.route == route }.flatMap { category -> + category.groups.flatMap { group -> + group.settings.filter { setting -> + val text = listOf(category.name, group.title, setting.title, setting.subtitle.orEmpty(), setting.prefName) + .joinToString(" ") + words.all { text.contains(it, ignoreCase = true) } + }.map { SettingsSearchHit(category, group, it) } + } + } + } + + fun accepts(setting: Setting, value: SettingValue): Boolean = when (setting.type) { + "boolean" -> value is SettingValue.Flag + "string" -> value is SettingValue.Text + "integer" -> value is SettingValue.Number + "decimal" -> value is SettingValue.Decimal && value.value.isFinite() + "string_set" -> value is SettingValue.TextSet + "select" -> setting.options.isNotEmpty() && setting.options.any { it.value == value } && + setting.options.all { it.value::class == setting.default::class } + else -> false + } +} diff --git a/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsIndex.kt b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsIndex.kt new file mode 100644 index 00000000..55fd9ce4 --- /dev/null +++ b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsIndex.kt @@ -0,0 +1,15 @@ +package com.aritxonly.bdfun.core.model.settings + +/** 索引只描述展示与值约束,不承载 Android 动作或存储实现。 */ +data class SettingsCategory(val name: String, val route: String, val icon: String?, val groups: List) +data class SettingsGroup(val title: String, val settings: List) +data class Setting( + val prefName: String, + val type: String, + val title: String, + val subtitle: String?, + val default: SettingValue, + val options: List = emptyList(), +) +data class SettingOption(val title: String, val value: SettingValue) +data class SettingsSearchHit(val category: SettingsCategory, val group: SettingsGroup, val setting: Setting) diff --git a/docs/SETTINGS_INDEX_DESIGN.md b/docs/SETTINGS_INDEX_DESIGN.md new file mode 100644 index 00000000..7719e618 --- /dev/null +++ b/docs/SETTINGS_INDEX_DESIGN.md @@ -0,0 +1,36 @@ +# SET-01 · 结构化设置索引与交互原型 + +## 本轮范围 + +先落设置索引后端与 HTML 原型,Android 仍使用现有设置页面。首批覆盖36项现有开关/固定枚举;不是全部设置迁移完成。后续在确认原型后将 Android 列表切换为类型驱动渲染器。 + +## 索引合同 + +唯一输入为 `app/src/main/assets/settings-index.json`,顶层是 SettingsCategory 数组;字段沿用 name / route / icon / groups、title / settings 与 pref_name / type / title / subtitle / default。 + +`select` 补充 `options: [{ title, value }]`,value 是实际持久化值,例如720P为64,绝不是数组位置。当前支持 boolean、string、integer、decimal、string_set、select;类型错误、未知类型、重复键/路由、非法默认值整份拒绝,不部分导入。读取索引不会重置用户旧值。default 用于展示合同及显式单项重置;现有控制器默认值兼容保留,后续迁移需持续核对二者。 + +存储仍是既有 SharedPreferences 和分组控制器。pref_name 通过显式绑定白名单访问仓储;索引不能执行代码、指定任意文件或直接写用户配置。索引装配在 SettingsStore,纯搜索/验证在 core/domain,JSON 解码在 infra/storage,搜索与值快照为应用层不可变 StateFlow。 + +搜索按分类名、组名、标题、副标题与持久化键匹配,忽略英文大小写,多词取交集;支持限定 route,空查询返回空结果。结果携带分类、分组和设置,前端据此导航及定位。 + +## 原型与交互 + +用浏览器打开 `docs/prototypes/settings/index.html`,无需服务器或外部依赖。数据为资产索引的内联快照,可离线查看。修改索引后须同步快照。 + +- 横屏:左侧分类及全局搜索,右侧二级设置;搜索按钮仅搜索当前分类。结果跳转后定位对应行。 +- 竖屏:一级分类列表,固定底部柔光胶囊;展开时列表淡出上移,胶囊分裂为输入框和关闭按钮。二级页搜索从右上角工具栏进入,结果保留分类范围。 +- 原型提供浅色/深色、横屏/竖屏与键盘占位开关;真机浏览器监听 visualViewport。HTML 只是材质/交互预览,正式 Android 使用现有 Miuix/TeakUI 和 IME inset,不把 CSS 当作原生效果验收。 +- 返回/关闭保留当前分类,搜索结果有空态,控件具备键盘焦点、语义标签与减弱动画支持。 + +## 后续待接入 + +动态 CDN 的候选与测速状态、壁纸系统选择、关于/日志/退出登录等动作不是简单持久化值,需新增 action/provider 合同与宿主白名单。我的页、推荐优化使用独立存储,不能假装都在 settings 文件。弹幕参数滑条仍要保留范围与原节流语义。原型的三级页以固定枚举选项页展示,横屏仍在右栏内推进;正式 Android 的 route/返回栈合同与上述专用行留在下一阶段。 + +## 验证与交付约束 + +索引解码测试覆盖默认值、重复项、非法类型、空标题、枚举真实值和范围搜索。应用层测试覆盖旧值保留、写入旧键、旧控制器改动回流及无效写入不落盘。 + +新 PR 仅包含 SET-01。测试 release 在独立集成分支合入 PR #52 与 #54,不推送 main、不合并远端 PR。APK 不代表新原生设置 UI 已完成。 + +手动验收:横竖屏分类切换、全局/分类搜索、无结果、结果定位、关闭恢复、键盘弹出与关闭、放大字体、深色与减弱动画。Android 后续接线还需验证主题、播放器/弹幕设置生效与升级保留偏好。 diff --git a/docs/prototypes/settings/index.html b/docs/prototypes/settings/index.html new file mode 100644 index 00000000..9caaa9bb --- /dev/null +++ b/docs/prototypes/settings/index.html @@ -0,0 +1,465 @@ + + + +bdFun · 设置原型 + +
bdFun / 设置
交互原型 · 值仅在当前预览中生效
+
+ + + diff --git a/infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodec.kt b/infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodec.kt new file mode 100644 index 00000000..d06eac5f --- /dev/null +++ b/infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodec.kt @@ -0,0 +1,51 @@ +package com.aritxonly.bdfun.infra.storage + +import com.aritxonly.bdfun.core.domain.* +import com.aritxonly.bdfun.core.model.settings.* +import org.json.JSONArray +import org.json.JSONObject + +class JsonSettingsIndexCodec : SettingsIndexCodec { + override fun decode(json: String): SettingsIndexImport = try { + require(json.length <= 1_048_576) { "索引超过 1MiB" } + val categories = JSONArray(json).objects().map { category -> + SettingsCategory(category.requiredText("name"), category.requiredText("route"), + category.optionalText("icon"), category.getJSONArray("groups").objects().map { group -> + SettingsGroup(group.requiredText("title"), group.getJSONArray("settings").objects().map(::setting)) + }) + } + SettingsIndex(categories) + SettingsIndexImport.Success(categories) + } catch (error: IllegalArgumentException) { + SettingsIndexImport.Invalid(error.message ?: "索引不合法") + } catch (error: org.json.JSONException) { + SettingsIndexImport.Invalid(error.message ?: "JSON 不合法") + } + + private fun setting(json: JSONObject): Setting = Setting( + json.requiredText("pref_name"), json.requiredText("type"), json.requiredText("title"), + json.optionalText("subtitle"), value(json.get("default")), + if (json.has("options")) json.getJSONArray("options").objects().map { + SettingOption(it.requiredText("title"), value(it.get("value"))) + } else emptyList(), + ) + + private fun value(raw: Any): SettingValue = when (raw) { + is Boolean -> SettingValue.Flag(raw) + is Int -> SettingValue.Number(raw) + is Long -> { require(raw in Int.MIN_VALUE..Int.MAX_VALUE); SettingValue.Number(raw.toInt()) } + is Number -> SettingValue.Decimal(raw.toFloat().also { require(it.isFinite()) }) + is String -> SettingValue.Text(raw) + is JSONArray -> SettingValue.TextSet((0 until raw.length()).map { + (raw.get(it) as? String) ?: throw IllegalArgumentException("集合仅允许字符串") + }.toSet()) + else -> throw IllegalArgumentException("不支持的设置值") + } + + private fun JSONArray.objects() = (0 until length()).map { getJSONObject(it) } + private fun JSONObject.requiredText(key: String): String = + (get(key) as? String)?.also { require(it.isNotBlank()) } ?: throw IllegalArgumentException("$key 必须为字符串") + private fun JSONObject.optionalText(key: String): String? = + if (!has(key) || isNull(key)) null else (get(key) as? String + ?: throw IllegalArgumentException("$key 必须为字符串")) +} diff --git a/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt new file mode 100644 index 00000000..0096fcad --- /dev/null +++ b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt @@ -0,0 +1,53 @@ +package com.aritxonly.bdfun.infra.storage + +import com.aritxonly.bdfun.core.domain.* +import com.aritxonly.bdfun.core.model.settings.* +import org.junit.Assert.* +import org.junit.Test + +class JsonSettingsIndexCodecTest { + private val codec = JsonSettingsIndexCodec() + private fun json(setting: String) = """[{"name":"播放","route":"settings/playback","groups":[{"title":"声音","settings":[$setting]}]}]""" + private val flag = """{"pref_name":"background_play","type":"boolean","title":"后台播放","subtitle":"允许离开视频后播放","default":true}""" + + @Test fun preservesStoredKeyAndDefault() { + val result = codec.decode(json(flag)) as SettingsIndexImport.Success + val entry = result.categories.single().groups.single().settings.single() + assertEquals("background_play", entry.prefName) + assertEquals(SettingValue.Flag(true), entry.default) + } + + @Test fun rejectsInvalidIndexAtomically() { + listOf(json("$flag,$flag"), json(flag.replace("true", "1")), + json(flag.replace("boolean", "unknown")), json(flag.replace("true", "null")), + "[]", "not JSON", json(flag.replace("后台播放", ""))).forEach { + assertTrue(it, codec.decode(it) is SettingsIndexImport.Invalid) + } + } + + @Test fun selectRequiresActualValueNotPosition() { + val raw = """{"pref_name":"quality","type":"select","title":"清晰度","default":64,"options":[{"title":"720P","value":64},{"title":"1080P","value":80}]}""" + val result = codec.decode(json(raw)) as SettingsIndexImport.Success + val index = SettingsIndex(result.categories) + val setting = index.settings.getValue("quality") + assertTrue(index.accepts(setting, SettingValue.Number(80))) + assertFalse(index.accepts(setting, SettingValue.Number(1))) + assertTrue(codec.decode(json(raw.replace("\"default\":64", "\"default\":32"))) is SettingsIndexImport.Invalid) + } + + @Test fun searchesAllWordsAndScopesByCategory() { + val index = SettingsIndex((codec.decode(json(flag)) as SettingsIndexImport.Success).categories) + assertEquals(1, index.search(" 声音 后台 ").size) + assertEquals(1, index.search("BACKGROUND_PLAY").size) + assertEquals(0, index.search("后台", "settings/appearance").size) + assertEquals(0, index.search(" ").size) + assertEquals(0, index.search("不存在").size) + } + + @Test fun bundledIndexIsValid() { + val file = java.io.File("../../app/src/main/assets/settings-index.json") + val result = codec.decode(file.readText()) + assertTrue(result.toString(), result is SettingsIndexImport.Success) + assertEquals(36, SettingsIndex((result as SettingsIndexImport.Success).categories).settings.size) + } +} From a3df1245529c91bfca7610e317350e516770c6f8 Mon Sep 17 00:00:00 2001 From: AritxOnly Date: Sat, 14 Nov 2026 18:06:13 +0800 Subject: [PATCH 2/8] feat(settings): organize full settings with original chrome and layout editor (SET-02) --- AGENTS.md | 6 + app/src/main/assets/settings-index.json | 659 +++++++++++---- .../com/aritxonly/bdfun/SettingsActivity.kt | 16 +- .../aritxonly/bdfun/SettingsScreenRoute.kt | 2 +- .../application/IndexedSettingBindings.kt | 161 ++-- .../application/IndexedSettingsStateHolder.kt | 6 +- .../application/SettingsBrowserStateHolder.kt | 57 ++ .../settings/page/IndexedSettingsActions.kt | 49 ++ .../page/IndexedSettingsComponents.kt | 99 +++ .../settings/page/SettingsAboutBackground.kt | 127 +++ .../settings/page/SettingsAboutContent.kt | 32 + .../settings/page/SettingsAboutIdentity.kt | 37 + .../settings/page/SettingsAppearanceCard.kt | 125 --- .../settings/page/SettingsBrowser.kt | 99 +++ .../settings/page/SettingsBrowserChrome.kt | 86 ++ .../settings/page/SettingsBrowserLists.kt | 121 +++ .../settings/page/SettingsFeatureCards.kt | 144 ---- .../settings/page/SettingsIndexGroupCard.kt | 29 + .../settings/page/SettingsInfoCards.kt | 75 -- .../settings/page/SettingsOptions.kt | 30 - .../settings/page/SettingsPageContracts.kt | 1 + .../settings/page/SettingsPlayerCard.kt | 155 ---- .../settings/page/SettingsScreen.kt | 34 +- .../settings/page/SettingsVisualRows.kt | 4 +- .../SettingsBrowserStateHolderTest.kt | 63 ++ .../bdfun/core/domain/SettingsIndex.kt | 18 +- .../core/model/settings/SettingsIndex.kt | 6 +- docs/SETTINGS_DEADLINER_PROVENANCE.md | 29 + docs/SETTINGS_INDEX_DESIGN.md | 46 +- docs/prototypes/settings/index.html | 466 +---------- docs/settings-organizer/assets/app.png | Bin 0 -> 54979 bytes docs/settings-organizer/assets/aritx.jpg | Bin 0 -> 7656 bytes docs/settings-organizer/assets/teak.jpg | Bin 0 -> 15758 bytes docs/settings-organizer/catalog.js | 788 ++++++++++++++++++ docs/settings-organizer/index.html | 6 + docs/settings-organizer/organizer.css | 1 + docs/settings-organizer/organizer.js | 47 ++ docs/settings-organizer/preview.css | 5 + docs/settings-organizer/preview.html | 1 + docs/settings-organizer/preview.js | 32 + docs/settings-organizer/schema.js | 29 + .../infra/storage/JsonSettingsIndexCodec.kt | 14 +- .../storage/JsonSettingsIndexCodecTest.kt | 24 +- tools/sync-settings-catalog.py | 11 + tools/test-settings-organizer.cjs | 44 + 45 files changed, 2500 insertions(+), 1284 deletions(-) create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsActions.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsComponents.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAboutBackground.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAboutContent.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAboutIdentity.kt delete mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAppearanceCard.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt delete mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsFeatureCards.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsIndexGroupCard.kt delete mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsInfoCards.kt delete mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsOptions.kt delete mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsPlayerCard.kt create mode 100644 app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt create mode 100644 docs/SETTINGS_DEADLINER_PROVENANCE.md create mode 100644 docs/settings-organizer/assets/app.png create mode 100644 docs/settings-organizer/assets/aritx.jpg create mode 100644 docs/settings-organizer/assets/teak.jpg create mode 100644 docs/settings-organizer/catalog.js create mode 100644 docs/settings-organizer/index.html create mode 100644 docs/settings-organizer/organizer.css create mode 100644 docs/settings-organizer/organizer.js create mode 100644 docs/settings-organizer/preview.css create mode 100644 docs/settings-organizer/preview.html create mode 100644 docs/settings-organizer/preview.js create mode 100644 docs/settings-organizer/schema.js create mode 100644 tools/sync-settings-catalog.py create mode 100644 tools/test-settings-organizer.cjs diff --git a/AGENTS.md b/AGENTS.md index 4334c943..e52dccac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -585,3 +585,9 @@ HyperOS 中性色以 Deadliner 的 HyperOS 适配为准:surface 为页面层 ### 2026-11-14 SET-01 设置索引与原型 设置索引位于 app/src/main/assets/settings-index.json,JSON 解析在 infra/storage,类型约束与搜索在 core/domain,写入通过 feature/settings/application 的显式白名单绑定调用原设置控制器,不能按任意 pref_name 直接落盘。当前索引包含首批36项开关/固定枚举;动态 CDN、文件选择、账号动作、我的页与推荐独立存储等仍沿用旧页,未宣称全量替换。HTML 原型在 docs/prototypes/settings,Android 页面尚未替换。 + +### 2026-11-14 SET-02 索引设置 UI 与编排器 + +设置页已改为 presentation/settings/page 的索引渲染器:一级分类、横屏双栏与页面作用域搜索。全量现有设置使用61个组件描述(含关于身份区及PR52的3个可选扩展);不再沿用原型36项索引或旧卡片兜底。switch/dropdown/font_scale/cdn_dropdown/page_switches/mine_switch/action/developer/version保持原组件、条件与存储边界;select不得改成三级页面。第一阶段不显示分类图标,功能按钮只用Miuix Icons。编排器为 docs/settings-organizer/index.html,导出必须完整保留组件键/type/default/options/storage/visible_when/inverted/value_type,允许调整分类、分组、顺序与文案;App资产是唯一来源,运行 tools/sync-settings-catalog.py 同步。浮点组件须保留value_type=decimal,防止JSON导出1.0变1后改变类型。关于页参考Deadliner 3b561656的AboutSettings.kt,来源与MIT许可见docs/SETTINGS_DEADLINER_PROVENANCE.md;光晕只在Canvas读取动画State,卡片仅背景随320dp滚动由0.5变1。推荐扩展由启动层注入,未合入PR52时隐藏,不建立跨功能实现依赖。 + +SET-02外壳约束:返回键、TopAppBar、大标题与滚动联动必须复用原TeakActivityScaffold/TeakLargeTitle/TrackTeakPageScroll,不画新的设置顶栏,不关掉原顶栏后自己仿制。二级页搜索仅加入原工具栏actions槽。 diff --git a/app/src/main/assets/settings-index.json b/app/src/main/assets/settings-index.json index 4b0cf782..ca22bf3e 100644 --- a/app/src/main/assets/settings-index.json +++ b/app/src/main/assets/settings-index.json @@ -2,14 +2,13 @@ { "name": "外观与显示", "route": "settings/appearance", - "icon": "◈", "groups": [ { "title": "主题与风格", "settings": [ { "pref_name": "dark_mode", - "type": "select", + "type": "dropdown", "title": "主题", "default": 0, "options": [ @@ -29,7 +28,7 @@ }, { "pref_name": "color_style", - "type": "select", + "type": "dropdown", "title": "色彩风格", "default": 0, "options": [ @@ -45,7 +44,7 @@ }, { "pref_name": "interface_style", - "type": "select", + "type": "dropdown", "title": "界面风格", "default": 0, "options": [ @@ -66,36 +65,49 @@ ] }, { - "title": "界面与动效", + "title": "界面效果", "settings": [ { "pref_name": "blur", - "type": "boolean", + "type": "switch", "title": "模糊效果", "default": true }, { - "pref_name": "scroll_top_enabled", - "type": "boolean", - "title": "回到顶部控件", - "default": true + "pref_name": "animations_enabled", + "type": "switch", + "title": "减弱动态效果", + "default": true, + "inverted": true }, { - "pref_name": "animations_enabled", - "type": "boolean", - "title": "动态效果", - "default": true + "pref_name": "smart_refresh", + "type": "switch", + "title": "智能刷新率", + "default": true, + "subtitle": "根据画面状态自动调整屏幕刷新率" }, { "pref_name": "bottom_bar_raise", - "type": "boolean", + "type": "switch", "title": "抬高底栏", "default": false, "subtitle": "解决未开启安卓导航条时底栏位置过低的问题" + } + ] + }, + { + "title": "回到顶部", + "settings": [ + { + "pref_name": "scroll_top_enabled", + "type": "switch", + "title": "回到顶部控件", + "default": true }, { "pref_name": "scroll_top_side", - "type": "select", + "type": "dropdown", "title": "回到顶部控件位置", "default": 1, "options": [ @@ -107,102 +119,214 @@ "title": "右侧", "value": 1 } - ] + ], + "visible_when": "scroll_top_enabled" + }, + { + "pref_name": "scroll_top_pages", + "type": "page_switches", + "title": "回到顶部控件显示页面", + "storage": "settings", + "default": [ + "home", + "dynamic", + "space", + "library", + "message", + "message_detail" + ], + "visible_when": "scroll_top_enabled" + } + ] + }, + { + "title": "壁纸与字号", + "settings": [ + { + "pref_name": "wallpaper_pick", + "type": "action", + "title": "设置开屏壁纸", + "storage": "none", + "default": "" + }, + { + "pref_name": "wallpaper_clear", + "type": "action", + "title": "清除开屏壁纸", + "storage": "none", + "default": "" + }, + { + "pref_name": "font_scale", + "type": "font_scale", + "title": "全局字号", + "default": 1.0, + "value_type": "decimal" } ] } ] }, { - "name": "播放与声音", - "route": "settings/playback", - "icon": "▷", + "name": "播放器", + "route": "settings/player", "groups": [ { - "title": "播放偏好", + "title": "播放与手势", "settings": [ { - "pref_name": "immersive_status", - "type": "boolean", - "title": "沉浸状态栏", - "default": false - }, - { - "pref_name": "sponsor_skip", - "type": "boolean", - "title": "空降助手", - "default": true - }, - { - "pref_name": "dynamic_auto_play", - "type": "boolean", - "title": "动态视频自动播放", + "pref_name": "show_viewer_count", + "type": "switch", + "title": "显示正在观看人数", "default": true }, { "pref_name": "resume_playback", - "type": "boolean", + "type": "switch", "title": "播放进度记忆", "default": true }, { "pref_name": "gesture_brightness_volume", - "type": "boolean", + "type": "switch", "title": "亮度/音量划动手势", "default": true }, { "pref_name": "gesture_center_fullscreen", - "type": "boolean", + "type": "switch", "title": "视频中部上下划屏切换/退出全屏模式", "default": true }, { "pref_name": "gesture_boost_swipe", - "type": "boolean", + "type": "switch", "title": "长按倍速划动调速", "default": true }, + { + "pref_name": "boost_default_speed", + "type": "dropdown", + "title": "长按默认倍率", + "default": 3.0, + "options": [ + { + "title": "1.5×", + "value": 1.5 + }, + { + "title": "2×", + "value": 2.0 + }, + { + "title": "2.5×", + "value": 2.5 + }, + { + "title": "3×", + "value": 3.0 + }, + { + "title": "4×", + "value": 4.0 + }, + { + "title": "5×", + "value": 5.0 + } + ], + "value_type": "decimal" + }, + { + "pref_name": "default_playback_speed", + "type": "dropdown", + "title": "默认播放倍速", + "default": 1.0, + "options": [ + { + "title": "0.5×", + "value": 0.5 + }, + { + "title": "0.75×", + "value": 0.75 + }, + { + "title": "1×", + "value": 1.0 + }, + { + "title": "1.25×", + "value": 1.25 + }, + { + "title": "1.5×", + "value": 1.5 + }, + { + "title": "2×", + "value": 2.0 + }, + { + "title": "2.5×", + "value": 2.5 + }, + { + "title": "3×", + "value": 3.0 + } + ], + "value_type": "decimal" + } + ] + }, + { + "title": "全屏控件", + "settings": [ { "pref_name": "show_fullscreen_time", - "type": "boolean", + "type": "switch", "title": "全屏显示时间", "default": true }, { "pref_name": "show_fullscreen_actions", - "type": "boolean", + "type": "switch", "title": "全屏显示互动按钮", "default": true }, { - "pref_name": "loudness_eq", - "type": "boolean", - "title": "响度均衡", - "default": false - }, - { - "pref_name": "auto_rotate", - "type": "boolean", - "title": "自动旋转", - "default": true - }, - { - "pref_name": "auto_play", - "type": "boolean", - "title": "进入视频自动播放", - "default": true - }, - { - "pref_name": "bt_latency_enabled", - "type": "boolean", - "title": "蓝牙耳机延迟补偿", - "default": false, - "subtitle": "检测到蓝牙耳机设备时自动校准播放延迟" - }, + "pref_name": "progress_bar_mode", + "type": "dropdown", + "title": "进度条常显", + "default": 0, + "options": [ + { + "title": "关闭", + "value": 0 + }, + { + "title": "仅半屏时常显", + "value": 1 + }, + { + "title": "仅全屏时常显", + "value": 2 + }, + { + "title": "永久显示", + "value": 3 + } + ] + } + ] + }, + { + "title": "播放源与解码", + "settings": [ { "pref_name": "quality", - "type": "select", + "type": "dropdown", "title": "默认清晰度", "default": 0, "options": [ @@ -232,30 +356,10 @@ } ] }, - { - "pref_name": "dynamic_auto_play_quality", - "type": "select", - "title": "动态视频清晰度", - "default": 64, - "options": [ - { - "title": "1080P", - "value": 80 - }, - { - "title": "720P", - "value": 64 - }, - { - "title": "480P", - "value": 32 - } - ] - }, { "pref_name": "decoder_mode", - "type": "select", - "title": "解码方式", + "type": "dropdown", + "title": "硬解模式", "default": 0, "options": [ { @@ -278,9 +382,10 @@ }, { "pref_name": "codec_preference", - "type": "select", - "title": "编码偏好", + "type": "dropdown", + "title": "首选解码格式", "default": "auto", + "subtitle": "不可用时自动回退到其他编码", "options": [ { "title": "自动", @@ -303,45 +408,121 @@ "value": "dvh1" } ] + }, + { + "pref_name": "cdn_service", + "type": "cdn_dropdown", + "title": "CDN 设置", + "default": "auto", + "subtitle": "如发现播放问题,请选择原地址" + }, + { + "pref_name": "auto_rotate", + "type": "switch", + "title": "自动旋转", + "default": true + }, + { + "pref_name": "auto_play", + "type": "switch", + "title": "进入视频自动播放", + "default": true } ] }, { - "title": "后台与退出行为", + "title": "声音与蓝牙", "settings": [ { - "pref_name": "exit_video_control", - "type": "select", - "title": "退出视频后的控件", - "default": 1, + "pref_name": "loudness_eq", + "type": "switch", + "title": "响度均衡", + "default": false + }, + { + "pref_name": "bt_latency_enabled", + "type": "switch", + "title": "蓝牙耳机延迟补偿", + "default": false, + "subtitle": "检测到蓝牙耳机设备时自动校准播放延迟" + }, + { + "pref_name": "bt_latency_ms", + "type": "dropdown", + "title": "补偿延迟", + "default": 100, "options": [ { - "title": "画中画", - "value": 0 + "title": "50 ms", + "value": 50 }, { - "title": "唱片", - "value": 1 + "title": "100 ms", + "value": 100 + }, + { + "title": "150 ms", + "value": 150 + }, + { + "title": "200 ms", + "value": 200 + }, + { + "title": "250 ms", + "value": 250 + }, + { + "title": "300 ms", + "value": 300 } - ] + ], + "visible_when": "bt_latency_enabled" + } + ] + } + ] + }, + { + "name": "视频详情与弹幕", + "route": "settings/video", + "groups": [ + { + "title": "视频详情页", + "settings": [ + { + "pref_name": "immersive_status", + "type": "switch", + "title": "沉浸状态栏", + "default": false }, { - "pref_name": "background_play", - "type": "boolean", - "title": "后台播放", - "default": true, - "subtitle": "允许离开视频页后继续播放" + "pref_name": "show_ip_location", + "type": "switch", + "title": "显示评论 IP 属地", + "default": true + } + ] + }, + { + "title": "弹幕", + "settings": [ + { + "pref_name": "sponsor_skip", + "type": "switch", + "title": "空降助手", + "default": true }, { - "pref_name": "stop_on_video_page_exit", - "type": "boolean", - "title": "退出视频页时停止播放", - "default": false + "pref_name": "hot_danmaku", + "type": "switch", + "title": "弹幕计数", + "default": true }, { - "pref_name": "show_disc_after_video", - "type": "boolean", - "title": "退出视频后显示唱片", + "pref_name": "dm_low_refresh", + "type": "switch", + "title": "降低弹幕刷新率", "default": true } ] @@ -349,23 +530,77 @@ ] }, { - "name": "弹幕与评论", - "route": "settings/danmaku", - "icon": "☷", + "name": "首页、动态与搜索", + "route": "settings/feeds", "groups": [ { - "title": "弹幕与评论", + "title": "首页推荐", "settings": [ { - "pref_name": "hot_danmaku", - "type": "boolean", - "title": "弹幕计数", + "pref_name": "recommendation_toggle", + "type": "extension", + "title": "推荐算法优化", + "subtitle": "根据本机观看反馈微调推荐顺序,减少重复。关闭后下次刷新恢复原推荐方式。", + "storage": "none", + "default": "", + "visible_when": "extension:recommendation_toggle" + }, + { + "pref_name": "recommendation_clear", + "type": "extension", + "title": "清除推荐学习记录", + "storage": "none", + "default": "", + "visible_when": "extension:recommendation_clear" + }, + { + "pref_name": "recommendation_error", + "type": "extension", + "title": "推荐记录", + "storage": "none", + "default": "", + "visible_when": "extension:recommendation_error" + } + ] + }, + { + "title": "动态页", + "settings": [ + { + "pref_name": "dynamic_auto_play", + "type": "switch", + "title": "动态视频自动播放", "default": true }, { - "pref_name": "dm_low_refresh", - "type": "boolean", - "title": "降低弹幕刷新率", + "pref_name": "dynamic_auto_play_quality", + "type": "dropdown", + "title": "动态视频清晰度", + "default": 64, + "options": [ + { + "title": "1080P", + "value": 80 + }, + { + "title": "720P", + "value": 64 + }, + { + "title": "480P", + "value": 32 + } + ] + } + ] + }, + { + "title": "搜索页", + "settings": [ + { + "pref_name": "show_hot_search", + "type": "switch", + "title": "显示热搜", "default": true } ] @@ -373,55 +608,177 @@ ] }, { - "name": "通用与搜索", - "route": "settings/general", - "icon": "⌘", + "name": "我的页", + "route": "settings/mine", "groups": [ { - "title": "通用与搜索", + "title": "入口与预览", "settings": [ { - "pref_name": "show_hot_search", - "type": "boolean", - "title": "显示热搜", + "pref_name": "sent_history_entry", + "type": "switch", + "title": "显示「我的评论与弹幕」入口", "default": true }, { - "pref_name": "show_viewer_count", - "type": "boolean", - "title": "显示正在观看人数", - "default": true + "pref_name": "history", + "type": "mine_switch", + "title": "展开历史记录", + "storage": "mine_layout", + "default": false }, { - "pref_name": "smart_refresh", - "type": "boolean", - "title": "智能刷新率", - "default": true, - "subtitle": "根据画面状态自动调整屏幕刷新率" + "pref_name": "favorites", + "type": "mine_switch", + "title": "展开我的收藏", + "storage": "mine_layout", + "default": false + }, + { + "pref_name": "watch_later", + "type": "mine_switch", + "title": "展开稍后再看", + "storage": "mine_layout", + "default": false } ] } ] }, { - "name": "消息", - "route": "settings/message", - "icon": "✉", + "name": "关于 App", + "route": "settings/about", "groups": [ { - "title": "消息", + "title": "应用信息", "settings": [ { - "pref_name": "show_ip_location", - "type": "boolean", - "title": "显示评论 IP 属地", - "default": true + "pref_name": "app_identity", + "type": "app_identity", + "title": "bdFun", + "storage": "none", + "default": "" + } + ] + }, + { + "title": "开发者", + "settings": [ + { + "pref_name": "developer_aritx", + "type": "developer", + "title": "AritxOnly", + "storage": "none", + "default": "" }, { - "pref_name": "sent_history_entry", - "type": "boolean", - "title": "显示「我的评论与弹幕」入口", - "default": true + "pref_name": "developer_teak", + "type": "developer", + "title": "Teak", + "storage": "none", + "default": "" + } + ] + }, + { + "title": "关于", + "settings": [ + { + "pref_name": "version", + "type": "version", + "title": "版本", + "storage": "none", + "default": "" + }, + { + "pref_name": "discussion", + "type": "action", + "title": "加入讨论组", + "storage": "none", + "default": "" + }, + { + "pref_name": "changelog", + "type": "action", + "title": "更新日志", + "storage": "none", + "default": "" + }, + { + "pref_name": "help", + "type": "action", + "title": "帮助中心", + "storage": "none", + "default": "" + }, + { + "pref_name": "onboarding", + "type": "action", + "title": "查看开始页", + "storage": "none", + "default": "" + }, + { + "pref_name": "donation", + "type": "action", + "title": "捐赠", + "storage": "none", + "default": "" + }, + { + "pref_name": "credits", + "type": "action", + "title": "引用", + "storage": "none", + "default": "" + }, + { + "pref_name": "project", + "type": "action", + "title": "项目地址", + "storage": "none", + "default": "" + } + ] + }, + { + "title": "调试", + "settings": [ + { + "pref_name": "live_preview", + "type": "action", + "title": "直播间(预览)", + "storage": "none", + "default": "", + "visible_when": "debug" + }, + { + "pref_name": "logs", + "type": "action", + "title": "日志抓取", + "storage": "none", + "default": "", + "visible_when": "debug" + } + ] + } + ], + "layout": "about" + }, + { + "name": "账号", + "route": "settings/account", + "groups": [ + { + "title": "账号", + "settings": [ + { + "pref_name": "logout", + "type": "action", + "title": "退出登录", + "storage": "none", + "default": "", + "visible_when": "logged_in" } ] } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/SettingsActivity.kt b/app/src/main/kotlin/com/aritxonly/bdfun/SettingsActivity.kt index 453fed31..5c93457a 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/SettingsActivity.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/SettingsActivity.kt @@ -1,29 +1,15 @@ package com.aritxonly.bdfun -import com.aritxonly.bdfun.ui.ActivityPageOptions - import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import com.aritxonly.bdfun.ui.TeakActivityScaffold class SettingsActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() AppGraph.ensure(applicationContext) - val settings = AppGraph.settings - setContent { - TeakActivityScaffold( - settings = settings, - onBack = { finish() }, - options = ActivityPageOptions( - title = "设置", - ), - ) { - SettingsScreenRoute(this) - } - } + setContent { SettingsScreenRoute(this) } } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/SettingsScreenRoute.kt b/app/src/main/kotlin/com/aritxonly/bdfun/SettingsScreenRoute.kt index 11905000..9b0033f8 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/SettingsScreenRoute.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/SettingsScreenRoute.kt @@ -24,7 +24,7 @@ internal fun SettingsScreenRoute(activity: ComponentActivity) { SettingsNavigation({ activity.finish() }, { AppGraph.logout(); activity.finish() }, { destination -> openSettingsDestination(activity, destination) }, { url -> openSettingsWebLink(activity, url) }, { activity.showToast(it) }), - dependencies, AppGraph.api.isLoggedIn, + dependencies, AppGraph.api.isLoggedIn, AppGraph.settings.indexed, ) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingBindings.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingBindings.kt index 63396635..f4435c47 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingBindings.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingBindings.kt @@ -5,78 +5,95 @@ import com.aritxonly.bdfun.core.model.settings.SettingValue internal data class IndexedSettingBinding(val read: () -> SettingValue, val write: (SettingValue) -> Unit) -/** 写操作仍经过原控制器,保留约束、节流与逐字段通知;不能另写一份 SharedPreferences。 */ -internal fun indexedSettingBindings(repository: SettingsRepository): Map = mapOf( - "blur" to IndexedSettingBinding({ SettingValue.Flag(repository.appearance.blurEnabled) }, - { repository.appearance.blurEnabled = (it as SettingValue.Flag).value }), - "scroll_top_enabled" to IndexedSettingBinding({ SettingValue.Flag(repository.appearance.scrollTopEnabled) }, - { repository.appearance.scrollTopEnabled = (it as SettingValue.Flag).value }), - "animations_enabled" to IndexedSettingBinding({ SettingValue.Flag(repository.appearance.animationsEnabled) }, - { repository.appearance.animationsEnabled = (it as SettingValue.Flag).value }), - "bottom_bar_raise" to IndexedSettingBinding({ SettingValue.Flag(repository.appearance.bottomBarRaise) }, - { repository.appearance.bottomBarRaise = (it as SettingValue.Flag).value }), - "dark_mode" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.darkMode) }, - { repository.appearance.darkMode = (it as SettingValue.Number).value }), - "color_style" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.colorStyle) }, - { repository.appearance.colorStyle = (it as SettingValue.Number).value }), - "interface_style" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.interfaceStyle) }, - { repository.appearance.interfaceStyle = (it as SettingValue.Number).value }), - "scroll_top_side" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.scrollTopSide) }, - { repository.appearance.scrollTopSide = (it as SettingValue.Number).value }), - "immersive_status" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.videoImmersiveStatusBar) }, - { repository.playback.videoImmersiveStatusBar = (it as SettingValue.Flag).value }), - "sponsor_skip" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.sponsorSkipEnabled) }, - { repository.playback.sponsorSkipEnabled = (it as SettingValue.Flag).value }), - "dynamic_auto_play" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.dynamicAutoPlay) }, - { repository.playback.dynamicAutoPlay = (it as SettingValue.Flag).value }), - "resume_playback" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.resumePlayback) }, - { repository.playback.resumePlayback = (it as SettingValue.Flag).value }), - "gesture_brightness_volume" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.gestureBrightnessVolume) }, - { repository.playback.gestureBrightnessVolume = (it as SettingValue.Flag).value }), - "gesture_center_fullscreen" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.gestureCenterFullscreen) }, - { repository.playback.gestureCenterFullscreen = (it as SettingValue.Flag).value }), - "gesture_boost_swipe" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.gestureBoostSwipe) }, - { repository.playback.gestureBoostSwipe = (it as SettingValue.Flag).value }), - "show_fullscreen_time" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.showFullscreenTime) }, - { repository.playback.showFullscreenTime = (it as SettingValue.Flag).value }), - "show_fullscreen_actions" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.showFullscreenActions) }, - { repository.playback.showFullscreenActions = (it as SettingValue.Flag).value }), - "loudness_eq" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.loudnessEq) }, - { repository.playback.loudnessEq = (it as SettingValue.Flag).value }), - "auto_rotate" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.autoRotate) }, - { repository.playback.autoRotate = (it as SettingValue.Flag).value }), - "auto_play" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.autoPlay) }, - { repository.playback.autoPlay = (it as SettingValue.Flag).value }), - "bt_latency_enabled" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.bluetoothLatencyEnabled) }, - { repository.playback.bluetoothLatencyEnabled = (it as SettingValue.Flag).value }), - "quality" to IndexedSettingBinding({ SettingValue.Number(repository.playback.qualityPreference) }, - { repository.playback.qualityPreference = (it as SettingValue.Number).value }), - "dynamic_auto_play_quality" to IndexedSettingBinding({ SettingValue.Number(repository.playback.dynamicAutoPlayQuality) }, - { repository.playback.dynamicAutoPlayQuality = (it as SettingValue.Number).value }), - "decoder_mode" to IndexedSettingBinding({ SettingValue.Number(repository.playback.decoderMode) }, - { repository.playback.decoderMode = (it as SettingValue.Number).value }), - "codec_preference" to IndexedSettingBinding({ SettingValue.Text(repository.playback.codecPreference) }, - { repository.playback.codecPreference = (it as SettingValue.Text).value }), - "exit_video_control" to IndexedSettingBinding({ SettingValue.Number(repository.playback.exitVideoControl) }, - { repository.playback.exitVideoControl = (it as SettingValue.Number).value }), - "hot_danmaku" to IndexedSettingBinding({ SettingValue.Flag(repository.danmaku.hotDanmakuEnabled) }, - { repository.danmaku.hotDanmakuEnabled = (it as SettingValue.Flag).value }), - "dm_low_refresh" to IndexedSettingBinding({ SettingValue.Flag(repository.danmaku.danmakuLowRefresh) }, - { repository.danmaku.danmakuLowRefresh = (it as SettingValue.Flag).value }), - "show_hot_search" to IndexedSettingBinding({ SettingValue.Flag(repository.general.showHotSearch) }, - { repository.general.showHotSearch = (it as SettingValue.Flag).value }), - "show_viewer_count" to IndexedSettingBinding({ SettingValue.Flag(repository.general.showViewerCount) }, - { repository.general.showViewerCount = (it as SettingValue.Flag).value }), - "smart_refresh" to IndexedSettingBinding({ SettingValue.Flag(repository.general.smartRefresh) }, - { repository.general.smartRefresh = (it as SettingValue.Flag).value }), - "show_ip_location" to IndexedSettingBinding({ SettingValue.Flag(repository.message.showIpLocation) }, - { repository.message.showIpLocation = (it as SettingValue.Flag).value }), - "sent_history_entry" to IndexedSettingBinding({ SettingValue.Flag(repository.message.sentHistoryEntry) }, - { repository.message.sentHistoryEntry = (it as SettingValue.Flag).value }), +/** 保留原控制器的约束和通知;动作与独立存储不伪装为 settings 偏好。 */ +internal fun indexedSettingBindings(repository: SettingsRepository): Map = + appearanceBindings(repository) + playbackBindings(repository) + otherBindings(repository) + +private fun appearanceBindings(repository: SettingsRepository) = mapOf( + "dark_mode" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.darkMode) }, + { repository.appearance.darkMode = (it as SettingValue.Number).value }), + "color_style" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.colorStyle) }, + { repository.appearance.colorStyle = (it as SettingValue.Number).value }), + "interface_style" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.interfaceStyle) }, + { repository.appearance.interfaceStyle = (it as SettingValue.Number).value }), + "blur" to IndexedSettingBinding({ SettingValue.Flag(repository.appearance.blurEnabled) }, + { repository.appearance.blurEnabled = (it as SettingValue.Flag).value }), + "animations_enabled" to IndexedSettingBinding({ SettingValue.Flag(repository.appearance.animationsEnabled) }, + { repository.appearance.animationsEnabled = (it as SettingValue.Flag).value }), + "bottom_bar_raise" to IndexedSettingBinding({ SettingValue.Flag(repository.appearance.bottomBarRaise) }, + { repository.appearance.bottomBarRaise = (it as SettingValue.Flag).value }), + "scroll_top_enabled" to IndexedSettingBinding({ SettingValue.Flag(repository.appearance.scrollTopEnabled) }, + { repository.appearance.scrollTopEnabled = (it as SettingValue.Flag).value }), + "scroll_top_side" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.scrollTopSide) }, + { repository.appearance.scrollTopSide = (it as SettingValue.Number).value }), + "font_scale" to IndexedSettingBinding({ SettingValue.Decimal(repository.appearance.fontScale) }, + { repository.appearance.fontScale = (it as SettingValue.Decimal).value }), + "scroll_top_pages" to IndexedSettingBinding({ SettingValue.TextSet(repository.appearance.scrollTopPages) }, + { repository.appearance.scrollTopPages = (it as SettingValue.TextSet).value }), +) + +private fun playbackBindings(repository: SettingsRepository) = mapOf( "background_play" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.backgroundPlay) }, { repository.playback.backgroundPlay = (it as SettingValue.Flag).value }), - "stop_on_video_page_exit" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.stopOnVideoPageExit) }, - { repository.playback.stopOnVideoPageExit = (it as SettingValue.Flag).value }), - "show_disc_after_video" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.showDiscAfterVideo) }, - { repository.playback.showDiscAfterVideo = (it as SettingValue.Flag).value }), + "resume_playback" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.resumePlayback) }, + { repository.playback.resumePlayback = (it as SettingValue.Flag).value }), + "gesture_brightness_volume" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.gestureBrightnessVolume) }, + { repository.playback.gestureBrightnessVolume = (it as SettingValue.Flag).value }), + "gesture_center_fullscreen" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.gestureCenterFullscreen) }, + { repository.playback.gestureCenterFullscreen = (it as SettingValue.Flag).value }), + "gesture_boost_swipe" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.gestureBoostSwipe) }, + { repository.playback.gestureBoostSwipe = (it as SettingValue.Flag).value }), + "boost_default_speed" to IndexedSettingBinding({ SettingValue.Decimal(repository.playback.boostDefaultSpeed) }, + { repository.playback.boostDefaultSpeed = (it as SettingValue.Decimal).value }), + "default_playback_speed" to IndexedSettingBinding({ SettingValue.Decimal(repository.playback.defaultPlaybackSpeed) }, + { repository.playback.defaultPlaybackSpeed = (it as SettingValue.Decimal).value }), + "show_fullscreen_time" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.showFullscreenTime) }, + { repository.playback.showFullscreenTime = (it as SettingValue.Flag).value }), + "show_fullscreen_actions" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.showFullscreenActions) }, + { repository.playback.showFullscreenActions = (it as SettingValue.Flag).value }), + "progress_bar_mode" to IndexedSettingBinding({ SettingValue.Number(repository.playback.progressBarMode) }, + { repository.playback.progressBarMode = (it as SettingValue.Number).value }), + "quality" to IndexedSettingBinding({ SettingValue.Number(repository.playback.qualityPreference) }, + { repository.playback.qualityPreference = (it as SettingValue.Number).value }), + "decoder_mode" to IndexedSettingBinding({ SettingValue.Number(repository.playback.decoderMode) }, + { repository.playback.decoderMode = (it as SettingValue.Number).value }), + "codec_preference" to IndexedSettingBinding({ SettingValue.Text(repository.playback.codecPreference) }, + { repository.playback.codecPreference = (it as SettingValue.Text).value }), + "cdn_service" to IndexedSettingBinding({ SettingValue.Text(repository.playback.cdnService) }, + { repository.playback.cdnService = (it as SettingValue.Text).value }), + "auto_rotate" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.autoRotate) }, + { repository.playback.autoRotate = (it as SettingValue.Flag).value }), + "auto_play" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.autoPlay) }, + { repository.playback.autoPlay = (it as SettingValue.Flag).value }), + "loudness_eq" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.loudnessEq) }, + { repository.playback.loudnessEq = (it as SettingValue.Flag).value }), + "bt_latency_enabled" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.bluetoothLatencyEnabled) }, + { repository.playback.bluetoothLatencyEnabled = (it as SettingValue.Flag).value }), + "bt_latency_ms" to IndexedSettingBinding({ SettingValue.Number(repository.playback.bluetoothLatencyMs) }, + { repository.playback.bluetoothLatencyMs = (it as SettingValue.Number).value }), + "immersive_status" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.videoImmersiveStatusBar) }, + { repository.playback.videoImmersiveStatusBar = (it as SettingValue.Flag).value }), + "sponsor_skip" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.sponsorSkipEnabled) }, + { repository.playback.sponsorSkipEnabled = (it as SettingValue.Flag).value }), + "dynamic_auto_play" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.dynamicAutoPlay) }, + { repository.playback.dynamicAutoPlay = (it as SettingValue.Flag).value }), + "dynamic_auto_play_quality" to IndexedSettingBinding({ SettingValue.Number(repository.playback.dynamicAutoPlayQuality) }, + { repository.playback.dynamicAutoPlayQuality = (it as SettingValue.Number).value }), +) + +private fun otherBindings(repository: SettingsRepository) = mapOf( + "smart_refresh" to IndexedSettingBinding({ SettingValue.Flag(repository.general.smartRefresh) }, + { repository.general.smartRefresh = (it as SettingValue.Flag).value }), + "show_viewer_count" to IndexedSettingBinding({ SettingValue.Flag(repository.general.showViewerCount) }, + { repository.general.showViewerCount = (it as SettingValue.Flag).value }), + "show_ip_location" to IndexedSettingBinding({ SettingValue.Flag(repository.message.showIpLocation) }, + { repository.message.showIpLocation = (it as SettingValue.Flag).value }), + "hot_danmaku" to IndexedSettingBinding({ SettingValue.Flag(repository.danmaku.hotDanmakuEnabled) }, + { repository.danmaku.hotDanmakuEnabled = (it as SettingValue.Flag).value }), + "dm_low_refresh" to IndexedSettingBinding({ SettingValue.Flag(repository.danmaku.danmakuLowRefresh) }, + { repository.danmaku.danmakuLowRefresh = (it as SettingValue.Flag).value }), + "show_hot_search" to IndexedSettingBinding({ SettingValue.Flag(repository.general.showHotSearch) }, + { repository.general.showHotSearch = (it as SettingValue.Flag).value }), + "sent_history_entry" to IndexedSettingBinding({ SettingValue.Flag(repository.message.sentHistoryEntry) }, + { repository.message.sentHistoryEntry = (it as SettingValue.Flag).value }), ) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt index c5cca47d..da3318c7 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt @@ -25,11 +25,12 @@ internal class IndexedSettingsStateHolder( scope: CoroutineScope, ) { private val bindings = indexedSettingBindings(repository) + private val stored = index.settings.filterValues { it.storage == "settings" } private val mutable = MutableStateFlow(IndexedSettingsState()) val state = mutable.asStateFlow() init { - require(index.settings.keys.all { it in bindings }) { "索引包含未注册的存储键" } + require(stored.keys.all { it in bindings }) { "索引包含未注册的存储键" } refresh() scope.launch { changes.collect { refresh() } } } @@ -40,6 +41,7 @@ internal class IndexedSettingsStateHolder( fun update(prefName: String, value: SettingValue): SettingUpdate { val setting = index.settings[prefName] ?: return SettingUpdate.Rejected("未知设置") + if (setting.storage != "settings") return SettingUpdate.Rejected("该组件由独立宿主处理") if (!index.accepts(setting, value)) return SettingUpdate.Rejected("值与设置类型或选项不匹配") bindings.getValue(prefName).write(value) refresh() @@ -52,7 +54,7 @@ internal class IndexedSettingsStateHolder( } private fun refresh() { - val snapshot = index.settings.keys.associateWith { bindings.getValue(it).read() } + val snapshot = stored.keys.associateWith { bindings.getValue(it).read() } mutable.update { it.copy(values = snapshot) } } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt new file mode 100644 index 00000000..dcfd415c --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt @@ -0,0 +1,57 @@ +package com.aritxonly.bdfun.feature.settings.application + +import com.aritxonly.bdfun.core.domain.SettingsIndex +import com.aritxonly.bdfun.core.model.settings.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +internal data class SettingsBrowserState( + val route: String? = null, + val searching: Boolean = false, + val searchRoute: String? = null, + val query: String = "", + val highlight: String? = null, +) + +/** 页面拥有自己的搜索和导航;旋转只改变布局,不另建一份选择状态。 */ +internal class SettingsBrowserStateHolder(initial: SettingsBrowserState = SettingsBrowserState()) { + private val mutable = MutableStateFlow(initial) + val state = mutable.asStateFlow() + + fun open(route: String, prefName: String? = null) { + mutable.value = SettingsBrowserState(route = route, highlight = prefName) + } + fun search(route: String? = null) { + mutable.update { it.copy(searching = true, searchRoute = route, query = "", highlight = null) } + } + fun query(value: String) { mutable.update { it.copy(query = value) } } + fun closeSearch() { mutable.update { it.copy(searching = false, query = "", searchRoute = null) } } + fun clearHighlight(key: String) { mutable.update { if (it.highlight == key) it.copy(highlight = null) else it } } + fun back(): Boolean = when { + state.value.searching -> { closeSearch(); true } + state.value.route != null -> { mutable.value = SettingsBrowserState(); true } + else -> false + } +} + +internal fun settingsVisible(setting: Setting, values: Map, loggedIn: Boolean, debug: Boolean, extensions: Set = emptySet()): Boolean = + when (val condition = setting.visibleWhen) { + null -> true + "logged_in" -> loggedIn + "debug" -> debug + else -> if (condition.startsWith("extension:")) condition.removePrefix("extension:") in extensions + else (values[condition] as? SettingValue.Flag)?.value == true + } + +internal fun visibleSettingsIndex(index: SettingsIndex, values: Map, loggedIn: Boolean, debug: Boolean, extensions: Set = emptySet()) = + index.categories.mapNotNull { category -> + val groups = category.groups.mapNotNull { group -> + group.settings.filter { settingsVisible(it, values, loggedIn, debug, extensions) }.takeIf { it.isNotEmpty() } + ?.let { group.copy(settings = it) } + } + groups.takeIf { it.isNotEmpty() }?.let { category.copy(groups = it) } + } + +internal fun searchSettingsCategories(categories: List, query: String, route: String?): List = + if (categories.isEmpty()) emptyList() else SettingsIndex(categories).search(query, route) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsActions.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsActions.kt new file mode 100644 index 00000000..467812cf --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsActions.kt @@ -0,0 +1,49 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.aritxonly.bdfun.BuildConfig +import com.aritxonly.bdfun.R +import com.aritxonly.bdfun.core.model.settings.Setting +import com.aritxonly.teak.common.settings.TeakSettingsActionRow +import com.aritxonly.teak.common.settings.TeakSettingsValueRow + +/** 索引仅选择已注册动作,不能注入 Intent、URL 或执行代码。 */ +@Composable +internal fun IndexedActionComponent(entry: Setting, runtime: SettingsPageRuntime) { + when (entry.prefName) { + "developer_aritx" -> DeveloperRow(entry.title, R.drawable.dev_artix, "https://github.com/AritxOnly", runtime.navigation.onDeveloper) + "developer_teak" -> DeveloperRow(entry.title, R.drawable.dev_teak, "https://github.com/QxTeak", runtime.navigation.onDeveloper) + "version" -> TeakSettingsValueRow(entry.title, BuildConfig.VERSION_NAME) + "wallpaper_pick" -> TeakSettingsActionRow(entry.title, + trailing = if (runtime.settings.splashWallpaper.isBlank()) "未设置" else "已设置", + onClick = runtime.onPickWallpaper) + "wallpaper_clear" -> TeakSettingsActionRow(entry.title) { + runtime.settings.splashWallpaper = "" + runtime.navigation.onNotify("已清除开屏壁纸") + } + "logout" -> TeakSettingsActionRow(entry.title, color = Color(0xFFE5484D), onClick = runtime.navigation.onLogout) + else -> SettingsDestinationAction(entry, runtime) + } +} + +@Composable +private fun SettingsDestinationAction(entry: Setting, runtime: SettingsPageRuntime) { + val destination = when (entry.prefName) { + "discussion" -> SettingsDestination.Discussion + "changelog" -> SettingsDestination.Changelog + "help" -> SettingsDestination.Help + "onboarding" -> SettingsDestination.Onboarding + "donation" -> SettingsDestination.Donation + "credits" -> SettingsDestination.Credits + "project" -> SettingsDestination.Project + "live_preview" -> SettingsDestination.LivePreview + "logs" -> SettingsDestination.Logs + else -> return + } + TeakSettingsActionRow(entry.title, trailing = when (entry.prefName) { + "discussion" -> "Telegram 群" + "live_preview", "logs" -> "调试" + else -> "" + }) { runtime.navigation.onAbout(destination) } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsComponents.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsComponents.kt new file mode 100644 index 00000000..65277b03 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsComponents.kt @@ -0,0 +1,99 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.runtime.* +import androidx.compose.ui.unit.dp +import com.aritxonly.bdfun.core.model.settings.* +import com.aritxonly.bdfun.feature.settings.application.IndexedSettingsStateHolder +import com.aritxonly.bdfun.feature.settings.application.SettingUpdate +import com.aritxonly.teak.common.settings.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +internal fun IndexedSettingComponent(entry: Setting, value: SettingValue?, runtime: SettingsPageRuntime, + controller: IndexedSettingsStateHolder) { + when (entry.type) { + "switch", "boolean" -> { + val checked = (value as? SettingValue.Flag)?.value ?: return + TeakSettingsSwitchRow(entry.title, entry.subtitle.orEmpty(), checked xor entry.inverted) { + updateIndexedSetting(controller, runtime, entry.prefName, SettingValue.Flag(it xor entry.inverted)) + } + } + "dropdown", "select" -> IndexedDropdown(entry, value ?: entry.default, runtime, controller) + "cdn_dropdown" -> CdnIndexedDropdown(entry, runtime) + "font_scale" -> GlobalFontScaleRow(runtime.settings.fontScale, { runtime.settings.fontScale = it }, entry.title) + "extension" -> runtime.extensions[entry.prefName]?.invoke(entry) + "app_identity" -> SettingsAboutAppHeader(entry.title) + "page_switches" -> ScrollPageSwitches(runtime) + "mine_switch" -> MineIndexedSwitch(entry, runtime) + else -> IndexedActionComponent(entry, runtime) + } +} + +@Composable +private fun IndexedDropdown(entry: Setting, value: SettingValue, runtime: SettingsPageRuntime, + controller: IndexedSettingsStateHolder) { + val ui = remember(entry.prefName) { SettingsAppearancePresentationStateHolder() } + LaunchedEffect(value) { ui.pendingDark = null } + val description = if (entry.prefName == "decoder_mode") when ((value as? SettingValue.Number)?.value) { + 1 -> "优先硬件解码,失败时自动回退" + 2 -> "只使用软件解码,兼容性优先" + 3 -> "只使用硬件解码,失败时不回退" + else -> "自动选择,优先硬件解码" + } else entry.subtitle.orEmpty() + TeakSettingsDropdownRow(title = entry.title, description = description, + options = entry.options.map { it.title }, + selected = ui.pendingDark ?: entry.options.indexOfFirst { it.value == value }.coerceAtLeast(0), + onSelected = { index -> + if (entry.prefName in setOf("dark_mode", "color_style")) { + ui.pendingDark = index + runtime.scope.launch { + delay(260) + updateIndexedSetting(controller, runtime, entry.prefName, entry.options[index].value) + } + } else updateIndexedSetting(controller, runtime, entry.prefName, entry.options[index].value) + }, + ) +} + +@Composable +private fun CdnIndexedDropdown(entry: Setting, runtime: SettingsPageRuntime) { + TeakSettingsDropdownRow(title = entry.title, description = entry.subtitle.orEmpty(), + options = runtime.cdnState.labels, + closedValue = runtime.cdn.services[runtime.cdn.selectedIndex(runtime.settings.cdnService)].label, + selected = runtime.cdn.selectedIndex(runtime.settings.cdnService), panelWidth = 300.dp, + onSelected = { runtime.settings.cdnService = runtime.cdn.services[it].id }, + ) +} + +@Composable +private fun ScrollPageSwitches(runtime: SettingsPageRuntime) { + scrollTopPageEntries.forEach { (key, label) -> + TeakSettingsSwitchRow(label, checked = runtime.settings.isScrollTopPageEnabled(key), + onCheckedChange = { runtime.settings.setScrollTopPageEnabled(key, it) }) + } +} + +@Composable +private fun MineIndexedSwitch(entry: Setting, runtime: SettingsPageRuntime) { + val layout by runtime.mineLayout.state.collectAsState() + val checked = when (entry.prefName) { + "history" -> layout.history + "favorites" -> layout.favorites + else -> layout.watchLater + } + TeakSettingsSwitchRow(entry.title, entry.subtitle.orEmpty(), checked) { value -> + when (entry.prefName) { + "history" -> runtime.mineLayout.setHistory(value) + "favorites" -> runtime.mineLayout.setFavorites(value) + "watch_later" -> runtime.mineLayout.setWatchLater(value) + } + } +} + +private fun updateIndexedSetting(controller: IndexedSettingsStateHolder, runtime: SettingsPageRuntime, + key: String, value: SettingValue) { + val result = controller.update(key, value) + if (result is SettingUpdate.Rejected) runtime.navigation.onNotify(result.reason) + if (key == "interface_style") com.aritxonly.teak.common.theme.TeakUiStyle.style = runtime.settings.interfaceStyle +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAboutBackground.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAboutBackground.kt new file mode 100644 index 00000000..a7f47cd9 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAboutBackground.kt @@ -0,0 +1,127 @@ +/* + * Adapted from Deadliner (MIT), Copyright (c) 2024 Aritx Zhou. + * Full upstream drawing closure; provenance/license: docs/SETTINGS_DEADLINER_PROVENANCE.md. + */ +package com.aritxonly.bdfun.ui + +import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.ColorUtils + +@Composable +internal fun SettingsAboutFloatingBackground( + modifier: Modifier = Modifier, + alpha: () -> Float, + animationsEnabled: Boolean, +) { + val (horizontalOffset, verticalOffset, accentOffset) = rememberAboutOffsets(animationsEnabled) + val primary = MaterialTheme.colorScheme.primary.toVividGlowColor() + val secondary = MaterialTheme.colorScheme.secondary.toVividGlowColor() + val tertiary = MaterialTheme.colorScheme.tertiary.toVividGlowColor() + val surface = MaterialTheme.colorScheme.surface + + Canvas(modifier = modifier) { + drawRect(color = surface) + val alpha = alpha().coerceIn(0f, 1f) + if (alpha > 0f) { + val glowRadius = maxOf(size.width, size.height) * 0.9f + drawCircle( + brush = Brush.radialGradient( + colors = listOf(primary.copy(alpha = 0.22f * alpha), Color.Transparent), + center = Offset( + x = size.width * (0.18f + horizontalOffset.value), + y = size.height * (0.22f + verticalOffset.value), + ), + radius = glowRadius, + ), + radius = glowRadius, + center = Offset( + x = size.width * (0.18f + horizontalOffset.value), + y = size.height * (0.22f + verticalOffset.value), + ), + ) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(secondary.copy(alpha = 0.18f * alpha), Color.Transparent), + center = Offset( + x = size.width * (0.86f - horizontalOffset.value), + y = size.height * (0.66f - verticalOffset.value), + ), + radius = glowRadius, + ), + radius = glowRadius * 0.86f, + center = Offset( + x = size.width * (0.86f - horizontalOffset.value), + y = size.height * (0.66f - verticalOffset.value), + ), + ) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(tertiary.copy(alpha = 0.16f * alpha), Color.Transparent), + center = Offset( + x = size.width * (0.52f + accentOffset.value), + y = size.height * (0.96f - accentOffset.value), + ), + radius = glowRadius * 0.7f, + ), + radius = glowRadius * 0.72f, + center = Offset( + x = size.width * (0.52f + accentOffset.value), + y = size.height * (0.96f - accentOffset.value), + ), + ) + } + } +} + +private fun Color.toVividGlowColor(): Color { + val hsl = FloatArray(3) + ColorUtils.colorToHSL(toArgb(), hsl) + hsl[1] = (hsl[1] * 1.55f).coerceIn(0.56f, 0.92f) + hsl[2] = if (hsl[2] < 0.5f) { + (hsl[2] + 0.14f).coerceAtMost(0.68f) + } else { + (hsl[2] - 0.06f).coerceAtLeast(0.36f) + } + return Color(ColorUtils.HSLToColor(hsl)) +} + +@Composable +private fun rememberAboutOffsets(animationsEnabled: Boolean): Triple, State, State> { + val transition = if (animationsEnabled) rememberInfiniteTransition(label = "aboutFloatingBackground") else null + val horizontalOffset = transition?.animateFloat( + initialValue = -0.12f, + targetValue = 0.12f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 7_500, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "aboutBackgroundHorizontalOffset", + ) ?: rememberUpdatedState(0f) + val verticalOffset = transition?.animateFloat( + initialValue = 0.08f, + targetValue = -0.08f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 5_600, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "aboutBackgroundVerticalOffset", + ) ?: rememberUpdatedState(0f) + val accentOffset = transition?.animateFloat( + initialValue = -0.08f, + targetValue = 0.1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 6_400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "aboutBackgroundAccentOffset", + ) ?: rememberUpdatedState(0f) + return Triple(horizontalOffset, verticalOffset, accentOffset) +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAboutContent.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAboutContent.kt new file mode 100644 index 00000000..a25df2d4 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAboutContent.kt @@ -0,0 +1,32 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.aritxonly.bdfun.core.model.settings.SettingValue +import com.aritxonly.bdfun.feature.settings.application.IndexedSettingsStateHolder +import com.aritxonly.teak.common.chrome.TrackTeakPageScroll +import com.aritxonly.teak.common.chrome.TeakLargeTitle + +@Composable +internal fun SettingsAboutContent(session: SettingsBrowserSession, values: Map, + runtime: SettingsPageRuntime, controller: IndexedSettingsStateHolder, modifier: Modifier) { + val category = session.selected ?: return + val scroll = rememberScrollState() + if (!session.navigation.searching) TrackTeakPageScroll(scroll) + val distance = with(LocalDensity.current) { 320.dp.toPx() } + val progress by remember(scroll, distance) { derivedStateOf { (scroll.value / distance).coerceIn(0f, 1f) } } + Box(modifier) { + SettingsAboutFloatingBackground(Modifier.matchParentSize(), { 1f - progress }, runtime.settings.animationsEnabled) + Column(Modifier.fillMaxSize().verticalScroll(scroll).padding(settingsListPadding())) { + TeakLargeTitle(category.name) + category.groups.forEach { group -> + SettingsIndexedGroup(group, session, values, runtime, controller, progress) + } + } + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAboutIdentity.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAboutIdentity.kt new file mode 100644 index 00000000..8b0ba8e5 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAboutIdentity.kt @@ -0,0 +1,37 @@ +/* + * AboutAppHeader layout adapted from Deadliner (MIT), Copyright (c) 2024 Aritx Zhou. + * Source revision and complete license: docs/SETTINGS_DEADLINER_PROVENANCE.md. + */ +package com.aritxonly.bdfun.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aritxonly.bdfun.BuildConfig +import com.aritxonly.bdfun.R +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** 保留 Deadliner 252dp 身份区、96dp 图标和版本层级;产品信息由 bdFun 提供。 */ +@Composable +internal fun SettingsAboutAppHeader(appName: String) { + Column(Modifier.fillMaxWidth().heightIn(min = 252.dp).padding(vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Image(painterResource(R.mipmap.ic_launcher), contentDescription = null, contentScale = ContentScale.Fit, + modifier = Modifier.size(96.dp).clip(RoundedCornerShape(24.dp))) + Text(appName, fontSize = 30.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, + color = MiuixTheme.colorScheme.onSurface, modifier = Modifier.padding(top = 2.dp)) + Text("v${BuildConfig.VERSION_NAME}", color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + fontSize = 14.sp, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAppearanceCard.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAppearanceCard.kt deleted file mode 100644 index 4fea0e1e..00000000 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsAppearanceCard.kt +++ /dev/null @@ -1,125 +0,0 @@ -package com.aritxonly.bdfun.ui - -import com.aritxonly.bdfun.core.model.settings.scrollTopPageEntries -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import com.aritxonly.teak.common.settings.TeakSettingsActionRow -import com.aritxonly.teak.common.settings.TeakSettingsCard -import com.aritxonly.teak.common.settings.TeakSettingsDropdownRow -import com.aritxonly.teak.common.settings.TeakSettingsSwitchRow - -@Composable -internal fun SettingsAppearanceCard(runtime: SettingsPageRuntime) { - val settings = runtime.settings - val ui = remember { SettingsAppearancePresentationStateHolder() } - LaunchedEffect(settings.darkMode) { ui.pendingDark = null } - LaunchedEffect(settings.colorStyle) { ui.pendingColor = null } - TeakSettingsCard(title = "外观与显示") { - SettingsThemeRows(runtime, ui) - SettingsDisplayRows(runtime, ui) - } -} - -@Composable -private fun SettingsThemeRows(runtime: SettingsPageRuntime, ui: SettingsAppearancePresentationStateHolder) { - val settings = runtime.settings - val scope = runtime.scope - TeakSettingsDropdownRow( - title = "主题", - options = listOf("跟随系统", "浅色", "深色"), - selected = ui.pendingDark ?: settings.darkMode, - onSelected = { index -> - ui.pendingDark = index - scope.launch { - delay(260) - settings.darkMode = index - } - }, - ) - TeakSettingsDropdownRow( - title = "色彩风格", - options = listOf("AC 粉", "Hyper 蓝"), - selected = ui.pendingColor ?: settings.colorStyle, - onSelected = { index -> - ui.pendingColor = index - scope.launch { - delay(260) - settings.colorStyle = index - } - }, - ) - TeakSettingsDropdownRow( - title = "界面风格", - options = listOf("官方", "经典", "HyperOS 4"), - selected = settings.interfaceStyle.coerceIn(0, 2), - onSelected = { - settings.interfaceStyle = it - com.aritxonly.teak.common.theme.TeakUiStyle.style = it - }, - ) - TeakSettingsSwitchRow( - title = "模糊效果", - checked = settings.blurEnabled, - onCheckedChange = { settings.blurEnabled = it }, - ) - TeakSettingsSwitchRow( - title = "回到顶部控件", - checked = settings.scrollTopEnabled, - onCheckedChange = { settings.scrollTopEnabled = it }, - ) -} - -@Composable -private fun SettingsDisplayRows(runtime: SettingsPageRuntime, ui: SettingsAppearancePresentationStateHolder) { - val settings = runtime.settings - if (settings.scrollTopEnabled) { - TeakSettingsDropdownRow( - title = "回到顶部控件位置", - options = listOf("左侧", "右侧"), - selected = settings.scrollTopSide, - onSelected = { settings.scrollTopSide = it }, - ) - // 每个页面单独开关;默认全部打开。 - scrollTopPageEntries.forEach { (key, label) -> - TeakSettingsSwitchRow( - title = label, - checked = settings.isScrollTopPageEnabled(key), - onCheckedChange = { settings.setScrollTopPageEnabled(key, it) }, - ) - } - } - TeakSettingsSwitchRow( - title = "减弱动态效果", - checked = !settings.animationsEnabled, - onCheckedChange = { settings.animationsEnabled = !it }, - ) - TeakSettingsSwitchRow( - title = "智能刷新率", - description = "根据画面状态自动调整屏幕刷新率", - checked = settings.smartRefresh, - onCheckedChange = { settings.smartRefresh = it }, - ) - TeakSettingsSwitchRow( - title = "抬高底栏", - description = "解决未开启安卓导航条时底栏位置过低的问题", - checked = settings.bottomBarRaise, - onCheckedChange = { settings.bottomBarRaise = it }, - ) - TeakSettingsActionRow( - title = "设置开屏壁纸", - trailing = if (settings.splashWallpaper.isBlank()) "未设置" else "已设置", - ) { - runtime.onPickWallpaper() - } - TeakSettingsActionRow(title = "清除开屏壁纸") { - settings.splashWallpaper = "" - runtime.navigation.onNotify("已清除开屏壁纸") - } - GlobalFontScaleRow( - value = settings.fontScale, - onValueChange = { settings.fontScale = it }, - ) -} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt new file mode 100644 index 00000000..1144f516 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt @@ -0,0 +1,99 @@ +package com.aritxonly.bdfun.ui + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.dp +import com.aritxonly.bdfun.BuildConfig +import com.aritxonly.bdfun.feature.settings.application.* +import com.aritxonly.teak.common.interaction.blockTouches +import com.aritxonly.teak.common.material.LocalTeakBackdrop +import com.aritxonly.teak.common.search.TeakSearchTransitionDefaults +import com.aritxonly.teak.common.search.teakSearchUnderlay +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.blur.layerBackdrop +import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop +import top.yukonga.miuix.kmp.theme.MiuixTheme + +@Composable +internal fun SettingsBrowser(controller: IndexedSettingsStateHolder, runtime: SettingsPageRuntime, loggedIn: Boolean, browser: SettingsBrowserStateHolder) { + val navigation by browser.state.collectAsState() + val settings by controller.state.collectAsState() + val categories = visibleSettingsIndex(controller.index, settings.values, loggedIn, BuildConfig.DEBUG, runtime.extensions.keys) + val configuration = LocalConfiguration.current + val wide = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE && configuration.screenWidthDp >= 600 + val selected = categories.firstOrNull { it.route == navigation.route } ?: categories.firstOrNull().takeIf { wide } + val focus = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + BackHandler { if (!browser.back()) runtime.navigation.onBack() } + LaunchedEffect(navigation.searching) { + if (!navigation.searching) { focus.clearFocus(); keyboard?.hide() } + } + LaunchedEffect(navigation.highlight) { + navigation.highlight?.let { kotlinx.coroutines.delay(1800); browser.clearHighlight(it) } + } + val background = MiuixTheme.colorScheme.surface + val backdrop = rememberLayerBackdrop { drawRect(background); drawContent() } + val session = SettingsBrowserSession(browser, navigation, selected, wide, categories) + CompositionLocalProvider(LocalTeakBackdrop provides backdrop) { + Scaffold(containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0, 0, 0, 0)) { + Box(Modifier.fillMaxSize().background(background)) { + Box(Modifier.fillMaxSize().layerBackdrop(backdrop)) { + Row(Modifier.fillMaxSize()) { + if (wide || selected == null) { + val hideCategories = navigation.searching && (!wide || navigation.searchRoute == null) + SettingsCategoryList(session, Modifier.then(if (wide) Modifier.width(280.dp) else Modifier.fillMaxSize()) + .settingsUnderlay(hideCategories, runtime.settings.animationsEnabled)) + } + if (selected != null) { + SettingsCategoryContent(session, settings.values, runtime, controller, + Modifier.weight(1f).fillMaxHeight().settingsUnderlay(navigation.searching, runtime.settings.animationsEnabled)) + } + } + if (navigation.searching) { + val hits = searchSettingsCategories(categories, navigation.query, navigation.searchRoute) + SettingsSearchResults(session, hits, Modifier.fillMaxSize() + .padding(start = if (wide) 280.dp else 0.dp)) + } + } + if (selected == null || wide || navigation.searching) SettingsSearchDock(session) + } + } + } +} + +@Composable +private fun Modifier.settingsUnderlay(hidden: Boolean, animationsEnabled: Boolean): Modifier { + val progress by animateFloatAsState(if (hidden) 1f else 0f, + if (animationsEnabled) TeakSearchTransitionDefaults.spec() else tween(0), label = "settingsSearchMotion") + val alpha by animateFloatAsState(if (hidden) 0f else 1f, + tween(if (animationsEnabled) TeakSearchTransitionDefaults.UnderlayAlphaMs else 0), label = "settingsSearchAlpha") + return this.blockTouches(hidden).then(if (hidden) Modifier.clearAndSetSemantics { } else Modifier) + .teakSearchUnderlay(progress, alpha) +} + +@Composable +internal fun rememberSettingsBrowser(): SettingsBrowserStateHolder { + val saver = remember { + Saver>(save = { + val s = it.state.value + listOf(s.route.orEmpty(), s.searching.toString(), s.searchRoute.orEmpty(), s.query) + }, restore = { + SettingsBrowserStateHolder(SettingsBrowserState(it[0].ifEmpty { null }, it[1].toBoolean(), + it[2].ifEmpty { null }, it[3])) + }) + } + return rememberSaveable(saver = saver) { SettingsBrowserStateHolder() } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt new file mode 100644 index 00000000..8f8796c1 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt @@ -0,0 +1,86 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aritxonly.teak.common.material.TeakGlassIconButton +import com.aritxonly.teak.common.material.TeakGlassSurface +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.Close +import top.yukonga.miuix.kmp.icon.extended.Search +import top.yukonga.miuix.kmp.theme.MiuixTheme + +@Composable +internal fun SettingsCategorySearchButton(onSearch: () -> Unit) { + TeakGlassIconButton(onClick = onSearch) { + Icon(MiuixIcons.Search, "搜索当前分类", tint = MiuixTheme.colorScheme.onSurface, modifier = Modifier.size(22.dp)) + } +} + +@Composable +internal fun BoxScope.SettingsSearchDock(session: SettingsBrowserSession) { + val searching = session.navigation.searching + val start = if (session.wide && searching) 280.dp else 0.dp + val width = if (session.wide && !searching) Modifier.width(280.dp) else Modifier.fillMaxWidth() + Row(Modifier.align(Alignment.BottomStart).then(width).padding(start = start) + .imePadding().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 16.dp).animateContentSize(), + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + TeakGlassSurface(modifier = Modifier.weight(1f).heightIn(min = 54.dp) + .then(if (!searching) Modifier.clickable(role = Role.Button) { session.browser.search() } else Modifier)) { + Row(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(MiuixIcons.Search, null, tint = MiuixTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp)) + if (searching) SettingsSearchInput(session) + else Text("搜索设置", color = MiuixTheme.colorScheme.onSurfaceVariantSummary) + } + } + AnimatedVisibility(searching, enter = fadeIn() + expandHorizontally(), exit = fadeOut() + shrinkHorizontally()) { + TeakGlassIconButton(onClick = session.browser::closeSearch, size = 54.dp) { + Icon(MiuixIcons.Close, "关闭搜索", tint = MiuixTheme.colorScheme.onSurface, modifier = Modifier.size(22.dp)) + } + } + } +} + +@Composable +private fun SettingsSearchInput(session: SettingsBrowserSession) { + val requester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + LaunchedEffect(Unit) { requester.requestFocus(); keyboard?.show() } + BasicTextField(value = session.navigation.query, onValueChange = session.browser::query, + modifier = Modifier.fillMaxWidth().focusRequester(requester), singleLine = true, + textStyle = TextStyle(color = MiuixTheme.colorScheme.onSurface, fontSize = 16.sp), + cursorBrush = SolidColor(MiuixTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }), + decorationBox = { input -> + Box { + if (session.navigation.query.isEmpty()) Text(if (session.navigation.searchRoute == null) "搜索全部设置" else "搜索当前分类", + color = MiuixTheme.colorScheme.onSurfaceVariantSummary) + input() + } + }) +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt new file mode 100644 index 00000000..e5826fce --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt @@ -0,0 +1,121 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.aritxonly.bdfun.core.model.settings.* +import com.aritxonly.bdfun.feature.settings.application.* +import com.aritxonly.teak.common.settings.TeakSettingsCard +import com.aritxonly.teak.common.chrome.* +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.preference.ArrowPreference +import top.yukonga.miuix.kmp.theme.MiuixTheme + +internal data class SettingsBrowserSession( + val browser: SettingsBrowserStateHolder, + val navigation: SettingsBrowserState, + val selected: SettingsCategory?, + val wide: Boolean, + val categories: List, +) + +@Composable +internal fun SettingsCategoryList(session: SettingsBrowserSession, modifier: Modifier) { + val list = rememberLazyListState() + val chrome = if (session.wide) rememberTeakPageScrollState() else LocalTeakPageScrollState.current + if (!session.navigation.searching) TrackTeakPageScroll(list, chrome) + LazyColumn(modifier, state = list, contentPadding = settingsListPadding()) { + item { TeakLargeTitle("设置", pageState = chrome) } + item { + TeakSettingsCard { + session.categories.forEach { category -> + val selected = session.wide && session.selected?.route == category.route + ArrowPreference(title = category.name, + modifier = if (selected) Modifier.background(MiuixTheme.colorScheme.primary.copy(alpha = .08f)) else Modifier, + onClick = { session.browser.open(category.route) }) + } + } + } + if (session.categories.isEmpty()) item { Text("暂无设置", Modifier.padding(24.dp)) } + } +} + +@Composable +internal fun SettingsCategoryContent(session: SettingsBrowserSession, values: Map, + runtime: SettingsPageRuntime, controller: IndexedSettingsStateHolder, modifier: Modifier) { + val category = session.selected ?: return + val list = key(category.route) { rememberLazyListState() } + if (category.layout != "about" && !session.navigation.searching) TrackTeakPageScroll(list) + LaunchedEffect(category.route, session.navigation.highlight) { + val key = session.navigation.highlight + val group = category.groups.indexOfFirst { g -> g.settings.any { it.prefName == key } } + if (group >= 0) list.animateScrollToItem(group + 1) + else list.scrollToItem(0) + } + if (category.layout == "about") { + SettingsAboutContent(session, values, runtime, controller, modifier) + return + } + LazyColumn(modifier, state = list, contentPadding = settingsListPadding()) { + item { TeakLargeTitle(category.name) } + items(category.groups) { group -> SettingsIndexedGroup(group, session, values, runtime, controller) } + } +} + +@Composable +internal fun SettingsSearchResults(session: SettingsBrowserSession, hits: List, modifier: Modifier) { + val list = rememberLazyListState() + TrackTeakPageScroll(list) + LazyColumn(modifier.imePadding().background(MiuixTheme.colorScheme.surface), state = list, contentPadding = settingsListPadding()) { + item { TeakLargeTitle("搜索设置") } + if (session.navigation.query.isBlank()) { + item { Text("搜索名称、说明或关键词", Modifier.padding(20.dp), color = MiuixTheme.colorScheme.onSurfaceVariantSummary) } + } else if (hits.isEmpty()) { + item { Text("没有找到相关设置", Modifier.padding(20.dp), color = MiuixTheme.colorScheme.onSurfaceVariantSummary) } + } else { + items(hits, key = { it.setting.prefName }) { hit -> + TeakSettingsCard { + ArrowPreference(title = hit.setting.title, summary = "${hit.category.name} · ${hit.group.title}", + onClick = { session.browser.open(hit.category.route, hit.setting.prefName) }) + } + } + } + } +} + +@Composable +internal fun settingsListPadding(): PaddingValues { + val horizontal = if (com.aritxonly.teak.common.theme.TeakUiStyle.isClassic) 0.dp else TeakChromeDefaults.ContentHorizontalPadding + return PaddingValues(start = horizontal, end = horizontal, + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + TeakChromeDefaults.ContentTopPadding, + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 100.dp) +} + +@Composable +internal fun SettingsIndexedGroup(group: SettingsGroup, session: SettingsBrowserSession, values: Map, + runtime: SettingsPageRuntime, controller: IndexedSettingsStateHolder, aboutProgress: Float? = null) { + if (group.settings.all { it.type == "app_identity" }) { + group.settings.forEach { SettingsAboutAppHeader(it.title) } + return + } + SettingsIndexGroupCard(group.title, aboutProgress) { + group.settings.forEach { setting -> + key(setting.prefName) { + val highlight = session.navigation.highlight == setting.prefName + val requester = remember { BringIntoViewRequester() } + LaunchedEffect(highlight) { if (highlight) { withFrameNanos { }; requester.bringIntoView() } } + Column(Modifier.bringIntoViewRequester(requester).then( + if (highlight) Modifier.background(MiuixTheme.colorScheme.primary.copy(alpha = .1f)) else Modifier)) { + IndexedSettingComponent(setting, values[setting.prefName], runtime, controller) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsFeatureCards.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsFeatureCards.kt deleted file mode 100644 index 807924be..00000000 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsFeatureCards.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.aritxonly.bdfun.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.graphics.Color -import com.aritxonly.teak.common.settings.TeakSettingsActionRow -import com.aritxonly.teak.common.settings.TeakSettingsCard -import com.aritxonly.teak.common.settings.TeakSettingsDropdownRow -import com.aritxonly.teak.common.settings.TeakSettingsSwitchRow - -@Composable -internal fun SettingsVideoDetailCard(runtime: SettingsPageRuntime) { - TeakSettingsCard(title = "视频详情页") { - SettingsVideoDetailCardRows1(runtime) - } -} - -@Composable -private fun SettingsVideoDetailCardRows1(runtime: SettingsPageRuntime) { - val settings = runtime.settings - TeakSettingsSwitchRow( - title = "沉浸状态栏", - checked = settings.videoImmersiveStatusBar, - onCheckedChange = { settings.videoImmersiveStatusBar = it }, - ) - TeakSettingsSwitchRow( - title = "显示评论 IP 属地", - checked = settings.showIpLocation, - onCheckedChange = { settings.showIpLocation = it }, - ) -} - -@Composable -internal fun SettingsDanmakuCard(runtime: SettingsPageRuntime) { - TeakSettingsCard(title = "弹幕") { - SettingsDanmakuCardRows1(runtime) - } -} - -@Composable -private fun SettingsDanmakuCardRows1(runtime: SettingsPageRuntime) { - val settings = runtime.settings - TeakSettingsSwitchRow( - title = "空降助手", - checked = settings.sponsorSkipEnabled, - onCheckedChange = { settings.sponsorSkipEnabled = it }, - ) - TeakSettingsSwitchRow( - title = "弹幕计数", - checked = settings.hotDanmakuEnabled, - onCheckedChange = { settings.hotDanmakuEnabled = it }, - ) - TeakSettingsSwitchRow( - title = "降低弹幕刷新率", - checked = settings.danmakuLowRefresh, - onCheckedChange = { settings.danmakuLowRefresh = it }, - ) -} - -@Composable -internal fun SettingsFeedCard(runtime: SettingsPageRuntime) { - TeakSettingsCard(title = "动态页") { - SettingsFeedCardRows1(runtime) - } -} - -@Composable -private fun SettingsFeedCardRows1(runtime: SettingsPageRuntime) { - val settings = runtime.settings - TeakSettingsSwitchRow( - title = "动态视频自动播放", - checked = settings.dynamicAutoPlay, - onCheckedChange = { settings.dynamicAutoPlay = it }, - ) - TeakSettingsDropdownRow( - title = "动态视频清晰度", - options = listOf("1080P", "720P", "480P"), - selected = listOf(80, 64, 32) - .indexOf(settings.dynamicAutoPlayQuality) - .coerceAtLeast(0), - onSelected = { - settings.dynamicAutoPlayQuality = listOf(80, 64, 32)[it] - }, - ) -} - -@Composable -internal fun SettingsSearchCard(runtime: SettingsPageRuntime) { - TeakSettingsCard(title = "搜索页") { - SettingsSearchCardRows1(runtime) - } -} - -@Composable -private fun SettingsSearchCardRows1(runtime: SettingsPageRuntime) { - val settings = runtime.settings - TeakSettingsSwitchRow( - title = "显示热搜", - checked = settings.showHotSearch, - onCheckedChange = { settings.showHotSearch = it }, - ) -} - -@Composable -internal fun SettingsMineCard(runtime: SettingsPageRuntime) { - TeakSettingsCard(title = "我的页") { - SettingsMineCardRows1(runtime) - val layout by runtime.mineLayout.state.collectAsState() - TeakSettingsSwitchRow(title = "展开历史记录", checked = layout.history, - onCheckedChange = runtime.mineLayout::setHistory) - TeakSettingsSwitchRow(title = "展开我的收藏", checked = layout.favorites, - onCheckedChange = runtime.mineLayout::setFavorites) - TeakSettingsSwitchRow(title = "展开稍后再看", checked = layout.watchLater, - onCheckedChange = runtime.mineLayout::setWatchLater) - } -} - -@Composable -private fun SettingsMineCardRows1(runtime: SettingsPageRuntime) { - val settings = runtime.settings - TeakSettingsSwitchRow( - title = "显示「我的评论与弹幕」入口", - checked = settings.sentHistoryEntry, - onCheckedChange = { settings.sentHistoryEntry = it }, - ) -} - -@Composable -internal fun SettingsAccountCard(runtime: SettingsPageRuntime) { - TeakSettingsCard(title = "账号") { - SettingsAccountCardRows1(runtime) - } -} - -@Composable -private fun SettingsAccountCardRows1(runtime: SettingsPageRuntime) { - val onLogout = runtime.navigation.onLogout - TeakSettingsActionRow( - title = "退出登录", - color = Color(0xFFE5484D), - onClick = onLogout, - ) -} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsIndexGroupCard.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsIndexGroupCard.kt new file mode 100644 index 00000000..70192ac4 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsIndexGroupCard.kt @@ -0,0 +1,29 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aritxonly.teak.common.chrome.TeakContinuousCornerShape +import com.aritxonly.teak.common.settings.TeakSettingsCard +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** 关于页只改变卡片容器的不透明度,文字和控件保持完整对比度。 */ +@Composable +internal fun SettingsIndexGroupCard(title: String, aboutProgress: Float?, content: @Composable ColumnScope.() -> Unit) { + if (aboutProgress == null) { + TeakSettingsCard(title = title, content = content) + return + } + val colors = MiuixTheme.colorScheme + Column(Modifier.padding(top = 12.dp)) { + Text(title, color = colors.onBackgroundVariant, fontSize = 11.5.sp, + modifier = Modifier.padding(start = 14.dp, bottom = 5.dp)) + Column(Modifier.fillMaxWidth().clip(TeakContinuousCornerShape(22.dp)) + .background(colors.surfaceContainer.copy(alpha = .5f + aboutProgress.coerceIn(0f, 1f) * .5f)), content = content) + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsInfoCards.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsInfoCards.kt deleted file mode 100644 index 78fce2c1..00000000 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsInfoCards.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.aritxonly.bdfun.ui - -import androidx.compose.runtime.Composable -import com.aritxonly.bdfun.BuildConfig -import com.aritxonly.bdfun.R -import com.aritxonly.teak.common.settings.TeakSettingsActionRow -import com.aritxonly.teak.common.settings.TeakSettingsCard -import com.aritxonly.teak.common.settings.TeakSettingsValueRow - -@Composable -internal fun SettingsDevelopersCard(runtime: SettingsPageRuntime) { - TeakSettingsCard(title = "开发者") { - SettingsDevelopersCardRows1(runtime) - } -} - -@Composable -private fun SettingsDevelopersCardRows1(runtime: SettingsPageRuntime) { - DeveloperRow( - name = "AritxOnly", - avatar = R.drawable.dev_artix, - url = "https://github.com/AritxOnly", - onOpen = runtime.navigation.onDeveloper, - ) - DeveloperRow( - name = "Teak", - avatar = R.drawable.dev_teak, - url = "https://github.com/QxTeak", - onOpen = runtime.navigation.onDeveloper, - ) -} - -@Composable -internal fun SettingsAboutCard(runtime: SettingsPageRuntime) { - TeakSettingsCard(title = "关于") { - SettingsAboutCardRows1(runtime) - } -} - -@Composable -private fun SettingsAboutCardRows1(runtime: SettingsPageRuntime) { - TeakSettingsValueRow( - title = "版本", - value = BuildConfig.VERSION_NAME, - ) - TeakSettingsActionRow(title = "加入讨论组", trailing = "Telegram 群") { - runtime.navigation.onAbout(SettingsDestination.Discussion) - } - TeakSettingsActionRow(title = "更新日志") { - runtime.navigation.onAbout(SettingsDestination.Changelog) - } - TeakSettingsActionRow(title = "帮助中心") { - runtime.navigation.onAbout(SettingsDestination.Help) - } - TeakSettingsActionRow(title = "查看开始页") { - runtime.navigation.onAbout(SettingsDestination.Onboarding) - } - TeakSettingsActionRow(title = "捐赠") { - runtime.navigation.onAbout(SettingsDestination.Donation) - } - TeakSettingsActionRow(title = "引用") { - runtime.navigation.onAbout(SettingsDestination.Credits) - } - TeakSettingsActionRow(title = "项目地址") { - runtime.navigation.onAbout(SettingsDestination.Project) - } - if (BuildConfig.DEBUG) { - TeakSettingsActionRow(title = "直播间(预览)", trailing = "调试") { - runtime.navigation.onAbout(SettingsDestination.LivePreview) - } - TeakSettingsActionRow(title = "日志抓取", trailing = "调试") { - runtime.navigation.onAbout(SettingsDestination.Logs) - } - } -} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsOptions.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsOptions.kt deleted file mode 100644 index 82df545d..00000000 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsOptions.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.aritxonly.bdfun.ui - - -internal val QUALITY_LABELS = listOf("自动", "HDR", "4K", "1080P+", "1080P", "720P") -internal val QUALITY_VALUES = listOf(0, 125, 120, 112, 80, 64) -internal val LATENCY_VALUES = listOf(50, 100, 150, 200, 250, 300) -internal val PLAYBACK_SPEED_VALUES = listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 2f, 2.5f, 3f) -internal val PLAYBACK_SPEED_LABELS = PLAYBACK_SPEED_VALUES.map { - if (it % 1f == 0f) "${it.toInt()}×" else "${it}×" -} -internal val BOOST_SPEED_VALUES = listOf(1.5f, 2f, 2.5f, 3f, 4f, 5f) -internal val BOOST_SPEED_LABELS = BOOST_SPEED_VALUES.map { - if (it % 1f == 0f) "${it.toInt()}×" else "${it}×" -} -internal val DECODER_LABELS = listOf( - "自动", - "硬件解码", - "软件解码", - "硬件解码(禁回退)", -) - -internal val CODEC_LABELS = listOf( - "自动", - "AVC (H.264)", - "HEVC (H.265)", - "AV1", - "DVH1", -) - -internal val CODEC_VALUES = listOf("auto", "avc", "hevc", "av1", "dvh1") diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsPageContracts.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsPageContracts.kt index 8c326e68..fad82985 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsPageContracts.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsPageContracts.kt @@ -15,4 +15,5 @@ internal class SettingsPageRuntime( val settings: SettingsBindings, val cdn: CdnSettingsStateHolder, val cdnState: CdnSettingsUiState, val navigation: SettingsNavigation, val scope: kotlinx.coroutines.CoroutineScope, val onPickWallpaper: () -> Unit, val mineLayout: com.aritxonly.bdfun.core.application.MineLayoutStateHolder, + val extensions: Map Unit> = emptyMap(), ) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsPlayerCard.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsPlayerCard.kt deleted file mode 100644 index 1cced085..00000000 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsPlayerCard.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.aritxonly.bdfun.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp -import com.aritxonly.teak.common.settings.TeakSettingsCard -import com.aritxonly.teak.common.settings.TeakSettingsDropdownRow -import com.aritxonly.teak.common.settings.TeakSettingsSwitchRow - -@Composable -internal fun SettingsPlayerCard(runtime: SettingsPageRuntime) { - TeakSettingsCard(title = "播放器") { - SettingsPlayerInteractionRows(runtime) - SettingsPlayerSourceRows(runtime) - SettingsPlayerContinuationRows(runtime) - } -} - -@Composable -private fun SettingsPlayerInteractionRows(runtime: SettingsPageRuntime) { - val settings = runtime.settings - TeakSettingsSwitchRow( - title = "显示正在观看人数", - checked = settings.showViewerCount, - onCheckedChange = { settings.showViewerCount = it }, - ) - TeakSettingsSwitchRow( - title = "播放进度记忆", - checked = settings.resumePlayback, - onCheckedChange = { settings.resumePlayback = it }, - ) - TeakSettingsSwitchRow( - title = "亮度/音量划动手势", - checked = settings.gestureBrightnessVolume, - onCheckedChange = { settings.gestureBrightnessVolume = it }, - ) - TeakSettingsSwitchRow( - title = "视频中部上下划屏切换/退出全屏模式", - checked = settings.gestureCenterFullscreen, - onCheckedChange = { settings.gestureCenterFullscreen = it }, - ) - TeakSettingsSwitchRow( - title = "长按倍速划动调速", - checked = settings.gestureBoostSwipe, - onCheckedChange = { settings.gestureBoostSwipe = it }, - ) - TeakSettingsDropdownRow( - title = "长按默认倍率", - options = BOOST_SPEED_LABELS, - selected = BOOST_SPEED_VALUES - .indexOfFirst { kotlin.math.abs(it - settings.boostDefaultSpeed) < 0.01f } - .coerceAtLeast(0), - onSelected = { settings.boostDefaultSpeed = BOOST_SPEED_VALUES[it] }, - ) - TeakSettingsDropdownRow( - title = "默认播放倍速", - options = PLAYBACK_SPEED_LABELS, - selected = PLAYBACK_SPEED_VALUES - .indexOfFirst { kotlin.math.abs(it - settings.defaultPlaybackSpeed) < 0.01f } - .coerceAtLeast(0), - onSelected = { settings.defaultPlaybackSpeed = PLAYBACK_SPEED_VALUES[it] }, - ) - TeakSettingsSwitchRow( - title = "全屏显示时间", - checked = settings.showFullscreenTime, - onCheckedChange = { settings.showFullscreenTime = it }, - ) - TeakSettingsSwitchRow( - title = "全屏显示互动按钮", - checked = settings.showFullscreenActions, - onCheckedChange = { settings.showFullscreenActions = it }, - ) - TeakSettingsDropdownRow( - title = "进度条常显", - options = listOf("关闭", "仅半屏时常显", "仅全屏时常显", "永久显示"), - selected = settings.progressBarMode, - onSelected = { settings.progressBarMode = it }, - ) -} - -@Composable -private fun SettingsPlayerSourceRows(runtime: SettingsPageRuntime) { - val settings = runtime.settings - val cdnOptions = runtime.cdnState.labels - TeakSettingsSwitchRow( - title = "响度均衡", - checked = settings.loudnessEq, - onCheckedChange = { settings.loudnessEq = it }, - ) - TeakSettingsDropdownRow( - title = "默认清晰度", - options = QUALITY_LABELS, - selected = QUALITY_VALUES.indexOf(settings.qualityPreference).coerceAtLeast(0), - onSelected = { settings.qualityPreference = QUALITY_VALUES[it] }, - ) - TeakSettingsDropdownRow( - title = "硬解模式", - description = when (settings.decoderMode) { - 1 -> "优先硬件解码,失败时自动回退" - 2 -> "只使用软件解码,兼容性优先" - 3 -> "只使用硬件解码,失败时不回退" - else -> "自动选择,优先硬件解码" - }, - options = DECODER_LABELS, - selected = settings.decoderMode.coerceIn(0, DECODER_LABELS.lastIndex), - onSelected = { settings.decoderMode = it }, - ) - TeakSettingsDropdownRow( - title = "首选解码格式", - description = "不可用时自动回退到其他编码", - options = CODEC_LABELS, - selected = CODEC_VALUES.indexOf(settings.codecPreference).coerceAtLeast(0), - onSelected = { settings.codecPreference = CODEC_VALUES[it] }, - ) - TeakSettingsDropdownRow( - title = "CDN 设置", - description = "如发现播放问题,请选择原地址", - options = cdnOptions, - closedValue = runtime.cdn.services[runtime.cdn.selectedIndex(settings.cdnService)].label, - selected = runtime.cdn.selectedIndex(settings.cdnService), - panelWidth = 300.dp, - onSelected = { index -> - settings.cdnService = runtime.cdn.services[index].id - }, - ) - TeakSettingsSwitchRow( - title = "自动旋转", - checked = settings.autoRotate, - onCheckedChange = { settings.autoRotate = it }, - ) - TeakSettingsSwitchRow( - title = "进入视频自动播放", - checked = settings.autoPlay, - onCheckedChange = { settings.autoPlay = it }, - ) - TeakSettingsSwitchRow( - title = "蓝牙耳机延迟补偿", - description = "检测到蓝牙耳机设备时自动校准播放延迟", - checked = settings.bluetoothLatencyEnabled, - onCheckedChange = { settings.bluetoothLatencyEnabled = it }, - ) -} - -@Composable -private fun SettingsPlayerContinuationRows(runtime: SettingsPageRuntime) { - val settings = runtime.settings - if (settings.bluetoothLatencyEnabled) { - TeakSettingsDropdownRow( - title = "补偿延迟", - options = LATENCY_VALUES.map { "$it ms" }, - selected = LATENCY_VALUES.indexOf(settings.bluetoothLatencyMs) - .coerceAtLeast(0), - onSelected = { settings.bluetoothLatencyMs = LATENCY_VALUES[it] }, - ) - } -} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsScreen.kt index c753c653..95343238 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsScreen.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsScreen.kt @@ -1,6 +1,5 @@ package com.aritxonly.bdfun.ui -import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -9,33 +8,34 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import kotlinx.coroutines.launch import com.aritxonly.bdfun.feature.settings.presentation.SettingsBindings -import com.aritxonly.teak.common.settings.TeakSettingsPage import androidx.compose.runtime.collectAsState @Composable -internal fun SettingsScreen(settings: SettingsBindings, navigation: SettingsNavigation, dependencies: SettingsDependencies, loggedIn: Boolean) { - BackHandler(onBack = navigation.onBack) +internal fun SettingsScreen(settings: SettingsBindings, navigation: SettingsNavigation, dependencies: SettingsDependencies, loggedIn: Boolean, indexed: com.aritxonly.bdfun.feature.settings.application.IndexedSettingsStateHolder, + extensions: Map Unit> = emptyMap()) { val scope = androidx.compose.runtime.rememberCoroutineScope() val cdn = remember(dependencies) { dependencies.createCdn() } val cdnState by cdn.state.collectAsState() androidx.compose.runtime.DisposableEffect(cdn) { onDispose { cdn.close() } } LaunchedEffect(cdn) { cdn.load() } val pickWallpaper = rememberSettingsWallpaperPicker(settings) - val runtime = SettingsPageRuntime(settings, cdn, cdnState, navigation, scope, pickWallpaper, dependencies.mineLayout) - TeakSettingsPage(title = "设置") { - item(key = "appearance") { SettingsAppearanceCard(runtime) } - item(key = "player") { SettingsPlayerCard(runtime) } - item(key = "video_detail") { SettingsVideoDetailCard(runtime) } - item(key = "danmaku") { SettingsDanmakuCard(runtime) } - item(key = "dynamics") { SettingsFeedCard(runtime) } - item(key = "search") { SettingsSearchCard(runtime) } - item(key = "mine") { SettingsMineCard(runtime) } - item(key = "developers") { SettingsDevelopersCard(runtime) } - item(key = "about") { SettingsAboutCard(runtime) } - if (loggedIn) item(key = "account") { SettingsAccountCard(runtime) } + val runtime = SettingsPageRuntime(settings, cdn, cdnState, navigation, scope, pickWallpaper, dependencies.mineLayout, extensions) + val browser = rememberSettingsBrowser() + val page by browser.state.collectAsState() + val wide = androidx.compose.ui.platform.LocalConfiguration.current.let { + it.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE && it.screenWidthDp >= 600 } + val category = indexed.index.categories.firstOrNull { it.route == page.route } + ?: indexed.index.categories.firstOrNull().takeIf { wide } + val title = if (page.searching) "搜索设置" else category?.name ?: "设置" + TeakActivityScaffold(settings = settings, + onBack = { if (!browser.back()) navigation.onBack() }, + options = ActivityPageOptions(title = title), + slots = ActivityPageSlots(topBarActions = { + if (category != null && !page.searching) SettingsCategorySearchButton { browser.search(category.route) } + }), + ) { SettingsBrowser(indexed, runtime, loggedIn, browser) } } @Composable diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsVisualRows.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsVisualRows.kt index 036b6f54..8e7b9be4 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsVisualRows.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsVisualRows.kt @@ -36,7 +36,7 @@ internal fun DeveloperRow( } @Composable -internal fun GlobalFontScaleRow(value: Float, onValueChange: (Float) -> Unit) { +internal fun GlobalFontScaleRow(value: Float, onValueChange: (Float) -> Unit, title: String = "全局字号") { val min = 0.8f val max = 1.2f val ui = remember(value) { SettingsFontScalePresentationStateHolder(value) } @@ -45,7 +45,7 @@ internal fun GlobalFontScaleRow(value: Float, onValueChange: (Float) -> Unit) { onValueChange = { ui.value = if (kotlin.math.abs(it - 1f) < 0.025f) 1f else it }, - title = "全局字号", + title = title, valueText = "${(ui.value * 100).toInt()}%", valueRange = min..max, keyPoints = listOf(1f), diff --git a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt new file mode 100644 index 00000000..11ea14e7 --- /dev/null +++ b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt @@ -0,0 +1,63 @@ +package com.aritxonly.bdfun.feature.settings.application + +import com.aritxonly.bdfun.core.domain.SettingsIndex +import com.aritxonly.bdfun.core.model.settings.* +import org.junit.Assert.* +import org.junit.Test + +class SettingsBrowserStateHolderTest { + @Test fun backClosesSearchThenReturnsToCategories() { + val page = SettingsBrowserStateHolder() + page.open("settings/player") + page.search("settings/player") + page.query("解码") + assertTrue(page.back()) + assertEquals("settings/player", page.state.value.route) + assertFalse(page.state.value.searching) + assertEquals("", page.state.value.query) + assertTrue(page.back()) + assertNull(page.state.value.route) + assertFalse(page.back()) + } + + @Test fun resultNavigationClosesSearchAndTargetsSetting() { + val page = SettingsBrowserStateHolder() + page.search() + page.query("主题") + page.open("settings/appearance", "dark_mode") + assertFalse(page.state.value.searching) + assertEquals("dark_mode", page.state.value.highlight) + page.clearHighlight("unrelated") + assertEquals("dark_mode", page.state.value.highlight) + page.clearHighlight("dark_mode") + assertNull(page.state.value.highlight) + } + + @Test fun hiddenRowsAndEmptyCategoriesAreNotExposed() { + val toggle = Setting("enabled", "switch", "开关", null, SettingValue.Flag(false)) + val dependent = Setting("side", "integer", "位置", null, SettingValue.Number(0), visibleWhen = "enabled") + val action = Setting("logout", "action", "退出登录", null, SettingValue.Text(""), storage = "none", visibleWhen = "logged_in") + val debug = action.copy(prefName = "logs", title = "日志", visibleWhen = "debug") + val index = SettingsIndex(listOf( + SettingsCategory("外观", "settings/appearance", null, listOf(SettingsGroup("显示", listOf(toggle, dependent)))), + SettingsCategory("账号", "settings/account", null, listOf(SettingsGroup("账号", listOf(action, debug)))), + )) + val guest = visibleSettingsIndex(index, mapOf("enabled" to SettingValue.Flag(false)), false, false) + assertEquals(1, guest.size) + assertEquals(listOf("enabled"), guest.single().groups.single().settings.map { it.prefName }) + val full = visibleSettingsIndex(index, mapOf("enabled" to SettingValue.Flag(true)), true, true) + assertEquals(4, full.flatMap { it.groups }.flatMap { it.settings }.size) + } + + @Test fun optionalComponentsRequireRegisteredHosts() { + val entry = Setting("recommendation_toggle", "extension", "推荐算法优化", null, + SettingValue.Text(""), storage = "none", visibleWhen = "extension:recommendation_toggle") + assertFalse(settingsVisible(entry, emptyMap(), true, true)) + assertTrue(settingsVisible(entry, emptyMap(), true, true, setOf("recommendation_toggle"))) + } + + @Test fun restoredNavigationDoesNotNeedASecondLayoutState() { + val saved = SettingsBrowserState("settings/player", true, "settings/player", "蓝牙") + assertEquals(saved, SettingsBrowserStateHolder(saved).state.value) + } +} diff --git a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt index 6dadc0f5..3de85402 100644 --- a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt +++ b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt @@ -20,11 +20,18 @@ class SettingsIndex(val categories: List) { val entries = categories.flatMap { it.groups }.flatMap { it.settings } require(entries.size == settings.size) { "pref_name 重复" } categories.forEach { category -> + require(category.layout in setOf("settings", "about")) require(category.name.isNotBlank() && category.route.isNotBlank()) category.groups.forEach { group -> require(group.title.isNotBlank()) group.settings.forEach { setting -> require(setting.prefName.matches(Regex("[a-z][a-z0-9_]*")) && setting.title.isNotBlank()) + require(setting.storage in setOf("settings", "mine_layout", "none")) + require(setting.visibleWhen == null || setting.visibleWhen in setOf("debug", "logged_in") || + settings[setting.visibleWhen]?.default is SettingValue.Flag || + setting.type == "extension" && setting.visibleWhen == "extension:${setting.prefName}") { "未知显隐条件" } + require(setting.valueType == null || setting.valueType == "decimal" && setting.default is SettingValue.Decimal) + require(!setting.inverted || setting.default is SettingValue.Flag) require(setting.options.map { it.value }.distinct().size == setting.options.size) require(accepts(setting, setting.default)) { "默认值与类型或选项不匹配:${setting.prefName}" } } @@ -47,13 +54,14 @@ class SettingsIndex(val categories: List) { } fun accepts(setting: Setting, value: SettingValue): Boolean = when (setting.type) { - "boolean" -> value is SettingValue.Flag - "string" -> value is SettingValue.Text + "boolean", "switch", "mine_switch" -> value is SettingValue.Flag + "string", "cdn_dropdown" -> value is SettingValue.Text "integer" -> value is SettingValue.Number - "decimal" -> value is SettingValue.Decimal && value.value.isFinite() - "string_set" -> value is SettingValue.TextSet - "select" -> setting.options.isNotEmpty() && setting.options.any { it.value == value } && + "decimal", "font_scale" -> value is SettingValue.Decimal && value.value.isFinite() + "string_set", "page_switches" -> value is SettingValue.TextSet + "select", "dropdown" -> setting.options.isNotEmpty() && setting.options.any { it.value == value } && setting.options.all { it.value::class == setting.default::class } + "action", "version", "developer", "app_identity", "extension" -> setting.storage == "none" && value == SettingValue.Text("") else -> false } } diff --git a/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsIndex.kt b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsIndex.kt index 55fd9ce4..a0fd641c 100644 --- a/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsIndex.kt +++ b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsIndex.kt @@ -1,7 +1,7 @@ package com.aritxonly.bdfun.core.model.settings /** 索引只描述展示与值约束,不承载 Android 动作或存储实现。 */ -data class SettingsCategory(val name: String, val route: String, val icon: String?, val groups: List) +data class SettingsCategory(val name: String, val route: String, val icon: String?, val groups: List, val layout: String = "settings") data class SettingsGroup(val title: String, val settings: List) data class Setting( val prefName: String, @@ -10,6 +10,10 @@ data class Setting( val subtitle: String?, val default: SettingValue, val options: List = emptyList(), + val storage: String = "settings", + val visibleWhen: String? = null, + val inverted: Boolean = false, + val valueType: String? = null, ) data class SettingOption(val title: String, val value: SettingValue) data class SettingsSearchHit(val category: SettingsCategory, val group: SettingsGroup, val setting: Setting) diff --git a/docs/SETTINGS_DEADLINER_PROVENANCE.md b/docs/SETTINGS_DEADLINER_PROVENANCE.md new file mode 100644 index 00000000..1e7636a9 --- /dev/null +++ b/docs/SETTINGS_DEADLINER_PROVENANCE.md @@ -0,0 +1,29 @@ +# 关于页设计来源 + +参考本机 Deadliner Android `3b561656e1ddca43220a39763f12aaa0c67fdaba` 的 `app/src/main/java/com/aritxonly/deadliner/ui/settings/AboutSettings.kt`。 + +迁入完整 AboutFloatingBackground 绘制与 toVividGlowColor 色彩辅助函数,保留 7500/5600/6400ms 动画、三层光晕比例/强度及320dp滚动淡出;身份区保留252dp、96dp图标、24dp圆角和名称/版本的层级。卡片透明度仍由0.5随滚动增加至1。bdFun已有TeakUI提供主题/玻璃/顶栏,不重复迁入Deadliner整个Material Scaffold或业务路由。应用名/图标/版本与行操作替换成bdFun自己的数据,不展示虚假的KMP标签;遵从bdFun减弱动效设置。普通设置控件仍复用原Miuix实现。 + +## 上游许可 + +MIT License + +Copyright (c) 2024 Aritx Zhou + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/SETTINGS_INDEX_DESIGN.md b/docs/SETTINGS_INDEX_DESIGN.md index 7719e618..60c08005 100644 --- a/docs/SETTINGS_INDEX_DESIGN.md +++ b/docs/SETTINGS_INDEX_DESIGN.md @@ -1,36 +1,42 @@ -# SET-01 · 结构化设置索引与交互原型 +# SET-01 / SET-02 · 索引设置与管理者编排器 -## 本轮范围 +## 运行时结构 -先落设置索引后端与 HTML 原型,Android 仍使用现有设置页面。首批覆盖36项现有开关/固定枚举;不是全部设置迁移完成。后续在确认原型后将 Android 列表切换为类型驱动渲染器。 +App 的唯一布局来源是 `app/src/main/assets/settings-index.json`。本轮以原生设置页完整清单重新建立索引,替换原型的36项示例;61个组件描述覆盖原全部设置、操作、开发者行,另含关于身份区与PR52的三个可选推荐组件。page_switches仍展开原来的六个页面开关。没有旧设置页兜底入口,原四个手写卡片文件已删除。 -## 索引合同 +竖屏为分类列表与二级页;横屏且宽度至少600dp使用左侧分类、右侧内容。导航和搜索由页面作用域SettingsBrowserStateHolder持有,旋转不另建选择状态。返回先关闭搜索再回分类。搜索匹配名称/说明/分组/分类/键,排除当前不满足条件的设置,结果跳转并滚动定位。页面外壳、返回键、TopAppBar和大标题/滚动/材质联动继续使用原来的TeakActivityScaffold与TeakLargeTitle/TrackTeakPageScroll,不另画顶栏;二级页只在原工具栏增加搜索按钮。一级底部柔光胶囊展开、分裂关闭按钮并跟随IME抬升;动画复用主页搜索参数。分类内从右上搜索。 -唯一输入为 `app/src/main/assets/settings-index.json`,顶层是 SettingsCategory 数组;字段沿用 name / route / icon / groups、title / settings 与 pref_name / type / title / subtitle / default。 +普通设置仍使用现有Teak/Miuix行:dropdown仍是玻璃dropdown(包括原面板宽度、开闭行为),字号仍用原SliderPreference和吸附/松手写入;主题/色彩的260ms延迟、动画反向开关、CDN动态选项、壁纸系统选择、开发者头像、退出登录的颜色与登录/debug显隐保持。第一阶段不展示分类图标;返回/搜索/关闭仅使用Miuix Icons。 -`select` 补充 `options: [{ title, value }]`,value 是实际持久化值,例如720P为64,绝不是数组位置。当前支持 boolean、string、integer、decimal、string_set、select;类型错误、未知类型、重复键/路由、非法默认值整份拒绝,不部分导入。读取索引不会重置用户旧值。default 用于展示合同及显式单项重置;现有控制器默认值兼容保留,后续迁移需持续核对二者。 +## JSON 合同 -存储仍是既有 SharedPreferences 和分组控制器。pref_name 通过显式绑定白名单访问仓储;索引不能执行代码、指定任意文件或直接写用户配置。索引装配在 SettingsStore,纯搜索/验证在 core/domain,JSON 解码在 infra/storage,搜索与值快照为应用层不可变 StateFlow。 +顶层SettingsCategory数组:name / route / groups,layout可为settings(默认)或about;icon预留但本阶段不使用。Group为title / settings。 -搜索按分类名、组名、标题、副标题与持久化键匹配,忽略英文大小写,多词取交集;支持限定 route,空查询返回空结果。结果携带分类、分组和设置,前端据此导航及定位。 +Setting保留pref_name / type / title / subtitle / default;type现在表示组件:switch、dropdown、cdn_dropdown、font_scale、page_switches、mine_switch、action、developer、version、app_identity、extension。旧boolean/select类型仍可解码。dropdown补充options:[{title,value}],使用真实存储值而非数组下标。浮点组件带value_type:decimal,保证JSON序列化1.0→1仍按Float读取。inverted保留减弱动效的反向展示。 -## 原型与交互 +storage为settings(原偏好文件)、mine_layout(独立我的页存储)或none(操作/只读/扩展组件)。只有settings键由IndexedSettingsStateHolder经过原分组控制器写入;动作标识不是数据库键,不按动作id创建偏好。mine_switch继续使用MineLayoutStateHolder。导入只读布局,不重置用户偏好。default供显式重置与合同校验,现有仓储默认值兼容保留。 -用浏览器打开 `docs/prototypes/settings/index.html`,无需服务器或外部依赖。数据为资产索引的内联快照,可离线查看。修改索引后须同步快照。 +visible_when可为debug、logged_in、布尔偏好键,或extension:<组件标识>。推荐开关/清除/错误行通过可选Composable端口注入,PR55可独立编译;与PR52集成后接原优化器,仍保持默认关闭与原动作语义。没有从展示层构造数据实现。 -- 横屏:左侧分类及全局搜索,右侧二级设置;搜索按钮仅搜索当前分类。结果跳转后定位对应行。 -- 竖屏:一级分类列表,固定底部柔光胶囊;展开时列表淡出上移,胶囊分裂为输入框和关闭按钮。二级页搜索从右上角工具栏进入,结果保留分类范围。 -- 原型提供浅色/深色、横屏/竖屏与键盘占位开关;真机浏览器监听 visualViewport。HTML 只是材质/交互预览,正式 Android 使用现有 Miuix/TeakUI 和 IME inset,不把 CSS 当作原生效果验收。 -- 返回/关闭保留当前分类,搜索结果有空态,控件具备键盘焦点、语义标签与减弱动画支持。 +索引验证拒绝重复键/路由、未知类型、非法默认值、混合枚举值类型与无效显隐条件。JSON解码在infra/storage,模型在core/model,验证与搜索在core/domain,状态/导航在feature/settings/application,Android装配在SettingsStore和路由壳。 -## 后续待接入 +## 管理者工具 -动态 CDN 的候选与测速状态、壁纸系统选择、关于/日志/退出登录等动作不是简单持久化值,需新增 action/provider 合同与宿主白名单。我的页、推荐优化使用独立存储,不能假装都在 settings 文件。弹幕参数滑条仍要保留范围与原节流语义。原型的三级页以固定枚举选项页展示,横屏仍在右栏内推进;正式 Android 的 route/返回栈合同与上述专用行留在下一阶段。 +浏览器直接打开`docs/settings-organizer/index.html`,无构建步骤、无服务端、无外部资源。支持: -## 验证与交付约束 +- 分类/分组/设置拖动、上移下移、跨分类或分组移动;添加/删除空分类与空分组。 +- 分类名称/路由、分组标题、设置标题/说明编辑;完整组件目录可检索。 +- 实时横竖屏与深浅色预览,dropdown/开关/字号可操作,预览不执行外部动作;登录/debug可切换。 +- 完整JSON导入导出、本地草稿、100步撤销/重做及键盘快捷键。 -索引解码测试覆盖默认值、重复项、非法类型、空标题、枚举真实值和范围搜索。应用层测试覆盖旧值保留、写入旧键、旧控制器改动回流及无效写入不落盘。 +导入必须包含基准全部组件且每项仅出现一次;类型、存储键、默认值、选项、显隐和值类型合同不可更改。非法导入不替换当前布局。导出替换App资产并重新构建生效。每次更新App索引运行`python3 tools/sync-settings-catalog.py`同步离线目录;`node tools/test-settings-organizer.cjs`验证同步与合同。原型旧路径转向完整索引预览,避免两份索引漂移。 -新 PR 仅包含 SET-01。测试 release 在独立集成分支合入 PR #52 与 #54,不推送 main、不合并远端 PR。APK 不代表新原生设置 UI 已完成。 +## 关于页 -手动验收:横竖屏分类切换、全局/分类搜索、无结果、结果定位、关闭恢复、键盘弹出与关闭、放大字体、深色与减弱动画。Android 后续接线还需验证主题、播放器/弹幕设置生效与升级保留偏好。 +layout=about使用Deadliner实际关于页的身份区、三层浮动光晕与320dp滚动淡出,卡片只有背景从0.5到1变化,文字对比度不变。完整来源、适配差异及MIT许可见`SETTINGS_DEADLINER_PROVENANCE.md`。不用虚假的KMP标签,bdFun应用图标和开发者头像属于原产品身份素材。 + +## 验证与发布 + +纯JVM测试覆盖旧键写入、旧值兼容、非法更新、页面返回/搜索/显隐以及JSON完整索引/浮点往返。Node测试覆盖编排器合同、目录同步与失败隔离;Chrome实测重命名、撤销、跨组移动、拖动排序、非法导入、导出和实时预览搜索。模拟器检查分类、dropdown、全量多行、搜索/IME、横屏与关于页。 + +继续更新PR55。release使用专门集成分支合入PR52和PR54,不把它们的业务提交带入设置PR。完整播放器、登录后的真实账号服务及所有设备/字体组合的长期回归仍由装机测试完成。 diff --git a/docs/prototypes/settings/index.html b/docs/prototypes/settings/index.html index 9caaa9bb..c0333869 100644 --- a/docs/prototypes/settings/index.html +++ b/docs/prototypes/settings/index.html @@ -1,465 +1 @@ - - - -bdFun · 设置原型 - -
bdFun / 设置
交互原型 · 值仅在当前预览中生效
-
- - - +bdFun 设置预览打开使用完整 App 索引的设置预览 diff --git a/docs/settings-organizer/assets/app.png b/docs/settings-organizer/assets/app.png new file mode 100644 index 0000000000000000000000000000000000000000..2e90ea747b171aaaae989e53b2722e047a3fa059 GIT binary patch literal 54979 zcmV(&K;gfMP)eL@Ns$$alqpi8C`z0O5+DH(zysbq*Iad{b54J2_}AXI-lN+o zvhd!ky7%03_Fj7p|N7TjMOBp@iVg*U4*%P}%A(XiyZoIUP!vFc0+XfzTKn;!03JVb z7VkXy2;OtzQM~o&gLvf7F`VqLp)6_?Wr0rKSJ_T$wqP6Q6Xwlh=?sYf*1b#22 z-CNO>==gko9eGV5*N@wl&n)Z`Wr0HOxs(5t=+O8pDxfIQ>H9jMlJ|6aD|#7y=n`dD z=;zq&cXG%2Gc+yq!iBuLKv8z+eEsApTmA3?g+8A=HE*u;$4zT2*Vbq7_v;n>*Rp!7cf>&mOrue!F;d$#-c=ecW5?7BLwa@DOUgf!b>-5vw z4%>UXIKOuT-`Ko{=Wkuamu_9gi?`mu<*jQ_;VFP#Rq5w6x+nQ_W_RifkpGv zuT$1lCxXR`9K3M;We=DHUWHYkkRNEjpHA(1OT8N3dFCm6|3mM@x1D|(Pp;jE<-s!V z7-(ncnyFs5Rk@a3;sPfTP+_&d_-6Y8>mQUr`>P&6`Ab#mQ|UtMflP~}uW$9-yYT8t z*ptaQ&<*8luCot30POOb`bJ2 zn(cx8%=RjT?OreD!)CuKc{W-Ru1uMzaB_cn(+6dtxa;To4Eo4PsA3~JIsCe4=!(ka z#@9uzBYPnKE^DCd*|pmiSN1pX`77VVZ@l(7eDsYk;L`3Ly=qn4K2U)FO=LCx&1X}& z5(VWlT-7R7b1w`*Iq>uR0OYR`fsx-&CZ;W)J$4p<_KD~4qmR4~j~{ywC zUoJ>)wI~Pr?5%10cxCo}-j6;T8v7)DGlhjZy}aiE;7iStQVGd@G^QwGzxa8+U;25O zW>(E^io!mx3+r6T&#M8@8|c4pZC}Sn-~1B((bqnXPhNa+?jX#09&`73_wOmYx%St+ z&JV!1G8uO-e>wnlSz|*h*{-zYui`Pv`+HRl3>H**e)7%d z@iSleU+|l+e@Wlf?^U{{qB>RN|1}oj;8$1?9)N!n;irovOC+yv+ZMA~i^o@w<3D`Q zpTeJi@;OxXGBDo7texqe7M1w0CR{4SuDRxKHgj?2!U=i3Yx&kTIW{XarwfDrRSuXg z@jd+4B$%x#xYA;aSb6%Wz8RSi-|ZT5rz^CzD>Hg%wz_tp-2IZ9Glz^CKL0XJ&v-%) zo(hT_8h5+w2kF|nB&}XfUsZ~wvULiPlIw})aY5DJiTkE&CDv7*H!YR;>-Jk7xDuV1 zQN|E+&rT~d^L^tr^&b)2#2V_s%h%VHkY1N^!S`|@{Q$KcnzmKTPz)CJwI6-sEBL#A z^vn3YOJ7A%0sXQU<~=1h`@4Gza?nMl{!I^nYA^R&MeFZRrWlkJ{`NcnIDYCK ze-f*UN6?LS(MmX{Zi9=k`pWX~7F1FY%Tml`{+Xg7QqVm2^YGnV@2SR|QTl%p7_Y_8vtTYRk=Cr~8vV?&e@NH&AAap)_>VvJ3%D}cz(T)|rj-CZb1JgkN?-G_ zfF6K8HAcH@oQiTlyqD53)Y`p?W4 z#{F^i?UF!zD`&^P^26nxrmkx`943UO%`BKVjYLr3?prdgE}GS10Mlj?Uf4wptvImy zrm6jsY6;|kxbV7o-Xo}_;5wReFO0^V_-w;{%_=(U7HIN1RATxZx}1dxzaIb*baQva zV&DB3agP>i+_n04JUn)z;Fj{*WT_PqQ%iL3-HRO$7nuhj$%1Hm*~fBm1?O+QiGT0o zKZjp`<8#R+(tD#*eDLb-HvVrr0Car{OeZt^g|~bs{$J1iBo6hKF&k~8EX^fSjW~DH zwn>frJ_OQ*m>O>Gz~uM9pwLV(7g%lH0ho`$E3a-rb@eR{fG+^erGCD}93t3r!mj}w zUt?EqV%+VcuISL_iZ!>dDGx$zxS`l#%WjAli+)R`O{d9Nyf%E{}D*!&|qumR2umd2Mw)lb9PVSbyF1r_AFE^*| zGEj(3QO$o|a)SE`SS)Xrz0%6nek(*x-8P@Y+UyZvki+phKbOmq1HIoNU^j>pMM z!UE*Dev(6TGsWVXkbkQ$`IuSTqPMV!kG}dv{P@58e_&^}tB1gL@t|zqBL?7W^4u;R zf^OF0pMLmn;>Vx;0nD~;dcw;!Q&Ff3r0Xkx7fqN2%)Aa4RbP`QVC)kP2;1FI@!D3!KBaV%7mW^syYZO>Hei{0$| z=UL`CQ^o1YT;rRDH0(z#MzTX?WU%jkuj=5kN-$_VYFc^AVR#(nFEG)7fkTif@&^#N2-GO%J+IFKG&4KW^;m) zWz&PxVw{UpyD2_<-Sma~G5!iUt=gEyDkwzvm

H|T^* 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%0AB{+! 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 RIWf&mz9&!1LhWYee*skm!1VwC literal 0 HcmV?d00001 diff --git a/docs/settings-organizer/assets/teak.jpg b/docs/settings-organizer/assets/teak.jpg new file mode 100644 index 0000000000000000000000000000000000000000..76f13e7c7cbc4a2d1c7cd643c7c70051c2a4a811 GIT binary patch literal 15758 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 literal 0 HcmV?d00001 diff --git a/docs/settings-organizer/catalog.js b/docs/settings-organizer/catalog.js new file mode 100644 index 00000000..eaaed973 --- /dev/null +++ b/docs/settings-organizer/catalog.js @@ -0,0 +1,788 @@ +// Generated by tools/sync-settings-catalog.py; source: app assets. +window.SETTINGS_CATALOG = [ + { + "name": "外观与显示", + "route": "settings/appearance", + "groups": [ + { + "title": "主题与风格", + "settings": [ + { + "pref_name": "dark_mode", + "type": "dropdown", + "title": "主题", + "default": 0, + "options": [ + { + "title": "跟随系统", + "value": 0 + }, + { + "title": "浅色", + "value": 1 + }, + { + "title": "深色", + "value": 2 + } + ] + }, + { + "pref_name": "color_style", + "type": "dropdown", + "title": "色彩风格", + "default": 0, + "options": [ + { + "title": "AC 粉", + "value": 0 + }, + { + "title": "Hyper 蓝", + "value": 1 + } + ] + }, + { + "pref_name": "interface_style", + "type": "dropdown", + "title": "界面风格", + "default": 0, + "options": [ + { + "title": "官方", + "value": 0 + }, + { + "title": "经典", + "value": 1 + }, + { + "title": "HyperOS 4", + "value": 2 + } + ] + } + ] + }, + { + "title": "界面效果", + "settings": [ + { + "pref_name": "blur", + "type": "switch", + "title": "模糊效果", + "default": true + }, + { + "pref_name": "animations_enabled", + "type": "switch", + "title": "减弱动态效果", + "default": true, + "inverted": true + }, + { + "pref_name": "smart_refresh", + "type": "switch", + "title": "智能刷新率", + "default": true, + "subtitle": "根据画面状态自动调整屏幕刷新率" + }, + { + "pref_name": "bottom_bar_raise", + "type": "switch", + "title": "抬高底栏", + "default": false, + "subtitle": "解决未开启安卓导航条时底栏位置过低的问题" + } + ] + }, + { + "title": "回到顶部", + "settings": [ + { + "pref_name": "scroll_top_enabled", + "type": "switch", + "title": "回到顶部控件", + "default": true + }, + { + "pref_name": "scroll_top_side", + "type": "dropdown", + "title": "回到顶部控件位置", + "default": 1, + "options": [ + { + "title": "左侧", + "value": 0 + }, + { + "title": "右侧", + "value": 1 + } + ], + "visible_when": "scroll_top_enabled" + }, + { + "pref_name": "scroll_top_pages", + "type": "page_switches", + "title": "回到顶部控件显示页面", + "storage": "settings", + "default": [ + "home", + "dynamic", + "space", + "library", + "message", + "message_detail" + ], + "visible_when": "scroll_top_enabled" + } + ] + }, + { + "title": "壁纸与字号", + "settings": [ + { + "pref_name": "wallpaper_pick", + "type": "action", + "title": "设置开屏壁纸", + "storage": "none", + "default": "" + }, + { + "pref_name": "wallpaper_clear", + "type": "action", + "title": "清除开屏壁纸", + "storage": "none", + "default": "" + }, + { + "pref_name": "font_scale", + "type": "font_scale", + "title": "全局字号", + "default": 1.0, + "value_type": "decimal" + } + ] + } + ] + }, + { + "name": "播放器", + "route": "settings/player", + "groups": [ + { + "title": "播放与手势", + "settings": [ + { + "pref_name": "show_viewer_count", + "type": "switch", + "title": "显示正在观看人数", + "default": true + }, + { + "pref_name": "resume_playback", + "type": "switch", + "title": "播放进度记忆", + "default": true + }, + { + "pref_name": "gesture_brightness_volume", + "type": "switch", + "title": "亮度/音量划动手势", + "default": true + }, + { + "pref_name": "gesture_center_fullscreen", + "type": "switch", + "title": "视频中部上下划屏切换/退出全屏模式", + "default": true + }, + { + "pref_name": "gesture_boost_swipe", + "type": "switch", + "title": "长按倍速划动调速", + "default": true + }, + { + "pref_name": "boost_default_speed", + "type": "dropdown", + "title": "长按默认倍率", + "default": 3.0, + "options": [ + { + "title": "1.5×", + "value": 1.5 + }, + { + "title": "2×", + "value": 2.0 + }, + { + "title": "2.5×", + "value": 2.5 + }, + { + "title": "3×", + "value": 3.0 + }, + { + "title": "4×", + "value": 4.0 + }, + { + "title": "5×", + "value": 5.0 + } + ], + "value_type": "decimal" + }, + { + "pref_name": "default_playback_speed", + "type": "dropdown", + "title": "默认播放倍速", + "default": 1.0, + "options": [ + { + "title": "0.5×", + "value": 0.5 + }, + { + "title": "0.75×", + "value": 0.75 + }, + { + "title": "1×", + "value": 1.0 + }, + { + "title": "1.25×", + "value": 1.25 + }, + { + "title": "1.5×", + "value": 1.5 + }, + { + "title": "2×", + "value": 2.0 + }, + { + "title": "2.5×", + "value": 2.5 + }, + { + "title": "3×", + "value": 3.0 + } + ], + "value_type": "decimal" + } + ] + }, + { + "title": "全屏控件", + "settings": [ + { + "pref_name": "show_fullscreen_time", + "type": "switch", + "title": "全屏显示时间", + "default": true + }, + { + "pref_name": "show_fullscreen_actions", + "type": "switch", + "title": "全屏显示互动按钮", + "default": true + }, + { + "pref_name": "progress_bar_mode", + "type": "dropdown", + "title": "进度条常显", + "default": 0, + "options": [ + { + "title": "关闭", + "value": 0 + }, + { + "title": "仅半屏时常显", + "value": 1 + }, + { + "title": "仅全屏时常显", + "value": 2 + }, + { + "title": "永久显示", + "value": 3 + } + ] + } + ] + }, + { + "title": "播放源与解码", + "settings": [ + { + "pref_name": "quality", + "type": "dropdown", + "title": "默认清晰度", + "default": 0, + "options": [ + { + "title": "自动", + "value": 0 + }, + { + "title": "HDR", + "value": 125 + }, + { + "title": "4K", + "value": 120 + }, + { + "title": "1080P+", + "value": 112 + }, + { + "title": "1080P", + "value": 80 + }, + { + "title": "720P", + "value": 64 + } + ] + }, + { + "pref_name": "decoder_mode", + "type": "dropdown", + "title": "硬解模式", + "default": 0, + "options": [ + { + "title": "自动", + "value": 0 + }, + { + "title": "硬件解码", + "value": 1 + }, + { + "title": "软件解码", + "value": 2 + }, + { + "title": "硬件解码(禁回退)", + "value": 3 + } + ] + }, + { + "pref_name": "codec_preference", + "type": "dropdown", + "title": "首选解码格式", + "default": "auto", + "subtitle": "不可用时自动回退到其他编码", + "options": [ + { + "title": "自动", + "value": "auto" + }, + { + "title": "AVC (H.264)", + "value": "avc" + }, + { + "title": "HEVC (H.265)", + "value": "hevc" + }, + { + "title": "AV1", + "value": "av1" + }, + { + "title": "DVH1", + "value": "dvh1" + } + ] + }, + { + "pref_name": "cdn_service", + "type": "cdn_dropdown", + "title": "CDN 设置", + "default": "auto", + "subtitle": "如发现播放问题,请选择原地址" + }, + { + "pref_name": "auto_rotate", + "type": "switch", + "title": "自动旋转", + "default": true + }, + { + "pref_name": "auto_play", + "type": "switch", + "title": "进入视频自动播放", + "default": true + } + ] + }, + { + "title": "声音与蓝牙", + "settings": [ + { + "pref_name": "loudness_eq", + "type": "switch", + "title": "响度均衡", + "default": false + }, + { + "pref_name": "bt_latency_enabled", + "type": "switch", + "title": "蓝牙耳机延迟补偿", + "default": false, + "subtitle": "检测到蓝牙耳机设备时自动校准播放延迟" + }, + { + "pref_name": "bt_latency_ms", + "type": "dropdown", + "title": "补偿延迟", + "default": 100, + "options": [ + { + "title": "50 ms", + "value": 50 + }, + { + "title": "100 ms", + "value": 100 + }, + { + "title": "150 ms", + "value": 150 + }, + { + "title": "200 ms", + "value": 200 + }, + { + "title": "250 ms", + "value": 250 + }, + { + "title": "300 ms", + "value": 300 + } + ], + "visible_when": "bt_latency_enabled" + } + ] + } + ] + }, + { + "name": "视频详情与弹幕", + "route": "settings/video", + "groups": [ + { + "title": "视频详情页", + "settings": [ + { + "pref_name": "immersive_status", + "type": "switch", + "title": "沉浸状态栏", + "default": false + }, + { + "pref_name": "show_ip_location", + "type": "switch", + "title": "显示评论 IP 属地", + "default": true + } + ] + }, + { + "title": "弹幕", + "settings": [ + { + "pref_name": "sponsor_skip", + "type": "switch", + "title": "空降助手", + "default": true + }, + { + "pref_name": "hot_danmaku", + "type": "switch", + "title": "弹幕计数", + "default": true + }, + { + "pref_name": "dm_low_refresh", + "type": "switch", + "title": "降低弹幕刷新率", + "default": true + } + ] + } + ] + }, + { + "name": "首页、动态与搜索", + "route": "settings/feeds", + "groups": [ + { + "title": "首页推荐", + "settings": [ + { + "pref_name": "recommendation_toggle", + "type": "extension", + "title": "推荐算法优化", + "subtitle": "根据本机观看反馈微调推荐顺序,减少重复。关闭后下次刷新恢复原推荐方式。", + "storage": "none", + "default": "", + "visible_when": "extension:recommendation_toggle" + }, + { + "pref_name": "recommendation_clear", + "type": "extension", + "title": "清除推荐学习记录", + "storage": "none", + "default": "", + "visible_when": "extension:recommendation_clear" + }, + { + "pref_name": "recommendation_error", + "type": "extension", + "title": "推荐记录", + "storage": "none", + "default": "", + "visible_when": "extension:recommendation_error" + } + ] + }, + { + "title": "动态页", + "settings": [ + { + "pref_name": "dynamic_auto_play", + "type": "switch", + "title": "动态视频自动播放", + "default": true + }, + { + "pref_name": "dynamic_auto_play_quality", + "type": "dropdown", + "title": "动态视频清晰度", + "default": 64, + "options": [ + { + "title": "1080P", + "value": 80 + }, + { + "title": "720P", + "value": 64 + }, + { + "title": "480P", + "value": 32 + } + ] + } + ] + }, + { + "title": "搜索页", + "settings": [ + { + "pref_name": "show_hot_search", + "type": "switch", + "title": "显示热搜", + "default": true + } + ] + } + ] + }, + { + "name": "我的页", + "route": "settings/mine", + "groups": [ + { + "title": "入口与预览", + "settings": [ + { + "pref_name": "sent_history_entry", + "type": "switch", + "title": "显示「我的评论与弹幕」入口", + "default": true + }, + { + "pref_name": "history", + "type": "mine_switch", + "title": "展开历史记录", + "storage": "mine_layout", + "default": false + }, + { + "pref_name": "favorites", + "type": "mine_switch", + "title": "展开我的收藏", + "storage": "mine_layout", + "default": false + }, + { + "pref_name": "watch_later", + "type": "mine_switch", + "title": "展开稍后再看", + "storage": "mine_layout", + "default": false + } + ] + } + ] + }, + { + "name": "关于 App", + "route": "settings/about", + "groups": [ + { + "title": "应用信息", + "settings": [ + { + "pref_name": "app_identity", + "type": "app_identity", + "title": "bdFun", + "storage": "none", + "default": "" + } + ] + }, + { + "title": "开发者", + "settings": [ + { + "pref_name": "developer_aritx", + "type": "developer", + "title": "AritxOnly", + "storage": "none", + "default": "" + }, + { + "pref_name": "developer_teak", + "type": "developer", + "title": "Teak", + "storage": "none", + "default": "" + } + ] + }, + { + "title": "关于", + "settings": [ + { + "pref_name": "version", + "type": "version", + "title": "版本", + "storage": "none", + "default": "" + }, + { + "pref_name": "discussion", + "type": "action", + "title": "加入讨论组", + "storage": "none", + "default": "" + }, + { + "pref_name": "changelog", + "type": "action", + "title": "更新日志", + "storage": "none", + "default": "" + }, + { + "pref_name": "help", + "type": "action", + "title": "帮助中心", + "storage": "none", + "default": "" + }, + { + "pref_name": "onboarding", + "type": "action", + "title": "查看开始页", + "storage": "none", + "default": "" + }, + { + "pref_name": "donation", + "type": "action", + "title": "捐赠", + "storage": "none", + "default": "" + }, + { + "pref_name": "credits", + "type": "action", + "title": "引用", + "storage": "none", + "default": "" + }, + { + "pref_name": "project", + "type": "action", + "title": "项目地址", + "storage": "none", + "default": "" + } + ] + }, + { + "title": "调试", + "settings": [ + { + "pref_name": "live_preview", + "type": "action", + "title": "直播间(预览)", + "storage": "none", + "default": "", + "visible_when": "debug" + }, + { + "pref_name": "logs", + "type": "action", + "title": "日志抓取", + "storage": "none", + "default": "", + "visible_when": "debug" + } + ] + } + ], + "layout": "about" + }, + { + "name": "账号", + "route": "settings/account", + "groups": [ + { + "title": "账号", + "settings": [ + { + "pref_name": "logout", + "type": "action", + "title": "退出登录", + "storage": "none", + "default": "", + "visible_when": "logged_in" + } + ] + } + ] + } +]; diff --git a/docs/settings-organizer/index.html b/docs/settings-organizer/index.html new file mode 100644 index 00000000..67bbf667 --- /dev/null +++ b/docs/settings-organizer/index.html @@ -0,0 +1,6 @@ + +bdFun 设置编排 +

+

页面结构

拖动调整顺序,也可用上移 / 下移按钮

编排属性

选择分类、分组或设置进行编辑

导出内容

控件类型、选项、默认值和显隐条件来自现有实现。这里只调整组织与文案,导出的文件替换 App 资产后构建生效。

实时预览

可点击 dropdown、开关和底部搜索

+ + diff --git a/docs/settings-organizer/organizer.css b/docs/settings-organizer/organizer.css new file mode 100644 index 00000000..a886af63 --- /dev/null +++ b/docs/settings-organizer/organizer.css @@ -0,0 +1 @@ +:root{font:14px/1.5 system-ui,-apple-system,"PingFang SC",sans-serif;color:#23242b;background:#f3f3f5;--card:#fff;--muted:#85858d;--blue:#3478f6}*{box-sizing:border-box}body{margin:0}button,input,textarea,select{font:inherit}button,.button{border:1px solid #e3e4e9;border-radius:10px;background:white;padding:8px 12px;color:#333;cursor:pointer;display:inline-block}button:disabled{opacity:.4;cursor:default}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:3px solid #86b5ff;outline-offset:2px}button:hover:not(:disabled),.button:hover{background:#f0f4fd}.primary{background:var(--blue);color:#fff;border-color:var(--blue)}button.primary:hover{background:#2464df}.toolbar{min-height:90px;display:flex;align-items:center;justify-content:space-between;padding:18px 26px;background:#ffffffc9;backdrop-filter:blur(20px);border-bottom:1px solid #e4e5e9;gap:18px}.toolbar strong{font-size:19px}.toolbar span{font-weight:450;color:#777}.toolbar p{margin:4px 0 0;font-size:12px;color:var(--muted)}.toolbar-actions,.preview-tools{display:flex;gap:8px;flex-wrap:wrap}main{display:grid;grid-template-columns:minmax(330px,1.05fr) minmax(280px,.85fr) minmax(380px,1.2fr);gap:18px;padding:22px;height:calc(100vh - 95px);min-height:650px}.structure,.inspector,.preview{min-width:0;background:var(--card);border-radius:20px;padding:20px;overflow:auto}.sectionhead{display:flex;gap:12px;align-items:center;justify-content:space-between;margin-bottom:18px}.sectionhead h1,.sectionhead h2{margin:0;font-size:17px}.sectionhead p{margin:5px 0 0;font-size:11px;color:var(--muted)}.sectionhead button{flex-shrink:0}.filter{width:100%;border:0;background:#f2f3f6;padding:11px 14px;border-radius:12px;margin-bottom:18px}.category{margin-bottom:18px;border:1px solid #e8e9ed;border-radius:15px;overflow:hidden}.category-head,.group-head,.setting{display:flex;align-items:center;gap:8px}.category-head{background:#f8f9fb;padding:11px 10px}.pick{flex:1;border:0;text-align:left;background:transparent;padding:6px;min-width:0;word-break:break-word}.category-head .pick{font-weight:600}.arrows{display:flex;gap:2px}.arrows button{padding:3px 6px;border:0;background:transparent;color:#8b8d94;font-size:11px}.group{padding:8px 8px 10px;border-top:1px solid #eee}.group-head .pick{font-size:12px;color:#747883}.setting{margin:4px 0;padding:3px 5px;border-radius:9px;cursor:grab}.setting .pick{font-size:13px}.setting small{display:block;font-size:10px;color:#a1a2a8}.selected{background:#ecf2ff}.drop-target{outline:2px dashed #5d94f6;outline-offset:-3px}.add-group{border:0;background:transparent;color:#6383b7;font-size:12px;margin:0 8px 8px}.field{display:block;margin:18px 0}.field>span{display:block;font-size:12px;color:#767985;margin-bottom:7px}.field input,.field textarea,.field select{width:100%;padding:10px;border:1px solid #e1e3e9;border-radius:10px;background:#fcfcfd}.field textarea{min-height:90px;resize:vertical}.readonly{background:#f4f5f8;border-radius:12px;padding:14px;font-size:12px;color:#747680;word-break:break-all}.readonly strong{display:block;color:#4d5361;font-weight:500;margin-bottom:7px}.danger{color:#b55050;margin-top:14px}.empty{padding:20px;color:#9699a3;font-size:12px}.footnote{font-size:11px;color:#9799a2;margin-top:24px;line-height:1.8}details{margin-top:28px;color:#737783;font-size:12px}details textarea{width:100%;height:260px;border:0;background:#f6f7f9;border-radius:10px;font-family:monospace;font-size:11px;margin-top:12px;padding:10px;resize:vertical}.preview{background:#e9ebef;display:flex;flex-direction:column;align-items:center}.preview .sectionhead{width:100%}iframe{width:min(100%,390px);height:750px;border:0;border-radius:24px;background:#f3f3f3;box-shadow:0 10px 30px #29384b14;flex-shrink:0}.preview.wide iframe{width:900px;height:540px;transform-origin:top left}.preview.wide{align-items:flex-start;overflow:auto}#notice{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#282c36;color:white;padding:14px 24px;border-radius:14px;max-width:90%;font-size:13px;box-shadow:0 8px 30px #0002;z-index:10}@media(max-width:1150px){main{grid-template-columns:1.1fr 1fr;height:auto}.preview{grid-column:1/-1;min-height:860px}.toolbar{flex-wrap:wrap}}@media(max-width:700px){main{display:block;padding:12px}.structure,.inspector,.preview{margin-bottom:14px;padding:16px}.toolbar{padding:14px}.toolbar-actions{gap:6px}.toolbar button,.toolbar .button{font-size:12px}.structure{max-height:650px}.preview-tools button{font-size:12px}} diff --git a/docs/settings-organizer/organizer.js b/docs/settings-organizer/organizer.js new file mode 100644 index 00000000..4dfef2cc --- /dev/null +++ b/docs/settings-organizer/organizer.js @@ -0,0 +1,47 @@ +'use strict'; +const {clone,flatten,validate}=SettingsSchema; +const baseline=clone(SETTINGS_CATALOG),storageKey='bdfun-settings-layout-v2'; +let model=clone(baseline),selection={kind:'category',route:model[0].route},undoStack=[],redoStack=[],wide=false,dark=false,drag=null; +const $=selector=>document.querySelector(selector),node=(tag,cls,text)=>{const e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e;}; +function notice(text){$('#notice').textContent=text;$('#notice').hidden=false;clearTimeout(notice.timer);notice.timer=setTimeout(()=>$('#notice').hidden=true,4500)} +try{const saved=localStorage.getItem(storageKey);if(saved)model=validate(JSON.parse(saved),baseline)}catch(e){notice('未恢复本地草稿:'+e.message)} +function locate(key){for(let c=0;cx.pref_name===key);if(s>=0)return{c,g,s}}return null} +function commit(change){try{const draft=clone(model);change(draft);const valid=validate(draft,baseline);if(JSON.stringify(valid)===JSON.stringify(model))return;undoStack.push(clone(model));if(undoStack.length>100)undoStack.shift();redoStack=[];model=valid;save();render()}catch(e){notice(e.message)}} +function save(){try{localStorage.setItem(storageKey,JSON.stringify(model))}catch(e){notice('浏览器无法保存草稿,请导出 JSON 备份')}$('#status').textContent=`${model.length} 个分类 · ${flatten(model).length} 个组件 · 仅保存到当前浏览器`} +function select(next){selection=next;render()} +function button(label,action,cls){const b=node('button',cls,label);b.type='button';b.onclick=action;return b} +function arrows(up,down){const a=node('span','arrows');a.append(button('上移',up),button('下移',down));return a} +function swap(array,index,delta){const target=index+delta;if(target>=0&&target{const item=draft[at.c].groups[at.g].settings.splice(at.s,1)[0];const target=draft[c].groups[g].settings;let index=position==null?target.length:position;if(at.c===c&&at.g===g&&at.s{e.preventDefault();e.stopPropagation();element.classList.add('drop-target')};element.ondragleave=()=>element.classList.remove('drop-target');element.ondrop=e=>{e.preventDefault();e.stopPropagation();element.classList.remove('drop-target');if(drag)payload(drag);drag=null}} +function draggable(element,value){element.draggable=true;element.ondragstart=e=>{e.stopPropagation();drag=value;e.dataTransfer.effectAllowed='move';e.dataTransfer.setData('text/plain',JSON.stringify(value))};element.ondragend=()=>{drag=null;document.querySelectorAll('.drop-target').forEach(e=>e.classList.remove('drop-target'))}} +function renderTree(){const tree=$('#tree'),filter=$('#filter').value.trim().toLowerCase();tree.replaceChildren();model.forEach((category,c)=>{const section=node('div','category'),head=node('div','category-head');head.append(button(category.name,()=>select({kind:'category',route:category.route}),'pick'),arrows(()=>commit(d=>swap(d,c,-1)),()=>commit(d=>swap(d,c,1))));draggable(head,{kind:'category',c});dropzone(head,p=>{if(p.kind==='category')commit(d=>{const item=d.splice(p.c,1)[0];d.splice(c,0,item)})});section.append(head); + category.groups.forEach((group,g)=>{const visible=group.settings.filter(s=>!filter||[category.name,group.title,s.title,s.pref_name,s.subtitle||''].join(' ').toLowerCase().includes(filter));if(filter&&!visible.length)return; + const block=node('div','group'),title=node('div','group-head');title.append(button(group.title,()=>select({kind:'group',route:category.route,g}),'pick'),arrows(()=>commit(d=>swap(d[c].groups,g,-1)),()=>commit(d=>swap(d[c].groups,g,1))));block.append(title);draggable(title,{kind:'group',c,g});dropzone(block,p=>{if(p.kind==='setting')moveSetting(p.key,c,g);else if(p.kind==='group')commit(d=>{const item=d[p.c].groups.splice(p.g,1)[0];d[c].groups.splice(g,0,item)})}); + visible.forEach(s=>{const index=group.settings.indexOf(s),row=node('div','setting'+(selection.kind==='setting'&&selection.key===s.pref_name?' selected':'')),pick=button('',()=>select({kind:'setting',key:s.pref_name}),'pick');pick.append(node('span','',s.title),node('small','',s.type+' · '+s.pref_name));row.append(pick,arrows(()=>commit(d=>swap(d[c].groups[g].settings,index,-1)),()=>commit(d=>swap(d[c].groups[g].settings,index,1))));draggable(row,{kind:'setting',key:s.pref_name});dropzone(row,p=>{if(p.kind==='setting')moveSetting(p.key,c,g,index)});block.append(row)}); + if(!group.settings.length)block.append(node('p','empty','把设置拖到这里,或在属性中移动到此分组')); + section.append(block); + }); + section.append(button('添加分组',()=>{commit(d=>d[c].groups.push({title:'新分组',settings:[]}));select({kind:'group',route:category.route,g:model[c].groups.length-1})},'add-group'));tree.append(section); +})} +function field(label,value,change,multiline=false){const wrapper=node('label','field');wrapper.append(node('span','',label));const input=node(multiline?'textarea':'input');input.value=value||'';input.onchange=()=>change(input.value);wrapper.append(input);return wrapper} +function destination(label,options,current,change){const wrapper=node('label','field'),select=node('select');wrapper.append(node('span','',label));options.forEach(([value,title])=>{const option=node('option','',title);option.value=value;option.selected=value===current;select.append(option)});select.onchange=()=>change(select.value);wrapper.append(select);return wrapper} +function renderProperties(){const panel=$('#properties');panel.replaceChildren();const c=model.findIndex(c=>c.route===selection.route); + if(selection.kind==='category'&&c>=0){const category=model[c];panel.append(field('分类名称',category.name,v=>commit(d=>d[c].name=v)),field('分类路由',category.route,v=>{const previous=category.route;commit(d=>d[c].route=v);selection.route=model[c].route||previous;render()}));panel.append(node('div','readonly','页面布局:'+(category.layout==='about'?'Deadliner 关于页':'标准设置页')+'。第一阶段不使用分类图标。'));panel.append(button('删除空分类',()=>{if(category.groups.some(g=>g.settings.length))return notice('请先把设置移动到其他分类');commit(d=>d.splice(c,1));selection={kind:'category',route:model[0].route};render()},'danger'));return} + if(selection.kind==='group'&&c>=0){const g=selection.g,group=model[c].groups[g];if(!group)return;panel.append(field('分组标题',group.title,v=>commit(d=>d[c].groups[g].title=v)));panel.append(destination('移动到分类',model.map((c,i)=>[String(i),c.name]),String(c),v=>{const target=Number(v);commit(d=>d[target].groups.push(d[c].groups.splice(g,1)[0]));selection={kind:'group',route:model[target].route,g:model[target].groups.length-1};render()}));panel.append(button('删除空分组',()=>{if(group.settings.length)return notice('请先移动这个分组中的设置');commit(d=>d[c].groups.splice(g,1));select({kind:'category',route:model[c].route})},'danger'));return} + if(selection.kind==='setting'){const at=locate(selection.key);if(!at)return;const s=model[at.c].groups[at.g].settings[at.s];panel.append(field('设置标题',s.title,v=>commit(d=>d[at.c].groups[at.g].settings[at.s].title=v)),field('说明',s.subtitle||'',v=>commit(d=>{const entry=d[at.c].groups[at.g].settings[at.s];if(v)entry.subtitle=v;else delete entry.subtitle}),true)); + const choices=model.flatMap((c,ci)=>c.groups.map((g,gi)=>[`${ci}:${gi}`,`${c.name} / ${g.title}`]));panel.append(destination('所在分类 / 分组',choices,`${at.c}:${at.g}`,v=>{const [c,g]=v.split(':').map(Number);moveSetting(s.pref_name,c,g)})); + const info=node('div','readonly');info.append(node('strong','',s.type+' 组件'),node('div','',`存储:${s.storage||'settings'} / ${s.pref_name}`),node('div','',`默认值:${JSON.stringify(s.default)}`),node('div','',`显隐:${s.visible_when||'始终显示'}`));if(s.inverted)info.append(node('div','','反向开关:保留原来的减弱动态效果语义'));if(s.options)info.append(node('div','',`Dropdown:${s.options.map(o=>o.title).join(' / ')}`));panel.append(info);return} + panel.append(node('p','empty','点击左侧条目编辑组织方式。')); +} +function syncPreview(){const route=selection.kind==='setting'?model[locate(selection.key)?.c]?.route:selection.route;$('#preview').contentWindow?.postMessage({type:'settings-layout',categories:model,route,wide,dark},'*')} +function render(){renderTree();renderProperties();$('#undo').disabled=!undoStack.length;$('#redo').disabled=!redoStack.length;$('#json').value=JSON.stringify(model,null,2);save();syncPreview()} +$('#filter').oninput=renderTree; +$('#undo').onclick=()=>{if(!undoStack.length)return;redoStack.push(clone(model));model=undoStack.pop();render()};$('#redo').onclick=()=>{if(!redoStack.length)return;undoStack.push(clone(model));model=redoStack.pop();render()}; +$('#add-category').onclick=()=>{let n=1;while(model.some(c=>c.route===`settings/category_${n}`))n++;const route=`settings/category_${n}`;commit(d=>d.push({name:'新分类',route,groups:[]}));select({kind:'category',route})}; +$('#reset').onclick=()=>{commit(d=>d.splice(0,d.length,...clone(baseline)));select({kind:'category',route:model[0].route})}; +$('#export').onclick=()=>{try{const valid=validate(model,baseline),blob=new Blob([JSON.stringify(valid,null,2)+'\n'],{type:'application/json'}),url=URL.createObjectURL(blob),a=node('a');a.href=url;a.download='settings-index.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);notice('已导出完整索引,现有组件及存储合同保持不变')}catch(e){notice(e.message)}}; +$('#import').onchange=async e=>{const file=e.target.files[0];if(!file)return;try{if(file.size>1048576)throw Error('文件超过1MiB');const next=validate(JSON.parse(await file.text()),baseline);commit(d=>d.splice(0,d.length,...next));selection={kind:'category',route:model[0].route};render();notice('索引已导入')}catch(error){notice('导入失败:'+error.message)}finally{e.target.value=''}}; +$('#orientation').onclick=()=>{wide=!wide;document.querySelector('.preview').classList.toggle('wide',wide);$('#orientation').textContent=wide?'竖屏':'横屏';syncPreview()};$('#theme').onclick=()=>{dark=!dark;$('#theme').textContent=dark?'浅色':'深色';syncPreview()};$('#preview').onload=syncPreview; +window.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'&&!['INPUT','TEXTAREA'].includes(document.activeElement.tagName)){e.preventDefault();(e.shiftKey?$('#redo'):$('#undo')).click()}}); +render(); diff --git a/docs/settings-organizer/preview.css b/docs/settings-organizer/preview.css new file mode 100644 index 00000000..341e94cf --- /dev/null +++ b/docs/settings-organizer/preview.css @@ -0,0 +1,5 @@ +:root{font-family:system-ui,-apple-system,"PingFang SC",sans-serif;--page:#f3f3f3;--card:#fff;--fg:#191919;--secondary:#8b8b92;--blue:#3482ff;--glass:#ffffffc7;color:var(--fg);background:var(--page);font-size:15px}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit;color:inherit}button{cursor:pointer;border:0;background:transparent}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #8fb8ff;outline-offset:2px}#standalone{padding:16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;background:var(--card)}#standalone button,#standalone a{padding:8px 13px;background:var(--page);border-radius:20px;font-size:13px;text-decoration:none;color:inherit}.context{height:30px;display:flex;align-items:center;justify-content:center;gap:12px;font-size:10px;color:var(--secondary);background:var(--card)}.context input{vertical-align:middle;width:12px;height:12px}#app{position:relative;display:flex;height:calc(100dvh - 100px);min-height:500px;overflow:hidden;background:var(--page)}body.embedded #app{height:calc(100dvh - 30px);min-height:0}body.embedded #standalone{display:none}aside{width:270px;flex-shrink:0;padding:0 16px 100px;overflow:auto;border-right:1px solid #8881}section{position:relative;flex:1;min-width:0;overflow:auto;padding:0 16px 100px}h1{font-size:26px;margin:0;font-weight:650;letter-spacing:-.6px}h3{font-size:11.5px;color:var(--secondary);font-weight:400;margin:19px 14px 5px}.header{position:sticky;top:0;z-index:2;min-height:80px;display:flex;align-items:center;gap:12px;background:linear-gradient(var(--page) 50%,transparent);backdrop-filter:blur(5px);padding:10px 0 18px}.header h1{flex:1}.header button{font-size:12px;flex-shrink:0;padding:12px;background:var(--glass);border-radius:30px}.card{background:var(--card);border-radius:22px;overflow:hidden;margin-bottom:8px}.row{display:flex;align-items:center;gap:14px;width:100%;text-align:left;min-height:75px;padding:18px 20px;position:relative}.row+.row:before{content:"";position:absolute;left:20px;right:20px;top:0;height:1px;background:#8881}.row.selected{background:#3482ff12}.label{flex:1;min-width:0}.label strong{display:block;font-size:15px;font-weight:500;line-height:1.5}.label small{display:block;color:var(--secondary);font-size:12px;line-height:1.6;margin-top:5px}.category-row{min-height:67px}.switch{width:49px;height:28px;padding:3px;border-radius:20px;flex-shrink:0;background:#d8d8dd;transition:background .2s}.switch:after{content:"";display:block;width:22px;height:22px;background:#fff;border-radius:50%;box-shadow:0 1px 2px #0001;transition:transform .2s}.switch[aria-checked=true]{background:var(--blue)}.switch[aria-checked=true]:after{transform:translateX(21px)}select{border:0;background:transparent;color:var(--secondary);max-width:44%;font-size:12px;text-align:right}.trailing{font-size:12px;color:var(--secondary)}.chevron{color:#a1a1a8;font-size:18px}.avatar{width:38px;height:38px;object-fit:cover;border-radius:50%}.slider-row{display:block}.slider-line{display:flex;align-items:center;gap:15px;margin-top:15px}.slider-line input{flex:1;min-width:0;accent-color:var(--blue)}.slider-line span{font-size:12px;color:var(--secondary)}.about{background:var(--page)}.about:before{content:"";position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse at 18% 22%,#fa7ca63a,transparent 70%),radial-gradient(ellipse at 86% 66%,#8c9ef52e,transparent 75%),radial-gradient(ellipse at 52% 96%,#f394bd29,transparent 65%);animation:float 7.5s linear infinite alternate;opacity:var(--glow-alpha,1)}.about .card,.about h3,.identity{position:relative}.identity{min-height:252px;padding:28px 0;display:flex;flex-direction:column;align-items:center;gap:8px}.identity img{width:96px;height:96px;object-fit:contain;border-radius:24px}.identity strong{font-size:30px;font-weight:700;margin-top:2px}.identity small{font-size:14px;color:var(--secondary);padding:4px 12px}.glass{background:var(--glass);backdrop-filter:blur(30px) saturate(1.4);box-shadow:inset 0 1px 1px #fff8,0 6px 24px #29384b12;border:1px solid #ffffff44}#dock{position:absolute;bottom:18px;left:16px;width:238px;display:flex;gap:10px;transition:bottom .38s,width .38s,left .38s;z-index:6}.input{min-width:0;min-height:54px;display:flex;align-items:center;gap:10px;border-radius:30px;padding:15px 18px;flex:1}.input span{font-size:12px;color:var(--secondary)}.input input{background:none;outline:0;border:0;width:100%;min-width:0;font-size:14px}#close{width:54px;height:54px;border-radius:50%;font-size:12px;display:none;flex-shrink:0}#results{display:none;position:absolute;top:0;bottom:0;left:270px;right:0;background:var(--page);overflow:auto;padding:0 16px 100px;z-index:4}#results .result{margin:10px 0;border-radius:18px;background:var(--card)}#results .hint{font-size:12px;color:var(--secondary);padding:25px 14px;line-height:1.8}#app.searching #results{display:block}#app.searching #close{display:block}#app.searching #dock{left:286px;width:calc(100% - 302px)}#content,aside{transition:opacity .15s,transform .38s}#app.searching #content,#app.searching.global aside{opacity:0;transform:translateY(-40px);pointer-events:none}#app.phone aside{width:100%;border:0}#app.phone section{display:none}#app.phone.detail aside{display:none}#app.phone.detail section{display:block}#app.phone #dock{left:16px;width:calc(100% - 32px)}#app.phone.detail:not(.searching) #dock{display:none}#app.phone #results{left:0}#app.phone.searching.ime #dock{bottom:288px}#keyboard{position:absolute;bottom:0;left:0;right:0;height:270px;background:#d5d7df;display:none;align-items:center;justify-content:center;color:#727581;font-size:12px;z-index:5}#app.phone.searching.ime #keyboard{display:flex}#app.phone.searching.ime #results{bottom:270px}#toast{position:absolute;bottom:85px;left:50%;transform:translateX(-50%);background:#252936;color:#fff;border-radius:20px;font-size:12px;padding:10px 18px;display:none;z-index:8}.spotlight{animation:spot 1.8s}@keyframes spot{20%,65%{background:#3482ff20}}@keyframes float{to{transform:translate(3%,2%)}}body.dark{--page:#000;--card:#242424;--fg:#f1f1f1;--secondary:#a0a0a8;--glass:#242424cb}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} + +/* 预览沿用原页面的独立返回工具栏与滚动大标题,不改成并排大标题栏。 */ +.chrome{position:sticky;top:0;z-index:2;display:flex;align-items:center;justify-content:space-between;min-height:56px;background:linear-gradient(var(--page) 45%,transparent);gap:10px} +.chrome button{font-size:12px;min-width:40px;min-height:40px;padding:8px;background:transparent;border-radius:24px}.small-title{flex:1;text-align:center;opacity:0;font-size:16px}.large-title{font-size:30px;font-weight:400;margin:0;padding:0 10px 8px;letter-spacing:0}.context{font-size:10px} diff --git a/docs/settings-organizer/preview.html b/docs/settings-organizer/preview.html new file mode 100644 index 00000000..e80ac1c2 --- /dev/null +++ b/docs/settings-organizer/preview.html @@ -0,0 +1 @@ +bdFun 设置预览
系统键盘区域(预览占位)
diff --git a/docs/settings-organizer/preview.js b/docs/settings-organizer/preview.js new file mode 100644 index 00000000..e34c91a1 --- /dev/null +++ b/docs/settings-organizer/preview.js @@ -0,0 +1,32 @@ +'use strict'; +let categories=SettingsSchema.clone(SETTINGS_CATALOG),route=null,searchScope=null,searching=false; +const values=Object.fromEntries(SettingsSchema.flatten(categories).map(s=>[s.pref_name,s.default])); +const $=s=>document.querySelector(s),app=$('#app'),query=$('#query'),el=(tag,cls,text)=>{const e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e}; +if(new URLSearchParams(location.search).has('embedded'))document.body.classList.add('embedded'); +app.classList.toggle('phone',innerWidth<650); +function visible(s){return !s.visible_when||s.visible_when.startsWith('extension:')||s.visible_when==='logged_in'&&$('#logged').checked||s.visible_when==='debug'&&$('#debug').checked||values[s.visible_when]===true} +function available(){return categories.map(c=>({...c,groups:c.groups.map(g=>({...g,settings:g.settings.filter(visible)})).filter(g=>g.settings.length)})).filter(c=>c.groups.length)} +function notify(text){const toast=$('#toast');toast.textContent=text;toast.style.display='block';clearTimeout(notify.timer);notify.timer=setTimeout(()=>toast.style.display='none',1900)} +function header(title,back,search){const fragment=document.createDocumentFragment(),chrome=el('div','chrome');if(back){const b=el('button','','返回');b.onclick=back;chrome.append(b)}const small=el('span','small-title',title);chrome.append(small);if(search){const b=el('button','','搜索');b.onclick=search;chrome.append(b)}fragment.append(chrome,el('h1','large-title',title));return fragment} + +function closeSearch(){searching=false;query.value='';query.blur();app.classList.remove('searching','global')} +function openCategory(next,key){route=next;closeSearch();render();if(key){const target=document.getElementById('pref-'+key);target?.scrollIntoView({block:'center',behavior:'smooth'});target?.classList.add('spotlight')}} +function startSearch(scope=null){searchScope=scope;searching=true;app.classList.add('searching');app.classList.toggle('global',!scope);query.placeholder=scope?'搜索当前分类':'搜索全部设置';query.focus();showResults()} +function identity(setting){const box=el('div','identity'),img=el('img');img.src='assets/app.png';img.alt='';box.append(img,el('strong','',setting.title),el('small','','vpreview-5.0'));return box} +function settingRow(s){if(s.pref_name==='recommendation_error')return document.createDocumentFragment();if(s.type==='app_identity')return identity(s);const row=el('div','row');row.id='pref-'+s.pref_name;const label=el('div','label');label.append(el('strong','',s.title));if(s.subtitle)label.append(el('small','',s.subtitle)); + if(s.type==='developer'){const image=el('img','avatar');image.src='assets/'+(s.pref_name==='developer_aritx'?'aritx':'teak')+'.jpg';image.alt='';row.append(image)}row.append(label); + if(['switch','boolean','mine_switch'].includes(s.type)||s.pref_name==='recommendation_toggle'){const b=el('button','switch');b.setAttribute('role','switch');b.ariaLabel=s.title;const checked=Boolean(values[s.pref_name])!==Boolean(s.inverted);b.setAttribute('aria-checked',String(checked));b.onclick=()=>{values[s.pref_name]=!values[s.pref_name];render()};row.append(b)} + else if(['dropdown','select','cdn_dropdown'].includes(s.type)){const select=el('select');select.ariaLabel=s.title;let options=s.options;if(s.type==='cdn_dropdown')options=[{title:'自动',value:'auto'},{title:'原地址',value:'origin'}];options.forEach((o,i)=>{const n=el('option','',o.title);n.value=i;n.selected=o.value===values[s.pref_name];select.append(n)});select.onchange=()=>values[s.pref_name]=options[Number(select.value)].value;row.append(select)} + else if(s.type==='font_scale'){row.classList.add('slider-row');const line=el('div','slider-line'),input=el('input'),value=el('span','',Math.trunc(values[s.pref_name]*100)+'%');input.type='range';input.min=.8;input.max=1.2;input.step=.01;input.value=values[s.pref_name];input.ariaLabel=s.title;input.oninput=()=>{let n=Number(input.value);if(Math.abs(n-1)<.025)n=1;values[s.pref_name]=n;value.textContent=Math.trunc(n*100)+'%'};line.append(input,value);row.append(line)} + else if(s.type==='page_switches'){const fragment=document.createDocumentFragment();['首页','动态页','个人空间','收藏 / 历史 / 稍后再看','消息页','消息详情页'].forEach((title,i)=>{const key=['home','dynamic','space','library','message','message_detail'][i],part=el('div','row'),l=el('div','label'),b=el('button','switch');l.append(el('strong','',title));b.setAttribute('role','switch');b.ariaLabel=title;b.setAttribute('aria-checked',String(values[s.pref_name].includes(key)));b.onclick=()=>{const current=values[s.pref_name];values[s.pref_name]=current.includes(key)?current.filter(v=>v!==key):[...current,key];render()};part.append(l,b);fragment.append(part)});return fragment} + else if(s.type==='version')row.append(el('span','trailing','preview-5.0')); + else{const tail=s.pref_name==='wallpaper_pick'?'未设置':s.pref_name==='discussion'?'Telegram 群':['logs','live_preview'].includes(s.pref_name)?'调试':'';if(tail)row.append(el('span','trailing',tail));row.append(el('span','chevron','›'));row.tabIndex=0;row.setAttribute('role','button');row.onclick=()=>notify('预览模式:'+s.title+',不执行外部操作');row.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();row.click()}};if(s.pref_name==='logout')label.style.color='#e5484d'} + return row; +} +function render(){app.style.setProperty('--blue',values.color_style===1?'#3482ff':'#ff6699');const list=available(),phone=app.classList.contains('phone'),selected=list.find(c=>c.route===route)||(!phone?list[0]:null);app.classList.toggle('detail',!!selected);const nav=$('#categories');nav.replaceChildren(header('设置',()=>notify('预览模式'),null));const card=el('div','card');list.forEach(c=>{const b=el('button','row category-row'+(selected?.route===c.route?' selected':''));b.append(el('span','label',c.name),el('span','chevron','›'));b.onclick=()=>openCategory(c.route);card.append(b)});nav.append(card);const content=$('#content');content.replaceChildren();content.classList.toggle('about',selected?.layout==='about');if(selected){content.append(header(selected.name,phone?()=>{route=null;render()}:null,()=>startSearch(selected.route)));selected.groups.forEach(g=>{if(g.settings.every(s=>s.type==='app_identity')){g.settings.forEach(s=>content.append(identity(s)));return}content.append(el('h3','',g.title));const group=el('div','card');g.settings.forEach(s=>group.append(settingRow(s)));content.append(group)});content.onscroll=()=>{if(selected.layout!=='about')return;const p=Math.min(1,content.scrollTop/320);content.style.setProperty('--glow-alpha',1-p);content.querySelectorAll('.card').forEach(card=>card.style.opacity=.5+p*.5)};if(selected.layout==='about')content.onscroll()} + if(searching)showResults(); +} +function showResults(){const result=$('#results');result.replaceChildren(header(searchScope?'搜索当前分类':'搜索设置',closeSearch,null));const words=query.value.trim().toLowerCase().split(/\s+/).filter(Boolean);if(!words.length){result.append(el('p','hint','搜索设置名称、说明或关键词'));return}let count=0;available().filter(c=>!searchScope||c.route===searchScope).forEach(c=>c.groups.forEach(g=>g.settings.forEach(s=>{const text=[c.name,g.title,s.title,s.subtitle||'',s.pref_name].join(' ').toLowerCase();if(words.every(w=>text.includes(w))){const b=el('button','row result'),label=el('div','label');label.append(el('strong','',s.title),el('small','',c.name+' · '+g.title));b.append(label);b.onclick=()=>openCategory(c.route,s.pref_name);result.append(b);count++}})));if(!count)result.append(el('p','hint','没有找到相关设置'))} +query.onfocus=()=>{if(!searching)startSearch()};query.oninput=showResults;query.onkeydown=e=>{if(e.key==='Escape')closeSearch()};$('#close').onclick=closeSearch;$('#logged').onchange=render;$('#debug').onchange=render;$('#ime').onchange=()=>app.classList.toggle('ime',$('#ime').checked);$('#toggle-width').onclick=()=>{app.classList.toggle('phone');render()};$('#toggle-dark').onclick=()=>document.body.classList.toggle('dark'); +window.addEventListener('message',e=>{if(e.source!==parent||e.data?.type!=='settings-layout')return;try{categories=SettingsSchema.validate(e.data.categories,SETTINGS_CATALOG);route=e.data.route||null;closeSearch();app.classList.toggle('phone',!e.data.wide);document.body.classList.toggle('dark',Boolean(e.data.dark));render()}catch(error){notify('预览未更新:'+error.message)}}); +render(); diff --git a/docs/settings-organizer/schema.js b/docs/settings-organizer/schema.js new file mode 100644 index 00000000..68fe4671 --- /dev/null +++ b/docs/settings-organizer/schema.js @@ -0,0 +1,29 @@ +/* Layout changes may not replace runtime component contracts. No external resources. */ +(function(root){ +'use strict'; +const clone=x=>JSON.parse(JSON.stringify(x)); +const stable=x=>JSON.stringify(normalize(x)); +function normalize(x){return Array.isArray(x)?x.map(normalize):x&&typeof x==='object'?Object.fromEntries(Object.keys(x).sort().map(k=>[k,normalize(x[k])])):x;} +const flatten=categories=>categories.flatMap(c=>c.groups.flatMap(g=>g.settings)); +function contract(setting){const copy=clone(setting);delete copy.title;delete copy.subtitle;return copy;} +function validate(candidate,baseline){ + if(!Array.isArray(candidate)||!candidate.length)throw Error('至少保留一个分类'); + if(candidate.length>100)throw Error('分类数量超过100'); + const routes=new Set(),seen=new Set(),original=new Map(flatten(baseline).map(s=>[s.pref_name,s])); + const text=(value,label)=>{if(typeof value!=='string'||!value.trim()||value.length>200)throw Error(label+'不能为空或超过200字');}; + candidate.forEach(c=>{text(c.name,'分类名称');text(c.route,'分类路由');if(routes.has(c.route))throw Error('分类路由重复:'+c.route);routes.add(c.route); + if(!['settings','about'].includes(c.layout||'settings'))throw Error('未知页面布局'); + if(c.icon!=null)throw Error('第一阶段不配置分类图标'); + if(!Array.isArray(c.groups)||c.groups.length>100)throw Error('分组格式不正确'); + c.groups.forEach(g=>{text(g.title,'分组标题');if(!Array.isArray(g.settings))throw Error('设置列表格式不正确'); + g.settings.forEach(s=>{text(s.title,'设置名称');if(s.subtitle!=null&&(typeof s.subtitle!=='string'||s.subtitle.length>1000))throw Error('说明格式不正确'); + const existing=original.get(s.pref_name);if(!existing)throw Error('未知设置:'+s.pref_name);if(seen.has(s.pref_name))throw Error('设置重复:'+s.pref_name);seen.add(s.pref_name); + if(stable(contract(s))!==stable(contract(existing)))throw Error('不能改变组件类型、存储键、默认值、选项或显隐条件:'+s.pref_name); + }); + }); + }); + if(seen.size!==original.size)throw Error('索引缺少设置:'+[...original.keys()].filter(k=>!seen.has(k)).join('、')); + return clone(candidate); +} +root.SettingsSchema={clone,flatten,validate}; +})(typeof window==='undefined'?globalThis:window); diff --git a/infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodec.kt b/infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodec.kt index d06eac5f..c998712c 100644 --- a/infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodec.kt +++ b/infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodec.kt @@ -12,7 +12,7 @@ class JsonSettingsIndexCodec : SettingsIndexCodec { SettingsCategory(category.requiredText("name"), category.requiredText("route"), category.optionalText("icon"), category.getJSONArray("groups").objects().map { group -> SettingsGroup(group.requiredText("title"), group.getJSONArray("settings").objects().map(::setting)) - }) + }, category.optionalText("layout") ?: "settings") } SettingsIndex(categories) SettingsIndexImport.Success(categories) @@ -24,12 +24,20 @@ class JsonSettingsIndexCodec : SettingsIndexCodec { private fun setting(json: JSONObject): Setting = Setting( json.requiredText("pref_name"), json.requiredText("type"), json.requiredText("title"), - json.optionalText("subtitle"), value(json.get("default")), + json.optionalText("subtitle"), typedValue(json.get("default"), json.optionalText("value_type")), if (json.has("options")) json.getJSONArray("options").objects().map { - SettingOption(it.requiredText("title"), value(it.get("value"))) + SettingOption(it.requiredText("title"), typedValue(it.get("value"), json.optionalText("value_type"))) } else emptyList(), + json.optionalText("storage") ?: "settings", json.optionalText("visible_when"), + if (json.has("inverted")) json.get("inverted").let { + it as? Boolean ?: throw IllegalArgumentException("inverted 必须为布尔值") + } else false, json.optionalText("value_type"), ) + private fun typedValue(raw: Any, type: String?): SettingValue = + if (type == "decimal" && raw is Number) SettingValue.Decimal(raw.toFloat().also { require(it.isFinite()) }) + else value(raw) + private fun value(raw: Any): SettingValue = when (raw) { is Boolean -> SettingValue.Flag(raw) is Int -> SettingValue.Number(raw) diff --git a/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt index 0096fcad..f901fb1f 100644 --- a/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt +++ b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt @@ -44,10 +44,32 @@ class JsonSettingsIndexCodecTest { assertEquals(0, index.search("不存在").size) } + @Test fun keepsComponentAndVisibilityMetadata() { + val raw = """{"pref_name":"font_scale","type":"font_scale","title":"全局字号","default":1.0}""" + val result = codec.decode(json(raw)) as SettingsIndexImport.Success + assertEquals("font_scale", result.categories.single().groups.single().settings.single().type) + val action = """{"pref_name":"logs","type":"action","storage":"none","visible_when":"debug","title":"日志抓取","default":""}""" + val entry = (codec.decode(json(action)) as SettingsIndexImport.Success).categories.single().groups.single().settings.single() + assertEquals("none", entry.storage) + assertEquals("debug", entry.visibleWhen) + } + + @Test fun numericFormattingDoesNotChangeComponentValueTypes() { + val raw = """{"pref_name":"speed","type":"dropdown","value_type":"decimal","title":"倍速","default":1,"options":[{"title":"1×","value":1},{"title":"1.5×","value":1.5}]}""" + val result = codec.decode(json(raw)) as SettingsIndexImport.Success + val setting = result.categories.single().groups.single().settings.single() + assertEquals(SettingValue.Decimal(1f), setting.default) + assertEquals(SettingValue.Decimal(1.5f), setting.options[1].value) + val file = java.io.File("../../app/src/main/assets/settings-index.json") + val original = codec.decode(file.readText()) as SettingsIndexImport.Success + val serialized = codec.decode(org.json.JSONArray(file.readText()).toString()) as SettingsIndexImport.Success + assertEquals(original.categories, serialized.categories) + } + @Test fun bundledIndexIsValid() { val file = java.io.File("../../app/src/main/assets/settings-index.json") val result = codec.decode(file.readText()) assertTrue(result.toString(), result is SettingsIndexImport.Success) - assertEquals(36, SettingsIndex((result as SettingsIndexImport.Success).categories).settings.size) + assertEquals(61, SettingsIndex((result as SettingsIndexImport.Success).categories).settings.size) } } diff --git a/tools/sync-settings-catalog.py b/tools/sync-settings-catalog.py new file mode 100644 index 00000000..4d25e8f5 --- /dev/null +++ b/tools/sync-settings-catalog.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""把实际 App 索引同步到离线编排器;不从原型反推运行时索引。""" +import json +from pathlib import Path + +root = Path(__file__).resolve().parents[1] +source = root / 'app/src/main/assets/settings-index.json' +target = root / 'docs/settings-organizer/catalog.js' +data = json.loads(source.read_text()) +target.write_text('// Generated by tools/sync-settings-catalog.py; source: app assets.\n' + + 'window.SETTINGS_CATALOG = ' + json.dumps(data, ensure_ascii=False, indent=2) + ';\n') diff --git a/tools/test-settings-organizer.cjs b/tools/test-settings-organizer.cjs new file mode 100644 index 00000000..5dc1e0b9 --- /dev/null +++ b/tools/test-settings-organizer.cjs @@ -0,0 +1,44 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const assert = require('node:assert/strict'); +const root = path.resolve(__dirname, '..'); +const context = vm.createContext({}); +for (const file of ['catalog.js', 'schema.js']) { + let source = fs.readFileSync(path.join(root, 'docs/settings-organizer', file), 'utf8'); + if (file === 'catalog.js') source = source.replace('window.SETTINGS_CATALOG', 'globalThis.SETTINGS_CATALOG'); + vm.runInContext(source, context); +} +const { clone, validate, flatten } = context.SettingsSchema; +const baseline = context.SETTINGS_CATALOG; +const app = JSON.parse(fs.readFileSync(path.join(root, 'app/src/main/assets/settings-index.json'), 'utf8')); +assert.equal(JSON.stringify(baseline), JSON.stringify(app), '编排器必须使用实际 App 索引'); +const changed = clone(baseline); +const entry = changed[0].groups[0].settings.shift(); +changed[1].groups[0].settings.push(entry); +changed[1].name = '自定义播放分类'; +entry.title = '自定义标题'; +validate(changed, baseline); +for (const mutate of [ + value => value[0].groups[0].settings.pop(), + value => value[0].groups[0].settings.push(clone(value[0].groups[0].settings[0])), + value => value[0].groups[0].settings[0].type = 'action', + value => value[0].groups[0].settings[0].default = 999, + value => value[0].groups[0].settings[0].visible_when = 'debug', + value => value[0].route = value[1].route, + value => value[0].icon = 'custom-icon', +]) { + const invalid = clone(baseline); + mutate(invalid); + assert.throws(() => validate(invalid, baseline)); +} +const reordered = clone(baseline); +const original = reordered[0].groups[0].settings[0]; +reordered[0].groups[0].settings[0] = Object.fromEntries(Object.entries(original).reverse()); +validate(reordered, baseline); +const snapshot = JSON.stringify(baseline); +const missing = clone(baseline); +missing[0].groups[0].settings.pop(); +assert.throws(() => validate(missing, baseline)); +assert.equal(JSON.stringify(baseline), snapshot, '失败校验不能更改输入基准'); +console.log(`PASS: ${flatten(baseline).length} components, full catalog sync, reorganization, contract guards, property order and failed import isolation`); From 20678a939ae3afd20542ff7bd9b8c7bfc5ff96e4 Mon Sep 17 00:00:00 2001 From: AritxOnly Date: Sat, 14 Nov 2026 18:16:22 +0800 Subject: [PATCH 3/8] fix(settings): compact search dock and preserve speed selection tolerance --- .../settings/application/SettingsBrowserStateHolder.kt | 7 +++++++ .../settings/page/IndexedSettingsComponents.kt | 3 ++- .../presentation/settings/page/SettingsBrowserChrome.kt | 8 ++++---- .../application/SettingsBrowserStateHolderTest.kt | 8 ++++++++ docs/SETTINGS_INDEX_DESIGN.md | 2 +- docs/settings-organizer/preview.css | 2 +- 6 files changed, 23 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt index dcfd415c..85a10b9d 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt @@ -55,3 +55,10 @@ internal fun visibleSettingsIndex(index: SettingsIndex, values: Map, query: String, route: String?): List = if (categories.isEmpty()) emptyList() else SettingsIndex(categories).search(query, route) + +/** 旧倍速dropdown按0.01容差定位,不能因JSON接入改成浮点完全相等。 */ +internal fun indexedOptionIndex(setting: Setting, value: SettingValue): Int = setting.options.indexOfFirst { option -> + val candidate = option.value + if (candidate is SettingValue.Decimal && value is SettingValue.Decimal) kotlin.math.abs(candidate.value - value.value) < .01f + else candidate == value +}.coerceAtLeast(0) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsComponents.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsComponents.kt index 65277b03..f3976c60 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsComponents.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsComponents.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.unit.dp import com.aritxonly.bdfun.core.model.settings.* import com.aritxonly.bdfun.feature.settings.application.IndexedSettingsStateHolder import com.aritxonly.bdfun.feature.settings.application.SettingUpdate +import com.aritxonly.bdfun.feature.settings.application.indexedOptionIndex import com.aritxonly.teak.common.settings.* import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -43,7 +44,7 @@ private fun IndexedDropdown(entry: Setting, value: SettingValue, runtime: Settin } else entry.subtitle.orEmpty() TeakSettingsDropdownRow(title = entry.title, description = description, options = entry.options.map { it.title }, - selected = ui.pendingDark ?: entry.options.indexOfFirst { it.value == value }.coerceAtLeast(0), + selected = ui.pendingDark ?: indexedOptionIndex(entry, value), onSelected = { index -> if (entry.prefName in setOf("dark_mode", "color_style")) { ui.pendingDark = index diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt index 8f8796c1..56878641 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt @@ -46,11 +46,11 @@ internal fun BoxScope.SettingsSearchDock(session: SettingsBrowserSession) { val start = if (session.wide && searching) 280.dp else 0.dp val width = if (session.wide && !searching) Modifier.width(280.dp) else Modifier.fillMaxWidth() Row(Modifier.align(Alignment.BottomStart).then(width).padding(start = start) - .imePadding().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 16.dp).animateContentSize(), + .imePadding().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp).animateContentSize(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { - TeakGlassSurface(modifier = Modifier.weight(1f).heightIn(min = 54.dp) + TeakGlassSurface(modifier = Modifier.weight(1f).heightIn(min = 48.dp) .then(if (!searching) Modifier.clickable(role = Role.Button) { session.browser.search() } else Modifier)) { - Row(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 14.dp), + Row(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { Icon(MiuixIcons.Search, null, tint = MiuixTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp)) if (searching) SettingsSearchInput(session) @@ -58,7 +58,7 @@ internal fun BoxScope.SettingsSearchDock(session: SettingsBrowserSession) { } } AnimatedVisibility(searching, enter = fadeIn() + expandHorizontally(), exit = fadeOut() + shrinkHorizontally()) { - TeakGlassIconButton(onClick = session.browser::closeSearch, size = 54.dp) { + TeakGlassIconButton(onClick = session.browser::closeSearch, size = 48.dp) { Icon(MiuixIcons.Close, "关闭搜索", tint = MiuixTheme.colorScheme.onSurface, modifier = Modifier.size(22.dp)) } } diff --git a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt index 11ea14e7..431ce44f 100644 --- a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt +++ b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt @@ -56,6 +56,14 @@ class SettingsBrowserStateHolderTest { assertTrue(settingsVisible(entry, emptyMap(), true, true, setOf("recommendation_toggle"))) } + @Test fun speedDropdownRetainsOriginalToleranceAndFallback() { + val entry = Setting("speed", "dropdown", "倍速", null, SettingValue.Decimal(1f), + listOf(.5f, .75f, 1f, 1.25f).map { SettingOption("$it", SettingValue.Decimal(it)) }) + assertEquals(2, indexedOptionIndex(entry, SettingValue.Decimal(1.001f))) + assertEquals(0, indexedOptionIndex(entry, SettingValue.Decimal(1.1f))) + assertEquals(3, indexedOptionIndex(entry, SettingValue.Decimal(1.25f))) + } + @Test fun restoredNavigationDoesNotNeedASecondLayoutState() { val saved = SettingsBrowserState("settings/player", true, "settings/player", "蓝牙") assertEquals(saved, SettingsBrowserStateHolder(saved).state.value) diff --git a/docs/SETTINGS_INDEX_DESIGN.md b/docs/SETTINGS_INDEX_DESIGN.md index 60c08005..d793f04c 100644 --- a/docs/SETTINGS_INDEX_DESIGN.md +++ b/docs/SETTINGS_INDEX_DESIGN.md @@ -4,7 +4,7 @@ App 的唯一布局来源是 `app/src/main/assets/settings-index.json`。本轮以原生设置页完整清单重新建立索引,替换原型的36项示例;61个组件描述覆盖原全部设置、操作、开发者行,另含关于身份区与PR52的三个可选推荐组件。page_switches仍展开原来的六个页面开关。没有旧设置页兜底入口,原四个手写卡片文件已删除。 -竖屏为分类列表与二级页;横屏且宽度至少600dp使用左侧分类、右侧内容。导航和搜索由页面作用域SettingsBrowserStateHolder持有,旋转不另建选择状态。返回先关闭搜索再回分类。搜索匹配名称/说明/分组/分类/键,排除当前不满足条件的设置,结果跳转并滚动定位。页面外壳、返回键、TopAppBar和大标题/滚动/材质联动继续使用原来的TeakActivityScaffold与TeakLargeTitle/TrackTeakPageScroll,不另画顶栏;二级页只在原工具栏增加搜索按钮。一级底部柔光胶囊展开、分裂关闭按钮并跟随IME抬升;动画复用主页搜索参数。分类内从右上搜索。 +竖屏为分类列表与二级页;横屏且宽度至少600dp使用左侧分类、右侧内容。导航和搜索由页面作用域SettingsBrowserStateHolder持有,旋转不另建选择状态。返回先关闭搜索再回分类。搜索匹配名称/说明/分组/分类/键,排除当前不满足条件的设置,结果跳转并滚动定位。页面外壳、返回键、TopAppBar和大标题/滚动/材质联动继续使用原来的TeakActivityScaffold与TeakLargeTitle/TrackTeakPageScroll,不另画顶栏;二级页只在原工具栏增加搜索按钮。一级底部柔光胶囊展开、分裂关闭按钮并跟随IME抬升;搜索栏/关闭按钮高度48dp,底部间距8dp,保留系统导航/IME避让;动画复用主页搜索参数。分类内从右上搜索。 普通设置仍使用现有Teak/Miuix行:dropdown仍是玻璃dropdown(包括原面板宽度、开闭行为),字号仍用原SliderPreference和吸附/松手写入;主题/色彩的260ms延迟、动画反向开关、CDN动态选项、壁纸系统选择、开发者头像、退出登录的颜色与登录/debug显隐保持。第一阶段不展示分类图标;返回/搜索/关闭仅使用Miuix Icons。 diff --git a/docs/settings-organizer/preview.css b/docs/settings-organizer/preview.css index 341e94cf..72cbf0da 100644 --- a/docs/settings-organizer/preview.css +++ b/docs/settings-organizer/preview.css @@ -1,4 +1,4 @@ -:root{font-family:system-ui,-apple-system,"PingFang SC",sans-serif;--page:#f3f3f3;--card:#fff;--fg:#191919;--secondary:#8b8b92;--blue:#3482ff;--glass:#ffffffc7;color:var(--fg);background:var(--page);font-size:15px}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit;color:inherit}button{cursor:pointer;border:0;background:transparent}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #8fb8ff;outline-offset:2px}#standalone{padding:16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;background:var(--card)}#standalone button,#standalone a{padding:8px 13px;background:var(--page);border-radius:20px;font-size:13px;text-decoration:none;color:inherit}.context{height:30px;display:flex;align-items:center;justify-content:center;gap:12px;font-size:10px;color:var(--secondary);background:var(--card)}.context input{vertical-align:middle;width:12px;height:12px}#app{position:relative;display:flex;height:calc(100dvh - 100px);min-height:500px;overflow:hidden;background:var(--page)}body.embedded #app{height:calc(100dvh - 30px);min-height:0}body.embedded #standalone{display:none}aside{width:270px;flex-shrink:0;padding:0 16px 100px;overflow:auto;border-right:1px solid #8881}section{position:relative;flex:1;min-width:0;overflow:auto;padding:0 16px 100px}h1{font-size:26px;margin:0;font-weight:650;letter-spacing:-.6px}h3{font-size:11.5px;color:var(--secondary);font-weight:400;margin:19px 14px 5px}.header{position:sticky;top:0;z-index:2;min-height:80px;display:flex;align-items:center;gap:12px;background:linear-gradient(var(--page) 50%,transparent);backdrop-filter:blur(5px);padding:10px 0 18px}.header h1{flex:1}.header button{font-size:12px;flex-shrink:0;padding:12px;background:var(--glass);border-radius:30px}.card{background:var(--card);border-radius:22px;overflow:hidden;margin-bottom:8px}.row{display:flex;align-items:center;gap:14px;width:100%;text-align:left;min-height:75px;padding:18px 20px;position:relative}.row+.row:before{content:"";position:absolute;left:20px;right:20px;top:0;height:1px;background:#8881}.row.selected{background:#3482ff12}.label{flex:1;min-width:0}.label strong{display:block;font-size:15px;font-weight:500;line-height:1.5}.label small{display:block;color:var(--secondary);font-size:12px;line-height:1.6;margin-top:5px}.category-row{min-height:67px}.switch{width:49px;height:28px;padding:3px;border-radius:20px;flex-shrink:0;background:#d8d8dd;transition:background .2s}.switch:after{content:"";display:block;width:22px;height:22px;background:#fff;border-radius:50%;box-shadow:0 1px 2px #0001;transition:transform .2s}.switch[aria-checked=true]{background:var(--blue)}.switch[aria-checked=true]:after{transform:translateX(21px)}select{border:0;background:transparent;color:var(--secondary);max-width:44%;font-size:12px;text-align:right}.trailing{font-size:12px;color:var(--secondary)}.chevron{color:#a1a1a8;font-size:18px}.avatar{width:38px;height:38px;object-fit:cover;border-radius:50%}.slider-row{display:block}.slider-line{display:flex;align-items:center;gap:15px;margin-top:15px}.slider-line input{flex:1;min-width:0;accent-color:var(--blue)}.slider-line span{font-size:12px;color:var(--secondary)}.about{background:var(--page)}.about:before{content:"";position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse at 18% 22%,#fa7ca63a,transparent 70%),radial-gradient(ellipse at 86% 66%,#8c9ef52e,transparent 75%),radial-gradient(ellipse at 52% 96%,#f394bd29,transparent 65%);animation:float 7.5s linear infinite alternate;opacity:var(--glow-alpha,1)}.about .card,.about h3,.identity{position:relative}.identity{min-height:252px;padding:28px 0;display:flex;flex-direction:column;align-items:center;gap:8px}.identity img{width:96px;height:96px;object-fit:contain;border-radius:24px}.identity strong{font-size:30px;font-weight:700;margin-top:2px}.identity small{font-size:14px;color:var(--secondary);padding:4px 12px}.glass{background:var(--glass);backdrop-filter:blur(30px) saturate(1.4);box-shadow:inset 0 1px 1px #fff8,0 6px 24px #29384b12;border:1px solid #ffffff44}#dock{position:absolute;bottom:18px;left:16px;width:238px;display:flex;gap:10px;transition:bottom .38s,width .38s,left .38s;z-index:6}.input{min-width:0;min-height:54px;display:flex;align-items:center;gap:10px;border-radius:30px;padding:15px 18px;flex:1}.input span{font-size:12px;color:var(--secondary)}.input input{background:none;outline:0;border:0;width:100%;min-width:0;font-size:14px}#close{width:54px;height:54px;border-radius:50%;font-size:12px;display:none;flex-shrink:0}#results{display:none;position:absolute;top:0;bottom:0;left:270px;right:0;background:var(--page);overflow:auto;padding:0 16px 100px;z-index:4}#results .result{margin:10px 0;border-radius:18px;background:var(--card)}#results .hint{font-size:12px;color:var(--secondary);padding:25px 14px;line-height:1.8}#app.searching #results{display:block}#app.searching #close{display:block}#app.searching #dock{left:286px;width:calc(100% - 302px)}#content,aside{transition:opacity .15s,transform .38s}#app.searching #content,#app.searching.global aside{opacity:0;transform:translateY(-40px);pointer-events:none}#app.phone aside{width:100%;border:0}#app.phone section{display:none}#app.phone.detail aside{display:none}#app.phone.detail section{display:block}#app.phone #dock{left:16px;width:calc(100% - 32px)}#app.phone.detail:not(.searching) #dock{display:none}#app.phone #results{left:0}#app.phone.searching.ime #dock{bottom:288px}#keyboard{position:absolute;bottom:0;left:0;right:0;height:270px;background:#d5d7df;display:none;align-items:center;justify-content:center;color:#727581;font-size:12px;z-index:5}#app.phone.searching.ime #keyboard{display:flex}#app.phone.searching.ime #results{bottom:270px}#toast{position:absolute;bottom:85px;left:50%;transform:translateX(-50%);background:#252936;color:#fff;border-radius:20px;font-size:12px;padding:10px 18px;display:none;z-index:8}.spotlight{animation:spot 1.8s}@keyframes spot{20%,65%{background:#3482ff20}}@keyframes float{to{transform:translate(3%,2%)}}body.dark{--page:#000;--card:#242424;--fg:#f1f1f1;--secondary:#a0a0a8;--glass:#242424cb}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +:root{font-family:system-ui,-apple-system,"PingFang SC",sans-serif;--page:#f3f3f3;--card:#fff;--fg:#191919;--secondary:#8b8b92;--blue:#3482ff;--glass:#ffffffc7;color:var(--fg);background:var(--page);font-size:15px}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit;color:inherit}button{cursor:pointer;border:0;background:transparent}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #8fb8ff;outline-offset:2px}#standalone{padding:16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;background:var(--card)}#standalone button,#standalone a{padding:8px 13px;background:var(--page);border-radius:20px;font-size:13px;text-decoration:none;color:inherit}.context{height:30px;display:flex;align-items:center;justify-content:center;gap:12px;font-size:10px;color:var(--secondary);background:var(--card)}.context input{vertical-align:middle;width:12px;height:12px}#app{position:relative;display:flex;height:calc(100dvh - 100px);min-height:500px;overflow:hidden;background:var(--page)}body.embedded #app{height:calc(100dvh - 30px);min-height:0}body.embedded #standalone{display:none}aside{width:270px;flex-shrink:0;padding:0 16px 100px;overflow:auto;border-right:1px solid #8881}section{position:relative;flex:1;min-width:0;overflow:auto;padding:0 16px 100px}h1{font-size:26px;margin:0;font-weight:650;letter-spacing:-.6px}h3{font-size:11.5px;color:var(--secondary);font-weight:400;margin:19px 14px 5px}.header{position:sticky;top:0;z-index:2;min-height:80px;display:flex;align-items:center;gap:12px;background:linear-gradient(var(--page) 50%,transparent);backdrop-filter:blur(5px);padding:10px 0 18px}.header h1{flex:1}.header button{font-size:12px;flex-shrink:0;padding:12px;background:var(--glass);border-radius:30px}.card{background:var(--card);border-radius:22px;overflow:hidden;margin-bottom:8px}.row{display:flex;align-items:center;gap:14px;width:100%;text-align:left;min-height:75px;padding:18px 20px;position:relative}.row+.row:before{content:"";position:absolute;left:20px;right:20px;top:0;height:1px;background:#8881}.row.selected{background:#3482ff12}.label{flex:1;min-width:0}.label strong{display:block;font-size:15px;font-weight:500;line-height:1.5}.label small{display:block;color:var(--secondary);font-size:12px;line-height:1.6;margin-top:5px}.category-row{min-height:67px}.switch{width:49px;height:28px;padding:3px;border-radius:20px;flex-shrink:0;background:#d8d8dd;transition:background .2s}.switch:after{content:"";display:block;width:22px;height:22px;background:#fff;border-radius:50%;box-shadow:0 1px 2px #0001;transition:transform .2s}.switch[aria-checked=true]{background:var(--blue)}.switch[aria-checked=true]:after{transform:translateX(21px)}select{border:0;background:transparent;color:var(--secondary);max-width:44%;font-size:12px;text-align:right}.trailing{font-size:12px;color:var(--secondary)}.chevron{color:#a1a1a8;font-size:18px}.avatar{width:38px;height:38px;object-fit:cover;border-radius:50%}.slider-row{display:block}.slider-line{display:flex;align-items:center;gap:15px;margin-top:15px}.slider-line input{flex:1;min-width:0;accent-color:var(--blue)}.slider-line span{font-size:12px;color:var(--secondary)}.about{background:var(--page)}.about:before{content:"";position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse at 18% 22%,#fa7ca63a,transparent 70%),radial-gradient(ellipse at 86% 66%,#8c9ef52e,transparent 75%),radial-gradient(ellipse at 52% 96%,#f394bd29,transparent 65%);animation:float 7.5s linear infinite alternate;opacity:var(--glow-alpha,1)}.about .card,.about h3,.identity{position:relative}.identity{min-height:252px;padding:28px 0;display:flex;flex-direction:column;align-items:center;gap:8px}.identity img{width:96px;height:96px;object-fit:contain;border-radius:24px}.identity strong{font-size:30px;font-weight:700;margin-top:2px}.identity small{font-size:14px;color:var(--secondary);padding:4px 12px}.glass{background:var(--glass);backdrop-filter:blur(30px) saturate(1.4);box-shadow:inset 0 1px 1px #fff8,0 6px 24px #29384b12;border:1px solid #ffffff44}#dock{position:absolute;bottom:8px;left:16px;width:238px;display:flex;gap:10px;transition:bottom .38s,width .38s,left .38s;z-index:6}.input{min-width:0;min-height:48px;display:flex;align-items:center;gap:10px;border-radius:30px;padding:12px 18px;flex:1}.input span{font-size:12px;color:var(--secondary)}.input input{background:none;outline:0;border:0;width:100%;min-width:0;font-size:14px}#close{width:48px;height:48px;border-radius:50%;font-size:12px;display:none;flex-shrink:0}#results{display:none;position:absolute;top:0;bottom:0;left:270px;right:0;background:var(--page);overflow:auto;padding:0 16px 100px;z-index:4}#results .result{margin:10px 0;border-radius:18px;background:var(--card)}#results .hint{font-size:12px;color:var(--secondary);padding:25px 14px;line-height:1.8}#app.searching #results{display:block}#app.searching #close{display:block}#app.searching #dock{left:286px;width:calc(100% - 302px)}#content,aside{transition:opacity .15s,transform .38s}#app.searching #content,#app.searching.global aside{opacity:0;transform:translateY(-40px);pointer-events:none}#app.phone aside{width:100%;border:0}#app.phone section{display:none}#app.phone.detail aside{display:none}#app.phone.detail section{display:block}#app.phone #dock{left:16px;width:calc(100% - 32px)}#app.phone.detail:not(.searching) #dock{display:none}#app.phone #results{left:0}#app.phone.searching.ime #dock{bottom:278px}#keyboard{position:absolute;bottom:0;left:0;right:0;height:270px;background:#d5d7df;display:none;align-items:center;justify-content:center;color:#727581;font-size:12px;z-index:5}#app.phone.searching.ime #keyboard{display:flex}#app.phone.searching.ime #results{bottom:270px}#toast{position:absolute;bottom:85px;left:50%;transform:translateX(-50%);background:#252936;color:#fff;border-radius:20px;font-size:12px;padding:10px 18px;display:none;z-index:8}.spotlight{animation:spot 1.8s}@keyframes spot{20%,65%{background:#3482ff20}}@keyframes float{to{transform:translate(3%,2%)}}body.dark{--page:#000;--card:#242424;--fg:#f1f1f1;--secondary:#a0a0a8;--glass:#242424cb}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} /* 预览沿用原页面的独立返回工具栏与滚动大标题,不改成并排大标题栏。 */ .chrome{position:sticky;top:0;z-index:2;display:flex;align-items:center;justify-content:space-between;min-height:56px;background:linear-gradient(var(--page) 45%,transparent);gap:10px} From b542ec2ee10c9fd87cc10f4047872a89b493422c Mon Sep 17 00:00:00 2001 From: AritxOnly Date: Sat, 14 Nov 2026 18:19:33 +0800 Subject: [PATCH 4/8] fix(settings): retain scroll position after search highlight expires --- .../presentation/settings/page/SettingsBrowserLists.kt | 3 +-- docs/settings-organizer/README.md | 9 +++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 docs/settings-organizer/README.md diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt index e5826fce..93e9c47a 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt @@ -54,10 +54,9 @@ internal fun SettingsCategoryContent(session: SettingsBrowserSession, values: Ma val list = key(category.route) { rememberLazyListState() } if (category.layout != "about" && !session.navigation.searching) TrackTeakPageScroll(list) LaunchedEffect(category.route, session.navigation.highlight) { - val key = session.navigation.highlight + val key = session.navigation.highlight ?: return@LaunchedEffect val group = category.groups.indexOfFirst { g -> g.settings.any { it.prefName == key } } if (group >= 0) list.animateScrollToItem(group + 1) - else list.scrollToItem(0) } if (category.layout == "about") { SettingsAboutContent(session, values, runtime, controller, modifier) diff --git a/docs/settings-organizer/README.md b/docs/settings-organizer/README.md new file mode 100644 index 00000000..12318583 --- /dev/null +++ b/docs/settings-organizer/README.md @@ -0,0 +1,9 @@ +# bdFun 设置编排器 + +直接用浏览器打开 `index.html`,也可以在此目录运行 `python3 -m http.server 8793` 后访问本机页面。所有脚本、预览和素材都在目录内,不需要联网。 + +左栏选择或拖动分类、分组与设置;中栏编辑名称、说明和所属分组;右栏实时预览。也可使用上移/下移按钮完成排序。支持本地草稿、撤销/重做、完整索引导入导出。 + +预览仅用于检查组织结构,不会修改手机偏好或执行退出登录、打开外链等真实动作。手机界面使用现有 Miuix/Teak 组件:select 仍为 dropdown,字号仍为滑条,返回键和 TopAppBar 沿用原壳。 + +导出的 `settings-index.json` 替换仓库 `app/src/main/assets/settings-index.json` 后重新构建生效。再运行 `python3 tools/sync-settings-catalog.py` 同步此工具的目录数据。请保留完整组件清单和原组件/存储/默认值/选项/显隐合同;第一阶段不配置分类图标。推荐扩展在合入 PR52 的构建中生效。 From 0b5ffd1e1bf65b7a6d2cee855c62d4632fb62ee3 Mon Sep 17 00:00:00 2001 From: AritxOnly Date: Sat, 14 Nov 2026 18:22:27 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(settings):=20name=20about=20category=20?= =?UTF-8?q?=E5=85=B3=E4=BA=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/assets/settings-index.json | 2 +- docs/settings-organizer/catalog.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/assets/settings-index.json b/app/src/main/assets/settings-index.json index ca22bf3e..9bd68b35 100644 --- a/app/src/main/assets/settings-index.json +++ b/app/src/main/assets/settings-index.json @@ -646,7 +646,7 @@ ] }, { - "name": "关于 App", + "name": "关于", "route": "settings/about", "groups": [ { diff --git a/docs/settings-organizer/catalog.js b/docs/settings-organizer/catalog.js index eaaed973..9eda5f01 100644 --- a/docs/settings-organizer/catalog.js +++ b/docs/settings-organizer/catalog.js @@ -647,7 +647,7 @@ window.SETTINGS_CATALOG = [ ] }, { - "name": "关于 App", + "name": "关于", "route": "settings/about", "groups": [ { From 207d5c86a59708640da822fb338b4e53f55752a0 Mon Sep 17 00:00:00 2001 From: AritxOnly Date: Sat, 14 Nov 2026 19:05:07 +0800 Subject: [PATCH 6/8] feat(settings): use full-width embedded panes and root logout footer (SET-03) --- AGENTS.md | 4 + app/src/main/assets/settings-index.json | 3 +- .../application/SettingsBrowserStateHolder.kt | 9 +- .../application/SettingsSplitPolicy.kt | 23 ++++ .../settings/page/SettingsBrowser.kt | 67 ++--------- .../settings/page/SettingsBrowserChrome.kt | 4 +- .../settings/page/SettingsBrowserLists.kt | 18 ++- .../settings/page/SettingsBrowserPane.kt | 111 ++++++++++++++++++ .../settings/page/SettingsScreen.kt | 15 +-- .../settings/page/SettingsSplitLayout.kt | 70 +++++++++++ .../SettingsBrowserStateHolderTest.kt | 21 +++- .../application/SettingsSplitPolicyTest.kt | 34 ++++++ .../bdfun/core/domain/SettingsIndex.kt | 4 +- docs/SETTINGS_INDEX_DESIGN.md | 6 +- docs/settings-organizer/README.md | 2 +- docs/settings-organizer/catalog.js | 3 +- docs/settings-organizer/organizer.js | 2 +- docs/settings-organizer/preview.css | 14 +++ docs/settings-organizer/preview.html | 2 +- docs/settings-organizer/preview.js | 17 ++- docs/settings-organizer/schema.js | 4 +- .../storage/JsonSettingsIndexCodecTest.kt | 7 ++ tools/test-settings-organizer.cjs | 1 + 23 files changed, 345 insertions(+), 96 deletions(-) create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicy.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsSplitLayout.kt create mode 100644 app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicyTest.kt diff --git a/AGENTS.md b/AGENTS.md index e52dccac..08453edd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -591,3 +591,7 @@ HyperOS 中性色以 Deadliner 的 HyperOS 适配为准:surface 为页面层 设置页已改为 presentation/settings/page 的索引渲染器:一级分类、横屏双栏与页面作用域搜索。全量现有设置使用61个组件描述(含关于身份区及PR52的3个可选扩展);不再沿用原型36项索引或旧卡片兜底。switch/dropdown/font_scale/cdn_dropdown/page_switches/mine_switch/action/developer/version保持原组件、条件与存储边界;select不得改成三级页面。第一阶段不显示分类图标,功能按钮只用Miuix Icons。编排器为 docs/settings-organizer/index.html,导出必须完整保留组件键/type/default/options/storage/visible_when/inverted/value_type,允许调整分类、分组、顺序与文案;App资产是唯一来源,运行 tools/sync-settings-catalog.py 同步。浮点组件须保留value_type=decimal,防止JSON导出1.0变1后改变类型。关于页参考Deadliner 3b561656的AboutSettings.kt,来源与MIT许可见docs/SETTINGS_DEADLINER_PROVENANCE.md;光晕只在Canvas读取动画State,卡片仅背景随320dp滚动由0.5变1。推荐扩展由启动层注入,未合入PR52时隐藏,不建立跨功能实现依赖。 SET-02外壳约束:返回键、TopAppBar、大标题与滚动联动必须复用原TeakActivityScaffold/TeakLargeTitle/TrackTeakPageScroll,不画新的设置顶栏,不关掉原顶栏后自己仿制。二级页搜索仅加入原工具栏actions槽。 + +### 2026-11-14 SET-03 独立分栏与一级退出登录 + +横屏设置必须覆盖窗口全宽,左右分别使用原TeakPageScaffold(竖屏仍由TeakActivityScaffold装配),不在一个受内容最大宽度约束的外壳内摆两列。每栏独立PageChrome/滚动/搜索/popup/backdrop,并按栏尺寸提供窗口上下文。默认分配3:7,左栏下限360dp;拖动分隔线改变比例,右栏取剩余空间。竖屏继续单壳。退出登录是layout=root_footer中的原动作,登录后在一级列表最底部独立卡片呈现,不生成账号二级页。 diff --git a/app/src/main/assets/settings-index.json b/app/src/main/assets/settings-index.json index 9bd68b35..56bec4ca 100644 --- a/app/src/main/assets/settings-index.json +++ b/app/src/main/assets/settings-index.json @@ -782,6 +782,7 @@ } ] } - ] + ], + "layout": "root_footer" } ] diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt index 85a10b9d..519da943 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt @@ -12,6 +12,7 @@ internal data class SettingsBrowserState( val searchRoute: String? = null, val query: String = "", val highlight: String? = null, + val splitFraction: Float = SettingsSplitPolicy.DefaultFraction, ) /** 页面拥有自己的搜索和导航;旋转只改变布局,不另建一份选择状态。 */ @@ -20,17 +21,21 @@ internal class SettingsBrowserStateHolder(initial: SettingsBrowserState = Settin val state = mutable.asStateFlow() fun open(route: String, prefName: String? = null) { - mutable.value = SettingsBrowserState(route = route, highlight = prefName) + mutable.update { it.copy(route = route, searching = false, searchRoute = null, query = "", highlight = prefName) } } fun search(route: String? = null) { mutable.update { it.copy(searching = true, searchRoute = route, query = "", highlight = null) } } + fun resize(totalWidth: Float, deltaDp: Float) { + mutable.update { it.copy(splitFraction = SettingsSplitPolicy.drag(totalWidth, it.splitFraction, deltaDp)) } + } + fun split(fraction: Float) { if (fraction.isFinite()) mutable.update { it.copy(splitFraction = fraction.coerceIn(0f, 1f)) } } fun query(value: String) { mutable.update { it.copy(query = value) } } fun closeSearch() { mutable.update { it.copy(searching = false, query = "", searchRoute = null) } } fun clearHighlight(key: String) { mutable.update { if (it.highlight == key) it.copy(highlight = null) else it } } fun back(): Boolean = when { state.value.searching -> { closeSearch(); true } - state.value.route != null -> { mutable.value = SettingsBrowserState(); true } + state.value.route != null -> { mutable.update { SettingsBrowserState(splitFraction = it.splitFraction) }; true } else -> false } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicy.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicy.kt new file mode 100644 index 00000000..e693de9d --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicy.kt @@ -0,0 +1,23 @@ +package com.aritxonly.bdfun.feature.settings.application + +/** 分隔条不计入3:7内容比例;优先保障左栏360dp,右栏始终使用剩余宽度。 */ +internal object SettingsSplitPolicy { + const val DefaultFraction = .3f + const val MinimumLeftDp = 360f + const val DividerDp = 12f + + fun leftWidth(totalWidth: Float, fraction: Float = DefaultFraction): Float { + val available = (totalWidth - DividerDp).coerceAtLeast(0f) + val minimum = MinimumLeftDp.coerceAtMost(available) + val maximum = (available - 1f).coerceAtLeast(minimum) + val safeFraction = if (fraction.isFinite()) fraction.coerceIn(0f, 1f) else DefaultFraction + return (available * safeFraction).coerceIn(minimum, maximum) + } + + fun drag(totalWidth: Float, fraction: Float, deltaDp: Float): Float { + val available = totalWidth - DividerDp + if (available <= 0f || !deltaDp.isFinite()) return fraction + val target = leftWidth(totalWidth, fraction) + deltaDp + return leftWidth(totalWidth, target / available) / available + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt index 1144f516..269d8efb 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt @@ -2,86 +2,37 @@ package com.aritxonly.bdfun.ui import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.unit.dp import com.aritxonly.bdfun.BuildConfig import com.aritxonly.bdfun.feature.settings.application.* -import com.aritxonly.teak.common.interaction.blockTouches -import com.aritxonly.teak.common.material.LocalTeakBackdrop -import com.aritxonly.teak.common.search.TeakSearchTransitionDefaults -import com.aritxonly.teak.common.search.teakSearchUnderlay -import top.yukonga.miuix.kmp.basic.Scaffold -import top.yukonga.miuix.kmp.blur.layerBackdrop -import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop -import top.yukonga.miuix.kmp.theme.MiuixTheme @Composable internal fun SettingsBrowser(controller: IndexedSettingsStateHolder, runtime: SettingsPageRuntime, loggedIn: Boolean, browser: SettingsBrowserStateHolder) { val navigation by browser.state.collectAsState() val settings by controller.state.collectAsState() - val categories = visibleSettingsIndex(controller.index, settings.values, loggedIn, BuildConfig.DEBUG, runtime.extensions.keys) + val visible = visibleSettingsIndex(controller.index, settings.values, loggedIn, BuildConfig.DEBUG, runtime.extensions.keys) + val categories = visible.filter { it.layout != "root_footer" } + val footers = visible.filter { it.layout == "root_footer" } val configuration = LocalConfiguration.current val wide = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE && configuration.screenWidthDp >= 600 val selected = categories.firstOrNull { it.route == navigation.route } ?: categories.firstOrNull().takeIf { wide } val focus = LocalFocusManager.current val keyboard = LocalSoftwareKeyboardController.current BackHandler { if (!browser.back()) runtime.navigation.onBack() } - LaunchedEffect(navigation.searching) { - if (!navigation.searching) { focus.clearFocus(); keyboard?.hide() } - } + LaunchedEffect(navigation.searching) { if (!navigation.searching) { focus.clearFocus(); keyboard?.hide() } } LaunchedEffect(navigation.highlight) { navigation.highlight?.let { kotlinx.coroutines.delay(1800); browser.clearHighlight(it) } } - val background = MiuixTheme.colorScheme.surface - val backdrop = rememberLayerBackdrop { drawRect(background); drawContent() } - val session = SettingsBrowserSession(browser, navigation, selected, wide, categories) - CompositionLocalProvider(LocalTeakBackdrop provides backdrop) { - Scaffold(containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0, 0, 0, 0)) { - Box(Modifier.fillMaxSize().background(background)) { - Box(Modifier.fillMaxSize().layerBackdrop(backdrop)) { - Row(Modifier.fillMaxSize()) { - if (wide || selected == null) { - val hideCategories = navigation.searching && (!wide || navigation.searchRoute == null) - SettingsCategoryList(session, Modifier.then(if (wide) Modifier.width(280.dp) else Modifier.fillMaxSize()) - .settingsUnderlay(hideCategories, runtime.settings.animationsEnabled)) - } - if (selected != null) { - SettingsCategoryContent(session, settings.values, runtime, controller, - Modifier.weight(1f).fillMaxHeight().settingsUnderlay(navigation.searching, runtime.settings.animationsEnabled)) - } - } - if (navigation.searching) { - val hits = searchSettingsCategories(categories, navigation.query, navigation.searchRoute) - SettingsSearchResults(session, hits, Modifier.fillMaxSize() - .padding(start = if (wide) 280.dp else 0.dp)) - } - } - if (selected == null || wide || navigation.searching) SettingsSearchDock(session) - } - } - } -} - -@Composable -private fun Modifier.settingsUnderlay(hidden: Boolean, animationsEnabled: Boolean): Modifier { - val progress by animateFloatAsState(if (hidden) 1f else 0f, - if (animationsEnabled) TeakSearchTransitionDefaults.spec() else tween(0), label = "settingsSearchMotion") - val alpha by animateFloatAsState(if (hidden) 0f else 1f, - tween(if (animationsEnabled) TeakSearchTransitionDefaults.UnderlayAlphaMs else 0), label = "settingsSearchAlpha") - return this.blockTouches(hidden).then(if (hidden) Modifier.clearAndSetSemantics { } else Modifier) - .teakSearchUnderlay(progress, alpha) + val session = SettingsBrowserSession(browser, navigation, selected, wide, categories, footers) + if (wide) SettingsSplitLayout(session, settings.values, runtime, controller) + else SettingsBrowserPane(session, settings.values, runtime, controller, SettingsPane.Single, Modifier.fillMaxSize()) } @Composable @@ -89,10 +40,10 @@ internal fun rememberSettingsBrowser(): SettingsBrowserStateHolder { val saver = remember { Saver>(save = { val s = it.state.value - listOf(s.route.orEmpty(), s.searching.toString(), s.searchRoute.orEmpty(), s.query) + listOf(s.route.orEmpty(), s.searching.toString(), s.searchRoute.orEmpty(), s.query, s.splitFraction.toString()) }, restore = { SettingsBrowserStateHolder(SettingsBrowserState(it[0].ifEmpty { null }, it[1].toBoolean(), - it[2].ifEmpty { null }, it[3])) + it[2].ifEmpty { null }, it[3], splitFraction = it.getOrNull(4)?.toFloatOrNull() ?: SettingsSplitPolicy.DefaultFraction)) }) } return rememberSaveable(saver = saver) { SettingsBrowserStateHolder() } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt index 56878641..3d7c04c0 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt @@ -43,9 +43,7 @@ internal fun SettingsCategorySearchButton(onSearch: () -> Unit) { @Composable internal fun BoxScope.SettingsSearchDock(session: SettingsBrowserSession) { val searching = session.navigation.searching - val start = if (session.wide && searching) 280.dp else 0.dp - val width = if (session.wide && !searching) Modifier.width(280.dp) else Modifier.fillMaxWidth() - Row(Modifier.align(Alignment.BottomStart).then(width).padding(start = start) + Row(Modifier.align(Alignment.BottomStart).fillMaxWidth() .imePadding().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp).animateContentSize(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { TeakGlassSurface(modifier = Modifier.weight(1f).heightIn(min = 48.dp) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt index 93e9c47a..3eb2ccc5 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt @@ -24,12 +24,13 @@ internal data class SettingsBrowserSession( val selected: SettingsCategory?, val wide: Boolean, val categories: List, + val footers: List = emptyList(), ) @Composable -internal fun SettingsCategoryList(session: SettingsBrowserSession, modifier: Modifier) { +internal fun SettingsCategoryList(session: SettingsBrowserSession, modifier: Modifier, runtime: SettingsPageRuntime) { val list = rememberLazyListState() - val chrome = if (session.wide) rememberTeakPageScrollState() else LocalTeakPageScrollState.current + val chrome = LocalTeakPageScrollState.current if (!session.navigation.searching) TrackTeakPageScroll(list, chrome) LazyColumn(modifier, state = list, contentPadding = settingsListPadding()) { item { TeakLargeTitle("设置", pageState = chrome) } @@ -43,6 +44,14 @@ internal fun SettingsCategoryList(session: SettingsBrowserSession, modifier: Mod } } } + session.footers.forEach { footer -> + item(key = footer.route) { + Spacer(Modifier.height(16.dp)) + TeakSettingsCard { + footer.groups.flatMap { it.settings }.forEach { IndexedActionComponent(it, runtime) } + } + } + } if (session.categories.isEmpty()) item { Text("暂无设置", Modifier.padding(24.dp)) } } } @@ -69,7 +78,7 @@ internal fun SettingsCategoryContent(session: SettingsBrowserSession, values: Ma } @Composable -internal fun SettingsSearchResults(session: SettingsBrowserSession, hits: List, modifier: Modifier) { +internal fun SettingsSearchResults(session: SettingsBrowserSession, hits: List, modifier: Modifier, runtime: SettingsPageRuntime) { val list = rememberLazyListState() TrackTeakPageScroll(list) LazyColumn(modifier.imePadding().background(MiuixTheme.colorScheme.surface), state = list, contentPadding = settingsListPadding()) { @@ -81,7 +90,8 @@ internal fun SettingsSearchResults(session: SettingsBrowserSession, hits: List TeakSettingsCard { - ArrowPreference(title = hit.setting.title, summary = "${hit.category.name} · ${hit.group.title}", + if (hit.category.layout == "root_footer") IndexedActionComponent(hit.setting, runtime) + else ArrowPreference(title = hit.setting.title, summary = "${hit.category.name} · ${hit.group.title}", onClick = { session.browser.open(hit.category.route, hit.setting.prefName) }) } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt new file mode 100644 index 00000000..b21f58a6 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt @@ -0,0 +1,111 @@ +package com.aritxonly.bdfun.ui + +import android.content.res.Configuration +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.* +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.IntSize +import com.aritxonly.bdfun.core.model.settings.SettingValue +import com.aritxonly.bdfun.feature.settings.application.* +import com.aritxonly.teak.common.chrome.TeakPageScaffold +import com.aritxonly.teak.common.chrome.rememberTeakPageScrollState +import com.aritxonly.teak.common.interaction.blockTouches +import com.aritxonly.teak.common.material.LocalTeakBackdrop +import com.aritxonly.teak.common.search.TeakSearchTransitionDefaults +import com.aritxonly.teak.common.search.teakSearchUnderlay +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.blur.layerBackdrop +import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop +import top.yukonga.miuix.kmp.theme.MiuixTheme + +internal enum class SettingsPane { Single, Categories, Details } + +@Composable +internal fun SettingsBrowserPane(session: SettingsBrowserSession, values: Map, + runtime: SettingsPageRuntime, controller: IndexedSettingsStateHolder, pane: SettingsPane, modifier: Modifier) { + BoxWithConstraints(modifier.clipToBounds()) { + val parent = LocalWindowInfo.current + val size = IntSize(constraints.maxWidth, constraints.maxHeight) + val window = remember(parent, size) { object : WindowInfo by parent { override val containerSize = size } } + val original = LocalConfiguration.current + val configuration = remember(original, maxWidth, maxHeight) { + Configuration(original).apply { + screenWidthDp = maxWidth.value.toInt() + screenHeightDp = maxHeight.value.toInt() + smallestScreenWidthDp = minOf(screenWidthDp, screenHeightDp) + orientation = if (screenWidthDp > screenHeightDp) Configuration.ORIENTATION_LANDSCAPE else Configuration.ORIENTATION_PORTRAIT + } + } + val searching = session.navigation.searching && (pane == SettingsPane.Single || + (session.navigation.searchRoute == null) == (pane == SettingsPane.Categories)) + val local = session.copy(navigation = session.navigation.copy(searching = searching)) + val categories = pane == SettingsPane.Categories || pane == SettingsPane.Single && session.selected == null + val title = if (searching) "搜索设置" else if (categories) "设置" else session.selected?.name ?: "设置" + val back = { + if (pane == SettingsPane.Categories) { + if (searching) session.browser.closeSearch() else runtime.navigation.onBack() + } else if (!session.browser.back()) runtime.navigation.onBack() + } + CompositionLocalProvider(LocalWindowInfo provides window, LocalConfiguration provides configuration) { + SettingsPaneScaffold(runtime, pane, title, back, + search = if (!categories && !searching && session.selected != null) ({ session.browser.search(session.selected.route) }) else null, + ) { SettingsPaneBody(local, values, runtime, controller, categories) } + } + } +} + +@Composable +private fun SettingsPaneScaffold(runtime: SettingsPageRuntime, pane: SettingsPane, title: String, + back: () -> Unit, search: (() -> Unit)?, content: @Composable () -> Unit) { + if (pane == SettingsPane.Single) { + TeakActivityScaffold(runtime.settings, back, options = ActivityPageOptions(title = title), + slots = ActivityPageSlots(topBarActions = { if (search != null) SettingsCategorySearchButton(search) }), content = content) + } else PageChromeScope { + // 两栏共用主题,但外壳、滚动、弹层和玻璃采样各自持有,不能再套整页内容宽度限制。 + TeakPageScaffold(title = title, onBack = back, limitContentWidth = false, + pageState = rememberTeakPageScrollState(), backgroundColor = MiuixTheme.colorScheme.surface, + contentModifier = Modifier.background(MiuixTheme.colorScheme.surface).smartRefresh(runtime.settings.smartRefresh), + topBarActions = { if (search != null) SettingsCategorySearchButton(search) }, + overlay = { ScrollTopButton(runtime.settings) }, content = content) + } +} + +@Composable +private fun SettingsPaneBody(session: SettingsBrowserSession, values: Map, runtime: SettingsPageRuntime, + controller: IndexedSettingsStateHolder, categories: Boolean) { + val background = MiuixTheme.colorScheme.surface + val backdrop = rememberLayerBackdrop { drawRect(background); drawContent() } + CompositionLocalProvider(LocalTeakBackdrop provides backdrop) { + Scaffold(containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0, 0, 0, 0)) { + Box(Modifier.fillMaxSize().background(background)) { + Box(Modifier.fillMaxSize().layerBackdrop(backdrop)) { + Box(Modifier.fillMaxSize().settingsUnderlay(session.navigation.searching, runtime.settings.animationsEnabled)) { + if (categories) SettingsCategoryList(session, Modifier.fillMaxSize(), runtime) + else SettingsCategoryContent(session, values, runtime, controller, Modifier.fillMaxSize()) + } + if (session.navigation.searching) { + val hits = searchSettingsCategories(session.categories + session.footers, session.navigation.query, session.navigation.searchRoute) + SettingsSearchResults(session, hits, Modifier.fillMaxSize(), runtime) + } + } + if (categories || session.navigation.searching) SettingsSearchDock(session) + } + } + } +} + +@Composable +private fun Modifier.settingsUnderlay(hidden: Boolean, animationsEnabled: Boolean): Modifier { + val progress by animateFloatAsState(if (hidden) 1f else 0f, + if (animationsEnabled) TeakSearchTransitionDefaults.spec() else tween(0), label = "settingsSearchMotion") + val alpha by animateFloatAsState(if (hidden) 0f else 1f, + tween(if (animationsEnabled) TeakSearchTransitionDefaults.UnderlayAlphaMs else 0), label = "settingsSearchAlpha") + return blockTouches(hidden).then(if (hidden) Modifier.clearAndSetSemantics { } else Modifier).teakSearchUnderlay(progress, alpha) +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsScreen.kt index 95343238..4961a8d8 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsScreen.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsScreen.kt @@ -22,20 +22,7 @@ internal fun SettingsScreen(settings: SettingsBindings, navigation: SettingsNavi val pickWallpaper = rememberSettingsWallpaperPicker(settings) val runtime = SettingsPageRuntime(settings, cdn, cdnState, navigation, scope, pickWallpaper, dependencies.mineLayout, extensions) val browser = rememberSettingsBrowser() - val page by browser.state.collectAsState() - val wide = androidx.compose.ui.platform.LocalConfiguration.current.let { - it.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE && it.screenWidthDp >= 600 - } - val category = indexed.index.categories.firstOrNull { it.route == page.route } - ?: indexed.index.categories.firstOrNull().takeIf { wide } - val title = if (page.searching) "搜索设置" else category?.name ?: "设置" - TeakActivityScaffold(settings = settings, - onBack = { if (!browser.back()) navigation.onBack() }, - options = ActivityPageOptions(title = title), - slots = ActivityPageSlots(topBarActions = { - if (category != null && !page.searching) SettingsCategorySearchButton { browser.search(category.route) } - }), - ) { SettingsBrowser(indexed, runtime, loggedIn, browser) } + SettingsBrowser(indexed, runtime, loggedIn, browser) } @Composable diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsSplitLayout.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsSplitLayout.kt new file mode 100644 index 00000000..aad3ed9b --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsSplitLayout.kt @@ -0,0 +1,70 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.key.* +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.* +import androidx.compose.ui.unit.dp +import com.aritxonly.bdfun.core.model.settings.SettingValue +import com.aritxonly.bdfun.feature.settings.application.IndexedSettingsStateHolder +import com.aritxonly.bdfun.feature.settings.application.SettingsSplitPolicy +import top.yukonga.miuix.kmp.theme.MiuixTheme + +@Composable +internal fun SettingsSplitLayout(session: SettingsBrowserSession, values: Map, + runtime: SettingsPageRuntime, controller: IndexedSettingsStateHolder) { + TeakBareTheme(runtime.settings) { _, _ -> + BoxWithConstraints(Modifier.fillMaxSize()) { + val total = maxWidth.value + val fraction = session.navigation.splitFraction + val left = SettingsSplitPolicy.leftWidth(total, fraction) + val density = LocalDensity.current + val drag: (Float) -> Unit = { delta -> + session.browser.resize(total, delta) + } + Row(Modifier.fillMaxSize()) { + SettingsBrowserPane(session, values, runtime, controller, SettingsPane.Categories, + Modifier.width(left.dp).fillMaxHeight().clipToBounds()) + SettingsSplitDivider(left / (total - SettingsSplitPolicy.DividerDp).coerceAtLeast(1f), drag, { session.browser.split(SettingsSplitPolicy.DefaultFraction) }, Modifier + .width(SettingsSplitPolicy.DividerDp.dp).fillMaxHeight() + .pointerInput(total, density.density) { + detectDragGestures { change, amount -> change.consume(); drag(amount.x / density.density) } + }) + SettingsBrowserPane(session, values, runtime, controller, SettingsPane.Details, + Modifier.weight(1f).fillMaxHeight().clipToBounds()) + } + } + } +} + +@Composable +private fun SettingsSplitDivider(fraction: Float, drag: (Float) -> Unit, reset: () -> Unit, modifier: Modifier) { + // 分隔线有独立热区和键盘/无障碍操作,不夺取左右栏的纵向滚动。 + Box(modifier.semantics { + contentDescription = "调整设置分栏宽度" + stateDescription = "左栏${(fraction * 100).toInt()}%" + customActions = listOf( + CustomAccessibilityAction("增大左栏") { drag(32f); true }, + CustomAccessibilityAction("缩小左栏") { drag(-32f); true }, + CustomAccessibilityAction("恢复3比7") { reset(); true }, + ) + }.onKeyEvent { + if (it.type != KeyEventType.KeyDown) false else when (it.key) { + Key.DirectionLeft -> { drag(-32f); true } + Key.DirectionRight -> { drag(32f); true } + Key.MoveHome -> { reset(); true } + else -> false + } + }.focusable(), contentAlignment = Alignment.Center) { + Box(Modifier.width(1.dp).fillMaxHeight().background(MiuixTheme.colorScheme.outline.copy(alpha = .2f))) + Box(Modifier.width(4.dp).height(36.dp).background(MiuixTheme.colorScheme.onSurfaceVariantSummary.copy(alpha = .4f))) + } +} diff --git a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt index 431ce44f..bef75564 100644 --- a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt +++ b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt @@ -40,7 +40,7 @@ class SettingsBrowserStateHolderTest { val debug = action.copy(prefName = "logs", title = "日志", visibleWhen = "debug") val index = SettingsIndex(listOf( SettingsCategory("外观", "settings/appearance", null, listOf(SettingsGroup("显示", listOf(toggle, dependent)))), - SettingsCategory("账号", "settings/account", null, listOf(SettingsGroup("账号", listOf(action, debug)))), + SettingsCategory("账号", "settings/account", null, listOf(SettingsGroup("账号", listOf(action, debug))), layout = "root_footer"), )) val guest = visibleSettingsIndex(index, mapOf("enabled" to SettingValue.Flag(false)), false, false) assertEquals(1, guest.size) @@ -64,6 +64,25 @@ class SettingsBrowserStateHolderTest { assertEquals(3, indexedOptionIndex(entry, SettingValue.Decimal(1.25f))) } + @Test fun dragUpdatesAccumulateBeforeTheNextComposition() { + val page = SettingsBrowserStateHolder() + repeat(4) { page.resize(1012f, 16f) } + assertEquals(424f, SettingsSplitPolicy.leftWidth(1012f, page.state.value.splitFraction), .001f) + } + + @Test fun resizingSurvivesPageAndSearchNavigation() { + val page = SettingsBrowserStateHolder() + page.split(.45f) + page.open("settings/player") + page.search("settings/player") + page.closeSearch() + assertEquals(.45f, page.state.value.splitFraction) + page.back() + assertEquals(.45f, page.state.value.splitFraction) + page.split(Float.NaN) + assertEquals(.45f, page.state.value.splitFraction) + } + @Test fun restoredNavigationDoesNotNeedASecondLayoutState() { val saved = SettingsBrowserState("settings/player", true, "settings/player", "蓝牙") assertEquals(saved, SettingsBrowserStateHolder(saved).state.value) diff --git a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicyTest.kt b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicyTest.kt new file mode 100644 index 00000000..beabb87e --- /dev/null +++ b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicyTest.kt @@ -0,0 +1,34 @@ +package com.aritxonly.bdfun.feature.settings.application + +import org.junit.Assert.* +import org.junit.Test + +class SettingsSplitPolicyTest { + @Test fun defaultsToThreeToSevenWith360Minimum() { + assertEquals(480f, SettingsSplitPolicy.leftWidth(1612f), .001f) + assertEquals(360f, SettingsSplitPolicy.leftWidth(1012f), .001f) + assertEquals(360f, SettingsSplitPolicy.leftWidth(612f), .001f) + } + + @Test fun dividerTracksDragInsteadOfJumpingFromUnclampedRatio() { + val moved = SettingsSplitPolicy.drag(1012f, .3f, 100f) + assertEquals(460f, SettingsSplitPolicy.leftWidth(1012f, moved), .001f) + val minimum = SettingsSplitPolicy.drag(1012f, moved, -1000f) + assertEquals(360f, SettingsSplitPolicy.leftWidth(1012f, minimum), .001f) + } + + @Test fun rightPaneAlwaysGetsRemainingWidth() { + listOf(612f, 922f, 1612f).forEach { total -> + listOf(-1f, .3f, .6f, 2f).forEach { ratio -> + val left = SettingsSplitPolicy.leftWidth(total, ratio) + assertTrue(left >= 360f) + assertTrue(total - SettingsSplitPolicy.DividerDp - left >= 1f) + } + } + } + + @Test fun invalidFractionUsesDefaultAndTinyWindowsDoNotCrash() { + assertEquals(SettingsSplitPolicy.leftWidth(1612f), SettingsSplitPolicy.leftWidth(1612f, Float.NaN), 0f) + assertEquals(0f, SettingsSplitPolicy.leftWidth(0f), 0f) + } +} diff --git a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt index 3de85402..2de9c623 100644 --- a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt +++ b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt @@ -20,12 +20,14 @@ class SettingsIndex(val categories: List) { val entries = categories.flatMap { it.groups }.flatMap { it.settings } require(entries.size == settings.size) { "pref_name 重复" } categories.forEach { category -> - require(category.layout in setOf("settings", "about")) + require(category.layout in setOf("settings", "about", "root_footer")) require(category.name.isNotBlank() && category.route.isNotBlank()) category.groups.forEach { group -> require(group.title.isNotBlank()) group.settings.forEach { setting -> require(setting.prefName.matches(Regex("[a-z][a-z0-9_]*")) && setting.title.isNotBlank()) + require(category.layout != "root_footer" || setting.type == "action" && setting.storage == "none") + require(setting.prefName != "logout" || category.layout == "root_footer") { "退出登录必须位于一级页面底部" } require(setting.storage in setOf("settings", "mine_layout", "none")) require(setting.visibleWhen == null || setting.visibleWhen in setOf("debug", "logged_in") || settings[setting.visibleWhen]?.default is SettingValue.Flag || diff --git a/docs/SETTINGS_INDEX_DESIGN.md b/docs/SETTINGS_INDEX_DESIGN.md index d793f04c..8ebf429a 100644 --- a/docs/SETTINGS_INDEX_DESIGN.md +++ b/docs/SETTINGS_INDEX_DESIGN.md @@ -4,13 +4,15 @@ App 的唯一布局来源是 `app/src/main/assets/settings-index.json`。本轮以原生设置页完整清单重新建立索引,替换原型的36项示例;61个组件描述覆盖原全部设置、操作、开发者行,另含关于身份区与PR52的三个可选推荐组件。page_switches仍展开原来的六个页面开关。没有旧设置页兜底入口,原四个手写卡片文件已删除。 -竖屏为分类列表与二级页;横屏且宽度至少600dp使用左侧分类、右侧内容。导航和搜索由页面作用域SettingsBrowserStateHolder持有,旋转不另建选择状态。返回先关闭搜索再回分类。搜索匹配名称/说明/分组/分类/键,排除当前不满足条件的设置,结果跳转并滚动定位。页面外壳、返回键、TopAppBar和大标题/滚动/材质联动继续使用原来的TeakActivityScaffold与TeakLargeTitle/TrackTeakPageScroll,不另画顶栏;二级页只在原工具栏增加搜索按钮。一级底部柔光胶囊展开、分裂关闭按钮并跟随IME抬升;搜索栏/关闭按钮高度48dp,底部间距8dp,保留系统导航/IME避让;动画复用主页搜索参数。分类内从右上搜索。 +竖屏为分类列表与二级页;横屏且宽度至少600dp使用覆盖窗口全宽的两个独立Scaffold,取消外层内容最大宽度限制。左右栏分别拥有原顶栏、返回、滚动状态、popupHost与backdrop,按各自尺寸提供窗口上下文;默认3:7(不含12dp分隔线),左栏至少360dp,右栏使用剩余空间。分隔线可拖动,也支持左右键调整和Home恢复比例。导航和搜索由页面作用域SettingsBrowserStateHolder持有,旋转不另建选择状态。返回先关闭搜索再回分类。搜索匹配名称/说明/分组/分类/键,排除当前不满足条件的设置,结果跳转并滚动定位。页面外壳、返回键、TopAppBar和大标题/滚动/材质联动继续使用原来的TeakActivityScaffold/TeakPageScaffold与TeakLargeTitle/TrackTeakPageScroll,不另画顶栏;二级页只在原工具栏增加搜索按钮。一级底部柔光胶囊展开、分裂关闭按钮并跟随IME抬升;搜索栏/关闭按钮高度48dp,底部间距8dp,保留系统导航/IME避让;动画复用主页搜索参数。分类内从右上搜索。横屏全局搜索由左栏持有,分类搜索由右栏持有,不再跨栏共用搜索浮层。 普通设置仍使用现有Teak/Miuix行:dropdown仍是玻璃dropdown(包括原面板宽度、开闭行为),字号仍用原SliderPreference和吸附/松手写入;主题/色彩的260ms延迟、动画反向开关、CDN动态选项、壁纸系统选择、开发者头像、退出登录的颜色与登录/debug显隐保持。第一阶段不展示分类图标;返回/搜索/关闭仅使用Miuix Icons。 ## JSON 合同 -顶层SettingsCategory数组:name / route / groups,layout可为settings(默认)或about;icon预留但本阶段不使用。Group为title / settings。 +顶层SettingsCategory数组:name / route / groups,layout可为settings(默认)、about或root_footer;icon预留但本阶段不使用。Group为title / settings。 + +layout=root_footer不生成二级页,登录后在一级列表最末尾的独立卡片中渲染退出登录;搜索命中时仍直接显示原操作,不跳到账号页。 Setting保留pref_name / type / title / subtitle / default;type现在表示组件:switch、dropdown、cdn_dropdown、font_scale、page_switches、mine_switch、action、developer、version、app_identity、extension。旧boolean/select类型仍可解码。dropdown补充options:[{title,value}],使用真实存储值而非数组下标。浮点组件带value_type:decimal,保证JSON序列化1.0→1仍按Float读取。inverted保留减弱动效的反向展示。 diff --git a/docs/settings-organizer/README.md b/docs/settings-organizer/README.md index 12318583..aa36c6b3 100644 --- a/docs/settings-organizer/README.md +++ b/docs/settings-organizer/README.md @@ -6,4 +6,4 @@ 预览仅用于检查组织结构,不会修改手机偏好或执行退出登录、打开外链等真实动作。手机界面使用现有 Miuix/Teak 组件:select 仍为 dropdown,字号仍为滑条,返回键和 TopAppBar 沿用原壳。 -导出的 `settings-index.json` 替换仓库 `app/src/main/assets/settings-index.json` 后重新构建生效。再运行 `python3 tools/sync-settings-catalog.py` 同步此工具的目录数据。请保留完整组件清单和原组件/存储/默认值/选项/显隐合同;第一阶段不配置分类图标。推荐扩展在合入 PR52 的构建中生效。 +导出的 `settings-index.json` 替换仓库 `app/src/main/assets/settings-index.json` 后重新构建生效。再运行 `python3 tools/sync-settings-catalog.py` 同步此工具的目录数据。请保留完整组件清单和原组件/存储/默认值/选项/显隐合同;第一阶段不配置分类图标。退出登录固定为一级页底部独立操作,不能移到二级页;横屏预览是独立双栏,左栏至少360px、默认3:7,可拖动分隔线或用方向键调整。推荐扩展在合入 PR52 的构建中生效。 diff --git a/docs/settings-organizer/catalog.js b/docs/settings-organizer/catalog.js index 9eda5f01..921af8c2 100644 --- a/docs/settings-organizer/catalog.js +++ b/docs/settings-organizer/catalog.js @@ -783,6 +783,7 @@ window.SETTINGS_CATALOG = [ } ] } - ] + ], + "layout": "root_footer" } ]; diff --git a/docs/settings-organizer/organizer.js b/docs/settings-organizer/organizer.js index 4dfef2cc..7506abb5 100644 --- a/docs/settings-organizer/organizer.js +++ b/docs/settings-organizer/organizer.js @@ -27,7 +27,7 @@ function renderTree(){const tree=$('#tree'),filter=$('#filter').value.trim().toL function field(label,value,change,multiline=false){const wrapper=node('label','field');wrapper.append(node('span','',label));const input=node(multiline?'textarea':'input');input.value=value||'';input.onchange=()=>change(input.value);wrapper.append(input);return wrapper} function destination(label,options,current,change){const wrapper=node('label','field'),select=node('select');wrapper.append(node('span','',label));options.forEach(([value,title])=>{const option=node('option','',title);option.value=value;option.selected=value===current;select.append(option)});select.onchange=()=>change(select.value);wrapper.append(select);return wrapper} function renderProperties(){const panel=$('#properties');panel.replaceChildren();const c=model.findIndex(c=>c.route===selection.route); - if(selection.kind==='category'&&c>=0){const category=model[c];panel.append(field('分类名称',category.name,v=>commit(d=>d[c].name=v)),field('分类路由',category.route,v=>{const previous=category.route;commit(d=>d[c].route=v);selection.route=model[c].route||previous;render()}));panel.append(node('div','readonly','页面布局:'+(category.layout==='about'?'Deadliner 关于页':'标准设置页')+'。第一阶段不使用分类图标。'));panel.append(button('删除空分类',()=>{if(category.groups.some(g=>g.settings.length))return notice('请先把设置移动到其他分类');commit(d=>d.splice(c,1));selection={kind:'category',route:model[0].route};render()},'danger'));return} + if(selection.kind==='category'&&c>=0){const category=model[c];panel.append(field('分类名称',category.name,v=>commit(d=>d[c].name=v)),field('分类路由',category.route,v=>{const previous=category.route;commit(d=>d[c].route=v);selection.route=model[c].route||previous;render()}));panel.append(node('div','readonly','页面布局:'+(category.layout==='about'?'Deadliner 关于页':category.layout==='root_footer'?'一级页底部独立操作':'标准设置页')+'。第一阶段不使用分类图标。'));panel.append(button('删除空分类',()=>{if(category.groups.some(g=>g.settings.length))return notice('请先把设置移动到其他分类');commit(d=>d.splice(c,1));selection={kind:'category',route:model[0].route};render()},'danger'));return} if(selection.kind==='group'&&c>=0){const g=selection.g,group=model[c].groups[g];if(!group)return;panel.append(field('分组标题',group.title,v=>commit(d=>d[c].groups[g].title=v)));panel.append(destination('移动到分类',model.map((c,i)=>[String(i),c.name]),String(c),v=>{const target=Number(v);commit(d=>d[target].groups.push(d[c].groups.splice(g,1)[0]));selection={kind:'group',route:model[target].route,g:model[target].groups.length-1};render()}));panel.append(button('删除空分组',()=>{if(group.settings.length)return notice('请先移动这个分组中的设置');commit(d=>d[c].groups.splice(g,1));select({kind:'category',route:model[c].route})},'danger'));return} if(selection.kind==='setting'){const at=locate(selection.key);if(!at)return;const s=model[at.c].groups[at.g].settings[at.s];panel.append(field('设置标题',s.title,v=>commit(d=>d[at.c].groups[at.g].settings[at.s].title=v)),field('说明',s.subtitle||'',v=>commit(d=>{const entry=d[at.c].groups[at.g].settings[at.s];if(v)entry.subtitle=v;else delete entry.subtitle}),true)); const choices=model.flatMap((c,ci)=>c.groups.map((g,gi)=>[`${ci}:${gi}`,`${c.name} / ${g.title}`]));panel.append(destination('所在分类 / 分组',choices,`${at.c}:${at.g}`,v=>{const [c,g]=v.split(':').map(Number);moveSetting(s.pref_name,c,g)})); diff --git a/docs/settings-organizer/preview.css b/docs/settings-organizer/preview.css index 72cbf0da..b20435cd 100644 --- a/docs/settings-organizer/preview.css +++ b/docs/settings-organizer/preview.css @@ -3,3 +3,17 @@ /* 预览沿用原页面的独立返回工具栏与滚动大标题,不改成并排大标题栏。 */ .chrome{position:sticky;top:0;z-index:2;display:flex;align-items:center;justify-content:space-between;min-height:56px;background:linear-gradient(var(--page) 45%,transparent);gap:10px} .chrome button{font-size:12px;min-width:40px;min-height:40px;padding:8px;background:transparent;border-radius:24px}.small-title{flex:1;text-align:center;opacity:0;font-size:16px}.large-title{font-size:30px;font-weight:400;margin:0;padding:0 10px 8px;letter-spacing:0}.context{font-size:10px} + +/* 两个页面外壳占满预览窗口,分隔线只调整左右内容比例。 */ +#app:not(.phone) aside{width:var(--left-width,360px);border:0} +#splitter{flex:0 0 12px;cursor:col-resize;touch-action:none;position:relative;background:var(--page)} +#splitter:before{content:"";position:absolute;top:0;bottom:0;left:5px;width:1px;background:#8883} +#splitter:after{content:"";position:absolute;left:4px;top:calc(50% - 18px);height:36px;width:4px;border-radius:3px;background:#8886} +#app.phone #splitter{display:none} +#app:not(.phone) #dock{width:calc(var(--left-width,360px) - 32px);left:16px} +#app:not(.phone) #results{left:0;width:var(--left-width,360px);right:auto} +#app:not(.phone).searching.scoped #results{left:calc(var(--left-width,360px) + 12px);width:auto;right:0} +#app:not(.phone).searching.scoped #dock{left:calc(var(--left-width,360px) + 28px);width:calc(100% - var(--left-width,360px) - 44px)} +#app.searching.global #content{opacity:1;transform:none;pointer-events:auto} +.root-footer{margin-top:24px} +#app:not(.phone) section{clip-path:inset(0);isolation:isolate} diff --git a/docs/settings-organizer/preview.html b/docs/settings-organizer/preview.html index e80ac1c2..ba3e862a 100644 --- a/docs/settings-organizer/preview.html +++ b/docs/settings-organizer/preview.html @@ -1 +1 @@ -bdFun 设置预览
bdFun / 设置预览打开编排器
系统键盘区域(预览占位)
+bdFun 设置预览
bdFun / 设置预览打开编排器
系统键盘区域(预览占位)
diff --git a/docs/settings-organizer/preview.js b/docs/settings-organizer/preview.js index e34c91a1..f3d3c544 100644 --- a/docs/settings-organizer/preview.js +++ b/docs/settings-organizer/preview.js @@ -9,9 +9,9 @@ function available(){return categories.map(c=>({...c,groups:c.groups.map(g=>({.. function notify(text){const toast=$('#toast');toast.textContent=text;toast.style.display='block';clearTimeout(notify.timer);notify.timer=setTimeout(()=>toast.style.display='none',1900)} function header(title,back,search){const fragment=document.createDocumentFragment(),chrome=el('div','chrome');if(back){const b=el('button','','返回');b.onclick=back;chrome.append(b)}const small=el('span','small-title',title);chrome.append(small);if(search){const b=el('button','','搜索');b.onclick=search;chrome.append(b)}fragment.append(chrome,el('h1','large-title',title));return fragment} -function closeSearch(){searching=false;query.value='';query.blur();app.classList.remove('searching','global')} +function closeSearch(){searching=false;query.value='';query.blur();app.classList.remove('searching','global','scoped')} function openCategory(next,key){route=next;closeSearch();render();if(key){const target=document.getElementById('pref-'+key);target?.scrollIntoView({block:'center',behavior:'smooth'});target?.classList.add('spotlight')}} -function startSearch(scope=null){searchScope=scope;searching=true;app.classList.add('searching');app.classList.toggle('global',!scope);query.placeholder=scope?'搜索当前分类':'搜索全部设置';query.focus();showResults()} +function startSearch(scope=null){searchScope=scope;searching=true;app.classList.add('searching');app.classList.toggle('global',!scope);app.classList.toggle('scoped',!!scope);query.placeholder=scope?'搜索当前分类':'搜索全部设置';query.focus();showResults()} function identity(setting){const box=el('div','identity'),img=el('img');img.src='assets/app.png';img.alt='';box.append(img,el('strong','',setting.title),el('small','','vpreview-5.0'));return box} function settingRow(s){if(s.pref_name==='recommendation_error')return document.createDocumentFragment();if(s.type==='app_identity')return identity(s);const row=el('div','row');row.id='pref-'+s.pref_name;const label=el('div','label');label.append(el('strong','',s.title));if(s.subtitle)label.append(el('small','',s.subtitle)); if(s.type==='developer'){const image=el('img','avatar');image.src='assets/'+(s.pref_name==='developer_aritx'?'aritx':'teak')+'.jpg';image.alt='';row.append(image)}row.append(label); @@ -23,10 +23,19 @@ function settingRow(s){if(s.pref_name==='recommendation_error')return document.c else{const tail=s.pref_name==='wallpaper_pick'?'未设置':s.pref_name==='discussion'?'Telegram 群':['logs','live_preview'].includes(s.pref_name)?'调试':'';if(tail)row.append(el('span','trailing',tail));row.append(el('span','chevron','›'));row.tabIndex=0;row.setAttribute('role','button');row.onclick=()=>notify('预览模式:'+s.title+',不执行外部操作');row.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();row.click()}};if(s.pref_name==='logout')label.style.color='#e5484d'} return row; } -function render(){app.style.setProperty('--blue',values.color_style===1?'#3482ff':'#ff6699');const list=available(),phone=app.classList.contains('phone'),selected=list.find(c=>c.route===route)||(!phone?list[0]:null);app.classList.toggle('detail',!!selected);const nav=$('#categories');nav.replaceChildren(header('设置',()=>notify('预览模式'),null));const card=el('div','card');list.forEach(c=>{const b=el('button','row category-row'+(selected?.route===c.route?' selected':''));b.append(el('span','label',c.name),el('span','chevron','›'));b.onclick=()=>openCategory(c.route);card.append(b)});nav.append(card);const content=$('#content');content.replaceChildren();content.classList.toggle('about',selected?.layout==='about');if(selected){content.append(header(selected.name,phone?()=>{route=null;render()}:null,()=>startSearch(selected.route)));selected.groups.forEach(g=>{if(g.settings.every(s=>s.type==='app_identity')){g.settings.forEach(s=>content.append(identity(s)));return}content.append(el('h3','',g.title));const group=el('div','card');g.settings.forEach(s=>group.append(settingRow(s)));content.append(group)});content.onscroll=()=>{if(selected.layout!=='about')return;const p=Math.min(1,content.scrollTop/320);content.style.setProperty('--glow-alpha',1-p);content.querySelectorAll('.card').forEach(card=>card.style.opacity=.5+p*.5)};if(selected.layout==='about')content.onscroll()} +function render(){app.style.setProperty('--blue',values.color_style===1?'#3482ff':'#ff6699');const all=available(),list=all.filter(c=>c.layout!=='root_footer'),footers=all.filter(c=>c.layout==='root_footer'),phone=app.classList.contains('phone'),selected=list.find(c=>c.route===route)||(!phone?list[0]:null);app.classList.toggle('detail',!!selected);const nav=$('#categories');nav.replaceChildren(header('设置',()=>notify('预览模式'),null));const card=el('div','card');list.forEach(c=>{const b=el('button','row category-row'+(selected?.route===c.route?' selected':''));b.append(el('span','label',c.name),el('span','chevron','›'));b.onclick=()=>openCategory(c.route);card.append(b)});nav.append(card);footers.forEach(c=>{const footer=el('div','card root-footer');c.groups.flatMap(g=>g.settings).forEach(s=>footer.append(settingRow(s)));nav.append(footer)});const content=$('#content');content.replaceChildren();content.classList.toggle('about',selected?.layout==='about');if(selected){content.append(header(selected.name,()=>{route=null;render()},()=>startSearch(selected.route)));selected.groups.forEach(g=>{if(g.settings.every(s=>s.type==='app_identity')){g.settings.forEach(s=>content.append(identity(s)));return}content.append(el('h3','',g.title));const group=el('div','card');g.settings.forEach(s=>group.append(settingRow(s)));content.append(group)});content.onscroll=()=>{if(selected.layout!=='about')return;const p=Math.min(1,content.scrollTop/320);content.style.setProperty('--glow-alpha',1-p);content.querySelectorAll('.card').forEach(card=>card.style.opacity=.5+p*.5)};if(selected.layout==='about')content.onscroll()} if(searching)showResults(); } -function showResults(){const result=$('#results');result.replaceChildren(header(searchScope?'搜索当前分类':'搜索设置',closeSearch,null));const words=query.value.trim().toLowerCase().split(/\s+/).filter(Boolean);if(!words.length){result.append(el('p','hint','搜索设置名称、说明或关键词'));return}let count=0;available().filter(c=>!searchScope||c.route===searchScope).forEach(c=>c.groups.forEach(g=>g.settings.forEach(s=>{const text=[c.name,g.title,s.title,s.subtitle||'',s.pref_name].join(' ').toLowerCase();if(words.every(w=>text.includes(w))){const b=el('button','row result'),label=el('div','label');label.append(el('strong','',s.title),el('small','',c.name+' · '+g.title));b.append(label);b.onclick=()=>openCategory(c.route,s.pref_name);result.append(b);count++}})));if(!count)result.append(el('p','hint','没有找到相关设置'))} +function showResults(){const result=$('#results');result.replaceChildren(header(searchScope?'搜索当前分类':'搜索设置',closeSearch,null));const words=query.value.trim().toLowerCase().split(/\s+/).filter(Boolean);if(!words.length){result.append(el('p','hint','搜索设置名称、说明或关键词'));return}let count=0;available().filter(c=>!searchScope||c.route===searchScope).forEach(c=>c.groups.forEach(g=>g.settings.forEach(s=>{const text=[c.name,g.title,s.title,s.subtitle||'',s.pref_name].join(' ').toLowerCase();if(words.every(w=>text.includes(w))){if(c.layout==='root_footer'){const block=el('div','card result');block.append(settingRow(s));result.append(block);count++;return}const b=el('button','row result'),label=el('div','label');label.append(el('strong','',s.title),el('small','',c.name+' · '+g.title));b.append(label);b.onclick=()=>openCategory(c.route,s.pref_name);result.append(b);count++}})));if(!count)result.append(el('p','hint','没有找到相关设置'))} query.onfocus=()=>{if(!searching)startSearch()};query.oninput=showResults;query.onkeydown=e=>{if(e.key==='Escape')closeSearch()};$('#close').onclick=closeSearch;$('#logged').onchange=render;$('#debug').onchange=render;$('#ime').onchange=()=>app.classList.toggle('ime',$('#ime').checked);$('#toggle-width').onclick=()=>{app.classList.toggle('phone');render()};$('#toggle-dark').onclick=()=>document.body.classList.toggle('dark'); window.addEventListener('message',e=>{if(e.source!==parent||e.data?.type!=='settings-layout')return;try{categories=SettingsSchema.validate(e.data.categories,SETTINGS_CATALOG);route=e.data.route||null;closeSearch();app.classList.toggle('phone',!e.data.wide);document.body.classList.toggle('dark',Boolean(e.data.dark));render()}catch(error){notify('预览未更新:'+error.message)}}); render(); + +let splitFraction=.3; +function sizePanes(){const usable=Math.max(0,app.clientWidth-12),left=Math.min(Math.max(usable*splitFraction,360),Math.max(360,usable-1));app.style.setProperty('--left-width',left+'px');$('#splitter').setAttribute('aria-valuenow',String(Math.round(left)));$('#splitter').setAttribute('aria-valuemin','360');$('#splitter').setAttribute('aria-valuemax',String(Math.floor(usable-1)))} +let splitDrag=null; +$('#splitter').onpointerdown=e=>{splitDrag={x:e.clientX,left:parseFloat(getComputedStyle(app).getPropertyValue('--left-width'))};e.currentTarget.setPointerCapture(e.pointerId);e.preventDefault()}; +$('#splitter').onpointermove=e=>{if(!splitDrag)return;const usable=app.clientWidth-12;splitFraction=Math.min(Math.max(splitDrag.left+e.clientX-splitDrag.x,360),usable-1)/usable;sizePanes()}; +$('#splitter').onpointerup=()=>splitDrag=null;$('#splitter').onpointercancel=()=>splitDrag=null; +$('#splitter').onkeydown=e=>{const usable=app.clientWidth-12,left=parseFloat(getComputedStyle(app).getPropertyValue('--left-width'));if(e.key==='Home')splitFraction=.3;else if(['ArrowLeft','ArrowRight'].includes(e.key))splitFraction=Math.min(Math.max(left+(e.key==='ArrowLeft'?-32:32),360),usable-1)/usable;else return;e.preventDefault();sizePanes()}; +new ResizeObserver(sizePanes).observe(app);sizePanes(); diff --git a/docs/settings-organizer/schema.js b/docs/settings-organizer/schema.js index 68fe4671..bff4b398 100644 --- a/docs/settings-organizer/schema.js +++ b/docs/settings-organizer/schema.js @@ -12,11 +12,11 @@ function validate(candidate,baseline){ const routes=new Set(),seen=new Set(),original=new Map(flatten(baseline).map(s=>[s.pref_name,s])); const text=(value,label)=>{if(typeof value!=='string'||!value.trim()||value.length>200)throw Error(label+'不能为空或超过200字');}; candidate.forEach(c=>{text(c.name,'分类名称');text(c.route,'分类路由');if(routes.has(c.route))throw Error('分类路由重复:'+c.route);routes.add(c.route); - if(!['settings','about'].includes(c.layout||'settings'))throw Error('未知页面布局'); + if(!['settings','about','root_footer'].includes(c.layout||'settings'))throw Error('未知页面布局'); if(c.icon!=null)throw Error('第一阶段不配置分类图标'); if(!Array.isArray(c.groups)||c.groups.length>100)throw Error('分组格式不正确'); c.groups.forEach(g=>{text(g.title,'分组标题');if(!Array.isArray(g.settings))throw Error('设置列表格式不正确'); - g.settings.forEach(s=>{text(s.title,'设置名称');if(s.subtitle!=null&&(typeof s.subtitle!=='string'||s.subtitle.length>1000))throw Error('说明格式不正确'); + g.settings.forEach(s=>{if(s.pref_name==='logout'&&c.layout!=='root_footer')throw Error('退出登录必须保留在一级页面底部');if(c.layout==='root_footer'&&(s.type!=='action'||s.storage!=='none'))throw Error('一级底部仅允许独立操作');text(s.title,'设置名称');if(s.subtitle!=null&&(typeof s.subtitle!=='string'||s.subtitle.length>1000))throw Error('说明格式不正确'); const existing=original.get(s.pref_name);if(!existing)throw Error('未知设置:'+s.pref_name);if(seen.has(s.pref_name))throw Error('设置重复:'+s.pref_name);seen.add(s.pref_name); if(stable(contract(s))!==stable(contract(existing)))throw Error('不能改变组件类型、存储键、默认值、选项或显隐条件:'+s.pref_name); }); diff --git a/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt index f901fb1f..71a52a37 100644 --- a/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt +++ b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt @@ -66,6 +66,13 @@ class JsonSettingsIndexCodecTest { assertEquals(original.categories, serialized.categories) } + @Test fun logoutCanOnlyBeImportedAsARootFooterAction() { + val raw = """{"pref_name":"logout","type":"action","storage":"none","visible_when":"logged_in","title":"退出登录","default":""}""" + assertTrue(codec.decode(json(raw)) is SettingsIndexImport.Invalid) + val footer = json(raw).replace("\"route\":\"settings/playback\"", "\"route\":\"settings/account\",\"layout\":\"root_footer\"") + assertTrue(codec.decode(footer) is SettingsIndexImport.Success) + } + @Test fun bundledIndexIsValid() { val file = java.io.File("../../app/src/main/assets/settings-index.json") val result = codec.decode(file.readText()) diff --git a/tools/test-settings-organizer.cjs b/tools/test-settings-organizer.cjs index 5dc1e0b9..2cf4bcd5 100644 --- a/tools/test-settings-organizer.cjs +++ b/tools/test-settings-organizer.cjs @@ -27,6 +27,7 @@ for (const mutate of [ value => value[0].groups[0].settings[0].visible_when = 'debug', value => value[0].route = value[1].route, value => value[0].icon = 'custom-icon', + value => value.find(c => c.layout === 'root_footer').layout = 'settings', ]) { const invalid = clone(baseline); mutate(invalid); From f703ed89f7f3d3f14b6b0b192cbef85c6846b570 Mon Sep 17 00:00:00 2001 From: AritxOnly Date: Sat, 14 Nov 2026 22:45:03 +0800 Subject: [PATCH 7/8] fix(settings): restore requested controls and embedded navigation --- AGENTS.md | 4 +- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 1 + app/src/main/assets/settings-index.json | 227 +++++++++--------- .../application/IndexedSettingBindings.kt | 10 + .../application/SettingsBrowserStateHolder.kt | 14 +- .../application/SettingsSplitPolicy.kt | 20 +- .../settings/page/SettingsBrowser.kt | 6 +- .../settings/page/SettingsBrowserPane.kt | 23 +- .../settings/page/SettingsDetailContent.kt | 25 ++ .../settings/page/SettingsPaneNavigation.kt | 36 +++ .../settings/page/SettingsSplitLayout.kt | 47 +--- .../RestoredSettingsBindingsTest.kt | 34 +++ .../SettingsBrowserStateHolderTest.kt | 26 +- .../application/SettingsSplitPolicyTest.kt | 31 +-- .../bdfun/core/domain/SettingsIndex.kt | 2 +- .../bdfun/core/domain/SettingsVisibility.kt | 26 ++ .../core/domain/SettingsVisibilityTest.kt | 26 ++ docs/SETTINGS_INDEX_DESIGN.md | 8 +- docs/settings-organizer/README.md | 2 +- docs/settings-organizer/catalog.js | 227 +++++++++--------- docs/settings-organizer/preview.css | 8 +- docs/settings-organizer/preview.html | 2 +- docs/settings-organizer/preview.js | 20 +- .../infra/storage/BundledSettingsIndexTest.kt | 20 ++ .../storage/JsonSettingsIndexCodecTest.kt | 2 +- 26 files changed, 483 insertions(+), 365 deletions(-) create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsDetailContent.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsPaneNavigation.kt create mode 100644 app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/RestoredSettingsBindingsTest.kt create mode 100644 core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsVisibility.kt create mode 100644 core/domain/src/test/kotlin/com/aritxonly/bdfun/core/domain/SettingsVisibilityTest.kt create mode 100644 infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/BundledSettingsIndexTest.kt diff --git a/AGENTS.md b/AGENTS.md index 08453edd..28df3df1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -588,10 +588,10 @@ HyperOS 中性色以 Deadliner 的 HyperOS 适配为准:surface 为页面层 ### 2026-11-14 SET-02 索引设置 UI 与编排器 -设置页已改为 presentation/settings/page 的索引渲染器:一级分类、横屏双栏与页面作用域搜索。全量现有设置使用61个组件描述(含关于身份区及PR52的3个可选扩展);不再沿用原型36项索引或旧卡片兜底。switch/dropdown/font_scale/cdn_dropdown/page_switches/mine_switch/action/developer/version保持原组件、条件与存储边界;select不得改成三级页面。第一阶段不显示分类图标,功能按钮只用Miuix Icons。编排器为 docs/settings-organizer/index.html,导出必须完整保留组件键/type/default/options/storage/visible_when/inverted/value_type,允许调整分类、分组、顺序与文案;App资产是唯一来源,运行 tools/sync-settings-catalog.py 同步。浮点组件须保留value_type=decimal,防止JSON导出1.0变1后改变类型。关于页参考Deadliner 3b561656的AboutSettings.kt,来源与MIT许可见docs/SETTINGS_DEADLINER_PROVENANCE.md;光晕只在Canvas读取动画State,卡片仅背景随320dp滚动由0.5变1。推荐扩展由启动层注入,未合入PR52时隐藏,不建立跨功能实现依赖。 +设置页已改为 presentation/settings/page 的索引渲染器:一级分类、横屏双栏与页面作用域搜索。全量现有设置使用63个组件描述(含关于身份区及PR52的3个可选扩展);不再沿用原型36项索引或旧卡片兜底。switch/dropdown/font_scale/cdn_dropdown/page_switches/mine_switch/action/developer/version保持原组件、条件与存储边界;select不得改成三级页面。第一阶段不显示分类图标,功能按钮只用Miuix Icons。编排器为 docs/settings-organizer/index.html,导出必须完整保留组件键/type/default/options/storage/visible_when/inverted/value_type,允许调整分类、分组、顺序与文案;App资产是唯一来源,运行 tools/sync-settings-catalog.py 同步。浮点组件须保留value_type=decimal,防止JSON导出1.0变1后改变类型。关于页参考Deadliner 3b561656的AboutSettings.kt,来源与MIT许可见docs/SETTINGS_DEADLINER_PROVENANCE.md;光晕只在Canvas读取动画State,卡片仅背景随320dp滚动由0.5变1。推荐扩展由启动层注入,未合入PR52时隐藏,不建立跨功能实现依赖。 SET-02外壳约束:返回键、TopAppBar、大标题与滚动联动必须复用原TeakActivityScaffold/TeakLargeTitle/TrackTeakPageScroll,不画新的设置顶栏,不关掉原顶栏后自己仿制。二级页搜索仅加入原工具栏actions槽。 ### 2026-11-14 SET-03 独立分栏与一级退出登录 -横屏设置必须覆盖窗口全宽,左右分别使用原TeakPageScaffold(竖屏仍由TeakActivityScaffold装配),不在一个受内容最大宽度约束的外壳内摆两列。每栏独立PageChrome/滚动/搜索/popup/backdrop,并按栏尺寸提供窗口上下文。默认分配3:7,左栏下限360dp;拖动分隔线改变比例,右栏取剩余空间。竖屏继续单壳。退出登录是layout=root_footer中的原动作,登录后在一级列表最底部独立卡片呈现,不生成账号二级页。 +横屏设置必须覆盖窗口全宽,左右分别使用原TeakPageScaffold(竖屏仍由TeakActivityScaffold装配),不在一个受内容最大宽度约束的外壳内摆两列。每栏独立PageChrome/滚动/搜索/popup/backdrop,并按栏尺寸提供窗口上下文。默认分配3:7,左栏下限360dp;不显示分隔线且不可拖动,右栏取剩余空间;一级页固定,右侧三级页只覆盖二级页,三级才显示右侧返回键。竖屏继续单壳。退出登录是layout=root_footer中的原动作,登录后在一级列表最底部独立卡片呈现,不生成账号二级页。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d8e03621..a48d8ca1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -138,6 +138,7 @@ dependencies { implementation(libs.compose.material3) implementation(libs.miuix.ui) + implementation(libs.miuix.nav) implementation(libs.miuix.blur) implementation(libs.miuix.icons) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6b757a4d..e320ca4e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,6 +63,7 @@ { closeSearch(); true } - state.value.route != null -> { mutable.update { SettingsBrowserState(splitFraction = it.splitFraction) }; true } + state.value.detail != null -> { mutable.update { it.copy(detail = null) }; true } + state.value.route != null -> { mutable.value = SettingsBrowserState(); true } else -> false } } @@ -46,7 +44,7 @@ internal fun settingsVisible(setting: Setting, values: Map "logged_in" -> loggedIn "debug" -> debug else -> if (condition.startsWith("extension:")) condition.removePrefix("extension:") in extensions - else (values[condition] as? SettingValue.Flag)?.value == true + else com.aritxonly.bdfun.core.domain.SettingsVisibility.matches(condition, values) } internal fun visibleSettingsIndex(index: SettingsIndex, values: Map, loggedIn: Boolean, debug: Boolean, extensions: Set = emptySet()) = diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicy.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicy.kt index e693de9d..9a4dce2c 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicy.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicy.kt @@ -1,23 +1,9 @@ package com.aritxonly.bdfun.feature.settings.application -/** 分隔条不计入3:7内容比例;优先保障左栏360dp,右栏始终使用剩余宽度。 */ +/** 固定3:7,无分隔线;窄横屏优先保障一级列表360dp。 */ internal object SettingsSplitPolicy { const val DefaultFraction = .3f const val MinimumLeftDp = 360f - const val DividerDp = 12f - - fun leftWidth(totalWidth: Float, fraction: Float = DefaultFraction): Float { - val available = (totalWidth - DividerDp).coerceAtLeast(0f) - val minimum = MinimumLeftDp.coerceAtMost(available) - val maximum = (available - 1f).coerceAtLeast(minimum) - val safeFraction = if (fraction.isFinite()) fraction.coerceIn(0f, 1f) else DefaultFraction - return (available * safeFraction).coerceIn(minimum, maximum) - } - - fun drag(totalWidth: Float, fraction: Float, deltaDp: Float): Float { - val available = totalWidth - DividerDp - if (available <= 0f || !deltaDp.isFinite()) return fraction - val target = leftWidth(totalWidth, fraction) + deltaDp - return leftWidth(totalWidth, target / available) / available - } + fun leftWidth(totalWidth: Float): Float = + (totalWidth * DefaultFraction).coerceAtLeast(MinimumLeftDp).coerceAtMost(totalWidth.coerceAtLeast(0f)) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt index 269d8efb..4317d948 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt @@ -32,7 +32,7 @@ internal fun SettingsBrowser(controller: IndexedSettingsStateHolder, runtime: Se } val session = SettingsBrowserSession(browser, navigation, selected, wide, categories, footers) if (wide) SettingsSplitLayout(session, settings.values, runtime, controller) - else SettingsBrowserPane(session, settings.values, runtime, controller, SettingsPane.Single, Modifier.fillMaxSize()) + else SettingsPaneNavigation(session, settings.values, runtime, controller, SettingsPane.Single, Modifier.fillMaxSize()) } @Composable @@ -40,10 +40,10 @@ internal fun rememberSettingsBrowser(): SettingsBrowserStateHolder { val saver = remember { Saver>(save = { val s = it.state.value - listOf(s.route.orEmpty(), s.searching.toString(), s.searchRoute.orEmpty(), s.query, s.splitFraction.toString()) + listOf(s.route.orEmpty(), s.searching.toString(), s.searchRoute.orEmpty(), s.query, s.detail.orEmpty()) }, restore = { SettingsBrowserStateHolder(SettingsBrowserState(it[0].ifEmpty { null }, it[1].toBoolean(), - it[2].ifEmpty { null }, it[3], splitFraction = it.getOrNull(4)?.toFloatOrNull() ?: SettingsSplitPolicy.DefaultFraction)) + it[2].ifEmpty { null }, it[3], detail = it.getOrNull(4)?.takeIf { name -> name in setOf("Help", "Changelog", "Credits", "Donation", "Logs") })) }) } return rememberSaveable(saver = saver) { SettingsBrowserStateHolder() } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt index b21f58a6..113649b1 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt @@ -47,29 +47,40 @@ internal fun SettingsBrowserPane(session: SettingsBrowserSession, values: Map + if (destination in settingsEmbeddedDestinations) session.browser.openDetail(destination.name) + else navigation.onAbout(destination) + }, navigation.onDeveloper, navigation.onNotify) + val localRuntime = SettingsPageRuntime(runtime.settings, runtime.cdn, runtime.cdnState, embedded, runtime.scope, + runtime.onPickWallpaper, runtime.mineLayout, runtime.extensions) + SettingsPaneScaffold(runtime, pane, title, back, detail != null, + search = if (detail == null && !categories && !searching && session.selected != null) ({ session.browser.search(session.selected.route) }) else null, + ) { + if (detail != null) SettingsDetailContent(detail, back) + else SettingsPaneBody(local, values, localRuntime, controller, categories) + } } } } @Composable private fun SettingsPaneScaffold(runtime: SettingsPageRuntime, pane: SettingsPane, title: String, - back: () -> Unit, search: (() -> Unit)?, content: @Composable () -> Unit) { + back: () -> Unit, detail: Boolean, search: (() -> Unit)?, content: @Composable () -> Unit) { if (pane == SettingsPane.Single) { TeakActivityScaffold(runtime.settings, back, options = ActivityPageOptions(title = title), slots = ActivityPageSlots(topBarActions = { if (search != null) SettingsCategorySearchButton(search) }), content = content) } else PageChromeScope { // 两栏共用主题,但外壳、滚动、弹层和玻璃采样各自持有,不能再套整页内容宽度限制。 - TeakPageScaffold(title = title, onBack = back, limitContentWidth = false, + TeakPageScaffold(title = title, onBack = back, showBackButton = pane == SettingsPane.Categories || detail, limitContentWidth = false, pageState = rememberTeakPageScrollState(), backgroundColor = MiuixTheme.colorScheme.surface, contentModifier = Modifier.background(MiuixTheme.colorScheme.surface).smartRefresh(runtime.settings.smartRefresh), topBarActions = { if (search != null) SettingsCategorySearchButton(search) }, diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsDetailContent.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsDetailContent.kt new file mode 100644 index 00000000..20aacad2 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsDetailContent.kt @@ -0,0 +1,25 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.runtime.Composable + +internal val settingsEmbeddedDestinations = setOf(SettingsDestination.Help, SettingsDestination.Changelog, + SettingsDestination.Credits, SettingsDestination.Donation) + +internal fun settingsDetailTitle(destination: SettingsDestination): String = when (destination) { + SettingsDestination.Help -> "帮助中心" + SettingsDestination.Changelog -> "更新日志" + SettingsDestination.Credits -> "引用" + SettingsDestination.Donation -> "捐赠" + else -> error("不是内嵌设置页面") +} + +@Composable +internal fun SettingsDetailContent(destination: SettingsDestination, onBack: () -> Unit) { + when (destination) { + SettingsDestination.Help -> HelpScreen() + SettingsDestination.Changelog -> ChangelogScreen(onBack) + SettingsDestination.Credits -> CreditsScreen() + SettingsDestination.Donation -> DonationScreen() + else -> error("不是内嵌设置页面") + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsPaneNavigation.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsPaneNavigation.kt new file mode 100644 index 00000000..b519e3a6 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsPaneNavigation.kt @@ -0,0 +1,36 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.runtime.* +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import com.aritxonly.bdfun.core.model.settings.SettingValue +import com.aritxonly.bdfun.feature.settings.application.IndexedSettingsStateHolder +import top.yukonga.miuix.kmp.nav.core.* +import top.yukonga.miuix.kmp.nav.transition.NavTransitions + +private data class SettingsPaneRoute(val category: String?, val detail: String? = null) : NavKey + +/** 保存由页面控制器负责;Miuix仅持有相同的瞬时栈,用原生转场渲染每层完整Scaffold。 */ +@Composable +internal fun SettingsPaneNavigation(session: SettingsBrowserSession, values: Map, + runtime: SettingsPageRuntime, controller: IndexedSettingsStateHolder, pane: SettingsPane, modifier: Modifier) { + val base = if (pane == SettingsPane.Single) null else session.selected?.route + val desired = buildList { + add(SettingsPaneRoute(base)) + if (pane == SettingsPane.Single && session.selected != null) add(SettingsPaneRoute(session.selected.route)) + session.navigation.detail?.let { add(SettingsPaneRoute(session.selected?.route, it)) } + } + val stack = remember { navBackStackOf(*desired.toTypedArray()) } + SideEffect { if (stack.toList() != desired) { stack.clear(); stack.addAll(desired) } } + NavDisplay(stack, modifier, onBack = { if (!session.browser.back()) runtime.navigation.onBack() }, + transition = if (runtime.settings.animationsEnabled) NavTransitions.MiuixDefault else NavTransitions.None, + effects = NavDisplayEffects(enableCornerClip = pane == SettingsPane.Single)) { + entry { key -> + val current = key == desired.last() + val page = session.copy(selected = session.categories.firstOrNull { it.route == key.category }, + navigation = session.navigation.copy(route = key.category, detail = key.detail, + searching = current && session.navigation.searching)) + SettingsBrowserPane(page, values, runtime, controller, pane, Modifier.fillMaxSize()) + } + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsSplitLayout.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsSplitLayout.kt index aad3ed9b..1563c875 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsSplitLayout.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsSplitLayout.kt @@ -1,22 +1,13 @@ package com.aritxonly.bdfun.ui -import androidx.compose.foundation.background -import androidx.compose.foundation.focusable -import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.input.key.* -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.* import androidx.compose.ui.unit.dp import com.aritxonly.bdfun.core.model.settings.SettingValue import com.aritxonly.bdfun.feature.settings.application.IndexedSettingsStateHolder import com.aritxonly.bdfun.feature.settings.application.SettingsSplitPolicy -import top.yukonga.miuix.kmp.theme.MiuixTheme @Composable internal fun SettingsSplitLayout(session: SettingsBrowserSession, values: Map, @@ -24,47 +15,13 @@ internal fun SettingsSplitLayout(session: SettingsBrowserSession, values: Map BoxWithConstraints(Modifier.fillMaxSize()) { val total = maxWidth.value - val fraction = session.navigation.splitFraction - val left = SettingsSplitPolicy.leftWidth(total, fraction) - val density = LocalDensity.current - val drag: (Float) -> Unit = { delta -> - session.browser.resize(total, delta) - } + val left = SettingsSplitPolicy.leftWidth(total) Row(Modifier.fillMaxSize()) { SettingsBrowserPane(session, values, runtime, controller, SettingsPane.Categories, Modifier.width(left.dp).fillMaxHeight().clipToBounds()) - SettingsSplitDivider(left / (total - SettingsSplitPolicy.DividerDp).coerceAtLeast(1f), drag, { session.browser.split(SettingsSplitPolicy.DefaultFraction) }, Modifier - .width(SettingsSplitPolicy.DividerDp.dp).fillMaxHeight() - .pointerInput(total, density.density) { - detectDragGestures { change, amount -> change.consume(); drag(amount.x / density.density) } - }) - SettingsBrowserPane(session, values, runtime, controller, SettingsPane.Details, + SettingsPaneNavigation(session, values, runtime, controller, SettingsPane.Details, Modifier.weight(1f).fillMaxHeight().clipToBounds()) } } } } - -@Composable -private fun SettingsSplitDivider(fraction: Float, drag: (Float) -> Unit, reset: () -> Unit, modifier: Modifier) { - // 分隔线有独立热区和键盘/无障碍操作,不夺取左右栏的纵向滚动。 - Box(modifier.semantics { - contentDescription = "调整设置分栏宽度" - stateDescription = "左栏${(fraction * 100).toInt()}%" - customActions = listOf( - CustomAccessibilityAction("增大左栏") { drag(32f); true }, - CustomAccessibilityAction("缩小左栏") { drag(-32f); true }, - CustomAccessibilityAction("恢复3比7") { reset(); true }, - ) - }.onKeyEvent { - if (it.type != KeyEventType.KeyDown) false else when (it.key) { - Key.DirectionLeft -> { drag(-32f); true } - Key.DirectionRight -> { drag(32f); true } - Key.MoveHome -> { reset(); true } - else -> false - } - }.focusable(), contentAlignment = Alignment.Center) { - Box(Modifier.width(1.dp).fillMaxHeight().background(MiuixTheme.colorScheme.outline.copy(alpha = .2f))) - Box(Modifier.width(4.dp).height(36.dp).background(MiuixTheme.colorScheme.onSurfaceVariantSummary.copy(alpha = .4f))) - } -} diff --git a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/RestoredSettingsBindingsTest.kt b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/RestoredSettingsBindingsTest.kt new file mode 100644 index 00000000..22ce03e1 --- /dev/null +++ b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/RestoredSettingsBindingsTest.kt @@ -0,0 +1,34 @@ +package com.aritxonly.bdfun.feature.settings.application + +import com.aritxonly.bdfun.core.model.settings.SettingValue +import kotlinx.coroutines.* +import org.junit.Assert.* +import org.junit.Test + +class RestoredSettingsBindingsTest { + @Test fun restoredRowsReadExistingPreferencesAndKeepExitFlagsConsistent() { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + try { + val prefs = MemorySettingsPreferences(mutableMapOf("home_card_style" to 1, "exit_video_control" to 0, + "disc_breath_enabled" to true, "disc_breath_cover_color" to true, "stop_on_video_page_exit" to true)) + val original = SettingsStateHolder.create(prefs, TestHistoryCodec, scope) + val bindings = indexedSettingBindings(original) + assertEquals(SettingValue.Number(1), bindings.getValue("home_card_style").read()) + assertEquals(SettingValue.Number(0), bindings.getValue("exit_video_control").read()) + assertEquals(SettingValue.Flag(true), bindings.getValue("disc_breath_enabled").read()) + assertEquals(SettingValue.Flag(true), bindings.getValue("disc_breath_cover_color").read()) + val continued = bindings.getValue("show_disc_after_video") + assertEquals(SettingValue.Flag(false), continued.read()) + continued.write(SettingValue.Flag(true)) + assertTrue(original.showDiscAfterVideo) + assertFalse(original.stopOnVideoPageExit) + continued.write(SettingValue.Flag(false)) + assertFalse(original.showDiscAfterVideo) + assertTrue(original.stopOnVideoPageExit) + bindings.getValue("exit_video_control").write(SettingValue.Number(1)) + assertEquals(1, prefs.values["exit_video_control"]) + bindings.getValue("home_card_style").write(SettingValue.Number(0)) + assertEquals(0, prefs.values["home_card_style"]) + } finally { scope.cancel() } + } +} diff --git a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt index bef75564..9ac5b082 100644 --- a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt +++ b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt @@ -64,23 +64,19 @@ class SettingsBrowserStateHolderTest { assertEquals(3, indexedOptionIndex(entry, SettingValue.Decimal(1.25f))) } - @Test fun dragUpdatesAccumulateBeforeTheNextComposition() { + @Test fun detailBackPreservesSelectedCategoryAndSearchClosesFirst() { val page = SettingsBrowserStateHolder() - repeat(4) { page.resize(1012f, 16f) } - assertEquals(424f, SettingsSplitPolicy.leftWidth(1012f, page.state.value.splitFraction), .001f) - } - - @Test fun resizingSurvivesPageAndSearchNavigation() { - val page = SettingsBrowserStateHolder() - page.split(.45f) + page.open("settings/about") + page.openDetail("Help") + page.search("settings/about") + assertTrue(page.back()) + assertEquals("Help", page.state.value.detail) + assertTrue(page.back()) + assertNull(page.state.value.detail) + assertEquals("settings/about", page.state.value.route) + page.openDetail("Credits") page.open("settings/player") - page.search("settings/player") - page.closeSearch() - assertEquals(.45f, page.state.value.splitFraction) - page.back() - assertEquals(.45f, page.state.value.splitFraction) - page.split(Float.NaN) - assertEquals(.45f, page.state.value.splitFraction) + assertNull(page.state.value.detail) } @Test fun restoredNavigationDoesNotNeedASecondLayoutState() { diff --git a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicyTest.kt b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicyTest.kt index beabb87e..dca4abec 100644 --- a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicyTest.kt +++ b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicyTest.kt @@ -5,30 +5,17 @@ import org.junit.Test class SettingsSplitPolicyTest { @Test fun defaultsToThreeToSevenWith360Minimum() { - assertEquals(480f, SettingsSplitPolicy.leftWidth(1612f), .001f) - assertEquals(360f, SettingsSplitPolicy.leftWidth(1012f), .001f) - assertEquals(360f, SettingsSplitPolicy.leftWidth(612f), .001f) + assertEquals(480f, SettingsSplitPolicy.leftWidth(1600f), .001f) + assertEquals(360f, SettingsSplitPolicy.leftWidth(1000f), .001f) + assertEquals(360f, SettingsSplitPolicy.leftWidth(600f), .001f) } - - @Test fun dividerTracksDragInsteadOfJumpingFromUnclampedRatio() { - val moved = SettingsSplitPolicy.drag(1012f, .3f, 100f) - assertEquals(460f, SettingsSplitPolicy.leftWidth(1012f, moved), .001f) - val minimum = SettingsSplitPolicy.drag(1012f, moved, -1000f) - assertEquals(360f, SettingsSplitPolicy.leftWidth(1012f, minimum), .001f) - } - - @Test fun rightPaneAlwaysGetsRemainingWidth() { - listOf(612f, 922f, 1612f).forEach { total -> - listOf(-1f, .3f, .6f, 2f).forEach { ratio -> - val left = SettingsSplitPolicy.leftWidth(total, ratio) - assertTrue(left >= 360f) - assertTrue(total - SettingsSplitPolicy.DividerDp - left >= 1f) - } + @Test fun fixedPanesUseWholeWindowWithoutDivider() { + listOf(600f, 922f, 1600f).forEach { total -> + val left = SettingsSplitPolicy.leftWidth(total) + assertTrue(left >= 360f) + assertTrue(total - left >= 240f) + assertEquals(total, left + (total - left), 0f) } - } - - @Test fun invalidFractionUsesDefaultAndTinyWindowsDoNotCrash() { - assertEquals(SettingsSplitPolicy.leftWidth(1612f), SettingsSplitPolicy.leftWidth(1612f, Float.NaN), 0f) assertEquals(0f, SettingsSplitPolicy.leftWidth(0f), 0f) } } diff --git a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt index 2de9c623..102e80aa 100644 --- a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt +++ b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt @@ -30,7 +30,7 @@ class SettingsIndex(val categories: List) { require(setting.prefName != "logout" || category.layout == "root_footer") { "退出登录必须位于一级页面底部" } require(setting.storage in setOf("settings", "mine_layout", "none")) require(setting.visibleWhen == null || setting.visibleWhen in setOf("debug", "logged_in") || - settings[setting.visibleWhen]?.default is SettingValue.Flag || + setting.visibleWhen?.let { SettingsVisibility.valid(it, settings) } == true || setting.type == "extension" && setting.visibleWhen == "extension:${setting.prefName}") { "未知显隐条件" } require(setting.valueType == null || setting.valueType == "decimal" && setting.default is SettingValue.Decimal) require(!setting.inverted || setting.default is SettingValue.Flag) diff --git a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsVisibility.kt b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsVisibility.kt new file mode 100644 index 00000000..752db45b --- /dev/null +++ b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsVisibility.kt @@ -0,0 +1,26 @@ +package com.aritxonly.bdfun.core.domain + +import com.aritxonly.bdfun.core.model.settings.Setting +import com.aritxonly.bdfun.core.model.settings.SettingValue + +/** 仅允许已声明的布尔键和整数选项比较;不执行任意表达式。 */ +object SettingsVisibility { + fun valid(condition: String, settings: Map): Boolean = condition.split("&&").all { term -> + val parts = term.split('=') + val setting = settings[parts[0]] + when (parts.size) { + 1 -> setting?.default is SettingValue.Flag + 2 -> parts[1].toIntOrNull()?.let { value -> setting?.options?.any { it.value == SettingValue.Number(value) } } == true + else -> false + } + } + + fun matches(condition: String, values: Map): Boolean = condition.split("&&").all { term -> + val parts = term.split('=') + when (parts.size) { + 1 -> (values[parts[0]] as? SettingValue.Flag)?.value == true + 2 -> parts[1].toIntOrNull()?.let { values[parts[0]] == SettingValue.Number(it) } == true + else -> false + } + } +} diff --git a/core/domain/src/test/kotlin/com/aritxonly/bdfun/core/domain/SettingsVisibilityTest.kt b/core/domain/src/test/kotlin/com/aritxonly/bdfun/core/domain/SettingsVisibilityTest.kt new file mode 100644 index 00000000..86a9ac01 --- /dev/null +++ b/core/domain/src/test/kotlin/com/aritxonly/bdfun/core/domain/SettingsVisibilityTest.kt @@ -0,0 +1,26 @@ +package com.aritxonly.bdfun.core.domain + +import com.aritxonly.bdfun.core.model.settings.* +import org.junit.Assert.* +import org.junit.Test + +class SettingsVisibilityTest { + private val toggle = Setting("continue_play", "switch", "继续播放", null, SettingValue.Flag(true)) + private val control = Setting("control", "dropdown", "控件", null, SettingValue.Number(1), + listOf(SettingOption("画中画", SettingValue.Number(0)), SettingOption("唱片", SettingValue.Number(1)))) + private val entries = listOf(toggle, control).associateBy { it.prefName } + @Test fun acceptsOnlyDeclaredBooleanKeysAndIntegerOptions() { + assertTrue(SettingsVisibility.valid("continue_play&&control=1", entries)) + listOf("missing", "control", "control=2", "continue_play=1", "control=1=1", "").forEach { + assertFalse(it, SettingsVisibility.valid(it, entries)) + } + } + @Test fun discOnlyVisibleWhenPlaybackIsEnabledAndDiscSelected() { + fun matches(enabled: Boolean, value: Int) = SettingsVisibility.matches("continue_play&&control=1", + mapOf("continue_play" to SettingValue.Flag(enabled), "control" to SettingValue.Number(value))) + assertTrue(matches(true, 1)) + assertFalse(matches(true, 0)) + assertFalse(matches(false, 1)) + assertFalse(SettingsVisibility.matches("continue_play&&control=1", emptyMap())) + } +} diff --git a/docs/SETTINGS_INDEX_DESIGN.md b/docs/SETTINGS_INDEX_DESIGN.md index 8ebf429a..ae8eda7c 100644 --- a/docs/SETTINGS_INDEX_DESIGN.md +++ b/docs/SETTINGS_INDEX_DESIGN.md @@ -2,9 +2,9 @@ ## 运行时结构 -App 的唯一布局来源是 `app/src/main/assets/settings-index.json`。本轮以原生设置页完整清单重新建立索引,替换原型的36项示例;61个组件描述覆盖原全部设置、操作、开发者行,另含关于身份区与PR52的三个可选推荐组件。page_switches仍展开原来的六个页面开关。没有旧设置页兜底入口,原四个手写卡片文件已删除。 +App 的唯一布局来源是 `app/src/main/assets/settings-index.json`。本轮以原生设置页完整清单重新建立索引,替换原型的36项示例;63个组件描述覆盖原全部设置、操作、开发者行,另含关于身份区与PR52的三个可选推荐组件。page_switches仍展开原来的六个页面开关。没有旧设置页兜底入口,原四个手写卡片文件已删除。 -竖屏为分类列表与二级页;横屏且宽度至少600dp使用覆盖窗口全宽的两个独立Scaffold,取消外层内容最大宽度限制。左右栏分别拥有原顶栏、返回、滚动状态、popupHost与backdrop,按各自尺寸提供窗口上下文;默认3:7(不含12dp分隔线),左栏至少360dp,右栏使用剩余空间。分隔线可拖动,也支持左右键调整和Home恢复比例。导航和搜索由页面作用域SettingsBrowserStateHolder持有,旋转不另建选择状态。返回先关闭搜索再回分类。搜索匹配名称/说明/分组/分类/键,排除当前不满足条件的设置,结果跳转并滚动定位。页面外壳、返回键、TopAppBar和大标题/滚动/材质联动继续使用原来的TeakActivityScaffold/TeakPageScaffold与TeakLargeTitle/TrackTeakPageScroll,不另画顶栏;二级页只在原工具栏增加搜索按钮。一级底部柔光胶囊展开、分裂关闭按钮并跟随IME抬升;搜索栏/关闭按钮高度48dp,底部间距8dp,保留系统导航/IME避让;动画复用主页搜索参数。分类内从右上搜索。横屏全局搜索由左栏持有,分类搜索由右栏持有,不再跨栏共用搜索浮层。 +竖屏为分类列表与二级页;横屏且宽度至少600dp使用覆盖窗口全宽的两个独立Scaffold,取消外层内容最大宽度限制。左右栏分别拥有原顶栏、返回、滚动状态、popupHost与backdrop,按各自尺寸提供窗口上下文;默认3:7,左栏至少360dp,右栏使用剩余空间。无分隔线且不可拖动;一级固定,三级仅覆盖右栏二级并显示右侧返回键。竖屏采用Miuix NavDisplay默认转场。导航和搜索由页面作用域SettingsBrowserStateHolder持有,旋转不另建选择状态。返回先关闭搜索再回分类。搜索匹配名称/说明/分组/分类/键,排除当前不满足条件的设置,结果跳转并滚动定位。页面外壳、返回键、TopAppBar和大标题/滚动/材质联动继续使用原来的TeakActivityScaffold/TeakPageScaffold与TeakLargeTitle/TrackTeakPageScroll,不另画顶栏;二级页只在原工具栏增加搜索按钮。一级底部柔光胶囊展开、分裂关闭按钮并跟随IME抬升;搜索栏/关闭按钮高度48dp,底部间距8dp,保留系统导航/IME避让;动画复用主页搜索参数。分类内从右上搜索。横屏全局搜索由左栏持有,分类搜索由右栏持有,不再跨栏共用搜索浮层。 普通设置仍使用现有Teak/Miuix行:dropdown仍是玻璃dropdown(包括原面板宽度、开闭行为),字号仍用原SliderPreference和吸附/松手写入;主题/色彩的260ms延迟、动画反向开关、CDN动态选项、壁纸系统选择、开发者头像、退出登录的颜色与登录/debug显隐保持。第一阶段不展示分类图标;返回/搜索/关闭仅使用Miuix Icons。 @@ -42,3 +42,7 @@ layout=about使用Deadliner实际关于页的身份区、三层浮动光晕与32 纯JVM测试覆盖旧键写入、旧值兼容、非法更新、页面返回/搜索/显隐以及JSON完整索引/浮点往返。Node测试覆盖编排器合同、目录同步与失败隔离;Chrome实测重命名、撤销、跨组移动、拖动排序、非法导入、导出和实时预览搜索。模拟器检查分类、dropdown、全量多行、搜索/IME、横屏与关于页。 继续更新PR55。release使用专门集成分支合入PR52和PR54,不把它们的业务提交带入设置PR。完整播放器、登录后的真实账号服务及所有设备/字体组合的长期回归仍由装机测试完成。 + +用户调整索引保留分类/分组/排序;删除补偿延迟、查看开始页、直播间预览。补回首页卡片样式与播放器末尾后台播放组,继续使用原存储键和dropdown;唱片灯及封面取色按控件选择显隐。visible_when支持&&和整数选项比较(key=value),由领域策略验证。 + +SET-04验证:SettingsActivity显式adjustResize,避免系统自动pan与Compose imePadding叠加;输入法仅由底部Dock消费一次。新增同版本miuix-nav依赖以采用NavTransitions.MiuixDefault,避免另写一套动画;不引入序列化插件,保存恢复仍归SettingsBrowserStateHolder。内页复用帮助/更新日志/捐赠/引用原内容,外链和日志导出继续交给原宿主。 diff --git a/docs/settings-organizer/README.md b/docs/settings-organizer/README.md index aa36c6b3..d855f7ff 100644 --- a/docs/settings-organizer/README.md +++ b/docs/settings-organizer/README.md @@ -6,4 +6,4 @@ 预览仅用于检查组织结构,不会修改手机偏好或执行退出登录、打开外链等真实动作。手机界面使用现有 Miuix/Teak 组件:select 仍为 dropdown,字号仍为滑条,返回键和 TopAppBar 沿用原壳。 -导出的 `settings-index.json` 替换仓库 `app/src/main/assets/settings-index.json` 后重新构建生效。再运行 `python3 tools/sync-settings-catalog.py` 同步此工具的目录数据。请保留完整组件清单和原组件/存储/默认值/选项/显隐合同;第一阶段不配置分类图标。退出登录固定为一级页底部独立操作,不能移到二级页;横屏预览是独立双栏,左栏至少360px、默认3:7,可拖动分隔线或用方向键调整。推荐扩展在合入 PR52 的构建中生效。 +导出的 `settings-index.json` 替换仓库 `app/src/main/assets/settings-index.json` 后重新构建生效。再运行 `python3 tools/sync-settings-catalog.py` 同步此工具的目录数据。请保留完整组件清单和原组件/存储/默认值/选项/显隐合同;第一阶段不配置分类图标。退出登录固定为一级页底部独立操作,不能移到二级页;横屏预览是独立双栏,左栏至少360px、默认3:7,无分隔线且不可拖动。推荐扩展在合入 PR52 的构建中生效。 diff --git a/docs/settings-organizer/catalog.js b/docs/settings-organizer/catalog.js index 921af8c2..7f754b19 100644 --- a/docs/settings-organizer/catalog.js +++ b/docs/settings-organizer/catalog.js @@ -94,6 +94,13 @@ window.SETTINGS_CATALOG = [ "title": "抬高底栏", "default": false, "subtitle": "解决未开启安卓导航条时底栏位置过低的问题" + }, + { + "pref_name": "font_scale", + "type": "font_scale", + "title": "全局字号", + "default": 1, + "value_type": "decimal" } ] }, @@ -141,7 +148,7 @@ window.SETTINGS_CATALOG = [ ] }, { - "title": "壁纸与字号", + "title": "个性化", "settings": [ { "pref_name": "wallpaper_pick", @@ -156,13 +163,6 @@ window.SETTINGS_CATALOG = [ "title": "清除开屏壁纸", "storage": "none", "default": "" - }, - { - "pref_name": "font_scale", - "type": "font_scale", - "title": "全局字号", - "default": 1.0, - "value_type": "decimal" } ] } @@ -173,7 +173,7 @@ window.SETTINGS_CATALOG = [ "route": "settings/player", "groups": [ { - "title": "播放与手势", + "title": "界面与手势", "settings": [ { "pref_name": "show_viewer_count", @@ -182,9 +182,15 @@ window.SETTINGS_CATALOG = [ "default": true }, { - "pref_name": "resume_playback", + "pref_name": "show_fullscreen_actions", "type": "switch", - "title": "播放进度记忆", + "title": "全屏显示互动按钮", + "default": true + }, + { + "pref_name": "show_fullscreen_time", + "type": "switch", + "title": "全屏显示时间", "default": true }, { @@ -209,7 +215,7 @@ window.SETTINGS_CATALOG = [ "pref_name": "boost_default_speed", "type": "dropdown", "title": "长按默认倍率", - "default": 3.0, + "default": 3, "options": [ { "title": "1.5×", @@ -217,7 +223,7 @@ window.SETTINGS_CATALOG = [ }, { "title": "2×", - "value": 2.0 + "value": 2 }, { "title": "2.5×", @@ -225,15 +231,15 @@ window.SETTINGS_CATALOG = [ }, { "title": "3×", - "value": 3.0 + "value": 3 }, { "title": "4×", - "value": 4.0 + "value": 4 }, { "title": "5×", - "value": 5.0 + "value": 5 } ], "value_type": "decimal" @@ -241,8 +247,8 @@ window.SETTINGS_CATALOG = [ { "pref_name": "default_playback_speed", "type": "dropdown", - "title": "默认播放倍速", - "default": 1.0, + "title": "默认播放倍率", + "default": 1, "options": [ { "title": "0.5×", @@ -254,7 +260,7 @@ window.SETTINGS_CATALOG = [ }, { "title": "1×", - "value": 1.0 + "value": 1 }, { "title": "1.25×", @@ -266,7 +272,7 @@ window.SETTINGS_CATALOG = [ }, { "title": "2×", - "value": 2.0 + "value": 2 }, { "title": "2.5×", @@ -274,27 +280,10 @@ window.SETTINGS_CATALOG = [ }, { "title": "3×", - "value": 3.0 + "value": 3 } ], "value_type": "decimal" - } - ] - }, - { - "title": "全屏控件", - "settings": [ - { - "pref_name": "show_fullscreen_time", - "type": "switch", - "title": "全屏显示时间", - "default": true - }, - { - "pref_name": "show_fullscreen_actions", - "type": "switch", - "title": "全屏显示互动按钮", - "default": true }, { "pref_name": "progress_bar_mode", @@ -319,12 +308,24 @@ window.SETTINGS_CATALOG = [ "value": 3 } ] + }, + { + "pref_name": "auto_rotate", + "type": "switch", + "title": "自动旋转", + "default": true } ] }, { - "title": "播放源与解码", + "title": "播放与解码", "settings": [ + { + "pref_name": "auto_play", + "type": "switch", + "title": "进入视频自动播放", + "default": true + }, { "pref_name": "quality", "type": "dropdown", @@ -357,6 +358,12 @@ window.SETTINGS_CATALOG = [ } ] }, + { + "pref_name": "resume_playback", + "type": "switch", + "title": "播放进度记忆", + "default": true + }, { "pref_name": "decoder_mode", "type": "dropdown", @@ -416,17 +423,28 @@ window.SETTINGS_CATALOG = [ "title": "CDN 设置", "default": "auto", "subtitle": "如发现播放问题,请选择原地址" + } + ] + }, + { + "title": "弹幕", + "settings": [ + { + "pref_name": "sponsor_skip", + "type": "switch", + "title": "空降助手", + "default": true }, { - "pref_name": "auto_rotate", + "pref_name": "hot_danmaku", "type": "switch", - "title": "自动旋转", + "title": "弹幕计数", "default": true }, { - "pref_name": "auto_play", + "pref_name": "dm_low_refresh", "type": "switch", - "title": "进入视频自动播放", + "title": "降低弹幕刷新率", "default": true } ] @@ -446,39 +464,48 @@ window.SETTINGS_CATALOG = [ "title": "蓝牙耳机延迟补偿", "default": false, "subtitle": "检测到蓝牙耳机设备时自动校准播放延迟" + } + ] + }, + { + "title": "后台播放", + "settings": [ + { + "pref_name": "show_disc_after_video", + "type": "switch", + "title": "退出视频后继续播放", + "default": true }, { - "pref_name": "bt_latency_ms", + "pref_name": "exit_video_control", "type": "dropdown", - "title": "补偿延迟", - "default": 100, + "title": "播放控件", + "default": 1, "options": [ { - "title": "50 ms", - "value": 50 - }, - { - "title": "100 ms", - "value": 100 - }, - { - "title": "150 ms", - "value": 150 - }, - { - "title": "200 ms", - "value": 200 - }, - { - "title": "250 ms", - "value": 250 + "title": "画中画", + "value": 0 }, { - "title": "300 ms", - "value": 300 + "title": "唱片", + "value": 1 } ], - "visible_when": "bt_latency_enabled" + "visible_when": "show_disc_after_video" + }, + { + "pref_name": "disc_breath_enabled", + "type": "switch", + "title": "唱片呼吸灯", + "default": false, + "visible_when": "show_disc_after_video&&exit_video_control=1" + }, + { + "pref_name": "disc_breath_cover_color", + "type": "switch", + "title": "呼吸灯颜色跟随封面", + "default": false, + "visible_when": "show_disc_after_video&&exit_video_control=1&&disc_breath_enabled" } ] } @@ -504,29 +531,6 @@ window.SETTINGS_CATALOG = [ "default": true } ] - }, - { - "title": "弹幕", - "settings": [ - { - "pref_name": "sponsor_skip", - "type": "switch", - "title": "空降助手", - "default": true - }, - { - "pref_name": "hot_danmaku", - "type": "switch", - "title": "弹幕计数", - "default": true - }, - { - "pref_name": "dm_low_refresh", - "type": "switch", - "title": "降低弹幕刷新率", - "default": true - } - ] } ] }, @@ -537,6 +541,22 @@ window.SETTINGS_CATALOG = [ { "title": "首页推荐", "settings": [ + { + "pref_name": "home_card_style", + "type": "dropdown", + "title": "首页卡片样式", + "default": 0, + "options": [ + { + "title": "双排小卡", + "value": 0 + }, + { + "title": "大卡", + "value": 1 + } + ] + }, { "pref_name": "recommendation_toggle", "type": "extension", @@ -615,12 +635,6 @@ window.SETTINGS_CATALOG = [ { "title": "入口与预览", "settings": [ - { - "pref_name": "sent_history_entry", - "type": "switch", - "title": "显示「我的评论与弹幕」入口", - "default": true - }, { "pref_name": "history", "type": "mine_switch", @@ -641,6 +655,12 @@ window.SETTINGS_CATALOG = [ "title": "展开稍后再看", "storage": "mine_layout", "default": false + }, + { + "pref_name": "sent_history_entry", + "type": "switch", + "title": "显示「我的评论与弹幕」入口", + "default": true } ] } @@ -712,13 +732,6 @@ window.SETTINGS_CATALOG = [ "storage": "none", "default": "" }, - { - "pref_name": "onboarding", - "type": "action", - "title": "查看开始页", - "storage": "none", - "default": "" - }, { "pref_name": "donation", "type": "action", @@ -745,14 +758,6 @@ window.SETTINGS_CATALOG = [ { "title": "调试", "settings": [ - { - "pref_name": "live_preview", - "type": "action", - "title": "直播间(预览)", - "storage": "none", - "default": "", - "visible_when": "debug" - }, { "pref_name": "logs", "type": "action", diff --git a/docs/settings-organizer/preview.css b/docs/settings-organizer/preview.css index b20435cd..e0a18a72 100644 --- a/docs/settings-organizer/preview.css +++ b/docs/settings-organizer/preview.css @@ -6,14 +6,10 @@ /* 两个页面外壳占满预览窗口,分隔线只调整左右内容比例。 */ #app:not(.phone) aside{width:var(--left-width,360px);border:0} -#splitter{flex:0 0 12px;cursor:col-resize;touch-action:none;position:relative;background:var(--page)} -#splitter:before{content:"";position:absolute;top:0;bottom:0;left:5px;width:1px;background:#8883} -#splitter:after{content:"";position:absolute;left:4px;top:calc(50% - 18px);height:36px;width:4px;border-radius:3px;background:#8886} -#app.phone #splitter{display:none} #app:not(.phone) #dock{width:calc(var(--left-width,360px) - 32px);left:16px} #app:not(.phone) #results{left:0;width:var(--left-width,360px);right:auto} -#app:not(.phone).searching.scoped #results{left:calc(var(--left-width,360px) + 12px);width:auto;right:0} -#app:not(.phone).searching.scoped #dock{left:calc(var(--left-width,360px) + 28px);width:calc(100% - var(--left-width,360px) - 44px)} +#app:not(.phone).searching.scoped #results{left:var(--left-width,360px);width:auto;right:0} +#app:not(.phone).searching.scoped #dock{left:calc(var(--left-width,360px) + 16px);width:calc(100% - var(--left-width,360px) - 32px)} #app.searching.global #content{opacity:1;transform:none;pointer-events:auto} .root-footer{margin-top:24px} #app:not(.phone) section{clip-path:inset(0);isolation:isolate} diff --git a/docs/settings-organizer/preview.html b/docs/settings-organizer/preview.html index ba3e862a..e80ac1c2 100644 --- a/docs/settings-organizer/preview.html +++ b/docs/settings-organizer/preview.html @@ -1 +1 @@ -bdFun 设置预览
bdFun / 设置预览打开编排器
系统键盘区域(预览占位)
+bdFun 设置预览
bdFun / 设置预览打开编排器
系统键盘区域(预览占位)
diff --git a/docs/settings-organizer/preview.js b/docs/settings-organizer/preview.js index f3d3c544..42db0d15 100644 --- a/docs/settings-organizer/preview.js +++ b/docs/settings-organizer/preview.js @@ -1,16 +1,16 @@ 'use strict'; -let categories=SettingsSchema.clone(SETTINGS_CATALOG),route=null,searchScope=null,searching=false; +let categories=SettingsSchema.clone(SETTINGS_CATALOG),route=null,detail=null,searchScope=null,searching=false; const values=Object.fromEntries(SettingsSchema.flatten(categories).map(s=>[s.pref_name,s.default])); const $=s=>document.querySelector(s),app=$('#app'),query=$('#query'),el=(tag,cls,text)=>{const e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e}; if(new URLSearchParams(location.search).has('embedded'))document.body.classList.add('embedded'); app.classList.toggle('phone',innerWidth<650); -function visible(s){return !s.visible_when||s.visible_when.startsWith('extension:')||s.visible_when==='logged_in'&&$('#logged').checked||s.visible_when==='debug'&&$('#debug').checked||values[s.visible_when]===true} +function visible(s){return !s.visible_when||s.visible_when.startsWith('extension:')||s.visible_when==='logged_in'&&$('#logged').checked||s.visible_when==='debug'&&$('#debug').checked||s.visible_when.split('&&').every(term=>{const [key,expected]=term.split('=');return expected===undefined?values[key]===true:values[key]===Number(expected)})} function available(){return categories.map(c=>({...c,groups:c.groups.map(g=>({...g,settings:g.settings.filter(visible)})).filter(g=>g.settings.length)})).filter(c=>c.groups.length)} function notify(text){const toast=$('#toast');toast.textContent=text;toast.style.display='block';clearTimeout(notify.timer);notify.timer=setTimeout(()=>toast.style.display='none',1900)} function header(title,back,search){const fragment=document.createDocumentFragment(),chrome=el('div','chrome');if(back){const b=el('button','','返回');b.onclick=back;chrome.append(b)}const small=el('span','small-title',title);chrome.append(small);if(search){const b=el('button','','搜索');b.onclick=search;chrome.append(b)}fragment.append(chrome,el('h1','large-title',title));return fragment} function closeSearch(){searching=false;query.value='';query.blur();app.classList.remove('searching','global','scoped')} -function openCategory(next,key){route=next;closeSearch();render();if(key){const target=document.getElementById('pref-'+key);target?.scrollIntoView({block:'center',behavior:'smooth'});target?.classList.add('spotlight')}} +function openCategory(next,key){route=next;detail=null;closeSearch();render();if(key){const target=document.getElementById('pref-'+key);target?.scrollIntoView({block:'center',behavior:'smooth'});target?.classList.add('spotlight')}} function startSearch(scope=null){searchScope=scope;searching=true;app.classList.add('searching');app.classList.toggle('global',!scope);app.classList.toggle('scoped',!!scope);query.placeholder=scope?'搜索当前分类':'搜索全部设置';query.focus();showResults()} function identity(setting){const box=el('div','identity'),img=el('img');img.src='assets/app.png';img.alt='';box.append(img,el('strong','',setting.title),el('small','','vpreview-5.0'));return box} function settingRow(s){if(s.pref_name==='recommendation_error')return document.createDocumentFragment();if(s.type==='app_identity')return identity(s);const row=el('div','row');row.id='pref-'+s.pref_name;const label=el('div','label');label.append(el('strong','',s.title));if(s.subtitle)label.append(el('small','',s.subtitle)); @@ -20,22 +20,16 @@ function settingRow(s){if(s.pref_name==='recommendation_error')return document.c else if(s.type==='font_scale'){row.classList.add('slider-row');const line=el('div','slider-line'),input=el('input'),value=el('span','',Math.trunc(values[s.pref_name]*100)+'%');input.type='range';input.min=.8;input.max=1.2;input.step=.01;input.value=values[s.pref_name];input.ariaLabel=s.title;input.oninput=()=>{let n=Number(input.value);if(Math.abs(n-1)<.025)n=1;values[s.pref_name]=n;value.textContent=Math.trunc(n*100)+'%'};line.append(input,value);row.append(line)} else if(s.type==='page_switches'){const fragment=document.createDocumentFragment();['首页','动态页','个人空间','收藏 / 历史 / 稍后再看','消息页','消息详情页'].forEach((title,i)=>{const key=['home','dynamic','space','library','message','message_detail'][i],part=el('div','row'),l=el('div','label'),b=el('button','switch');l.append(el('strong','',title));b.setAttribute('role','switch');b.ariaLabel=title;b.setAttribute('aria-checked',String(values[s.pref_name].includes(key)));b.onclick=()=>{const current=values[s.pref_name];values[s.pref_name]=current.includes(key)?current.filter(v=>v!==key):[...current,key];render()};part.append(l,b);fragment.append(part)});return fragment} else if(s.type==='version')row.append(el('span','trailing','preview-5.0')); - else{const tail=s.pref_name==='wallpaper_pick'?'未设置':s.pref_name==='discussion'?'Telegram 群':['logs','live_preview'].includes(s.pref_name)?'调试':'';if(tail)row.append(el('span','trailing',tail));row.append(el('span','chevron','›'));row.tabIndex=0;row.setAttribute('role','button');row.onclick=()=>notify('预览模式:'+s.title+',不执行外部操作');row.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();row.click()}};if(s.pref_name==='logout')label.style.color='#e5484d'} + else{const tail=s.pref_name==='wallpaper_pick'?'未设置':s.pref_name==='discussion'?'Telegram 群':['logs','live_preview'].includes(s.pref_name)?'调试':'';if(tail)row.append(el('span','trailing',tail));row.append(el('span','chevron','›'));row.tabIndex=0;row.setAttribute('role','button');row.onclick=()=>{if(['help','changelog','donation','credits'].includes(s.pref_name)){detail=s;render()}else notify('预览模式:'+s.title+',不执行外部操作')};row.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();row.click()}};if(s.pref_name==='logout')label.style.color='#e5484d'} return row; } -function render(){app.style.setProperty('--blue',values.color_style===1?'#3482ff':'#ff6699');const all=available(),list=all.filter(c=>c.layout!=='root_footer'),footers=all.filter(c=>c.layout==='root_footer'),phone=app.classList.contains('phone'),selected=list.find(c=>c.route===route)||(!phone?list[0]:null);app.classList.toggle('detail',!!selected);const nav=$('#categories');nav.replaceChildren(header('设置',()=>notify('预览模式'),null));const card=el('div','card');list.forEach(c=>{const b=el('button','row category-row'+(selected?.route===c.route?' selected':''));b.append(el('span','label',c.name),el('span','chevron','›'));b.onclick=()=>openCategory(c.route);card.append(b)});nav.append(card);footers.forEach(c=>{const footer=el('div','card root-footer');c.groups.flatMap(g=>g.settings).forEach(s=>footer.append(settingRow(s)));nav.append(footer)});const content=$('#content');content.replaceChildren();content.classList.toggle('about',selected?.layout==='about');if(selected){content.append(header(selected.name,()=>{route=null;render()},()=>startSearch(selected.route)));selected.groups.forEach(g=>{if(g.settings.every(s=>s.type==='app_identity')){g.settings.forEach(s=>content.append(identity(s)));return}content.append(el('h3','',g.title));const group=el('div','card');g.settings.forEach(s=>group.append(settingRow(s)));content.append(group)});content.onscroll=()=>{if(selected.layout!=='about')return;const p=Math.min(1,content.scrollTop/320);content.style.setProperty('--glow-alpha',1-p);content.querySelectorAll('.card').forEach(card=>card.style.opacity=.5+p*.5)};if(selected.layout==='about')content.onscroll()} +function render(){app.style.setProperty('--blue',values.color_style===1?'#3482ff':'#ff6699');const all=available(),list=all.filter(c=>c.layout!=='root_footer'),footers=all.filter(c=>c.layout==='root_footer'),phone=app.classList.contains('phone'),selected=list.find(c=>c.route===route)||(!phone?list[0]:null);app.classList.toggle('detail',!!selected);const nav=$('#categories');nav.replaceChildren(header('设置',()=>notify('预览模式'),null));const card=el('div','card');list.forEach(c=>{const b=el('button','row category-row'+(selected?.route===c.route?' selected':''));b.append(el('span','label',c.name),el('span','chevron','›'));b.onclick=()=>openCategory(c.route);card.append(b)});nav.append(card);footers.forEach(c=>{const footer=el('div','card root-footer');c.groups.flatMap(g=>g.settings).forEach(s=>footer.append(settingRow(s)));nav.append(footer)});const content=$('#content');content.replaceChildren();content.classList.toggle('about',!detail&&selected?.layout==='about');if(selected&&detail){content.append(header(detail.title,()=>{detail=null;render()},null));const panel=el('div','card detail-preview');panel.append(el('p','hint','此处复用应用内的'+detail.title+'页面内容,返回后仍在'+selected.name+'。'));content.append(panel)}else if(selected){content.append(header(selected.name,phone?()=>{route=null;render()}:null,()=>startSearch(selected.route)));selected.groups.forEach(g=>{if(g.settings.every(s=>s.type==='app_identity')){g.settings.forEach(s=>content.append(identity(s)));return}content.append(el('h3','',g.title));const group=el('div','card');g.settings.forEach(s=>group.append(settingRow(s)));content.append(group)});content.onscroll=()=>{if(selected.layout!=='about')return;const p=Math.min(1,content.scrollTop/320);content.style.setProperty('--glow-alpha',1-p);content.querySelectorAll('.card').forEach(card=>card.style.opacity=.5+p*.5)};if(selected.layout==='about')content.onscroll()} if(searching)showResults(); } function showResults(){const result=$('#results');result.replaceChildren(header(searchScope?'搜索当前分类':'搜索设置',closeSearch,null));const words=query.value.trim().toLowerCase().split(/\s+/).filter(Boolean);if(!words.length){result.append(el('p','hint','搜索设置名称、说明或关键词'));return}let count=0;available().filter(c=>!searchScope||c.route===searchScope).forEach(c=>c.groups.forEach(g=>g.settings.forEach(s=>{const text=[c.name,g.title,s.title,s.subtitle||'',s.pref_name].join(' ').toLowerCase();if(words.every(w=>text.includes(w))){if(c.layout==='root_footer'){const block=el('div','card result');block.append(settingRow(s));result.append(block);count++;return}const b=el('button','row result'),label=el('div','label');label.append(el('strong','',s.title),el('small','',c.name+' · '+g.title));b.append(label);b.onclick=()=>openCategory(c.route,s.pref_name);result.append(b);count++}})));if(!count)result.append(el('p','hint','没有找到相关设置'))} query.onfocus=()=>{if(!searching)startSearch()};query.oninput=showResults;query.onkeydown=e=>{if(e.key==='Escape')closeSearch()};$('#close').onclick=closeSearch;$('#logged').onchange=render;$('#debug').onchange=render;$('#ime').onchange=()=>app.classList.toggle('ime',$('#ime').checked);$('#toggle-width').onclick=()=>{app.classList.toggle('phone');render()};$('#toggle-dark').onclick=()=>document.body.classList.toggle('dark'); -window.addEventListener('message',e=>{if(e.source!==parent||e.data?.type!=='settings-layout')return;try{categories=SettingsSchema.validate(e.data.categories,SETTINGS_CATALOG);route=e.data.route||null;closeSearch();app.classList.toggle('phone',!e.data.wide);document.body.classList.toggle('dark',Boolean(e.data.dark));render()}catch(error){notify('预览未更新:'+error.message)}}); +window.addEventListener('message',e=>{if(e.source!==parent||e.data?.type!=='settings-layout')return;try{categories=SettingsSchema.validate(e.data.categories,SETTINGS_CATALOG);detail=null;route=e.data.route||null;closeSearch();app.classList.toggle('phone',!e.data.wide);document.body.classList.toggle('dark',Boolean(e.data.dark));render()}catch(error){notify('预览未更新:'+error.message)}}); render(); -let splitFraction=.3; -function sizePanes(){const usable=Math.max(0,app.clientWidth-12),left=Math.min(Math.max(usable*splitFraction,360),Math.max(360,usable-1));app.style.setProperty('--left-width',left+'px');$('#splitter').setAttribute('aria-valuenow',String(Math.round(left)));$('#splitter').setAttribute('aria-valuemin','360');$('#splitter').setAttribute('aria-valuemax',String(Math.floor(usable-1)))} -let splitDrag=null; -$('#splitter').onpointerdown=e=>{splitDrag={x:e.clientX,left:parseFloat(getComputedStyle(app).getPropertyValue('--left-width'))};e.currentTarget.setPointerCapture(e.pointerId);e.preventDefault()}; -$('#splitter').onpointermove=e=>{if(!splitDrag)return;const usable=app.clientWidth-12;splitFraction=Math.min(Math.max(splitDrag.left+e.clientX-splitDrag.x,360),usable-1)/usable;sizePanes()}; -$('#splitter').onpointerup=()=>splitDrag=null;$('#splitter').onpointercancel=()=>splitDrag=null; -$('#splitter').onkeydown=e=>{const usable=app.clientWidth-12,left=parseFloat(getComputedStyle(app).getPropertyValue('--left-width'));if(e.key==='Home')splitFraction=.3;else if(['ArrowLeft','ArrowRight'].includes(e.key))splitFraction=Math.min(Math.max(left+(e.key==='ArrowLeft'?-32:32),360),usable-1)/usable;else return;e.preventDefault();sizePanes()}; +function sizePanes(){const left=Math.min(Math.max(app.clientWidth*.3,360),app.clientWidth);app.style.setProperty('--left-width',left+'px')} new ResizeObserver(sizePanes).observe(app);sizePanes(); diff --git a/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/BundledSettingsIndexTest.kt b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/BundledSettingsIndexTest.kt new file mode 100644 index 00000000..6c15aa03 --- /dev/null +++ b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/BundledSettingsIndexTest.kt @@ -0,0 +1,20 @@ +package com.aritxonly.bdfun.infra.storage + +import com.aritxonly.bdfun.core.domain.* +import org.junit.Assert.* +import org.junit.Test +import java.io.File + +class BundledSettingsIndexTest { + @Test fun shippedIndexPreservesRequestedOrganizationAndRestoredControls() { + val file = File("../../app/src/main/assets/settings-index.json") + val decoded = JsonSettingsIndexCodec().decode(file.readText()) + assertTrue(decoded.toString(), decoded is SettingsIndexImport.Success) + val index = SettingsIndex((decoded as SettingsIndexImport.Success).categories) + listOf("bt_latency_ms", "onboarding", "live_preview").forEach { assertFalse(index.settings.containsKey(it)) } + assertEquals("后台播放", index.categories.first { it.route == "settings/player" }.groups.last().title) + listOf("show_disc_after_video", "exit_video_control", "disc_breath_enabled", "disc_breath_cover_color", "home_card_style") + .forEach { assertTrue(it, it in index.settings) } + assertEquals("root_footer", index.categories.first { it.route == "settings/account" }.layout) + } +} diff --git a/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt index 71a52a37..36e515c0 100644 --- a/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt +++ b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt @@ -77,6 +77,6 @@ class JsonSettingsIndexCodecTest { val file = java.io.File("../../app/src/main/assets/settings-index.json") val result = codec.decode(file.readText()) assertTrue(result.toString(), result is SettingsIndexImport.Success) - assertEquals(61, SettingsIndex((result as SettingsIndexImport.Success).categories).settings.size) + assertEquals(63, SettingsIndex((result as SettingsIndexImport.Success).categories).settings.size) } } From 491cc8f11665f29f6c43a572618eab433310f749 Mon Sep 17 00:00:00 2001 From: AritxOnly Date: Sat, 14 Nov 2026 23:42:21 +0800 Subject: [PATCH 8/8] feat(settings): group primary entries with Miuix icons and confirm logout --- AGENTS.md | 6 +- .../teak/common/chrome/WideFoldTopAppBar.kt | 4 + app/src/main/assets/settings-seperation.json | 56 +++++ .../com/aritxonly/bdfun/data/SettingsStore.kt | 12 +- .../application/IndexedSettingsStateHolder.kt | 1 + .../application/SettingsBrowserStateHolder.kt | 4 + .../settings/page/SettingsBrowser.kt | 7 +- .../settings/page/SettingsBrowserLists.kt | 21 +- .../settings/page/SettingsBrowserPane.kt | 6 +- .../settings/page/SettingsCategoryIcon.kt | 32 +++ .../settings/page/SettingsLogoutDialog.kt | 24 +++ .../SettingsBrowserStateHolderTest.kt | 12 ++ .../core/domain/SettingsSeperationIndex.kt | 29 +++ .../domain/SettingsSeperationIndexTest.kt | 29 +++ .../core/model/settings/SettingsSeperation.kt | 5 + docs/SETTINGS_INDEX_DESIGN.md | 4 + docs/settings-organizer/README.md | 4 +- .../assets/miuix/Background.svg | 2 + .../assets/miuix/ContactsCircle.svg | 2 + docs/settings-organizer/assets/miuix/Home.svg | 2 + docs/settings-organizer/assets/miuix/Info.svg | 2 + .../assets/miuix/LICENSE.txt | 202 ++++++++++++++++++ .../assets/miuix/Messages.svg | 2 + .../assets/miuix/NOTICE.txt | 3 + docs/settings-organizer/assets/miuix/Play.svg | 2 + .../assets/miuix/Settings.svg | 2 + docs/settings-organizer/index.html | 4 +- docs/settings-organizer/organizer.js | 12 +- docs/settings-organizer/preview.css | 4 + docs/settings-organizer/preview.html | 2 +- docs/settings-organizer/preview.js | 17 +- docs/settings-organizer/schema.js | 2 +- docs/settings-organizer/seperation-catalog.js | 57 +++++ docs/settings-organizer/seperation.js | 29 +++ .../storage/JsonSettingsSeperationCodec.kt | 29 +++ .../JsonSettingsSeperationCodecTest.kt | 24 +++ tools/sync-settings-catalog.py | 5 + tools/test-settings-organizer.cjs | 28 +++ 38 files changed, 663 insertions(+), 25 deletions(-) create mode 100644 app/src/main/assets/settings-seperation.json create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsCategoryIcon.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsLogoutDialog.kt create mode 100644 core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsSeperationIndex.kt create mode 100644 core/domain/src/test/kotlin/com/aritxonly/bdfun/core/domain/SettingsSeperationIndexTest.kt create mode 100644 core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsSeperation.kt create mode 100644 docs/settings-organizer/assets/miuix/Background.svg create mode 100644 docs/settings-organizer/assets/miuix/ContactsCircle.svg create mode 100644 docs/settings-organizer/assets/miuix/Home.svg create mode 100644 docs/settings-organizer/assets/miuix/Info.svg create mode 100644 docs/settings-organizer/assets/miuix/LICENSE.txt create mode 100644 docs/settings-organizer/assets/miuix/Messages.svg create mode 100644 docs/settings-organizer/assets/miuix/NOTICE.txt create mode 100644 docs/settings-organizer/assets/miuix/Play.svg create mode 100644 docs/settings-organizer/assets/miuix/Settings.svg create mode 100644 docs/settings-organizer/seperation-catalog.js create mode 100644 docs/settings-organizer/seperation.js create mode 100644 infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsSeperationCodec.kt create mode 100644 infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsSeperationCodecTest.kt diff --git a/AGENTS.md b/AGENTS.md index 28df3df1..97f5b4b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -588,10 +588,14 @@ HyperOS 中性色以 Deadliner 的 HyperOS 适配为准:surface 为页面层 ### 2026-11-14 SET-02 索引设置 UI 与编排器 -设置页已改为 presentation/settings/page 的索引渲染器:一级分类、横屏双栏与页面作用域搜索。全量现有设置使用63个组件描述(含关于身份区及PR52的3个可选扩展);不再沿用原型36项索引或旧卡片兜底。switch/dropdown/font_scale/cdn_dropdown/page_switches/mine_switch/action/developer/version保持原组件、条件与存储边界;select不得改成三级页面。第一阶段不显示分类图标,功能按钮只用Miuix Icons。编排器为 docs/settings-organizer/index.html,导出必须完整保留组件键/type/default/options/storage/visible_when/inverted/value_type,允许调整分类、分组、顺序与文案;App资产是唯一来源,运行 tools/sync-settings-catalog.py 同步。浮点组件须保留value_type=decimal,防止JSON导出1.0变1后改变类型。关于页参考Deadliner 3b561656的AboutSettings.kt,来源与MIT许可见docs/SETTINGS_DEADLINER_PROVENANCE.md;光晕只在Canvas读取动画State,卡片仅背景随320dp滚动由0.5变1。推荐扩展由启动层注入,未合入PR52时隐藏,不建立跨功能实现依赖。 +设置页已改为 presentation/settings/page 的索引渲染器:一级分类、横屏双栏与页面作用域搜索。全量现有设置使用63个组件描述(含关于身份区及PR52的3个可选扩展);不再沿用原型36项索引或旧卡片兜底。switch/dropdown/font_scale/cdn_dropdown/page_switches/mine_switch/action/developer/version保持原组件、条件与存储边界;select不得改成三级页面。一级入口现按settings-seperation.json分组,并使用彩色底板的Miuix Icons;功能按钮仍只用Miuix Icons。编排器为 docs/settings-organizer/index.html,导出必须完整保留组件键/type/default/options/storage/visible_when/inverted/value_type,允许调整分类、分组、顺序与文案;App资产是唯一来源,运行 tools/sync-settings-catalog.py 同步。浮点组件须保留value_type=decimal,防止JSON导出1.0变1后改变类型。关于页参考Deadliner 3b561656的AboutSettings.kt,来源与MIT许可见docs/SETTINGS_DEADLINER_PROVENANCE.md;光晕只在Canvas读取动画State,卡片仅背景随320dp滚动由0.5变1。推荐扩展由启动层注入,未合入PR52时隐藏,不建立跨功能实现依赖。 SET-02外壳约束:返回键、TopAppBar、大标题与滚动联动必须复用原TeakActivityScaffold/TeakLargeTitle/TrackTeakPageScroll,不画新的设置顶栏,不关掉原顶栏后自己仿制。二级页搜索仅加入原工具栏actions槽。 ### 2026-11-14 SET-03 独立分栏与一级退出登录 横屏设置必须覆盖窗口全宽,左右分别使用原TeakPageScaffold(竖屏仍由TeakActivityScaffold装配),不在一个受内容最大宽度约束的外壳内摆两列。每栏独立PageChrome/滚动/搜索/popup/backdrop,并按栏尺寸提供窗口上下文。默认分配3:7,左栏下限360dp;不显示分隔线且不可拖动,右栏取剩余空间;一级页固定,右侧三级页只覆盖二级页,三级才显示右侧返回键。竖屏继续单壳。退出登录是layout=root_footer中的原动作,登录后在一级列表最底部独立卡片呈现,不生成账号二级页。 + +### 2026-11-14 SET-05 一级分组与宽屏TopBar + +settings-seperation.json独立描述一级卡片分组及入口route/icon/color,设置值和二级页面仍以settings-index.json为唯一来源。不得复制设置项或把退出登录移入入口分组。左右栏保留真实容器大小供弹层使用,但通过LocalWideFoldTopBarOverride明确使用外层宽屏chrome策略,不能让360dp左栏误判为手机TopBar。 diff --git a/TeakUI/src/main/kotlin/com/aritxonly/teak/common/chrome/WideFoldTopAppBar.kt b/TeakUI/src/main/kotlin/com/aritxonly/teak/common/chrome/WideFoldTopAppBar.kt index d14265ae..839a1786 100644 --- a/TeakUI/src/main/kotlin/com/aritxonly/teak/common/chrome/WideFoldTopAppBar.kt +++ b/TeakUI/src/main/kotlin/com/aritxonly/teak/common/chrome/WideFoldTopAppBar.kt @@ -21,12 +21,16 @@ import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TopAppBarDefaults import top.yukonga.miuix.kmp.theme.MiuixTheme +/** 分栏保留真实窗口大小供弹层定位;页面壳可显式沿用外层宽屏策略。 */ +val LocalWideFoldTopBarOverride = androidx.compose.runtime.staticCompositionLocalOf { null } + /** Window policy, not a device/model whitelist. Uses the window before IME insets. */ internal fun usesFixedWideFoldTopBar(widthDp: Float, heightDp: Float): Boolean = widthDp >= 400f && heightDp > 0f && heightDp / widthDp <= 1.65f @Composable fun isWideFoldTopBar(): Boolean { + LocalWideFoldTopBarOverride.current?.let { return it } val window = LocalWindowInfo.current.containerSize val density = LocalDensity.current.density return usesFixedWideFoldTopBar(window.width / density, window.height / density) diff --git a/app/src/main/assets/settings-seperation.json b/app/src/main/assets/settings-seperation.json new file mode 100644 index 00000000..fb6d6cfa --- /dev/null +++ b/app/src/main/assets/settings-seperation.json @@ -0,0 +1,56 @@ +[ + { + "id": "appearance", + "title": "个性化", + "entries": [ + { + "route": "settings/appearance", + "icon": "Background", + "color": "#FF9F0A" + } + ] + }, + { + "id": "playback", + "title": "观看体验", + "entries": [ + { + "route": "settings/player", + "icon": "Play", + "color": "#3482FF" + }, + { + "route": "settings/video", + "icon": "Messages", + "color": "#30B961" + } + ] + }, + { + "id": "content", + "title": "内容与个人", + "entries": [ + { + "route": "settings/feeds", + "icon": "Home", + "color": "#FF5D73" + }, + { + "route": "settings/mine", + "icon": "ContactsCircle", + "color": "#AF65D9" + } + ] + }, + { + "id": "about", + "title": "应用信息", + "entries": [ + { + "route": "settings/about", + "icon": "Info", + "color": "#778795" + } + ] + } +] diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/data/SettingsStore.kt b/app/src/main/kotlin/com/aritxonly/bdfun/data/SettingsStore.kt index cd142202..ab160cdb 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/data/SettingsStore.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/data/SettingsStore.kt @@ -11,8 +11,9 @@ import kotlinx.coroutines.* class SettingsStore private constructor(context: Context, scope: CoroutineScope) : SettingsBindings( scope, SettingsStateHolder.create(AndroidSettingsPreferences(context), JsonSearchHistoryCodec(), scope), ) { + private val index = loadSettingsIndex(context) internal val indexed = com.aritxonly.bdfun.feature.settings.application.IndexedSettingsStateHolder( - loadSettingsIndex(context), this, state, scope, + index, this, state, scope, loadSettingsSeperation(context, index), ) constructor(context: Context) : this(context, CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)) @@ -26,3 +27,12 @@ private fun loadSettingsIndex(context: Context): com.aritxonly.bdfun.core.domain is com.aritxonly.bdfun.core.domain.SettingsIndexImport.Invalid -> error(result.reason) } } + +private fun loadSettingsSeperation(context: Context, index: com.aritxonly.bdfun.core.domain.SettingsIndex): + List { + val json = context.assets.open("settings-seperation.json").bufferedReader().use { it.readText() } + return when (val result = com.aritxonly.bdfun.infra.storage.JsonSettingsSeperationCodec().decode(json, index)) { + is com.aritxonly.bdfun.core.domain.SettingsSeperationImport.Success -> result.index.groups + is com.aritxonly.bdfun.core.domain.SettingsSeperationImport.Invalid -> error(result.reason) + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt index da3318c7..6395a118 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt @@ -23,6 +23,7 @@ internal class IndexedSettingsStateHolder( repository: SettingsRepository, changes: StateFlow, scope: CoroutineScope, + val seperation: List = emptyList(), ) { private val bindings = indexedSettingBindings(repository) private val stored = index.settings.filterValues { it.storage == "settings" } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt index ea53cc02..e8c1e842 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt @@ -13,6 +13,7 @@ internal data class SettingsBrowserState( val query: String = "", val highlight: String? = null, val detail: String? = null, + val confirmLogout: Boolean = false, ) /** 页面拥有自己的搜索和导航;旋转只改变布局,不另建一份选择状态。 */ @@ -27,10 +28,13 @@ internal class SettingsBrowserStateHolder(initial: SettingsBrowserState = Settin mutable.update { it.copy(searching = true, searchRoute = route, query = "", highlight = null) } } fun openDetail(destination: String) { mutable.update { it.copy(detail = destination, searching = false, searchRoute = null, query = "") } } + fun requestLogout() { mutable.update { it.copy(confirmLogout = true) } } + fun cancelLogout() { mutable.update { it.copy(confirmLogout = false) } } fun query(value: String) { mutable.update { it.copy(query = value) } } fun closeSearch() { mutable.update { it.copy(searching = false, query = "", searchRoute = null) } } fun clearHighlight(key: String) { mutable.update { if (it.highlight == key) it.copy(highlight = null) else it } } fun back(): Boolean = when { + state.value.confirmLogout -> { cancelLogout(); true } state.value.searching -> { closeSearch(); true } state.value.detail != null -> { mutable.update { it.copy(detail = null) }; true } state.value.route != null -> { mutable.value = SettingsBrowserState(); true } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt index 4317d948..054cc272 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt @@ -30,7 +30,7 @@ internal fun SettingsBrowser(controller: IndexedSettingsStateHolder, runtime: Se LaunchedEffect(navigation.highlight) { navigation.highlight?.let { kotlinx.coroutines.delay(1800); browser.clearHighlight(it) } } - val session = SettingsBrowserSession(browser, navigation, selected, wide, categories, footers) + val session = SettingsBrowserSession(browser, navigation, selected, wide, categories, footers, controller.seperation) if (wide) SettingsSplitLayout(session, settings.values, runtime, controller) else SettingsPaneNavigation(session, settings.values, runtime, controller, SettingsPane.Single, Modifier.fillMaxSize()) } @@ -40,10 +40,11 @@ internal fun rememberSettingsBrowser(): SettingsBrowserStateHolder { val saver = remember { Saver>(save = { val s = it.state.value - listOf(s.route.orEmpty(), s.searching.toString(), s.searchRoute.orEmpty(), s.query, s.detail.orEmpty()) + listOf(s.route.orEmpty(), s.searching.toString(), s.searchRoute.orEmpty(), s.query, s.detail.orEmpty(), s.confirmLogout.toString()) }, restore = { SettingsBrowserStateHolder(SettingsBrowserState(it[0].ifEmpty { null }, it[1].toBoolean(), - it[2].ifEmpty { null }, it[3], detail = it.getOrNull(4)?.takeIf { name -> name in setOf("Help", "Changelog", "Credits", "Donation", "Logs") })) + it[2].ifEmpty { null }, it[3], detail = it.getOrNull(4)?.takeIf { name -> name in setOf("Help", "Changelog", "Credits", "Donation") }, + confirmLogout = it.getOrNull(5)?.toBoolean() ?: false)) }) } return rememberSaveable(saver = saver) { SettingsBrowserStateHolder() } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt index 3eb2ccc5..98b5314a 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt @@ -25,6 +25,7 @@ internal data class SettingsBrowserSession( val wide: Boolean, val categories: List, val footers: List = emptyList(), + val seperation: List = emptyList(), ) @Composable @@ -34,13 +35,19 @@ internal fun SettingsCategoryList(session: SettingsBrowserSession, modifier: Mod if (!session.navigation.searching) TrackTeakPageScroll(list, chrome) LazyColumn(modifier, state = list, contentPadding = settingsListPadding()) { item { TeakLargeTitle("设置", pageState = chrome) } - item { - TeakSettingsCard { - session.categories.forEach { category -> - val selected = session.wide && session.selected?.route == category.route - ArrowPreference(title = category.name, - modifier = if (selected) Modifier.background(MiuixTheme.colorScheme.primary.copy(alpha = .08f)) else Modifier, - onClick = { session.browser.open(category.route) }) + session.seperation.forEach { group -> + val entries = group.entries.mapNotNull { entry -> + session.categories.firstOrNull { it.route == entry.route }?.let { entry to it } + } + if (entries.isNotEmpty()) item(key = group.id) { + if (group != session.seperation.first()) Spacer(Modifier.height(16.dp)) + TeakSettingsCard { + entries.forEach { (entry, category) -> + val selected = session.wide && session.selected?.route == category.route + ArrowPreference(title = category.name, startAction = { SettingsCategoryIcon(entry) }, + modifier = if (selected) Modifier.background(MiuixTheme.colorScheme.primary.copy(alpha = .08f)) else Modifier, + onClick = { session.browser.open(category.route) }) + } } } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt index 113649b1..0c4f84e5 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt @@ -54,9 +54,10 @@ internal fun SettingsBrowserPane(session: SettingsBrowserSession, values: Map + val embedded = SettingsNavigation(navigation.onBack, session.browser::requestLogout, { destination -> if (destination in settingsEmbeddedDestinations) session.browser.openDetail(destination.name) else navigation.onAbout(destination) }, navigation.onDeveloper, navigation.onNotify) @@ -67,6 +68,7 @@ internal fun SettingsBrowserPane(session: SettingsBrowserSession, values: Map MiuixIcons.Normal.Background + "Play" -> MiuixIcons.Normal.Play + "Messages" -> MiuixIcons.Normal.Messages + "Home" -> MiuixIcons.Normal.Home + "ContactsCircle" -> MiuixIcons.Normal.ContactsCircle + "Info" -> MiuixIcons.Normal.Info + else -> MiuixIcons.Normal.Settings + } + Box(Modifier.padding(end = 16.dp).size(32.dp) + .background(Color(0xFF000000 or entry.color.removePrefix("#").toLong(16)), RoundedCornerShape(9.dp)), + contentAlignment = Alignment.Center) { + Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(23.dp)) + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsLogoutDialog.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsLogoutDialog.kt new file mode 100644 index 00000000..c9e3f695 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsLogoutDialog.kt @@ -0,0 +1,24 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.runtime.* +import androidx.compose.material3.Text +import com.aritxonly.bdfun.feature.settings.application.SettingsBrowserStateHolder +import com.aritxonly.teak.common.modal.AlertDialog +import com.aritxonly.teak.common.modal.TextButton + +@Composable +internal fun SettingsLogoutDialog(browser: SettingsBrowserStateHolder, onLogout: () -> Unit) { + val state by browser.state.collectAsState() + AlertDialog(show = state.confirmLogout, onDismissRequest = browser::cancelLogout, + miuixTitle = "退出登录?", miuixSummary = "确认退出当前账号?", + confirmButton = { + TextButton(onClick = { + if (browser.state.value.confirmLogout) { + browser.cancelLogout() + onLogout() + } + }, miuixText = "退出登录") { Text("退出登录") } + }, dismissButton = { + TextButton(onClick = browser::cancelLogout, miuixText = "取消") { Text("取消") } + }) +} diff --git a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt index 9ac5b082..28502754 100644 --- a/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt +++ b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt @@ -79,6 +79,18 @@ class SettingsBrowserStateHolderTest { assertNull(page.state.value.detail) } + @Test fun logoutRequestOnlyShowsDialogAndBackCancelsWithoutChangingPage() { + val page = SettingsBrowserStateHolder() + page.search() + page.query("退出") + page.requestLogout() + assertTrue(page.state.value.confirmLogout) + assertTrue(page.back()) + assertFalse(page.state.value.confirmLogout) + assertTrue(page.state.value.searching) + assertEquals("退出", page.state.value.query) + } + @Test fun restoredNavigationDoesNotNeedASecondLayoutState() { val saved = SettingsBrowserState("settings/player", true, "settings/player", "蓝牙") assertEquals(saved, SettingsBrowserStateHolder(saved).state.value) diff --git a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsSeperationIndex.kt b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsSeperationIndex.kt new file mode 100644 index 00000000..de00db82 --- /dev/null +++ b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsSeperationIndex.kt @@ -0,0 +1,29 @@ +package com.aritxonly.bdfun.core.domain + +import com.aritxonly.bdfun.core.model.settings.SettingsSeperation + +class SettingsSeperationIndex(val groups: List, index: SettingsIndex) { + init { + require(groups.isNotEmpty() && groups.map { it.id }.distinct().size == groups.size) { "一级分组为空或重复" } + val routes = groups.flatMap { it.entries }.map { it.route } + require(routes.distinct().size == routes.size) { "一级入口重复" } + val expected = index.categories.filter { it.layout != "root_footer" }.map { it.route }.toSet() + require(routes.toSet() == expected) { "一级入口必须恰好引用全部二级页面,不能包含退出登录" } + groups.forEach { group -> + require(group.id.matches(Regex("[a-z][a-z0-9_]*")) && group.title.isNotBlank() && group.entries.isNotEmpty()) + group.entries.forEach { entry -> + require(entry.icon in Icons) { "未知Miuix图标" } + require(entry.color.matches(Regex("#[0-9a-fA-F]{6}"))) { "图标底色必须是RGB颜色" } + } + } + } + companion object { val Icons = setOf("Background", "Play", "Messages", "Home", "ContactsCircle", "Info", "Settings") } +} + +interface SettingsSeperationCodec { + fun decode(json: String, index: SettingsIndex): SettingsSeperationImport +} +sealed interface SettingsSeperationImport { + data class Success(val index: SettingsSeperationIndex) : SettingsSeperationImport + data class Invalid(val reason: String) : SettingsSeperationImport +} diff --git a/core/domain/src/test/kotlin/com/aritxonly/bdfun/core/domain/SettingsSeperationIndexTest.kt b/core/domain/src/test/kotlin/com/aritxonly/bdfun/core/domain/SettingsSeperationIndexTest.kt new file mode 100644 index 00000000..186829c1 --- /dev/null +++ b/core/domain/src/test/kotlin/com/aritxonly/bdfun/core/domain/SettingsSeperationIndexTest.kt @@ -0,0 +1,29 @@ +package com.aritxonly.bdfun.core.domain + +import com.aritxonly.bdfun.core.model.settings.* +import org.junit.Assert.* +import org.junit.Test + +class SettingsSeperationIndexTest { + private val index = SettingsIndex(listOf( + SettingsCategory("外观", "settings/appearance", null, emptyList()), + SettingsCategory("播放器", "settings/player", null, emptyList()), + SettingsCategory("账号", "settings/account", null, emptyList(), "root_footer"))) + private val appearance = SettingsEntry("settings/appearance", "Background", "#FF9F0A") + private val player = SettingsEntry("settings/player", "Play", "#3482FF") + @Test fun groupsReferenceEachSecondaryPageExactlyOnceAndPreserveOrder() { + val groups = listOf(SettingsSeperation("watch", "观看", listOf(player)), SettingsSeperation("look", "外观", listOf(appearance))) + assertEquals(groups, SettingsSeperationIndex(groups, index).groups) + } + @Test fun invalidReferencesIconsColorsAndDuplicateGroupsAreRejected() { + val valid = SettingsSeperation("all", "全部", listOf(appearance, player)) + val invalid = listOf( + listOf(valid.copy(entries = listOf(appearance))), + listOf(valid.copy(entries = listOf(appearance, player, player))), + listOf(valid.copy(entries = listOf(appearance, player.copy(route = "settings/account")))), + listOf(valid.copy(entries = listOf(appearance, player.copy(icon = "Unknown")))), + listOf(valid.copy(entries = listOf(appearance, player.copy(color = "red")))), + listOf(valid, valid), emptyList()) + invalid.forEach { assertThrows(IllegalArgumentException::class.java) { SettingsSeperationIndex(it, index) } } + } +} diff --git a/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsSeperation.kt b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsSeperation.kt new file mode 100644 index 00000000..c94891f1 --- /dev/null +++ b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsSeperation.kt @@ -0,0 +1,5 @@ +package com.aritxonly.bdfun.core.model.settings + +/** 一级入口索引不包含设置值,route引用二级索引。命名沿用资产协议seperation。 */ +data class SettingsSeperation(val id: String, val title: String, val entries: List) +data class SettingsEntry(val route: String, val icon: String, val color: String) diff --git a/docs/SETTINGS_INDEX_DESIGN.md b/docs/SETTINGS_INDEX_DESIGN.md index ae8eda7c..3a19c50b 100644 --- a/docs/SETTINGS_INDEX_DESIGN.md +++ b/docs/SETTINGS_INDEX_DESIGN.md @@ -46,3 +46,7 @@ layout=about使用Deadliner实际关于页的身份区、三层浮动光晕与32 用户调整索引保留分类/分组/排序;删除补偿延迟、查看开始页、直播间预览。补回首页卡片样式与播放器末尾后台播放组,继续使用原存储键和dropdown;唱片灯及封面取色按控件选择显隐。visible_when支持&&和整数选项比较(key=value),由领域策略验证。 SET-04验证:SettingsActivity显式adjustResize,避免系统自动pan与Compose imePadding叠加;输入法仅由底部Dock消费一次。新增同版本miuix-nav依赖以采用NavTransitions.MiuixDefault,避免另写一套动画;不引入序列化插件,保存恢复仍归SettingsBrowserStateHolder。内页复用帮助/更新日志/捐赠/引用原内容,外链和日志导出继续交给原宿主。 + +SET-05:新增settings-seperation.json(名称沿用需求中的seperation),格式为分组数组,每组id/title/entries,每个入口只保存route/icon/color。一级卡片按数组顺序呈现,卡片间留白,不复制二级设置或纳入root_footer。一级图标使用Miuix extended Normal图标和彩色圆角底板。宽屏chrome用显式CompositionLocal覆盖测量判断,popup仍按每栏真实窗口尺寸定位。 + +退出登录先设置页面作用域confirmLogout,显示原Teak/MIUIX AlertDialog;取消、关闭或系统返回均只清此状态。只有确认按钮执行原宿主logout,先清状态以阻止重复点击。对话框显示状态随设置导航保存恢复。 diff --git a/docs/settings-organizer/README.md b/docs/settings-organizer/README.md index d855f7ff..796f959f 100644 --- a/docs/settings-organizer/README.md +++ b/docs/settings-organizer/README.md @@ -6,4 +6,6 @@ 预览仅用于检查组织结构,不会修改手机偏好或执行退出登录、打开外链等真实动作。手机界面使用现有 Miuix/Teak 组件:select 仍为 dropdown,字号仍为滑条,返回键和 TopAppBar 沿用原壳。 -导出的 `settings-index.json` 替换仓库 `app/src/main/assets/settings-index.json` 后重新构建生效。再运行 `python3 tools/sync-settings-catalog.py` 同步此工具的目录数据。请保留完整组件清单和原组件/存储/默认值/选项/显隐合同;第一阶段不配置分类图标。退出登录固定为一级页底部独立操作,不能移到二级页;横屏预览是独立双栏,左栏至少360px、默认3:7,无分隔线且不可拖动。推荐扩展在合入 PR52 的构建中生效。 +导出的 `settings-index.json` 替换仓库 `app/src/main/assets/settings-index.json` 后重新构建生效。再运行 `python3 tools/sync-settings-catalog.py` 同步此工具的目录数据。请保留完整组件清单和原组件/存储/默认值/选项/显隐合同;一级分组与MIUIX图标由独立的`settings-seperation.json`配置。退出登录固定为一级页底部独立操作,不能移到二级页;横屏预览是独立双栏,左栏至少360px、默认3:7,无分隔线且不可拖动。推荐扩展在合入 PR52 的构建中生效。 + +一级入口索引可单独导入/导出,替换同名App资产后构建;每个二级route恰好引用一次,不能包括退出登录。分类属性可选所属分组、MIUIX图标与底色;展开一级索引可编辑分组名称、顺序或新增分组后应用。二级页面索引与一级索引须一起交付。离线图标取自固定Miuix Normal矢量,授权见assets/miuix/。 diff --git a/docs/settings-organizer/assets/miuix/Background.svg b/docs/settings-organizer/assets/miuix/Background.svg new file mode 100644 index 00000000..f56a77a7 --- /dev/null +++ b/docs/settings-organizer/assets/miuix/Background.svg @@ -0,0 +1,2 @@ + + diff --git a/docs/settings-organizer/assets/miuix/ContactsCircle.svg b/docs/settings-organizer/assets/miuix/ContactsCircle.svg new file mode 100644 index 00000000..e1cf54cb --- /dev/null +++ b/docs/settings-organizer/assets/miuix/ContactsCircle.svg @@ -0,0 +1,2 @@ + + diff --git a/docs/settings-organizer/assets/miuix/Home.svg b/docs/settings-organizer/assets/miuix/Home.svg new file mode 100644 index 00000000..f4799b8e --- /dev/null +++ b/docs/settings-organizer/assets/miuix/Home.svg @@ -0,0 +1,2 @@ + + diff --git a/docs/settings-organizer/assets/miuix/Info.svg b/docs/settings-organizer/assets/miuix/Info.svg new file mode 100644 index 00000000..eaea68f1 --- /dev/null +++ b/docs/settings-organizer/assets/miuix/Info.svg @@ -0,0 +1,2 @@ + + diff --git a/docs/settings-organizer/assets/miuix/LICENSE.txt b/docs/settings-organizer/assets/miuix/LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/docs/settings-organizer/assets/miuix/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/docs/settings-organizer/assets/miuix/Messages.svg b/docs/settings-organizer/assets/miuix/Messages.svg new file mode 100644 index 00000000..505e0e61 --- /dev/null +++ b/docs/settings-organizer/assets/miuix/Messages.svg @@ -0,0 +1,2 @@ + + diff --git a/docs/settings-organizer/assets/miuix/NOTICE.txt b/docs/settings-organizer/assets/miuix/NOTICE.txt new file mode 100644 index 00000000..a39827ec --- /dev/null +++ b/docs/settings-organizer/assets/miuix/NOTICE.txt @@ -0,0 +1,3 @@ +Miuix icons, compose-miuix-ui contributors. +Source: https://github.com/miuix-kotlin-multiplatform/miuix commit 45645854cd46c32275c5666e3aafa9980d8f104e. +Normal ImageVector paths converted to SVG for offline previews. Licensed under Apache-2.0, see LICENSE.txt. diff --git a/docs/settings-organizer/assets/miuix/Play.svg b/docs/settings-organizer/assets/miuix/Play.svg new file mode 100644 index 00000000..b4d2d6d5 --- /dev/null +++ b/docs/settings-organizer/assets/miuix/Play.svg @@ -0,0 +1,2 @@ + + diff --git a/docs/settings-organizer/assets/miuix/Settings.svg b/docs/settings-organizer/assets/miuix/Settings.svg new file mode 100644 index 00000000..19598dd3 --- /dev/null +++ b/docs/settings-organizer/assets/miuix/Settings.svg @@ -0,0 +1,2 @@ + + diff --git a/docs/settings-organizer/index.html b/docs/settings-organizer/index.html index 67bbf667..cc7fe3cf 100644 --- a/docs/settings-organizer/index.html +++ b/docs/settings-organizer/index.html @@ -1,6 +1,6 @@ bdFun 设置编排
bdFun / 设置编排

正在读取完整设置索引

-

页面结构

拖动调整顺序,也可用上移 / 下移按钮

编排属性

选择分类、分组或设置进行编辑

导出内容

控件类型、选项、默认值和显隐条件来自现有实现。这里只调整组织与文案,导出的文件替换 App 资产后构建生效。

实时预览

可点击 dropdown、开关和底部搜索

+

页面结构

拖动调整顺序,也可用上移 / 下移按钮

编排属性

选择分类、分组或设置进行编辑

一级入口分组(seperation)

一级卡片由此索引组织;可在分类属性中选择所属卡片与 MIUIX 图标。

导出内容

控件类型、选项、默认值和显隐条件来自现有实现。这里只调整组织与文案,导出的文件替换 App 资产后构建生效。

实时预览

可点击 dropdown、开关和底部搜索

- + diff --git a/docs/settings-organizer/organizer.js b/docs/settings-organizer/organizer.js index 7506abb5..860ef862 100644 --- a/docs/settings-organizer/organizer.js +++ b/docs/settings-organizer/organizer.js @@ -1,6 +1,8 @@ 'use strict'; const {clone,flatten,validate}=SettingsSchema; const baseline=clone(SETTINGS_CATALOG),storageKey='bdfun-settings-layout-v2'; +let seperation=clone(SETTINGS_SEPERATION); +try{const saved=localStorage.getItem('bdfun-settings-seperation-v1');if(saved)seperation=SettingsSeperation.validate(JSON.parse(saved),baseline)}catch(e){} let model=clone(baseline),selection={kind:'category',route:model[0].route},undoStack=[],redoStack=[],wide=false,dark=false,drag=null; const $=selector=>document.querySelector(selector),node=(tag,cls,text)=>{const e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e;}; function notice(text){$('#notice').textContent=text;$('#notice').hidden=false;clearTimeout(notice.timer);notice.timer=setTimeout(()=>$('#notice').hidden=true,4500)} @@ -27,15 +29,15 @@ function renderTree(){const tree=$('#tree'),filter=$('#filter').value.trim().toL function field(label,value,change,multiline=false){const wrapper=node('label','field');wrapper.append(node('span','',label));const input=node(multiline?'textarea':'input');input.value=value||'';input.onchange=()=>change(input.value);wrapper.append(input);return wrapper} function destination(label,options,current,change){const wrapper=node('label','field'),select=node('select');wrapper.append(node('span','',label));options.forEach(([value,title])=>{const option=node('option','',title);option.value=value;option.selected=value===current;select.append(option)});select.onchange=()=>change(select.value);wrapper.append(select);return wrapper} function renderProperties(){const panel=$('#properties');panel.replaceChildren();const c=model.findIndex(c=>c.route===selection.route); - if(selection.kind==='category'&&c>=0){const category=model[c];panel.append(field('分类名称',category.name,v=>commit(d=>d[c].name=v)),field('分类路由',category.route,v=>{const previous=category.route;commit(d=>d[c].route=v);selection.route=model[c].route||previous;render()}));panel.append(node('div','readonly','页面布局:'+(category.layout==='about'?'Deadliner 关于页':category.layout==='root_footer'?'一级页底部独立操作':'标准设置页')+'。第一阶段不使用分类图标。'));panel.append(button('删除空分类',()=>{if(category.groups.some(g=>g.settings.length))return notice('请先把设置移动到其他分类');commit(d=>d.splice(c,1));selection={kind:'category',route:model[0].route};render()},'danger'));return} + if(selection.kind==='category'&&c>=0){const category=model[c];panel.append(field('分类名称',category.name,v=>commit(d=>d[c].name=v)),field('分类路由',category.route,v=>{const previous=category.route;commit(d=>d[c].route=v);selection.route=model[c].route||previous;render()}));panel.append(node('div','readonly','页面布局:'+(category.layout==='about'?'Deadliner 关于页':category.layout==='root_footer'?'一级页底部独立操作':'标准设置页')+'。一级分组和图标在seperation中配置。'));if(category.layout!=='root_footer'){const group=seperation.find(g=>g.entries.some(e=>e.route===category.route)),entry=group?.entries.find(e=>e.route===category.route);if(group&&entry){panel.append(destination('一级入口分组',seperation.map(g=>[g.id,g.title]),group.id,v=>{group.entries=group.entries.filter(e=>e.route!==category.route);seperation.find(g=>g.id===v).entries.push(entry);seperation=seperation.filter(g=>g.entries.length);render()}),destination('MIUIX图标',SettingsSeperation.icons.map(i=>[i,i]),entry.icon,v=>{entry.icon=v;render()}),field('图标底色(#RRGGBB)',entry.color,v=>{if(!/^#[0-9a-fA-F]{6}$/.test(v))return notice('请输入#RRGGBB颜色');entry.color=v;render()}))}}panel.append(button('删除空分类',()=>{if(category.groups.some(g=>g.settings.length))return notice('请先把设置移动到其他分类');commit(d=>d.splice(c,1));selection={kind:'category',route:model[0].route};render()},'danger'));return} if(selection.kind==='group'&&c>=0){const g=selection.g,group=model[c].groups[g];if(!group)return;panel.append(field('分组标题',group.title,v=>commit(d=>d[c].groups[g].title=v)));panel.append(destination('移动到分类',model.map((c,i)=>[String(i),c.name]),String(c),v=>{const target=Number(v);commit(d=>d[target].groups.push(d[c].groups.splice(g,1)[0]));selection={kind:'group',route:model[target].route,g:model[target].groups.length-1};render()}));panel.append(button('删除空分组',()=>{if(group.settings.length)return notice('请先移动这个分组中的设置');commit(d=>d[c].groups.splice(g,1));select({kind:'category',route:model[c].route})},'danger'));return} if(selection.kind==='setting'){const at=locate(selection.key);if(!at)return;const s=model[at.c].groups[at.g].settings[at.s];panel.append(field('设置标题',s.title,v=>commit(d=>d[at.c].groups[at.g].settings[at.s].title=v)),field('说明',s.subtitle||'',v=>commit(d=>{const entry=d[at.c].groups[at.g].settings[at.s];if(v)entry.subtitle=v;else delete entry.subtitle}),true)); const choices=model.flatMap((c,ci)=>c.groups.map((g,gi)=>[`${ci}:${gi}`,`${c.name} / ${g.title}`]));panel.append(destination('所在分类 / 分组',choices,`${at.c}:${at.g}`,v=>{const [c,g]=v.split(':').map(Number);moveSetting(s.pref_name,c,g)})); const info=node('div','readonly');info.append(node('strong','',s.type+' 组件'),node('div','',`存储:${s.storage||'settings'} / ${s.pref_name}`),node('div','',`默认值:${JSON.stringify(s.default)}`),node('div','',`显隐:${s.visible_when||'始终显示'}`));if(s.inverted)info.append(node('div','','反向开关:保留原来的减弱动态效果语义'));if(s.options)info.append(node('div','',`Dropdown:${s.options.map(o=>o.title).join(' / ')}`));panel.append(info);return} panel.append(node('p','empty','点击左侧条目编辑组织方式。')); } -function syncPreview(){const route=selection.kind==='setting'?model[locate(selection.key)?.c]?.route:selection.route;$('#preview').contentWindow?.postMessage({type:'settings-layout',categories:model,route,wide,dark},'*')} -function render(){renderTree();renderProperties();$('#undo').disabled=!undoStack.length;$('#redo').disabled=!redoStack.length;$('#json').value=JSON.stringify(model,null,2);save();syncPreview()} +function syncPreview(){const route=selection.kind==='setting'?model[locate(selection.key)?.c]?.route:selection.route;$('#preview').contentWindow?.postMessage({type:'settings-layout',categories:model,seperation,route,wide,dark},'*')} +function render(){seperation=SettingsSeperation.reconcile(seperation,model);$('#seperation-json').value=JSON.stringify(seperation,null,2);try{localStorage.setItem('bdfun-settings-seperation-v1',JSON.stringify(seperation))}catch(e){}renderTree();renderProperties();$('#undo').disabled=!undoStack.length;$('#redo').disabled=!redoStack.length;$('#json').value=JSON.stringify(model,null,2);save();syncPreview()} $('#filter').oninput=renderTree; $('#undo').onclick=()=>{if(!undoStack.length)return;redoStack.push(clone(model));model=undoStack.pop();render()};$('#redo').onclick=()=>{if(!redoStack.length)return;undoStack.push(clone(model));model=redoStack.pop();render()}; $('#add-category').onclick=()=>{let n=1;while(model.some(c=>c.route===`settings/category_${n}`))n++;const route=`settings/category_${n}`;commit(d=>d.push({name:'新分类',route,groups:[]}));select({kind:'category',route})}; @@ -45,3 +47,7 @@ $('#import').onchange=async e=>{const file=e.target.files[0];if(!file)return;try $('#orientation').onclick=()=>{wide=!wide;document.querySelector('.preview').classList.toggle('wide',wide);$('#orientation').textContent=wide?'竖屏':'横屏';syncPreview()};$('#theme').onclick=()=>{dark=!dark;$('#theme').textContent=dark?'浅色':'深色';syncPreview()};$('#preview').onload=syncPreview; window.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'&&!['INPUT','TEXTAREA'].includes(document.activeElement.tagName)){e.preventDefault();(e.shiftKey?$('#redo'):$('#undo')).click()}}); render(); + +$('#apply-seperation').onclick=()=>{try{const next=SettingsSeperation.validate(JSON.parse($('#seperation-json').value),model);seperation=next;render();notice('一级分组已更新')}catch(e){notice('一级索引未更新:'+e.message)}}; +$('#import-seperation').onchange=async e=>{const file=e.target.files[0];if(!file)return;try{if(file.size>1048576)throw Error('文件超过1MiB');const next=SettingsSeperation.validate(JSON.parse(await file.text()),model);seperation=next;render();notice('一级索引已导入')}catch(error){notice('一级索引未更新:'+error.message)}finally{e.target.value=''}}; +$('#export-seperation').onclick=()=>{try{const valid=SettingsSeperation.validate(seperation,model),url=URL.createObjectURL(new Blob([JSON.stringify(valid,null,2)+'\n'],{type:'application/json'})),a=node('a');a.href=url;a.download='settings-seperation.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}catch(e){notice(e.message)}}; diff --git a/docs/settings-organizer/preview.css b/docs/settings-organizer/preview.css index e0a18a72..a63e37a6 100644 --- a/docs/settings-organizer/preview.css +++ b/docs/settings-organizer/preview.css @@ -13,3 +13,7 @@ #app.searching.global #content{opacity:1;transform:none;pointer-events:auto} .root-footer{margin-top:24px} #app:not(.phone) section{clip-path:inset(0);isolation:isolate} + +.navigation-group{margin-bottom:16px}.category-icon{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;flex-shrink:0;margin-right:16px}.category-icon img{width:23px;height:23px}.root-footer{margin-top:16px} + +.logout-dialog{background:var(--card);color:var(--fg);border:0;border-radius:32px;padding:28px;max-width:360px;width:calc(100% - 80px);box-shadow:0 12px 48px #0002}.logout-dialog::backdrop{background:#0005;backdrop-filter:blur(12px)}.logout-dialog h2{font-size:20px;margin:0 0 12px}.logout-dialog p{font-size:15px;color:var(--secondary)}.dialog-actions{display:flex;gap:12px;margin-top:24px}.dialog-actions button{flex:1;border:0;border-radius:24px;padding:14px 8px;background:#8882;color:inherit;font:inherit}.dialog-actions .confirm{background:var(--blue);color:white} diff --git a/docs/settings-organizer/preview.html b/docs/settings-organizer/preview.html index e80ac1c2..57882d1a 100644 --- a/docs/settings-organizer/preview.html +++ b/docs/settings-organizer/preview.html @@ -1 +1 @@ -bdFun 设置预览
bdFun / 设置预览打开编排器
系统键盘区域(预览占位)
+bdFun 设置预览
bdFun / 设置预览打开编排器
系统键盘区域(预览占位)
diff --git a/docs/settings-organizer/preview.js b/docs/settings-organizer/preview.js index 42db0d15..52f62e55 100644 --- a/docs/settings-organizer/preview.js +++ b/docs/settings-organizer/preview.js @@ -1,4 +1,5 @@ 'use strict'; +let seperation=SettingsSchema.clone(SETTINGS_SEPERATION); let categories=SettingsSchema.clone(SETTINGS_CATALOG),route=null,detail=null,searchScope=null,searching=false; const values=Object.fromEntries(SettingsSchema.flatten(categories).map(s=>[s.pref_name,s.default])); const $=s=>document.querySelector(s),app=$('#app'),query=$('#query'),el=(tag,cls,text)=>{const e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e}; @@ -20,16 +21,26 @@ function settingRow(s){if(s.pref_name==='recommendation_error')return document.c else if(s.type==='font_scale'){row.classList.add('slider-row');const line=el('div','slider-line'),input=el('input'),value=el('span','',Math.trunc(values[s.pref_name]*100)+'%');input.type='range';input.min=.8;input.max=1.2;input.step=.01;input.value=values[s.pref_name];input.ariaLabel=s.title;input.oninput=()=>{let n=Number(input.value);if(Math.abs(n-1)<.025)n=1;values[s.pref_name]=n;value.textContent=Math.trunc(n*100)+'%'};line.append(input,value);row.append(line)} else if(s.type==='page_switches'){const fragment=document.createDocumentFragment();['首页','动态页','个人空间','收藏 / 历史 / 稍后再看','消息页','消息详情页'].forEach((title,i)=>{const key=['home','dynamic','space','library','message','message_detail'][i],part=el('div','row'),l=el('div','label'),b=el('button','switch');l.append(el('strong','',title));b.setAttribute('role','switch');b.ariaLabel=title;b.setAttribute('aria-checked',String(values[s.pref_name].includes(key)));b.onclick=()=>{const current=values[s.pref_name];values[s.pref_name]=current.includes(key)?current.filter(v=>v!==key):[...current,key];render()};part.append(l,b);fragment.append(part)});return fragment} else if(s.type==='version')row.append(el('span','trailing','preview-5.0')); - else{const tail=s.pref_name==='wallpaper_pick'?'未设置':s.pref_name==='discussion'?'Telegram 群':['logs','live_preview'].includes(s.pref_name)?'调试':'';if(tail)row.append(el('span','trailing',tail));row.append(el('span','chevron','›'));row.tabIndex=0;row.setAttribute('role','button');row.onclick=()=>{if(['help','changelog','donation','credits'].includes(s.pref_name)){detail=s;render()}else notify('预览模式:'+s.title+',不执行外部操作')};row.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();row.click()}};if(s.pref_name==='logout')label.style.color='#e5484d'} + else{const tail=s.pref_name==='wallpaper_pick'?'未设置':s.pref_name==='discussion'?'Telegram 群':['logs','live_preview'].includes(s.pref_name)?'调试':'';if(tail)row.append(el('span','trailing',tail));row.append(el('span','chevron','›'));row.tabIndex=0;row.setAttribute('role','button');row.onclick=()=>{if(s.pref_name==='logout'){showLogoutConfirmation();return}if(['help','changelog','donation','credits'].includes(s.pref_name)){detail=s;render()}else notify('预览模式:'+s.title+',不执行外部操作')};row.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();row.click()}};if(s.pref_name==='logout')label.style.color='#e5484d'} return row; } -function render(){app.style.setProperty('--blue',values.color_style===1?'#3482ff':'#ff6699');const all=available(),list=all.filter(c=>c.layout!=='root_footer'),footers=all.filter(c=>c.layout==='root_footer'),phone=app.classList.contains('phone'),selected=list.find(c=>c.route===route)||(!phone?list[0]:null);app.classList.toggle('detail',!!selected);const nav=$('#categories');nav.replaceChildren(header('设置',()=>notify('预览模式'),null));const card=el('div','card');list.forEach(c=>{const b=el('button','row category-row'+(selected?.route===c.route?' selected':''));b.append(el('span','label',c.name),el('span','chevron','›'));b.onclick=()=>openCategory(c.route);card.append(b)});nav.append(card);footers.forEach(c=>{const footer=el('div','card root-footer');c.groups.flatMap(g=>g.settings).forEach(s=>footer.append(settingRow(s)));nav.append(footer)});const content=$('#content');content.replaceChildren();content.classList.toggle('about',!detail&&selected?.layout==='about');if(selected&&detail){content.append(header(detail.title,()=>{detail=null;render()},null));const panel=el('div','card detail-preview');panel.append(el('p','hint','此处复用应用内的'+detail.title+'页面内容,返回后仍在'+selected.name+'。'));content.append(panel)}else if(selected){content.append(header(selected.name,phone?()=>{route=null;render()}:null,()=>startSearch(selected.route)));selected.groups.forEach(g=>{if(g.settings.every(s=>s.type==='app_identity')){g.settings.forEach(s=>content.append(identity(s)));return}content.append(el('h3','',g.title));const group=el('div','card');g.settings.forEach(s=>group.append(settingRow(s)));content.append(group)});content.onscroll=()=>{if(selected.layout!=='about')return;const p=Math.min(1,content.scrollTop/320);content.style.setProperty('--glow-alpha',1-p);content.querySelectorAll('.card').forEach(card=>card.style.opacity=.5+p*.5)};if(selected.layout==='about')content.onscroll()} +function render(){app.style.setProperty('--blue',values.color_style===1?'#3482ff':'#ff6699');const all=available(),list=all.filter(c=>c.layout!=='root_footer'),footers=all.filter(c=>c.layout==='root_footer'),phone=app.classList.contains('phone'),selected=list.find(c=>c.route===route)||(!phone?list[0]:null);app.classList.toggle('detail',!!selected);const nav=$('#categories');nav.replaceChildren(header('设置',()=>notify('预览模式'),null));seperation.forEach(group=>{const card=el('div','card navigation-group');group.entries.forEach(entry=>{const c=list.find(c=>c.route===entry.route);if(!c)return;const b=el('button','row category-row'+(selected?.route===c.route?' selected':'')),badge=el('span','category-icon'),img=el('img');badge.style.backgroundColor=entry.color;img.src='assets/miuix/'+entry.icon+'.svg';img.alt='';badge.append(img);b.append(badge,el('span','label',c.name),el('span','chevron','›'));b.onclick=()=>openCategory(c.route);card.append(b)});if(card.children.length)nav.append(card)});footers.forEach(c=>{const footer=el('div','card root-footer');c.groups.flatMap(g=>g.settings).forEach(s=>footer.append(settingRow(s)));nav.append(footer)});const content=$('#content');content.replaceChildren();content.classList.toggle('about',!detail&&selected?.layout==='about');if(selected&&detail){content.append(header(detail.title,()=>{detail=null;render()},null));const panel=el('div','card detail-preview');panel.append(el('p','hint','此处复用应用内的'+detail.title+'页面内容,返回后仍在'+selected.name+'。'));content.append(panel)}else if(selected){content.append(header(selected.name,phone?()=>{route=null;render()}:null,()=>startSearch(selected.route)));selected.groups.forEach(g=>{if(g.settings.every(s=>s.type==='app_identity')){g.settings.forEach(s=>content.append(identity(s)));return}content.append(el('h3','',g.title));const group=el('div','card');g.settings.forEach(s=>group.append(settingRow(s)));content.append(group)});content.onscroll=()=>{if(selected.layout!=='about')return;const p=Math.min(1,content.scrollTop/320);content.style.setProperty('--glow-alpha',1-p);content.querySelectorAll('.card').forEach(card=>card.style.opacity=.5+p*.5)};if(selected.layout==='about')content.onscroll()} if(searching)showResults(); } function showResults(){const result=$('#results');result.replaceChildren(header(searchScope?'搜索当前分类':'搜索设置',closeSearch,null));const words=query.value.trim().toLowerCase().split(/\s+/).filter(Boolean);if(!words.length){result.append(el('p','hint','搜索设置名称、说明或关键词'));return}let count=0;available().filter(c=>!searchScope||c.route===searchScope).forEach(c=>c.groups.forEach(g=>g.settings.forEach(s=>{const text=[c.name,g.title,s.title,s.subtitle||'',s.pref_name].join(' ').toLowerCase();if(words.every(w=>text.includes(w))){if(c.layout==='root_footer'){const block=el('div','card result');block.append(settingRow(s));result.append(block);count++;return}const b=el('button','row result'),label=el('div','label');label.append(el('strong','',s.title),el('small','',c.name+' · '+g.title));b.append(label);b.onclick=()=>openCategory(c.route,s.pref_name);result.append(b);count++}})));if(!count)result.append(el('p','hint','没有找到相关设置'))} query.onfocus=()=>{if(!searching)startSearch()};query.oninput=showResults;query.onkeydown=e=>{if(e.key==='Escape')closeSearch()};$('#close').onclick=closeSearch;$('#logged').onchange=render;$('#debug').onchange=render;$('#ime').onchange=()=>app.classList.toggle('ime',$('#ime').checked);$('#toggle-width').onclick=()=>{app.classList.toggle('phone');render()};$('#toggle-dark').onclick=()=>document.body.classList.toggle('dark'); -window.addEventListener('message',e=>{if(e.source!==parent||e.data?.type!=='settings-layout')return;try{categories=SettingsSchema.validate(e.data.categories,SETTINGS_CATALOG);detail=null;route=e.data.route||null;closeSearch();app.classList.toggle('phone',!e.data.wide);document.body.classList.toggle('dark',Boolean(e.data.dark));render()}catch(error){notify('预览未更新:'+error.message)}}); +window.addEventListener('message',e=>{if(e.source!==parent||e.data?.type!=='settings-layout')return;try{categories=SettingsSchema.validate(e.data.categories,SETTINGS_CATALOG);seperation=SettingsSeperation.reconcile(e.data.seperation||seperation,categories);detail=null;route=e.data.route||null;closeSearch();app.classList.toggle('phone',!e.data.wide);document.body.classList.toggle('dark',Boolean(e.data.dark));render()}catch(error){notify('预览未更新:'+error.message)}}); render(); function sizePanes(){const left=Math.min(Math.max(app.clientWidth*.3,360),app.clientWidth);app.style.setProperty('--left-width',left+'px')} new ResizeObserver(sizePanes).observe(app);sizePanes(); + +function showLogoutConfirmation(){ + if(document.querySelector('#logout-dialog'))return; + const dialog=el('dialog','logout-dialog');dialog.id='logout-dialog'; + dialog.append(el('h2','','退出登录?'),el('p','','确认退出当前账号?')); + const actions=el('div','dialog-actions'),cancel=el('button','','取消'),confirm=el('button','confirm','退出登录'); + cancel.onclick=()=>{dialog.close();dialog.remove()}; + confirm.onclick=()=>{dialog.close();dialog.remove();$('#logged').checked=false;render();notify('预览:已切换为未登录状态')}; + dialog.oncancel=()=>dialog.remove();actions.append(cancel,confirm);dialog.append(actions);app.append(dialog);dialog.showModal(); +} diff --git a/docs/settings-organizer/schema.js b/docs/settings-organizer/schema.js index bff4b398..e7d5418c 100644 --- a/docs/settings-organizer/schema.js +++ b/docs/settings-organizer/schema.js @@ -13,7 +13,7 @@ function validate(candidate,baseline){ const text=(value,label)=>{if(typeof value!=='string'||!value.trim()||value.length>200)throw Error(label+'不能为空或超过200字');}; candidate.forEach(c=>{text(c.name,'分类名称');text(c.route,'分类路由');if(routes.has(c.route))throw Error('分类路由重复:'+c.route);routes.add(c.route); if(!['settings','about','root_footer'].includes(c.layout||'settings'))throw Error('未知页面布局'); - if(c.icon!=null)throw Error('第一阶段不配置分类图标'); + if(c.icon!=null)throw Error('一级图标请在seperation中配置'); if(!Array.isArray(c.groups)||c.groups.length>100)throw Error('分组格式不正确'); c.groups.forEach(g=>{text(g.title,'分组标题');if(!Array.isArray(g.settings))throw Error('设置列表格式不正确'); g.settings.forEach(s=>{if(s.pref_name==='logout'&&c.layout!=='root_footer')throw Error('退出登录必须保留在一级页面底部');if(c.layout==='root_footer'&&(s.type!=='action'||s.storage!=='none'))throw Error('一级底部仅允许独立操作');text(s.title,'设置名称');if(s.subtitle!=null&&(typeof s.subtitle!=='string'||s.subtitle.length>1000))throw Error('说明格式不正确'); diff --git a/docs/settings-organizer/seperation-catalog.js b/docs/settings-organizer/seperation-catalog.js new file mode 100644 index 00000000..3d94dffc --- /dev/null +++ b/docs/settings-organizer/seperation-catalog.js @@ -0,0 +1,57 @@ +// Generated by tools/sync-settings-catalog.py; source: app assets. +globalThis.SETTINGS_SEPERATION = [ + { + "id": "appearance", + "title": "个性化", + "entries": [ + { + "route": "settings/appearance", + "icon": "Background", + "color": "#FF9F0A" + } + ] + }, + { + "id": "playback", + "title": "观看体验", + "entries": [ + { + "route": "settings/player", + "icon": "Play", + "color": "#3482FF" + }, + { + "route": "settings/video", + "icon": "Messages", + "color": "#30B961" + } + ] + }, + { + "id": "content", + "title": "内容与个人", + "entries": [ + { + "route": "settings/feeds", + "icon": "Home", + "color": "#FF5D73" + }, + { + "route": "settings/mine", + "icon": "ContactsCircle", + "color": "#AF65D9" + } + ] + }, + { + "id": "about", + "title": "应用信息", + "entries": [ + { + "route": "settings/about", + "icon": "Info", + "color": "#778795" + } + ] + } +]; diff --git a/docs/settings-organizer/seperation.js b/docs/settings-organizer/seperation.js new file mode 100644 index 00000000..3e3b143d --- /dev/null +++ b/docs/settings-organizer/seperation.js @@ -0,0 +1,29 @@ +/* 一级入口只引用二级route;不能复制设置合同或包含root_footer。 */ +(function(root){ +'use strict'; +const clone=x=>JSON.parse(JSON.stringify(x)),icons=['Background','Play','Messages','Home','ContactsCircle','Info','Settings']; +function validate(candidate,categories){ + if(!Array.isArray(candidate)||!candidate.length||candidate.length>100)throw Error('一级分组数量不正确'); + const expected=new Set(categories.filter(c=>c.layout!=='root_footer').map(c=>c.route)),seen=new Set(),ids=new Set(); + candidate.forEach(group=>{ + if(typeof group.id!=='string'||!(/^[a-z][a-z0-9_]*$/).test(group.id)||ids.has(group.id))throw Error('一级分组ID为空、非法或重复');ids.add(group.id); + if(typeof group.title!=='string'||!group.title.trim()||!Array.isArray(group.entries)||!group.entries.length)throw Error('一级分组名称或入口列表不正确'); + group.entries.forEach(entry=>{ + if(!expected.has(entry.route)||seen.has(entry.route))throw Error('一级入口未知、重复或包含退出登录:'+entry.route);seen.add(entry.route); + if(!icons.includes(entry.icon))throw Error('请选择已支持的MIUIX图标'); + if(typeof entry.color!=='string'||!/^#[0-9a-fA-F]{6}$/.test(entry.color))throw Error('图标底色必须为RGB颜色'); + }); + }); + if(seen.size!==expected.size)throw Error('一级索引缺少二级页面');return clone(candidate); +} +function reconcile(candidate,categories){ + const routes=new Set(categories.filter(c=>c.layout!=='root_footer').map(c=>c.route)),seen=new Set(); + const groups=clone(candidate).map(g=>({...g,entries:g.entries.filter(e=>routes.has(e.route)&&!seen.has(e.route)&&seen.add(e.route))})).filter(g=>g.entries.length); + categories.filter(c=>routes.has(c.route)&&!seen.has(c.route)).forEach(c=>{ + if(!groups.length)groups.push({id:'general',title:'通用',entries:[]}); + groups[groups.length-1].entries.push({route:c.route,icon:'Settings',color:'#778795'}); + }); + return groups; +} +root.SettingsSeperation={validate,reconcile,icons}; +})(globalThis); diff --git a/infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsSeperationCodec.kt b/infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsSeperationCodec.kt new file mode 100644 index 00000000..51b038f7 --- /dev/null +++ b/infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsSeperationCodec.kt @@ -0,0 +1,29 @@ +package com.aritxonly.bdfun.infra.storage + +import com.aritxonly.bdfun.core.domain.* +import com.aritxonly.bdfun.core.model.settings.* +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +class JsonSettingsSeperationCodec : SettingsSeperationCodec { + override fun decode(json: String, index: SettingsIndex): SettingsSeperationImport = try { + require(json.length <= 1_048_576) { "索引超过1MiB" } + val array = JSONArray(json) + val groups = (0 until array.length()).map { at -> + val group = array.getJSONObject(at) + val entries = group.getJSONArray("entries") + SettingsSeperation(group.text("id"), group.text("title"), (0 until entries.length()).map { row -> + val entry = entries.getJSONObject(row) + SettingsEntry(entry.text("route"), entry.text("icon"), entry.text("color")) + }) + } + SettingsSeperationImport.Success(SettingsSeperationIndex(groups, index)) + } catch (error: IllegalArgumentException) { + SettingsSeperationImport.Invalid(error.message ?: "一级索引不合法") + } catch (error: JSONException) { + SettingsSeperationImport.Invalid(error.message ?: "一级JSON不合法") + } + private fun JSONObject.text(key: String): String = + get(key) as? String ?: throw IllegalArgumentException("$key 必须是字符串") +} diff --git a/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsSeperationCodecTest.kt b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsSeperationCodecTest.kt new file mode 100644 index 00000000..c24c5bd7 --- /dev/null +++ b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsSeperationCodecTest.kt @@ -0,0 +1,24 @@ +package com.aritxonly.bdfun.infra.storage + +import com.aritxonly.bdfun.core.domain.* +import org.junit.Assert.* +import org.junit.Test +import java.io.File + +class JsonSettingsSeperationCodecTest { + private fun index(): SettingsIndex = SettingsIndex((JsonSettingsIndexCodec().decode( + File("../../app/src/main/assets/settings-index.json").readText()) as SettingsIndexImport.Success).categories) + @Test fun bundledSeperationCoversAllSixPagesInFourGroups() { + val decoded = JsonSettingsSeperationCodec().decode(File("../../app/src/main/assets/settings-seperation.json").readText(), index()) + assertTrue(decoded.toString(), decoded is SettingsSeperationImport.Success) + val groups = (decoded as SettingsSeperationImport.Success).index.groups + assertEquals(4, groups.size) + assertEquals(6, groups.sumOf { it.entries.size }) + } + @Test fun malformedOrPartialImportsAreRejectedAtomically() { + val codec = JsonSettingsSeperationCodec() + listOf("broken", "[]", "[{}]", "[{\"id\":\"x\",\"title\":\"x\",\"entries\":[]}]").forEach { + assertTrue(codec.decode(it, index()) is SettingsSeperationImport.Invalid) + } + } +} diff --git a/tools/sync-settings-catalog.py b/tools/sync-settings-catalog.py index 4d25e8f5..6c2dfbaf 100644 --- a/tools/sync-settings-catalog.py +++ b/tools/sync-settings-catalog.py @@ -9,3 +9,8 @@ data = json.loads(source.read_text()) target.write_text('// Generated by tools/sync-settings-catalog.py; source: app assets.\n' + 'window.SETTINGS_CATALOG = ' + json.dumps(data, ensure_ascii=False, indent=2) + ';\n') + +seperation = json.loads((root / 'app/src/main/assets/settings-seperation.json').read_text()) +(root / 'docs/settings-organizer/seperation-catalog.js').write_text( + '// Generated by tools/sync-settings-catalog.py; source: app assets.\n' + + 'globalThis.SETTINGS_SEPERATION = ' + json.dumps(seperation, ensure_ascii=False, indent=2) + ';\n') diff --git a/tools/test-settings-organizer.cjs b/tools/test-settings-organizer.cjs index 2cf4bcd5..1b6b6e8b 100644 --- a/tools/test-settings-organizer.cjs +++ b/tools/test-settings-organizer.cjs @@ -43,3 +43,31 @@ missing[0].groups[0].settings.pop(); assert.throws(() => validate(missing, baseline)); assert.equal(JSON.stringify(baseline), snapshot, '失败校验不能更改输入基准'); console.log(`PASS: ${flatten(baseline).length} components, full catalog sync, reorganization, contract guards, property order and failed import isolation`); +for (const file of ['seperation-catalog.js', 'seperation.js']) { + vm.runInContext(fs.readFileSync(path.join(root, 'docs/settings-organizer', file), 'utf8'), context); +} +const seperation = context.SETTINGS_SEPERATION; +const rootIndex = JSON.parse(fs.readFileSync(path.join(root, 'app/src/main/assets/settings-seperation.json'), 'utf8')); +assert.equal(JSON.stringify(seperation), JSON.stringify(rootIndex), '一级索引必须与App同步'); +context.SettingsSeperation.validate(seperation, baseline); +for (const mutate of [ + value => value[0].entries.pop(), + value => value[0].entries.push(clone(value[1].entries[0])), + value => value[0].entries[0].route = 'settings/account', + value => value[0].entries[0].icon = 'Unknown', + value => value[0].entries[0].color = 'red', + value => value[0].id = value[1].id, +]) { + const invalid = clone(seperation); mutate(invalid); + assert.throws(() => context.SettingsSeperation.validate(invalid, baseline)); +} +const grouped = clone(seperation); +grouped[1].entries.push(grouped[0].entries.pop());grouped.shift(); +context.SettingsSeperation.validate(grouped, baseline); +const extended = clone(baseline);extended.push({name:'新页面',route:'settings/new',groups:[]}); +context.SettingsSeperation.validate(context.SettingsSeperation.reconcile(seperation, extended), extended); +for (const group of seperation) for (const entry of group.entries) { + const svg=fs.readFileSync(path.join(root,'docs/settings-organizer/assets/miuix',entry.icon+'.svg'),'utf8'); + assert(svg.includes('