From 3f7c2ac7306cdb879954ce7920dabe08ef1b3eac Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:41:46 -0400 Subject: [PATCH] UI Updates & Fixes (#114) Fixes #113 - Ensures all audio and subtitle options are shown during playback - Fix home rows not being at top when going back - Show error message when background home page refresh fails Also shows more details for login failures and adds a link to the debug page (with log info) after multiple login failure attempts. Hopefully this can help diagnose #112 and #18 --- app/build.gradle.kts | 2 +- .../damontecres/wholphin/MainActivity.kt | 4 +- .../wholphin/ui/detail/DebugPage.kt | 10 +-- .../ui/detail/series/SeriesViewModel.kt | 4 +- .../damontecres/wholphin/ui/main/HomePage.kt | 23 ++++++ .../wholphin/ui/playback/PlaybackControls.kt | 6 +- .../wholphin/ui/setup/SwitchUserContent.kt | 45 ++++++++++-- .../wholphin/ui/setup/SwitchUserViewModel.kt | 7 ++ .../damontecres/wholphin/util/LoadingState.kt | 3 + .../wholphin/util/ServerEventListener.kt | 73 +++++++++++-------- gradle/libs.versions.toml | 4 +- 11 files changed, 128 insertions(+), 53 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1fbc2201..eca3067e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -227,7 +227,7 @@ dependencies { ksp(libs.hilt.android.compiler) implementation(libs.timber) - implementation(libs.slf4j.simple) + implementation(libs.slf4j2.timber) implementation(libs.aboutlibraries.core) implementation(libs.aboutlibraries.compose.m3) implementation(libs.multiplatform.markdown.renderer) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 46f49ae2..596168f8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -150,8 +150,8 @@ class MainActivity : AppCompatActivity() { } } } - LaunchedEffect(server) { - serverEventListener.init() + LaunchedEffect(server, user) { + serverEventListener.init(server, user) } ApplicationContent( user = user, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 17e37668..c5a2d03f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -66,10 +66,10 @@ class DebugViewModel withContext(Dispatchers.Main) { itemPlaybacks.value = results } - val logcat = getLogCatLines() - withContext(Dispatchers.Main) { - this@DebugViewModel.logcat.value = logcat - } + } + val logcat = getLogCatLines() + withContext(Dispatchers.Main) { + this@DebugViewModel.logcat.value = logcat } } } @@ -95,7 +95,7 @@ class DebugViewModel while (count < lineCount) { val line = reader.readLine() if (line != null) { - val level = line.split(" ").getOrNull(4) + val level = line.split(Regex("\\s+")).getOrNull(4) val logLevel = when (level?.uppercase()) { "V" -> Log.VERBOSE diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 28fb29b7..606ce71f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -234,7 +234,9 @@ class SeriesViewModel }.coerceAtLeast(0) } else { // Force the first page to to be fetched - pager.getBlocking(0) + if (pager.isNotEmpty()) { + pager.getBlocking(0) + } 0 } Timber.v("Loaded ${pager.size} episodes for season $seasonId, initialIndex=$initialIndex") diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 4a62b420..2e116b5d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.main +import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,6 +21,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState 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 @@ -32,6 +34,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -62,6 +65,7 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.formatDateTime import com.github.damontecres.wholphin.util.seasonEpisode +import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration @@ -78,6 +82,7 @@ fun HomePage( modifier: Modifier = Modifier, viewModel: HomeViewModel = hiltViewModel(), ) { + val context = LocalContext.current var firstLoad by rememberSaveable { mutableStateOf(true) } LaunchedEffect(Unit) { viewModel.init(preferences).join() @@ -85,6 +90,20 @@ fun HomePage( } val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) val homeRows by viewModel.homeRows.observeAsState(listOf()) + LaunchedEffect(loading) { + val state = loading + if (!firstLoad && state is LoadingState.Error) { + // After the first load, refreshes occur in the background and an ErrorMessage won't show + // So send a Toast on errors instead + Toast + .makeText( + context, + "Home refresh error: ${state.localizedMessage}", + Toast.LENGTH_LONG, + ).show() + } + } + when (val state = if (firstLoad) loading else LoadingState.Success) { is LoadingState.Error -> ErrorMessage(state) @@ -148,6 +167,7 @@ fun HomePageContent( onFocusPosition: ((RowColumn) -> Unit)? = null, loadingState: LoadingState? = null, ) { + val scope = rememberCoroutineScope() var position by rememberSaveable(stateSaver = RowColumnSaver) { mutableStateOf(RowColumn(0, 0)) } @@ -158,6 +178,9 @@ fun HomePageContent( val positionFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { positionFocusRequester.tryRequestFocus() + // Hacky, but mostly works + delay(50) + listState.animateScrollToItem(position.row) } LaunchedEffect(position) { listState.animateScrollToItem(position.row) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index 1e46cef6..c20af123 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt @@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.CircleShape @@ -638,7 +640,7 @@ private fun BottomDialog( .padding(8.dp) .background(Color.DarkGray, shape = RoundedCornerShape(16.dp)), ) { - Column( + LazyColumn( modifier = Modifier .fillMaxWidth() @@ -647,7 +649,7 @@ private fun BottomDialog( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - choices.forEachIndexed { index, choice -> + itemsIndexed(choices) { index, choice -> val interactionSource = remember { MutableInteractionSource() } val focused = interactionSource.collectIsFocusedAsState().value val color = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index 56bc5b26..b8d3c9d3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme @@ -61,6 +62,7 @@ fun SwitchUserContent( var showAddUser by remember { mutableStateOf(false) } val userState by viewModel.switchUserState.observeAsState(LoadingState.Pending) + val loginAttempts by viewModel.loginAttempts.observeAsState(0) LaunchedEffect(userState) { if (!showAddUser) { when (val s = userState) { @@ -129,6 +131,7 @@ fun SwitchUserContent( var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) } LaunchedEffect(Unit) { viewModel.clearSwitchUserState() + viewModel.resetAttempts() if (useQuickConnect) { viewModel.initiateQuickConnect(server) } @@ -138,6 +141,10 @@ fun SwitchUserContent( viewModel.cancelQuickConnect() showAddUser = false }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -145,7 +152,7 @@ fun SwitchUserContent( Modifier .focusGroup() .padding(16.dp) - .fillMaxWidth(), + .fillMaxWidth(.66f), ) { if (useQuickConnect) { if (quickConnect == null && userState !is LoadingState.Error) { @@ -206,7 +213,7 @@ fun SwitchUserContent( UserStateError(userState) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.focusGroup(), + modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( text = "Username", @@ -231,7 +238,7 @@ fun SwitchUserContent( } Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier, + modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( text = "Password", @@ -265,6 +272,20 @@ fun SwitchUserContent( Text("Login") } } + if (loginAttempts > 2) { + Text( + text = "Trouble logging in?", + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Button( + onClick = { + viewModel.navigationManager.navigateTo(Destination.Debug) + }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text("Show debug info") + } + } } } } @@ -288,11 +309,19 @@ private fun UserStateError( color = MaterialTheme.colorScheme.error, ) } - s.exception?.localizedMessage?.let { - Text( - text = it, - color = MaterialTheme.colorScheme.error, - ) + if (s.exception != null) { + s.exception.localizedMessage?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + ) + } + s.exception.cause?.localizedMessage?.let { + Text( + text = "Cause: $it", + color = MaterialTheme.colorScheme.error, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index bc69a50c..3fd142e9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -55,6 +55,8 @@ class SwitchUserViewModel val addServerState = MutableLiveData(LoadingState.Pending) val switchUserState = MutableLiveData(LoadingState.Pending) + val loginAttempts = MutableLiveData(0) + fun clearAddServerState() { addServerState.value = LoadingState.Pending } @@ -63,6 +65,10 @@ class SwitchUserViewModel switchUserState.value = LoadingState.Pending } + fun resetAttempts() { + loginAttempts.value = 0 + } + init { init() } @@ -338,6 +344,7 @@ class SwitchUserViewModel msg: String? = null, ex: Exception? = null, ) = withContext(Dispatchers.Main) { + loginAttempts.value = (loginAttempts.value ?: 0) + 1 switchUserState.value = LoadingState.Error(msg, ex) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 8d863ae8..77be5ffc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -15,5 +15,8 @@ sealed interface LoadingState { val exception: Throwable? = null, ) : LoadingState { constructor(exception: Throwable) : this(null, exception) + + val localizedMessage: String = + listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ServerEventListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ServerEventListener.kt index 0fef2112..ae36a9b3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ServerEventListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ServerEventListener.kt @@ -2,11 +2,14 @@ package com.github.damontecres.wholphin.util import android.content.Context import android.widget.Toast +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.hilt.IoCoroutineScope import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.jellyfin.sdk.api.client.ApiClient @@ -27,43 +30,49 @@ class ServerEventListener private val api: ApiClient, @param:IoCoroutineScope private val scope: CoroutineScope, ) { - init { - setupListeners() - } + private var listenJob: Job? = null - fun init() { - scope.launchIO { - api.sessionApi.postCapabilities( - playableMediaTypes = listOf(MediaType.VIDEO), - supportedCommands = - listOf( - GeneralCommandType.DISPLAY_MESSAGE, - GeneralCommandType.SEND_STRING, - ), - supportsMediaControl = true, - ) + fun init( + server: JellyfinServer?, + user: JellyfinUser?, + ) { + if (server != null && user != null && api.baseUrl != null && api.accessToken != null) { + scope.launchIO { + api.sessionApi.postCapabilities( + playableMediaTypes = listOf(MediaType.VIDEO), + supportedCommands = + listOf( + GeneralCommandType.DISPLAY_MESSAGE, + GeneralCommandType.SEND_STRING, + ), + supportsMediaControl = true, + ) + setupListeners() + } } } fun setupListeners() { Timber.v("Subscribing to WebSocket") - api.webSocket - .subscribe() - .onEach { message -> - if (message.data?.name in - setOf( - GeneralCommandType.DISPLAY_MESSAGE, - GeneralCommandType.SEND_STRING, - ) - ) { - val header = message.data?.arguments["Header"] - val text = - message.data?.arguments["Text"] ?: message.data?.arguments["String"] - val toast = - listOfNotNull(header, text) - .joinToString("\n") - showToast(context, toast, Toast.LENGTH_LONG) - } - }.launchIn(scope) + listenJob?.cancel() + listenJob = + api.webSocket + .subscribe() + .onEach { message -> + if (message.data?.name in + setOf( + GeneralCommandType.DISPLAY_MESSAGE, + GeneralCommandType.SEND_STRING, + ) + ) { + val header = message.data?.arguments["Header"] + val text = + message.data?.arguments["Text"] ?: message.data?.arguments["String"] + val toast = + listOfNotNull(header, text) + .joinToString("\n") + showToast(context, toast, Toast.LENGTH_LONG) + } + }.launchIn(scope) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 15841264..0c00502d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ composeBom = "2025.10.01" compose-runtime = "1.9.4" multiplatformMarkdownRenderer = "0.37.0" programguide = "1.6.0" -slf4j = "2.0.17" +slf4j2Timber = "1.2" timber = "5.0.1" tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" @@ -98,7 +98,7 @@ androidx-room-common-jvm = { group = "androidx.room", name = "room-common-jvm", androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } -slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } +slf4j2-timber = { module = "uk.kulikov:slf4j2-timber", version.ref = "slf4j2Timber" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }