diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 52a63020..41d24b3c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin import android.os.Bundle import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -12,18 +13,19 @@ import androidx.compose.runtime.CompositionLocalProvider 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 import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope -import androidx.navigation3.runtime.rememberNavBackStack +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface @@ -33,24 +35,30 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.AppUpgradeHandler +import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.RefreshRateService -import com.github.damontecres.wholphin.services.ServerEventListener +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent -import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setup.SwitchServerContent +import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -59,8 +67,7 @@ import javax.inject.Inject @AndroidEntryPoint class MainActivity : AppCompatActivity() { - @Inject - lateinit var serverRepository: ServerRepository + private val viewModel: MainActivityViewModel by viewModels() @Inject lateinit var userPreferencesDataStore: DataStore @@ -72,27 +79,26 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var navigationManager: NavigationManager + @Inject + lateinit var setupNavigationManager: SetupNavigationManager + @Inject lateinit var updateChecker: UpdateChecker @Inject lateinit var appUpgradeHandler: AppUpgradeHandler - @Inject - lateinit var serverEventListener: ServerEventListener - @Inject lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver - @Inject - lateinit var deviceProfileService: DeviceProfileService - @Inject lateinit var imageUrlService: ImageUrlService @Inject lateinit var refreshRateService: RefreshRateService + private var signInAuto = true + @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -109,9 +115,13 @@ class MainActivity : AppCompatActivity() { window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate } } } + viewModel.appStart() setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) appPreferences?.let { appPreferences -> + LaunchedEffect(appPreferences.signInAutomatically) { + signInAuto = appPreferences.signInAutomatically + } CoilConfig( diskCacheSizeBytes = appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let { @@ -140,100 +150,85 @@ class MainActivity : AppCompatActivity() { .background(MaterialTheme.colorScheme.background), shape = RectangleShape, ) { - var isRestoringSession by remember { mutableStateOf(true) } - LaunchedEffect(Unit) { - // TODO PIN-related -// if (appPreferences.signInAutomatically) { - try { - serverRepository.restoreSession( - appPreferences.currentServerId?.toUUIDOrNull(), - appPreferences.currentUserId?.toUUIDOrNull(), - ) - } catch (ex: Exception) { - Timber.e(ex, "Exception restoring session") - } -// } - isRestoringSession = false - } - val current by serverRepository.current.observeAsState() +// val backStack = rememberNavBackStack(SetupDestination.Loading) +// setupNavigationManager.backStack = backStack + val backStack = setupNavigationManager.backStack + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = { key -> + key as SetupDestination + NavEntry(key) { + when (key) { + SetupDestination.Loading -> { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } - val preferences = - UserPreferences( - appPreferences, - current?.userDto?.configuration ?: DefaultUserConfiguration, - ) + SetupDestination.ServerList -> { + SwitchServerContent(Modifier.fillMaxSize()) + } - if (isRestoringSession) { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } else { - // TODO PIN-related -// DisposableEffect(Unit) { -// onDispose { -// if (!appPreferences.signInAutomatically || current?.user?.hasPin == true) { -// serverRepository.closeSession() -// } -// } -// } - key(current?.server?.id, current?.user?.id) { - // TODO PIN-related -// LaunchedEffect(current?.user?.pin) { -// if (current?.user?.pin?.isNotNullOrBlank() == true) { -// // If user has a pin, then obscure the window in previews -// window?.setFlags( -// WindowManager.LayoutParams.FLAG_SECURE, -// WindowManager.LayoutParams.FLAG_SECURE, -// ) -// } else { -// window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) -// } -// } - val initialDestination = - when { - current != null -> Destination.Home() + is SetupDestination.UserList -> { + SwitchUserContent( + currentServer = key.server, + Modifier.fillMaxSize(), + ) + } - // TODO PIN-related - // !appPreferences.signInAutomatically -> Destination.ServerList - - else -> Destination.ServerList - } - val backStack = rememberNavBackStack(initialDestination) - navigationManager.backStack = backStack - if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { - LaunchedEffect(Unit) { - try { - updateChecker.maybeShowUpdateToast(appPreferences.updateUrl) - } catch (ex: Exception) { - Timber.w(ex, "Failed to check for update") + is SetupDestination.AppContent -> { + val current = key.current + ProvideLocalClock { + if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { + LaunchedEffect(Unit) { + try { + updateChecker.maybeShowUpdateToast( + appPreferences.updateUrl, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Exception during update check", + ) + } + } + } + val appPreferences by userPreferencesDataStore.data.collectAsState( + appPreferences, + ) + val preferences = + remember(appPreferences, current) { + UserPreferences( + appPreferences, + current.userDto.configuration + ?: DefaultUserConfiguration, + ) + } + ApplicationContent( + user = current.user, + server = current.server, + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } } } } - LaunchedEffect(current, preferences) { - withContext(Dispatchers.IO) { - deviceProfileService.getOrCreateDeviceProfile( - preferences.appPreferences.playbackPreferences, - current?.server?.serverVersion, - ) - } - } - ProvideLocalClock { - ApplicationContent( - user = current?.user, - server = current?.server, - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) - } - } - } + }, + ) } } } @@ -250,12 +245,69 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() - // TODO PIN-related + Timber.i("onRestart") + viewModel.appStart() // val signInAutomatically = // runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true -// Timber.i("onRestart: signInAutomatically=$signInAutomatically") -// if (!signInAutomatically || serverRepository.currentUser.value?.hasPin == true) { + +// // TODO PIN-related +// // if (!signInAutomatically || serverRepository.currentUser.value?.hasPin == true) { +// if (!signInAutomatically) { // serverRepository.closeSession() // } } } + +@HiltViewModel +class MainActivityViewModel + @Inject + constructor( + private val preferences: DataStore, + private val serverRepository: ServerRepository, + private val navigationManager: SetupNavigationManager, + private val deviceProfileService: DeviceProfileService, + private val backdropService: BackdropService, + ) : ViewModel() { + fun appStart() { + viewModelScope.launch { + val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + if (prefs.signInAutomatically) { + val current = + withContext(Dispatchers.IO) { + serverRepository.restoreSession( + prefs.currentServerId?.toUUIDOrNull(), + prefs.currentUserId?.toUUIDOrNull(), + ) + } + if (current != null) { + // Restored + navigationManager.navigateTo(SetupDestination.AppContent(current)) + } else { + // Did not restore + navigationManager.navigateTo(SetupDestination.ServerList) + } + } else { + navigationManager.navigateTo(SetupDestination.Loading) + backdropService.clearBackdrop() + val currentServerId = prefs.currentServerId?.toUUIDOrNull() + if (currentServerId != null) { + val currentServer = + withContext(Dispatchers.IO) { + serverRepository.serverDao.getServer(currentServerId)?.server + } + if (currentServer != null) { + navigationManager.navigateTo(SetupDestination.UserList(currentServer)) + } else { + navigationManager.navigateTo(SetupDestination.ServerList) + } + } else { + navigationManager.navigateTo(SetupDestination.ServerList) + } + } + } + viewModelScope.launchIO { + // Create the mediaCodecCapabilitiesTest if needed + deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC() + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 48bea5b3..539c8ac4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.systemApi @@ -68,44 +69,46 @@ class ServerRepository suspend fun changeUser( server: JellyfinServer, user: JellyfinUser, - ) = withContext(Dispatchers.IO) { - if (server.id != user.serverId) { - throw IllegalStateException("User is not part of the server") - } - Timber.v("Changing user to ${user.name} on ${server.url}") - apiClient.update(baseUrl = server.url, accessToken = user.accessToken) - val userDto by apiClient.userApi.getCurrentUser() - val updatedServer = - try { - val sysInfo by apiClient.systemApi.getPublicSystemInfo() - server.copy(name = sysInfo.serverName, version = sysInfo.version) - } catch (ex: Exception) { - Timber.w(ex, "Exception fetching public system info") - server + ): CurrentUser? = + withContext(Dispatchers.IO) { + if (server.id != user.serverId) { + throw IllegalStateException("User is not part of the server") } - var updatedUser = - user.copy( - id = userDto.id, - name = userDto.name, - ) - serverDao.addOrUpdateServer(updatedServer) - updatedUser = serverDao.addOrUpdateUser(updatedUser) - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = updatedServer.id.toServerString() - currentUserId = updatedUser.id.toServerString() - }.build() + Timber.v("Changing user to ${user.name} on ${server.url}") + apiClient.update(baseUrl = server.url, accessToken = user.accessToken) + val userDto by apiClient.userApi.getCurrentUser() + val updatedServer = + try { + val sysInfo by apiClient.systemApi.getPublicSystemInfo() + server.copy(name = sysInfo.serverName, version = sysInfo.version) + } catch (ex: Exception) { + Timber.w(ex, "Exception fetching public system info") + server + } + var updatedUser = + user.copy( + id = userDto.id, + name = userDto.name, + ) + serverDao.addOrUpdateServer(updatedServer) + updatedUser = serverDao.addOrUpdateUser(updatedUser) + userPreferencesDataStore.updateData { + it + .toBuilder() + .apply { + currentServerId = updatedServer.id.toServerString() + currentUserId = updatedUser.id.toServerString() + }.build() + } + withContext(Dispatchers.Main) { + _current.value = CurrentUser(updatedServer, updatedUser, userDto) + } + sharedPreferences.edit(true) { + putString(SERVER_URL_KEY, updatedServer.url) + putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) + } + return@withContext _current.value } - withContext(Dispatchers.Main) { - _current.value = CurrentUser(updatedServer, updatedUser, userDto) - } - sharedPreferences.edit(true) { - putString(SERVER_URL_KEY, updatedServer.url) - putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) - } - } /** * Restores a session for the given server & user such as when the app reopens @@ -115,10 +118,10 @@ class ServerRepository suspend fun restoreSession( serverId: UUID?, userId: UUID?, - ): Boolean { + ): CurrentUser? { if (serverId == null || userId == null) { _current.setValueOnMain(null) - return false + return null } val serverAndUsers = withContext(Dispatchers.IO) { @@ -129,13 +132,17 @@ class ServerRepository if (user != null) { // TODO pin-related // if (user != null && !user.hasPin) { - changeUser(serverAndUsers.server, user) - return true + return changeUser(serverAndUsers.server, user) } } - return false + return null } + suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? = + withContext(Dispatchers.IO) { + serverId?.let { serverDao.getServer(serverId)?.server } + } + fun closeSession() { _current.value = null } @@ -170,7 +177,7 @@ class ServerRepository ) } if (user != null) { - changeUser(server, user) + return@withContext changeUser(server, user) } else { throw IllegalArgumentException("Authentication result's user was null") } @@ -260,6 +267,7 @@ class ServerRepository } } +@Serializable data class CurrentUser( val server: JellyfinServer, val user: JellyfinUser, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt index bb733ccd..f819c7c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt @@ -14,6 +14,16 @@ import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID +@Serializable +data class CollectionFolderFilter( + val nameOverride: String? = null, + val filter: GetItemsFilter = GetItemsFilter(), + /** + * Whether to use the libray's saved sort & filter + */ + val useSavedLibraryDisplayInfo: Boolean = true, +) + @Serializable data class GetItemsFilter( val favorite: Boolean? = null, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index d8e40779..2c516272 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -41,6 +41,7 @@ data class JellyfinServer( ], indices = [Index("id", "serverId", unique = true)], ) +@Serializable data class JellyfinUser( @PrimaryKey(autoGenerate = true) val rowId: Int = 0, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 4d6154d6..6bca9d38 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -851,8 +851,7 @@ val basicPreferences = title = R.string.ui_interface, preferences = listOf( - // TODO PIN-related - // AppPreference.SignInAuto, + AppPreference.SignInAuto, AppPreference.HomePageItems, AppPreference.CombineContinueNext, AppPreference.RewatchNextUp, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index ed04c3f2..9ce70b21 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -20,7 +20,7 @@ class DeviceProfileService constructor( @param:ApplicationContext private val context: Context, ) { - private val mediaCodecCapabilitiesTest by lazy { + val mediaCodecCapabilitiesTest by lazy { // Created lazily below on the IO thread since it cn take time MediaCodecCapabilitiesTest(context) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index a387ac8c..136cb746 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -29,12 +29,7 @@ class NavigationManager */ fun navigateToFromDrawer(destination: Destination) { goToHome() - if (destination == Destination.ServerList || destination is Destination.UserList) { - backStack.add(destination) - (0.. backStack.removeAt(0) } - } else { - backStack.add(destination) - } + backStack.add(destination) log() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index 4d15d414..824342d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.model.PlaylistInfo import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.components.baseItemKinds import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.toServerString @@ -22,6 +23,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playlistsApi +import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CreatePlaylistDto @@ -64,10 +66,14 @@ class PlaylistCreator sortBy = if (shuffled) ItemSortBy.RANDOM else null, limit = Playlist.MAX_SIZE, ) - val episodes = GetEpisodesRequestHandler.execute(api, request).content.items + val episodes = + GetEpisodesRequestHandler + .execute(api, request) + .content.items + .convertAndAddParts(false) val startIndex = episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0 - return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex) + return Playlist(episodes, startIndex) } suspend fun createFromPlaylistId( @@ -82,12 +88,11 @@ class PlaylistCreator startIndex = startIndex, limit = Playlist.MAX_SIZE, ) - val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items - var baseItems = items.map { BaseItem.from(it, api) } + var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items if (shuffled) { - baseItems = baseItems.shuffled() + items = items.shuffled() } - return Playlist(baseItems, 0) + return Playlist(items.convertAndAddParts(), 0) } private suspend fun createFromCollection( @@ -120,19 +125,20 @@ class PlaylistCreator ), ) val items = - GetItemsRequestHandler.execute(api, request).content.items.map { - BaseItem.from(it, api) - } + GetItemsRequestHandler + .execute(api, request) + .content.items + .convertAndAddParts() return Playlist(items, 0) } suspend fun createFrom( item: BaseItemDto, startIndex: Int = 0, - sortAndDirection: SortAndDirection?, - shuffled: Boolean, - recursive: Boolean, - filter: GetItemsFilter, + sortAndDirection: SortAndDirection? = SortAndDirection.DEFAULT, + shuffled: Boolean = false, + recursive: Boolean = false, + filter: GetItemsFilter = GetItemsFilter(), ): PlaylistCreationResult = when (item.type) { BaseItemKind.BOX_SET, @@ -166,7 +172,7 @@ class PlaylistCreator ), ) } else { - PlaylistCreationResult.Error(null, "Episode has not seriesId") + PlaylistCreationResult.Error(null, "Episode has no seriesId") } } @@ -182,7 +188,7 @@ class PlaylistCreator ), ) } else { - PlaylistCreationResult.Error(null, "Episode has not seriesId") + PlaylistCreationResult.Error(null, "Episode has no seriesId") } } @@ -207,6 +213,26 @@ class PlaylistCreator ) } + BaseItemKind.MOVIE, + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + -> { + val list = + buildList { + add(BaseItem(item, false)) + + if (item.partCount.gt(1)) { + api.videosApi + .getAdditionalPart(item.id) + .content.items + .map { + BaseItem(it, false) + }.let(::addAll) + } + } + PlaylistCreationResult.Success(Playlist(list, 0)) + } + // Not support yet // BaseItemKind.AGGREGATE_FOLDER -> TODO() // BaseItemKind.FOLDER -> TODO() @@ -220,6 +246,20 @@ class PlaylistCreator } } + private suspend fun List.convertAndAddParts(useSeriesForPrimary: Boolean = false): List = + buildList { + this@convertAndAddParts.forEach { ep -> + add(BaseItem(ep, useSeriesForPrimary)) + if (ep.partCount.gt(1)) { + val parts = + api.videosApi.getAdditionalPart(ep.id).content.items.map { part -> + BaseItem(part, useSeriesForPrimary) + } + addAll(parts) + } + } + } + suspend fun getServerPlaylists( mediaType: MediaType?, scope: CoroutineScope, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt new file mode 100644 index 00000000..bd95ea1d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt @@ -0,0 +1,54 @@ +package com.github.damontecres.wholphin.services + +import androidx.compose.runtime.mutableStateListOf +import androidx.navigation3.runtime.NavKey +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.model.JellyfinServer +import kotlinx.serialization.Serializable +import org.acra.ACRA +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages navigating for setup + */ +@Singleton +class SetupNavigationManager + @Inject + constructor() { + var backStack: MutableList = mutableStateListOf(SetupDestination.Loading) + + /** + * Go to the specified [SetupDestination] + */ + fun navigateTo(destination: SetupDestination) { + backStack[0] = destination + log() + } + + private fun log() { + val dest = backStack.lastOrNull().toString() + Timber.i("Current setup destination: %s", dest) + ACRA.errorReporter.putCustomData("setupDestination", dest) + } + } + +@Serializable +sealed interface SetupDestination : NavKey { + @Serializable + data object Loading : SetupDestination + + @Serializable + data object ServerList : SetupDestination + + @Serializable + data class UserList( + val server: JellyfinServer, + ) : SetupDestination + + @Serializable + data class AppContent( + val current: CurrentUser, + ) : SetupDestination +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index 93ad9946..bc39c48e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -418,3 +418,10 @@ fun rememberBackDropImage(item: BaseItem): String? { val imageUrlService = LocalImageUrlService.current return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } } + +/** + * Check if this, coalescing nulls to zero, is greater than that + */ +fun Int?.gt(that: Int) = (this ?: 0) > that + +fun Int?.lt(that: Int) = (this ?: 0) < that diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt index 43d160e3..c2e863c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -65,7 +65,6 @@ fun GenreCard( .fillMaxSize() .clip(RoundedCornerShape(8.dp)), ) { - Timber.v("genre image=${genre?.imageUrl}") if (genre?.imageUrl.isNotNullOrBlank()) { AsyncImage( model = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index f52f3970..8692d447 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -58,6 +58,7 @@ import com.github.damontecres.wholphin.data.filter.PlayedFilter import com.github.damontecres.wholphin.data.filter.VideoTypeFilter import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo @@ -136,12 +137,13 @@ class CollectionFolderViewModel val viewOptions = MutableLiveData() private var useSeriesForPrimary: Boolean = true + private lateinit var collectionFilter: CollectionFolderFilter fun init( itemId: String, initialSortAndDirection: SortAndDirection?, recursive: Boolean, - filter: GetItemsFilter, + collectionFilter: CollectionFolderFilter, useSeriesForPrimary: Boolean, defaultViewOptions: ViewOptions, ): Job = @@ -151,6 +153,7 @@ class CollectionFolderViewModel context.getString(R.string.error_loading_collection, itemId), ) + Dispatchers.IO, ) { + this@CollectionFolderViewModel.collectionFilter = collectionFilter this@CollectionFolderViewModel.useSeriesForPrimary = useSeriesForPrimary this@CollectionFolderViewModel.itemId = itemId itemId.toUUIDOrNull()?.let { @@ -166,15 +169,17 @@ class CollectionFolderViewModel ) val sortAndDirection = - libraryDisplayInfo?.sortAndDirection - ?: initialSortAndDirection - ?: SortAndDirection.DEFAULT + if (collectionFilter.useSavedLibraryDisplayInfo) { + libraryDisplayInfo?.sortAndDirection + } else { + null + } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT val filterToUse = - if (libraryDisplayInfo?.filter != null) { - filter.merge(libraryDisplayInfo.filter) + if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) { + collectionFilter.filter.merge(libraryDisplayInfo.filter) } else { - filter + collectionFilter.filter } loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) @@ -185,18 +190,20 @@ class CollectionFolderViewModel newSort: SortAndDirection = this.sortAndDirection.value!!, viewOptions: ViewOptions? = this.viewOptions.value, ) { - serverRepository.currentUser.value?.let { user -> - viewModelScope.launch(Dispatchers.IO) { - val libraryDisplayInfo = - LibraryDisplayInfo( - userId = user.rowId, - itemId = itemId, - sort = newSort.sort, - direction = newSort.direction, - filter = newFilter, - viewOptions = viewOptions, - ) - libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + if (collectionFilter.useSavedLibraryDisplayInfo) { + serverRepository.currentUser.value?.let { user -> + viewModelScope.launch(Dispatchers.IO) { + val libraryDisplayInfo = + LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId, + sort = newSort.sort, + direction = newSort.direction, + filter = newFilter, + viewOptions = viewOptions, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } } } } @@ -490,7 +497,7 @@ class CollectionFolderViewModel fun CollectionFolderGrid( preferences: UserPreferences, itemId: UUID, - initialFilter: GetItemsFilter, + initialFilter: CollectionFolderFilter, recursive: Boolean, onClickItem: (Int, BaseItem) -> Unit, sortOptions: List, @@ -523,7 +530,7 @@ fun CollectionFolderGrid( fun CollectionFolderGrid( preferences: UserPreferences, itemId: String, - initialFilter: GetItemsFilter, + initialFilter: CollectionFolderFilter, recursive: Boolean, onClickItem: (Int, BaseItem) -> Unit, sortOptions: List, @@ -550,7 +557,7 @@ fun CollectionFolderGrid( ) } val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) - val filter by viewModel.filter.observeAsState(initialFilter) + val filter by viewModel.filter.observeAsState(initialFilter.filter) val loading by viewModel.loading.observeAsState(LoadingState.Loading) val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() @@ -574,11 +581,17 @@ fun CollectionFolderGrid( LoadingState.Success -> { pager?.let { pager -> + val title = + initialFilter.nameOverride + ?: item?.name + ?: item?.data?.collectionType?.name + ?: stringResource(R.string.collection) Box(modifier = modifier) { CollectionFolderGridContent( - preferences, - item, - pager, + preferences = preferences, + item = item, + title = title, + pager = pager, sortAndDirection = sortAndDirection!!, modifier = Modifier.fillMaxSize(), onClickItem = onClickItem, @@ -699,6 +712,7 @@ fun CollectionFolderGrid( fun CollectionFolderGridContent( preferences: UserPreferences, item: BaseItem?, + title: String, pager: List, sortAndDirection: SortAndDirection, onClickItem: (Int, BaseItem) -> Unit, @@ -722,7 +736,6 @@ fun CollectionFolderGridContent( onFilterChange: (GetItemsFilter) -> Unit = {}, ) { val context = LocalContext.current - val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) var showHeader by rememberSaveable { mutableStateOf(true) } var showViewOptions by rememberSaveable { mutableStateOf(false) } @@ -833,8 +846,7 @@ fun CollectionFolderGridContent( item = focusedItem, modifier = Modifier - .fillMaxWidth(.6f) -// .fillMaxHeight(.25f) + .fillMaxWidth() .height(140.dp) .padding(16.dp), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt index 4b04849c..2c9b1d78 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt @@ -20,14 +20,17 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.tv.material3.LocalTextStyle @@ -54,22 +57,24 @@ fun DotSeparatedRow( text = text, style = textStyle, color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, ) if (communityRating != null || criticRating != null || index != texts.lastIndex) { Dot() } } + val height = with(LocalDensity.current) { textStyle.fontSize.toDp() } communityRating?.let { SimpleStarRating( communityRating = it, - modifier = Modifier, + modifier = Modifier.height(height), ) if (criticRating != null) { Dot() } } criticRating?.let { - TomatoRating(it) + TomatoRating(it, Modifier.height(height)) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index f4fea70c..87fc1da6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -15,6 +15,9 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager @@ -39,6 +42,7 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemFields @@ -55,9 +59,12 @@ class GenreViewModel constructor( private val api: ApiClient, private val imageUrlService: ImageUrlService, + private val serverRepository: ServerRepository, val navigationManager: NavigationManager, ) : ViewModel() { private lateinit var itemId: UUID + + val item = MutableLiveData(null) val loading = MutableLiveData(LoadingState.Pending) val genres = MutableLiveData>(listOf()) @@ -65,8 +72,14 @@ class GenreViewModel loading.value = LoadingState.Loading this.itemId = itemId viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { + val item = + api.userLibraryApi.getItem(itemId = itemId).content.let { + BaseItem(it, false) + } + this@GenreViewModel.item.setValueOnMain(item) val request = GetGenresRequest( + userId = serverRepository.currentUser.value?.id, parentId = itemId, fields = SlimItemFields, ) @@ -187,13 +200,23 @@ fun GenreCardGrid( LoadingState.Success -> { Box(modifier = modifier) { LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + val item by viewModel.item.observeAsState(null) CardGrid( pager = genres, onClickItem = { _, genre -> viewModel.navigationManager.navigateTo( Destination.FilteredCollection( itemId = itemId, - filter = GetItemsFilter(genres = listOf(genre.id)), + filter = + CollectionFolderFilter( + nameOverride = + listOfNotNull( + genre.name, + item?.title, + ).joinToString(" "), + filter = GetItemsFilter(genres = listOf(genre.id)), + useSavedLibraryDisplayInfo = false, + ), recursive = true, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt index d4087ea1..c52fb52f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt @@ -102,6 +102,7 @@ fun SimpleStarRating( if (text.isNotNullOrBlank()) { Text( text = text, + maxLines = 1, modifier = Modifier, ) val height = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() } @@ -130,6 +131,7 @@ fun TomatoRating( CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { Text( text = rating.toInt().toString() + "%", + maxLines = 1, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index cb179860..6e48692a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -9,14 +9,15 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.main.buildCombinedNextUp import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ExceptionHandler @@ -57,6 +58,7 @@ class RecommendedTvShowViewModel private val api: ApiClient, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, + private val datePlayedService: DatePlayedService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, @@ -78,17 +80,17 @@ class RecommendedTvShowViewModel override fun init() { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { - val itemsPerRow = - preferencesDataStore.data - .firstOrNull() - ?.homePagePreferences - ?.maxItemsPerRow - ?: AppPreference.HomePageItems.defaultValue.toInt() + val preferences = + preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + val combineNextUp = preferences.homePagePreferences.combineContinueNext + val itemsPerRow = preferences.homePagePreferences.maxItemsPerRow + val userId = serverRepository.currentUser.value?.id try { val resumeItemsDeferred = viewModelScope.async(Dispatchers.IO) { val resumeItemsRequest = GetResumeItemsRequest( + userId = userId, parentId = parentId, fields = SlimItemFields, includeItemTypes = listOf(BaseItemKind.EPISODE), @@ -106,12 +108,14 @@ class RecommendedTvShowViewModel viewModelScope.async(Dispatchers.IO) { val nextUpRequest = GetNextUpRequest( - parentId = parentId, + userId = userId, fields = SlimItemFields, - enableUserData = true, - startIndex = 0, + imageTypeLimit = 1, + parentId = parentId, limit = itemsPerRow, - enableTotalRecordCount = false, + enableResumable = false, + enableUserData = true, + enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp, ) GetNextUpRequestHandler @@ -119,21 +123,45 @@ class RecommendedTvShowViewModel .toBaseItems(api, true) } val resumeItems = resumeItemsDeferred.await() - update( - R.string.continue_watching, - HomeRowLoadingState.Success( - context.getString(R.string.continue_watching), - resumeItems, - ), - ) val nextUpItems = nextUpItemsDeferred.await() - update( - R.string.next_up, - HomeRowLoadingState.Success( - context.getString(R.string.next_up), - nextUpItems, - ), - ) + if (combineNextUp) { + val combined = + buildCombinedNextUp( + viewModelScope, + datePlayedService, + resumeItems, + nextUpItems, + ) + update( + R.string.continue_watching, + HomeRowLoadingState.Success( + context.getString(R.string.continue_watching), + combined, + ), + ) + update( + R.string.next_up, + HomeRowLoadingState.Success( + context.getString(R.string.next_up), + listOf(), + ), + ) + } else { + update( + R.string.continue_watching, + HomeRowLoadingState.Success( + context.getString(R.string.continue_watching), + resumeItems, + ), + ) + update( + R.string.next_up, + HomeRowLoadingState.Success( + context.getString(R.string.next_up), + nextUpItems, + ), + ) + } if (resumeItems.isNotEmpty() || nextUpItems.isNotEmpty()) { loading.setValueOnMain(LoadingState.Success) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt index d71cea69..4af65f1d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster @@ -28,7 +28,7 @@ fun CollectionFolderBoxSet( item: BaseItem?, recursive: Boolean, modifier: Modifier = Modifier, - filter: GetItemsFilter = GetItemsFilter(), + filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), playEnabled: Boolean = false, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index 6852abbc..6db471e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.ItemFilterBy -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster @@ -29,7 +29,7 @@ fun CollectionFolderGeneric( recursive: Boolean, playEnabled: Boolean, modifier: Modifier = Modifier, - filter: GetItemsFilter = GetItemsFilter(), + filter: CollectionFolderFilter = CollectionFolderFilter(), filterOptions: List> = DefaultFilterOptions, sortOptions: List = VideoSortOptions, preferencesViewModel: PreferencesViewModel = hiltViewModel(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 2b69c105..c71e0abe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -29,7 +29,7 @@ import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager @@ -164,7 +164,7 @@ fun CollectionFolderLiveTv( preferences = preferences, onClickItem = onClickItem, itemId = folders[folderIndex].id, - initialFilter = GetItemsFilter(), + initialFilter = CollectionFolderFilter(), showTitle = false, recursive = false, sortOptions = VideoSortOptions, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 798683aa..f824a4b9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid @@ -115,8 +116,11 @@ fun CollectionFolderMovie( }, itemId = destination.itemId, initialFilter = - GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.MOVIE), + CollectionFolderFilter( + filter = + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.MOVIE), + ), ), showTitle = false, recursive = true, @@ -143,8 +147,11 @@ fun CollectionFolderMovie( }, itemId = destination.itemId, initialFilter = - GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.BOX_SET), + CollectionFolderFilter( + filter = + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.BOX_SET), + ), ), showTitle = false, recursive = true, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index 15e36ad3..47547219 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare @@ -25,7 +25,7 @@ fun CollectionFolderPlaylist( item: BaseItem?, recursive: Boolean, modifier: Modifier = Modifier, - filter: GetItemsFilter = GetItemsFilter(), + filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { var showHeader by remember { mutableStateOf(true) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt index 0540c299..5362ddb5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt @@ -9,7 +9,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster @@ -23,7 +23,7 @@ fun CollectionFolderRecordings( itemId: UUID, recursive: Boolean, modifier: Modifier = Modifier, - filter: GetItemsFilter = GetItemsFilter(), + filter: CollectionFolderFilter = CollectionFolderFilter(), preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { var showHeader by remember { mutableStateOf(true) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index b32189d4..bf032813 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid @@ -115,8 +116,11 @@ fun CollectionFolderTv( preferences = preferences, itemId = destination.itemId, initialFilter = - GetItemsFilter( - includeItemTypes = listOf(BaseItemKind.SERIES), + CollectionFolderFilter( + filter = + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.SERIES), + ), ), showTitle = false, recursive = true, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index 5157f7d7..a62c58f5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -25,6 +25,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.preferences.UserPreferences @@ -117,9 +118,12 @@ fun FavoritesPage( onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_movies", initialFilter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.MOVIE), + CollectionFolderFilter( + filter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.MOVIE), + ), ), showTitle = false, recursive = true, @@ -145,9 +149,12 @@ fun FavoritesPage( onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_series", initialFilter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.SERIES), + CollectionFolderFilter( + filter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.SERIES), + ), ), showTitle = false, recursive = true, @@ -173,9 +180,12 @@ fun FavoritesPage( onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_episodes", initialFilter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.EPISODE), + CollectionFolderFilter( + filter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), + ), ), showTitle = false, recursive = true, @@ -202,9 +212,12 @@ fun FavoritesPage( onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_videos", initialFilter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.VIDEO), + CollectionFolderFilter( + filter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.VIDEO), + ), ), showTitle = false, recursive = true, @@ -230,9 +243,12 @@ fun FavoritesPage( onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_playlists", initialFilter = - GetItemsFilter( - favorite = true, - includeItemTypes = listOf(BaseItemKind.PLAYLIST), + CollectionFolderFilter( + filter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.PLAYLIST), + ), ), showTitle = false, recursive = true, @@ -258,9 +274,12 @@ fun FavoritesPage( onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_people", initialFilter = - GetItemsFilter( - favorite = true, - override = GetItemsFilterOverride.PERSON, + CollectionFolderFilter( + filter = + GetItemsFilter( + favorite = true, + override = GetItemsFilterOverride.PERSON, + ), ), initialSortAndDirection = SortAndDirection( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index fa225688..9a2d4dcf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -59,6 +60,7 @@ import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.logTab +import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import kotlinx.coroutines.launch @@ -249,6 +251,12 @@ fun SeriesOverviewContent( bringIntoViewRequester.bringIntoView() } } + }.onKeyEvent { + if (episode != null && isPlayKeyUp(it)) { + onClick.invoke(episode) + return@onKeyEvent true + } + return@onKeyEvent false }, interactionSource = interactionSource, cardHeight = 120.dp, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 38c1ff4f..ad1c2970 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.supportItemKinds import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -90,6 +91,7 @@ class HomeViewModel ), ) { Timber.d("init HomeViewModel") + backdropService.clearBackdrop() serverRepository.currentUserDto.value?.let { userDto -> val includedIds = @@ -109,7 +111,13 @@ class HomeViewModel val watching = buildList { if (prefs.combineContinueNext) { - val items = buildCombined(resume, nextUp) + val items = + buildCombinedNextUp( + viewModelScope, + datePlayedService, + resume, + nextUp, + ) add( HomeRowLoadingState.Success( title = context.getString(R.string.continue_watching), @@ -267,39 +275,6 @@ class HomeViewModel latestRows.setValueOnMain(rows) } - private suspend fun buildCombined( - resume: List, - nextUp: List, - ): List = - withContext(Dispatchers.IO) { - val start = System.currentTimeMillis() - val semaphore = Semaphore(3) - val deferred = - nextUp - .filter { it.data.seriesId != null } - .map { item -> - viewModelScope.async(Dispatchers.IO) { - try { - semaphore.withPermit { - datePlayedService.getLastPlayed(item) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching %s", item.id) - null - } - } - } - - val nextUpLastPlayed = deferred.awaitAll() - val timestamps = mutableMapOf() - nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) - resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } - val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } - val duration = (System.currentTimeMillis() - start).milliseconds - Timber.v("buildCombined took %s", duration) - return@withContext result - } - fun setWatched( itemId: UUID, played: Boolean, @@ -340,3 +315,38 @@ data class LatestData( val title: String, val request: GetLatestMediaRequest, ) + +suspend fun buildCombinedNextUp( + scope: CoroutineScope, + datePlayedService: DatePlayedService, + resume: List, + nextUp: List, +): List = + withContext(Dispatchers.IO) { + val start = System.currentTimeMillis() + val semaphore = Semaphore(3) + val deferred = + nextUp + .filter { it.data.seriesId != null } + .map { item -> + scope.async(Dispatchers.IO) { + try { + semaphore.withPermit { + datePlayedService.getLastPlayed(item) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching %s", item.id) + null + } + } + } + + val nextUpLastPlayed = deferred.awaitAll() + val timestamps = mutableMapOf() + nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) + resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } + val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } + val duration = (System.currentTimeMillis() - start).milliseconds + Timber.v("buildCombined took %s", duration) + return@withContext result + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 2d3200ba..7e7d0d2b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -24,8 +25,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.runtime.serialization.NavBackStackSerializer +import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.MaterialTheme import coil3.compose.AsyncImage @@ -62,13 +67,22 @@ class ApplicationContentViewModel */ @Composable fun ApplicationContent( - server: JellyfinServer?, - user: JellyfinUser?, + server: JellyfinServer, + user: JellyfinUser, navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: ApplicationContentViewModel = hiltViewModel(), ) { + val backStack: MutableList = + rememberSerializable( + server, + user, + serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()), + ) { + NavBackStack(Destination.Home()) + } + navigationManager.backStack = backStack val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle Box( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 1cec6762..d28af193 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -5,10 +5,10 @@ package com.github.damontecres.wholphin.ui.nav import androidx.annotation.StringRes import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.DiscoverItem 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.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds @@ -29,14 +29,6 @@ import java.util.UUID sealed class Destination( val fullScreen: Boolean = false, ) : NavKey { - @Serializable - data object ServerList : Destination(true) - - @Serializable - data class UserList( - val server: JellyfinServer, - ) : Destination(true) - @Serializable data class Home( val id: Long = 0L, @@ -104,7 +96,7 @@ sealed class Destination( @Serializable data class FilteredCollection( val itemId: UUID, - val filter: GetItemsFilter, + val filter: CollectionFolderFilter, val recursive: Boolean, ) : Destination(false) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index d6013d56..ed0d91f2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -32,8 +32,6 @@ import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.seerr.SeerrDiscoverPage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage -import com.github.damontecres.wholphin.ui.setup.SwitchServerContent -import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import timber.log.Timber @@ -69,14 +67,6 @@ fun DestinationContent( ) } - Destination.ServerList -> { - SwitchServerContent(modifier) - } - - is Destination.UserList -> { - SwitchUserContent(destination.server, modifier) - } - is Destination.Settings -> { PreferencesPage( preferences.appPreferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 60d2a758..c78295dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -79,6 +79,8 @@ import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.ifElse @@ -106,6 +108,7 @@ class NavDrawerViewModel constructor( private val navDrawerItemRepository: NavDrawerItemRepository, val navigationManager: NavigationManager, + val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, ) : ViewModel() { private var all: List? = null @@ -375,10 +378,8 @@ fun NavDrawer( drawerOpen = drawerState.isOpen, interactionSource = interactionSource, onClick = { - viewModel.navigationManager.navigateToFromDrawer( - Destination.UserList( - server, - ), + viewModel.setupNavigationManager.navigateTo( + SetupDestination.UserList(server), ) }, modifier = Modifier.padding(start = drawerPadding), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt index e145d380..b3813ed5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -107,7 +107,7 @@ fun DownloadSubtitlesContent( color = MaterialTheme.colorScheme.onSurface, ) // val lang = rememberTextFieldState(language) - var lang by rememberSaveable { mutableStateOf("") } + var lang by rememberSaveable { mutableStateOf(language) } Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index e462a312..1d380e08 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -286,11 +286,12 @@ class PlaybackViewModel playNextUp() } - if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) { - val playlist = - playlistCreator.createFromEpisode(queriedItem.seriesId!!, queriedItem.id) - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = playlist + if (!isPlaylist) { + val result = playlistCreator.createFrom(queriedItem) + if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { + withContext(Dispatchers.Main) { + this@PlaybackViewModel.playlist.value = result.playlist + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index 75af5155..f9e7d0a3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -9,9 +9,9 @@ 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.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState @@ -41,7 +41,7 @@ class SwitchServerViewModel val jellyfin: Jellyfin, val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, - val navigationManager: NavigationManager, + val navigationManager: SetupNavigationManager, ) : ViewModel() { val servers = MutableLiveData>(listOf()) val serverStatus = MutableLiveData>(mapOf()) @@ -156,7 +156,7 @@ class SwitchServerViewModel ) serverRepository.addAndChangeServer(updatedServer) withContext(Dispatchers.Main) { - navigationManager.navigateTo(Destination.UserList(server)) + navigationManager.navigateTo(SetupDestination.UserList(updatedServer)) } } else if (result is ServerConnectionStatus.Error) { showToast(context, "Error connecting: $${result.message}") @@ -201,7 +201,7 @@ class SwitchServerViewModel } withContext(Dispatchers.Main) { addServerState.value = LoadingState.Success - navigationManager.navigateTo(Destination.UserList(server)) + navigationManager.navigateTo(SetupDestination.UserList(server)) } } else { withContext(Dispatchers.Main) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index 19c00f18..b71e0af0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -37,6 +37,7 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.EditTextBox @@ -130,7 +131,9 @@ fun SwitchUserContent( viewModel.removeUser(user) }, onSwitchServer = { - viewModel.navigationManager.navigateTo(Destination.ServerList) + viewModel.setupNavigationManager.navigateTo( + SetupDestination.ServerList, + ) }, modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index 7da64ca7..68b9e583 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -9,6 +9,8 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler @@ -43,6 +45,7 @@ class SwitchUserViewModel val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, val navigationManager: NavigationManager, + val setupNavigationManager: SetupNavigationManager, val imageUrlService: ImageUrlService, @Assisted val server: JellyfinServer, ) : ViewModel() { @@ -120,9 +123,11 @@ class SwitchUserViewModel fun switchUser(user: JellyfinUser) { viewModelScope.launchIO { try { - serverRepository.changeUser(server, user) - withContext(Dispatchers.Main) { - navigationManager.goToHome() + val current = serverRepository.changeUser(server, user) + if (current != null) { + withContext(Dispatchers.Main) { + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) + } } } catch (ex: Exception) { Timber.e(ex, "Error switching user") @@ -144,9 +149,11 @@ class SwitchUserViewModel username = username, password = password, ) - serverRepository.changeUser(server.url, authenticationResult) - withContext(Dispatchers.Main) { - navigationManager.goToHome() + val current = serverRepository.changeUser(server.url, authenticationResult) + if (current != null) { + withContext(Dispatchers.Main) { + setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) + } } } catch (ex: Exception) { Timber.e(ex, "Error logging in user") @@ -192,9 +199,13 @@ class SwitchUserViewModel val authenticationResult by api.userApi.authenticateWithQuickConnect( QuickConnectDto(secret = state.secret), ) - serverRepository.changeUser(server.url, authenticationResult) - withContext(Dispatchers.Main) { - navigationManager.goToHome() + val current = serverRepository.changeUser(server.url, authenticationResult) + if (current != null) { + withContext(Dispatchers.Main) { + setupNavigationManager.navigateTo( + SetupDestination.AppContent(current), + ) + } } } catch (ex: Exception) { Timber.e(ex, "Error during quick connect") diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt index 52af2644..acb2f8d8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt @@ -38,9 +38,17 @@ object MPVLib { external fun create(appctx: Context) - external fun init() + fun initialize() { + synchronized(this) { init() } + } - external fun destroy() + private external fun init() + + fun tearDown() { + synchronized(this) { destroy() } + } + + private external fun destroy() external fun attachSurface(surface: Surface) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index a31638c4..4777e977 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -76,6 +76,7 @@ class MpvPlayer( private var surface: Surface? = null private val playbackState = AtomicReference(PlaybackState.EMPTY) + private val mpvLogger = MpvLogger() // This looper will sent events to the main thread private val looper = Util.getCurrentOrMainLooper() @@ -87,7 +88,7 @@ class MpvPlayer( ) { listener, eventFlags -> listener.onEvents(this@MpvPlayer, Player.Events(eventFlags)) } - private val availableCommands: Player.Commands + private var availableCommands: Player.Commands = Player.Commands.Builder().build() private val trackSelector = DefaultTrackSelector(context) // This thread/looper will receive commands from the main thread to execute @@ -101,7 +102,13 @@ class MpvPlayer( private set init { + sendCommand(MpvCommand.INITIALIZE, null) + } + + private fun init() { Timber.v("config-dir=${context.filesDir.path}") + MPVLib.addLogObserver(mpvLogger) + MPVLib.create(context) MPVLib.setOptionString("config", "yes") MPVLib.setOptionString("config-dir", context.filesDir.path) @@ -120,17 +127,14 @@ class MpvPlayer( MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}") MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") - MPVLib.init() + MPVLib.initialize() MPVLib.setOptionString("force-window", "no") MPVLib.setOptionString("idle", "yes") // MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath) - internalHandler.post { - MPVLib.addObserver(this@MpvPlayer) - MPVProperty.observedProperties.forEach(MPVLib::observeProperty) - MPVLib.addLogObserver(MpvLogger()) - } + MPVLib.addObserver(this@MpvPlayer) + MPVProperty.observedProperties.forEach(MPVLib::observeProperty) availableCommands = Player.Commands @@ -298,15 +302,14 @@ class MpvPlayer( override fun release() { Timber.i("release") - internalHandler.removeCallbacks(updatePlaybackState) playbackState.update { PlaybackState.EMPTY } if (!isReleased) { - thread.quit() - MPVLib.removeObserver(this) - clearVideoSurfaceView(null) - MPVLib.destroy() + internalHandler.removeCallbacks(updatePlaybackState) + MPVLib.removeObserver(this@MpvPlayer) + sendCommand(MpvCommand.DESTROY, null) + thread.quitSafely() } isReleased = true } @@ -466,15 +469,9 @@ class MpvPlayer( throwIfReleased() if (DEBUG) Timber.v("setVideoSurfaceView") val surface = surfaceView?.holder?.surface - if (surface != null) { - this.surface = surface + if (surface != null && surface.isValid) { Timber.v("Queued attach") - MPVLib.attachSurface(surface) - MPVLib.setOptionString("force-window", "yes") - Timber.d("Attached surface") - playbackState.load().media?.let { - sendCommand(MpvCommand.LOAD_FILE, it) - } + sendCommand(MpvCommand.ATTACH_SURFACE, surface) } else { clearVideoSurfaceView(null) } @@ -483,9 +480,7 @@ class MpvPlayer( override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { if (surface == surfaceView?.holder?.surface) { Timber.d("clearVideoSurfaceView") - MPVLib.detachSurface() - MPVLib.setPropertyString("vo", "null") - MPVLib.setPropertyString("force-window", "no") + sendCommand(MpvCommand.ATTACH_SURFACE, null) } else { Timber.w("clearVideoSurfaceView called with different surface") } @@ -621,7 +616,7 @@ class MpvPlayer( } override fun event(eventId: Int) { - Timber.v("event: thread=${Thread.currentThread().name}, eventId=$eventId") + Timber.v("event: eventId=%s", eventId) when (eventId) { MPV_EVENT_START_FILE -> { internalHandler.post(updatePlaybackState) @@ -891,6 +886,33 @@ class MpvPlayer( MpvCommand.LOAD_FILE -> { loadFile(msg.obj as MediaAndPosition) } + + MpvCommand.ATTACH_SURFACE -> { + val surface = msg.obj as Surface? + if (surface == null || (this.surface != null && this.surface != surface)) { + // If clearing or changing the surface + MPVLib.detachSurface() + MPVLib.setPropertyString("vo", "null") + MPVLib.setPropertyString("force-window", "no") + } + if (surface != null) { + MPVLib.attachSurface(surface) + this.surface = surface + MPVLib.setOptionString("force-window", "yes") + Timber.d("Attached surface") + } + } + + MpvCommand.INITIALIZE -> { + init() + } + + MpvCommand.DESTROY -> { + clearVideoSurfaceView(null) + MPVLib.removeLogObserver(mpvLogger) + MPVLib.tearDown() + Timber.d("MPVLib destroyed") + } } return true } @@ -1020,4 +1042,7 @@ enum class MpvCommand { SET_SPEED, SET_SUBTITLE_DELAY, LOAD_FILE, + ATTACH_SURFACE, + INITIALIZE, + DESTROY, }