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:
Claude
2026-07-17 19:33:43 +00:00
parent f380d92498
commit f759ef6ca7
5 changed files with 442 additions and 139 deletions
@@ -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,
)
}
}
@@ -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
@@ -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
}
@@ -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
}
}
+8
View File
@@ -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>