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)