Refactoring switching server or user (#205)

Now when initiating a user or server switch, you can't back out of it
and must select a user/server. This will be useful when PIN code are
implemented down the road.

The app will now disconnect the web socket when backgrounded.

Also lots of internal refactoring
This commit is contained in:
damontecres 2025-11-12 17:44:16 -05:00 committed by GitHub
parent 2e3e4e3f5f
commit 58395e9adf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 442 additions and 277 deletions

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -96,22 +97,19 @@ class MainActivity : AppCompatActivity() {
) {
var isRestoringSession by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
if (appPreferences.currentServerId.isNotBlank() && appPreferences.currentUserId.isNotBlank()) {
try {
serverRepository.restoreSession(
appPreferences.currentServerId?.toUUIDOrNull(),
appPreferences.currentUserId?.toUUIDOrNull(),
)
} catch (ex: Exception) {
Timber.e(ex, "Exception restoring session")
}
Timber.d("MainActivity session restored")
try {
serverRepository.restoreSession(
appPreferences.currentServerId?.toUUIDOrNull(),
appPreferences.currentUserId?.toUUIDOrNull(),
)
} catch (ex: Exception) {
Timber.e(ex, "Exception restoring session")
}
isRestoringSession = false
}
val server = serverRepository.currentServer
val user = serverRepository.currentUser
val userDto = serverRepository.currentUserDto
val server by serverRepository.currentServer.observeAsState()
val user by serverRepository.currentUser.observeAsState()
val userDto by serverRepository.currentUserDto.observeAsState()
val preferences =
UserPreferences(
@ -139,8 +137,6 @@ class MainActivity : AppCompatActivity() {
val initialDestination =
if (server != null && user != null) {
Destination.Home()
} else if (server != null) {
Destination.UserList
} else {
Destination.ServerList
}
@ -155,9 +151,6 @@ class MainActivity : AppCompatActivity() {
}
}
}
LaunchedEffect(server, user) {
serverEventListener.init(server, user)
}
ApplicationContent(
user = user,
server = server,

View file

@ -25,7 +25,7 @@ class ItemPlaybackRepository
itemId: UUID,
item: BaseItem,
): ChosenStreams? =
serverRepository.currentUser?.let { user ->
serverRepository.currentUser.value?.let { user ->
val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId)
if (itemPlayback != null) {
Timber.v("Got itemPlayback for %s", itemId)
@ -72,7 +72,7 @@ class ItemPlaybackRepository
sourceId: UUID,
): ItemPlayback? =
withContext(Dispatchers.IO) {
serverRepository.currentUser?.let { user ->
serverRepository.currentUser.value?.let { user ->
val itemPlayback =
ItemPlayback(
userId = user.rowId,
@ -89,7 +89,7 @@ class ItemPlaybackRepository
itemPlayback: ItemPlayback?,
trackIndex: Int,
type: MediaStreamType,
) = serverRepository.currentUser?.let { user ->
) = serverRepository.currentUser.value?.let { user ->
var toSave =
itemPlayback ?: ItemPlayback(
userId = user.rowId,
@ -113,7 +113,9 @@ class ItemPlaybackRepository
val toSave =
if (itemPlayback.userId < 0) {
val userRowId =
serverRepository.currentUser?.rowId?.takeIf { it >= 0 }
serverRepository.currentUser.value
?.rowId
?.takeIf { it >= 0 }
?: throw IllegalStateException("Trying to save an ItemPlayback without a user, but there is no current user")
itemPlayback.copy(userId = userRowId)
} else {

View file

@ -27,7 +27,7 @@ class NavDrawerItemRepository
val user = serverRepository.currentUser
val userViews =
api.userViewsApi
.getUserViews(userId = user?.id)
.getUserViews(userId = user.value?.id)
.content.items
val builtins = listOf(NavDrawerItem.Favorites)
@ -46,7 +46,7 @@ class NavDrawerItemRepository
}
suspend fun getFilteredNavDrawerItems(items: List<NavDrawerItem>): List<NavDrawerItem> {
val user = serverRepository.currentUser
val user = serverRepository.currentUser.value
val navDrawerPins =
user
?.let {

View file

@ -2,14 +2,15 @@ package com.github.damontecres.wholphin.data
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@ -41,12 +42,12 @@ class ServerRepository
) {
private val sharedPreferences = getServerSharedPreferences(context)
private var _currentServer by mutableStateOf<JellyfinServer?>(null)
val currentServer get() = _currentServer
private var _currentUser by mutableStateOf<JellyfinUser?>(null)
val currentUser get() = _currentUser
private var _currentUserDto by mutableStateOf<UserDto?>(null)
val currentUserDto get() = _currentUserDto
private var _current = MutableLiveData<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current
val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server }
val currentUser: LiveData<JellyfinUser?> get() = _current.map { it?.user }
val currentUserDto: LiveData<UserDto?> get() = _current.map { it?.userDto }
/**
* Adds a server to the app database and updated the [ApiClient] to the server's URL
@ -57,19 +58,8 @@ class ServerRepository
withContext(Dispatchers.IO) {
serverDao.addOrUpdateServer(server)
}
changeServer(server)
}
/**
* Updates the [ApiClient] to the server's URL
*
* The current user is removed
*/
fun changeServer(server: JellyfinServer) {
apiClient.update(baseUrl = server.url, accessToken = null)
_currentServer = server
_currentUser = null
_currentUserDto = null
_current.setValueOnMain(null)
}
/**
@ -109,9 +99,7 @@ class ServerRepository
}.build()
}
withContext(Dispatchers.Main) {
_currentUserDto = userDto
_currentServer = updatedServer
_currentUser = updatedUser
_current.value = CurrentUser(updatedServer, updatedUser, userDto)
}
sharedPreferences.edit(true) {
putString(SERVER_URL_KEY, updatedServer.url)
@ -127,6 +115,7 @@ class ServerRepository
userId: UUID?,
): Boolean {
if (serverId == null || userId == null) {
_current.setValueOnMain(null)
return false
}
val serverAndUsers =
@ -186,8 +175,9 @@ class ServerRepository
suspend fun removeUser(user: JellyfinUser) {
if (currentUser == user) {
_currentUser = null
_currentUserDto = null
withContext(Dispatchers.Main) {
_current.value = null
}
userPreferencesDataStore.updateData {
it
.toBuilder()
@ -204,9 +194,9 @@ class ServerRepository
suspend fun removeServer(server: JellyfinServer) {
if (currentServer == server) {
_currentServer = null
_currentUser = null
_currentUserDto = null
withContext(Dispatchers.Main) {
_current.value = null
}
userPreferencesDataStore.updateData {
it
.toBuilder()
@ -222,6 +212,18 @@ class ServerRepository
}
}
suspend fun switchServerOrUser() {
apiClient.update(baseUrl = null, accessToken = null)
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = ""
currentUserId = ""
}.build()
}
}
companion object {
fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(
@ -233,3 +235,9 @@ class ServerRepository
const val ACCESS_TOKEN_KEY = "current.accessToken"
}
}
data class CurrentUser(
val server: JellyfinServer,
val user: JellyfinUser,
val userDto: UserDto,
)

View file

@ -1,3 +1,5 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model
import androidx.room.ColumnInfo
@ -8,9 +10,13 @@ import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Entity(tableName = "servers")
@Serializable
data class JellyfinServer(
@PrimaryKey val id: UUID,
val name: String?,

View file

@ -145,7 +145,7 @@ class PlaylistCreator
name: String,
initialItems: List<UUID>,
): UUID? =
serverRepository.currentUser?.let { user ->
serverRepository.currentUser.value?.let { user ->
api.playlistsApi
.createPlaylist(
CreatePlaylistDto(

View file

@ -95,7 +95,7 @@ object AppModule {
.addInterceptor {
val request = it.request()
val newRequest =
serverRepository.currentUser?.accessToken?.let { token ->
serverRepository.currentUser.value?.accessToken?.let { token ->
request
.newBuilder()
.addHeader(
@ -105,7 +105,7 @@ object AppModule {
clientVersion = clientInfo.version,
deviceId = deviceInfo.id,
deviceName = deviceInfo.name,
accessToken = serverRepository.currentUser?.accessToken,
accessToken = token,
),
).build()
}
@ -149,7 +149,7 @@ object AppModule {
appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope,
) = object : RememberTabManager {
fun key(itemId: String) = "${serverRepository.currentServer?.id}_${serverRepository.currentUser?.id}_$itemId"
fun key(itemId: String) = "${serverRepository.currentServer.value?.id}_${serverRepository.currentUser.value?.id}_$itemId"
override fun getRememberedTab(
preferences: UserPreferences,

View file

@ -108,7 +108,7 @@ class CollectionFolderViewModel
val sortAndDirection =
if (initialSortAndDirection == null) {
serverRepository.currentUser?.let { user ->
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)?.sortAndDirection
} ?: SortAndDirection.DEFAULT
} else {
@ -123,7 +123,7 @@ class CollectionFolderViewModel
recursive: Boolean,
filter: GetItemsFilter,
) {
serverRepository.currentUser?.let { user ->
serverRepository.currentUser.value?.let { user ->
viewModelScope.launch(Dispatchers.IO) {
val libraryDisplayInfo =
LibraryDisplayInfo(

View file

@ -133,7 +133,7 @@ class RecommendedMovieViewModel
val suggestionsRequest =
GetSuggestionsRequest(
userId = serverRepository.currentUser?.id,
userId = serverRepository.currentUser.value?.id,
type = listOf(BaseItemKind.MOVIE),
)
val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope)

View file

@ -161,7 +161,7 @@ class RecommendedTvShowViewModel
val suggestionsRequest =
GetSuggestionsRequest(
userId = serverRepository.currentUser?.id,
userId = serverRepository.currentUser.value?.id,
type = listOf(BaseItemKind.SERIES),
)
val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope)

View file

@ -66,7 +66,7 @@ class LiveTvCollectionViewModel
viewModelScope.launchIO {
val folders =
api.liveTvApi
.getRecordingFolders(userId = serverRepository.currentUser?.id)
.getRecordingFolders(userId = serverRepository.currentUser.value?.id)
.content.items
.map { TabId(it.name ?: "Recordings", it.id) }
this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders)

View file

@ -61,7 +61,7 @@ class DebugViewModel
init {
viewModelScope.launchIO {
serverRepository.currentUser?.rowId?.let {
serverRepository.currentUser.value?.rowId?.let {
val results = itemPlaybackDao.getItems(it)
withContext(Dispatchers.Main) {
itemPlaybacks.value = results

View file

@ -303,7 +303,7 @@ fun MovieDetails(
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto
viewModel.serverRepository.currentUserDto.value
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },

View file

@ -147,7 +147,7 @@ class MovieViewModel
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id,
userId = serverRepository.currentUser.value?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,

View file

@ -346,7 +346,7 @@ fun SeriesOverview(
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto
viewModel.serverRepository.currentUserDto.value
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },

View file

@ -121,7 +121,7 @@ class SeriesViewModel
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id,
userId = serverRepository.currentUser.value?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,

View file

@ -77,7 +77,7 @@ class HomeViewModel
) {
Timber.d("init HomeViewModel")
serverRepository.currentUserDto?.let { userDto ->
serverRepository.currentUserDto.value?.let { userDto ->
val includedIds =
navDrawerItemRepository
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())

View file

@ -10,6 +10,7 @@ import androidx.navigation3.ui.NavDisplay
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import org.jellyfin.sdk.model.api.DeviceProfile
/**
@ -45,7 +46,7 @@ fun ApplicationContent(
deviceProfile = deviceProfile,
modifier = modifier.fillMaxSize(),
)
} else {
} else if (user != null && server != null) {
NavDrawer(
destination = key,
preferences = preferences,
@ -54,6 +55,8 @@ fun ApplicationContent(
server = server,
modifier = modifier,
)
} else {
ErrorMessage("Trying to go to $key without a user logged in", null)
}
}
},

View file

@ -6,6 +6,7 @@ import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@ -29,7 +30,9 @@ sealed class Destination(
data object ServerList : Destination(true)
@Serializable
data object UserList : Destination(true)
data class UserList(
val server: JellyfinServer,
) : Destination(true)
@Serializable
data class Home(

View file

@ -54,7 +54,7 @@ fun DestinationContent(
)
Destination.ServerList -> SwitchServerContent(modifier)
Destination.UserList -> SwitchUserContent(modifier)
is Destination.UserList -> SwitchUserContent(destination.server, modifier)
is Destination.Settings ->
PreferencesPage(

View file

@ -84,7 +84,6 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber
import java.time.LocalTime
import java.util.UUID
import javax.inject.Inject
@ -141,7 +140,7 @@ class NavDrawerViewModel
null
}
}
Timber.v("Found $index => $key")
// Timber.v("Found $index => $key")
if (index != null) {
selectedIndex.setValueOnMain(index)
break
@ -198,8 +197,8 @@ data class ServerNavDrawerItem(
fun NavDrawer(
destination: Destination,
preferences: UserPreferences,
user: JellyfinUser?,
server: JellyfinServer?,
user: JellyfinUser,
server: JellyfinServer,
deviceProfile: DeviceProfile,
modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel =
@ -319,7 +318,11 @@ fun NavDrawer(
selected = false,
interactionSource = interactionSource,
onClick = {
viewModel.navigationManager.navigateTo(Destination.UserList)
viewModel.navigationManager.navigateToFromDrawer(
Destination.UserList(
server,
),
)
},
modifier = Modifier.animateItem(),
)

View file

@ -28,7 +28,12 @@ class NavigationManager
*/
fun navigateToFromDrawer(destination: Destination) {
goToHome()
backStack.add(destination)
if (destination == Destination.ServerList || destination is Destination.UserList) {
backStack.add(destination)
(0..<backStack.size - 1).forEach { _ -> backStack.removeAt(0) }
} else {
backStack.add(destination)
}
log()
}

View file

@ -268,13 +268,14 @@ class PlaybackViewModel
if (itemPlayback != null) {
itemPlayback
} else {
val user = serverRepository.currentUser!!
itemPlaybackDao.getItem(user, base.id)?.let {
Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) {
it
} else {
null
serverRepository.currentUser.value?.let { user ->
itemPlaybackDao.getItem(user, base.id)?.let {
Timber.v("Fetched itemPlayback from DB: %s", it)
if (it.sourceId != null) {
it
} else {
null
}
}
}
}

View file

@ -49,7 +49,7 @@ class PreferencesViewModel
init {
viewModelScope.launchIO {
serverRepository.currentUser?.let { user ->
serverRepository.currentUser.value?.let { user ->
allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems()
val pins = serverPreferencesDao.getNavDrawerPinnedItems(user)
val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) }
@ -60,7 +60,7 @@ class PreferencesViewModel
fun updatePins(newSelectedItems: List<NavDrawerItem>) {
viewModelScope.launchIO(ExceptionHandler(true)) {
serverRepository.currentUser?.let { user ->
serverRepository.currentUser.value?.let { user ->
val disabledItems =
mutableListOf<NavDrawerItem>().apply {
addAll(allNavDrawerItems)

View file

@ -49,6 +49,7 @@ fun ServerList(
servers: List<JellyfinServer>,
connectionStatus: Map<UUID, ServerConnectionStatus>,
onSwitchServer: (JellyfinServer) -> Unit,
onTestServer: (JellyfinServer) -> Unit,
onAddServer: () -> Unit,
onRemoveServer: (JellyfinServer) -> Unit,
allowAdd: Boolean,
@ -61,7 +62,7 @@ fun ServerList(
items(servers) { server ->
val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending
ListItem(
enabled = status == ServerConnectionStatus.Success,
enabled = true,
selected = false,
headlineContent = { Text(text = server.name?.ifBlank { null } ?: server.url) },
supportingContent = { if (server.name.isNotNullOrBlank()) Text(text = server.url) },
@ -77,12 +78,18 @@ fun ServerList(
Icon(
imageVector = Icons.Default.Warning,
contentDescription = status.message,
tint = MaterialTheme.colorScheme.error,
tint = MaterialTheme.colorScheme.errorContainer,
)
}
}
},
onClick = { onSwitchServer.invoke(server) },
onClick = {
when (status) {
ServerConnectionStatus.Success -> onSwitchServer.invoke(server)
ServerConnectionStatus.Pending -> {}
is ServerConnectionStatus.Error -> onTestServer.invoke(server)
}
},
onLongClick = {
if (allowDelete) {
showDeleteDialog = server

View file

@ -43,9 +43,8 @@ import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun SwitchServerContent(
modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel = hiltViewModel(),
viewModel: SwitchServerViewModel = hiltViewModel(),
) {
val currentServer = viewModel.serverRepository.currentServer
val servers by viewModel.servers.observeAsState(listOf())
val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
@ -90,6 +89,9 @@ fun SwitchServerContent(
onSwitchServer = {
viewModel.addServer(it.url)
},
onTestServer = {
viewModel.testServer(it)
},
onAddServer = {
showAddServer = true
},
@ -142,6 +144,9 @@ fun SwitchServerContent(
onSwitchServer = {
viewModel.addServer(it.url)
},
onTestServer = {
viewModel.testServer(it)
},
onAddServer = {},
onRemoveServer = {},
allowAdd = false,

View file

@ -0,0 +1,214 @@
package com.github.damontecres.wholphin.ui.setup
import android.content.Context
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class SwitchServerViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
val jellyfin: Jellyfin,
val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao,
val navigationManager: NavigationManager,
) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())
val serverQuickConnect = MutableLiveData<Map<UUID, Boolean>>(mapOf())
val discoveredServers = MutableLiveData<List<JellyfinServer>>(listOf())
val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending)
fun clearAddServerState() {
addServerState.value = LoadingState.Pending
}
init {
init()
}
fun init() {
viewModelScope.launchIO {
withContext(Dispatchers.Main) {
serverStatus.value = mapOf()
serverQuickConnect.value = mapOf()
}
val allServers =
serverDao
.getServers()
.map { it.server }
.sortedWith(compareBy<JellyfinServer> { it.name }.thenBy { it.url })
withContext(Dispatchers.Main) {
servers.value = allServers
}
allServers.forEach { server ->
internalTestServer(server)
}
}
}
fun testServer(server: JellyfinServer) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Pending,
)
}
viewModelScope.launchIO {
delay(1000)
val result = internalTestServer(server)
if (result == ServerConnectionStatus.Success) {
showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT)
} else if (result is ServerConnectionStatus.Error) {
showToast(context, result.message ?: "Error", Toast.LENGTH_SHORT)
}
}
}
private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus =
try {
jellyfin
.createApi(
server.url,
httpClientOptions =
HttpClientOptions(
requestTimeout = 6.seconds,
connectTimeout = 6.seconds,
socketTimeout = 6.seconds,
),
).systemApi
.getPublicSystemInfo()
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Success,
)
}
}
ServerConnectionStatus.Success
} catch (ex: Exception) {
val status = ServerConnectionStatus.Error(ex.localizedMessage)
Timber.w(ex, "Error checking server ${server.url}")
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
status,
)
}
}
status
}
fun addServer(serverUrl: String) {
addServerState.value = LoadingState.Loading
viewModelScope.launchIO {
try {
val serverInfo by jellyfin
.createApi(serverUrl)
.systemApi
.getPublicSystemInfo()
val id = serverInfo.id?.toUUIDOrNull()
if (id != null && serverInfo.startupWizardCompleted == true) {
val server =
JellyfinServer(
id = id,
name = serverInfo.serverName,
url = serverUrl,
)
serverRepository.addAndChangeServer(server)
val quickConnect =
jellyfin
.createApi(serverUrl)
.quickConnectApi
.getQuickConnectEnabled()
.content
withContext(Dispatchers.Main) {
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(id, quickConnect)
}
}
withContext(Dispatchers.Main) {
addServerState.value = LoadingState.Success
navigationManager.navigateTo(Destination.UserList(server))
}
} else {
withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error("Server returned invalid response")
}
}
} catch (ex: Exception) {
Timber.w(ex, "Error creating API for $serverUrl")
withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error(exception = ex)
}
}
}
}
fun removeServer(server: JellyfinServer) {
viewModelScope.launchIO {
serverRepository.removeServer(server)
init()
}
}
fun discoverServers() {
viewModelScope.launchIO {
jellyfin.discovery.discoverLocalServers().collect { server ->
val newServerList =
discoveredServers.value!!
.toMutableList()
.apply {
add(
JellyfinServer(
server.id.toUUID(),
server.name,
server.address,
),
)
}
withContext(Dispatchers.Main) {
discoveredServers.value = newServerList
}
}
}
}
}

View file

@ -39,6 +39,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.EditTextBox
@ -49,17 +50,20 @@ import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun SwitchUserContent(
currentServer: JellyfinServer,
modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel = hiltViewModel(),
viewModel: SwitchUserViewModel =
hiltViewModel<SwitchUserViewModel, SwitchUserViewModel.Factory>(
creationCallback = { it.create(currentServer) },
),
) {
val context = LocalContext.current
val currentServer = viewModel.serverRepository.currentServer
val currentUser = viewModel.serverRepository.currentUser
// val currentServer by viewModel.serverRepository.currentServer.observeAsState()
val currentUser by viewModel.serverRepository.currentUser.observeAsState()
val users by viewModel.users.observeAsState(listOf())
val serverQuickConnect by viewModel.serverQuickConnect.observeAsState(mapOf())
val quickConnectEnabled = currentServer?.let { serverQuickConnect[it.id] ?: false } ?: false
val quickConnectEnabled by viewModel.serverQuickConnect.observeAsState(false)
val quickConnect by viewModel.quickConnectState.observeAsState(null)
var showAddUser by remember { mutableStateOf(false) }
@ -109,7 +113,7 @@ fun SwitchUserContent(
users = users,
currentUser = currentUser,
onSwitchUser = { user ->
viewModel.switchUser(server, user)
viewModel.switchUser(user)
},
onAddUser = { showAddUser = true },
onRemoveUser = { user ->

View file

@ -8,13 +8,18 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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.HttpClientOptions
@ -25,42 +30,41 @@ 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 org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
@HiltViewModel(assistedFactory = SwitchUserViewModel.Factory::class)
class SwitchUserViewModel
@Inject
@AssistedInject
constructor(
val jellyfin: Jellyfin,
val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao,
val navigationManager: NavigationManager,
@Assisted val server: JellyfinServer,
) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())
val serverQuickConnect = MutableLiveData<Map<UUID, Boolean>>(mapOf())
@AssistedFactory
interface Factory {
fun create(server: JellyfinServer): SwitchUserViewModel
}
init {
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
serverRepository.switchServerOrUser()
}
}
val serverQuickConnect = MutableLiveData<Boolean>(false)
val users = MutableLiveData<List<JellyfinUser>>(listOf())
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
private var quickConnectJob: Job? = null
val discoveredServers = MutableLiveData<List<JellyfinServer>>(listOf())
val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending)
val switchUserState = MutableLiveData<LoadingState>(LoadingState.Pending)
val loginAttempts = MutableLiveData(0)
fun clearAddServerState() {
addServerState.value = LoadingState.Pending
}
fun clearSwitchUserState() {
switchUserState.value = LoadingState.Pending
}
@ -74,91 +78,47 @@ class SwitchUserViewModel
}
fun init() {
quickConnectJob?.cancel()
viewModelScope.launchIO {
quickConnectJob?.cancel()
users.setValueOnMain(listOf())
val serverUsers =
serverDao.getServer(server.id)?.users?.sortedBy { it.name } ?: listOf()
withContext(Dispatchers.Main) {
users.value = listOf()
serverStatus.value = mapOf()
serverQuickConnect.value = mapOf()
}
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,
httpClientOptions =
HttpClientOptions(
requestTimeout = 6.seconds,
connectTimeout = 6.seconds,
socketTimeout = 6.seconds,
),
).systemApi
.getPublicSystemInfo()
val quickConnect by
jellyfin
.createApi(server.url)
.quickConnectApi
.getQuickConnectEnabled()
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Success,
)
}
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(server.id, quickConnect)
}
}
} catch (ex: Exception) {
Timber.w(ex, "Error checking quick connect for server ${server.url}")
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Error(ex.localizedMessage),
)
}
}
}
users.setValueOnMain(serverUsers)
}
}
viewModelScope.launchIO {
serverRepository.currentServer?.let {
val quickConnect =
try {
jellyfin
.createApi(
server.url,
httpClientOptions =
HttpClientOptions(
requestTimeout = 6.seconds,
connectTimeout = 6.seconds,
socketTimeout = 6.seconds,
),
).systemApi
.getPublicSystemInfo()
val quickConnect by
jellyfin
.createApi(it.url)
.createApi(server.url)
.quickConnectApi
.getQuickConnectEnabled()
.content
val serverUsers = serverDao.getServer(it.id)?.users?.sortedBy { it.name } ?: listOf()
withContext(Dispatchers.Main) {
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(it.id, quickConnect)
}
users.value = serverUsers
serverQuickConnect.value = quickConnect
}
} catch (ex: Exception) {
Timber.w(ex, "Error checking quick connect for server ${server.url}")
withContext(Dispatchers.Main) {
serverQuickConnect.value = false
}
}
}
}
fun switchUser(
server: JellyfinServer,
user: JellyfinUser,
) {
fun switchUser(user: JellyfinUser) {
viewModelScope.launchIO {
try {
serverRepository.changeUser(server, user)
@ -242,10 +202,7 @@ class SwitchUserViewModel
if (ex is InvalidStatusException && ex.status == 401) {
withContext(Dispatchers.Main) {
quickConnectState.value = null
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(server.id, false)
}
serverQuickConnect.value = false
}
}
setError("Error with Quick Connect", ex)
@ -258,55 +215,6 @@ class SwitchUserViewModel
quickConnectState.value = null
}
fun addServer(serverUrl: String) {
addServerState.value = LoadingState.Loading
viewModelScope.launchIO {
try {
val serverInfo by jellyfin
.createApi(serverUrl)
.systemApi
.getPublicSystemInfo()
val id = serverInfo.id?.toUUIDOrNull()
if (id != null && serverInfo.startupWizardCompleted == true) {
serverRepository.addAndChangeServer(
JellyfinServer(
id = id,
name = serverInfo.serverName,
url = serverUrl,
),
)
val quickConnect =
jellyfin
.createApi(serverUrl)
.quickConnectApi
.getQuickConnectEnabled()
.content
withContext(Dispatchers.Main) {
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(id, quickConnect)
}
}
withContext(Dispatchers.Main) {
addServerState.value = LoadingState.Success
navigationManager.navigateTo(Destination.UserList)
}
} else {
withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error("Server returned invalid response")
}
}
} catch (ex: Exception) {
Timber.w(ex, "Error creating API for $serverUrl")
withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error(exception = ex)
}
}
}
}
fun removeUser(user: JellyfinUser) {
viewModelScope.launchIO {
serverRepository.removeUser(user)
@ -318,35 +226,6 @@ class SwitchUserViewModel
}
}
fun removeServer(server: JellyfinServer) {
viewModelScope.launchIO {
serverRepository.removeServer(server)
init()
}
}
fun discoverServers() {
viewModelScope.launchIO {
jellyfin.discovery.discoverLocalServers().collect { server ->
val newServerList =
discoveredServers.value!!
.toMutableList()
.apply {
add(
JellyfinServer(
server.id.toUUID(),
server.name,
server.address,
),
)
}
withContext(Dispatchers.Main) {
discoveredServers.value = newServerList
}
}
}
}
private suspend fun setError(
msg: String? = null,
ex: Exception? = null,

View file

@ -2,13 +2,17 @@ package com.github.damontecres.wholphin.util
import android.content.Context
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -20,24 +24,36 @@ import org.jellyfin.sdk.model.api.GeneralCommandType
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@ActivityScoped
class ServerEventListener
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:ActivityContext private val context: Context,
private val api: ApiClient,
@param:IoCoroutineScope private val scope: CoroutineScope,
) {
private val serverRepository: ServerRepository,
) : DefaultLifecycleObserver {
private val activity = (context as AppCompatActivity)
private var listenJob: Job? = null
init {
activity.lifecycle.addObserver(this)
serverRepository.current.observe(activity) {
Timber.d("New user/server: %s", it)
listenJob?.cancel()
if (it != null) {
init(it.server, it.user)
}
}
}
fun init(
server: JellyfinServer?,
user: JellyfinUser?,
) {
if (server != null && user != null && api.baseUrl != null && api.accessToken != null) {
scope.launchIO {
(context as AppCompatActivity).lifecycleScope.launchIO {
api.sessionApi.postCapabilities(
playableMediaTypes = listOf(MediaType.VIDEO),
supportedCommands =
@ -53,6 +69,7 @@ class ServerEventListener
}
fun setupListeners() {
serverRepository.currentUser
Timber.v("Subscribing to WebSocket")
listenJob?.cancel()
listenJob =
@ -73,6 +90,20 @@ class ServerEventListener
.joinToString("\n")
showToast(context, toast, Toast.LENGTH_LONG)
}
}.launchIn(scope)
}.launchIn(activity.lifecycleScope)
}
override fun onResume(owner: LifecycleOwner) {
serverRepository.current.value?.let { init(it.server, it.user) }
}
override fun onPause(owner: LifecycleOwner) {
Timber.v("Cancelling WebSocket")
listenJob?.cancel()
}
override fun onStop(owner: LifecycleOwner) {
Timber.v("Cancelling WebSocket")
listenJob?.cancel()
}
}

View file

@ -147,6 +147,7 @@
<string name="italic_font">Italicize font</string>
<string name="font">Font</string>
<string name="background">Background</string>
<string name="success">Success</string>
<plurals name="downloads">
<item quantity="zero">%s downloads</item>