mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat: redesign login screen on both app and app-tv
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user