diff --git a/app/shared/app-lang/src/androidMain/res/values-zh-rCN/strings.xml b/app/shared/app-lang/src/androidMain/res/values-zh-rCN/strings.xml
index d7a62a7b5f..7ce2135370 100644
--- a/app/shared/app-lang/src/androidMain/res/values-zh-rCN/strings.xml
+++ b/app/shared/app-lang/src/androidMain/res/values-zh-rCN/strings.xml
@@ -236,6 +236,9 @@
排序
终止测试
开始测试
+ 清除无效源(%1$d)
+ 确定要删除 %1$d 个无效数据源吗?
+ 已清除 %1$d 个无效源,失败 %2$d 个。
删除数据源
该数据源无特殊配置,删除后可以重新从模板直接添加,确认删除吗?
该数据源有配置,删除后将丢失配置,之后从模板添加时需要重新配置,确认删除吗?
diff --git a/app/shared/app-lang/src/androidMain/res/values-zh-rHK/strings.xml b/app/shared/app-lang/src/androidMain/res/values-zh-rHK/strings.xml
index 406024c2ed..733b10a1b1 100644
--- a/app/shared/app-lang/src/androidMain/res/values-zh-rHK/strings.xml
+++ b/app/shared/app-lang/src/androidMain/res/values-zh-rHK/strings.xml
@@ -214,6 +214,9 @@
排序
終止測試
開始測試
+ 清除無效源(%1$d)
+ 確定要刪除 %1$d 個無效數據源嗎?
+ 已清除 %1$d 個無效源,失敗 %2$d 個。
刪除數據源
該數據源無特殊配置,刪除後可以重新從模板直接添加,確認刪除嗎?
該數據源有配置,刪除後將丟失配置,之後從模板添加時需要重新配置,確認刪除嗎?
diff --git a/app/shared/app-lang/src/androidMain/res/values-zh-rTW/strings.xml b/app/shared/app-lang/src/androidMain/res/values-zh-rTW/strings.xml
index f7973b18c2..cb68275124 100644
--- a/app/shared/app-lang/src/androidMain/res/values-zh-rTW/strings.xml
+++ b/app/shared/app-lang/src/androidMain/res/values-zh-rTW/strings.xml
@@ -214,6 +214,9 @@
排序
終止測試
開始測試
+ 清除無效來源(%1$d)
+ 確定要刪除 %1$d 個無效資料來源嗎?
+ 已清除 %1$d 個無效來源,失敗 %2$d 個。
刪除資料源
該資料源無特殊配置,刪除後可以重新從模板直接添加,確認刪除嗎?
該資料源有配置,刪除後將遺失配置,之後從模板添加時需要重新配置,確認刪除嗎?
diff --git a/app/shared/app-lang/src/androidMain/res/values/strings.xml b/app/shared/app-lang/src/androidMain/res/values/strings.xml
index ba21fc401c..b5e343c04d 100644
--- a/app/shared/app-lang/src/androidMain/res/values/strings.xml
+++ b/app/shared/app-lang/src/androidMain/res/values/strings.xml
@@ -211,6 +211,9 @@
Sort
Stop test
Start test
+ Clear invalid sources (%1$d)
+ Delete %1$d invalid data sources?
+ Removed %1$d invalid sources, %2$d failed.
Delete data source
This data source has no special configuration. It can be re-added from a template after deletion. Confirm deletion?
This data source has configurations. Deleting it will lose those configurations. They must be reconfigured when re-added from a template. Confirm deletion?
diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroup.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroup.kt
index 9de8525740..768a71295b 100644
--- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroup.kt
+++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroup.kt
@@ -55,6 +55,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -78,10 +80,14 @@ import me.him188.ani.app.ui.foundation.LocalPlatform
import me.him188.ani.app.ui.foundation.animation.AniAnimatedVisibility
import me.him188.ani.app.ui.foundation.ifThen
import me.him188.ani.app.ui.foundation.interaction.onRightClickIfSupported
+import me.him188.ani.app.ui.foundation.widgets.LocalToaster
import me.him188.ani.app.ui.lang.Lang
import me.him188.ani.app.ui.lang.settings_media_source_add
import me.him188.ani.app.ui.lang.settings_media_source_cancel
import me.him188.ani.app.ui.lang.settings_media_source_cancel_sort
+import me.him188.ani.app.ui.lang.settings_media_source_clear_invalid
+import me.him188.ani.app.ui.lang.settings_media_source_clear_invalid_confirmation
+import me.him188.ani.app.ui.lang.settings_media_source_clear_invalid_result
import me.him188.ani.app.ui.lang.settings_media_source_delete
import me.him188.ani.app.ui.lang.settings_media_source_delete_can_readd
import me.him188.ani.app.ui.lang.settings_media_source_delete_confirm
@@ -99,6 +105,7 @@ import me.him188.ani.app.ui.lang.settings_media_source_select_template
import me.him188.ani.app.ui.lang.settings_media_source_sort
import me.him188.ani.app.ui.lang.settings_media_source_start_test
import me.him188.ani.app.ui.lang.settings_media_source_stop_test
+import me.him188.ani.app.ui.settings.framework.ConnectionTestResult
import me.him188.ani.app.ui.settings.framework.ConnectionTesterResultIndicator
import me.him188.ani.app.ui.settings.framework.components.SettingsScope
import me.him188.ani.app.ui.settings.framework.components.TextButtonItem
@@ -112,6 +119,7 @@ import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorder
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
import org.burnoutcrew.reorderable.reorderable
+import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
@Stable
@@ -127,7 +135,9 @@ internal fun SettingsScope.MediaSourceGroup(
) {
val navigator = LocalNavigator.current
val uiScope = rememberCoroutineScope()
+ val toaster = LocalToaster.current
var showSelectTemplate by remember { mutableStateOf(false) }
+ var showConfirmClearDialog by rememberSaveable { mutableStateOf(false) }
if (showSelectTemplate) {
// 选一个数据源来添加
SelectMediaSourceTemplateDialog(
@@ -170,6 +180,31 @@ internal fun SettingsScope.MediaSourceGroup(
val sorter = rememberSorterState(
onComplete = { list -> state.reorderMediaSources(newOrder = list.map { it.instanceId }) },
)
+ val isEditTaskRunning by edit.editTaskRunningFlow.collectAsState()
+ val canMutateMediaSources = !isEditTaskRunning
+ val invalidSources by remember {
+ derivedStateOf {
+ // 不能用 remember(state.mediaSources) 缓存:tester.result 是原地变化,不会改变列表 identity。
+ state.mediaSources.filter {
+ it.connectionTester.result == ConnectionTestResult.FAILED
+ }
+ }
+ }
+ val isEditingUiActive = edit.editMediaSourceState != null || showSelectTemplate
+ val showClearButton = remember(
+ invalidSources,
+ state.mediaSourceTesters.anyTesting,
+ isEditTaskRunning,
+ sorter.isSorting,
+ isEditingUiActive,
+ ) {
+ invalidSources.isNotEmpty() &&
+ !state.mediaSourceTesters.anyTesting &&
+ !isEditTaskRunning &&
+ !sorter.isSorting &&
+ !isEditingUiActive
+ }
+ val platform = LocalPlatform.current
Group(
title = { Text(stringResource(Lang.settings_media_source_list, state.mediaSources.size)) },
@@ -196,6 +231,7 @@ internal fun SettingsScope.MediaSourceGroup(
edit.cancelEdit()
showSelectTemplate = true
},
+ enabled = canMutateMediaSources,
) {
Icon(Icons.Rounded.Add, contentDescription = stringResource(Lang.settings_media_source_add))
}
@@ -229,6 +265,58 @@ internal fun SettingsScope.MediaSourceGroup(
}
},
) {
+ if (showConfirmClearDialog) {
+ AlertDialog(
+ onDismissRequest = { showConfirmClearDialog = false },
+ icon = { Icon(Icons.Rounded.Delete, null, tint = MaterialTheme.colorScheme.error) },
+ title = {
+ Text(
+ stringResource(
+ Lang.settings_media_source_clear_invalid,
+ invalidSources.size,
+ ),
+ )
+ },
+ text = {
+ Text(
+ stringResource(
+ Lang.settings_media_source_clear_invalid_confirmation,
+ invalidSources.size,
+ ),
+ )
+ },
+ confirmButton = {
+ TextButton(
+ {
+ val targets = invalidSources.toList()
+ edit.deleteMediaSources(targets) { result ->
+ toaster.toast(
+ getString(
+ Lang.settings_media_source_clear_invalid_result,
+ result.deleted,
+ result.failed,
+ ),
+ )
+ }
+ showConfirmClearDialog = false
+ },
+ ) {
+ Text(
+ stringResource(Lang.settings_media_source_delete_confirm),
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(
+ {
+ showConfirmClearDialog = false
+ },
+ ) { Text(stringResource(Lang.settings_media_source_cancel)) }
+ },
+ )
+ }
+
Box {
Column(
Modifier
@@ -246,7 +334,6 @@ internal fun SettingsScope.MediaSourceGroup(
edit.startEditing(item)
}
}
- val platform = LocalPlatform.current
var showMoreDropdown by remember { mutableStateOf(false) }
var showConfirmDeletionDialog by rememberSaveable { mutableStateOf(false) }
@@ -268,6 +355,7 @@ internal fun SettingsScope.MediaSourceGroup(
edit.deleteMediaSource(item);
showConfirmDeletionDialog = false
},
+ enabled = canMutateMediaSources,
) {
Text(
stringResource(Lang.settings_media_source_delete_confirm),
@@ -288,6 +376,7 @@ internal fun SettingsScope.MediaSourceGroup(
MediaSourceItem(
item,
Modifier.combinedClickable(
+ enabled = canMutateMediaSources,
onClickLabel = "编辑",
onLongClick = {
if (platform.isMobile()) {
@@ -297,7 +386,9 @@ internal fun SettingsScope.MediaSourceGroup(
onLongClickLabel = "开始排序",
onClick = startEditing,
).onRightClickIfSupported {
- showMoreDropdown = true
+ if (canMutateMediaSources) {
+ showMoreDropdown = true
+ }
},
) {
IconButton({}, enabled = false) { // 放在 button 里保持 padding 一致
@@ -308,7 +399,10 @@ internal fun SettingsScope.MediaSourceGroup(
}
Box {
- IconButton(onClick = { showMoreDropdown = true }) {
+ IconButton(
+ onClick = { showMoreDropdown = true },
+ enabled = canMutateMediaSources,
+ ) {
Icon(
Icons.Rounded.MoreVert,
contentDescription = "更多",
@@ -317,6 +411,7 @@ internal fun SettingsScope.MediaSourceGroup(
MoreOptionsDropdown(
showMoreDropdown,
+ enabled = canMutateMediaSources,
onDismissRequest = { showMoreDropdown = false },
onDeleteRequest = { showConfirmDeletionDialog = true },
item,
@@ -368,6 +463,18 @@ internal fun SettingsScope.MediaSourceGroup(
HorizontalDividerItem()
+ if (showClearButton) {
+ TextButtonItem(
+ onClick = { showConfirmClearDialog = true },
+ title = {
+ Text(
+ stringResource(Lang.settings_media_source_clear_invalid, invalidSources.size),
+ color = MaterialTheme.colorScheme.error,
+ )
+ },
+ )
+ HorizontalDividerItem()
+ }
TextButtonItem(
onClick = {
@@ -471,6 +578,7 @@ internal fun SettingsScope.MediaSourceItem(
@Composable
private fun MoreOptionsDropdown(
showMore: Boolean,
+ enabled: Boolean,
onDismissRequest: () -> Unit,
onDeleteRequest: () -> Unit,
item: MediaSourcePresentation,
@@ -482,6 +590,7 @@ private fun MoreOptionsDropdown(
onDismissRequest = onDismissRequest,
) {
DropdownMenuItem(
+ enabled = enabled,
leadingIcon = {
if (item.isEnabled) {
Icon(Icons.Rounded.VisibilityOff, null)
@@ -502,6 +611,7 @@ private fun MoreOptionsDropdown(
},
)
DropdownMenuItem(
+ enabled = enabled,
leadingIcon = { Icon(Icons.Rounded.Edit, null) },
text = { Text(stringResource(Lang.settings_media_source_edit)) }, // 直接点击数据源一行也可以编辑, 但还是在这里放一个按钮以免有人不知道
onClick = {
@@ -510,6 +620,7 @@ private fun MoreOptionsDropdown(
},
)
DropdownMenuItem(
+ enabled = enabled,
leadingIcon = { Icon(Icons.Rounded.Delete, null, tint = MaterialTheme.colorScheme.error) },
text = {
Text(
diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroupState.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroupState.kt
index 65edd23a32..241f090659 100644
--- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroupState.kt
+++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroupState.kt
@@ -16,6 +16,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -147,6 +148,11 @@ class EditMediaSourceState(
private val onSetEnabled: suspend (instanceId: String, enabled: Boolean) -> Unit,
private val backgroundScope: CoroutineScope,
) {
+ data class DeleteMediaSourcesResult(
+ val deleted: Int,
+ val failed: Int,
+ )
+
var editMediaSourceState by mutableStateOf(null)
private set
@@ -185,6 +191,7 @@ class EditMediaSourceState(
}
private val editTasker = MonoTasker(backgroundScope)
+ val editTaskRunningFlow get() = editTasker.isRunning
fun confirmEdit(state: EditingMediaSource): Job {
return editTasker.launch {
@@ -224,6 +231,35 @@ class EditMediaSourceState(
}
}
+ fun deleteMediaSources(
+ items: List,
+ onComplete: suspend (DeleteMediaSourcesResult) -> Unit = {},
+ ) {
+ editTasker.launch {
+ val targets = items.distinctBy { it.instanceId }
+ var deleted = 0
+ var failed = 0
+ targets.forEach { item ->
+ runCatching {
+ onDelete(item.instanceId)
+ }.onSuccess {
+ deleted++
+ }.onFailure { throwable ->
+ if (throwable is CancellationException) throw throwable
+ failed++
+ }
+ }
+ withContext(Dispatchers.Main) {
+ onComplete(
+ DeleteMediaSourcesResult(
+ deleted = deleted,
+ failed = failed,
+ ),
+ )
+ }
+ }
+ }
+
fun toggleMediaSourceEnabled(item: MediaSourcePresentation, enabled: Boolean) {
editTasker.launch {
onSetEnabled(item.instanceId, enabled)