mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge pull request #3628 from vitorpamplona/claude/mediaservers-settings-ui-design-gbq9m7
Redesign media servers UI with drag-reorder, health probes, and cards
This commit is contained in:
+423
-120
@@ -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<ServerName>,
|
||||
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<String>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+95
-54
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+79
-21
@@ -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<List<ServerName>>(emptyList())
|
||||
val fileServers = _fileServers.asStateFlow()
|
||||
|
||||
/** Reachability status per server, keyed by [ServerName.baseUrl]. */
|
||||
private val _health = MutableStateFlow<Map<String, ServerHealth>>(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<String>) {
|
||||
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<String>) {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+135
@@ -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<String, CachedResult>()
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
@@ -1550,6 +1550,19 @@
|
||||
<string name="use_default_servers">Use Default List</string>
|
||||
<string name="add_media_server">Add media server</string>
|
||||
<string name="delete_media_server">Delete media server</string>
|
||||
<string name="media_servers_reorder_hint">Drag to reorder. Uploads try each server from the top down.</string>
|
||||
<string name="media_servers_priority_section">Upload priority</string>
|
||||
<string name="media_servers_add_section">Add a server</string>
|
||||
<string name="media_servers_recommended_label">Recommended</string>
|
||||
<string name="media_servers_add_url_label">Or paste a server address</string>
|
||||
<string name="media_servers_cache_section">On-device cache</string>
|
||||
<string name="media_server_primary_badge">Primary</string>
|
||||
<string name="media_server_added">Added</string>
|
||||
<string name="media_server_reorder">Reorder server</string>
|
||||
<string name="media_server_status_online">Online</string>
|
||||
<string name="media_server_status_slow">Slow</string>
|
||||
<string name="media_server_status_offline">Offline</string>
|
||||
<string name="media_server_status_checking">Checking…</string>
|
||||
<string name="payment_targets">Payment Targets</string>
|
||||
<string name="payment_targets_explainer">Publish your payment addresses so others can send you funds directly.</string>
|
||||
<string name="payment_targets_section_explainer">Add payment addresses for different networks (e.g. bitcoin, lightning, ethereum).</string>
|
||||
|
||||
Reference in New Issue
Block a user