diff --git a/AGENTS.md b/AGENTS.md index cf48ff9f..97f5b4b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -581,3 +581,21 @@ 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 页面尚未替换。 + +### 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不得改成三级页面。一级入口现按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/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 @@ 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/data/SettingsStore.kt b/app/src/main/kotlin/com/aritxonly/bdfun/data/SettingsStore.kt index fd534dbf..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,5 +11,28 @@ 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( + index, this, state, scope, loadSettingsSeperation(context, index), + ) + 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) + } +} + +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/IndexedSettingBindings.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingBindings.kt new file mode 100644 index 00000000..30d5502c --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingBindings.kt @@ -0,0 +1,109 @@ +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) + +/** 保留原控制器的约束和通知;动作与独立存储不伪装为 settings 偏好。 */ +internal fun indexedSettingBindings(repository: SettingsRepository): Map = + appearanceBindings(repository) + playbackBindings(repository) + otherBindings(repository) + +private fun appearanceBindings(repository: SettingsRepository) = mapOf( + "home_card_style" to IndexedSettingBinding({ SettingValue.Number(repository.appearance.homeCardStyle) }, + { repository.appearance.homeCardStyle = (it as SettingValue.Number).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 }), + "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( + "show_disc_after_video" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.showDiscAfterVideo && !repository.playback.stopOnVideoPageExit) }, + { repository.playback.showDiscAfterVideo = (it as SettingValue.Flag).value; repository.playback.stopOnVideoPageExit = !it.value }), + "exit_video_control" to IndexedSettingBinding({ SettingValue.Number(repository.playback.exitVideoControl) }, + { repository.playback.exitVideoControl = (it as SettingValue.Number).value }), + "disc_breath_enabled" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.discBreathEnabled) }, + { repository.playback.discBreathEnabled = (it as SettingValue.Flag).value }), + "disc_breath_cover_color" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.discBreathCoverColor) }, + { repository.playback.discBreathCoverColor = (it as SettingValue.Flag).value }), + "background_play" to IndexedSettingBinding({ SettingValue.Flag(repository.playback.backgroundPlay) }, + { repository.playback.backgroundPlay = (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 new file mode 100644 index 00000000..6395a118 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/IndexedSettingsStateHolder.kt @@ -0,0 +1,61 @@ +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, + val seperation: List = emptyList(), +) { + 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(stored.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 (setting.storage != "settings") 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 = 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..e8c1e842 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolder.kt @@ -0,0 +1,71 @@ +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, + val detail: String? = null, + val confirmLogout: Boolean = false, +) + +/** 页面拥有自己的搜索和导航;旋转只改变布局,不另建一份选择状态。 */ +internal class SettingsBrowserStateHolder(initial: SettingsBrowserState = SettingsBrowserState()) { + private val mutable = MutableStateFlow(initial) + val state = mutable.asStateFlow() + + fun open(route: String, prefName: String? = null) { + mutable.update { it.copy(route = route, searching = false, searchRoute = null, query = "", highlight = prefName, detail = null) } + } + fun search(route: String? = null) { + 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 } + 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 com.aritxonly.bdfun.core.domain.SettingsVisibility.matches(condition, values) + } + +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) + +/** 旧倍速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/feature/settings/application/SettingsSplitPolicy.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicy.kt new file mode 100644 index 00000000..9a4dce2c --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicy.kt @@ -0,0 +1,9 @@ +package com.aritxonly.bdfun.feature.settings.application + +/** 固定3:7,无分隔线;窄横屏优先保障一级列表360dp。 */ +internal object SettingsSplitPolicy { + const val DefaultFraction = .3f + const val MinimumLeftDp = 360f + 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/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..f3976c60 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/IndexedSettingsComponents.kt @@ -0,0 +1,100 @@ +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.bdfun.feature.settings.application.indexedOptionIndex +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 ?: indexedOptionIndex(entry, value), + 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..054cc272 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowser.kt @@ -0,0 +1,51 @@ +package com.aritxonly.bdfun.ui + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +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.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import com.aritxonly.bdfun.BuildConfig +import com.aritxonly.bdfun.feature.settings.application.* + +@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 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.highlight) { + navigation.highlight?.let { kotlinx.coroutines.delay(1800); browser.clearHighlight(it) } + } + 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()) +} + +@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, 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") }, + confirmLogout = it.getOrNull(5)?.toBoolean() ?: false)) + }) + } + 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..3d7c04c0 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserChrome.kt @@ -0,0 +1,84 @@ +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 + 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) + .then(if (!searching) Modifier.clickable(role = Role.Button) { session.browser.search() } else Modifier)) { + 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) + else Text("搜索设置", color = MiuixTheme.colorScheme.onSurfaceVariantSummary) + } + } + AnimatedVisibility(searching, enter = fadeIn() + expandHorizontally(), exit = fadeOut() + shrinkHorizontally()) { + TeakGlassIconButton(onClick = session.browser::closeSearch, size = 48.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..98b5314a --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserLists.kt @@ -0,0 +1,137 @@ +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, + val footers: List = emptyList(), + val seperation: List = emptyList(), +) + +@Composable +internal fun SettingsCategoryList(session: SettingsBrowserSession, modifier: Modifier, runtime: SettingsPageRuntime) { + val list = rememberLazyListState() + val chrome = LocalTeakPageScrollState.current + if (!session.navigation.searching) TrackTeakPageScroll(list, chrome) + LazyColumn(modifier, state = list, contentPadding = settingsListPadding()) { + item { TeakLargeTitle("设置", pageState = chrome) } + 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) }) + } + } + } + } + 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)) } + } +} + +@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 ?: return@LaunchedEffect + val group = category.groups.indexOfFirst { g -> g.settings.any { it.prefName == key } } + if (group >= 0) list.animateScrollToItem(group + 1) + } + 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, runtime: SettingsPageRuntime) { + 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 { + 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) }) + } + } + } + } +} + +@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/SettingsBrowserPane.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt new file mode 100644 index 00000000..0c4f84e5 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsBrowserPane.kt @@ -0,0 +1,124 @@ +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 detail = session.navigation.detail?.takeIf { pane != SettingsPane.Categories }?.let { SettingsDestination.valueOf(it) } + val title = if (detail != null) settingsDetailTitle(detail) else 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, + com.aritxonly.teak.common.chrome.LocalWideFoldTopBarOverride provides if (session.wide) true else null) { + val navigation = runtime.navigation + 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) + 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) + if (categories) SettingsLogoutDialog(session.browser, runtime.navigation.onLogout) + } + } + } +} + +@Composable +private fun SettingsPaneScaffold(runtime: SettingsPageRuntime, pane: SettingsPane, title: String, + 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, 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) }, + 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/SettingsCategoryIcon.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsCategoryIcon.kt new file mode 100644 index 00000000..dc986773 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsCategoryIcon.kt @@ -0,0 +1,32 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.aritxonly.bdfun.core.model.settings.SettingsEntry +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.* + +@Composable +internal fun SettingsCategoryIcon(entry: SettingsEntry) { + val icon = when (entry.icon) { + "Background" -> 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/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/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/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/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/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/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..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 @@ -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,21 @@ 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() + 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..1563c875 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsSplitLayout.kt @@ -0,0 +1,27 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +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 + +@Composable +internal fun SettingsSplitLayout(session: SettingsBrowserSession, values: Map, + runtime: SettingsPageRuntime, controller: IndexedSettingsStateHolder) { + TeakBareTheme(runtime.settings) { _, _ -> + BoxWithConstraints(Modifier.fillMaxSize()) { + val total = maxWidth.value + val left = SettingsSplitPolicy.leftWidth(total) + Row(Modifier.fillMaxSize()) { + SettingsBrowserPane(session, values, runtime, controller, SettingsPane.Categories, + Modifier.width(left.dp).fillMaxHeight().clipToBounds()) + SettingsPaneNavigation(session, values, runtime, controller, SettingsPane.Details, + Modifier.weight(1f).fillMaxHeight().clipToBounds()) + } + } + } +} 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/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/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 new file mode 100644 index 00000000..28502754 --- /dev/null +++ b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsBrowserStateHolderTest.kt @@ -0,0 +1,98 @@ +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))), layout = "root_footer"), + )) + 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 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 detailBackPreservesSelectedCategoryAndSearchClosesFirst() { + val page = SettingsBrowserStateHolder() + 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") + 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/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..dca4abec --- /dev/null +++ b/app/src/test/kotlin/com/aritxonly/bdfun/feature/settings/application/SettingsSplitPolicyTest.kt @@ -0,0 +1,21 @@ +package com.aritxonly.bdfun.feature.settings.application + +import org.junit.Assert.* +import org.junit.Test + +class SettingsSplitPolicyTest { + @Test fun defaultsToThreeToSevenWith360Minimum() { + assertEquals(480f, SettingsSplitPolicy.leftWidth(1600f), .001f) + assertEquals(360f, SettingsSplitPolicy.leftWidth(1000f), .001f) + assertEquals(360f, SettingsSplitPolicy.leftWidth(600f), .001f) + } + @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) + } + 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 new file mode 100644 index 00000000..102e80aa --- /dev/null +++ b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/SettingsIndex.kt @@ -0,0 +1,69 @@ +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.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") || + 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) + 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", "switch", "mine_switch" -> value is SettingValue.Flag + "string", "cdn_dropdown" -> value is SettingValue.Text + "integer" -> value is SettingValue.Number + "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/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/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/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/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/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..a0fd641c --- /dev/null +++ b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/settings/SettingsIndex.kt @@ -0,0 +1,19 @@ +package com.aritxonly.bdfun.core.model.settings + +/** 索引只描述展示与值约束,不承载 Android 动作或存储实现。 */ +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, + val type: String, + val title: String, + 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/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_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 new file mode 100644 index 00000000..3a19c50b --- /dev/null +++ b/docs/SETTINGS_INDEX_DESIGN.md @@ -0,0 +1,52 @@ +# SET-01 / SET-02 · 索引设置与管理者编排器 + +## 运行时结构 + +App 的唯一布局来源是 `app/src/main/assets/settings-index.json`。本轮以原生设置页完整清单重新建立索引,替换原型的36项示例;63个组件描述覆盖原全部设置、操作、开发者行,另含关于身份区与PR52的三个可选推荐组件。page_switches仍展开原来的六个页面开关。没有旧设置页兜底入口,原四个手写卡片文件已删除。 + +竖屏为分类列表与二级页;横屏且宽度至少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。 + +## JSON 合同 + +顶层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保留减弱动效的反向展示。 + +storage为settings(原偏好文件)、mine_layout(独立我的页存储)或none(操作/只读/扩展组件)。只有settings键由IndexedSettingsStateHolder经过原分组控制器写入;动作标识不是数据库键,不按动作id创建偏好。mine_switch继续使用MineLayoutStateHolder。导入只读布局,不重置用户偏好。default供显式重置与合同校验,现有仓储默认值兼容保留。 + +visible_when可为debug、logged_in、布尔偏好键,或extension:<组件标识>。推荐开关/清除/错误行通过可选Composable端口注入,PR55可独立编译;与PR52集成后接原优化器,仍保持默认关闭与原动作语义。没有从展示层构造数据实现。 + +索引验证拒绝重复键/路由、未知类型、非法默认值、混合枚举值类型与无效显隐条件。JSON解码在infra/storage,模型在core/model,验证与搜索在core/domain,状态/导航在feature/settings/application,Android装配在SettingsStore和路由壳。 + +## 管理者工具 + +浏览器直接打开`docs/settings-organizer/index.html`,无构建步骤、无服务端、无外部资源。支持: + +- 分类/分组/设置拖动、上移下移、跨分类或分组移动;添加/删除空分类与空分组。 +- 分类名称/路由、分组标题、设置标题/说明编辑;完整组件目录可检索。 +- 实时横竖屏与深浅色预览,dropdown/开关/字号可操作,预览不执行外部动作;登录/debug可切换。 +- 完整JSON导入导出、本地草稿、100步撤销/重做及键盘快捷键。 + +导入必须包含基准全部组件且每项仅出现一次;类型、存储键、默认值、选项、显隐和值类型合同不可更改。非法导入不替换当前布局。导出替换App资产并重新构建生效。每次更新App索引运行`python3 tools/sync-settings-catalog.py`同步离线目录;`node tools/test-settings-organizer.cjs`验证同步与合同。原型旧路径转向完整索引预览,避免两份索引漂移。 + +## 关于页 + +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。完整播放器、登录后的真实账号服务及所有设备/字体组合的长期回归仍由装机测试完成。 + +用户调整索引保留分类/分组/排序;删除补偿延迟、查看开始页、直播间预览。补回首页卡片样式与播放器末尾后台播放组,继续使用原存储键和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/prototypes/settings/index.html b/docs/prototypes/settings/index.html new file mode 100644 index 00000000..c0333869 --- /dev/null +++ b/docs/prototypes/settings/index.html @@ -0,0 +1 @@ +bdFun 设置预览打开使用完整 App 索引的设置预览 diff --git a/docs/settings-organizer/README.md b/docs/settings-organizer/README.md new file mode 100644 index 00000000..796f959f --- /dev/null +++ b/docs/settings-organizer/README.md @@ -0,0 +1,11 @@ +# 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` 同步此工具的目录数据。请保留完整组件清单和原组件/存储/默认值/选项/显隐合同;一级分组与MIUIX图标由独立的`settings-seperation.json`配置。退出登录固定为一级页底部独立操作,不能移到二级页;横屏预览是独立双栏,左栏至少360px、默认3:7,无分隔线且不可拖动。推荐扩展在合入 PR52 的构建中生效。 + +一级入口索引可单独导入/导出,替换同名App资产后构建;每个二级route恰好引用一次,不能包括退出登录。分类属性可选所属分组、MIUIX图标与底色;展开一级索引可编辑分组名称、顺序或新增分组后应用。二级页面索引与一级索引须一起交付。离线图标取自固定Miuix Normal矢量,授权见assets/miuix/。 diff --git a/docs/settings-organizer/assets/app.png b/docs/settings-organizer/assets/app.png new file mode 100644 index 00000000..2e90ea74 Binary files /dev/null and b/docs/settings-organizer/assets/app.png differ diff --git a/docs/settings-organizer/assets/aritx.jpg b/docs/settings-organizer/assets/aritx.jpg new file mode 100644 index 00000000..5015a9f1 Binary files /dev/null and b/docs/settings-organizer/assets/aritx.jpg differ 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/assets/teak.jpg b/docs/settings-organizer/assets/teak.jpg new file mode 100644 index 00000000..76f13e7c Binary files /dev/null and b/docs/settings-organizer/assets/teak.jpg differ diff --git a/docs/settings-organizer/catalog.js b/docs/settings-organizer/catalog.js new file mode 100644 index 00000000..7f754b19 --- /dev/null +++ b/docs/settings-organizer/catalog.js @@ -0,0 +1,794 @@ +// 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": "解决未开启安卓导航条时底栏位置过低的问题" + }, + { + "pref_name": "font_scale", + "type": "font_scale", + "title": "全局字号", + "default": 1, + "value_type": "decimal" + } + ] + }, + { + "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": "" + } + ] + } + ] + }, + { + "name": "播放器", + "route": "settings/player", + "groups": [ + { + "title": "界面与手势", + "settings": [ + { + "pref_name": "show_viewer_count", + "type": "switch", + "title": "显示正在观看人数", + "default": true + }, + { + "pref_name": "show_fullscreen_actions", + "type": "switch", + "title": "全屏显示互动按钮", + "default": true + }, + { + "pref_name": "show_fullscreen_time", + "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, + "options": [ + { + "title": "1.5×", + "value": 1.5 + }, + { + "title": "2×", + "value": 2 + }, + { + "title": "2.5×", + "value": 2.5 + }, + { + "title": "3×", + "value": 3 + }, + { + "title": "4×", + "value": 4 + }, + { + "title": "5×", + "value": 5 + } + ], + "value_type": "decimal" + }, + { + "pref_name": "default_playback_speed", + "type": "dropdown", + "title": "默认播放倍率", + "default": 1, + "options": [ + { + "title": "0.5×", + "value": 0.5 + }, + { + "title": "0.75×", + "value": 0.75 + }, + { + "title": "1×", + "value": 1 + }, + { + "title": "1.25×", + "value": 1.25 + }, + { + "title": "1.5×", + "value": 1.5 + }, + { + "title": "2×", + "value": 2 + }, + { + "title": "2.5×", + "value": 2.5 + }, + { + "title": "3×", + "value": 3 + } + ], + "value_type": "decimal" + }, + { + "pref_name": "progress_bar_mode", + "type": "dropdown", + "title": "进度条常显", + "default": 0, + "options": [ + { + "title": "关闭", + "value": 0 + }, + { + "title": "仅半屏时常显", + "value": 1 + }, + { + "title": "仅全屏时常显", + "value": 2 + }, + { + "title": "永久显示", + "value": 3 + } + ] + }, + { + "pref_name": "auto_rotate", + "type": "switch", + "title": "自动旋转", + "default": true + } + ] + }, + { + "title": "播放与解码", + "settings": [ + { + "pref_name": "auto_play", + "type": "switch", + "title": "进入视频自动播放", + "default": true + }, + { + "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": "resume_playback", + "type": "switch", + "title": "播放进度记忆", + "default": true + }, + { + "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": "如发现播放问题,请选择原地址" + } + ] + }, + { + "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 + } + ] + }, + { + "title": "声音与蓝牙", + "settings": [ + { + "pref_name": "loudness_eq", + "type": "switch", + "title": "响度均衡", + "default": false + }, + { + "pref_name": "bt_latency_enabled", + "type": "switch", + "title": "蓝牙耳机延迟补偿", + "default": false, + "subtitle": "检测到蓝牙耳机设备时自动校准播放延迟" + } + ] + }, + { + "title": "后台播放", + "settings": [ + { + "pref_name": "show_disc_after_video", + "type": "switch", + "title": "退出视频后继续播放", + "default": true + }, + { + "pref_name": "exit_video_control", + "type": "dropdown", + "title": "播放控件", + "default": 1, + "options": [ + { + "title": "画中画", + "value": 0 + }, + { + "title": "唱片", + "value": 1 + } + ], + "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" + } + ] + } + ] + }, + { + "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 + } + ] + } + ] + }, + { + "name": "首页、动态与搜索", + "route": "settings/feeds", + "groups": [ + { + "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", + "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": "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 + }, + { + "pref_name": "sent_history_entry", + "type": "switch", + "title": "显示「我的评论与弹幕」入口", + "default": true + } + ] + } + ] + }, + { + "name": "关于", + "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": "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": "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" + } + ] + } + ], + "layout": "root_footer" + } +]; diff --git a/docs/settings-organizer/index.html b/docs/settings-organizer/index.html new file mode 100644 index 00000000..cc7fe3cf --- /dev/null +++ b/docs/settings-organizer/index.html @@ -0,0 +1,6 @@ + +bdFun 设置编排 +
bdFun / 设置编排

