mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Show loading/error for large seasons
This commit is contained in:
parent
ba45c3f509
commit
d4b3c554f8
8 changed files with 173 additions and 112 deletions
|
|
@ -72,6 +72,7 @@ android {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "11"
|
jvmTarget = "11"
|
||||||
|
|
@ -227,4 +228,5 @@ dependencies {
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,6 @@ data class BaseItem(
|
||||||
val subtitle =
|
val subtitle =
|
||||||
if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
|
if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
|
||||||
|
|
||||||
@Transient
|
|
||||||
val indexNumber = data.indexNumber
|
|
||||||
|
|
||||||
@Transient
|
@Transient
|
||||||
val resumeMs =
|
val resumeMs =
|
||||||
data.userData
|
data.userData
|
||||||
|
|
@ -41,12 +38,23 @@ data class BaseItem(
|
||||||
?.ticks
|
?.ticks
|
||||||
?.inWholeMilliseconds
|
?.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 {
|
fun destination(): Destination {
|
||||||
val result =
|
val result =
|
||||||
// Redirect episodes & seasons to their series if possible
|
// Redirect episodes & seasons to their series if possible
|
||||||
when (type) {
|
when (type) {
|
||||||
BaseItemKind.EPISODE -> {
|
BaseItemKind.EPISODE -> {
|
||||||
data.indexNumber?.let { episode ->
|
indexNumber?.let { episode ->
|
||||||
data.parentIndexNumber?.let { season ->
|
data.parentIndexNumber?.let { season ->
|
||||||
Destination.SeriesOverview(
|
Destination.SeriesOverview(
|
||||||
data.seriesId!!,
|
data.seriesId!!,
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class SeriesViewModel
|
||||||
private lateinit var prefs: UserPreferences
|
private lateinit var prefs: UserPreferences
|
||||||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||||
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
|
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
|
||||||
val episodes = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
|
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
|
||||||
val people = MutableLiveData<List<Person>>(listOf())
|
val people = MutableLiveData<List<Person>>(listOf())
|
||||||
|
|
||||||
fun init(
|
fun init(
|
||||||
|
|
@ -82,33 +82,25 @@ class SeriesViewModel
|
||||||
) + Dispatchers.IO,
|
) + Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
val item = fetchItem(seriesId, potential)
|
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
|
// If a particular season was requested, fetch those episodes, otherwise get the first season
|
||||||
val episodeInfo =
|
val episodeInfo =
|
||||||
(season ?: seasonsInfo.items.firstOrNull()?.indexNumber)
|
(season ?: seasonsInfo.items.firstOrNull()?.indexNumber)
|
||||||
?.let { seasonNum ->
|
?.let { seasonNum ->
|
||||||
loadEpisodesInternal(seasonNum)
|
loadEpisodesInternal(seasonNum)
|
||||||
} ?: ItemListAndMapping.empty()
|
} ?: EpisodeList.Error("Could not determine season")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
seasons.value = seasonsInfo
|
seasons.value = seasonsInfo
|
||||||
episodes.value = episodeInfo
|
episodes.value = episodeInfo
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
people.value =
|
people.value =
|
||||||
item.data.people
|
item.data.people
|
||||||
?.letNotEmpty { people ->
|
?.letNotEmpty { people ->
|
||||||
people.map { Person.fromDto(it, api) }
|
people.map { Person.fromDto(it, api) }
|
||||||
}.orEmpty()
|
}.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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
maybePlayThemeSong(prefs.appPreferences.interfacePreferences.playThemeSongs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,7 +195,7 @@ class SeriesViewModel
|
||||||
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
|
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadEpisodesInternal(season: Int): ItemListAndMapping {
|
private suspend fun loadEpisodesInternal(season: Int): EpisodeList {
|
||||||
val request =
|
val request =
|
||||||
GetEpisodesRequest(
|
GetEpisodesRequest(
|
||||||
seriesId = item.value!!.id,
|
seriesId = item.value!!.id,
|
||||||
|
|
@ -222,23 +214,24 @@ class SeriesViewModel
|
||||||
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
|
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
|
||||||
pager.init()
|
pager.init()
|
||||||
Timber.Forest.v("Loaded ${pager.size} episodes for season $season")
|
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)) {
|
viewModelScope.async(ExceptionHandler(true)) {
|
||||||
val episodes =
|
val episodes =
|
||||||
try {
|
try {
|
||||||
loadEpisodesInternal(season)
|
loadEpisodesInternal(season)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Error loading episodes for $seriesId for season $season")
|
Timber.e(e, "Error loading episodes for $seriesId for season $season")
|
||||||
// TODO show error in UI?
|
EpisodeList.Error(e)
|
||||||
ItemListAndMapping.empty()
|
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@SeriesViewModel.episodes.value = episodes
|
this@SeriesViewModel.episodes.value = episodes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setWatched(
|
fun setWatched(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
|
|
@ -272,14 +265,15 @@ class SeriesViewModel
|
||||||
val base = api.userLibraryApi.getItem(itemId).content
|
val base = api.userLibraryApi.getItem(itemId).content
|
||||||
val item = BaseItem.Companion.from(base, api)
|
val item = BaseItem.Companion.from(base, api)
|
||||||
val eps = episodes.value!!
|
val eps = episodes.value!!
|
||||||
withContext(Dispatchers.Main) {
|
if (eps is EpisodeList.Success) {
|
||||||
episodes.value =
|
val newItems =
|
||||||
eps.copy(
|
eps.episodes.items.toMutableList().apply {
|
||||||
items =
|
this[listIndex] = item
|
||||||
eps.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
|
* This allows for handling of missing seasons
|
||||||
*/
|
*/
|
||||||
private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping {
|
private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping {
|
||||||
val pairs =
|
val pairs =
|
||||||
pager.mapIndexed { index, _ ->
|
pager.mapIndexed { index, _ ->
|
||||||
val season = pager.getBlocking(index)
|
val item = pager.getBlocking(index)
|
||||||
Pair(season?.indexNumber!!, index)
|
Pair(item?.indexNumber ?: index, index)
|
||||||
}
|
}
|
||||||
val seasonNumToIndex = mapOf(*pairs.toTypedArray())
|
val seasonNumToIndex = mapOf(*pairs.toTypedArray())
|
||||||
val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
|
val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
|
||||||
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.components.LoadingPage
|
||||||
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo
|
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.ItemListAndMapping
|
||||||
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
|
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
|
||||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
|
|
@ -75,7 +76,8 @@ fun SeriesOverview(
|
||||||
|
|
||||||
val series by viewModel.item.observeAsState(null)
|
val series by viewModel.item.observeAsState(null)
|
||||||
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
|
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(
|
var position by rememberSaveable(
|
||||||
destination,
|
destination,
|
||||||
|
|
@ -89,7 +91,10 @@ fun SeriesOverview(
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
SeriesOverviewPosition(
|
SeriesOverviewPosition(
|
||||||
seasons.numberToIndex[initialSeasonEpisode?.season ?: 0] ?: 0,
|
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<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(episodes.items) {
|
LaunchedEffect(episodes) {
|
||||||
if (episodes.items.isNotEmpty()) {
|
episodes?.let { episodes ->
|
||||||
// TODO focus on first episode when changing seasons?
|
if (episodes is EpisodeList.Success) {
|
||||||
|
if (episodes.episodes.items.isNotEmpty()) {
|
||||||
|
// TODO focus on first episode when changing seasons?
|
||||||
// firstItemFocusRequester.requestFocus()
|
// firstItemFocusRequester.requestFocus()
|
||||||
episodes.items.getOrNull(position.episodeRowIndex)?.let {
|
episodes.episodes.items.getOrNull(position.episodeRowIndex)?.let {
|
||||||
viewModel.refreshEpisode(it.id, position.episodeRowIndex)
|
viewModel.refreshEpisode(it.id, position.episodeRowIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +129,7 @@ fun SeriesOverview(
|
||||||
SeriesOverviewContent(
|
SeriesOverviewContent(
|
||||||
series = series,
|
series = series,
|
||||||
seasons = seasons.items,
|
seasons = seasons.items,
|
||||||
episodes = episodes.items,
|
episodes = episodes,
|
||||||
position = position,
|
position = position,
|
||||||
backdropImageUrl =
|
backdropImageUrl =
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -140,7 +149,6 @@ fun SeriesOverview(
|
||||||
position = it
|
position = it
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.release()
|
|
||||||
val resumePosition =
|
val resumePosition =
|
||||||
it.data.userData
|
it.data.userData
|
||||||
?.playbackPositionTicks
|
?.playbackPositionTicks
|
||||||
|
|
@ -157,7 +165,7 @@ fun SeriesOverview(
|
||||||
// TODO
|
// TODO
|
||||||
},
|
},
|
||||||
playOnClick = { resume ->
|
playOnClick = { resume ->
|
||||||
episodes.items.getOrNull(position.episodeRowIndex)?.let {
|
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||||
viewModel.release()
|
viewModel.release()
|
||||||
viewModel.navigateTo(
|
viewModel.navigateTo(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
|
|
@ -169,13 +177,13 @@ fun SeriesOverview(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watchOnClick = {
|
watchOnClick = {
|
||||||
episodes.items.getOrNull(position.episodeRowIndex)?.let {
|
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||||
val played = it.data.userData?.played ?: false
|
val played = it.data.userData?.played ?: false
|
||||||
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
|
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
moreOnClick = {
|
moreOnClick = {
|
||||||
episodes.items.getOrNull(position.episodeRowIndex)?.let { ep ->
|
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
|
||||||
moreDialog =
|
moreDialog =
|
||||||
DialogParams(
|
DialogParams(
|
||||||
fromLongClick = false,
|
fromLongClick = false,
|
||||||
|
|
@ -227,7 +235,7 @@ fun SeriesOverview(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
overviewOnClick = {
|
overviewOnClick = {
|
||||||
episodes.items.getOrNull(position.episodeRowIndex)?.let {
|
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||||
overviewDialog =
|
overviewDialog =
|
||||||
ItemDetailsDialogInfo(
|
ItemDetailsDialogInfo(
|
||||||
title = it.name ?: "Unknown",
|
title = it.name ?: "Unknown",
|
||||||
|
|
|
||||||
|
|
@ -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.data.model.aspectRatioFloat
|
||||||
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
||||||
import com.github.damontecres.dolphin.ui.cards.BannerCard
|
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.ifElse
|
||||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.dolphin.util.formatDateTime
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SeriesOverviewContent(
|
fun SeriesOverviewContent(
|
||||||
series: BaseItem,
|
series: BaseItem,
|
||||||
seasons: List<BaseItem?>,
|
seasons: List<BaseItem?>,
|
||||||
episodes: List<BaseItem?>,
|
episodes: EpisodeList,
|
||||||
position: SeriesOverviewPosition,
|
position: SeriesOverviewPosition,
|
||||||
backdropImageUrl: String?,
|
backdropImageUrl: String?,
|
||||||
firstItemFocusRequester: FocusRequester,
|
firstItemFocusRequester: FocusRequester,
|
||||||
|
|
@ -76,7 +80,8 @@ fun SeriesOverviewContent(
|
||||||
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
|
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
|
||||||
val tabRowFocusRequester = remember { FocusRequester() }
|
val tabRowFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val focusedEpisode = episodes.getOrNull(position.episodeRowIndex)
|
val focusedEpisode =
|
||||||
|
(episodes as? EpisodeList.Success)?.episodes?.items?.getOrNull(position.episodeRowIndex)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -200,56 +205,74 @@ fun SeriesOverviewContent(
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
key(position.seasonTabIndex) {
|
key(position.seasonTabIndex) {
|
||||||
val state = rememberLazyListState()
|
when (val eps = episodes) {
|
||||||
OneTimeLaunchedEffect {
|
EpisodeList.Loading -> LoadingPage()
|
||||||
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
|
is EpisodeList.Error -> ErrorMessage(eps.message, eps.exception)
|
||||||
state.scrollToItem(position.episodeRowIndex)
|
is EpisodeList.Success -> {
|
||||||
firstItemFocusRequester.tryRequestFocus()
|
val state = rememberLazyListState()
|
||||||
}
|
OneTimeLaunchedEffect {
|
||||||
}
|
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
|
||||||
LazyRow(
|
state.scrollToItem(position.episodeRowIndex)
|
||||||
state = state,
|
}
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
firstItemFocusRequester.tryRequestFocus()
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
LazyRow(
|
||||||
BannerCard(
|
state = state,
|
||||||
name = episode?.name,
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
imageUrl = episode?.imageUrl,
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
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) },
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.ifElse(
|
Modifier
|
||||||
episodeIndex == position.episodeRowIndex,
|
.focusRestorer(firstItemFocusRequester)
|
||||||
Modifier.focusRequester(firstItemFocusRequester),
|
.focusRequester(episodeRowFocusRequester),
|
||||||
),
|
) {
|
||||||
interactionSource = interactionSource,
|
itemsIndexed(eps.episodes.items) { episodeIndex, episode ->
|
||||||
cardHeight = 120.dp,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import com.github.damontecres.dolphin.ui.timeRemaining
|
||||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.dolphin.util.LoadingState
|
import com.github.damontecres.dolphin.util.LoadingState
|
||||||
import com.github.damontecres.dolphin.util.formatDateTime
|
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.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
|
||||||
|
|
@ -231,7 +232,13 @@ fun MainPageHeader(
|
||||||
val details =
|
val details =
|
||||||
buildList {
|
buildList {
|
||||||
if (isEpisode) {
|
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) {
|
if (isEpisode) {
|
||||||
dto.premiereDate?.let { add(formatDateTime(it)) }
|
dto.premiereDate?.let { add(formatDateTime(it)) }
|
||||||
|
|
@ -271,13 +278,13 @@ fun MainPageHeader(
|
||||||
val overviewModifier =
|
val overviewModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(0.dp)
|
.padding(0.dp)
|
||||||
.height(48.dp)
|
.height(48.dp + if (!isEpisode) 12.dp else 0.dp)
|
||||||
if (overview.isNotNullOrBlank()) {
|
if (overview.isNotNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = overview,
|
text = overview,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 2,
|
maxLines = if (isEpisode) 2 else 3,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = overviewModifier,
|
modifier = overviewModifier,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import java.time.format.DateTimeFormatter
|
||||||
*/
|
*/
|
||||||
fun formatDateTime(dateTime: LocalDateTime): String =
|
fun formatDateTime(dateTime: LocalDateTime): String =
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
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")
|
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
|
||||||
formatter.format(dateTime)
|
formatter.format(dateTime)
|
||||||
} else if (dateTime.toString().length >= 10) {
|
} else if (dateTime.toString().length >= 10) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
[versions]
|
[versions]
|
||||||
aboutLibraries = "12.2.4"
|
aboutLibraries = "12.2.4"
|
||||||
agp = "8.13.0"
|
agp = "8.13.0"
|
||||||
|
desugar_jdk_libs = "2.1.5"
|
||||||
hiltNavigationCompose = "1.3.0"
|
hiltNavigationCompose = "1.3.0"
|
||||||
kotlin = "2.2.20"
|
kotlin = "2.2.20"
|
||||||
ksp = "2.2.20-2.0.2" # https://github.com/google/ksp/issues/2596 2.0.3 is broken
|
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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
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 = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||||
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", 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" }
|
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue