Support switching users & servers

This commit is contained in:
Damontecres 2025-09-24 17:56:53 -04:00
parent d3eeb67877
commit d7dde352bc
No known key found for this signature in database
16 changed files with 787 additions and 226 deletions

View file

@ -19,14 +19,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.dolphin.data.ServerRepository
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.CoilConfig
import com.github.damontecres.dolphin.ui.ServerLoginPage
import com.github.damontecres.dolphin.ui.nav.ApplicationContent
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.setup.SwitchServerContent
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
import com.github.damontecres.dolphin.util.profile.createDeviceProfile
import dagger.hilt.android.AndroidEntryPoint
@ -69,13 +73,19 @@ class MainActivity : AppCompatActivity() {
isRestoringSession = false
}
val deviceProfile =
remember {
remember(preferences) {
createDeviceProfile(this@MainActivity, preferences, false)
}
val server = serverRepository.currentServer
val user = serverRepository.currentUser
val backStack = rememberNavBackStack(Destination.Main)
val navigationManager = NavigationManager(backStack)
if (server != null && user != null) {
ApplicationContent(
user = user,
server = server,
navigationManager = navigationManager,
preferences = preferences,
deviceProfile = deviceProfile,
modifier = Modifier.fillMaxSize(),
@ -90,8 +100,17 @@ class MainActivity : AppCompatActivity() {
modifier = Modifier.align(Alignment.Center),
)
}
} else if (server != null) {
// Have server but no user, go to user selection
SwitchUserContent(
navigationManager = navigationManager,
modifier = Modifier.fillMaxSize(),
)
} else {
ServerLoginPage(modifier = Modifier.fillMaxSize())
SwitchServerContent(
navigationManager = navigationManager,
modifier = Modifier.fillMaxSize(),
)
}
}
}

View file

@ -51,7 +51,7 @@ data class JellyfinServerUsers(
@Dao
interface JellyfinServerDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun addServer(vararg servers: JellyfinServer)
@Insert(onConflict = OnConflictStrategy.REPLACE)

View file

@ -3,11 +3,14 @@ package com.github.damontecres.dolphin.data
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.datastore.core.DataStore
import com.github.damontecres.dolphin.preferences.UserPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.AuthenticationResult
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@ -18,6 +21,7 @@ class ServerRepository
constructor(
val serverDao: JellyfinServerDao,
val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<UserPreferences>,
) {
private var _currentServer by mutableStateOf<JellyfinServer?>(null)
val currentServer get() = _currentServer
@ -103,4 +107,44 @@ class ServerRepository
}
return false
}
suspend fun changeUser(
serverUrl: String,
authenticationResult: AuthenticationResult,
) {
val accessToken = authenticationResult.accessToken
if (accessToken != null) {
val authedUser = authenticationResult.user
val server =
authenticationResult.serverId?.let {
JellyfinServer(
id = it,
name = authedUser?.serverName,
url = serverUrl,
)
}
if (server != null) {
val user =
authedUser?.let {
JellyfinUser(
id = it.id.toString(),
name = it.name,
serverId = server.id,
accessToken = accessToken,
)
}
if (user != null) {
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = server.id
currentUserId = user.id
}.build()
}
addAndChangeUser(server, user)
}
}
}
}
}

View file

