diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7a8cea71..8cf76519 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,6 +59,7 @@ android { debug { isMinifyEnabled = false isDebuggable = true + applicationIdSuffix = ".debug" } } compileOptions { diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 00000000..606329c9 --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Wholphin (Debug) + 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 638ebc0f..325f49f4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -32,7 +32,6 @@ import androidx.tv.material3.Surface 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.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.BackdropService @@ -58,10 +57,7 @@ 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 import timber.log.Timber @@ -110,7 +106,7 @@ class MainActivity : AppCompatActivity() { @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - Timber.i("MainActivity.onCreate") + Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) if (savedInstanceState == null) { appUpgradeHandler.copySubfont(false) @@ -217,12 +213,8 @@ class MainActivity : AppCompatActivity() { appPreferences, ) val preferences = - remember(appPreferences, current) { - UserPreferences( - appPreferences, - current.userDto.configuration - ?: DefaultUserConfiguration, - ) + remember(appPreferences) { + UserPreferences(appPreferences) } ApplicationContent( user = current.user, @@ -246,6 +238,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() + Timber.d("onResume") lifecycleScope.launchIO { appUpgradeHandler.run() } @@ -253,7 +246,7 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() - Timber.i("onRestart") + Timber.d("onRestart") viewModel.appStart() // val signInAutomatically = // runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true @@ -264,6 +257,36 @@ class MainActivity : AppCompatActivity() { // serverRepository.closeSession() // } } + + override fun onStop() { + super.onStop() + Timber.d("onStop") + } + + override fun onPause() { + super.onPause() + Timber.d("onPause") + } + + override fun onStart() { + super.onStart() + Timber.d("onStart") + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Timber.d("onSaveInstanceState") + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + Timber.d("onRestoreInstanceState") + } + + override fun onDestroy() { + super.onDestroy() + Timber.d("onDestroy") + } } @HiltViewModel @@ -277,40 +300,42 @@ class MainActivityViewModel private val backdropService: BackdropService, ) : ViewModel() { fun appStart() { - viewModelScope.launch { - val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - if (prefs.signInAutomatically) { - val current = - withContext(Dispatchers.IO) { + viewModelScope.launchIO { + try { + val prefs = + preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + if (prefs.signInAutomatically) { + val current = 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)) + if (current != null) { + // Restored + navigationManager.navigateTo(SetupDestination.AppContent(current)) } else { + // Did not restore navigationManager.navigateTo(SetupDestination.ServerList) } } else { - navigationManager.navigateTo(SetupDestination.ServerList) + navigationManager.navigateTo(SetupDestination.Loading) + backdropService.clearBackdrop() + val currentServerId = prefs.currentServerId?.toUUIDOrNull() + if (currentServerId != null) { + val currentServer = + serverRepository.serverDao.getServer(currentServerId)?.server + if (currentServer != null) { + navigationManager.navigateTo(SetupDestination.UserList(currentServer)) + } else { + navigationManager.navigateTo(SetupDestination.ServerList) + } + } else { + navigationManager.navigateTo(SetupDestination.ServerList) + } } + } catch (ex: Exception) { + Timber.e(ex, "Error during appStart") + navigationManager.navigateTo(SetupDestination.ServerList) } } viewModelScope.launchIO { 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 539c8ac4..a396b873 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 @@ -46,9 +46,11 @@ class ServerRepository private var _current = EqualityMutableLiveData(null) val current: LiveData = _current + private var _currentUserDto = EqualityMutableLiveData(null) + val currentUserDto: LiveData = _currentUserDto + val currentServer: LiveData get() = _current.map { it?.server } val currentUser: LiveData get() = _current.map { it?.user } - val currentUserDto: LiveData get() = _current.map { it?.userDto } /** * Adds a server to the app database and updated the [ApiClient] to the server's URL @@ -101,7 +103,8 @@ class ServerRepository }.build() } withContext(Dispatchers.Main) { - _current.value = CurrentUser(updatedServer, updatedUser, userDto) + _current.value = CurrentUser(updatedServer, updatedUser) + _currentUserDto.value = userDto } sharedPreferences.edit(true) { putString(SERVER_URL_KEY, updatedServer.url) @@ -128,11 +131,21 @@ class ServerRepository serverDao.getServer(serverId) } if (serverAndUsers != null) { - val user = serverAndUsers.users.firstOrNull { it.id == userId } - if (user != null) { - // TODO pin-related + val current = _current.value + if (current != null && current.server.id == serverId && current.user.id == userId) { + Timber.v("Restoring session for current user, so shortcut") + apiClient.update( + baseUrl = current.server.url, + accessToken = current.user.accessToken, + ) + return current + } else { + val user = serverAndUsers.users.firstOrNull { it.id == userId } + if (user != null) { + // TODO pin-related // if (user != null && !user.hasPin) { - return changeUser(serverAndUsers.server, user) + return changeUser(serverAndUsers.server, user) + } } } return null @@ -271,5 +284,4 @@ class ServerRepository data class CurrentUser( val server: JellyfinServer, val user: JellyfinUser, - val userDto: UserDto, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt index 520a12e8..35da376a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt @@ -8,7 +8,6 @@ import org.jellyfin.sdk.model.api.UserConfiguration */ data class UserPreferences( val appPreferences: AppPreferences, - val userConfig: UserConfiguration, ) val DefaultUserConfiguration = diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index e1490ac0..703821fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -13,6 +13,7 @@ import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import org.jellyfin.sdk.model.api.UserConfiguration import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.UUID @@ -26,6 +27,8 @@ class StreamChoiceService private val serverRepository: ServerRepository, private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, ) { + private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto.value?.configuration + suspend fun updateAudio( dto: BaseItemDto, audioLang: String, @@ -117,7 +120,7 @@ class StreamChoiceService val seriesLang = playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } // If the user has chosen a different language for the series, prefer that - val audioLanguage = seriesLang ?: prefs.userConfig.audioLanguagePreference + val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference if (audioLanguage.isNotNullOrBlank()) { val sorted = @@ -174,7 +177,7 @@ class StreamChoiceService val seriesLang = playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } val subtitleLanguage = - (seriesLang ?: prefs.userConfig.subtitleLanguagePreference) + (seriesLang ?: userConfig?.subtitleLanguagePreference) ?.takeIf { it.isNotNullOrBlank() } val subtitleMode = @@ -192,7 +195,7 @@ class StreamChoiceService else -> { // Fallback to the user's preference - prefs.userConfig.subtitleMode + userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT } } return when (subtitleMode) { @@ -213,7 +216,7 @@ class StreamChoiceService } SubtitlePlaybackMode.SMART -> { - val audioLanguage = prefs.userConfig.audioLanguagePreference + val audioLanguage = userConfig?.audioLanguagePreference val audioStreamLang = audioStream?.language if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) { candidates.firstOrNull { it.language == subtitleLanguage } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt index a59f287b..90d68e52 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.services import androidx.datastore.core.DataStore import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.UserPreferences import kotlinx.coroutines.flow.firstOrNull import javax.inject.Inject @@ -21,7 +20,6 @@ class UserPreferencesService val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() UserPreferences( appPrefs, - userConfig ?: DefaultUserConfiguration, ) } } 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 bc39c48e..4eba1b32 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 @@ -28,7 +28,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.media3.common.Player import coil3.request.ErrorResult import com.github.damontecres.wholphin.data.model.BaseItem @@ -167,6 +169,26 @@ fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) { } } +/** + * Calls [tryRequestFocus] on the provided [FocusRequester] when this composable launches or resumes + */ +@Composable +fun RequestOrRestoreFocus( + focusRequester: FocusRequester?, + debugKey: String? = null, +) { + if (focusRequester != null) { + LaunchedEffect(Unit) { + debugKey?.let { Timber.v("RequestOrRestoreFocus: %s", it) } + focusRequester.tryRequestFocus() + } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + debugKey?.let { Timber.v("RequestOrRestoreFocus onResume: %s", it) } + focusRequester.tryRequestFocus() + } + } +} + fun Modifier.enableMarquee(focused: Boolean) = if (focused) { basicMarquee( 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 c2e863c3..4340466c 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 @@ -16,6 +16,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -77,7 +78,7 @@ fun GenreCard( contentDescription = null, modifier = Modifier - .alpha(.6f) + .alpha(.75f) .aspectRatio(AspectRatios.WIDE) .fillMaxSize(), ) 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 8692d447..a2e99ffe 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 @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -67,7 +68,7 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel @@ -85,17 +86,18 @@ import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler 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 dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -114,13 +116,13 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.TreeSet import java.util.UUID -import javax.inject.Inject import kotlin.time.Duration -@HiltViewModel +@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class) class CollectionFolderViewModel - @Inject + @AssistedInject constructor( + private val savedStateHandle: SavedStateHandle, api: ApiClient, @param:ApplicationContext private val context: Context, private val serverRepository: ServerRepository, @@ -128,7 +130,25 @@ class CollectionFolderViewModel private val favoriteWatchManager: FavoriteWatchManager, private val backdropService: BackdropService, val navigationManager: NavigationManager, + @Assisted itemId: String, + @Assisted initialSortAndDirection: SortAndDirection?, + @Assisted("recursive") private val recursive: Boolean, + @Assisted private val collectionFilter: CollectionFolderFilter, + @Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean, + @Assisted defaultViewOptions: ViewOptions, ) : ItemViewModel(api) { + @AssistedFactory + interface Factory { + fun create( + itemId: String, + initialSortAndDirection: SortAndDirection?, + @Assisted("recursive") recursive: Boolean, + collectionFilter: CollectionFolderFilter, + @Assisted("useSeriesForPrimary") useSeriesForPrimary: Boolean, + defaultViewOptions: ViewOptions, + ): CollectionFolderViewModel + } + val loading = MutableLiveData(LoadingState.Loading) val backgroundLoading = MutableLiveData(LoadingState.Loading) val pager = MutableLiveData>(listOf()) @@ -136,26 +156,19 @@ class CollectionFolderViewModel val filter = MutableLiveData(GetItemsFilter()) val viewOptions = MutableLiveData() - private var useSeriesForPrimary: Boolean = true - private lateinit var collectionFilter: CollectionFolderFilter + var position: Int + get() = savedStateHandle.get("position") ?: 0 + set(value) { + savedStateHandle["position"] = value + } - fun init( - itemId: String, - initialSortAndDirection: SortAndDirection?, - recursive: Boolean, - collectionFilter: CollectionFolderFilter, - useSeriesForPrimary: Boolean, - defaultViewOptions: ViewOptions, - ): Job = + init { viewModelScope.launch( LoadingExceptionHandler( loading, 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 { fetchItem(it) } @@ -184,6 +197,7 @@ class CollectionFolderViewModel loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) } + } private fun saveLibraryDisplayInfo( newFilter: GetItemsFilter = this.filter.value!!, @@ -537,25 +551,27 @@ fun CollectionFolderGrid( playEnabled: Boolean, defaultViewOptions: ViewOptions, modifier: Modifier = Modifier, - viewModel: CollectionFolderViewModel = hiltViewModel(key = itemId), - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), initialSortAndDirection: SortAndDirection? = null, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, useSeriesForPrimary: Boolean = true, filterOptions: List> = DefaultFilterOptions, + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), + viewModel: CollectionFolderViewModel = + hiltViewModel( + key = itemId, + ) { + it.create( + itemId = itemId, + initialSortAndDirection = initialSortAndDirection, + recursive = recursive, + collectionFilter = initialFilter, + useSeriesForPrimary = useSeriesForPrimary, + defaultViewOptions = defaultViewOptions, + ) + }, ) { val context = LocalContext.current - OneTimeLaunchedEffect { - viewModel.init( - itemId, - initialSortAndDirection, - recursive, - initialFilter, - useSeriesForPrimary, - defaultViewOptions, - ) - } val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) val filter by viewModel.filter.observeAsState(initialFilter.filter) val loading by viewModel.loading.observeAsState(LoadingState.Loading) @@ -589,6 +605,7 @@ fun CollectionFolderGrid( Box(modifier = modifier) { CollectionFolderGridContent( preferences = preferences, + initialPosition = viewModel.position, item = item, title = title, pager = pager, @@ -611,7 +628,10 @@ fun CollectionFolderGrid( }, showTitle = showTitle, sortOptions = sortOptions, - positionCallback = positionCallback, + positionCallback = { columns, position -> + viewModel.position = position + positionCallback?.invoke(columns, position) + }, letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, viewOptions = viewOptions, defaultViewOptions = defaultViewOptions, @@ -728,6 +748,7 @@ fun CollectionFolderGridContent( onClickPlayAll: (shuffle: Boolean) -> Unit, onClickPlay: (Int, BaseItem) -> Unit, onChangeBackdrop: (BaseItem) -> Unit, + initialPosition: Int, modifier: Modifier = Modifier, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, @@ -742,10 +763,10 @@ fun CollectionFolderGridContent( var viewOptions by remember { mutableStateOf(viewOptions) } val gridFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + RequestOrRestoreFocus(gridFocusRequester) var backdropImageUrl by remember { mutableStateOf(null) } - var position by rememberInt(0) + var position by rememberInt(initialPosition) val focusedItem = pager.getOrNull(position) if (viewOptions.showDetails) { LaunchedEffect(focusedItem) { @@ -861,7 +882,7 @@ fun CollectionFolderGridContent( showJumpButtons = false, // TODO add preference showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME, modifier = Modifier.fillMaxSize(), - initialPosition = 0, + initialPosition = initialPosition, positionCallback = { columns, newPosition -> showHeader = newPosition < columns position = newPosition 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 87fc1da6..ac9ac814 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 @@ -11,6 +11,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -68,7 +72,10 @@ class GenreViewModel val loading = MutableLiveData(LoadingState.Pending) val genres = MutableLiveData>(listOf()) - fun init(itemId: UUID) { + fun init( + itemId: UUID, + cardWidthPx: Int, + ) { loading.value = LoadingState.Loading this.itemId = itemId viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { @@ -133,7 +140,8 @@ class GenreViewModel item.type, null, false, - ImageType.THUMB, + ImageType.BACKDROP, + fillWidth = cardWidthPx, ) } } @@ -179,8 +187,23 @@ fun GenreCardGrid( modifier: Modifier = Modifier, viewModel: GenreViewModel = hiltViewModel(), ) { + val columns = 4 + val spacing = 16.dp + val density = LocalDensity.current + val configuration = LocalConfiguration.current + val cardWidthPx = + remember { + with(density) { + // Grid has 16dp padding on either side & 16dp spacing between 4 cards + // This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster + // E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required + (configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing)) + .div(columns) + .roundToPx() + } + } OneTimeLaunchedEffect { - viewModel.init(itemId) + viewModel.init(itemId, cardWidthPx) } val loading by viewModel.loading.observeAsState(LoadingState.Pending) val genres by viewModel.genres.observeAsState(listOf()) @@ -231,7 +254,8 @@ fun GenreCardGrid( initialPosition = 0, positionCallback = { columns, position -> }, - columns = 4, + columns = columns, + spacing = spacing, cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> GenreCard( genre = item, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt index 89323bdd..16e26649 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -49,8 +50,8 @@ fun TabRow( modifier: Modifier = Modifier, ) { val state = rememberLazyListState() - LaunchedEffect(Unit) { - state.animateScrollToItem(selectedTabIndex) + LaunchedEffect(selectedTabIndex) { + state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) } val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } var rowHasFocus by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index ba74641a..19f8f6fd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -117,12 +117,16 @@ fun CardGrid( val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f) - val gridState = rememberLazyGridState(cacheWindow = fractionCacheWindow) + var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) } + val gridState = + rememberLazyGridState( + cacheWindow = fractionCacheWindow, + initialFirstVisibleItemIndex = focusedIndex, + ) val scope = rememberCoroutineScope() val firstFocus = remember { FocusRequester() } val zeroFocus = remember { FocusRequester() } var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) } - var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) } var alphabetFocus by remember { mutableStateOf(false) } val focusOn = { index: Int -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index b65f8624..60ad839e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -302,7 +302,7 @@ fun DebugPage( color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "User server settings: ${preferences.userConfig}", + text = "User server settings: ${viewModel.serverRepository.currentUserDto.value?.configuration}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index bca08e7b..493093da 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -31,6 +30,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -48,7 +48,6 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -291,9 +290,7 @@ fun EpisodeDetailsContent( val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val bringIntoViewRequester = remember { BringIntoViewRequester() } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position)?.tryRequestFocus() - } + RequestOrRestoreFocus(focusRequesters.getOrNull(position)) Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 24d2f98e..78876e38 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -49,6 +48,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.cards.ChapterRow import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow @@ -73,7 +73,6 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -391,9 +390,9 @@ fun MovieDetailsContent( val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val bringIntoViewRequester = remember { BringIntoViewRequester() } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position)?.tryRequestFocus() - } + + RequestOrRestoreFocus(focusRequesters.getOrNull(position)) + Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 0ab81b7e..227f13bd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -46,6 +45,7 @@ import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow @@ -74,7 +74,6 @@ import com.github.damontecres.wholphin.ui.detail.movie.TrailerRow import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -88,13 +87,15 @@ fun SeriesDetails( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - viewModel: SeriesViewModel = hiltViewModel(), + viewModel: SeriesViewModel = + hiltViewModel( + creationCallback = { + it.create(destination.itemId, null, SeriesPageType.DETAILS) + }, + ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - LaunchedEffect(Unit) { - viewModel.init(preferences, destination.itemId, null, true) - } val loading by viewModel.loading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() @@ -285,7 +286,7 @@ private const val SIMILAR_ROW = EXTRAS_ROW + 1 fun SeriesDetailsContent( preferences: UserPreferences, series: BaseItem, - seasons: List, + seasons: List, similar: List, trailers: List, extras: List, @@ -311,9 +312,7 @@ fun SeriesDetailsContent( var position by rememberInt() val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val playFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position)?.tryRequestFocus() - } + RequestOrRestoreFocus(focusRequesters.getOrNull(position)) var moreDialog by remember { mutableStateOf(null) } Box( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 73f489c1..9377aa1d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -4,12 +4,11 @@ package com.github.damontecres.wholphin.ui.detail.series import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -22,7 +21,6 @@ import androidx.lifecycle.map import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -36,13 +34,12 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems -import com.github.damontecres.wholphin.ui.equalsNotNull -import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind @@ -52,7 +49,6 @@ import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import timber.log.Timber import java.util.UUID import kotlin.time.Duration @@ -80,10 +76,15 @@ data class SeriesOverviewPosition( fun SeriesOverview( preferences: UserPreferences, destination: Destination.SeriesOverview, + initialSeasonEpisode: SeasonEpisodeIds?, modifier: Modifier = Modifier, - viewModel: SeriesViewModel = hiltViewModel(), + viewModel: SeriesViewModel = + hiltViewModel( + creationCallback = { + it.create(destination.itemId, initialSeasonEpisode, SeriesPageType.OVERVIEW) + }, + ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), - initialSeasonEpisode: SeasonEpisodeIds? = null, ) { val context = LocalContext.current val firstItemFocusRequester = remember { FocusRequester() } @@ -91,18 +92,6 @@ fun SeriesOverview( val castCrewRowFocusRequester = remember { FocusRequester() } val guestStarRowFocusRequester = remember { FocusRequester() } - var initialLoadDone by rememberSaveable { mutableStateOf(false) } - OneTimeLaunchedEffect { - Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode") - viewModel.init( - preferences, - destination.itemId, - initialSeasonEpisode, - false, - ) - initialLoadDone = true - } - val loading by viewModel.loading.observeAsState(LoadingState.Loading) val series by viewModel.item.observeAsState(null) @@ -111,27 +100,9 @@ fun SeriesOverview( val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf()) val episodeList = (episodes as? EpisodeList.Success)?.episodes - var position by rememberSaveable( - destination, - loading, - stateSaver = - Saver( - save = { listOf(it.seasonTabIndex, it.episodeRowIndex) }, - restore = { SeriesOverviewPosition(it[0], it[1]) }, - ), - ) { - mutableStateOf( - SeriesOverviewPosition( - seasons.indexOfFirstOrNull { - equalsNotNull(it.id, initialSeasonEpisode?.seasonId) || - equalsNotNull(it.indexNumber, initialSeasonEpisode?.seasonNumber) - } ?: 0, - (episodes as? EpisodeList.Success)?.initialIndex ?: 0, - ), - ) - } - if (initialLoadDone) { - LaunchedEffect(Unit) { + val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0)) + LaunchedEffect(Unit) { + if (seasons.isNotEmpty()) { seasons.getOrNull(position.seasonTabIndex)?.let { viewModel.loadEpisodes(it.id) } @@ -301,13 +272,20 @@ fun SeriesOverview( episodeRowFocusRequester = episodeRowFocusRequester, castCrewRowFocusRequester = castCrewRowFocusRequester, guestStarRowFocusRequester = guestStarRowFocusRequester, - onFocus = { - if (it.seasonTabIndex != position.seasonTabIndex) { - seasons.getOrNull(it.seasonTabIndex)?.let { season -> + onChangeSeason = { index -> + if (index != position.seasonTabIndex) { + seasons.getOrNull(index)?.let { season -> viewModel.loadEpisodes(season.id) + viewModel.position.update { + SeriesOverviewPosition(index, 0) + } } } - position = it + }, + onFocusEpisode = { episodeIndex -> + viewModel.position.update { + it.copy(episodeRowIndex = episodeIndex) + } }, onClick = { rowFocused = EPISODE_ROW 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 9a2d4dcf..75d8344b 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 @@ -26,7 +26,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,7 +49,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -80,7 +79,8 @@ fun SeriesOverviewContent( episodeRowFocusRequester: FocusRequester, castCrewRowFocusRequester: FocusRequester, guestStarRowFocusRequester: FocusRequester, - onFocus: (SeriesOverviewPosition) -> Unit, + onChangeSeason: (Int) -> Unit, + onFocusEpisode: (Int) -> Unit, onClick: (BaseItem) -> Unit, onLongClick: (BaseItem) -> Unit, playOnClick: (Duration) -> Unit, @@ -141,11 +141,12 @@ fun SeriesOverviewContent( tabs = seasons.mapNotNull { it?.name - ?: (stringResource(R.string.tv_season) + " ${it?.data?.indexNumber}") + ?: it?.data?.indexNumber?.let { stringResource(R.string.tv_season) + " $it" } + ?: "" }, onClick = { selectedTabIndex = it - onFocus.invoke(SeriesOverviewPosition(it, 0)) + onChangeSeason.invoke(it) }, modifier = Modifier @@ -169,102 +170,93 @@ fun SeriesOverviewContent( modifier = Modifier.fillMaxWidth(.6f), ) - key(position.seasonTabIndex) { - when (val eps = episodes) { - EpisodeList.Loading -> { - LoadingPage() - } +// key(position.seasonTabIndex) { + when (val eps = episodes) { + EpisodeList.Loading -> { + LoadingPage() + } - is EpisodeList.Error -> { - ErrorMessage(eps.message, eps.exception) - } + is EpisodeList.Error -> { + ErrorMessage(eps.message, eps.exception) + } - is EpisodeList.Success -> { - val state = rememberLazyListState() - OneTimeLaunchedEffect { - if (state.firstVisibleItemIndex != position.episodeRowIndex) { - state.scrollToItem(position.episodeRowIndex) + is EpisodeList.Success -> { + val state = rememberLazyListState(position.episodeRowIndex) + RequestOrRestoreFocus(firstItemFocusRequester) + + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + modifier = + Modifier + .focusRestorer(firstItemFocusRequester) + .focusRequester(episodeRowFocusRequester) + .onFocusChanged { + cardRowHasFocus = it.hasFocus + }, + ) { + itemsIndexed(eps.episodes) { episodeIndex, episode -> + val interactionSource = remember { MutableInteractionSource() } + if (interactionSource.collectIsFocusedAsState().value) { + onFocusEpisode.invoke(episodeIndex) } - firstItemFocusRequester.tryRequestFocus() - } - LazyRow( - state = state, - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - modifier = - Modifier - .focusRestorer(firstItemFocusRequester) - .focusRequester(episodeRowFocusRequester) - .onFocusChanged { - cardRowHasFocus = it.hasFocus - }, - ) { - itemsIndexed(eps.episodes) { episodeIndex, episode -> - val interactionSource = remember { MutableInteractionSource() } - if (interactionSource.collectIsFocusedAsState().value) { - onFocus.invoke( - SeriesOverviewPosition( - selectedTabIndex, - episodeIndex, - ), - ) - } - val cornerText = - episode?.data?.indexNumber?.let { "E$it" } - ?: episode?.data?.premiereDate?.let(::formatDateTime) - BannerCard( - name = episode?.name, - item = episode, - aspectRatio = - episode - ?.aspectRatio - ?.coerceAtLeast(AspectRatios.FOUR_THREE) - ?: (AspectRatios.WIDE), - cornerText = cornerText, - played = episode?.data?.userData?.played ?: false, - playPercent = - episode?.data?.userData?.playedPercentage - ?: 0.0, - onClick = { if (episode != null) onClick.invoke(episode) }, - onLongClick = { - if (episode != null) { - onLongClick.invoke( - episode, - ) - } - }, - modifier = - Modifier - .ifElse( - episodeIndex == position.episodeRowIndex, - Modifier.focusRequester(firstItemFocusRequester), - ).ifElse( - episodeIndex != position.episodeRowIndex, - Modifier - .background( - Color.Black, - shape = RoundedCornerShape(8.dp), - ).alpha(dimming), - ).onFocusChanged { - if (it.isFocused) { - scope.launch { - bringIntoViewRequester.bringIntoView() - } + val cornerText = + episode?.data?.indexNumber?.let { "E$it" } + ?: episode?.data?.premiereDate?.let(::formatDateTime) + BannerCard( + name = episode?.name, + item = episode, + aspectRatio = + episode + ?.aspectRatio + ?.coerceAtLeast(AspectRatios.FOUR_THREE) + ?: (AspectRatios.WIDE), + cornerText = cornerText, + played = episode?.data?.userData?.played ?: false, + playPercent = + episode?.data?.userData?.playedPercentage + ?: 0.0, + onClick = { if (episode != null) onClick.invoke(episode) }, + onLongClick = { + if (episode != null) { + onLongClick.invoke( + episode, + ) + } + }, + modifier = + Modifier + .ifElse( + episodeIndex == position.episodeRowIndex, + Modifier.focusRequester(firstItemFocusRequester), + ).ifElse( + episodeIndex != position.episodeRowIndex, + Modifier + .background( + Color.Black, + shape = RoundedCornerShape(8.dp), + ).alpha(dimming), + ).onFocusChanged { + if (it.isFocused) { + scope.launch { + bringIntoViewRequester.bringIntoView() } - }.onKeyEvent { - if (episode != null && isPlayKeyUp(it)) { - onClick.invoke(episode) - return@onKeyEvent true - } - return@onKeyEvent false - }, - interactionSource = interactionSource, - cardHeight = 120.dp, - ) - } + } + }.onKeyEvent { + if (episode != null && isPlayKeyUp(it)) { + onClick.invoke(episode) + return@onKeyEvent true + } + return@onKeyEvent false + }, + interactionSource = interactionSource, + cardHeight = 120.dp, + ) } } } +// } } focusedEpisode?.let { ep -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 5dbe62f8..a1a4edf6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -13,7 +13,6 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.preferences.ThemeSongVolume -import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager @@ -26,8 +25,10 @@ import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.equalsNotNull +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.lt import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast @@ -37,11 +38,19 @@ import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler 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 dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -57,11 +66,10 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import timber.log.Timber import java.util.UUID -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = SeriesViewModel.Factory::class) class SeriesViewModel - @Inject + @AssistedInject constructor( api: ApiClient, @param:ApplicationContext val context: Context, @@ -76,11 +84,21 @@ class SeriesViewModel val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + @Assisted val seriesId: UUID, + @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, + @Assisted val seriesPageType: SeriesPageType, ) : ItemViewModel(api) { - private lateinit var seriesId: UUID - private lateinit var prefs: UserPreferences + @AssistedFactory + interface Factory { + fun create( + seriesId: UUID, + seasonEpisodeIds: SeasonEpisodeIds?, + seriesPageType: SeriesPageType, + ): SeriesViewModel + } + val loading = MutableLiveData(LoadingState.Loading) - val seasons = MutableLiveData>(listOf()) + val seasons = MutableLiveData>(listOf()) val episodes = MutableLiveData(EpisodeList.Loading) val trailers = MutableLiveData>(listOf()) @@ -90,48 +108,76 @@ class SeriesViewModel val peopleInEpisode = MutableLiveData(PeopleInItem()) - fun init( - prefs: UserPreferences, - itemId: UUID, - seasonEpisodeIds: SeasonEpisodeIds?, - loadAdditionalDetails: Boolean, - ) { - this.seriesId = itemId - this.prefs = prefs + val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) + + init { viewModelScope.launch( LoadingExceptionHandler( loading, "Error loading series $seriesId", ) + Dispatchers.IO, ) { + Timber.v("Start") val item = fetchItem(seriesId) backdropService.submit(item) - val seasons = getSeasons(item) - // If a particular season was requested, fetch those episodes, otherwise get the first season - val initialSeason = - if (seasonEpisodeIds != null) { - seasons.firstOrNull { - equalsNotNull(it.id, seasonEpisodeIds.seasonId) || - equalsNotNull(it.indexNumber, seasonEpisodeIds.seasonNumber) + val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) + + val episodeListDeferred = + if (seriesPageType == SeriesPageType.OVERVIEW) { + viewModelScope.async(Dispatchers.IO) { + if (seasonEpisodeIds != null) { + loadEpisodesInternal( + seasonEpisodeIds.seasonId, + seasonEpisodeIds.episodeId, + seasonEpisodeIds.episodeNumber, + ) + } else { + seasonsDeferred.await().firstOrNull()?.let { + loadEpisodesInternal( + it.id, + null, + null, + ) + } ?: EpisodeList.Error(message = "Could not determine season") + } } } else { - seasons.firstOrNull() + CompletableDeferred(value = EpisodeList.Loading) } - val episodeInfo = - initialSeason?.let { - loadEpisodesInternal( - it.id, - seasonEpisodeIds?.episodeId, - seasonEpisodeIds?.episodeNumber, - ) - } ?: EpisodeList.Error("Could not determine season for selected episode") + val seasons = seasonsDeferred.await() + val episodes = episodeListDeferred.await() + Timber.v("Done") + + if (seriesPageType == SeriesPageType.OVERVIEW && seasonEpisodeIds != null) { + viewModelScope.launchIO { + val index = + (seasons as? ApiRequestPager<*>)?.let { + findIndexOf( + seasonEpisodeIds.seasonNumber, + seasonEpisodeIds.seasonId, + it, + ) + } ?: 0 + Timber.v("Got initial season index: $index") + position.update { + it.copy(seasonTabIndex = index) + } + } + } + withContext(Dispatchers.Main) { + this@SeriesViewModel.position.update { + it.copy( + episodeRowIndex = + (episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0, + ) + } this@SeriesViewModel.seasons.value = seasons - episodes.value = episodeInfo + this@SeriesViewModel.episodes.value = episodes loading.value = LoadingState.Success } - if (loadAdditionalDetails) { + if (seriesPageType == SeriesPageType.DETAILS) { viewModelScope.launchIO { val trailers = trailerService.getTrailers(item) withContext(Dispatchers.Main) { @@ -153,7 +199,7 @@ class SeriesViewModel .getSimilarItems( GetSimilarItemsRequest( userId = serverRepository.currentUser.value?.id, - itemId = itemId, + itemId = seriesId, fields = SlimItemFields, limit = 25, ), @@ -185,31 +231,45 @@ class SeriesViewModel themeSongPlayer.stop() } - private suspend fun getSeasons(series: BaseItem): List { - val request = - GetItemsRequest( - parentId = series.id, - recursive = false, - includeItemTypes = listOf(BaseItemKind.SEASON), - sortBy = listOf(ItemSortBy.INDEX_NUMBER), - sortOrder = listOf(SortOrder.ASCENDING), - fields = - listOf( - ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.CHILD_COUNT, - ItemFields.SEASON_USER_DATA, - ), - ) - val seasons = - GetItemsRequestHandler.execute(api, request).content.items.map { - BaseItem.from( - it, - api, + private fun getSeasons( + series: BaseItem, + seasonNum: Int?, + ): Deferred> = + viewModelScope.async(Dispatchers.IO) { + val request = + GetItemsRequest( + parentId = series.id, + recursive = false, + includeItemTypes = listOf(BaseItemKind.SEASON), + sortBy = listOf(ItemSortBy.INDEX_NUMBER), + sortOrder = listOf(SortOrder.ASCENDING), + fields = + if (seriesPageType == SeriesPageType.DETAILS) { + listOf( + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ) + } else { + null + }, ) - } - Timber.v("Loaded ${seasons.size} seasons for series ${series.id}") - return seasons - } + val pager = + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + pageSize = 10, + ).init(seasonNum ?: 0) +// val seasons = +// GetItemsRequestHandler.execute(api, request).content.items.map { +// BaseItem.from( +// it, +// api, +// ) +// } +// Timber.v("Loaded ${seasons.size} seasons for series ${series.id}") + pager + } private suspend fun loadEpisodesInternal( seasonId: UUID, @@ -233,14 +293,11 @@ class SeriesViewModel ), ) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) - pager.init() + pager.init(episodeNumber ?: 0) val initialIndex = if (episodeId != null || episodeNumber != null) { - pager - .indexOfBlocking { - equalsNotNull(it?.id, episodeId) || - equalsNotNull(it?.indexNumber, episodeNumber) - }.coerceAtLeast(0) + findIndexOf(episodeNumber, episodeId, pager) + .coerceAtLeast(0) } else { // Force the first page to to be fetched if (pager.isNotEmpty()) { @@ -272,7 +329,7 @@ class SeriesViewModel if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { (episodes as? EpisodeList.Success) ?.let { - it.episodes.getOrNull(it.initialIndex) + it.episodes.getOrNull(it.initialEpisodeIndex) }?.let { lookupPeopleInEpisode(it) } } } @@ -312,7 +369,7 @@ class SeriesViewModel ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { setWatched(seasonId, played, null) val series = fetchItem(seriesId) - val seasons = getSeasons(series) + val seasons = getSeasons(series, null).await() this@SeriesViewModel.seasons.setValueOnMain(seasons) } @@ -320,7 +377,7 @@ class SeriesViewModel viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setWatched(seriesId, played) val series = fetchItem(seriesId) - val seasons = getSeasons(series) + val seasons = getSeasons(series, null).await() this@SeriesViewModel.seasons.setValueOnMain(seasons) } @@ -469,7 +526,7 @@ sealed interface EpisodeList { data class Success( val seasonId: UUID, val episodes: ApiRequestPager, - val initialIndex: Int, + val initialEpisodeIndex: Int, ) : EpisodeList } @@ -477,3 +534,49 @@ data class PeopleInItem( val itemId: UUID? = null, val people: List = listOf(), ) + +enum class SeriesPageType { + DETAILS, + OVERVIEW, +} + +private suspend fun findIndexOf( + targetNum: Int?, + targetId: UUID?, + pager: ApiRequestPager<*>, +): Int { + val index = + if (targetId != null && (targetNum == null || targetNum !in pager.indices)) { + // No hint info, so have to check everything + pager.indexOfBlocking { equalsNotNull(it?.id, targetId) } + } else if (targetNum != null && targetNum in pager.indices) { + // Start searching from the season number and choose direction from there + val num = pager.getBlocking(targetNum)?.indexNumber + if (num.lt(targetNum)) { + for (i in targetNum + 1 until pager.lastIndex) { + val season = pager.getBlocking(i) + if (equalsNotNull(season?.indexNumber, targetNum) || + equalsNotNull(season?.id, targetId) + ) { + return i + } + } + return 0 + } else if (num.gt(targetNum)) { + for (i in targetNum - 1 downTo 0) { + val season = pager.getBlocking(i) + if (equalsNotNull(season?.indexNumber, targetNum) || + equalsNotNull(season?.id, targetId) + ) { + return i + } + } + return 0 + } else { + targetNum + } + } else { + 0 + } + return index +} 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 ad1c2970..7370398c 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 @@ -91,7 +91,9 @@ class HomeViewModel ), ) { Timber.d("init HomeViewModel") - backdropService.clearBackdrop() + if (reload) { + backdropService.clearBackdrop() + } serverRepository.currentUserDto.value?.let { userDto -> val includedIds = 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 4cc50eec..a2b2a764 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 @@ -79,10 +79,10 @@ fun DestinationContent( is Destination.SeriesOverview -> { SeriesOverview( - preferences, - destination, - modifier, + preferences = preferences, + destination = destination, initialSeasonEpisode = destination.seasonEpisode, + modifier = modifier, ) }