diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 26ebd428..1956a7d1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,6 +72,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { jvmTarget = "11" @@ -227,4 +228,5 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) + coreLibraryDesugaring(libs.desugar.jdk.libs) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt index 4c4b3fd6..25408458 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt @@ -31,9 +31,6 @@ data class BaseItem( val subtitle = if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString() - @Transient - val indexNumber = data.indexNumber - @Transient val resumeMs = data.userData @@ -41,12 +38,23 @@ data class BaseItem( ?.ticks ?.inWholeMilliseconds + @Transient + val indexNumber = data.indexNumber ?: dateAsIndex() + + private fun dateAsIndex(): Int? = + data.premiereDate + ?.let { + it.year.toString() + + it.monthValue.toString().padStart(2, '0') + + it.dayOfMonth.toString().padStart(2, '0') + }?.toIntOrNull() + fun destination(): Destination { val result = // Redirect episodes & seasons to their series if possible when (type) { BaseItemKind.EPISODE -> { - data.indexNumber?.let { episode -> + indexNumber?.let { episode -> data.parentIndexNumber?.let { season -> Destination.SeriesOverview( data.seriesId!!, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt index cc4d6604..e237c7a1 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt @@ -63,7 +63,7 @@ class SeriesViewModel private lateinit var prefs: UserPreferences val loading = MutableLiveData(LoadingState.Loading) val seasons = MutableLiveData(ItemListAndMapping.empty()) - val episodes = MutableLiveData(ItemListAndMapping.empty()) + val episodes = MutableLiveData(EpisodeList.Loading) val people = MutableLiveData>(listOf()) fun init( @@ -82,33 +82,25 @@ class SeriesViewModel ) + Dispatchers.IO, ) { val item = fetchItem(seriesId, potential) - if (item != null) { - val seasonsInfo = getSeasons(item) + val seasonsInfo = getSeasons(item) - // If a particular season was requested, fetch those episodes, otherwise get the first season - val episodeInfo = - (season ?: seasonsInfo.items.firstOrNull()?.indexNumber) - ?.let { seasonNum -> - loadEpisodesInternal(seasonNum) - } ?: ItemListAndMapping.empty() - withContext(Dispatchers.Main) { - seasons.value = seasonsInfo - episodes.value = episodeInfo - loading.value = LoadingState.Success - people.value = - item.data.people - ?.letNotEmpty { people -> - people.map { Person.fromDto(it, api) } - }.orEmpty() - } - maybePlayThemeSong(prefs.appPreferences.interfacePreferences.playThemeSongs) - } else { - withContext(Dispatchers.Main) { - seasons.value = ItemListAndMapping.empty() - episodes.value = ItemListAndMapping.empty() - loading.value = LoadingState.Error("Series $seriesId not found") - } + // If a particular season was requested, fetch those episodes, otherwise get the first season + val episodeInfo = + (season ?: seasonsInfo.items.firstOrNull()?.indexNumber) + ?.let { seasonNum -> + loadEpisodesInternal(seasonNum) + } ?: EpisodeList.Error("Could not determine season") + withContext(Dispatchers.Main) { + seasons.value = seasonsInfo + episodes.value = episodeInfo + loading.value = LoadingState.Success + people.value = + item.data.people + ?.letNotEmpty { people -> + people.map { Person.fromDto(it, api) } + }.orEmpty() } + maybePlayThemeSong(prefs.appPreferences.interfacePreferences.playThemeSongs) } } @@ -203,7 +195,7 @@ class SeriesViewModel return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum) } - private suspend fun loadEpisodesInternal(season: Int): ItemListAndMapping { + private suspend fun loadEpisodesInternal(season: Int): EpisodeList { val request = GetEpisodesRequest( seriesId = item.value!!.id, @@ -222,23 +214,24 @@ class SeriesViewModel val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) pager.init() Timber.Forest.v("Loaded ${pager.size} episodes for season $season") - return convertPager(pager) + return EpisodeList.Success(convertPager(pager)) } - fun loadEpisodes(season: Int) = + fun loadEpisodes(season: Int) { + this@SeriesViewModel.episodes.value = EpisodeList.Loading viewModelScope.async(ExceptionHandler(true)) { val episodes = try { loadEpisodesInternal(season) } catch (e: Exception) { Timber.e(e, "Error loading episodes for $seriesId for season $season") - // TODO show error in UI? - ItemListAndMapping.empty() + EpisodeList.Error(e) } withContext(Dispatchers.Main) { this@SeriesViewModel.episodes.value = episodes } } + } fun setWatched( itemId: UUID, @@ -272,14 +265,15 @@ class SeriesViewModel val base = api.userLibraryApi.getItem(itemId).content val item = BaseItem.Companion.from(base, api) val eps = episodes.value!! - withContext(Dispatchers.Main) { - episodes.value = - eps.copy( - items = - eps.items.toMutableList().apply { - this[listIndex] = item - }, - ) + if (eps is EpisodeList.Success) { + val newItems = + eps.episodes.items.toMutableList().apply { + this[listIndex] = item + } + val newValue = EpisodeList.Success(eps.episodes.copy(items = newItems)) + withContext(Dispatchers.Main) { + episodes.value = newValue + } } } @@ -325,17 +319,32 @@ data class ItemListAndMapping( } /** - * Calculate the index<->season number pairings + * Calculate the index<->season/ep number pairings * * This allows for handling of missing seasons */ private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping { val pairs = pager.mapIndexed { index, _ -> - val season = pager.getBlocking(index) - Pair(season?.indexNumber!!, index) + val item = pager.getBlocking(index) + Pair(item?.indexNumber ?: index, index) } val seasonNumToIndex = mapOf(*pairs.toTypedArray()) val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray()) return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum) } + +sealed interface EpisodeList { + data object Loading : EpisodeList + + data class Error( + val message: String? = null, + val exception: Throwable? = null, + ) : EpisodeList { + constructor(exception: Throwable) : this(null, exception) + } + + data class Success( + val episodes: ItemListAndMapping, + ) : EpisodeList +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt index 0b57e8ce..b7f04844 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt @@ -25,6 +25,7 @@ import com.github.damontecres.dolphin.ui.components.ErrorMessage import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.dolphin.ui.detail.EpisodeList import com.github.damontecres.dolphin.ui.detail.ItemListAndMapping import com.github.damontecres.dolphin.ui.detail.SeriesViewModel import com.github.damontecres.dolphin.ui.nav.Destination @@ -75,7 +76,8 @@ fun SeriesOverview( val series by viewModel.item.observeAsState(null) val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty()) - val episodes by viewModel.episodes.observeAsState(ItemListAndMapping.empty()) + val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading) + val episodeList = (episodes as? EpisodeList.Success)?.episodes?.items var position by rememberSaveable( destination, @@ -89,7 +91,10 @@ fun SeriesOverview( mutableStateOf( SeriesOverviewPosition( seasons.numberToIndex[initialSeasonEpisode?.season ?: 0] ?: 0, - episodes.numberToIndex[initialSeasonEpisode?.episode ?: 0] ?: 0, + (episodes as? EpisodeList.Success)?.episodes?.numberToIndex[ + initialSeasonEpisode?.episode + ?: 0, + ] ?: 0, ), ) } @@ -97,12 +102,16 @@ fun SeriesOverview( var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } - LaunchedEffect(episodes.items) { - if (episodes.items.isNotEmpty()) { - // TODO focus on first episode when changing seasons? + LaunchedEffect(episodes) { + episodes?.let { episodes -> + if (episodes is EpisodeList.Success) { + if (episodes.episodes.items.isNotEmpty()) { + // TODO focus on first episode when changing seasons? // firstItemFocusRequester.requestFocus() - episodes.items.getOrNull(position.episodeRowIndex)?.let { - viewModel.refreshEpisode(it.id, position.episodeRowIndex) + episodes.episodes.items.getOrNull(position.episodeRowIndex)?.let { + viewModel.refreshEpisode(it.id, position.episodeRowIndex) + } + } } } } @@ -120,7 +129,7 @@ fun SeriesOverview( SeriesOverviewContent( series = series, seasons = seasons.items, - episodes = episodes.items, + episodes = episodes, position = position, backdropImageUrl = remember { @@ -140,7 +149,6 @@ fun SeriesOverview( position = it }, onClick = { - viewModel.release() val resumePosition = it.data.userData ?.playbackPositionTicks @@ -157,7 +165,7 @@ fun SeriesOverview( // TODO }, playOnClick = { resume -> - episodes.items.getOrNull(position.episodeRowIndex)?.let { + episodeList?.getOrNull(position.episodeRowIndex)?.let { viewModel.release() viewModel.navigateTo( Destination.Playback( @@ -169,13 +177,13 @@ fun SeriesOverview( } }, watchOnClick = { - episodes.items.getOrNull(position.episodeRowIndex)?.let { + episodeList?.getOrNull(position.episodeRowIndex)?.let { val played = it.data.userData?.played ?: false viewModel.setWatched(it.id, !played, position.episodeRowIndex) } }, moreOnClick = { - episodes.items.getOrNull(position.episodeRowIndex)?.let { ep -> + episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> moreDialog = DialogParams( fromLongClick = false, @@ -227,7 +235,7 @@ fun SeriesOverview( } }, overviewOnClick = { - episodes.items.getOrNull(position.episodeRowIndex)?.let { + episodeList?.getOrNull(position.episodeRowIndex)?.let { overviewDialog = ItemDetailsDialogInfo( title = it.name ?: "Unknown", diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt index 3a48d6fd..1a8adcb1 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt @@ -46,16 +46,20 @@ import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.aspectRatioFloat import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect import com.github.damontecres.dolphin.ui.cards.BannerCard +import com.github.damontecres.dolphin.ui.components.ErrorMessage +import com.github.damontecres.dolphin.ui.components.LoadingPage +import com.github.damontecres.dolphin.ui.detail.EpisodeList import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.tryRequestFocus +import com.github.damontecres.dolphin.util.formatDateTime import kotlin.time.Duration @Composable fun SeriesOverviewContent( series: BaseItem, seasons: List, - episodes: List, + episodes: EpisodeList, position: SeriesOverviewPosition, backdropImageUrl: String?, firstItemFocusRequester: FocusRequester, @@ -76,7 +80,8 @@ fun SeriesOverviewContent( var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) } val tabRowFocusRequester = remember { FocusRequester() } - val focusedEpisode = episodes.getOrNull(position.episodeRowIndex) + val focusedEpisode = + (episodes as? EpisodeList.Success)?.episodes?.items?.getOrNull(position.episodeRowIndex) Box( modifier = @@ -200,56 +205,74 @@ fun SeriesOverviewContent( } item { key(position.seasonTabIndex) { - val state = rememberLazyListState() - OneTimeLaunchedEffect { - if (state.firstVisibleItemIndex != position.episodeRowIndex) { - state.scrollToItem(position.episodeRowIndex) - firstItemFocusRequester.tryRequestFocus() - } - } - LazyRow( - state = state, - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - modifier = - Modifier - .focusRestorer(firstItemFocusRequester) - .focusRequester(episodeRowFocusRequester), - ) { - itemsIndexed(episodes) { episodeIndex, episode -> - val interactionSource = remember { MutableInteractionSource() } - if (interactionSource.collectIsFocusedAsState().value) { - onFocus.invoke( - SeriesOverviewPosition( - selectedTabIndex, - episodeIndex, - ), - ) + when (val eps = episodes) { + EpisodeList.Loading -> LoadingPage() + is EpisodeList.Error -> ErrorMessage(eps.message, eps.exception) + is EpisodeList.Success -> { + val state = rememberLazyListState() + OneTimeLaunchedEffect { + if (state.firstVisibleItemIndex != position.episodeRowIndex) { + state.scrollToItem(position.episodeRowIndex) + } + firstItemFocusRequester.tryRequestFocus() } - - BannerCard( - name = episode?.name, - imageUrl = episode?.imageUrl, - aspectRatio = - episode - ?.data - ?.primaryImageAspectRatio - ?.toFloat() - ?.coerceAtLeast(episode.data.aspectRatioFloat ?: (4f / 3f)) - ?: (16f / 9), // TODO some episode images don't match the file's aspect ratio - cornerText = "E${episode?.data?.indexNumber}", - 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) }, + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 16.dp), modifier = - Modifier.ifElse( - episodeIndex == position.episodeRowIndex, - Modifier.focusRequester(firstItemFocusRequester), - ), - interactionSource = interactionSource, - cardHeight = 120.dp, - ) + Modifier + .focusRestorer(firstItemFocusRequester) + .focusRequester(episodeRowFocusRequester), + ) { + itemsIndexed(eps.episodes.items) { 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, + imageUrl = episode?.imageUrl, + aspectRatio = + episode + ?.data + ?.primaryImageAspectRatio + ?.toFloat() + ?.coerceAtLeast( + episode.data.aspectRatioFloat ?: (4f / 3f), + ) + ?: (16f / 9), // TODO some episode images don't match the file's aspect ratio + 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), + ), + interactionSource = interactionSource, + cardHeight = 120.dp, + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt index a0310d9d..6319a61d 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt @@ -54,6 +54,7 @@ import com.github.damontecres.dolphin.ui.timeRemaining import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.util.LoadingState import com.github.damontecres.dolphin.util.formatDateTime +import com.github.damontecres.dolphin.util.seasonEpisode import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.extensions.ticks @@ -231,7 +232,13 @@ fun MainPageHeader( val details = buildList { if (isEpisode) { - add("S${dto.parentIndexNumber} E${dto.indexNumber}") + val se = dto.seasonEpisode + if (se != null) { + add(se) + } else if (dto.parentIndexNumber != null) { + // Maybe a daily episode, so just show season, the date is added below + add("S${dto.parentIndexNumber}") + } } if (isEpisode) { dto.premiereDate?.let { add(formatDateTime(it)) } @@ -271,13 +278,13 @@ fun MainPageHeader( val overviewModifier = Modifier .padding(0.dp) - .height(48.dp) + .height(48.dp + if (!isEpisode) 12.dp else 0.dp) if (overview.isNotNullOrBlank()) { Text( text = overview, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, + maxLines = if (isEpisode) 2 else 3, overflow = TextOverflow.Ellipsis, modifier = overviewModifier, ) diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt b/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt index 33073572..7406c112 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt @@ -13,6 +13,8 @@ import java.time.format.DateTimeFormatter */ fun formatDateTime(dateTime: LocalDateTime): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // TODO server returns in UTC, but sdk converts to local time + // eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020 val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy") formatter.format(dateTime) } else if (dateTime.toString().length >= 10) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02878fff..17051a1c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] aboutLibraries = "12.2.4" agp = "8.13.0" +desugar_jdk_libs = "2.1.5" hiltNavigationCompose = "1.3.0" kotlin = "2.2.20" ksp = "2.2.20-2.0.2" # https://github.com/google/ksp/issues/2596 2.0.3 is broken @@ -51,6 +52,7 @@ androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.re 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" } +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" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" }