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.core.feature.browse.home.AppViewModel
import hu.bbara.purefin.ui.screen.movie.TvMovieScreen import hu.bbara.purefin.ui.screen.movie.TvMovieScreen
import hu.bbara.purefin.ui.screen.series.TvSeriesScreen 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.TvAppScreen
import hu.bbara.purefin.ui.screen.library.TvLibraryScreen import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
import hu.bbara.purefin.ui.screen.player.TvPlayerScreen import hu.bbara.purefin.ui.screen.player.TvPlayerScreen
@@ -21,7 +21,7 @@ fun EntryProviderScope<Route>.tvHomeSection() {
fun EntryProviderScope<Route>.tvLoginSection() { fun EntryProviderScope<Route>.tvLoginSection() {
entry<Route.LoginRoute> { 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.core.navigation.Route
import hu.bbara.purefin.navigation.LocalNavigationBackStack import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager 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.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.theme.backgroundDark import hu.bbara.purefin.ui.theme.backgroundDark
@@ -229,7 +229,7 @@ class TvActivity : ComponentActivity() {
) )
} }
} else { } else {
LoginScreen() TvLoginScreen()
} }
availableUpdate?.let { update -> 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.border
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -56,10 +60,9 @@ import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
fun TvLoginContent( fun TvLoginContent(
state: LoginContentState, state: LoginContentState,
callbacks: LoginContentCallbacks, callbacks: LoginContentCallbacks,
isLoggingIn: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (isLoggingIn) { if (state.isLoggingIn) {
PurefinWaitingScreen(modifier = modifier) PurefinWaitingScreen(modifier = modifier)
return return
} }
@@ -120,12 +123,21 @@ private fun TvLoginForm(
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val serverFocusRequester = remember { FocusRequester() } val serverFocusRequester = remember { FocusRequester() }
val findFocusRequester = remember { FocusRequester() }
val changeServerFocusRequester = remember { FocusRequester() }
val quickConnectFocusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() } val usernameFocusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() }
val connectFocusRequester = remember { FocusRequester() } val connectFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(state.phase) {
serverFocusRequester.requestFocus() if (state.phase == LoginContentPhase.ServerSearch) {
serverFocusRequester.requestFocus()
} else if (state.quickConnectAvailable) {
quickConnectFocusRequester.requestFocus()
} else {
usernameFocusRequester.requestFocus()
}
} }
Box( Box(
@@ -137,7 +149,7 @@ private fun TvLoginForm(
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.widthIn(max = 480.dp) .widthIn(max = if (state.phase == LoginContentPhase.Login) 1040.dp else 480.dp)
.fillMaxWidth(), .fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
@@ -153,7 +165,11 @@ private fun TvLoginForm(
modifier = Modifier.padding(top = 8.dp) modifier = Modifier.padding(top = 8.dp)
) )
Text( 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, color = scheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
@@ -178,21 +194,223 @@ private fun TvLoginForm(
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
} }
TvLoginTextField( when (state.phase) {
label = "Server URL", LoginContentPhase.ServerSearch -> TvServerSearchFields(
value = state.serverUrl, state = state,
onValueChange = callbacks.onServerUrlChange, callbacks = callbacks,
placeholder = "http://192.168.1.100:8096", serverFocusRequester = serverFocusRequester,
leadingIcon = Icons.Default.Storage, findFocusRequester = findFocusRequester
modifier = Modifier )
.focusRequester(serverFocusRequester) LoginContentPhase.Login -> TvLoginFields(
.focusProperties { state = state,
down = usernameFocusRequester 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( TvLoginTextField(
label = "Username", label = "Username",
value = state.username, value = state.username,
@@ -202,7 +420,12 @@ private fun TvLoginForm(
modifier = Modifier modifier = Modifier
.focusRequester(usernameFocusRequester) .focusRequester(usernameFocusRequester)
.focusProperties { .focusProperties {
up = serverFocusRequester up = changeServerFocusRequester
left = if (state.quickConnectAvailable) {
quickConnectFocusRequester
} else {
FocusRequester.Default
}
down = passwordFocusRequester down = passwordFocusRequester
} }
) )
@@ -223,10 +446,11 @@ private fun TvLoginForm(
} }
) )
Spacer(modifier = Modifier.height(22.dp)) Spacer(modifier = Modifier.height(18.dp))
Button( Button(
onClick = callbacks.onConnect, onClick = callbacks.onConnect,
enabled = !state.isQuickConnecting,
modifier = Modifier modifier = Modifier
.focusRequester(connectFocusRequester) .focusRequester(connectFocusRequester)
.focusProperties { .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 @Composable
private fun TvLoginTextField( private fun TvLoginTextField(
label: String, 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.material.icons.filled.Storage
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.button.PurefinTextButton import hu.bbara.purefin.ui.common.button.PurefinTextButton
import hu.bbara.purefin.ui.common.image.PurefinLogo import hu.bbara.purefin.ui.common.image.PurefinLogo
@@ -33,12 +35,11 @@ import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
fun LoginContent( fun LoginContent(
state: LoginContentState, state: LoginContentState,
callbacks: LoginContentCallbacks, callbacks: LoginContentCallbacks,
isLoggingIn: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
if (isLoggingIn) { if (state.isLoggingIn) {
PurefinWaitingScreen(modifier = modifier) PurefinWaitingScreen(modifier = modifier)
return return
} }
@@ -76,21 +77,6 @@ fun LoginContent(
Spacer(modifier = Modifier.height(28.dp)) 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 -> state.errorMessage?.let { errorMessage ->
Text( Text(
text = errorMessage, text = errorMessage,
@@ -107,43 +93,177 @@ fun LoginContent(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
} }
PurefinComplexTextField( when (state.phase) {
label = "Server URL", LoginContentPhase.ServerSearch -> ServerSearchContent(state, callbacks)
value = state.serverUrl, LoginContentPhase.Login -> LoginPhaseContent(state, callbacks)
onValueChange = callbacks.onServerUrlChange, }
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage
)
Spacer(modifier = Modifier.height(12.dp))
PurefinComplexTextField(
label = "Username",
value = state.username,
onValueChange = callbacks.onUsernameChange,
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person
)
Spacer(modifier = Modifier.height(12.dp))
PurefinPasswordField(
label = "Password",
value = state.password,
onValueChange = callbacks.onPasswordChange,
placeholder = "Enter your password",
leadingIcon = Icons.Default.Lock,
)
Spacer(modifier = Modifier.height(24.dp))
PurefinTextButton(
content = { Text("Connect") },
onClick = callbacks.onConnect,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
)
} }
} }
} }
@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,
onValueChange = callbacks.onServerUrlChange,
placeholder = "http://192.168.1.100:8096",
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(
label = "Username",
value = state.username,
onValueChange = callbacks.onUsernameChange,
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person
)
Spacer(modifier = Modifier.height(12.dp))
PurefinPasswordField(
label = "Password",
value = state.password,
onValueChange = callbacks.onPasswordChange,
placeholder = "Enter your password",
leadingIcon = Icons.Default.Lock,
)
Spacer(modifier = Modifier.height(24.dp))
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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.feature.login.LoginViewModel import hu.bbara.purefin.core.feature.login.LoginViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -18,51 +17,82 @@ fun LoginScreen(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val serverUrl by viewModel.url.collectAsState() 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 username by viewModel.username.collectAsState()
val password by viewModel.password.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 errorMessage by viewModel.errorMessage.collectAsState()
var isLoggingIn by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope() 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( LoginContentState(
phase = if (phase == LoginViewModel.Phase.LOGIN) {
LoginContentPhase.Login
} else {
LoginContentPhase.ServerSearch
},
serverUrl = serverUrl, serverUrl = serverUrl,
selectedServerName = selectedServer?.name,
selectedServerUrl = selectedServer?.address,
discoveredServers = discoveredServers.map {
LoginServerCandidateUi(name = it.name, address = it.address)
},
username = username, username = username,
password = password, password = password,
isSearching = isSearching,
isLoggingIn = isLoggingIn,
quickConnectAvailable = quickConnectAvailable,
quickConnectCode = quickConnectCode,
isQuickConnecting = isQuickConnecting,
errorMessage = errorMessage errorMessage = errorMessage
) )
} }
val callbacks = remember(viewModel, coroutineScope) { val callbacks = remember(viewModel, coroutineScope) {
LoginContentCallbacks( LoginContentCallbacks(
onServerUrlChange = { onServerUrlChange = viewModel::setUrl,
viewModel.clearError() onFindServer = viewModel::findServer,
viewModel.setUrl(it) onDiscoveredServerClick = {
viewModel.selectDiscoveredServer(JellyfinServerCandidate(name = it.name, address = it.address))
}, },
onChangeServer = viewModel::changeServer,
onUsernameChange = { onUsernameChange = {
viewModel.clearError()
viewModel.setUsername(it) viewModel.setUsername(it)
}, },
onPasswordChange = { onPasswordChange = {
viewModel.clearError()
viewModel.setPassword(it) viewModel.setPassword(it)
}, },
onConnect = { onConnect = {
coroutineScope.launch { coroutineScope.launch {
isLoggingIn = true viewModel.login()
try {
viewModel.login()
} finally {
isLoggingIn = false
}
} }
} },
onQuickConnect = viewModel::startQuickConnect,
onCancelQuickConnect = viewModel::cancelQuickConnect
) )
} }
LoginContent( LoginContent(
state = state, state = state,
callbacks = callbacks, callbacks = callbacks,
isLoggingIn = isLoggingIn,
modifier = modifier modifier = modifier
) )
} }

View File

@@ -2,17 +2,42 @@ package hu.bbara.purefin.ui.screen.login
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
enum class LoginContentPhase {
ServerSearch,
Login
}
@Immutable
data class LoginServerCandidateUi(
val name: String?,
val address: String
)
@Immutable @Immutable
data class LoginContentState( data class LoginContentState(
val phase: LoginContentPhase,
val serverUrl: String, val serverUrl: String,
val selectedServerName: String?,
val selectedServerUrl: String?,
val discoveredServers: List<LoginServerCandidateUi>,
val username: String, val username: String,
val password: String, val password: String,
val isSearching: Boolean,
val isLoggingIn: Boolean,
val quickConnectAvailable: Boolean,
val quickConnectCode: String?,
val isQuickConnecting: Boolean,
val errorMessage: String? = null val errorMessage: String? = null
) )
class LoginContentCallbacks( class LoginContentCallbacks(
val onServerUrlChange: (String) -> Unit, val onServerUrlChange: (String) -> Unit,
val onFindServer: () -> Unit,
val onDiscoveredServerClick: (LoginServerCandidateUi) -> Unit,
val onChangeServer: () -> Unit,
val onUsernameChange: (String) -> Unit, val onUsernameChange: (String) -> Unit,
val onPasswordChange: (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 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 { 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 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 androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.core.data.AuthenticationRepository import hu.bbara.purefin.core.data.AuthenticationRepository
import hu.bbara.purefin.core.data.JellyfinServerCandidate
import hu.bbara.purefin.core.data.UserSessionRepository 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -17,30 +21,57 @@ class LoginViewModel @Inject constructor(
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val authenticationRepository: AuthenticationRepository, private val authenticationRepository: AuthenticationRepository,
) : ViewModel() { ) : ViewModel() {
enum class Phase {
SERVER_SEARCH,
LOGIN
}
private val _username = MutableStateFlow("") private val _username = MutableStateFlow("")
val username: StateFlow<String> = _username.asStateFlow() val username: StateFlow<String> = _username.asStateFlow()
private val _password = MutableStateFlow("") private val _password = MutableStateFlow("")
val password: StateFlow<String> = _password.asStateFlow() val password: StateFlow<String> = _password.asStateFlow()
private val _url = MutableStateFlow("") private val _url = MutableStateFlow("")
val url: StateFlow<String> = _url.asStateFlow() 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) private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow() val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
private var quickConnectJob: Job? = null
init { init {
viewModelScope.launch { viewModelScope.launch {
_url.value = userSessionRepository.serverUrl.first() _url.value = userSessionRepository.serverUrl.first()
} }
discoverServers()
} }
fun setUrl(url: String) { fun setUrl(url: String) {
clearError()
_url.value = url _url.value = url
} }
fun setUsername(username: String) { fun setUsername(username: String) {
clearError()
_username.value = username _username.value = username
} }
fun setPassword(password: String) { fun setPassword(password: String) {
clearError()
_password.value = password _password.value = password
} }
@@ -49,19 +80,144 @@ class LoginViewModel @Inject constructor(
} }
suspend fun clearFields() { suspend fun clearFields() {
userSessionRepository.setServerUrl(""); cancelQuickConnect()
userSessionRepository.setServerUrl("")
_url.value = ""
_username.value = "" _username.value = ""
_password.value = "" _password.value = ""
_selectedServer.value = null
_phase.value = Phase.SERVER_SEARCH
} }
suspend fun login(): Boolean { suspend fun login(): Boolean {
cancelQuickConnect()
_errorMessage.value = null _errorMessage.value = null
userSessionRepository.setServerUrl(url.value) _isLoggingIn.value = true
val success = authenticationRepository.login(url.value, username.value, password.value) return try {
if (!success) { val serverUrl = selectedServer.value?.address ?: url.value
_errorMessage.value = "Login failed. Check your server URL, username, and password." 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."
}
success
} finally {
_isLoggingIn.value = false
} }
return success }
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 dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.PlaybackMethod import hu.bbara.purefin.core.data.PlaybackMethod
import hu.bbara.purefin.core.data.PlaybackReportContext 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 hu.bbara.purefin.core.data.UserSessionRepository
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.Response 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.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
import org.jellyfin.sdk.api.client.extensions.playStateApi 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.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi 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.client.extensions.videosApi
import org.jellyfin.sdk.api.operations.SystemApi import org.jellyfin.sdk.api.operations.SystemApi
import org.jellyfin.sdk.createJellyfin 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.ClientInfo
import org.jellyfin.sdk.model.api.AuthenticationResult import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.BaseItemDto 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( suspend fun authenticate(
url: String, url: String,
username: 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) { suspend fun searchBySearchTerm(searchTerm: String): List<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("searchBySearchTerm") { logRequest("searchBySearchTerm") {
if (!ensureConfigured()) { if (!ensureConfigured()) {
@@ -547,6 +624,18 @@ class JellyfinApiClient @Inject constructor(
PlaybackMethod.TRANSCODE -> PlayMethod.TRANSCODE 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 { companion object {
private const val TAG = "JellyfinApiClient" private const val TAG = "JellyfinApiClient"
} }

View File

@@ -2,8 +2,12 @@ package hu.bbara.purefin.data.jellyfin.session
import android.util.Log import android.util.Log
import hu.bbara.purefin.core.data.AuthenticationRepository 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.core.data.UserSessionRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -12,23 +16,61 @@ class JellyfinAuthenticationRepository @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
) : AuthenticationRepository { ) : 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 { override suspend fun login(url: String, username: String, password: String): Boolean {
return try { return try {
val authResult = jellyfinApiClient.authenticate(url = url, username = username, password = password) val authResult = jellyfinApiClient.authenticate(url = url, username = username, password = password)
?: return false ?: return false
val token = authResult.accessToken ?: return false saveAuthenticationResult(authResult)
val userId = authResult.user?.id ?: return false
userSessionRepository.setAccessToken(accessToken = token)
userSessionRepository.setUserId(userId)
userSessionRepository.setLoggedIn(true)
true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Login failed", e) Log.e(TAG, "Login failed", e)
false 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 companion object {
private const val TAG = "JellyfinAuthRepo" private const val TAG = "JellyfinAuthRepo"
} }