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.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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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.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,
|
||||||
|
|||||||
@@ -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.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user