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) {
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,6 +194,36 @@ private fun TvLoginForm(
Spacer(modifier = Modifier.height(10.dp))
}
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,
@@ -187,12 +233,184 @@ private fun TvLoginForm(
modifier = Modifier
.focusRequester(serverFocusRequester)
.focusProperties {
down = usernameFocusRequester
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(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
)
}

View File

@@ -18,10 +18,12 @@ import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.button.PurefinTextButton
import hu.bbara.purefin.ui.common.image.PurefinLogo
@@ -33,12 +35,11 @@ import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
fun LoginContent(
state: LoginContentState,
callbacks: LoginContentCallbacks,
isLoggingIn: Boolean,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
if (isLoggingIn) {
if (state.isLoggingIn) {
PurefinWaitingScreen(modifier = modifier)
return
}
@@ -76,21 +77,6 @@ fun LoginContent(
Spacer(modifier = Modifier.height(28.dp))
Text(
text = "Connect to server",
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.align(Alignment.Start)
)
Text(
text = "Enter your server and account details.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.align(Alignment.Start)
.padding(top = 2.dp, bottom = 16.dp)
)
state.errorMessage?.let { errorMessage ->
Text(
text = errorMessage,
@@ -107,6 +93,34 @@ fun LoginContent(
Spacer(modifier = Modifier.height(12.dp))
}
when (state.phase) {
LoginContentPhase.ServerSearch -> ServerSearchContent(state, callbacks)
LoginContentPhase.Login -> LoginPhaseContent(state, callbacks)
}
}
}
}
@Composable
private fun ServerSearchContent(
state: LoginContentState,
callbacks: LoginContentCallbacks
) {
val scheme = MaterialTheme.colorScheme
Text(
text = "Find your server",
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Search by address or choose a nearby Jellyfin server.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 2.dp, bottom = 16.dp).fillMaxWidth()
)
PurefinComplexTextField(
label = "Server URL",
value = state.serverUrl,
@@ -115,6 +129,113 @@ fun LoginContent(
leadingIcon = Icons.Default.Storage
)
Spacer(modifier = Modifier.height(16.dp))
PurefinTextButton(
content = { Text(if (state.isSearching) "Searching..." else "Find server") },
onClick = callbacks.onFindServer,
enabled = !state.isSearching,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
)
if (state.discoveredServers.isNotEmpty()) {
Spacer(modifier = Modifier.height(20.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.forEach { server ->
TextButton(
onClick = { callbacks.onDiscoveredServerClick(server) },
modifier = Modifier.fillMaxWidth()
) {
Text(
text = server.name?.let { "$it\n${server.address}" } ?: server.address,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
@Composable
private fun LoginPhaseContent(
state: LoginContentState,
callbacks: LoginContentCallbacks
) {
val scheme = MaterialTheme.colorScheme
val selectedServer = state.selectedServerName ?: state.selectedServerUrl.orEmpty()
Text(
text = "Log in",
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.fillMaxWidth()
)
Text(
text = selectedServer,
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth()
)
TextButton(
onClick = callbacks.onChangeServer,
modifier = Modifier.fillMaxWidth()
) {
Text("Change server")
}
if (state.quickConnectAvailable) {
Spacer(modifier = Modifier.height(8.dp))
state.quickConnectCode?.let { code ->
Text(
text = code,
color = scheme.primary,
style = MaterialTheme.typography.headlineMedium.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().padding(top = 4.dp)
)
Spacer(modifier = Modifier.height(10.dp))
TextButton(onClick = callbacks.onCancelQuickConnect) {
Text("Cancel Quick Connect")
}
} ?: PurefinTextButton(
content = { Text("Quick Connect") },
onClick = callbacks.onQuickConnect,
enabled = !state.isQuickConnecting,
modifier = Modifier.fillMaxWidth().height(48.dp)
)
} else {
Text(
text = "Quick Connect is not enabled on this server.",
color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "Manual login",
color = scheme.onBackground,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
PurefinComplexTextField(
@@ -140,10 +261,9 @@ fun LoginContent(
PurefinTextButton(
content = { Text("Connect") },
onClick = callbacks.onConnect,
enabled = !state.isQuickConnecting,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
)
}
}
}

View File

@@ -3,12 +3,11 @@ 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.data.JellyfinServerCandidate
import hu.bbara.purefin.core.feature.login.LoginViewModel
import kotlinx.coroutines.launch
@@ -18,51 +17,82 @@ fun LoginScreen(
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()
var isLoggingIn by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val state = remember(serverUrl, username, password, errorMessage) {
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.clearError()
viewModel.setUrl(it)
onServerUrlChange = viewModel::setUrl,
onFindServer = viewModel::findServer,
onDiscoveredServerClick = {
viewModel.selectDiscoveredServer(JellyfinServerCandidate(name = it.name, address = it.address))
},
onChangeServer = viewModel::changeServer,
onUsernameChange = {
viewModel.clearError()
viewModel.setUsername(it)
},
onPasswordChange = {
viewModel.clearError()
viewModel.setPassword(it)
},
onConnect = {
coroutineScope.launch {
isLoggingIn = true
try {
viewModel.login()
} finally {
isLoggingIn = false
}
}
}
},
onQuickConnect = viewModel::startQuickConnect,
onCancelQuickConnect = viewModel::cancelQuickConnect
)
}
LoginContent(
state = state,
callbacks = callbacks,
isLoggingIn = isLoggingIn,
modifier = modifier
)
}

View File

@@ -2,17 +2,42 @@ package hu.bbara.purefin.ui.screen.login
import androidx.compose.runtime.Immutable
enum class LoginContentPhase {
ServerSearch,
Login
}
@Immutable
data class LoginServerCandidateUi(
val name: String?,
val address: String
)
@Immutable
data class LoginContentState(
val phase: LoginContentPhase,
val serverUrl: String,
val selectedServerName: String?,
val selectedServerUrl: String?,
val discoveredServers: List<LoginServerCandidateUi>,
val username: String,
val password: String,
val isSearching: Boolean,
val isLoggingIn: Boolean,
val quickConnectAvailable: Boolean,
val quickConnectCode: String?,
val isQuickConnecting: Boolean,
val errorMessage: String? = null
)
class LoginContentCallbacks(
val onServerUrlChange: (String) -> Unit,
val onFindServer: () -> Unit,
val onDiscoveredServerClick: (LoginServerCandidateUi) -> Unit,
val onChangeServer: () -> Unit,
val onUsernameChange: (String) -> Unit,
val onPasswordChange: (String) -> Unit,
val onConnect: () -> Unit
val onConnect: () -> Unit,
val onQuickConnect: () -> Unit,
val onCancelQuickConnect: () -> Unit
)

View File

@@ -1,5 +1,24 @@
package hu.bbara.purefin.core.data
import kotlinx.coroutines.flow.Flow
data class JellyfinServerCandidate(
val name: String?,
val address: String
)
data class QuickConnectSession(
val code: String,
val secret: String,
val authenticated: Boolean
)
interface AuthenticationRepository {
fun discoverServers(): Flow<JellyfinServerCandidate>
suspend fun findServer(input: String): JellyfinServerCandidate?
suspend fun isQuickConnectEnabled(url: String): Boolean
suspend fun initiateQuickConnect(url: String): QuickConnectSession?
suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession?
suspend fun login(url: String, username: String, password: String): Boolean
suspend fun loginWithQuickConnect(url: String, secret: String): Boolean
}

View File

@@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.AuthenticationRepository
import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.data.UserSessionRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -17,30 +21,57 @@ class LoginViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository,
private val authenticationRepository: AuthenticationRepository,
) : ViewModel() {
enum class Phase {
SERVER_SEARCH,
LOGIN
}
private val _username = MutableStateFlow("")
val username: StateFlow<String> = _username.asStateFlow()
private val _password = MutableStateFlow("")
val password: StateFlow<String> = _password.asStateFlow()
private val _url = MutableStateFlow("")
val url: StateFlow<String> = _url.asStateFlow()
private val _phase = MutableStateFlow(Phase.SERVER_SEARCH)
val phase: StateFlow<Phase> = _phase.asStateFlow()
private val _selectedServer = MutableStateFlow<JellyfinServerCandidate?>(null)
val selectedServer: StateFlow<JellyfinServerCandidate?> = _selectedServer.asStateFlow()
private val _discoveredServers = MutableStateFlow<List<JellyfinServerCandidate>>(emptyList())
val discoveredServers: StateFlow<List<JellyfinServerCandidate>> = _discoveredServers.asStateFlow()
private val _isSearching = MutableStateFlow(false)
val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()
private val _isLoggingIn = MutableStateFlow(false)
val isLoggingIn: StateFlow<Boolean> = _isLoggingIn.asStateFlow()
private val _quickConnectAvailable = MutableStateFlow(false)
val quickConnectAvailable: StateFlow<Boolean> = _quickConnectAvailable.asStateFlow()
private val _quickConnectCode = MutableStateFlow<String?>(null)
val quickConnectCode: StateFlow<String?> = _quickConnectCode.asStateFlow()
private val _isQuickConnecting = MutableStateFlow(false)
val isQuickConnecting: StateFlow<Boolean> = _isQuickConnecting.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
private var quickConnectJob: Job? = null
init {
viewModelScope.launch {
_url.value = userSessionRepository.serverUrl.first()
}
discoverServers()
}
fun setUrl(url: String) {
clearError()
_url.value = url
}
fun setUsername(username: String) {
clearError()
_username.value = username
}
fun setPassword(password: String) {
clearError()
_password.value = password
}
@@ -49,19 +80,144 @@ class LoginViewModel @Inject constructor(
}
suspend fun clearFields() {
userSessionRepository.setServerUrl("");
cancelQuickConnect()
userSessionRepository.setServerUrl("")
_url.value = ""
_username.value = ""
_password.value = ""
_selectedServer.value = null
_phase.value = Phase.SERVER_SEARCH
}
suspend fun login(): Boolean {
cancelQuickConnect()
_errorMessage.value = null
userSessionRepository.setServerUrl(url.value)
val success = authenticationRepository.login(url.value, username.value, password.value)
_isLoggingIn.value = true
return try {
val serverUrl = selectedServer.value?.address ?: url.value
userSessionRepository.setServerUrl(serverUrl)
val success = authenticationRepository.login(serverUrl, username.value, password.value)
if (!success) {
_errorMessage.value = "Login failed. Check your server URL, username, and password."
}
return success
success
} finally {
_isLoggingIn.value = false
}
}
fun findServer() {
viewModelScope.launch {
val input = url.value.trim()
if (input.isBlank()) {
_errorMessage.value = "Enter a Jellyfin server address."
return@launch
}
cancelQuickConnect()
_isSearching.value = true
_errorMessage.value = null
val server = authenticationRepository.findServer(input)
_isSearching.value = false
if (server == null) {
_errorMessage.value = "No Jellyfin server found for that address."
return@launch
}
selectServer(server)
}
}
fun selectDiscoveredServer(server: JellyfinServerCandidate) {
viewModelScope.launch {
cancelQuickConnect()
_errorMessage.value = null
selectServer(server)
}
}
fun changeServer() {
cancelQuickConnect()
_errorMessage.value = null
_quickConnectAvailable.value = false
_selectedServer.value = null
_phase.value = Phase.SERVER_SEARCH
}
fun startQuickConnect() {
val serverUrl = selectedServer.value?.address ?: url.value
if (serverUrl.isBlank()) {
_errorMessage.value = "Select a Jellyfin server first."
return
}
quickConnectJob?.cancel()
quickConnectJob = viewModelScope.launch {
_errorMessage.value = null
_isQuickConnecting.value = true
_quickConnectCode.value = null
try {
var session = authenticationRepository.initiateQuickConnect(serverUrl) ?: run {
_errorMessage.value = "Quick Connect is not available on this server."
return@launch
}
_quickConnectCode.value = session.code
while (!session.authenticated) {
delay(2_000L)
val updatedSession = authenticationRepository.getQuickConnectState(serverUrl, session.secret)
if (updatedSession == null) {
_errorMessage.value = "Quick Connect failed. Try again or use manual login."
return@launch
}
session = updatedSession
_quickConnectCode.value = session.code
}
userSessionRepository.setServerUrl(serverUrl)
val success = authenticationRepository.loginWithQuickConnect(serverUrl, session.secret)
if (!success) {
_errorMessage.value = "Quick Connect login failed. Try again or use manual login."
}
} catch (_: CancellationException) {
} finally {
_isQuickConnecting.value = false
_quickConnectCode.value = null
}
}
}
fun cancelQuickConnect() {
quickConnectJob?.cancel()
quickConnectJob = null
_isQuickConnecting.value = false
_quickConnectCode.value = null
}
override fun onCleared() {
cancelQuickConnect()
super.onCleared()
}
private fun discoverServers() {
viewModelScope.launch {
try {
authenticationRepository.discoverServers().collect { server ->
_discoveredServers.value = (_discoveredServers.value + server)
.distinctBy { it.address }
}
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
}
}
}
private suspend fun selectServer(server: JellyfinServerCandidate) {
_selectedServer.value = server
_url.value = server.address
_phase.value = Phase.LOGIN
userSessionRepository.setServerUrl(server.address)
_quickConnectAvailable.value = authenticationRepository.isQuickConnectEnabled(server.address)
}
}

View File

@@ -6,18 +6,25 @@ import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.data.QuickConnectSession
import hu.bbara.purefin.core.data.UserSessionRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi
@@ -26,6 +33,8 @@ import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.operations.SystemApi
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.BaseItemDto
@@ -90,6 +99,30 @@ class JellyfinApiClient @Inject constructor(
}
}
fun discoverServers(): Flow<JellyfinServerCandidate> =
jellyfin.discovery.discoverLocalServers()
.map { JellyfinServerCandidate(name = it.name, address = it.address) }
.flowOn(Dispatchers.IO)
suspend fun findServer(input: String): JellyfinServerCandidate? = withContext(Dispatchers.IO) {
logRequest("findServer") {
val address = input.trim()
if (address.isBlank()) {
return@logRequest null
}
val recommendedServers = jellyfin.discovery.getRecommendedServers(
input = address,
minimumScore = RecommendedServerInfoScore.OK
)
val server = recommendedServers.bestServer() ?: return@logRequest null
JellyfinServerCandidate(
name = server.systemInfo.getOrNull()?.serverName,
address = server.address
)
}
}
suspend fun authenticate(
url: String,
username: String,
@@ -106,6 +139,50 @@ class JellyfinApiClient @Inject constructor(
}
}
suspend fun isQuickConnectEnabled(url: String): Boolean = withContext(Dispatchers.IO) {
logRequest("isQuickConnectEnabled") {
val trimmedUrl = url.trim()
if (trimmedUrl.isBlank()) {
return@logRequest false
}
api.update(baseUrl = trimmedUrl)
api.quickConnectApi.getQuickConnectEnabled().content
}
}
suspend fun initiateQuickConnect(url: String): QuickConnectSession? = withContext(Dispatchers.IO) {
logRequest("initiateQuickConnect") {
val trimmedUrl = url.trim()
if (trimmedUrl.isBlank()) {
return@logRequest null
}
api.update(baseUrl = trimmedUrl)
api.quickConnectApi.initiateQuickConnect().content.toQuickConnectSession()
}
}
suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession? = withContext(Dispatchers.IO) {
logRequest("getQuickConnectState") {
val trimmedUrl = url.trim()
if (trimmedUrl.isBlank() || secret.isBlank()) {
return@logRequest null
}
api.update(baseUrl = trimmedUrl)
api.quickConnectApi.getQuickConnectState(secret).content.toQuickConnectSession()
}
}
suspend fun authenticateWithQuickConnect(url: String, secret: String): AuthenticationResult? = withContext(Dispatchers.IO) {
logRequest("authenticateWithQuickConnect") {
val trimmedUrl = url.trim()
if (trimmedUrl.isBlank() || secret.isBlank()) {
return@logRequest null
}
api.update(baseUrl = trimmedUrl)
api.userApi.authenticateWithQuickConnect(secret).content
}
}
suspend fun searchBySearchTerm(searchTerm: String): List<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("searchBySearchTerm") {
if (!ensureConfigured()) {
@@ -547,6 +624,18 @@ class JellyfinApiClient @Inject constructor(
PlaybackMethod.TRANSCODE -> PlayMethod.TRANSCODE
}
private fun Collection<RecommendedServerInfo>.bestServer(): RecommendedServerInfo? =
firstOrNull { it.score == RecommendedServerInfoScore.GREAT }
?: firstOrNull { it.score == RecommendedServerInfoScore.GOOD }
?: firstOrNull { it.score == RecommendedServerInfoScore.OK }
private fun org.jellyfin.sdk.model.api.QuickConnectResult.toQuickConnectSession(): QuickConnectSession =
QuickConnectSession(
code = code,
secret = secret,
authenticated = authenticated
)
companion object {
private const val TAG = "JellyfinApiClient"
}

View File

@@ -2,8 +2,12 @@ package hu.bbara.purefin.data.jellyfin.session
import android.util.Log
import hu.bbara.purefin.core.data.AuthenticationRepository
import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.data.QuickConnectSession
import hu.bbara.purefin.core.data.UserSessionRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import kotlinx.coroutines.flow.Flow
import org.jellyfin.sdk.model.api.AuthenticationResult
import javax.inject.Inject
import javax.inject.Singleton
@@ -12,23 +16,61 @@ class JellyfinAuthenticationRepository @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository,
) : AuthenticationRepository {
override fun discoverServers(): Flow<JellyfinServerCandidate> =
jellyfinApiClient.discoverServers()
override suspend fun findServer(input: String): JellyfinServerCandidate? =
runCatching { jellyfinApiClient.findServer(input) }
.onFailure { Log.e(TAG, "Server search failed", it) }
.getOrNull()
override suspend fun isQuickConnectEnabled(url: String): Boolean =
runCatching { jellyfinApiClient.isQuickConnectEnabled(url) }
.onFailure { Log.e(TAG, "Quick Connect check failed", it) }
.getOrDefault(false)
override suspend fun initiateQuickConnect(url: String): QuickConnectSession? =
runCatching { jellyfinApiClient.initiateQuickConnect(url) }
.onFailure { Log.e(TAG, "Quick Connect initiation failed", it) }
.getOrNull()
override suspend fun getQuickConnectState(url: String, secret: String): QuickConnectSession? =
runCatching { jellyfinApiClient.getQuickConnectState(url, secret) }
.onFailure { Log.e(TAG, "Quick Connect state check failed", it) }
.getOrNull()
override suspend fun login(url: String, username: String, password: String): Boolean {
return try {
val authResult = jellyfinApiClient.authenticate(url = url, username = username, password = password)
?: return false
val token = authResult.accessToken ?: return false
val userId = authResult.user?.id ?: return false
userSessionRepository.setAccessToken(accessToken = token)
userSessionRepository.setUserId(userId)
userSessionRepository.setLoggedIn(true)
true
saveAuthenticationResult(authResult)
} catch (e: Exception) {
Log.e(TAG, "Login failed", e)
false
}
}
override suspend fun loginWithQuickConnect(url: String, secret: String): Boolean {
return try {
val authResult = jellyfinApiClient.authenticateWithQuickConnect(url = url, secret = secret)
?: return false
saveAuthenticationResult(authResult)
} catch (e: Exception) {
Log.e(TAG, "Quick Connect login failed", e)
false
}
}
private suspend fun saveAuthenticationResult(authResult: AuthenticationResult): Boolean {
val token = authResult.accessToken ?: return false
val userId = authResult.user?.id ?: return false
userSessionRepository.setAccessToken(accessToken = token)
userSessionRepository.setUserId(userId)
userSessionRepository.setLoggedIn(true)
return true
}
private companion object {
private const val TAG = "JellyfinAuthRepo"
}