From f759ef6ca7e48b38a569b3e35a30b218db3d183e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:47:02 +0000 Subject: [PATCH 1/6] feat: modernize Media Servers settings with reorderable priority list Redesigns the Media Servers screen around the fact that Blossom uploads mirror in list order, plus a lightweight reachability check per server. - Split the screen into "Servers" / "Local cache" segmented tabs, moving the local-cache switches into their own tab to declutter the list. - Make the server list drag-to-reorder using the shared RelayDragState utility; list order is the upload/fallback priority (row #1 first). - Add a rank badge per row (accent-filled for the primary target) and a reorder hint. - Add a per-server health dot (Online / Slow / Offline / Checking) backed by a one-shot HEAD probe through the account's Tor-aware preview client. - Add moveServer() + health StateFlow to BlossomServersViewModel; probe on load and when a server is added. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA --- .../mediaServers/AllMediaServersLIstView.kt | 382 ++++++++++++------ .../mediaServers/AllMediaServersScreen.kt | 56 ++- .../mediaServers/BlossomServersViewModel.kt | 37 ++ .../actions/mediaServers/MediaServerHealth.kt | 98 +++++ amethyst/src/main/res/values/strings.xml | 8 + 5 files changed, 442 insertions(+), 139 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt index 956c24ff62..59de9d8df7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -20,15 +20,20 @@ */ package com.vitorpamplona.amethyst.ui.actions.mediaServers +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -37,6 +42,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -44,6 +53,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayDragState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.draggableRelayItem +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relayDragHandle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.rememberRelayDragState import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding @@ -51,16 +64,28 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.warningColor @Composable -fun AllMediaBody(blossomServersViewModel: BlossomServersViewModel) { +fun AllMediaBody( + blossomServersViewModel: BlossomServersViewModel, + modifier: Modifier = Modifier, +) { val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle() + val healthState by blossomServersViewModel.health.collectAsStateWithLifecycle() + + val dragState = + rememberRelayDragState( + onMove = { from, to -> blossomServersViewModel.moveServer(from, to) }, + itemCount = { blossomServersState.size }, + ) LazyColumn( - verticalArrangement = Arrangement.SpaceAround, - horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, contentPadding = FeedPadding, + userScrollEnabled = !dragState.isDragging, ) { item { SettingsCategory( @@ -70,52 +95,70 @@ fun AllMediaBody(blossomServersViewModel: BlossomServersViewModel) { ) } - renderMediaServerList( - mediaServersState = blossomServersState, - keyType = "blossom", - editLabel = R.string.add_a_blossom_server, - emptyLabel = R.string.no_blossom_server_message, - onAddServer = { server -> - blossomServersViewModel.addServer(server) - }, - onDeleteServer = { - blossomServersViewModel.removeServer(serverUrl = it) - }, - ) - - DEFAULT_MEDIA_SERVERS.let { + if (blossomServersState.isEmpty()) { item { - SettingsCategoryWithButton( - title = R.string.recommended_media_servers, - description = R.string.built_in_servers_description, - modifier = SettingsCategorySpacingModifier, + Text( + text = stringRes(id = R.string.no_blossom_server_message), + modifier = DoubleVertPadding, + ) + } + } else { + item { + Text( + text = stringRes(id = R.string.media_servers_reorder_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) + } + + itemsIndexed( + blossomServersState, + key = { _, server -> "blossom" + server.baseUrl }, + ) { index, entry -> + MediaServerRow( + index = index, + serverEntry = entry, + health = healthState[entry.baseUrl] ?: ServerHealth.Unknown, + dragState = dragState, + onDelete = { blossomServersViewModel.removeServer(serverUrl = it) }, + ) + } + } + + item { + Spacer(modifier = StdVertSpacer) + MediaServerEditField(R.string.add_a_blossom_server) { + blossomServersViewModel.addServer(it) + } + } + + item { + SettingsCategoryWithButton( + title = R.string.recommended_media_servers, + description = R.string.built_in_servers_description, + modifier = SettingsCategorySpacingModifier, + ) { + OutlinedButton( + onClick = { + blossomServersViewModel.addServerList( + DEFAULT_MEDIA_SERVERS.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null }, + ) + }, ) { - OutlinedButton( - onClick = { - blossomServersViewModel.addServerList( - it.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null }, - ) - }, - ) { - Text(text = stringRes(id = R.string.use_default_servers)) - } + Text(text = stringRes(id = R.string.use_default_servers)) } } - itemsIndexed( - it, - key = { _: Int, server: ServerName -> - "Proposed" + server.baseUrl - }, - ) { _, server -> - MediaServerEntry( - serverEntry = server, - isAmethystDefault = true, - onAddOrDelete = { serverUrl -> - if (server.type == ServerType.Blossom) { - blossomServersViewModel.addServer(serverUrl) - } - }, - ) + } + + itemsIndexed( + DEFAULT_MEDIA_SERVERS, + key = { _, server -> "Proposed" + server.baseUrl }, + ) { _, server -> + RecommendedServerRow(serverEntry = server) { + if (server.type == ServerType.Blossom) { + blossomServersViewModel.addServer(server.baseUrl) + } } } @@ -125,97 +168,186 @@ fun AllMediaBody(blossomServersViewModel: BlossomServersViewModel) { } } -fun LazyListScope.renderMediaServerList( - mediaServersState: List, - keyType: String, - editLabel: Int, - emptyLabel: Int, - onAddServer: (String) -> Unit, - onDeleteServer: (String) -> Unit, -) { - if (mediaServersState.isEmpty()) { - item { - Text( - text = stringRes(id = emptyLabel), - modifier = DoubleVertPadding, - ) - } - } else { - itemsIndexed( - mediaServersState, - key = { _: Int, server: ServerName -> - keyType + server.baseUrl - }, - ) { _, entry -> - MediaServerEntry( - serverEntry = entry, - onAddOrDelete = { - onDeleteServer(it) - }, - ) - } - } - - item { - Spacer(modifier = StdVertSpacer) - MediaServerEditField(editLabel) { - onAddServer(it) - } - } -} - +/** + * A draggable, ranked server row. Position in the list is the upload/fallback + * priority (row #1 is tried first), so each row carries a rank badge and a drag + * handle wired into the shared [RelayDragState]. + */ @Composable -fun MediaServerEntry( - modifier: Modifier = Modifier, +fun MediaServerRow( + index: Int, serverEntry: ServerName, - isAmethystDefault: Boolean = false, - onAddOrDelete: (serverUrl: String) -> Unit, + health: ServerHealth, + dragState: RelayDragState, + onDelete: (serverUrl: String) -> Unit, ) { Row( modifier = - modifier + Modifier .fillMaxWidth() - .padding(vertical = 10.dp), + .draggableRelayItem(index, dragState) + .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceAround, ) { + Icon( + symbol = MaterialSymbols.DragIndicator, + contentDescription = stringRes(id = R.string.media_server_reorder), + modifier = Modifier.size(24.dp).relayDragHandle(index, dragState), + tint = MaterialTheme.colorScheme.grayText, + ) + + RankBadge(rank = index + 1) + Column( - modifier = - Modifier - .weight(1f), + modifier = Modifier.weight(1f).padding(start = 12.dp), ) { - serverEntry.let { - Text( - text = it.name.replaceFirstChar(Char::titlecase), - style = MaterialTheme.typography.bodyLarge, - ) - Spacer(modifier = StdVertSpacer) - Text( - text = it.baseUrl, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.grayText, - ) - } + Text( + text = serverEntry.name.replaceFirstChar(Char::titlecase), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdVertSpacer) + Text( + text = serverEntry.baseUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - Row( - horizontalArrangement = Arrangement.End, - ) { - IconButton( - onClick = { - onAddOrDelete(serverEntry.baseUrl) - }, - ) { - Icon( - symbol = if (isAmethystDefault) MaterialSymbols.Add else MaterialSymbols.Delete, - contentDescription = - if (isAmethystDefault) { - stringRes(id = R.string.add_media_server) - } else { - stringRes(id = R.string.delete_media_server) - }, - ) - } + HealthIndicator(health) + + IconButton(onClick = { onDelete(serverEntry.baseUrl) }) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringRes(id = R.string.delete_media_server), + tint = MaterialTheme.colorScheme.grayText, + ) } } } + +/** A recommended default server, added on tap of the trailing "+". */ +@Composable +fun RecommendedServerRow( + serverEntry: ServerName, + onAdd: (serverUrl: String) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = serverEntry.name.replaceFirstChar(Char::titlecase), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdVertSpacer) + Text( + text = serverEntry.baseUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + IconButton(onClick = { onAdd(serverEntry.baseUrl) }) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(id = R.string.add_media_server), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +/** Rounded rank badge. The primary target (#1) is filled with the accent color. */ +@Composable +private fun RankBadge(rank: Int) { + val isPrimary = rank == 1 + Box( + modifier = + Modifier + .size(26.dp) + .clip(RoundedCornerShape(8.dp)) + .background( + if (isPrimary) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = rank.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = + if (isPrimary) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} + +/** Colored reachability dot + label, or a spinner while a probe is in flight. */ +@Composable +private fun HealthIndicator(health: ServerHealth) { + if (health == ServerHealth.Unknown) return + + if (health == ServerHealth.Checking) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.grayText, + ) + return + } + + val color: Color + val label: Int + when (health) { + ServerHealth.Online -> { + color = MaterialTheme.colorScheme.allGoodColor + label = R.string.media_server_status_online + } + ServerHealth.Slow -> { + color = MaterialTheme.colorScheme.warningColor + label = R.string.media_server_status_slow + } + else -> { + color = MaterialTheme.colorScheme.error + label = R.string.media_server_status_offline + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + modifier = + Modifier + .size(9.dp) + .clip(CircleShape) + .background(color), + ) + Text( + text = stringRes(id = label), + style = MaterialTheme.typography.labelSmall, + color = color, + maxLines = 1, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt index 25bd2eeb09..9930d0738f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -28,18 +28,24 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -101,25 +107,47 @@ fun MediaServersScaffold( bottom = padding.calculateBottomPadding(), ).consumeWindowInsets(padding) .imePadding(), - verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top), - horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = stringRes(id = R.string.set_preferred_media_servers), - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 10.dp), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.grayText, - ) + var selectedTab by remember { mutableIntStateOf(TAB_SERVERS) } + val tabs = listOf(R.string.media_servers_tab_servers, R.string.media_servers_tab_cache) - LocalBlossomCacheToggle(accountViewModel) - HorizontalDivider() + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 8.dp), + ) { + tabs.forEachIndexed { index, labelRes -> + SegmentedButton( + selected = selectedTab == index, + onClick = { selectedTab = index }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = tabs.size), + ) { + Text(text = stringRes(id = labelRes)) + } + } + } - AllMediaBody(blossomServersViewModel) + when (selectedTab) { + TAB_SERVERS -> AllMediaBody(blossomServersViewModel, Modifier.weight(1f)) + else -> LocalBlossomCacheTab(accountViewModel, Modifier.weight(1f)) + } } } } +private const val TAB_SERVERS = 0 + +@Composable +private fun LocalBlossomCacheTab( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + LocalBlossomCacheToggle(accountViewModel) + } +} + @Composable private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) { val enabled by accountViewModel.account.settings.useLocalBlossomCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index aef785e87d..ec8f49b6a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -24,9 +24,11 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Rfc3986 +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -36,18 +38,26 @@ import kotlinx.coroutines.launch class BlossomServersViewModel : ViewModel() { private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account + private var httpClientBuilder: IRoleBasedHttpClientBuilder? = null private val _fileServers = MutableStateFlow>(emptyList()) val fileServers = _fileServers.asStateFlow() + + /** Reachability status per server, keyed by [ServerName.baseUrl]. */ + private val _health = MutableStateFlow>(emptyMap()) + val health = _health.asStateFlow() + private var isModified = false fun init(accountViewModel: AccountViewModel) { this.accountViewModel = accountViewModel this.account = accountViewModel.account + this.httpClientBuilder = accountViewModel.httpClientBuilder } fun load() { refresh() + checkAllHealth() } fun refresh() { @@ -69,6 +79,32 @@ class BlossomServersViewModel : ViewModel() { } } + /** Moves a server to a new position; list order is the upload/fallback priority. */ + fun moveServer( + from: Int, + to: Int, + ) { + _fileServers.update { list -> + if (from !in list.indices || to !in list.indices) return@update list + list.toMutableList().apply { add(to, removeAt(from)) } + } + isModified = true + } + + /** Re-probes every server currently in the list. */ + fun checkAllHealth() { + _fileServers.value.forEach { probeServer(it.baseUrl) } + } + + private fun probeServer(serverUrl: String) { + val builder = httpClientBuilder ?: return + _health.update { it + (serverUrl to ServerHealth.Checking) } + viewModelScope.launch(Dispatchers.IO) { + val result = MediaServerHealthProbe.probe(serverUrl, builder::okHttpClientForPreview) + _health.update { it + (serverUrl to result) } + } + } + fun addServerList(serverList: List) { serverList.forEach { serverUrl -> addServer(serverUrl) @@ -100,6 +136,7 @@ class BlossomServersViewModel : ViewModel() { _fileServers.update { it.plus(serverRef) } + probeServer(serverRef.baseUrl) } isModified = true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt new file mode 100644 index 0000000000..8a1444b1e1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * 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. + */ +package com.vitorpamplona.amethyst.ui.actions.mediaServers + +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync +import java.util.concurrent.TimeUnit + +/** + * Reachability status of a media server, shown as a colored dot next to each + * entry in the Media Servers list. + */ +enum class ServerHealth { + /** Not probed yet. */ + Unknown, + + /** A probe is in flight. */ + Checking, + + /** Responded quickly. */ + Online, + + /** Responded, but slower than [MediaServerHealthProbe.SLOW_THRESHOLD_MS]. */ + Slow, + + /** Could not be reached (DNS, refused, timeout, TLS). */ + Offline, +} + +/** + * A one-shot, lightweight reachability check for a Blossom server. Issues a + * `HEAD /` to the server's base URL and classifies the outcome by round-trip + * time. Any HTTP response — even 404/405 — counts as reachable; only + * connection-level failures map to [ServerHealth.Offline]. + * + * Mirrors the timeout/short-circuit shape of + * [com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe], + * but runs per-server and returns latency-classified status rather than a boolean. + */ +object MediaServerHealthProbe { + /** Round-trip time above which a reachable server is reported as [ServerHealth.Slow]. */ + const val SLOW_THRESHOLD_MS: Long = 1_000L + private const val PROBE_TIMEOUT_MS: Long = 5_000L + + suspend fun probe( + baseUrl: String, + clientForUrl: (String) -> OkHttpClient, + ): ServerHealth = + try { + val client = + clientForUrl(baseUrl) + .newBuilder() + .connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .build() + + val request = + Request + .Builder() + .url(baseUrl) + .head() + .build() + + val startedAt = TimeUtils.nowMillis() + client.newCall(request).executeAsync().use { + // The status code doesn't matter — a Blossom root often answers 404/405. + // Getting any response back proves the host is reachable. + val elapsed = TimeUtils.nowMillis() - startedAt + if (elapsed > SLOW_THRESHOLD_MS) ServerHealth.Slow else ServerHealth.Online + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + ServerHealth.Offline + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 348b08fecc..77d8d148cf 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1553,6 +1553,14 @@ Use Default List Add media server Delete media server + Servers + Local cache + Drag to reorder. Uploads try each server from the top down. + Reorder server + Online + Slow + Offline + Checking… Payment Targets Publish your payment addresses so others can send you funds directly. Add payment addresses for different networks (e.g. bitcoin, lightning, ethereum). From 40acd69781e863e8199f8ca445ddcbbe15340029 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 21:33:26 +0000 Subject: [PATCH 2/6] refactor(media-servers): cache health probes, prune stale status, save tab Follow-up on the audit of the Media Servers redesign: - Add a process-wide TTL cache (60s) to MediaServerHealthProbe so probe results survive the screen's ViewModel being recreated on each open; the ViewModel reuses fresh cached status instead of re-hitting the network, and skips launching a probe when one is already in flight. - Prune the _health map on refresh/remove so it can't grow unbounded as servers are added and removed within a session. - Persist the selected tab across configuration changes (rememberSaveable). - Restore the screen's intro caption, reusing the previously orphaned set_preferred_media_servers string. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA --- .../mediaServers/AllMediaServersScreen.kt | 13 ++++++-- .../mediaServers/BlossomServersViewModel.kt | 20 +++++++++++- .../actions/mediaServers/MediaServerHealth.kt | 31 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt index 9930d0738f..a955d457e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -42,7 +42,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -108,11 +108,18 @@ fun MediaServersScaffold( ).consumeWindowInsets(padding) .imePadding(), ) { - var selectedTab by remember { mutableIntStateOf(TAB_SERVERS) } + var selectedTab by rememberSaveable { mutableIntStateOf(TAB_SERVERS) } val tabs = listOf(R.string.media_servers_tab_servers, R.string.media_servers_tab_cache) + Text( + text = stringRes(id = R.string.set_preferred_media_servers), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.padding(top = 10.dp), + ) + SingleChoiceSegmentedButtonRow( - modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 8.dp), ) { tabs.forEachIndexed { index, labelRes -> SegmentedButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index ec8f49b6a7..a5e5607a40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -77,6 +77,7 @@ class BlossomServersViewModel : ViewModel() { } } } + pruneHealth() } /** Moves a server to a new position; list order is the upload/fallback priority. */ @@ -91,13 +92,23 @@ class BlossomServersViewModel : ViewModel() { isModified = true } - /** Re-probes every server currently in the list. */ + /** Re-probes every server currently in the list. Fresh cached results are reused. */ fun checkAllHealth() { _fileServers.value.forEach { probeServer(it.baseUrl) } } private fun probeServer(serverUrl: String) { val builder = httpClientBuilder ?: return + + // A probe is already in flight for this URL — don't launch a duplicate. + if (_health.value[serverUrl] == ServerHealth.Checking) return + + // Reuse a still-fresh cached status instead of hitting the network again. + MediaServerHealthProbe.cached(serverUrl)?.let { cachedStatus -> + _health.update { it + (serverUrl to cachedStatus) } + return + } + _health.update { it + (serverUrl to ServerHealth.Checking) } viewModelScope.launch(Dispatchers.IO) { val result = MediaServerHealthProbe.probe(serverUrl, builder::okHttpClientForPreview) @@ -105,6 +116,12 @@ class BlossomServersViewModel : ViewModel() { } } + /** Drops health entries for servers no longer in the list so the map can't grow unbounded. */ + private fun pruneHealth() { + val liveUrls = _fileServers.value.mapTo(HashSet()) { it.baseUrl } + _health.update { statuses -> statuses.filterKeys { it in liveUrls } } + } + fun addServerList(serverList: List) { serverList.forEach { serverUrl -> addServer(serverUrl) @@ -157,6 +174,7 @@ class BlossomServersViewModel : ViewModel() { ServerName(serverName, serverUrl, ServerType.Blossom), ) } + pruneHealth() isModified = true } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt index 8a1444b1e1..c7dbb15d23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.CancellationException import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.coroutines.executeAsync +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit /** @@ -63,9 +64,39 @@ object MediaServerHealthProbe { const val SLOW_THRESHOLD_MS: Long = 1_000L private const val PROBE_TIMEOUT_MS: Long = 5_000L + /** + * How long a probe result is reused before the server is re-checked. The cache is + * process-wide (this is a singleton) so results survive the screen's ViewModel being + * recreated on each open, mirroring [com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe]. + */ + private const val CACHE_TTL_MS: Long = 60_000L + + private class CachedResult( + val status: ServerHealth, + val atMs: Long, + ) + + private val cache = ConcurrentHashMap() + + /** The cached status for [baseUrl] if still within [CACHE_TTL_MS], else null. */ + fun cached(baseUrl: String): ServerHealth? { + val entry = cache[baseUrl] ?: return null + return if (TimeUtils.nowMillis() - entry.atMs < CACHE_TTL_MS) entry.status else null + } + suspend fun probe( baseUrl: String, clientForUrl: (String) -> OkHttpClient, + ): ServerHealth { + cached(baseUrl)?.let { return it } + val result = runProbe(baseUrl, clientForUrl) + cache[baseUrl] = CachedResult(result, TimeUtils.nowMillis()) + return result + } + + private suspend fun runProbe( + baseUrl: String, + clientForUrl: (String) -> OkHttpClient, ): ServerHealth = try { val client = From 0feb0072a17b63f3f48f8f2b07d637e5f4227197 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 22:53:13 +0000 Subject: [PATCH 3/6] feat(media-servers): redesign whole screen as one canvas, drop tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Servers/Local-cache segmented tabs with a single cohesive scroll, and redesigns every zone of the screen: - Upload priority: draggable, ranked server rows now carry a colored monogram (identity) and a "Primary" badge on #1, alongside the health dot. - Add a server: the old inline URL field + a second 10-row recommended list are replaced by one inline add area — the URL field plus the recommended servers as a horizontal strip of add-chips that read as "done" once added (matched by host so URL normalization can't hide it). - On-device cache: the old bare switch rows become a self-contained card with a storage icon, a detection-status dot, and the profile-pics-only sub-toggle nested under it. Removes the segmented tabs and their now-dead strings. Same staged Save/Cancel model, kind-10063 publish, drag utility, and health probe. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA --- .../mediaServers/AllMediaServersLIstView.kt | 314 ++++++++++++------ .../mediaServers/AllMediaServersScreen.kt | 166 ++++----- amethyst/src/main/res/values/strings.xml | 8 +- 3 files changed, 318 insertions(+), 170 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt index 59de9d8df7..9023ae2be9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -21,23 +21,28 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -46,13 +51,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayDragState import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.draggableRelayItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relayDragHandle @@ -61,16 +66,28 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier -import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.warningColor +import com.vitorpamplona.quartz.utils.Rfc3986 + +/** Vibrant palette for server monograms; picked deterministically from the host name. */ +private val MonogramColors = + listOf( + Color(0xFF8B5CF6), + Color(0xFF0EA5A0), + Color(0xFFE07B00), + Color(0xFF4169E1), + Color(0xFFD16D8F), + Color(0xFF4F9D4F), + Color(0xFFB66605), + Color(0xFF7C6FE0), + ) @Composable fun AllMediaBody( blossomServersViewModel: BlossomServersViewModel, + accountViewModel: AccountViewModel, modifier: Modifier = Modifier, ) { val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle() @@ -88,10 +105,18 @@ fun AllMediaBody( userScrollEnabled = !dragState.isDragging, ) { item { - SettingsCategory( - R.string.media_servers_blossom_section, - R.string.media_servers_blossom_explainer, - SettingsCategoryFirstModifier, + Text( + text = stringRes(id = R.string.set_preferred_media_servers), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) + } + + item { + SectionLabel( + title = stringRes(id = R.string.media_servers_priority_section), + caption = stringRes(id = R.string.media_servers_reorder_hint), ) } @@ -103,15 +128,6 @@ fun AllMediaBody( ) } } else { - item { - Text( - text = stringRes(id = R.string.media_servers_reorder_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.grayText, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - ) - } - itemsIndexed( blossomServersState, key = { _, server -> "blossom" + server.baseUrl }, @@ -127,39 +143,22 @@ fun AllMediaBody( } item { - Spacer(modifier = StdVertSpacer) - MediaServerEditField(R.string.add_a_blossom_server) { - blossomServersViewModel.addServer(it) - } + AddServerSection( + // Server entries carry their host in `name`; match recommended chips by host so + // normalization differences in the URL don't hide the "added" state. + addedHosts = blossomServersState.mapTo(HashSet()) { it.name }, + onAddServer = { blossomServersViewModel.addServer(it) }, + onAddAll = { + blossomServersViewModel.addServerList( + DEFAULT_MEDIA_SERVERS.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null }, + ) + }, + ) } item { - SettingsCategoryWithButton( - title = R.string.recommended_media_servers, - description = R.string.built_in_servers_description, - modifier = SettingsCategorySpacingModifier, - ) { - OutlinedButton( - onClick = { - blossomServersViewModel.addServerList( - DEFAULT_MEDIA_SERVERS.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null }, - ) - }, - ) { - Text(text = stringRes(id = R.string.use_default_servers)) - } - } - } - - itemsIndexed( - DEFAULT_MEDIA_SERVERS, - key = { _, server -> "Proposed" + server.baseUrl }, - ) { _, server -> - RecommendedServerRow(serverEntry = server) { - if (server.type == ServerType.Blossom) { - blossomServersViewModel.addServer(server.baseUrl) - } - } + SectionLabel(title = stringRes(id = R.string.media_servers_cache_section)) + MediaCacheSection(accountViewModel) } item { @@ -168,10 +167,34 @@ fun AllMediaBody( } } +/** Compact section header: an accent label with an optional gray caption below. */ +@Composable +private fun SectionLabel( + title: String, + caption: String? = null, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth().padding(top = 20.dp, bottom = 8.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + if (caption != null) { + Text( + text = caption, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + } +} + /** - * A draggable, ranked server row. Position in the list is the upload/fallback - * priority (row #1 is tried first), so each row carries a rank badge and a drag - * handle wired into the shared [RelayDragState]. + * A draggable, ranked server row. Position in the list is the upload/fallback priority + * (row #1 is tried first), so each row carries a rank badge and a drag handle wired into + * the shared [RelayDragState], plus a monogram and a live reachability dot. */ @Composable fun MediaServerRow( @@ -186,28 +209,38 @@ fun MediaServerRow( Modifier .fillMaxWidth() .draggableRelayItem(index, dragState) - .padding(vertical = 8.dp), + .padding(vertical = 7.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( symbol = MaterialSymbols.DragIndicator, contentDescription = stringRes(id = R.string.media_server_reorder), - modifier = Modifier.size(24.dp).relayDragHandle(index, dragState), + modifier = Modifier.size(22.dp).relayDragHandle(index, dragState), tint = MaterialTheme.colorScheme.grayText, ) RankBadge(rank = index + 1) + Spacer(Modifier.size(10.dp)) + + ServerMonogram(name = serverEntry.name, size = 32.dp) + Column( - modifier = Modifier.weight(1f).padding(start = 12.dp), + modifier = Modifier.weight(1f).padding(start = 11.dp), ) { - Text( - text = serverEntry.name.replaceFirstChar(Char::titlecase), - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdVertSpacer) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = serverEntry.name.replaceFirstChar(Char::titlecase), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (index == 0) { + Spacer(Modifier.size(6.dp)) + PrimaryBadge() + } + } Text( text = serverEntry.baseUrl, style = MaterialTheme.typography.bodyMedium, @@ -229,43 +262,119 @@ fun MediaServerRow( } } -/** A recommended default server, added on tap of the trailing "+". */ +/** + * Inline "add a server" area: a URL field followed by the recommended servers as a + * horizontal strip of add-chips (already-added ones read as done). + */ @Composable -fun RecommendedServerRow( - serverEntry: ServerName, - onAdd: (serverUrl: String) -> Unit, +private fun AddServerSection( + addedHosts: Set, + onAddServer: (String) -> Unit, + onAddAll: () -> Unit, ) { + SectionLabel(title = stringRes(id = R.string.media_servers_add_section)) + + MediaServerEditField(R.string.add_a_blossom_server) { onAddServer(it) } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(id = R.string.media_servers_recommended_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onAddAll) { + Text(text = stringRes(id = R.string.use_default_servers)) + } + } + + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 4.dp), + ) { + items( + DEFAULT_MEDIA_SERVERS, + key = { it.baseUrl }, + ) { server -> + val host = runCatching { Rfc3986.host(server.baseUrl) }.getOrNull() + RecommendedChip( + serverEntry = server, + added = host != null && host in addedHosts, + onAdd = { onAddServer(server.baseUrl) }, + ) + } + } +} + +/** A recommended server as a tappable pill. Once added it reads as done and stops responding. */ +@Composable +private fun RecommendedChip( + serverEntry: ServerName, + added: Boolean, + onAdd: () -> Unit, +) { + val shape = RoundedCornerShape(50) Row( modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), + .clip(shape) + .then( + if (added) { + Modifier.background(MaterialTheme.colorScheme.surfaceVariant) + } else { + Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) + }, + ).clickable(enabled = !added, onClick = onAdd) + .padding(start = 6.dp, end = 12.dp, top = 6.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = serverEntry.name.replaceFirstChar(Char::titlecase), - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdVertSpacer) - Text( - text = serverEntry.baseUrl, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.grayText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + ServerMonogram(name = serverEntry.name, size = 24.dp) + Text( + text = serverEntry.name.replaceFirstChar(Char::titlecase), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + Icon( + symbol = if (added) MaterialSymbols.CheckCircle else MaterialSymbols.Add, + contentDescription = + if (added) { + stringRes(id = R.string.media_server_added) + } else { + stringRes(id = R.string.add_media_server) + }, + modifier = Modifier.size(18.dp), + tint = if (added) MaterialTheme.colorScheme.grayText else MaterialTheme.colorScheme.primary, + ) + } +} - IconButton(onClick = { onAdd(serverEntry.baseUrl) }) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = stringRes(id = R.string.add_media_server), - tint = MaterialTheme.colorScheme.primary, - ) - } +/** A colored letter tile identifying a server, derived from its host name. */ +@Composable +private fun ServerMonogram( + name: String, + size: Dp, +) { + val letter = name.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar()?.toString() ?: "?" + val color = MonogramColors[((name.hashCode() % MonogramColors.size) + MonogramColors.size) % MonogramColors.size] + Box( + modifier = + Modifier + .size(size) + .clip(RoundedCornerShape(size / 3)) + .background(color), + contentAlignment = Alignment.Center, + ) { + Text( + text = letter, + style = if (size >= 30.dp) MaterialTheme.typography.labelLarge else MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + ) } } @@ -276,8 +385,8 @@ private fun RankBadge(rank: Int) { Box( modifier = Modifier - .size(26.dp) - .clip(RoundedCornerShape(8.dp)) + .size(22.dp) + .clip(RoundedCornerShape(7.dp)) .background( if (isPrimary) { MaterialTheme.colorScheme.primary @@ -301,6 +410,25 @@ private fun RankBadge(rank: Int) { } } +/** Small "Primary" pill shown on the #1 server. */ +@Composable +private fun PrimaryBadge() { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(5.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) { + Text( + text = stringRes(id = R.string.media_server_primary_badge), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} + /** Colored reachability dot + label, or a spinner while a probe is in flight. */ @Composable private fun HealthIndicator(health: ServerHealth) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt index a955d457e2..760935d1db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -20,7 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.actions.mediaServers +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets @@ -28,32 +31,32 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.grayText @Composable @@ -96,7 +99,9 @@ fun MediaServersScaffold( ) }, ) { padding -> - Column( + AllMediaBody( + blossomServersViewModel = blossomServersViewModel, + accountViewModel = accountViewModel, modifier = Modifier .fillMaxSize() @@ -107,72 +112,49 @@ fun MediaServersScaffold( bottom = padding.calculateBottomPadding(), ).consumeWindowInsets(padding) .imePadding(), - ) { - var selectedTab by rememberSaveable { mutableIntStateOf(TAB_SERVERS) } - val tabs = listOf(R.string.media_servers_tab_servers, R.string.media_servers_tab_cache) - - Text( - text = stringRes(id = R.string.set_preferred_media_servers), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.grayText, - modifier = Modifier.padding(top = 10.dp), - ) - - SingleChoiceSegmentedButtonRow( - modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 8.dp), - ) { - tabs.forEachIndexed { index, labelRes -> - SegmentedButton( - selected = selectedTab == index, - onClick = { selectedTab = index }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = tabs.size), - ) { - Text(text = stringRes(id = labelRes)) - } - } - } - - when (selectedTab) { - TAB_SERVERS -> AllMediaBody(blossomServersViewModel, Modifier.weight(1f)) - else -> LocalBlossomCacheTab(accountViewModel, Modifier.weight(1f)) - } - } + ) } } -private const val TAB_SERVERS = 0 - +/** + * The on-device Blossom cache, rendered as a self-contained card so it reads as its + * own feature rather than a stray toggle. Binds to the same two account settings as + * before. + */ @Composable -private fun LocalBlossomCacheTab( - accountViewModel: AccountViewModel, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier.fillMaxWidth().verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - LocalBlossomCacheToggle(accountViewModel) - } -} - -@Composable -private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) { +fun MediaCacheSection(accountViewModel: AccountViewModel) { val enabled by accountViewModel.account.settings.useLocalBlossomCache .collectAsStateWithLifecycle() val profilePicturesOnly by accountViewModel.account.settings.localBlossomCacheProfilePicturesOnly .collectAsStateWithLifecycle() - val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics - .collectAsStateWithLifecycle() Column( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp)), ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { + Box( + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.Storage, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Column(modifier = Modifier.weight(1f).padding(start = 14.dp, end = 12.dp)) { Text( text = stringRes(id = R.string.use_local_blossom_cache), style = MaterialTheme.typography.bodyLarge, @@ -182,18 +164,6 @@ private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.grayText, ) - Text( - text = - if (enabled && probeAvailable) { - stringRes(id = R.string.local_blossom_cache_detected) - } else if (enabled) { - stringRes(id = R.string.local_blossom_cache_not_detected) - } else { - "" - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.grayText, - ) } Switch( checked = enabled, @@ -202,11 +172,21 @@ private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) { } if (enabled) { + CacheDetectionChip(accountViewModel) + + HorizontalDivider( + modifier = Modifier.padding(start = 64.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + Row( - modifier = Modifier.fillMaxWidth().padding(start = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(start = 64.dp, end = 14.dp, top = 12.dp, bottom = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Text( text = stringRes(id = R.string.local_blossom_cache_profile_pics_only), style = MaterialTheme.typography.bodyMedium, @@ -227,3 +207,39 @@ private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) { } } } + +/** + * Loopback-detection status for the local cache, shown only while the cache is enabled + * (kept in its own composable so the loopback probe is subscribed only then). + */ +@Composable +private fun CacheDetectionChip(accountViewModel: AccountViewModel) { + val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics + .collectAsStateWithLifecycle() + + val color = if (probeAvailable) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText + + Row( + modifier = Modifier.padding(start = 64.dp, end = 14.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(color), + ) + Text( + text = + if (probeAvailable) { + stringRes(id = R.string.local_blossom_cache_detected) + } else { + stringRes(id = R.string.local_blossom_cache_not_detected) + }, + style = MaterialTheme.typography.labelMedium, + color = color, + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 77d8d148cf..dcc3c4a25e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1553,9 +1553,13 @@ Use Default List Add media server Delete media server - Servers - Local cache Drag to reorder. Uploads try each server from the top down. + Upload priority + Add a server + Recommended + On-device cache + Primary + Added Reorder server Online Slow From 4d199ab28a28ab23369255ce4496e3de555f63d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 23:03:05 +0000 Subject: [PATCH 4/6] fix(media-servers): probe /upload not root; polish rows into cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Health probe was hitting the bare server root with HEAD. CDN-fronted hosts like cdn.satellite.earth never answer `/` (verified: HEAD/GET `/` time out, while HEAD `/upload` returns 404 in ~0.5s), so a fast, working server was wrongly shown Offline. Probe the BUD-01 `/upload` endpoint instead — the path that actually matters for an upload target. Visual polish: - Server rows are now outlined cards; the primary target (#1) gets an accent border, a faint accent tint, and a "Primary" badge. - Rank and monogram merge into one avatar (rank as a small corner badge), removing the separate rank tile and decluttering the row. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA --- .../mediaServers/AllMediaServersLIstView.kt | 112 ++++++++++++------ .../actions/mediaServers/MediaServerHealth.kt | 18 ++- 2 files changed, 86 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt index 9023ae2be9..f6d120ebf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -53,6 +54,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -192,9 +194,10 @@ private fun SectionLabel( } /** - * A draggable, ranked server row. Position in the list is the upload/fallback priority - * (row #1 is tried first), so each row carries a rank badge and a drag handle wired into - * the shared [RelayDragState], plus a monogram and a live reachability dot. + * A draggable, ranked server card. Position in the list is the upload/fallback priority + * (row #1 is tried first), so each card carries its rank on the monogram and a drag handle + * wired into the shared [RelayDragState], plus a live reachability dot. The primary target + * (#1) is called out with an accent border, tint, and badge. */ @Composable fun MediaServerRow( @@ -204,12 +207,26 @@ fun MediaServerRow( dragState: RelayDragState, onDelete: (serverUrl: String) -> Unit, ) { + val isPrimary = index == 0 + val shape = RoundedCornerShape(16.dp) Row( modifier = Modifier .fillMaxWidth() + .padding(vertical = 5.dp) .draggableRelayItem(index, dragState) - .padding(vertical = 7.dp), + .clip(shape) + .background( + if (isPrimary) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.06f) + } else { + Color.Transparent + }, + ).border( + width = if (isPrimary) 1.5.dp else 1.dp, + color = if (isPrimary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + shape = shape, + ).padding(start = 6.dp, top = 8.dp, bottom = 8.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -219,14 +236,12 @@ fun MediaServerRow( tint = MaterialTheme.colorScheme.grayText, ) - RankBadge(rank = index + 1) + Spacer(Modifier.size(8.dp)) - Spacer(Modifier.size(10.dp)) - - ServerMonogram(name = serverEntry.name, size = 32.dp) + ServerAvatar(name = serverEntry.name, rank = index + 1, isPrimary = isPrimary) Column( - modifier = Modifier.weight(1f).padding(start = 11.dp), + modifier = Modifier.weight(1f).padding(start = 12.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -236,7 +251,7 @@ fun MediaServerRow( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - if (index == 0) { + if (isPrimary) { Spacer(Modifier.size(6.dp)) PrimaryBadge() } @@ -378,35 +393,56 @@ private fun ServerMonogram( } } -/** Rounded rank badge. The primary target (#1) is filled with the accent color. */ +/** + * The server's monogram with its priority rank as a small corner badge — one visual unit + * for identity + position. The rank chip is accent-filled for the primary target (#1). + */ @Composable -private fun RankBadge(rank: Int) { - val isPrimary = rank == 1 - Box( - modifier = - Modifier - .size(22.dp) - .clip(RoundedCornerShape(7.dp)) - .background( - if (isPrimary) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - contentAlignment = Alignment.Center, - ) { - Text( - text = rank.toString(), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = - if (isPrimary) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) +private fun ServerAvatar( + name: String, + rank: Int, + isPrimary: Boolean, +) { + Box(modifier = Modifier.size(40.dp)) { + ServerMonogram(name = name, size = 36.dp) + + Box( + modifier = + Modifier + .align(Alignment.BottomEnd) + .size(18.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background) + .padding(1.5.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .clip(CircleShape) + .background( + if (isPrimary) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = rank.toString(), + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + color = + if (isPrimary) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt index c7dbb15d23..23e8c85326 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions.mediaServers +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException import okhttp3.OkHttpClient @@ -50,11 +51,16 @@ enum class ServerHealth { } /** - * A one-shot, lightweight reachability check for a Blossom server. Issues a - * `HEAD /` to the server's base URL and classifies the outcome by round-trip - * time. Any HTTP response — even 404/405 — counts as reachable; only + * A one-shot, lightweight reachability check for a Blossom server. Issues a `HEAD` to + * the server's `/upload` endpoint (BUD-01/BUD-02) and classifies the outcome by + * round-trip time. Any HTTP response — even 401/404/405 — counts as reachable; only * connection-level failures map to [ServerHealth.Offline]. * + * The `/upload` path is probed rather than the bare root because CDN-fronted hosts + * (e.g. cdn.satellite.earth) don't answer `/` at all and would time out, wrongly + * reading as offline even though uploads work. `/upload` is the endpoint that + * actually matters for a media upload target. + * * Mirrors the timeout/short-circuit shape of * [com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe], * but runs per-server and returns latency-classified status rather than a boolean. @@ -110,14 +116,14 @@ object MediaServerHealthProbe { val request = Request .Builder() - .url(baseUrl) + .url(BlossomServerUrl.upload(baseUrl)) .head() .build() val startedAt = TimeUtils.nowMillis() client.newCall(request).executeAsync().use { - // The status code doesn't matter — a Blossom root often answers 404/405. - // Getting any response back proves the host is reachable. + // The status code doesn't matter — /upload commonly answers 401/404/405 + // without auth. Getting any response back proves the host is reachable. val elapsed = TimeUtils.nowMillis() - startedAt if (elapsed > SLOW_THRESHOLD_MS) ServerHealth.Slow else ServerHealth.Online } From a99fc59de82e42aca68bd83c4aef3cd8fa26f54c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 23:21:02 +0000 Subject: [PATCH 5/6] feat(media-servers): auto-save on every change; polish cache card & add flow Addresses review feedback on the redesign: - Auto-save: drop the Save button and replace the cancel "X" with a standard back arrow (TopBarWithBackButton). Every add/remove persists immediately; a reorder persists once the drag gesture ends, so dragging no longer publishes a kind-10063 event on every intermediate swap. - On-device cache card now uses the app's filled surfaceContainer card style (no outline), matching the main Settings screens. - Drop the redundant intro sentence at the top of the screen. - Rework the "Add a server" block: recommended servers come first as one-tap chips, followed by the "or paste a server address" field. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA --- .../mediaServers/AllMediaServersLIstView.kt | 30 +++++++----- .../mediaServers/AllMediaServersScreen.kt | 26 ++++------ .../mediaServers/BlossomServersViewModel.kt | 47 ++++++++++--------- amethyst/src/main/res/values/strings.xml | 1 + 4 files changed, 52 insertions(+), 52 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt index f6d120ebf7..a37ccaf0b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -45,6 +45,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -101,24 +102,21 @@ fun AllMediaBody( itemCount = { blossomServersState.size }, ) + // Auto-save the reordering once the drag finishes, rather than on every intermediate swap. + LaunchedEffect(dragState.isDragging) { + if (!dragState.isDragging) blossomServersViewModel.persistPending() + } + LazyColumn( modifier = modifier, contentPadding = FeedPadding, userScrollEnabled = !dragState.isDragging, ) { - item { - Text( - text = stringRes(id = R.string.set_preferred_media_servers), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.grayText, - modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), - ) - } - item { SectionLabel( title = stringRes(id = R.string.media_servers_priority_section), caption = stringRes(id = R.string.media_servers_reorder_hint), + modifier = Modifier.padding(top = 8.dp), ) } @@ -289,10 +287,9 @@ private fun AddServerSection( ) { SectionLabel(title = stringRes(id = R.string.media_servers_add_section)) - MediaServerEditField(R.string.add_a_blossom_server) { onAddServer(it) } - + // Recommended servers first — one tap adds a known-good host. Row( - modifier = Modifier.fillMaxWidth().padding(top = 14.dp), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -323,6 +320,15 @@ private fun AddServerSection( ) } } + + // ...or paste any server address. + Text( + text = stringRes(id = R.string.media_servers_add_url_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + ) + MediaServerEditField(R.string.add_a_blossom_server) { onAddServer(it) } } /** A recommended server as a tappable pill. Once added it reads as done and stops responding. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt index 760935d1db..cefa1fe242 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -53,7 +52,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.allGoodColor @@ -72,9 +71,7 @@ fun AllMediaServersScreen( blossomServersViewModel.load() } - MediaServersScaffold(blossomServersViewModel, accountViewModel) { - nav.popBack() - } + MediaServersScaffold(blossomServersViewModel, accountViewModel, nav) } @OptIn(ExperimentalMaterial3Api::class) @@ -82,20 +79,13 @@ fun AllMediaServersScreen( fun MediaServersScaffold( blossomServersViewModel: BlossomServersViewModel, accountViewModel: AccountViewModel, - onClose: () -> Unit, + nav: INav, ) { Scaffold( topBar = { - SavingTopBar( - titleRes = R.string.media_servers, - onCancel = { - blossomServersViewModel.refresh() - onClose() - }, - onPost = { - blossomServersViewModel.saveFileServers() - onClose() - }, + TopBarWithBackButton( + caption = stringRes(id = R.string.media_servers), + nav = nav, ) }, ) { padding -> @@ -132,8 +122,8 @@ fun MediaCacheSection(accountViewModel: AccountViewModel) { modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp)), + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), ) { Row( modifier = Modifier.fillMaxWidth().padding(14.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index a5e5607a40..c22154e5c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -123,12 +123,17 @@ class BlossomServersViewModel : ViewModel() { } fun addServerList(serverList: List) { - serverList.forEach { serverUrl -> - addServer(serverUrl) - } + var added = false + serverList.forEach { if (addServerInternal(it)) added = true } + if (added) persist() } fun addServer(serverUrl: String) { + if (addServerInternal(serverUrl)) persist() + } + + /** Adds a server to the in-memory list (no persist). Returns true if it was new. */ + private fun addServerInternal(serverUrl: String): Boolean { val normalizedUrl = try { Rfc3986.normalize(serverUrl.trim()) @@ -147,15 +152,12 @@ class BlossomServersViewModel : ViewModel() { normalizedUrl, ServerType.Blossom, ) - if (_fileServers.value.contains(serverRef)) { - return - } else { - _fileServers.update { - it.plus(serverRef) - } - probeServer(serverRef.baseUrl) - } + if (_fileServers.value.contains(serverRef)) return false + + _fileServers.update { it.plus(serverRef) } + probeServer(serverRef.baseUrl) isModified = true + return true } fun removeServer( @@ -176,21 +178,22 @@ class BlossomServersViewModel : ViewModel() { } pruneHealth() isModified = true + persist() } } - fun removeAllServers() { - _fileServers.update { emptyList() } - isModified = true - } + /** + * Publishes any pending change. Called after each discrete edit (add/remove) and, + * for a reorder, once the drag gesture completes — so a drag doesn't publish a + * kind-10063 event on every intermediate swap. + */ + fun persistPending() = persist() - fun saveFileServers() { - if (isModified) { - accountViewModel.launchSigner { - val serverList = _fileServers.value.map { it.baseUrl } - account.sendBlossomServersList(serverList) - refresh() - } + private fun persist() { + if (!isModified) return + isModified = false + accountViewModel.launchSigner { + account.sendBlossomServersList(_fileServers.value.map { it.baseUrl }) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index dcc3c4a25e..ba81aba405 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1557,6 +1557,7 @@ Upload priority Add a server Recommended + Or paste a server address On-device cache Primary Added From d2e18cb18df6fcf200120a71ccb9c64fccb794cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 23:26:34 +0000 Subject: [PATCH 6/6] fix(media-servers): trim the gap above the first section header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first "Upload priority" header stacked FeedPadding's top inset, an extra 8dp, and SectionLabel's 20dp inter-section top padding — ~38dp of dead space under the top bar. Make SectionLabel's top padding a parameter and shrink it for the first section. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA --- .../ui/actions/mediaServers/AllMediaServersLIstView.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt index a37ccaf0b0..572f75fce1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -116,7 +116,7 @@ fun AllMediaBody( SectionLabel( title = stringRes(id = R.string.media_servers_priority_section), caption = stringRes(id = R.string.media_servers_reorder_hint), - modifier = Modifier.padding(top = 8.dp), + topPadding = 4.dp, ) } @@ -172,9 +172,10 @@ fun AllMediaBody( private fun SectionLabel( title: String, caption: String? = null, + topPadding: Dp = 20.dp, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.fillMaxWidth().padding(top = 20.dp, bottom = 8.dp)) { + Column(modifier = modifier.fillMaxWidth().padding(top = topPadding, bottom = 8.dp)) { Text( text = title, style = MaterialTheme.typography.titleSmall,