@ -1,160 +0,0 @@
package com.github.damontecres.dolphin.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
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.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Button
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.JellyfinServer
import com.github.damontecres.dolphin.data.JellyfinUser
import com.github.damontecres.dolphin.data.ServerRepository
import com.github.damontecres.dolphin.preferences.UserPreferences
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.userApi
import javax.inject.Inject
@HiltViewModel
class ServerManagementViewModel
@Inject
constructor(
val serverRepository: ServerRepository,
val userPreferencesDataStore: DataStore<UserPreferences>,
val jellyfin: Jellyfin,
) : ViewModel() {
fun addServer() {
}
suspend fun loginWithPassword(
serverUrl: String,
username: String,
password: String,
) {
try {
val api = jellyfin.createApi(baseUrl = serverUrl)
val authenticationResult by api.userApi.authenticateUserByName(
username = username,
password = password,
)
val accessToken = authenticationResult.accessToken
if (accessToken != null) {
val authedUser = authenticationResult.user
val server =
authenticationResult.serverId?.let {
JellyfinServer(
id = it,
name = authedUser?.serverName,
url = serverUrl,
)
}
if (server != null) {
val user =
authedUser?.let {
JellyfinUser(
id = it.id.toString(),
name = it.name,
serverId = server.id,
accessToken = accessToken,
)
}
if (user != null) {
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = server.id
currentUserId = user.id
}.build()
}
serverRepository.addAndChangeUser(server, user)
}
}
}
// Print session information
println(authenticationResult.sessionInfo)
} catch (err: InvalidStatusException) {
if (err.status == 401) {
// Username or password is incorrect
println("Invalid user")
}
}
}
}
@Composable
fun ServerLoginPage(
modifier: Modifier = Modifier,
viewModel: ServerManagementViewModel = viewModel(),
) {
val scope = rememberCoroutineScope()
ServerLoginForm(
modifier = modifier,
onSubmit = { serverUrl, username, password ->
scope.launch {
// Handle form submission
viewModel.loginWithPassword(serverUrl, username, password)
}
},
)
}
@Composable
fun ServerLoginForm(
modifier: Modifier = Modifier,
onSubmit: (serverUrl: String, username: String, password: String) -> Unit,
) {
var serverUrl by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column(modifier = modifier.padding(16.dp)) {
TextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server URL") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
TextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
TextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { onSubmit(serverUrl, username, password) },
modifier = Modifier.fillMaxWidth(),
) {
Text("Submit")
}
}
}

View file

@ -1,8 +1,10 @@
package com.github.damontecres.dolphin.ui
import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import com.github.damontecres.dolphin.R
import org.jellyfin.sdk.model.api.ItemFields
@ -24,3 +26,10 @@ val DefaultItemFields =
ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT,
)
@Preview(
device = "spec:parent=tv_1080p",
backgroundColor = 0xFF383535,
uiMode = UI_MODE_TYPE_TELEVISION,
)
annotation class PreviewTvSpec

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.height
@ -46,8 +47,6 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.dolphin.ui.FontAwesome
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
sealed interface DialogItemEntry
@ -266,33 +265,24 @@ fun ScrollableDialog(
}
@Composable
fun MarkerDurationDialog(
fun BasicDialog(
onDismissRequest: () -> Unit,
onClick: (Long) -> Unit,
properties: DialogProperties = DialogProperties(),
content: @Composable () -> Unit,
) {
val dialogItems =
remember {
listOf(
15.seconds,
20.seconds,
30.seconds,
60.seconds,
5.minutes,
10.minutes,
20.minutes,
).map {
DialogItem(it.toString()) {
onClick.invoke(it.inWholeMilliseconds)
}
}
}
DialogPopup(
showDialog = true,
title = "How long?",
dialogItems = dialogItems,
Dialog(
onDismissRequest = onDismissRequest,
dismissOnClick = false,
waitToLoad = false,
properties = DialogProperties(),
)
properties = properties,
) {
Box(
modifier =
Modifier
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(8.dp),
),
) {
content()
}
}
}

View file

