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)
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)

View file

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

View file

@ -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

View file

@ -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")

View file

@ -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)

View file

@ -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 =

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.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,
)
}
}
}
}

View file

@ -55,6 +55,8 @@ class SwitchUserViewModel
val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending)
val switchUserState = MutableLiveData<LoadingState>(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)
}
}

View file

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

View file

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