feat: redesign login screen on both app and app-tv

This commit is contained in:
2026-05-13 21:59:23 +02:00
parent e69a3a6d4a
commit d3b66d4fc5
12 changed files with 943 additions and 177 deletions

View File

@@ -7,7 +7,7 @@ import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.core.feature.browse.home.AppViewModel
import hu.bbara.purefin.ui.screen.movie.TvMovieScreen
import hu.bbara.purefin.ui.screen.series.TvSeriesScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.screen.login.TvLoginScreen
import hu.bbara.purefin.ui.screen.TvAppScreen
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
import hu.bbara.purefin.ui.screen.player.TvPlayerScreen
@@ -21,7 +21,7 @@ fun EntryProviderScope<Route>.tvHomeSection() {
fun EntryProviderScope<Route>.tvLoginSection() {
entry<Route.LoginRoute> {
LoginScreen()
TvLoginScreen()
}
}

View File

@@ -52,7 +52,7 @@ import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.screen.login.TvLoginScreen
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.theme.backgroundDark
@@ -229,7 +229,7 @@ class TvActivity : ComponentActivity() {
)
}
} else {
LoginScreen()
TvLoginScreen()
}
availableUpdate?.let { update ->

View File

@@ -1,68 +0,0 @@
package hu.bbara.purefin.ui.screen.login
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.core.feature.login.LoginViewModel
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val serverUrl by viewModel.url.collectAsState()
val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
var isLoggingIn by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val state = remember(serverUrl, username, password, errorMessage) {
LoginContentState(
serverUrl = serverUrl,
username = username,
password = password,
errorMessage = errorMessage
)
}
val callbacks = remember(viewModel, coroutineScope) {
LoginContentCallbacks(
onServerUrlChange = {
viewModel.clearError()
viewModel.setUrl(it)
},
onUsernameChange = {
viewModel.clearError()
viewModel.setUsername(it)
},
onPasswordChange = {
viewModel.clearError()
viewModel.setPassword(it)
},
onConnect = {
coroutineScope.launch {
isLoggingIn = true
try {
viewModel.login()
} finally {
isLoggingIn = false
}
}
}
)
}
TvLoginContent(
state = state,
callbacks = callbacks,
isLoggingIn = isLoggingIn,
modifier = modifier
)
}

View File