@ -5,40 +5,26 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator
import androidx.navigation3.scene.rememberSceneSetupNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.github.damontecres.dolphin.data.JellyfinServer
import com.github.damontecres.dolphin.data.JellyfinUser
import com.github.damontecres.dolphin.preferences.UserPreferences
import org.jellyfin.sdk.model.api.DeviceProfile
@Composable
fun ApplicationContent(
server: JellyfinServer,
user: JellyfinUser,
navigationManager: NavigationManager,
preferences: UserPreferences,
deviceProfile: DeviceProfile,
modifier: Modifier = Modifier,
) {
val backStack = rememberNavBackStack(Destination.Main)
val navigationManager =
object : NavigationManager {
override fun navigateTo(destination: Destination) {
backStack.add(destination)
}
override fun goBack() {
backStack.removeLastOrNull()
}
override fun goToHome() {
backStack.clear()
backStack.add(Destination.Main)
}
}
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
backStack = navigationManager.backStack,
onBack = { navigationManager.goBack() },
entryDecorators =
listOf(
rememberSceneSetupNavEntryDecorator(),
@ -46,8 +32,9 @@ fun ApplicationContent(
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = { key ->
NavEntry(key) {
key as Destination
key as Destination
val contentKey = "${key}_${server.id}_${user.id}"
NavEntry(key, contentKey = contentKey) {
if (key.fullScreen) {
DestinationContent(
destination = key,

View file

@ -15,6 +15,12 @@ sealed class Destination(
@Serializable
data object Setup : Destination(true)
@Serializable
data object ServerList : Destination(true)
@Serializable
data object UserList : Destination(true)
@Serializable
data object Main : Destination()

View file

@ -6,6 +6,8 @@ import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.detail.MediaItemContent
import com.github.damontecres.dolphin.ui.main.MainPage
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
import com.github.damontecres.dolphin.ui.setup.SwitchServerContent
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent
import org.jellyfin.sdk.model.api.DeviceProfile
@Composable
@ -17,23 +19,22 @@ fun DestinationContent(
modifier: Modifier = Modifier,
) {
when (destination) {
Destination.Main -> {
Destination.Main ->
MainPage(
preferences = preferences,
navigationManager = navigationManager,
modifier = modifier,
)
}
is Destination.MediaItem -> {
is Destination.MediaItem ->
MediaItemContent(
preferences = preferences,
navigationManager = navigationManager,
destination = destination,
modifier = modifier,
)
}
is Destination.Playback -> {
is Destination.Playback ->
PlaybackContent(
preferences = preferences,
navigationManager = navigationManager,
@ -41,7 +42,10 @@ fun DestinationContent(
destination = destination,
modifier = modifier,
)
}
Destination.ServerList -> SwitchServerContent(navigationManager, modifier)
Destination.UserList -> SwitchUserContent(navigationManager, modifier)
Destination.Search -> TODO()
Destination.Settings -> TODO()
Destination.Setup -> TODO()

View file

@ -124,7 +124,7 @@ fun NavDrawer(
IconNavItem(
text = user?.name ?: "",
icon = Icons.Default.AccountCircle,
onClick = { navigationManager.navigateTo(Destination.Setup) },
onClick = { navigationManager.navigateTo(Destination.UserList) },
)
}
item {

View file

@ -1,9 +1,21 @@
package com.github.damontecres.dolphin.ui.nav
interface NavigationManager {
fun navigateTo(destination: Destination)
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
fun goBack()
class NavigationManager(
val backStack: NavBackStack<NavKey>,
) {
fun navigateTo(destination: Destination) {
backStack.add(destination)
}
fun goToHome()
fun goBack() {
backStack.removeLastOrNull()
}
fun goToHome() {
backStack.clear()
backStack.add(Destination.Main)
}
}

View file

@ -0,0 +1,78 @@
package com.github.damontecres.dolphin.ui.setup
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.JellyfinServer
import com.github.damontecres.dolphin.ui.components.CircularProgress
sealed interface ServerConnectionStatus {
object Success : ServerConnectionStatus
object Pending : ServerConnectionStatus
data class Error(
val message: String?,
) : ServerConnectionStatus
}
@Composable
fun ServerList(
servers: List<JellyfinServer>,
connectionStatus: Map<String, ServerConnectionStatus>,
onSwitchServer: (JellyfinServer) -> Unit,
onAddServer: () -> Unit,
onRemoveServer: (JellyfinServer) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
items(servers) { server ->
val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending
ListItem(
enabled = status == ServerConnectionStatus.Success,
selected = false,
headlineContent = { Text(text = server.name ?: server.id) },
supportingContent = { Text(text = server.url) },
leadingContent = {
when (status) {
ServerConnectionStatus.Success -> {}
ServerConnectionStatus.Pending -> {
CircularProgress()
}
is ServerConnectionStatus.Error -> {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = status.message,
tint = MaterialTheme.colorScheme.error,
)
}
}
},
onClick = { onSwitchServer.invoke(server) },
onLongClick = {
// TODO dialog to remove server
},
modifier = Modifier,
)
}
item {
HorizontalDivider()
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = "Add User") },
supportingContent = { },
onClick = { onAddServer.invoke() },
modifier = Modifier,
)
}
}
}

View file

@ -0,0 +1,104 @@
package com.github.damontecres.dolphin.ui.setup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.ui.components.BasicDialog
import com.github.damontecres.dolphin.ui.components.EditTextBox
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
@Composable
fun SwitchServerContent(
navigationManager: NavigationManager,
modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel = hiltViewModel(),
) {
val currentServer = viewModel.serverRepository.currentServer
val servers by viewModel.servers.observeAsState(listOf())
val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
var showAddServer by remember { mutableStateOf(false) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = "Select Server",
)
ServerList(
servers = servers,
connectionStatus = serverStatus,
onSwitchServer = {},
onAddServer = {
},
onRemoveServer = {
// TODO
},
modifier = Modifier.fillMaxWidth(.5f),
)
}
if (showAddServer) {
var url by remember { mutableStateOf("") }
val submit = {
showAddServer = false
viewModel.addServer(url) {
navigationManager.navigateTo(Destination.UserList)
}
}
BasicDialog(
onDismissRequest = { showAddServer = false },
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp),
) {
Text(
text = "Enter Server URL",
)
EditTextBox(
value = url,
onValueChange = { url = it },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Uri,
),
keyboardActions =
KeyboardActions(
onDone = { submit.invoke() },
),
modifier = Modifier,
)
Button(
onClick = { submit.invoke() },
modifier = Modifier,
) {
Text(text = "Submit")
}
}
}
}
}

View file

@ -0,0 +1,182 @@
package com.github.damontecres.dolphin.ui.setup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.ui.components.BasicDialog
import com.github.damontecres.dolphin.ui.components.EditTextBox
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
@Composable
fun SwitchUserContent(
navigationManager: NavigationManager,
modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel = hiltViewModel(),
) {
val currentServer = viewModel.serverRepository.currentServer
val currentUser = viewModel.serverRepository.currentUser
val users by viewModel.users.observeAsState(listOf())
val quickConnectEnabled by viewModel.quickConnectEnabled.observeAsState(false)
val quickConnect by viewModel.quickConnectState.observeAsState(null)
var showAddUser by remember { mutableStateOf(false) }
currentServer?.let { server ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = server.name ?: server.url,
)
Text(
text = "Select User",
)
UserList(
users = users,
currentUser = currentUser,
onSwitchUser = { user ->
viewModel.switchUser(server, user) {
navigationManager.goToHome()
}
},
onAddUser = { showAddUser = true },
onRemoveUser = { user ->
},
onSwitchServer = {
navigationManager.navigateTo(Destination.ServerList)
},
modifier = Modifier.fillMaxWidth(.5f),
)
}
if (showAddUser) {
var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) }
LaunchedEffect(Unit) {
if (quickConnectEnabled) {
viewModel.initiateQuickConnect(server) {
navigationManager.goToHome()
}
}
}
BasicDialog(
onDismissRequest = {
viewModel.cancelQuickConnect()
showAddUser = false
},
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp).fillMaxWidth(),
) {
if (useQuickConnect) {
quickConnect?.let { qc ->
Text(
text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = qc.code,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Button(
onClick = {
useQuickConnect = false
},
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(text = "Use username/password")
}
}
} else {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val onSubmit = {
viewModel.login(server, username, password) {
showAddUser = false
navigationManager.goToHome()
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
Text(
text = "Username",
modifier = Modifier,
)
EditTextBox(
value = username,
onValueChange = { username = it },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Text,
),
modifier = Modifier,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
Text(
text = "Password",
modifier = Modifier,
)
EditTextBox(
value = password,
onValueChange = { password = it },
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
),
keyboardActions =
KeyboardActions(
onDone = { onSubmit.invoke() },
),
modifier = Modifier,
)
}
Button(
onClick = { onSubmit.invoke() },
enabled = username.isNotNullOrBlank() && password.isNotNullOrBlank(),
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text("Login")
}
}
}
}
}
}
}