正在读取完整设置索引

+

页面结构

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

编排属性

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

一级入口分组(seperation)

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

导出内容

控件类型、选项、默认值和显隐条件来自现有实现。这里只调整组织与文案,导出的文件替换 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..860ef862 --- /dev/null +++ b/docs/settings-organizer/organizer.js @@ -0,0 +1,53 @@ +'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)} +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 关于页':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,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})}; +$('#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(); + +$('#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 new file mode 100644 index 00000000..a63e37a6 --- /dev/null +++ b/docs/settings-organizer/preview.css @@ -0,0 +1,19 @@ +: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} +.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} +#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: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} + +.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 new file mode 100644 index 00000000..57882d1a --- /dev/null +++ b/docs/settings-organizer/preview.html @@ -0,0 +1 @@ +bdFun 设置预览
bdFun / 设置预览打开编排器
系统键盘区域(预览占位)
diff --git a/docs/settings-organizer/preview.js b/docs/settings-organizer/preview.js new file mode 100644 index 00000000..52f62e55 --- /dev/null +++ b/docs/settings-organizer/preview.js @@ -0,0 +1,46 @@ +'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}; +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||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;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)); + 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=()=>{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));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);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 new file mode 100644 index 00000000..e7d5418c --- /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','root_footer'].includes(c.layout||'settings'))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('说明格式不正确'); + 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/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/JsonSettingsIndexCodec.kt b/infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodec.kt new file mode 100644 index 00000000..c998712c --- /dev/null +++ b/infra/storage/src/main/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodec.kt @@ -0,0 +1,59 @@ +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)) + }, category.optionalText("layout") ?: "settings") + } + 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"), typedValue(json.get("default"), json.optionalText("value_type")), + if (json.has("options")) json.getJSONArray("options").objects().map { + 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) + 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/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/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 new file mode 100644 index 00000000..36e515c0 --- /dev/null +++ b/infra/storage/src/test/kotlin/com/aritxonly/bdfun/infra/storage/JsonSettingsIndexCodecTest.kt @@ -0,0 +1,82 @@ +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 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 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()) + assertTrue(result.toString(), result is SettingsIndexImport.Success) + assertEquals(63, SettingsIndex((result as SettingsIndexImport.Success).categories).settings.size) + } +} 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 new file mode 100644 index 00000000..6c2dfbaf --- /dev/null +++ b/tools/sync-settings-catalog.py @@ -0,0 +1,16 @@ +#!/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') + +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 new file mode 100644 index 00000000..1b6b6e8b --- /dev/null +++ b/tools/test-settings-organizer.cjs @@ -0,0 +1,73 @@ +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', + value => value.find(c => c.layout === 'root_footer').layout = 'settings', +]) { + 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`); +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('