@@ -4,12 +4,15 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -42,6 +45,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -56,10 +60,9 @@ import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
fun TvLoginContent(
state: LoginContentState,
callbacks: LoginContentCallbacks,
isLoggingIn: Boolean,
modifier: Modifier = Modifier
) {
if (isLoggingIn) {
if (state.isLoggingIn) {
PurefinWaitingScreen(modifier = modifier)
return
}
@@ -120,12 +123,21 @@ private fun TvLoginForm(
) {
val scheme = MaterialTheme.colorScheme
val serverFocusRequester = remember { FocusRequester() }
val findFocusRequester = remember { FocusRequester() }
val changeServerFocusRequester = remember { FocusRequester() }
val quickConnectFocusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
val connectFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
serverFocusRequester.requestFocus()
LaunchedEffect(state.phase) {
if (state.phase == LoginContentPhase.ServerSearch) {
serverFocusRequester.requestFocus()
} else if (state.quickConnectAvailable) {
quickConnectFocusRequester.requestFocus()
} else {
usernameFocusRequester.requestFocus()
}
}
Box(
@@ -137,7 +149,7 @@ private fun TvLoginForm(
) {
Column(
modifier = Modifier
.widthIn(max = 480.dp)
.widthIn(max = if (state.phase == LoginContentPhase.Login) 1040.dp else 480.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -153,7 +165,11 @@ private fun TvLoginForm(
modifier = Modifier.padding(top = 8.dp)
)
Text(
text = "Connect to your media server",
text = if (state.phase == LoginContentPhase.ServerSearch) {
"Find your Jellyfin server"
} else {
"Connect to your media server"
},
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge
)
@@ -178,21 +194,223 @@ private fun TvLoginForm(
Spacer(modifier = Modifier.height(10.dp))
}
TvLoginTextField(
label = "Server URL",
value = state.serverUrl,
onValueChange = callbacks.onServerUrlChange,
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage,
modifier = Modifier
.focusRequester(serverFocusRequester)
.focusProperties {
down = usernameFocusRequester
}
when (state.phase) {
LoginContentPhase.ServerSearch -> TvServerSearchFields(
state = state,
callbacks = callbacks,
serverFocusRequester = serverFocusRequester,
findFocusRequester = findFocusRequester
)
LoginContentPhase.Login -> TvLoginFields(
state = state,
callbacks = callbacks,
changeServerFocusRequester = changeServerFocusRequester,
quickConnectFocusRequester = quickConnectFocusRequester,
usernameFocusRequester = usernameFocusRequester,
passwordFocusRequester = passwordFocusRequester,
connectFocusRequester = connectFocusRequester
)
}
}
}
}
@Composable
private fun TvServerSearchFields(
state: LoginContentState,
callbacks: LoginContentCallbacks,
serverFocusRequester: FocusRequester,
findFocusRequester: FocusRequester
) {
val scheme = MaterialTheme.colorScheme
TvLoginTextField(
label = "Server URL",
value = state.serverUrl,
onValueChange = callbacks.onServerUrlChange,
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage,
modifier = Modifier
.focusRequester(serverFocusRequester)
.focusProperties {
down = findFocusRequester
}
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = callbacks.onFindServer,
enabled = !state.isSearching,
modifier = Modifier
.focusRequester(findFocusRequester)
.focusProperties {
up = serverFocusRequester
}
.fillMaxWidth()
.height(48.dp)
) {
TvText(
text = if (state.isSearching) "Searching..." else "Find server",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
if (state.discoveredServers.isNotEmpty()) {
Spacer(modifier = Modifier.height(14.dp))
Text(
text = "Nearby servers",
color = scheme.onBackground,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
state.discoveredServers.take(3).forEach { server ->
Button(
onClick = { callbacks.onDiscoveredServerClick(server) },
modifier = Modifier.fillMaxWidth().height(54.dp)
) {
TvText(
text = server.name ?: server.address,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 15.sp
)
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
private fun TvLoginFields(
state: LoginContentState,
callbacks: LoginContentCallbacks,
changeServerFocusRequester: FocusRequester,
quickConnectFocusRequester: FocusRequester,
usernameFocusRequester: FocusRequester,
passwordFocusRequester: FocusRequester,
connectFocusRequester: FocusRequester
) {
val scheme = MaterialTheme.colorScheme
val selectedServer = state.selectedServerName ?: state.selectedServerUrl.orEmpty()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Selected server",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = selectedServer,
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Button(
onClick = callbacks.onChangeServer,
modifier = Modifier
.focusRequester(changeServerFocusRequester)
.focusProperties {
down = if (state.quickConnectAvailable) quickConnectFocusRequester else usernameFocusRequester
}
.width(176.dp)
.height(44.dp)
) {
TvText(text = "Change server", fontSize = 15.sp)
}
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(18.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(20.dp)
) {
TvLoginOptionPanel(
title = "Quick Connect",
modifier = Modifier.weight(1f)
) {
if (state.quickConnectAvailable) {
val quickConnectCode = state.quickConnectCode
if (quickConnectCode != null) {
Text(
text = quickConnectCode,
color = scheme.primary,
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Approve this code in another Jellyfin client.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
} else {
Text(
text = "Use another Jellyfin client to approve this TV without entering your password.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
}
Button(
onClick = if (state.quickConnectCode == null) {
callbacks.onQuickConnect
} else {
callbacks.onCancelQuickConnect
},
enabled = state.quickConnectCode != null || !state.isQuickConnecting,
modifier = Modifier
.focusRequester(quickConnectFocusRequester)
.focusProperties {
up = changeServerFocusRequester
right = usernameFocusRequester
down = usernameFocusRequester
}
.fillMaxWidth()
.height(48.dp)
) {
TvText(
text = if (state.quickConnectCode == null) {
"Quick Connect"
} else {
"Cancel Quick Connect"
},
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
} else {
Text(
text = "Quick Connect is not enabled on this server.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
TvLoginOptionPanel(
title = "Manual login",
modifier = Modifier.weight(1f)
) {
TvLoginTextField(
label = "Username",
value = state.username,
@@ -202,7 +420,12 @@ private fun TvLoginForm(
modifier = Modifier
.focusRequester(usernameFocusRequester)
.focusProperties {
up = serverFocusRequester
up = changeServerFocusRequester
left = if (state.quickConnectAvailable) {
quickConnectFocusRequester
} else {
FocusRequester.Default
}
down = passwordFocusRequester
}
)
@@ -223,10 +446,11 @@ private fun TvLoginForm(
}
)
Spacer(modifier = Modifier.height(22.dp))
Spacer(modifier = Modifier.height(18.dp))
Button(
onClick = callbacks.onConnect,
enabled = !state.isQuickConnecting,
modifier = Modifier
.focusRequester(connectFocusRequester)
.focusProperties {
@@ -245,6 +469,37 @@ private fun TvLoginForm(
}
}
@Composable
private fun TvLoginOptionPanel(
title: String,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(18.dp)
Column(
modifier = modifier
.clip(shape)
.background(scheme.surface)
.border(
width = 1.dp,
color = scheme.outlineVariant.copy(alpha = 0.45f),
shape = shape
)
.padding(22.dp)
) {
Text(
text = title,
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(18.dp))
content()
}
}
@Composable
private fun TvLoginTextField(
label: String,

View File

@@ -0,0 +1,98 @@
package hu.bbara.purefin.ui.screen.login
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.feature.login.LoginViewModel
import kotlinx.coroutines.launch
@Composable
fun TvLoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val serverUrl by viewModel.url.collectAsState()
val phase by viewModel.phase.collectAsState()
val selectedServer by viewModel.selectedServer.collectAsState()
val discoveredServers by viewModel.discoveredServers.collectAsState()
val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState()
val isSearching by viewModel.isSearching.collectAsState()
val isLoggingIn by viewModel.isLoggingIn.collectAsState()
val quickConnectAvailable by viewModel.quickConnectAvailable.collectAsState()
val quickConnectCode by viewModel.quickConnectCode.collectAsState()
val isQuickConnecting by viewModel.isQuickConnecting.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
val coroutineScope = rememberCoroutineScope()
val state = remember(
phase,
serverUrl,
selectedServer,
discoveredServers,
username,
password,
isSearching,
isLoggingIn,
quickConnectAvailable,
quickConnectCode,
isQuickConnecting,
errorMessage
) {
LoginContentState(
phase = if (phase == LoginViewModel.Phase.LOGIN) {
LoginContentPhase.Login
} else {
LoginContentPhase.ServerSearch
},
serverUrl = serverUrl,
selectedServerName = selectedServer?.name,
selectedServerUrl = selectedServer?.address,
discoveredServers = discoveredServers.map {
LoginServerCandidateUi(name = it.name, address = it.address)
},
username = username,
password = password,
isSearching = isSearching,
isLoggingIn = isLoggingIn,
quickConnectAvailable = quickConnectAvailable,
quickConnectCode = quickConnectCode,
isQuickConnecting = isQuickConnecting,
errorMessage = errorMessage
)
}
val callbacks = remember(viewModel, coroutineScope) {
LoginContentCallbacks(
onServerUrlChange = viewModel::setUrl,
onFindServer = viewModel::findServer,
onDiscoveredServerClick = {
viewModel.selectDiscoveredServer(JellyfinServerCandidate(name = it.name, address = it.address))
},
onChangeServer = viewModel::changeServer,
onUsernameChange = {
viewModel.setUsername(it)
},
onPasswordChange = {
viewModel.setPassword(it)
},
onConnect = {
coroutineScope.launch {
viewModel.login()
}
},
onQuickConnect = viewModel::startQuickConnect,
onCancelQuickConnect = viewModel::cancelQuickConnect
)
}
TvLoginContent(
state = state,
callbacks = callbacks,
modifier = modifier
)
}