View file

@ -0,0 +1,216 @@
package com.github.damontecres.dolphin.ui.setup
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.dolphin.data.JellyfinServer
import com.github.damontecres.dolphin.data.JellyfinServerDao
import com.github.damontecres.dolphin.data.JellyfinUser
import com.github.damontecres.dolphin.data.ServerRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.QuickConnectDto
import org.jellyfin.sdk.model.api.QuickConnectResult
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class SwitchUserViewModel
@Inject
constructor(
val jellyfin: Jellyfin,
val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao,
) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<String, ServerConnectionStatus>>(mapOf())
val users = MutableLiveData<List<JellyfinUser>>(listOf())
val quickConnectEnabled = MutableLiveData(false)
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
private var quickConnectJob: Job? = null
init {
viewModelScope.launch(Dispatchers.IO) {
val allServers =
serverDao
.getServers()
.map { it.server }
.sortedWith(compareBy<JellyfinServer> { it.name }.thenBy { it.url })
withContext(Dispatchers.Main) {
servers.value = allServers
}
allServers.forEach { server ->
try {
jellyfin
.createApi(server.url)
.systemApi
.getPublicSystemInfo()
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Success,
)
}
}
} catch (ex: Exception) {
Timber.w(ex, "Error checking quick connect for server ${server.url}")
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Error(ex.localizedMessage),
)
}
}
}
}
viewModelScope.launch(Dispatchers.IO) {
serverRepository.currentServer?.let {
val quickConnect =
jellyfin
.createApi(it.url)
.quickConnectApi
.getQuickConnectEnabled()
.content
val serverUsers = serverDao.getServer(it.id)?.users?.sortedBy { it.name } ?: listOf()
withContext(Dispatchers.Main) {
quickConnectEnabled.value = quickConnect
users.value = serverUsers
}
}
}
}
fun switchUser(
server: JellyfinServer,
user: JellyfinUser,
callback: () -> Unit,
) {
viewModelScope.launch {
serverRepository.changeUser(server, user)
callback.invoke()
}
}
fun login(
server: JellyfinServer,
username: String,
password: String,
onAuthenticated: () -> Unit,
) {
quickConnectJob?.cancel()
viewModelScope.launch {
try {
val api = jellyfin.createApi(baseUrl = server.url)
val authenticationResult by api.userApi.authenticateUserByName(
username = username,
password = password,
)
serverRepository.changeUser(server.url, authenticationResult)
withContext(Dispatchers.Main) {
onAuthenticated.invoke()
}
} catch (err: InvalidStatusException) {
if (err.status == 401) {
// TODO set state to notify user
}
}
}
}
fun initiateQuickConnect(
server: JellyfinServer,
onAuthenticated: () -> Unit,
) {
quickConnectJob?.cancel()
viewModelScope.launch(Dispatchers.IO) {
try {
val api = jellyfin.createApi(server.url)
var state =
api
.quickConnectApi
.initiateQuickConnect()
.content
withContext(Dispatchers.Main) {
quickConnectState.value = state
}
quickConnectJob =
viewModelScope.launch {
while (!state.authenticated) {
delay(5_000L)
state =
api.quickConnectApi
.getQuickConnectState(
secret = state.secret,
).content
withContext(Dispatchers.Main) {
quickConnectState.value = state
}
}
val authenticationResult by api.userApi.authenticateWithQuickConnect(
QuickConnectDto(secret = state.secret),
)
serverRepository.changeUser(server.url, authenticationResult)
withContext(Dispatchers.Main) {
onAuthenticated.invoke()
}
}
} catch (err: InvalidStatusException) {
if (err.status == 401) {
quickConnectState.value = null
quickConnectEnabled.value = false
}
}
}
}
fun cancelQuickConnect() {
quickConnectJob?.cancel()
quickConnectState.value = null
}
fun addServer(
serverUrl: String,
callback: () -> Unit,
) {
viewModelScope.launch {
try {
val serverInfo by jellyfin
.createApi(serverUrl)
.systemApi
.getPublicSystemInfo()
val id = serverInfo.id
if (id != null) {
serverRepository.addAndChangeServer(
JellyfinServer(
id = id,
name = serverInfo.serverName,
url = serverUrl,
),
)
callback.invoke()
} else {
// TODO
}
} catch (ex: Exception) {
Timber.w(ex, "Error creating API for $serverUrl")
// TODO notify user
}
}
}
}

View file

@ -0,0 +1,70 @@
package com.github.damontecres.dolphin.ui.setup
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.JellyfinUser
@Composable
fun UserList(
users: List<JellyfinUser>,
currentUser: JellyfinUser?,
onSwitchUser: (JellyfinUser) -> Unit,
onAddUser: () -> Unit,
onRemoveUser: (JellyfinUser) -> Unit,
onSwitchServer: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
items(users) { user ->
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = user.name ?: user.id) },
supportingContent = { },
leadingContent = {
if (user.id == currentUser?.id) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "current user",
)
}
},
onClick = { onSwitchUser.invoke(user) },
onLongClick = {
// TODO dialog to remove user
},
modifier = Modifier,
)
}
item {
HorizontalDivider()
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = "Add User") },
supportingContent = { },
onClick = { onAddUser.invoke() },
modifier = Modifier,
)
}
item {
HorizontalDivider()
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = "Switch servers") },
supportingContent = { },
onClick = { onSwitchServer.invoke() },
modifier = Modifier,
)
}
}
}