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..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 @@ -20,103 +20,145 @@ */ 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.fillMaxSize 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.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.LaunchedEffect 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.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 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 +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 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) { +fun AllMediaBody( + blossomServersViewModel: BlossomServersViewModel, + accountViewModel: AccountViewModel, + 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 }, + ) + + // Auto-save the reordering once the drag finishes, rather than on every intermediate swap. + LaunchedEffect(dragState.isDragging) { + if (!dragState.isDragging) blossomServersViewModel.persistPending() + } LazyColumn( - verticalArrangement = Arrangement.SpaceAround, - horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, contentPadding = FeedPadding, + userScrollEnabled = !dragState.isDragging, ) { item { - SettingsCategory( - R.string.media_servers_blossom_section, - R.string.media_servers_blossom_explainer, - SettingsCategoryFirstModifier, + SectionLabel( + title = stringRes(id = R.string.media_servers_priority_section), + caption = stringRes(id = R.string.media_servers_reorder_hint), + topPadding = 4.dp, ) } - 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, - ) { - 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)) - } - } - } - 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) - } - }, + Text( + text = stringRes(id = R.string.no_blossom_server_message), + modifier = DoubleVertPadding, ) } + } else { + 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 { + 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 { + SectionLabel(title = stringRes(id = R.string.media_servers_cache_section)) + MediaCacheSection(accountViewModel) } item { @@ -125,97 +167,358 @@ fun AllMediaBody(blossomServersViewModel: BlossomServersViewModel) { } } -fun LazyListScope.renderMediaServerList( - mediaServersState: List, - keyType: String, - editLabel: Int, - emptyLabel: Int, - onAddServer: (String) -> Unit, - onDeleteServer: (String) -> Unit, +/** Compact section header: an accent label with an optional gray caption below. */ +@Composable +private fun SectionLabel( + title: String, + caption: String? = null, + topPadding: Dp = 20.dp, + modifier: Modifier = Modifier, ) { - if (mediaServersState.isEmpty()) { - item { + Column(modifier = modifier.fillMaxWidth().padding(top = topPadding, bottom = 8.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + if (caption != null) { Text( - text = stringRes(id = emptyLabel), - modifier = DoubleVertPadding, + text = caption, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, ) } - } 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 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 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, ) { + val isPrimary = index == 0 + val shape = RoundedCornerShape(16.dp) Row( modifier = - modifier + Modifier .fillMaxWidth() - .padding(vertical = 10.dp), + .padding(vertical = 5.dp) + .draggableRelayItem(index, dragState) + .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, - horizontalArrangement = Arrangement.SpaceAround, ) { + Icon( + symbol = MaterialSymbols.DragIndicator, + contentDescription = stringRes(id = R.string.media_server_reorder), + modifier = Modifier.size(22.dp).relayDragHandle(index, dragState), + tint = MaterialTheme.colorScheme.grayText, + ) + + Spacer(Modifier.size(8.dp)) + + ServerAvatar(name = serverEntry.name, rank = index + 1, isPrimary = isPrimary) + Column( - modifier = - Modifier - .weight(1f), + modifier = Modifier.weight(1f).padding(start = 12.dp), ) { - serverEntry.let { + Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = it.name.replaceFirstChar(Char::titlecase), + text = serverEntry.name.replaceFirstChar(Char::titlecase), style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), ) - Spacer(modifier = StdVertSpacer) - Text( - text = it.baseUrl, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.grayText, - ) + if (isPrimary) { + Spacer(Modifier.size(6.dp)) + PrimaryBadge() + } } + 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) + HealthIndicator(health) + + IconButton(onClick = { onDelete(serverEntry.baseUrl) }) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringRes(id = R.string.delete_media_server), + tint = MaterialTheme.colorScheme.grayText, + ) + } + } +} + +/** + * 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 +private fun AddServerSection( + addedHosts: Set, + onAddServer: (String) -> Unit, + onAddAll: () -> Unit, +) { + SectionLabel(title = stringRes(id = R.string.media_servers_add_section)) + + // Recommended servers first — one tap adds a known-good host. + Row( + modifier = Modifier.fillMaxWidth(), + 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) }, + ) + } + } + + // ...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. */ +@Composable +private fun RecommendedChip( + serverEntry: ServerName, + added: Boolean, + onAdd: () -> Unit, +) { + val shape = RoundedCornerShape(50) + Row( + modifier = + Modifier + .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), + ) { + 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, + ) + } +} + +/** 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, + ) + } +} + +/** + * 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 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, ) { - Icon( - symbol = if (isAmethystDefault) MaterialSymbols.Add else MaterialSymbols.Delete, - contentDescription = - if (isAmethystDefault) { - stringRes(id = R.string.add_media_server) + Text( + text = rank.toString(), + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + color = + if (isPrimary) { + MaterialTheme.colorScheme.onPrimary } else { - stringRes(id = R.string.delete_media_server) + MaterialTheme.colorScheme.onSurfaceVariant }, ) } } } } + +/** 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) { + 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..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 @@ -20,7 +20,9 @@ */ 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.consumeWindowInsets @@ -28,6 +30,9 @@ 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.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 @@ -39,15 +44,18 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign +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.navigation.topbars.TopBarWithBackButton 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 @@ -63,9 +71,7 @@ fun AllMediaServersScreen( blossomServersViewModel.load() } - MediaServersScaffold(blossomServersViewModel, accountViewModel) { - nav.popBack() - } + MediaServersScaffold(blossomServersViewModel, accountViewModel, nav) } @OptIn(ExperimentalMaterial3Api::class) @@ -73,24 +79,19 @@ 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 -> - Column( + AllMediaBody( + blossomServersViewModel = blossomServersViewModel, + accountViewModel = accountViewModel, modifier = Modifier .fillMaxSize() @@ -101,43 +102,49 @@ 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, - ) - - LocalBlossomCacheToggle(accountViewModel) - HorizontalDivider() - - AllMediaBody(blossomServersViewModel) - } + ) } } +/** + * 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 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(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), ) { 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, @@ -147,18 +154,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, @@ -167,11 +162,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, @@ -192,3 +197,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/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index aef785e87d..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 @@ -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() { @@ -67,15 +77,63 @@ class BlossomServersViewModel : ViewModel() { } } } + pruneHealth() } - fun addServerList(serverList: List) { - serverList.forEach { serverUrl -> - addServer(serverUrl) + /** 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. 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) + _health.update { it + (serverUrl to result) } } } + /** 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) { + 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()) @@ -94,14 +152,12 @@ class BlossomServersViewModel : ViewModel() { normalizedUrl, ServerType.Blossom, ) - if (_fileServers.value.contains(serverRef)) { - return - } else { - _fileServers.update { - it.plus(serverRef) - } - } + if (_fileServers.value.contains(serverRef)) return false + + _fileServers.update { it.plus(serverRef) } + probeServer(serverRef.baseUrl) isModified = true + return true } fun removeServer( @@ -120,22 +176,24 @@ class BlossomServersViewModel : ViewModel() { ServerName(serverName, serverUrl, ServerType.Blossom), ) } + 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/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..23e8c85326 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt @@ -0,0 +1,135 @@ +/* + * 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.nipB7Blossom.BlossomServerUrl +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync +import java.util.concurrent.ConcurrentHashMap +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 `/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. + */ +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 + + /** + * 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 = + 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(BlossomServerUrl.upload(baseUrl)) + .head() + .build() + + val startedAt = TimeUtils.nowMillis() + client.newCall(request).executeAsync().use { + // 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 + } + } 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 1fca88d9d9..67af627ae0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1550,6 +1550,19 @@ Use Default List Add media server Delete media server + Drag to reorder. Uploads try each server from the top down. + Upload priority + Add a server + Recommended + Or paste a server address + On-device cache + Primary + Added + 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).