mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 00:37:41 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
This commit is contained in:
+257
-125
@@ -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<ServerName>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+42
-14
@@ -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
|
||||
|
||||
+37
@@ -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() {
|
||||
@@ -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<String>) {
|
||||
serverList.forEach { serverUrl ->
|
||||
addServer(serverUrl)
|
||||
@@ -100,6 +136,7 @@ class BlossomServersViewModel : ViewModel() {
|
||||
_fileServers.update {
|
||||
it.plus(serverRef)
|
||||
}
|
||||
probeServer(serverRef.baseUrl)
|
||||
}
|
||||
isModified = true
|
||||
}
|
||||
|
||||
+98
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1553,6 +1553,14 @@
|
||||
<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_tab_servers">Servers</string>
|
||||
<string name="media_servers_tab_cache">Local cache</string>
|
||||
<string name="media_servers_reorder_hint">Drag to reorder. Uploads try each server from the top down.</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