Several minor bug fixes (#1811)

## Description
Fixes several small bugs and does some minor refactoring:
- Use "Recently Added in XYZ" instead of "Recently recorded" for
recorded tv libraries
- Improve home page query performance by using fewer fields in the query
- When an error occurs connecting to discovered servers, show the error
instead of only logging
- Fix headers sometimes appearing briefly on some pages when navigating
back
- Catch exceptions in socket connections
- Use a larger buffer when downloading updates
- Split up ADB logging for media info

### Related issues
Related to
https://github.com/damontecres/Wholphin/issues/1517#issuecomment-5184316086
Maybe the larger download buffer will help with
https://github.com/damontecres/Wholphin/issues/1696
Will help show the error in #1801
Prevents the crash in #1786

### Testing
Emulator & nvidia shield

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-08-13 12:36:20 -04:00 • committed by GitHub
parent 54966f9124
commit 51ab39ed3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 282 additions and 250 deletions

View file

@ -12,9 +12,8 @@ import com.github.damontecres.wholphin.data.model.createGenreDestination
import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.HomeItemFields
import com.github.damontecres.wholphin.ui.ProgramItemFields
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions
@ -62,6 +61,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserDto
@ -280,7 +280,7 @@ class HomeSettingsService
libraries
.mapIndexed { index, it ->
val parentId = it.itemId
val title = getRecentlyAddedTitle(it)
val title = getRecentlyAddedTitle(it.name)
if (it.collectionType == CollectionType.LIVETV) {
HomeRowConfigDisplay(
id = index,
@ -766,13 +766,11 @@ class HomeSettingsService
}
is HomeRowConfig.RecentlyAdded -> {
val library =
libraries
.firstOrNull { it.itemId == row.parentId }
val title = getRecentlyAddedTitle(library)
val library = libraries.firstOrNull { it.itemId == row.parentId }
val title = getRecentlyAddedTitle(library?.name)
val request =
GetLatestMediaRequest(
fields = SlimItemFields,
fields = library.itemFields,
imageTypeLimit = 1,
parentId = row.parentId,
groupItems = true,
@ -797,12 +795,9 @@ class HomeSettingsService
}
is HomeRowConfig.RecentlyReleased -> {
val name =
libraries
.firstOrNull { it.itemId == row.parentId }
?.name
val library = libraries.firstOrNull { it.itemId == row.parentId }
val title =
name?.let {
library?.name?.let {
ResArgStringProvider(R.string.recently_released_in, it)
} ?: ResStringProvider(R.string.recently_released)
val request =
@ -821,7 +816,7 @@ class HomeSettingsService
SortOrder.ASCENDING,
SortOrder.DESCENDING,
),
fields = DefaultItemFields,
fields = library.itemFields,
recursive = true,
maxPremiereDate = LocalDateTime.now(),
isUnaired = false,
@ -851,6 +846,7 @@ class HomeSettingsService
}
is HomeRowConfig.ByParent -> {
val library = libraries.firstOrNull { it.itemId == row.parentId }
val request =
GetItemsRequest(
userId = userDto.id,
@ -885,7 +881,7 @@ class HomeSettingsService
}
},
limit = limit,
fields = DefaultItemFields,
fields = library.itemFields,
)
// Not using getItemName because we want to throw the 404
@ -970,7 +966,7 @@ class HomeSettingsService
GetPersonsRequest(
userId = userDto.id,
limit = limit,
fields = DefaultItemFields,
fields = HomeItemFields,
isFavorite = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY),
@ -988,12 +984,18 @@ class HomeSettingsService
)
}
} else {
val fields =
if (row.kind == BaseItemKind.BOX_SET) {
HomeItemFieldsBoxSets
} else {
HomeItemFields
}
val request =
GetItemsRequest(
userId = userDto.id,
recursive = true,
limit = limit,
fields = DefaultItemFields,
fields = fields,
includeItemTypes = listOf(row.kind),
isFavorite = true,
)
@ -1027,7 +1029,7 @@ class HomeSettingsService
GetRecordingsRequest(
userId = userDto.id,
isInProgress = true,
fields = DefaultItemFields,
fields = HomeItemFields,
limit = limit,
enableImages = true,
enableUserData = true,
@ -1098,7 +1100,7 @@ class HomeSettingsService
val request =
GetLiveTvChannelsRequest(
userId = userDto.id,
fields = DefaultItemFields,
fields = HomeItemFields,
limit = limit,
enableImages = true,
)
@ -1225,10 +1227,17 @@ class UnsupportedHomeSettingsVersionException(
val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION,
) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion")
fun getRecentlyAddedTitle(library: Library?): StringProvider =
if (library?.isRecordingFolder == true) {
ResStringProvider(R.string.recently_recorded)
} else {
library?.name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
?: ResStringProvider(R.string.recently_added)
}
fun getRecentlyAddedTitle(name: String?): StringProvider =
name?.let { ResArgStringProvider(R.string.recently_added_in, it) }
?: ResStringProvider(R.string.recently_added)
private val Library?.itemFields: List<ItemFields>
get() =
if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) {
// Get child count to show in the header
HomeItemFieldsBoxSets
} else {
HomeItemFields
}
private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT)

