diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d81c5e18..56513c4b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -43,7 +43,7 @@ jobs: - name: Build app id: buildapp run: | - ./gradlew clean assembleDebug --no-daemon + ./gradlew clean assembleDebug testDebugUnitTest --no-daemon apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" - name: Tar build dirs diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7a8cea71..e997870d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,6 +59,7 @@ android { debug { isMinifyEnabled = false isDebuggable = true + applicationIdSuffix = ".debug" } } compileOptions { @@ -230,6 +231,9 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) implementation(libs.protobuf.kotlin.lite) + implementation(libs.androidx.tvprovider) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.datasource.okhttp) @@ -266,6 +270,7 @@ dependencies { implementation(libs.androidx.palette.ktx) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) + ksp(libs.androidx.hilt.compiler) implementation(libs.timber) implementation(libs.slf4j2.timber) @@ -291,5 +296,6 @@ dependencies { implementation(files("libs/lib-decoder-ffmpeg-release.aar")) } -// implementation(project(":seerr-api")) + testImplementation(libs.mockk.android) + testImplementation(libs.mockk.agent) } 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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d84299e..9edf3067 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -11,6 +12,7 @@ + + 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..92398cb5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin +import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -13,13 +14,16 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -32,7 +36,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 @@ -47,10 +50,13 @@ import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme @@ -58,11 +64,9 @@ 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.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject @@ -105,12 +109,15 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var userSwitchListener: UserSwitchListener + @Inject + lateinit var tvProviderSchedulerService: TvProviderSchedulerService + private var signInAuto = true @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) @@ -124,6 +131,7 @@ class MainActivity : AppCompatActivity() { } } viewModel.appStart() + val requestedDestination = this.intent?.let(::extractDestination) setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) appPreferences?.let { appPreferences -> @@ -217,20 +225,41 @@ class MainActivity : AppCompatActivity() { appPreferences, ) val preferences = - remember(appPreferences, current) { - UserPreferences( - appPreferences, - current.userDto.configuration - ?: DefaultUserConfiguration, + remember(appPreferences) { + UserPreferences(appPreferences) + } + var showContent by remember { + mutableStateOf(true) + } + if (!preferences.appPreferences.signInAutomatically) { + LifecycleStartEffect(Unit) { + onStopOrDispose { + showContent = false + } + } + } + if (showContent) { + ApplicationContent( + user = current.user, + server = current.server, + startDestination = + requestedDestination + ?: Destination.Home(), + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), ) } - ApplicationContent( - user = current.user, - server = current.server, - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) + } } } } @@ -246,6 +275,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() + Timber.d("onResume") lifecycleScope.launchIO { appUpgradeHandler.run() } @@ -253,7 +283,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 +294,85 @@ class MainActivity : AppCompatActivity() { // serverRepository.closeSession() // } } + + override fun onStop() { + super.onStop() + Timber.d("onStop") + tvProviderSchedulerService.launchOneTimeRefresh() + } + + 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") + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + Timber.v("onNewIntent") + extractDestination(intent)?.let { + navigationManager.replace(it) + } + } + + private fun extractDestination(intent: Intent): Destination? = + intent.let { + val itemId = + it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull() + val type = + it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull) + if (itemId != null && type != null) { + val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull() + val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull() + val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1) + val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1) + if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) { + Destination.SeriesOverview( + itemId = seriesId, + type = BaseItemKind.SERIES, + seasonEpisode = + SeasonEpisodeIds( + seasonId = seasonId, + seasonNumber = seasonNumber, + episodeId = itemId, + episodeNumber = episodeNumber, + ), + ) + } else { + Destination.MediaItem(itemId, type) + } + } else { + null + } + } + + companion object { + const val INTENT_ITEM_ID = "itemId" + const val INTENT_ITEM_TYPE = "itemType" + const val INTENT_SERIES_ID = "seriesId" + const val INTENT_EPISODE_NUMBER = "epNum" + const val INTENT_SEASON_NUMBER = "seaNum" + const val INTENT_SEASON_ID = "seaId" + } } @HiltViewModel @@ -277,40 +386,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/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index 3368091a..ae4b5d28 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -7,6 +7,8 @@ import android.os.StrictMode.ThreadPolicy import android.util.Log import androidx.compose.runtime.Composer import androidx.compose.runtime.ExperimentalComposeRuntimeApi +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import org.acra.ACRA import org.acra.ReportField @@ -14,10 +16,13 @@ import org.acra.config.dialog import org.acra.data.StringFormat import org.acra.ktx.initAcra import timber.log.Timber +import javax.inject.Inject @OptIn(ExperimentalComposeRuntimeApi::class) @HiltAndroidApp -class WholphinApplication : Application() { +class WholphinApplication : + Application(), + Configuration.Provider { init { instance = this @@ -94,6 +99,16 @@ class WholphinApplication : Application() { ACRA.errorReporter.putCustomData("SDK_INT", Build.VERSION.SDK_INT.toString()) } + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration: Configuration + get() = + Configuration + .Builder() + .setWorkerFactory(workerFactory) + .build() + companion object { lateinit var instance: WholphinApplication private set diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 00bc0fb7..17ad9f0f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -61,7 +61,7 @@ class ItemPlaybackRepository ) val subtitleStream = streamChoiceService.chooseSubtitleStream( - audioStream = audioStream, + audioStreamLang = audioStream?.language, candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } 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..1e5cade5 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 @@ -229,7 +242,6 @@ class ServerRepository } suspend fun switchServerOrUser() { - apiClient.update(baseUrl = null, accessToken = null) userPreferencesDataStore.updateData { it .toBuilder() @@ -271,5 +283,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/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 164035e7..cab8609e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -31,7 +31,7 @@ class ImageUrlService imageType: ImageType, fillWidth: Int? = null, fillHeight: Int? = null, - ): String = + ): String? = when (imageType) { ImageType.BACKDROP, ImageType.LOGO, @@ -124,8 +124,9 @@ class ImageUrlService backgroundColor: String? = null, foregroundLayer: String? = null, imageIndex: Int? = null, - ): String = - api.imageApi.getItemImageUrl( + ): String? { + if (api.baseUrl.isNullOrBlank()) return null + return api.imageApi.getItemImageUrl( itemId = itemId, imageType = imageType, maxWidth = maxWidth, @@ -144,6 +145,7 @@ class ImageUrlService foregroundLayer = foregroundLayer, imageIndex = imageIndex, ) + } fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt new file mode 100644 index 00000000..44ed1a58 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -0,0 +1,190 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.main.LatestData +import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.supportItemKinds +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest +import timber.log.Timber +import java.time.LocalDateTime +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +@Singleton +class LatestNextUpService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val datePlayedService: DatePlayedService, + ) { + suspend fun getResume( + userId: UUID, + limit: Int, + includeEpisodes: Boolean, + ): List { + val request = + GetResumeItemsRequest( + userId = userId, + fields = SlimItemFields, + limit = limit, + includeItemTypes = + if (includeEpisodes) { + supportItemKinds + } else { + supportItemKinds + .toMutableSet() + .apply { + remove(BaseItemKind.EPISODE) + } + }, + ) + val items = + api.itemsApi + .getResumeItems(request) + .content + .items + .map { BaseItem.from(it, api, true) } + return items + } + + suspend fun getNextUp( + userId: UUID, + limit: Int, + enableRewatching: Boolean, + enableResumable: Boolean, + ): List { + val request = + GetNextUpRequest( + userId = userId, + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = null, + limit = limit, + enableResumable = enableResumable, + enableUserData = true, + enableRewatching = enableRewatching, + ) + val nextUp = + api.tvShowsApi + .getNextUp(request) + .content + .items + .map { BaseItem.from(it, api, true) } + return nextUp + } + + suspend fun getLatest( + user: UserDto, + limit: Int, + includedIds: List, + ): List { + val excluded = user.configuration?.latestItemsExcludes.orEmpty() + val views by api.userViewsApi.getUserViews() + val latestData = + views.items + .filter { + it.id in includedIds && it.id !in excluded && + it.collectionType in supportedLatestCollectionTypes + }.map { view -> + val title = + view.name?.let { context.getString(R.string.recently_added_in, it) } + ?: context.getString(R.string.recently_added) + val request = + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = view.id, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + LatestData(title, request) + } + + return latestData + } + + suspend fun loadLatest(latestData: List): List { + val rows = + latestData.mapNotNull { (title, request) -> + try { + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem.from(it, api, true) } + if (latest.isNotEmpty()) { + HomeRowLoadingState.Success( + title = title, + items = latest, + ) + } else { + null + } + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching %s", title) + HomeRowLoadingState.Error( + title = title, + exception = ex, + ) + } + } + return rows + } + + suspend fun buildCombined( + resume: List, + nextUp: List, + ): List = + withContext(Dispatchers.IO) { + val start = System.currentTimeMillis() + val semaphore = Semaphore(3) + val deferred = + nextUp + .filter { it.data.seriesId != null } + .map { item -> + async(Dispatchers.IO) { + try { + semaphore.withPermit { + datePlayedService.getLastPlayed(item) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching %s", item.id) + null + } + } + } + + val nextUpLastPlayed = deferred.awaitAll() + val timestamps = mutableMapOf() + nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) + resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } + val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } + val duration = (System.currentTimeMillis() - start).milliseconds + Timber.v("buildCombined took %s", duration) + return@withContext result + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 136cb746..63a85cef 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -68,9 +68,20 @@ class NavigationManager log() } + /** + * Resets the backstack to the specified destination + */ + fun replace(destination: Destination) { + while (backStack.size > 1) { + backStack.removeLastOrNull() + } + backStack[0] = destination + log() + } + private fun log() { val dest = backStack.lastOrNull().toString() - Timber.Forest.i("Current Destination: %s", dest) + Timber.i("Current Destination: %s", dest) ACRA.errorReporter.putCustomData("destination", dest) } } 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..ada96c57 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 = @@ -150,7 +153,7 @@ class StreamChoiceService return source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } chooseSubtitleStream( - audioStream, + audioStream?.language, candidates, itemPlayback, plc, @@ -160,7 +163,7 @@ class StreamChoiceService } fun chooseSubtitleStream( - audioStream: MediaStream?, + audioStreamLang: String?, candidates: List, itemPlayback: ItemPlayback?, playbackLanguageChoice: PlaybackLanguageChoice?, @@ -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,51 +195,62 @@ class StreamChoiceService else -> { // Fallback to the user's preference - prefs.userConfig.subtitleMode + userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT } } + val candidates = + candidates.sortedWith( + compareBy( + { it.isDefault }, + { !it.isForced && it.language.equals(subtitleLanguage, true) }, + { it.isForced && it.language.equals(subtitleLanguage, true) }, + { it.isForced && it.language.isUnknown }, + { it.isForced }, + ), + ) return when (subtitleMode) { SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage != null) { - candidates.firstOrNull { it.language == subtitleLanguage } + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { + it.language.equals(subtitleLanguage, true) || + it.language.isUnknown + } } else { candidates.firstOrNull() } } SubtitlePlaybackMode.ONLY_FORCED -> { - if (subtitleLanguage != null) { + if (subtitleLanguage.isNotNullOrBlank()) { candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } + ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } } else { candidates.firstOrNull { it.isForced } } } SubtitlePlaybackMode.SMART -> { - val audioLanguage = prefs.userConfig.audioLanguagePreference - val audioStreamLang = audioStream?.language - if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) { - candidates.firstOrNull { it.language == subtitleLanguage } + if (subtitleLanguage.isNotNullOrBlank()) { + val audioLanguage = userConfig?.audioLanguagePreference + if (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) { + candidates.firstOrNull { it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.language.isUnknown } + } else { + candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } + } } else { - null + candidates.firstOrNull { it.isForced } } } SubtitlePlaybackMode.DEFAULT -> { - subtitleLanguage?.let { lang -> - // Find best track that is in the preferred language - ( - candidates.firstOrNull { it.isDefault && it.isForced && it.language == lang } - ?: candidates.firstOrNull { it.isDefault && it.language == lang } - ?: candidates.firstOrNull { it.isForced && it.language == lang } - ) + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } + ?: candidates.firstOrNull { it.isDefault || it.isForced } + } else { + candidates.firstOrNull { it.isDefault || it.isForced } } - ?: ( - // If none in preferred language, just find the best track - candidates.firstOrNull { it.isDefault && it.isForced } - ?: candidates.firstOrNull { it.isDefault } - ?: candidates.firstOrNull { it.isForced } - ) } SubtitlePlaybackMode.NONE -> { @@ -246,3 +260,12 @@ class StreamChoiceService } } } + +private val String?.isUnknown: Boolean + get() = + this == null || + this.equals("unknown", true) || + this.equals("und", true) || + this.equals("undetermined", true) || + this.equals("mul", true) || + this.equals("zxx", true) 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/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt new file mode 100644 index 00000000..05ce1d2a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -0,0 +1,89 @@ +package com.github.damontecres.wholphin.services.tvprovider + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.work.BackoffPolicy +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.await +import androidx.work.workDataOf +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.ExceptionHandler +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import timber.log.Timber +import javax.inject.Inject +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toJavaDuration + +@ActivityScoped +class TvProviderSchedulerService + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + ) { + private val activity = (context as AppCompatActivity) + private val workManager = WorkManager.getInstance(context) + + private val supportsTvProvider = + // TODO <=25 has limited support + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + + init { + serverRepository.current.observe(activity) { user -> + workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME) + if (supportsTvProvider) { + if (user != null) { + activity.lifecycleScope.launchIO(ExceptionHandler()) { + Timber.i("Scheduling TvProviderWorker for ${user.user}") + workManager + .enqueueUniquePeriodicWork( + uniqueWorkName = TvProviderWorker.WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = + PeriodicWorkRequestBuilder( + repeatInterval = 1.hours.toJavaDuration(), + ).setBackoffCriteria( + BackoffPolicy.LINEAR, + 15.minutes.toJavaDuration(), + ).setInputData( + workDataOf( + TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), + TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), + ), + ).build(), + ).await() + } + } + } + } + } + + fun launchOneTimeRefresh() { + if (supportsTvProvider) { + activity.lifecycleScope.launchIO(ExceptionHandler()) { + serverRepository.current.value?.let { user -> + Timber.i("Scheduling on-time TvProviderWorker for ${user.user}") + workManager.enqueue( + OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), + TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), + ), + ).build(), + ) + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt new file mode 100644 index 00000000..1d54f256 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -0,0 +1,245 @@ +package com.github.damontecres.wholphin.services.tvprovider + +import android.content.Context +import android.content.Intent +import android.database.Cursor +import androidx.core.net.toUri +import androidx.datastore.core.DataStore +import androidx.hilt.work.HiltWorker +import androidx.tvprovider.media.tv.TvContractCompat +import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms +import androidx.tvprovider.media.tv.WatchNextProgram +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.github.damontecres.wholphin.MainActivity +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LatestNextUpService +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.firstOrNull +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.extensions.ticks +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.time.ZoneId +import java.util.Date +import java.util.UUID +import kotlin.time.Duration.Companion.minutes + +@HiltWorker +class TvProviderWorker + @AssistedInject + constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val serverRepository: ServerRepository, + private val preferences: DataStore, + private val api: ApiClient, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + ) : CoroutineWorker(context, workerParams) { + override suspend fun doWork(): Result { + Timber.d("Start") + val serverId = + inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() + val userId = + inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() + + if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { + // Not active + var currentUser = serverRepository.current.value + if (currentUser == null) { + serverRepository.restoreSession(serverId, userId) + currentUser = serverRepository.current.value + } + if (currentUser == null) { + Timber.w("No user found during run") + return Result.failure() + } + } + try { + val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + val potentialItemsToAdd = + getPotentialItems( + userId, + prefs.homePagePreferences.enableRewatchingNextUp, + prefs.homePagePreferences.combineContinueNext, + ) + val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } + + Timber.v("potentialItemsToAddIds=%s", potentialItemsToAddIds) + val currentItems = getCurrentTvChannelNextUp() + val currentItemIds = currentItems.map { it.internalProviderId } + + val toRemove = + currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds } + + val userRemoved = currentItems.filterNot { it.isBrowsable } + val userRemovedIds = userRemoved.map { it.internalProviderId } + Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId }) + val toAdd = + potentialItemsToAdd.filterNot { it.id.toString() in currentItemIds && it.id.toString() in userRemovedIds } + Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id }) + + // Remove existing items if they are no longer in the next up from server + (toRemove + userRemoved) + .map { TvContractCompat.buildWatchNextProgramUri(it.id) } + .forEach { + context.contentResolver.delete(it, null, null) + } + + // Add new ones + val addedCount = + context.contentResolver.bulkInsert( + WatchNextPrograms.CONTENT_URI, + toAdd + .map { convert(it).toContentValues() } + .toTypedArray(), + ) + Timber.v("Added %s", addedCount) + + Timber.d("Completed successfully") + } catch (_: ApiClientException) { + return Result.retry() + } catch (_: Exception) { + return Result.failure() + } + return Result.success() + } + + private suspend fun getPotentialItems( + userId: UUID, + enableRewatching: Boolean, + combineContinueNext: Boolean, + ): List { + val resumeItems = latestNextUpService.getResume(userId, 10, true) + val seriesIds = resumeItems.mapNotNull { it.data.seriesId } + val nextUpItems = + latestNextUpService + .getNextUp(userId, 10, enableRewatching, false) + .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } + return if (combineContinueNext) { + latestNextUpService.buildCombined(resumeItems, nextUpItems) + } else { + resumeItems + nextUpItems + } + } + + private suspend fun getCurrentTvChannelNextUp(): List = + context.contentResolver + .query( + WatchNextPrograms.CONTENT_URI, + WatchNextProgram.PROJECTION, + null, + null, + null, + )?.map(WatchNextProgram::fromCursor) + .orEmpty() + + private fun convert(item: BaseItem): WatchNextProgram = + WatchNextProgram + .Builder() + .apply { + val dto = item.data + setInternalProviderId(item.id.toString()) + + val type = + when (item.type) { + BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE + BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE + else -> WatchNextPrograms.TYPE_CLIP + } + setType(type) + + val resumePosition = dto.userData?.playbackPositionTicks?.ticks + if (resumePosition != null && resumePosition >= 2.minutes) { + // https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content + setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) + setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt()) + } else { + setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT) + } + dto.runTimeTicks + ?.ticks + ?.inWholeMilliseconds + ?.toInt() + ?.let(::setDurationMillis) + + setLastEngagementTimeUtcMillis( + dto.userData + ?.lastPlayedDate + ?.atZone(ZoneId.systemDefault()) + ?.toEpochSecond() + ?: Date().time, // TODO + ) + + setTitle(item.title) + setDescription(dto.overview) + if (item.type == BaseItemKind.EPISODE) { + setEpisodeTitle(item.name) + dto.indexNumber?.let(::setEpisodeNumber) + dto.parentIndexNumber?.let(::setSeasonNumber) + } + + setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9) + val imageType = + when (item.type) { + BaseItemKind.EPISODE -> ImageType.THUMB + else -> ImageType.PRIMARY + } + setPosterArtUri(imageUrlService.getItemImageUrl(item, imageType)!!.toUri()) + + setIntent( + Intent(context, MainActivity::class.java) + .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) + .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .apply { + if (item.type == BaseItemKind.EPISODE) { + putExtra( + MainActivity.INTENT_SERIES_ID, + dto.seriesId?.toString(), + ) + putExtra( + MainActivity.INTENT_SEASON_ID, + dto.seasonId?.toString(), + ) + dto.parentIndexNumber?.let { + putExtra( + MainActivity.INTENT_SEASON_NUMBER, + it, + ) + } + dto.indexNumber?.let { + putExtra( + MainActivity.INTENT_EPISODE_NUMBER, + it, + ) + } + } + }, + ) + }.build() + + companion object { + const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker" + const val PARAM_USER_ID = "userId" + const val PARAM_SERVER_ID = "serverId" + } + } + +fun Cursor.map(transform: (Cursor) -> T): List = + this.use { + buildList { + if (moveToFirst()) { + do { + add(transform.invoke(this@map)) + } while (moveToNext()) + } + } + } 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..c84045b3 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")) { @@ -96,7 +103,7 @@ class GenreViewModel loading.value = LoadingState.Success } // val excludeItemIds = mutableSetOf() - val genreToUrl = ConcurrentHashMap() + val genreToUrl = ConcurrentHashMap() val semaphore = Semaphore(4) genres .map { genre -> @@ -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/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 6e48692a..fa4071b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -12,12 +12,11 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.main.buildCombinedNextUp import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ExceptionHandler @@ -58,7 +57,7 @@ class RecommendedTvShowViewModel private val api: ApiClient, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, - private val datePlayedService: DatePlayedService, + private val lastestNextUpService: LatestNextUpService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, @@ -126,9 +125,7 @@ class RecommendedTvShowViewModel val nextUpItems = nextUpItemsDeferred.await() if (combineNextUp) { val combined = - buildCombinedNextUp( - viewModelScope, - datePlayedService, + lastestNextUpService.buildCombined( resumeItems, nextUpItems, ) 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/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index 4947fda6..8e6eb07f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -30,6 +32,7 @@ import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.VideoRange import org.jellyfin.sdk.model.api.VideoRangeType +import org.jellyfin.sdk.model.extensions.ticks import java.util.Locale data class ItemDetailsDialogInfo( @@ -54,33 +57,32 @@ fun ItemDetailsDialog( val subtitleLabel = stringResource(R.string.subtitle) val bitrateLabel = stringResource(R.string.bitrate) val unknown = stringResource(R.string.unknown) + val runtimeLabel = stringResource(R.string.runtime_sort) ScrollableDialog( onDismissRequest = onDismissRequest, - width = 720.dp, - maxHeight = 480.dp, - itemSpacing = 4.dp, + width = 680.dp, + maxHeight = 440.dp, + itemSpacing = 8.dp, ) { item { - Text( - text = info.title, - style = MaterialTheme.typography.titleLarge, - ) - } - if (info.genres.isNotEmpty()) { - item { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( - text = info.genres.joinToString(", "), - style = MaterialTheme.typography.titleSmall, - ) - } - } - if (info.overview.isNotNullOrBlank()) { - item { - Text( - text = info.overview, - style = MaterialTheme.typography.bodyLarge, + text = info.title, + style = MaterialTheme.typography.headlineSmall, ) + if (info.genres.isNotEmpty()) { + Text( + text = info.genres.joinToString(", "), + style = MaterialTheme.typography.titleSmall, + ) + } + if (info.overview.isNotNullOrBlank()) { + Text( + text = info.overview, + style = MaterialTheme.typography.bodyMedium, + ) + } } } @@ -89,13 +91,19 @@ fun ItemDetailsDialog( source.mediaStreams?.letNotEmpty { mediaStreams -> item { Spacer(Modifier.height(8.dp)) + HorizontalDivider() } // General file information item { val containerLabel = stringResource(R.string.container) MediaInfoSection( - title = stringResource(R.string.general), + title = + titleIndex( + stringResource(R.string.general), + index, + info.files.size, + ), items = buildList { source.container?.let { add(containerLabel to it) } @@ -111,31 +119,38 @@ fun ItemDetailsDialog( bitrateLabel to formatBytes(it, byteRateSuffixes), ) } + source.runTimeTicks?.let { + add(runtimeLabel to it.ticks.toString()) + } }, ) } // Video streams - items(mediaStreams.filter { it.type == MediaStreamType.VIDEO }) { stream -> + val videoStreams = mediaStreams.filter { it.type == MediaStreamType.VIDEO } + itemsIndexed(videoStreams) { index, stream -> MediaInfoSection( - title = videoLabel, - items = buildVideoStreamInfo(context, stream), + title = titleIndex(videoLabel, index, videoStreams.size), + items = remember { buildVideoStreamInfo(context, stream) }, + additional = remember { buildVideoStreamInfoAdditional(context, stream) }, ) } // Audio streams - display multiple per row - items( - mediaStreams - .filter { it.type == MediaStreamType.AUDIO } - .chunked(3), - ) { streamGroup -> + val audioStreams = mediaStreams.filter { it.type == MediaStreamType.AUDIO } + itemsIndexed(audioStreams.chunked(3)) { groupIndex, streamGroup -> Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - streamGroup.forEach { stream -> + streamGroup.forEachIndexed { index, stream -> MediaInfoSection( - title = audioLabel, + title = + titleIndex( + audioLabel, + groupIndex * 3 + index, + audioStreams.size, + ), items = buildAudioStreamInfo(context, stream), modifier = Modifier.weight(1f), ) @@ -148,19 +163,21 @@ fun ItemDetailsDialog( } // Subtitle streams - display multiple per row - items( - mediaStreams - .filter { it.type == MediaStreamType.SUBTITLE } - .chunked(3), - ) { streamGroup -> + val subtitleStreams = mediaStreams.filter { it.type == MediaStreamType.SUBTITLE } + itemsIndexed(subtitleStreams.chunked(3)) { groupIndex, streamGroup -> Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - streamGroup.forEach { stream -> + streamGroup.forEachIndexed { index, stream -> MediaInfoSection( - title = subtitleLabel, - items = buildSubtitleStreamInfo(context, stream), + title = + titleIndex( + subtitleLabel, + groupIndex * 3 + index, + subtitleStreams.size, + ), + items = buildSubtitleStreamInfo(context, stream, showFilePath), modifier = Modifier.weight(1f), ) } @@ -185,6 +202,7 @@ private fun MediaInfoSection( title: String, items: List>, modifier: Modifier = Modifier, + additional: List> = listOf(), ) { Column( verticalArrangement = Arrangement.spacedBy(2.dp), @@ -192,66 +210,86 @@ private fun MediaInfoSection( ) { Text( text = title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, ) - items.forEach { (label, value) -> - Row( - modifier = Modifier.padding(start = 12.dp), - ) { - Text( - text = "$label: ", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.padding(start = 12.dp), + ) { + Column(modifier = Modifier.weight(1f, fill = false)) { + items.forEach { (label, value) -> + Row( + modifier = Modifier, + ) { + Text( + text = "$label: ", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + if (additional.isNotEmpty()) { + Column(modifier = Modifier.weight(1f, fill = false)) { + additional.forEach { (label, value) -> + Row( + modifier = Modifier, + ) { + Text( + text = "$label: ", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } } } } } +fun titleIndex( + title: String, + index: Int, + total: Int, +) = if (total > 1) { + "$title (${index + 1})" +} else { + title +} + private fun buildVideoStreamInfo( context: Context, stream: MediaStream, ): List> = buildList { - val titleLabel = context.getString(R.string.title) - val codecLabel = context.getString(R.string.codec) - val avcLabel = context.getString(R.string.avc) - val profileLabel = context.getString(R.string.profile) - val levelLabel = context.getString(R.string.level) - val resolutionLabel = context.getString(R.string.resolution) - val aspectRatioLabel = context.getString(R.string.aspect_ratio) - val anamorphicLabel = context.getString(R.string.anamorphic) - val interlacedLabel = context.getString(R.string.interlaced) - val framerateLabel = context.getString(R.string.framerate) - val bitrateLabel = context.getString(R.string.bitrate) - val bitDepthLabel = context.getString(R.string.bit_depth) - val videoRangeLabel = context.getString(R.string.video_range) - val videoRangeTypeLabel = context.getString(R.string.video_range_type) - val colorSpaceLabel = context.getString(R.string.color_space) - val colorTransferLabel = context.getString(R.string.color_transfer) - val colorPrimariesLabel = context.getString(R.string.color_primaries) - val pixelFormatLabel = context.getString(R.string.pixel_format) - val refFramesLabel = context.getString(R.string.ref_frames) - val nalLabel = context.getString(R.string.nal) val yesStr = context.getString(R.string.yes) val noStr = context.getString(R.string.no) + + val titleLabel = context.getString(R.string.title) + val codecLabel = context.getString(R.string.codec) + val resolutionLabel = context.getString(R.string.resolution) + val aspectRatioLabel = context.getString(R.string.aspect_ratio) + val framerateLabel = context.getString(R.string.framerate) + val bitrateLabel = context.getString(R.string.bitrate) + val profileLabel = context.getString(R.string.profile) + val levelLabel = context.getString(R.string.level) + val interlacedLabel = context.getString(R.string.interlaced) + val videoRangeLabel = context.getString(R.string.video_range) val sdrStr = context.getString(R.string.sdr) val hdrStr = context.getString(R.string.hdr) - val hdr10Str = context.getString(R.string.hdr10) - val hdr10PlusStr = context.getString(R.string.hdr10_plus) - val hlgStr = context.getString(R.string.hlg) - val bitUnit = context.getString(R.string.bit_unit) stream.title?.let { add(titleLabel to it) } stream.codec?.let { add(codecLabel to it.uppercase()) } - stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } - stream.profile?.let { add(profileLabel to it) } - stream.level?.let { add(levelLabel to it.toString()) } if (stream.width != null && stream.height != null) { add(resolutionLabel to "${stream.width}x${stream.height}") } @@ -259,23 +297,55 @@ private fun buildVideoStreamInfo( val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!) add(aspectRatioLabel to aspectRatio) } - stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) } - stream.isInterlaced?.let { add(interlacedLabel to if (it) yesStr else noStr) } + stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } stream.averageFrameRate?.let { add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it)) } - stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } - stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") } - stream.videoRange?.let { + + stream.videoRange.let { val rangeStr = when (it) { VideoRange.SDR -> sdrStr VideoRange.HDR -> hdrStr VideoRange.UNKNOWN -> null - else -> null } rangeStr?.let { add(videoRangeLabel to it) } } + stream.profile?.let { add(profileLabel to it) } + stream.level?.let { add(levelLabel to it.toString()) } + stream.isInterlaced.let { add(interlacedLabel to if (it) yesStr else noStr) } + } + +private fun buildVideoStreamInfoAdditional( + context: Context, + stream: MediaStream, +): List> = + buildList { + val yesStr = context.getString(R.string.yes) + val noStr = context.getString(R.string.no) + + val avcLabel = context.getString(R.string.avc) + val anamorphicLabel = context.getString(R.string.anamorphic) + val bitDepthLabel = context.getString(R.string.bit_depth) + + val videoRangeTypeLabel = context.getString(R.string.video_range_type) + val colorSpaceLabel = context.getString(R.string.color_space) + val colorTransferLabel = context.getString(R.string.color_transfer) + val colorPrimariesLabel = context.getString(R.string.color_primaries) + val pixelFormatLabel = context.getString(R.string.pixel_format) + val refFramesLabel = context.getString(R.string.ref_frames) + val nalLabel = context.getString(R.string.nal) + val dolbyVisionLabel = context.getString(R.string.dolby_vision) + + val sdrStr = context.getString(R.string.sdr) + val hdr10Str = context.getString(R.string.hdr10) + val hdr10PlusStr = context.getString(R.string.hdr10_plus) + val hlgStr = context.getString(R.string.hlg) + val bitUnit = context.getString(R.string.bit_unit) + + stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } + stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) } + stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") } stream.videoRangeType?.let { val rangeTypeStr = when (it) { @@ -304,7 +374,8 @@ private fun buildVideoStreamInfo( stream.colorPrimaries?.let { add(colorPrimariesLabel to it) } stream.pixelFormat?.let { add(pixelFormatLabel to it) } stream.refFrames?.let { add(refFramesLabel to it.toString()) } - stream.nalLengthSize?.let { add(nalLabel to it.toString()) } + stream.nalLengthSize?.let { add(nalLabel to it) } + stream.videoDoViTitle?.let { add(dolbyVisionLabel to it) } } private fun buildAudioStreamInfo( @@ -341,17 +412,18 @@ private fun buildAudioStreamInfo( private fun buildSubtitleStreamInfo( context: Context, stream: MediaStream, + showPath: Boolean, ): List> = buildList { val titleLabel = context.getString(R.string.title) val languageLabel = context.getString(R.string.language) val codecLabel = context.getString(R.string.codec) - val avcLabel = context.getString(R.string.avc) val defaultLabel = context.getString(R.string.default_track) val forcedLabel = context.getString(R.string.forced_track) val externalLabel = context.getString(R.string.external_track) val yesStr = context.getString(R.string.yes) val noStr = context.getString(R.string.no) + val pathLabel = context.getString(R.string.path) stream.title?.let { add(titleLabel to it) } stream.language?.let { add(languageLabel to languageName(it)) } @@ -359,10 +431,15 @@ private fun buildSubtitleStreamInfo( val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase() add(codecLabel to formattedCodec) } - stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } stream.isForced?.let { add(forcedLabel to if (it) yesStr else noStr) } stream.isExternal?.let { add(externalLabel to if (it) yesStr else noStr) } + stream.isHearingImpaired?.let { + add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr) + } + if (showPath) { + stream.path?.let { pathLabel to it } + } } private fun calculateAspectRatio( 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/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index e19f44aa..32101cbf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -48,6 +48,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress @@ -360,13 +361,21 @@ fun HomePageContent( .fillMaxWidth() .animateItem(), cardContent = { index, item, cardModifier, onClick, onLongClick -> + val cornerText = + remember { + item?.data?.indexNumber?.let { "E$it" } + ?: item + ?.data + ?.userData + ?.unplayedItemCount + ?.takeIf { it > 0 } + ?.let { abbreviateNumber(it) } + } BannerCard( name = item?.data?.seriesName ?: item?.name, item = item, aspectRatio = AspectRatios.TALL, - cornerText = - item?.data?.indexNumber?.let { "E$it" } - ?: item?.data?.childCount?.let { if (it > 0) it.toString() else null }, + cornerText = cornerText, played = item?.data?.userData?.played ?: false, favorite = item?.favorite ?: false, playPercent = 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..e2704ad8 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 @@ -12,8 +12,8 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.setValueOnMain @@ -21,34 +21,18 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState -import com.github.damontecres.wholphin.util.supportItemKinds import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest -import org.jellyfin.sdk.model.api.request.GetNextUpRequest -import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import timber.log.Timber -import java.time.LocalDateTime import java.util.UUID import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class HomeViewModel @@ -61,6 +45,7 @@ class HomeViewModel val navDrawerItemRepository: NavDrawerItemRepository, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, + private val latestNextUpService: LatestNextUpService, private val backdropService: BackdropService, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) @@ -91,7 +76,9 @@ class HomeViewModel ), ) { Timber.d("init HomeViewModel") - backdropService.clearBackdrop() + if (reload) { + backdropService.clearBackdrop() + } serverRepository.currentUserDto.value?.let { userDto -> val includedIds = @@ -99,10 +86,9 @@ class HomeViewModel .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) .filter { it is ServerNavDrawerItem } .map { (it as ServerNavDrawerItem).itemId } - // TODO data is fetched all together which may be slow for large servers - val resume = getResume(userDto.id, limit, true) + val resume = latestNextUpService.getResume(userDto.id, limit, true) val nextUp = - getNextUp( + latestNextUpService.getNextUp( userDto.id, limit, prefs.enableRewatchingNextUp, @@ -111,13 +97,7 @@ class HomeViewModel val watching = buildList { if (prefs.combineContinueNext) { - val items = - buildCombinedNextUp( - viewModelScope, - datePlayedService, - resume, - nextUp, - ) + val items = latestNextUpService.buildCombined(resume, nextUp) add( HomeRowLoadingState.Success( title = context.getString(R.string.continue_watching), @@ -144,7 +124,7 @@ class HomeViewModel } } - val latest = getLatest(userDto, limit, includedIds) + val latest = latestNextUpService.getLatest(userDto, limit, includedIds) val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } withContext(Dispatchers.Main) { @@ -154,127 +134,13 @@ class HomeViewModel } loadingState.value = LoadingState.Success } - loadLatest(latest) refreshState.setValueOnMain(LoadingState.Success) + val loadedLatest = latestNextUpService.loadLatest(latest) + this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) } } } - private suspend fun getResume( - userId: UUID, - limit: Int, - includeEpisodes: Boolean, - ): List { - val request = - GetResumeItemsRequest( - userId = userId, - fields = SlimItemFields, - limit = limit, - includeItemTypes = - if (includeEpisodes) { - supportItemKinds - } else { - supportItemKinds - .toMutableSet() - .apply { - remove(BaseItemKind.EPISODE) - } - }, - ) - val items = - api.itemsApi - .getResumeItems(request) - .content - .items - .map { BaseItem.from(it, api, true) } - return items - } - - private suspend fun getNextUp( - userId: UUID, - limit: Int, - enableRewatching: Boolean, - enableResumable: Boolean, - ): List { - val request = - GetNextUpRequest( - userId = userId, - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = null, - limit = limit, - enableResumable = enableResumable, - enableUserData = true, - enableRewatching = enableRewatching, - ) - val nextUp = - api.tvShowsApi - .getNextUp(request) - .content - .items - .map { BaseItem.from(it, api, true) } - return nextUp - } - - private suspend fun getLatest( - user: UserDto, - limit: Int, - includedIds: List, - ): List { - val excluded = user.configuration?.latestItemsExcludes.orEmpty() - val views by api.userViewsApi.getUserViews() - val latestData = - views.items - .filter { - it.id in includedIds && it.id !in excluded && - it.collectionType in supportedLatestCollectionTypes - }.map { view -> - val title = - view.name?.let { context.getString(R.string.recently_added_in, it) } - ?: context.getString(R.string.recently_added) - val request = - GetLatestMediaRequest( - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = view.id, - groupItems = true, - limit = limit, - isPlayed = null, // Server will handle user's preference - ) - LatestData(title, request) - } - - return latestData - } - - private suspend fun loadLatest(latestData: List) { - val rows = - latestData.mapNotNull { (title, request) -> - try { - val latest = - api.userLibraryApi - .getLatestMedia(request) - .content - .map { BaseItem.from(it, api, true) } - if (latest.isNotEmpty()) { - HomeRowLoadingState.Success( - title = title, - items = latest, - ) - } else { - null - } - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching %s", title) - HomeRowLoadingState.Error( - title = title, - exception = ex, - ) - } - } - latestRows.setValueOnMain(rows) - } - fun setWatched( itemId: UUID, played: Boolean, @@ -315,38 +181,3 @@ data class LatestData( val title: String, val request: GetLatestMediaRequest, ) - -suspend fun buildCombinedNextUp( - scope: CoroutineScope, - datePlayedService: DatePlayedService, - resume: List, - nextUp: List, -): List = - withContext(Dispatchers.IO) { - val start = System.currentTimeMillis() - val semaphore = Semaphore(3) - val deferred = - nextUp - .filter { it.data.seriesId != null } - .map { item -> - scope.async(Dispatchers.IO) { - try { - semaphore.withPermit { - datePlayedService.getLastPlayed(item) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching %s", item.id) - null - } - } - } - - val nextUpLastPlayed = deferred.awaitAll() - val timestamps = mutableMapOf() - nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) - resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } - val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } - val duration = (System.currentTimeMillis() - start).milliseconds - Timber.v("buildCombined took %s", duration) - return@withContext result - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 7e7d0d2b..14f34747 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -69,6 +69,7 @@ class ApplicationContentViewModel fun ApplicationContent( server: JellyfinServer, user: JellyfinUser, + startDestination: Destination, navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, @@ -80,7 +81,7 @@ fun ApplicationContent( user, serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()), ) { - NavBackStack(Destination.Home()) + NavBackStack(startDestination) } navigationManager.backStack = backStack val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() 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, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index e27ea11f..1bf8ec56 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -483,6 +483,7 @@ fun PlaybackPage( aspectRatio = it.aspectRatio ?: AspectRatios.WIDE, onClick = { viewModel.reportInteraction() + controllerViewState.hideControls() viewModel.playNextUp() }, timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt new file mode 100644 index 00000000..fa16c5ab --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt @@ -0,0 +1,540 @@ +package com.github.damontecres.wholphin.test + +import androidx.lifecycle.MutableLiveData +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.TrackIndex +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.StreamChoiceService +import io.mockk.every +import io.mockk.mockk +import org.jellyfin.sdk.model.UUID +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.UserDto +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceBasic( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + null, + SubtitlePlaybackMode.NONE, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + ), + TestInput( + 1, + SubtitlePlaybackMode.NONE, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + plc = plc(subtitleLang = "spa"), + ), + TestInput( + 0, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + ), + TestInput( + 1, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + userSubtitleLang = "spa", + ), + TestInput( + null, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + plc = plc(subtitlesDisabled = true), + ), + TestInput( + null, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.DISABLED), + ), + TestInput( + 0, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED), + ), + ) + } +} + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceDefault( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + userSubtitleLang = null, + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", true), + ), + userSubtitleLang = null, + ), + TestInput( + 1, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", true), + ), + userSubtitleLang = null, + ), + TestInput( + null, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", false), + ), + ), + TestInput( + 1, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + itemPlayback = itemPlayback(subtitleIndex = 1), + ), + TestInput( + 1, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", false), + ), + plc = plc(subtitleLang = "spa"), + ), + TestInput( + 1, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + plc = plc(subtitleLang = "spa"), + ), + TestInput( + null, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + plc = plc(subtitlesDisabled = true), + ), + ) + } +} + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceSmart( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + streamAudioLang = null, + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "spa", + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + ), + TestInput( + 1, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "spa", + plc = plc(subtitleLang = "spa"), + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "spa", + plc = plc(subtitlesDisabled = true), + ), + TestInput( + 1, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + userSubtitleLang = "spa", + userAudioLang = "spa", + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + userSubtitleLang = "spa", + userAudioLang = "eng", + ), + ) + } +} + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceOnlyForced( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + null, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + ), + TestInput( + 1, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = 1), + ), + TestInput( + 0, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitleLang = "eng"), + ), + TestInput( + null, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesDisabled = true), + ), + TestInput( + 0, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + ), + TestInput( + null, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + ), + TestInput( + 1, + SubtitlePlaybackMode.ONLY_FORCED, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + ), + ) + } +} + +data class TestInput( + val expectedIndex: Int?, + val userSubtitleMode: SubtitlePlaybackMode?, + val userAudioLang: String? = "eng", + val userSubtitleLang: String? = "eng", + val streamAudioLang: String? = "eng", + val subtitles: List, + val itemPlayback: ItemPlayback? = null, + val plc: PlaybackLanguageChoice? = null, +) { + override fun toString(): String = "test(mode=$userSubtitleMode, subtitles=${subtitles.map { it.toShortString() }})" +} + +private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)" + +private fun serverRepo( + audioLang: String?, + subtitleMode: SubtitlePlaybackMode?, + subtitleLang: String?, +): ServerRepository { + val mocked = mockk() + every { mocked.currentUserDto } returns + MutableLiveData( + UserDto( + id = UUID.randomUUID(), + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = true, + configuration = + DefaultUserConfiguration.copy( + audioLanguagePreference = audioLang, + subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT, + subtitleLanguagePreference = subtitleLang, + ), + ), + ) + return mocked +} + +private fun runTest(input: TestInput) { + val service = + StreamChoiceService( + serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), + mockk(), + ) + val result = + service.chooseSubtitleStream( + audioStreamLang = input.streamAudioLang, + candidates = input.subtitles, + itemPlayback = input.itemPlayback, + playbackLanguageChoice = input.plc, + prefs = UserPreferences(AppPreferences.getDefaultInstance()), + ) + Assert.assertEquals(input.expectedIndex, result?.index) +} + +fun subtitle( + index: Int, + lang: String?, + default: Boolean = false, + forced: Boolean = false, +): MediaStream = + MediaStream( + type = MediaStreamType.SUBTITLE, + language = lang, + isDefault = default, + isForced = forced, + isHearingImpaired = false, + isInterlaced = false, + index = index, + isExternal = false, + isTextSubtitleStream = true, + supportsExternalStream = true, + ) + +private fun itemPlayback( + audioIndex: Int = TrackIndex.UNSPECIFIED, + subtitleIndex: Int = TrackIndex.UNSPECIFIED, +): ItemPlayback = + ItemPlayback( + rowId = 1, + userId = 1, + itemId = UUID.randomUUID(), + sourceId = UUID.randomUUID(), + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + ) + +private fun plc( + audioLang: String? = null, + subtitleLang: String? = null, + subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null, +): PlaybackLanguageChoice = + PlaybackLanguageChoice( + userId = 1, + seriesId = UUID.randomUUID(), + audioLanguage = audioLang, + subtitleLanguage = subtitleLang, + subtitlesDisabled = subtitlesDisabled, + ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 467bc93a..61c5e8cf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,13 +5,16 @@ agp = "8.13.2" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" +hiltCompiler = "1.3.0" hiltNavigationCompose = "1.3.0" +hiltWork = "1.3.0" kotlin = "2.2.21" kotlinxCoroutinesCore = "1.10.2" ksp = "2.3.0" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2025.12.01" +mockk = "1.14.7" multiplatformMarkdownRenderer = "0.38.1" okhttpBom = "5.3.2" programguide = "1.6.0" @@ -34,6 +37,8 @@ protobuf-javalite = "4.33.2" hilt = "2.57.2" room = "2.8.4" preferenceKtx = "1.2.1" +tvprovider = "1.1.0" +workRuntimeKtx = "2.11.0" paletteKtx = "1.0.0" [libraries] @@ -55,18 +60,24 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" } androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" } auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "autoServiceKsp" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } okhttp = { module = "com.squareup.okhttp3:okhttp" }