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,
)
}