View file

@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.google.protobuf.MessageLiteOrBuilder
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.Json
@ -76,21 +77,18 @@ class MediaReportService
model=${Build.MODEL}
apiLevel=${Build.VERSION.SDK_INT}
playbackPrefs=${
appPreferences.playbackPreferences.toString().replace("\n", ", ")
.replace("\t", " ")
}
experimental=${
appPreferences.experimentalPreferences.toString().replace("\n", ", ")
.replace("\t", " ")
}
deviceProfile=$deviceProfileJson
playbackPrefs=${appPreferences.playbackPreferences.toStringOneLine()}
experimental=${appPreferences.experimentalPreferences.toStringOneLine()}
mediaSources=$sourcesJson
deviceProfile=$deviceProfileJson
""".trimIndent()
Timber.w(body)
body.chunked(2048).forEach { Timber.w(it) }
Timber.w("End send media info")
val response by api.clientLogApi.logFile(body)
showToast(context, "Sent! Filename=${response.fileName}")
}
}
private fun MessageLiteOrBuilder.toStringOneLine(): String = toString().replace("\n", ", ").replace("\t", " ")

View file

@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.jellyfin.sdk.api.client.ApiClient
@ -94,6 +95,8 @@ class ServerEventListener
.joinToString("\n")
showToast(context, toast, Toast.LENGTH_LONG)
}
}.catch { ex ->
Timber.e(ex, "Error in websocket subscription")
}.launchIn(activity.lifecycleScope)
}

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.services
import android.Manifest
import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context
import android.content.Intent
@ -61,8 +62,6 @@ class UpdateChecker
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
private const val PERMISSION_REQUEST_CODE = 123456
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
val ACTIVE = BuildConfig.UPDATING_ENABLED
@ -158,8 +157,8 @@ class UpdateChecker
private fun getRelease(request: Request): Release? {
return okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
val result = Json.parseToJsonElement(it.body!!.string())
if (it.isSuccessful) {
val result = Json.parseToJsonElement(it.body.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name)
val publishedAt =
@ -195,6 +194,7 @@ class UpdateChecker
/**
* Download and install an update
*/
@SuppressLint("RequestInstallPackagesPolicy")
suspend fun installRelease(
release: Release,
callback: DownloadCallback,
@ -208,7 +208,7 @@ class UpdateChecker
.get()
.build()
okHttpClient.newCall(request).execute().use {
if (it.isSuccessful && it.body != null) {
if (it.isSuccessful) {
Timber.v("Request successful for ${release.downloadUrl}")
withContext(WholphinDispatchers.Main) {
callback.contentLength(it.body.contentLength())
@ -230,7 +230,7 @@ class UpdateChecker
contentValues,
)
if (uri != null) {
it.body!!.byteStream().use { input ->
it.body.byteStream().use { input ->
resolver.openOutputStream(uri).use { output ->
copyTo(input, output!!, callback = callback)
}
@ -290,7 +290,7 @@ class UpdateChecker
downloadDir.mkdirs()
val targetFile = File(downloadDir, APK_NAME)
targetFile.outputStream().use { output ->
response.body!!.byteStream().use { input ->
response.body.byteStream().use { input ->
copyTo(input, output, callback = callback)
}
}
@ -374,7 +374,7 @@ data class Release(
Regex("https://github.com/\\w*/\\w+/pull/(\\d+)"),
"#$1",
)
// Remove the last line for full changelog since its just a link
// Remove the last line for full changelog since it's just a link
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), "")
}
@ -387,29 +387,30 @@ interface DownloadCallback {
suspend fun copyTo(
input: InputStream,
out: OutputStream,
bufferSize: Int = 16 * 1024,
bufferSize: Int = 64 * 1024,
callback: DownloadCallback,
): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = input.read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
withContext(WholphinDispatchers.Main) {
callback.bytesDownloaded(bytesCopied)
): Long =
withContext(WholphinDispatchers.IO) {
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = input.read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
withContext(WholphinDispatchers.Main) {
callback.bytesDownloaded(bytesCopied)
}
bytes = input.read(buffer)
}
bytes = input.read(buffer)
return@withContext bytesCopied
}
return bytesCopied
}
fun getDownloadUrl(
assets: JsonArray,
debug: Boolean,
supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(),
supportedABIs: List<String> = Build.SUPPORTED_ABIS.toList(),
): String? {
val abiSuffix = supportedAbis.firstOrNull().let { if (it != null) "-$it" else "" }
val abiSuffix = supportedABIs.firstOrNull().let { if (it != null) "-$it" else "" }
val releaseSuffix = if (debug) "-debug" else "-release"
val preferredNames =
buildList {

View file

@ -49,8 +49,6 @@ const val DEFAULT_PAGE_SIZE = 100
*/
val DefaultItemFields =
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW,
ItemFields.TRICKPLAY,
@ -58,7 +56,6 @@ val DefaultItemFields =
ItemFields.CHAPTERS,
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID,
ItemFields.CAN_DELETE,
)
@ -67,13 +64,16 @@ val DefaultItemFields =
*/
val SlimItemFields =
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW,
ItemFields.SORT_NAME,
ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID,
ItemFields.CAN_DELETE,
)
val HomeItemFields =
listOf(
ItemFields.OVERVIEW,
ItemFields.CAN_DELETE,
)

View file

@ -265,7 +265,7 @@ fun ItemCardImageOverlay(
}
}
watchedPercent?.let { percent ->
if (watchedPercent != null && watchedPercent > 0 && watchedPercent < 100) {
Box(
modifier =
Modifier
@ -274,7 +274,7 @@ fun ItemCardImageOverlay(
MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape)
.height(Cards.playedPercentHeight)
.fillMaxWidth((percent / 100.0).toFloat()),
.fillMaxWidth((watchedPercent / 100.0).toFloat()),
)
}
}

View file

@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -99,7 +100,7 @@ fun CollectionFolderList(
val positionFocusRequester = remember { FocusRequester() }
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
var showHeader by remember { mutableStateOf(true) }
var showHeader by rememberSaveable { mutableStateOf(true) }
fun jumpTo(newPosition: Int) {
scope.launch {

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -32,7 +33,7 @@ fun CollectionFolderGeneric(
sortOptions: List<ItemSortBy> = VideoSortOptions,
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
var showHeader by remember { mutableStateOf(true) }
var showHeader by rememberSaveable { mutableStateOf(true) }
val viewOptions =
remember(usePosters) {
if (usePosters) {

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -48,7 +49,7 @@ fun CollectionFolderPhotoAlbum(
)
},
) {
var showHeader by remember { mutableStateOf(true) }
var showHeader by rememberSaveable { mutableStateOf(true) }
val state by viewModel.state.collectAsState()
CollectionFolderView(
preferences = preferences,

View file

@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.ui.detail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -24,7 +24,7 @@ fun CollectionFolderPlaylist(
filter: CollectionFolderFilter = CollectionFolderFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
var showHeader by remember { mutableStateOf(true) }
var showHeader by rememberSaveable { mutableStateOf(true) }
CollectionFolderView(
preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },

View file

@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.ui.detail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -24,7 +24,7 @@ fun CollectionFolderRecordings(
filter: CollectionFolderFilter = CollectionFolderFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
var showHeader by remember { mutableStateOf(true) }
var showHeader by rememberSaveable { mutableStateOf(true) }
CollectionFolderView(
preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },

View file

@ -11,6 +11,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -259,7 +260,7 @@ fun DiscoverRequestGrid(
) {
val state by viewModel.state.collectAsState()
var showHeader by remember { mutableStateOf(state.startIndex < 6) }
var showHeader by rememberSaveable { mutableStateOf(state.startIndex < 6) }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,

View file

@ -576,7 +576,7 @@ fun SearchPage(
viewModel.navigationManager.navigateTo(dest)
}
var showHeader by remember { mutableStateOf(true) }
var showHeader by rememberSaveable { mutableStateOf(true) }
val positionCallback = { columns: Int, index: Int ->
showHeader = index < columns
}

View file

@ -31,6 +31,7 @@ import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.getRecentlyAddedTitle
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.AspectRatio
@ -381,7 +382,7 @@ class HomeSettingsViewModel
}
LibraryRowType.RECENTLY_RECORDED -> {
val title = ResStringProvider(R.string.recently_recorded)
val title = getRecentlyAddedTitle(library.name)
HomeRowConfigDisplay(
id = id,
title = title,

View file

@ -84,6 +84,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@ -1470,6 +1471,8 @@ class PlaybackViewModel
}
}
}
}.catch { ex ->
Timber.e(ex, "Error in websocket subscription")
}.launchIn(viewModelScope)
/**

View file

@ -278,188 +278,182 @@ private fun SwitchServerContentInternal(
}
if (showAddServer) {
var showEnterAddress by remember { mutableStateOf(false) }
AddServerDialog(
onDismissRequest = { showAddServer = false },
viewModel = viewModel,
)
}
}
}
LaunchedEffect(Unit) {
viewModel.clearAddServerState()
if (!showEnterAddress) {
viewModel.discoverServers()
}
}
@Composable
private fun AddServerDialog(
onDismissRequest: () -> Unit,
viewModel: SwitchServerViewModel,
) {
val state by viewModel.state.collectAsState()
var showEnterAddress by remember { mutableStateOf(false) }
// Filter out duplicates within the discovered servers list (same URL appearing multiple times)
val filteredDiscoveredServers =
remember(state.discoveredServers) {
val seenUrls = mutableSetOf<String>()
state.discoveredServers.filter { server ->
val normalizedUrl = server.url.lowercase().trim()
if (normalizedUrl in seenUrls) {
false // Duplicate, filter it out
} else {
seenUrls.add(normalizedUrl)
true // First occurrence, keep it
LaunchedEffect(Unit) {
viewModel.clearAddServerState()
if (!showEnterAddress) {
viewModel.discoverServers()
}
}
val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
// Default focus to first discovered server if available
LaunchedEffect(state.discoveredServers.isNotEmpty(), showEnterAddress) {
if (!showEnterAddress && state.discoveredServers.isNotEmpty()) {
firstDiscoveredServerFocusRequester.tryRequestFocus()
}
}
BasicDialog(
onDismissRequest = {
showEnterAddress = false
viewModel.clearAddServerState()
onDismissRequest.invoke()
},
properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = 10.dp,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(16.dp)
.fillMaxWidth(.4f),
) {
if (!showEnterAddress) {
// Show discovered servers first
Text(
text = stringResource(R.string.discovered_servers),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (state.discoveredServers.isEmpty()) {
Text(
text = stringResource(R.string.searching),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
} else {
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp),
) {
items(
state.discoveredServers.size,
key = { state.discoveredServers[it].url },
) { index ->
val server = state.discoveredServers[index]
val focusRequester =
if (index == 0) {
firstDiscoveredServerFocusRequester
} else {
remember { FocusRequester() }
}
ListItem(
enabled = true,
selected = false,
headlineContent = {
Text(
text =
server.name?.ifBlank { null }
?: server.url,
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
text = server.url,
style = MaterialTheme.typography.bodyMedium,
)
},
onClick = {
viewModel.addServer(server.url, showToast = true)
},
modifier = Modifier.focusRequester(focusRequester),
)
}
}
}
val firstDiscoveredServerFocusRequester = remember { FocusRequester() }
// Default focus to first discovered server if available
LaunchedEffect(filteredDiscoveredServers.isNotEmpty(), showEnterAddress) {
if (!showEnterAddress && filteredDiscoveredServers.isNotEmpty()) {
firstDiscoveredServerFocusRequester.tryRequestFocus()
TextButton(
onClick = {
showEnterAddress = true
},
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(text = stringResource(R.string.enter_server_address))
}
}
} else {
// Show enter server address form
val addServerState = state.addServerState
var url by remember { mutableStateOf("") }
val submit = {
viewModel.addServer(url, showToast = false)
}
val textBoxFocusRequester = remember { FocusRequester() }
BasicDialog(
onDismissRequest = {
showAddServer = false
showEnterAddress = false
LaunchedEffect(Unit) {
textBoxFocusRequester.tryRequestFocus()
}
LaunchedEffect(url) {
viewModel.clearAddServerState()
},
properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = 10.dp,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
}
Text(
text = stringResource(R.string.enter_server_url),
)
EditTextBox(
value = url,
onValueChange = { url = it },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
keyboardActions =
KeyboardActions(
onGo = { submit.invoke() },
),
modifier =
Modifier
.padding(16.dp)
.fillMaxWidth(.4f),
.testTag("server_url_text")
.focusRequester(textBoxFocusRequester)
.fillMaxWidth(),
)
when (val st = addServerState) {
is LoadingState.Error -> {
Text(
text =
st.message ?: st.exception?.localizedMessage
?: "An error occurred",
color = MaterialTheme.colorScheme.error,
)
}
else -> {}
}
TextButton(
onClick = { submit.invoke() },
enabled = url.isNotNullOrBlank() && addServerState == LoadingState.Pending,
modifier = Modifier,
) {
if (!showEnterAddress) {
// Show discovered servers first
Text(
text = stringResource(R.string.discovered_servers),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (filteredDiscoveredServers.isEmpty() && state.discoveredServers.isEmpty()) {
Text(
text = stringResource(R.string.searching),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
} else if (filteredDiscoveredServers.isEmpty()) {
Text(
text = stringResource(R.string.no_servers_found),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
} else {
LazyColumn(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp),
) {
items(
filteredDiscoveredServers.size,
key = { filteredDiscoveredServers[it].url },
) { index ->
val server = filteredDiscoveredServers[index]
val focusRequester =
if (index == 0) {
firstDiscoveredServerFocusRequester
} else {
remember { FocusRequester() }
}
ListItem(
enabled = true,
selected = false,
headlineContent = {
Text(
text =
server.name?.ifBlank { null }
?: server.url,
style = MaterialTheme.typography.bodyLarge,
)
},
supportingContent = {
Text(
text = server.url,
style = MaterialTheme.typography.bodyMedium,
)
},
onClick = {
viewModel.addServer(server.url)
},
modifier = Modifier.focusRequester(focusRequester),
)
}
}
}
TextButton(
onClick = {
showEnterAddress = true
},
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(text = stringResource(R.string.enter_server_address))
}
if (addServerState == LoadingState.Loading) {
CircularProgress(Modifier.size(32.dp))
} else {
// Show enter server address form
val addServerState = state.addServerState
var url by remember { mutableStateOf("") }
val submit = {
viewModel.addServer(url)
}
val textBoxFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
textBoxFocusRequester.tryRequestFocus()
}
Text(
text = stringResource(R.string.enter_server_url),
)
EditTextBox(
value = url,
onValueChange = { url = it },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
keyboardActions =
KeyboardActions(
onGo = { submit.invoke() },
),
modifier =
Modifier
.testTag("server_url_text")
.focusRequester(textBoxFocusRequester)
.fillMaxWidth(),
)
when (val st = addServerState) {
is LoadingState.Error -> {
Text(
text =
st.message ?: st.exception?.localizedMessage
?: "An error occurred",
color = MaterialTheme.colorScheme.error,
)
}
else -> {}
}
TextButton(
onClick = { submit.invoke() },
enabled = url.isNotNullOrBlank() && addServerState == LoadingState.Pending,
modifier = Modifier,
) {
if (addServerState == LoadingState.Loading) {
CircularProgress(Modifier.size(32.dp))
} else {
Text(text = stringResource(R.string.submit))
}
}
Text(text = stringResource(R.string.submit))
}
}
}

View file

@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.client.exception.TimeoutException
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.discovery.RecommendedServerIssue
@ -134,6 +135,14 @@ class SwitchServerViewModel
.getPublicSystemInfo()
.content
ServerConnectionStatus.Success(systemInfo)
} catch (ex: TimeoutException) {
Timber.w(ex, "timeout checking server ${server.url}")
val cause = ex.cause
if (cause is java.net.ConnectException) {
ServerConnectionStatus.Error("${cause.localizedMessage}")
} else {
ServerConnectionStatus.Error(ex.localizedMessage)
}
} catch (ex: Exception) {
Timber.w(ex, "Error checking server ${server.url}")
ServerConnectionStatus.Error(ex.localizedMessage)
@ -155,12 +164,15 @@ class SwitchServerViewModel
serverRepository.addAndChangeServer(updatedServer)
navigationManager.navigateTo(SetupDestination.UserList(updatedServer))
} else if (result is ServerConnectionStatus.Error) {
showToast(context, "Error connecting: $${result.message}")
showToast(context, "Error connecting: ${result.message}")
}
}
}
fun addServer(inputUrl: String) {
fun addServer(
inputUrl: String,
showToast: Boolean,
) {
_state.update { it.copy(addServerState = LoadingState.Loading) }
viewModelScope.launchIO {
try {
@ -186,7 +198,9 @@ class SwitchServerViewModel
_state.update { it.copy(addServerState = LoadingState.Success) }
navigationManager.navigateTo(SetupDestination.UserList(server))
} else {
_state.update { it.copy(addServerState = LoadingState.Error("Server returned invalid response")) }
val msg = "Server returned invalid response"
_state.update { it.copy(addServerState = LoadingState.Error(msg)) }
if (showToast) showToast(context, msg)
}
} else {
Timber.w("Error connecting with %s: %s", inputUrl, scores)
@ -215,10 +229,12 @@ class SwitchServerViewModel
}
val message = "Error, tried addresses:\n$errors"
_state.update { it.copy(addServerState = LoadingState.Error(message)) }
if (showToast) showToast(context, message)
}
} catch (ex: Exception) {
Timber.w(ex, "Error creating API for $inputUrl")
_state.update { it.copy(addServerState = LoadingState.Error(exception = ex)) }
if (showToast) showToast(context, "Error: ${ex.localizedMessage}")
}
}
}
@ -247,6 +263,9 @@ class SwitchServerViewModel
.toMutableList()
.apply {
add(jellyfinServer)
}.distinctBy {
// Filter out duplicates within the discovered servers list (same URL appearing multiple times)
it.url.lowercase().trim()
},
)
}