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
This commit is contained in:
damontecres 2025-10-30 13:41:46 -04:00 committed by GitHub
parent b1275ae210
commit 3f7c2ac730
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 128 additions and 53 deletions

View file

@ -227,7 +227,7 @@ dependencies {
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)
implementation(libs.timber) implementation(libs.timber)
implementation(libs.slf4j.simple) implementation(libs.slf4j2.timber)
implementation(libs.aboutlibraries.core) implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3) implementation(libs.aboutlibraries.compose.m3)
implementation(libs.multiplatform.markdown.renderer) implementation(libs.multiplatform.markdown.renderer)

View file

@ -150,8 +150,8 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
LaunchedEffect(server) { LaunchedEffect(server, user) {
serverEventListener.init() serverEventListener.init(server, user)
} }
ApplicationContent( ApplicationContent(
user = user, user = user,

View file

@ -66,10 +66,10 @@ class DebugViewModel
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
itemPlaybacks.value = results itemPlaybacks.value = results
} }
val logcat = getLogCatLines() }
withContext(Dispatchers.Main) { val logcat = getLogCatLines()
this@DebugViewModel.logcat.value = logcat withContext(Dispatchers.Main) {
} this@DebugViewModel.logcat.value = logcat
} }
} }
} }
@ -95,7 +95,7 @@ class DebugViewModel
while (count < lineCount) { while (count < lineCount) {
val line = reader.readLine() val line = reader.readLine()
if (line != null) { if (line != null) {
val level = line.split(" ").getOrNull(4) val level = line.split(Regex("\\s+")).getOrNull(4)
val logLevel = val logLevel =
when (level?.uppercase()) { when (level?.uppercase()) {
"V" -> Log.VERBOSE "V" -> Log.VERBOSE

View file

@ -234,7 +234,9 @@ class SeriesViewModel
}.coerceAtLeast(0) }.coerceAtLeast(0)
} else { } else {
// Force the first page to to be fetched // Force the first page to to be fetched
pager.getBlocking(0) if (pager.isNotEmpty()) {
pager.getBlocking(0)
}
0 0
} }
Timber.v("Loaded ${pager.size} episodes for season $seasonId, initialIndex=$initialIndex") Timber.v("Loaded ${pager.size} episodes for season $seasonId, initialIndex=$initialIndex")

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.main package com.github.damontecres.wholphin.ui.main
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow 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.LoadingState
import com.github.damontecres.wholphin.util.formatDateTime import com.github.damontecres.wholphin.util.formatDateTime
import com.github.damontecres.wholphin.util.seasonEpisode import com.github.damontecres.wholphin.util.seasonEpisode
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration import kotlin.time.Duration
@ -78,6 +82,7 @@ fun HomePage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel(), viewModel: HomeViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
var firstLoad by rememberSaveable { mutableStateOf(true) } var firstLoad by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(preferences).join() viewModel.init(preferences).join()
@ -85,6 +90,20 @@ fun HomePage(
} }
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
val homeRows by viewModel.homeRows.observeAsState(listOf()) 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) { when (val state = if (firstLoad) loading else LoadingState.Success) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)
@ -148,6 +167,7 @@ fun HomePageContent(
onFocusPosition: ((RowColumn) -> Unit)? = null, onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null, loadingState: LoadingState? = null,
) { ) {
val scope = rememberCoroutineScope()
var position by rememberSaveable(stateSaver = RowColumnSaver) { var position by rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(RowColumn(0, 0)) mutableStateOf(RowColumn(0, 0))
} }
@ -158,6 +178,9 @@ fun HomePageContent(
val positionFocusRequester = remember { FocusRequester() } val positionFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
positionFocusRequester.tryRequestFocus() positionFocusRequester.tryRequestFocus()
// Hacky, but mostly works
delay(50)
listState.animateScrollToItem(position.row)
} }
LaunchedEffect(position) { LaunchedEffect(position) {
listState.animateScrollToItem(position.row) listState.animateScrollToItem(position.row)

View file

@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth 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.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@ -638,7 +640,7 @@ private fun BottomDialog(
.padding(8.dp) .padding(8.dp)
.background(Color.DarkGray, shape = RoundedCornerShape(16.dp)), .background(Color.DarkGray, shape = RoundedCornerShape(16.dp)),
) { ) {
Column( LazyColumn(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -647,7 +649,7 @@ private fun BottomDialog(
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
choices.forEachIndexed { index, choice -> itemsIndexed(choices) { index, choice ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value val focused = interactionSource.collectIsFocusedAsState().value
val color = val color =

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -61,6 +62,7 @@ fun SwitchUserContent(
var showAddUser by remember { mutableStateOf(false) } var showAddUser by remember { mutableStateOf(false) }
val userState by viewModel.switchUserState.observeAsState(LoadingState.Pending) val userState by viewModel.switchUserState.observeAsState(LoadingState.Pending)
val loginAttempts by viewModel.loginAttempts.observeAsState(0)
LaunchedEffect(userState) { LaunchedEffect(userState) {
if (!showAddUser) { if (!showAddUser) {
when (val s = userState) { when (val s = userState) {
@ -129,6 +131,7 @@ fun SwitchUserContent(
var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) } var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.clearSwitchUserState() viewModel.clearSwitchUserState()
viewModel.resetAttempts()
if (useQuickConnect) { if (useQuickConnect) {
viewModel.initiateQuickConnect(server) viewModel.initiateQuickConnect(server)
} }
@ -138,6 +141,10 @@ fun SwitchUserContent(
viewModel.cancelQuickConnect() viewModel.cancelQuickConnect()
showAddUser = false showAddUser = false
}, },
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@ -145,7 +152,7 @@ fun SwitchUserContent(
Modifier Modifier
.focusGroup() .focusGroup()
.padding(16.dp) .padding(16.dp)
.fillMaxWidth(), .fillMaxWidth(.66f),
) { ) {
if (useQuickConnect) { if (useQuickConnect) {
if (quickConnect == null && userState !is LoadingState.Error) { if (quickConnect == null && userState !is LoadingState.Error) {
@ -206,7 +213,7 @@ fun SwitchUserContent(
UserStateError(userState) UserStateError(userState)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusGroup(), modifier = Modifier.align(Alignment.CenterHorizontally),
) { ) {
Text( Text(
text = "Username", text = "Username",
@ -231,7 +238,7 @@ fun SwitchUserContent(
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier, modifier = Modifier.align(Alignment.CenterHorizontally),
) { ) {
Text( Text(
text = "Password", text = "Password",
@ -265,6 +272,20 @@ fun SwitchUserContent(
Text("Login") 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, color = MaterialTheme.colorScheme.error,
) )
} }
s.exception?.localizedMessage?.let { if (s.exception != null) {
Text( s.exception.localizedMessage?.let {
text = it, Text(
color = MaterialTheme.colorScheme.error, text = it,
) color = MaterialTheme.colorScheme.error,
)
}
s.exception.cause?.localizedMessage?.let {
Text(
text = "Cause: $it",
color = MaterialTheme.colorScheme.error,
)
}
} }
} }
} }

View file

@ -55,6 +55,8 @@ class SwitchUserViewModel
val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending) val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending)
val switchUserState = MutableLiveData<LoadingState>(LoadingState.Pending) val switchUserState = MutableLiveData<LoadingState>(LoadingState.Pending)
val loginAttempts = MutableLiveData(0)
fun clearAddServerState() { fun clearAddServerState() {
addServerState.value = LoadingState.Pending addServerState.value = LoadingState.Pending
} }
@ -63,6 +65,10 @@ class SwitchUserViewModel
switchUserState.value = LoadingState.Pending switchUserState.value = LoadingState.Pending
} }
fun resetAttempts() {
loginAttempts.value = 0
}
init { init {
init() init()
} }
@ -338,6 +344,7 @@ class SwitchUserViewModel
msg: String? = null, msg: String? = null,
ex: Exception? = null, ex: Exception? = null,
) = withContext(Dispatchers.Main) { ) = withContext(Dispatchers.Main) {
loginAttempts.value = (loginAttempts.value ?: 0) + 1
switchUserState.value = LoadingState.Error(msg, ex) switchUserState.value = LoadingState.Error(msg, ex)
} }
} }

View file

@ -15,5 +15,8 @@ sealed interface LoadingState {
val exception: Throwable? = null, val exception: Throwable? = null,
) : LoadingState { ) : LoadingState {
constructor(exception: Throwable) : this(null, exception) constructor(exception: Throwable) : this(null, exception)
val localizedMessage: String =
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
} }
} }

View file

@ -2,11 +2,14 @@ package com.github.damontecres.wholphin.util
import android.content.Context import android.content.Context
import android.widget.Toast 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.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -27,43 +30,49 @@ class ServerEventListener
private val api: ApiClient, private val api: ApiClient,
@param:IoCoroutineScope private val scope: CoroutineScope, @param:IoCoroutineScope private val scope: CoroutineScope,
) { ) {
init { private var listenJob: Job? = null
setupListeners()
}
fun init() { fun init(
scope.launchIO { server: JellyfinServer?,
api.sessionApi.postCapabilities( user: JellyfinUser?,
playableMediaTypes = listOf(MediaType.VIDEO), ) {
supportedCommands = if (server != null && user != null && api.baseUrl != null && api.accessToken != null) {
listOf( scope.launchIO {
GeneralCommandType.DISPLAY_MESSAGE, api.sessionApi.postCapabilities(
GeneralCommandType.SEND_STRING, playableMediaTypes = listOf(MediaType.VIDEO),
), supportedCommands =
supportsMediaControl = true, listOf(
) GeneralCommandType.DISPLAY_MESSAGE,
GeneralCommandType.SEND_STRING,
),
supportsMediaControl = true,
)
setupListeners()
}
} }
} }
fun setupListeners() { fun setupListeners() {
Timber.v("Subscribing to WebSocket") Timber.v("Subscribing to WebSocket")
api.webSocket listenJob?.cancel()
.subscribe<GeneralCommandMessage>() listenJob =
.onEach { message -> api.webSocket
if (message.data?.name in .subscribe<GeneralCommandMessage>()
setOf( .onEach { message ->
GeneralCommandType.DISPLAY_MESSAGE, if (message.data?.name in
GeneralCommandType.SEND_STRING, setOf(
) GeneralCommandType.DISPLAY_MESSAGE,
) { GeneralCommandType.SEND_STRING,
val header = message.data?.arguments["Header"] )
val text = ) {
message.data?.arguments["Text"] ?: message.data?.arguments["String"] val header = message.data?.arguments["Header"]
val toast = val text =
listOfNotNull(header, text) message.data?.arguments["Text"] ?: message.data?.arguments["String"]
.joinToString("\n") val toast =
showToast(context, toast, Toast.LENGTH_LONG) listOfNotNull(header, text)
} .joinToString("\n")
}.launchIn(scope) showToast(context, toast, Toast.LENGTH_LONG)
}
}.launchIn(scope)
} }
} }

View file

@ -14,7 +14,7 @@ composeBom = "2025.10.01"
compose-runtime = "1.9.4" compose-runtime = "1.9.4"
multiplatformMarkdownRenderer = "0.37.0" multiplatformMarkdownRenderer = "0.37.0"
programguide = "1.6.0" programguide = "1.6.0"
slf4j = "2.0.17" slf4j2Timber = "1.2"
timber = "5.0.1" timber = "5.0.1"
tvFoundation = "1.0.0-alpha12" tvFoundation = "1.0.0-alpha12"
tvMaterial = "1.0.1" 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-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", 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" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }