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

@ -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()
},
)
}