mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Support switching users & servers
This commit is contained in:
parent
d3eeb67877
commit
d7dde352bc
16 changed files with 787 additions and 226 deletions
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue