From 67c602a5ef90b58eded383ec7e3b220f464c72e2 Mon Sep 17 00:00:00 2001 From: beyond Date: Thu, 3 Sep 2026 15:22:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../myapplication/data/EmbyApiModels.kt | 2 + .../myapplication/data/EmbyRepository.kt | 49 ++- .../myapplication/data/TmdbRepository.kt | 114 ++++--- .../myapplication/navigation/AppNavHost.kt | 26 ++ .../myapplication/navigation/AppRoute.kt | 5 + .../screens/FavoritesAllScreen.kt | 2 +- .../screens/HomeLibraryAllScreen.kt | 298 ++++++++++++++++++ .../myapplication/screens/HomeScreen.kt | 74 ++++- .../screens/MediaDetailScreen.kt | 104 +++--- .../myapplication/screens/RecordsScreen.kt | 23 +- .../myapplication/screens/SearchAllScreen.kt | 2 +- .../example/myapplication/ui/LiquidGlass.kt | 22 +- app/src/main/res/values-en/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + .../screens/RecordsScreenDataTest.kt | 39 +++ 15 files changed, 669 insertions(+), 105 deletions(-) create mode 100644 app/src/main/kotlin/com/example/myapplication/screens/HomeLibraryAllScreen.kt create mode 100644 app/src/test/kotlin/com/example/myapplication/screens/RecordsScreenDataTest.kt diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt index a31c0ea..a4f32fc 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt @@ -54,6 +54,7 @@ data class EmbyItem( @SerializedName("Genres") val _genres: List? = emptyList(), @SerializedName("RunTimeTicks") val runTimeTicks: Long? = null, @SerializedName("PremiereDate") val premiereDate: String? = null, + @SerializedName("DateCreated") val dateCreated: String? = null, @SerializedName("ImageTags") val imageTags: Map? = null, @SerializedName("BackdropImageTags") val _backdropImageTags: List? = emptyList(), @SerializedName("SeriesName") val seriesName: String? = null, @@ -109,6 +110,7 @@ data class EmbyItem( data class EmbyUserData( @SerializedName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0L, @SerializedName("Played") val played: Boolean = false, + @SerializedName("LastPlayedDate") val lastPlayedDate: String? = null, ) data class EmbyPerson( diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt index 5c39111..27ff46f 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt @@ -227,9 +227,16 @@ object EmbyRepository { /** * Get items for a specific library folder. */ - suspend fun getItemsForLibrary(server: EmbyServer, libraryId: String, limit: Int = 6): EmbyLibraryPage = withContext(Dispatchers.IO) { + suspend fun getItemsForLibrary( + server: EmbyServer, + libraryId: String, + sortBy: String = "DateCreated", + sortOrder: String = "Descending", + startIndex: Int = 0, + limit: Int = 6, + ): EmbyLibraryPage = withContext(Dispatchers.IO) { try { - val url = "${server.fullUrl}/emby/Users/${server.userId}/Items?ParentId=$libraryId&SortBy=DateCreated&SortOrder=Descending&Limit=$limit&IncludeItemTypes=Movie,Series&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,Genres&Recursive=true" + val url = "${server.fullUrl}/emby/Users/${server.userId}/Items?ParentId=$libraryId&SortBy=$sortBy&SortOrder=$sortOrder&StartIndex=${startIndex.coerceAtLeast(0)}&Limit=${limit.coerceAtLeast(1)}&IncludeItemTypes=Movie,Series&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,Genres,RecursiveChildCount,ChildCount,DateCreated,RunTimeTicks,UserData&Recursive=true" val request = Request.Builder() .url(url) .header("X-Emby-Token", server.apiKey) @@ -321,7 +328,10 @@ object EmbyRepository { .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) null - else gson.fromJson(response.body?.string().orEmpty(), EmbyItemsResponse::class.java)?.totalRecordCount + else { + val body = response.body?.string().orEmpty() + gson.fromJson(body, EmbyItemsResponse::class.java)?.totalRecordCount + } } } catch (_: Exception) { null @@ -342,7 +352,10 @@ object EmbyRepository { .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) null - else gson.fromJson(response.body?.string().orEmpty(), EmbyItemCounts::class.java) + else { + val body = response.body?.string().orEmpty() + gson.fromJson(body, EmbyItemCounts::class.java) + } } } catch (_: Exception) { null @@ -386,7 +399,21 @@ object EmbyRepository { client.newCall(request).execute().use { response -> if (!response.isSuccessful) return@use emptyList() val body = response.body?.string() ?: return@use emptyList() - gson.fromJson(body, EmbyItemsResponse::class.java)?.items.orEmpty() + val items = gson.fromJson(body, EmbyItemsResponse::class.java)?.items.orEmpty() + val seriesItems = items.filter { it.type.equals("Series", ignoreCase = true) } + if (seriesItems.isEmpty()) { + items + } else { + val episodeCounts = seriesItems + .map { item -> async { item.id to getSeriesEpisodeCount(server, item.id) } } + .map { it.await() } + .toMap() + items.map { item -> + episodeCounts[item.id]?.let { count -> + item.copy(recursiveChildCount = count) + } ?: item + } + } } } catch (e: Exception) { Log.w("EmbySearch", "search failed", e) @@ -426,7 +453,7 @@ object EmbyRepository { */ suspend fun getSeasons(server: EmbyServer, seriesId: String): List = withContext(Dispatchers.IO) { val dedicated = try { - val url = "${server.fullUrl}/emby/Shows/$seriesId/Seasons?userId=${server.userId}&Fields=PrimaryImageAspectRatio,Overview" + val url = "${server.fullUrl}/emby/Shows/$seriesId/Seasons?userId=${server.userId}&Fields=PrimaryImageAspectRatio,Overview,RecursiveChildCount" val request = Request.Builder() .url(url) .header("X-Emby-Token", server.apiKey) @@ -449,7 +476,7 @@ object EmbyRepository { return@withContext try { val url = "${server.fullUrl}/emby/Users/${server.userId}/Items" + "?ParentId=$seriesId&IncludeItemTypes=Season&Recursive=true&SortBy=SortName&SortOrder=Ascending" + - "&Fields=PrimaryImageAspectRatio,Overview" + "&Fields=PrimaryImageAspectRatio,Overview,RecursiveChildCount" val request = Request.Builder() .url(url) .header("X-Emby-Token", server.apiKey) @@ -468,6 +495,14 @@ object EmbyRepository { } } + /** Returns the total episode count by summing each season's paged episode count. */ + suspend fun getSeriesEpisodeCount(server: EmbyServer, seriesId: String): Int = withContext(Dispatchers.IO) { + // The Episodes endpoint is season-scoped on some Emby versions. + getSeasons(server, seriesId).sumOf { season -> + getEpisodePage(server, seriesId, season.id, limit = 1).totalCount + } + } + /** Get one page of episodes so the UI can render the first cards before the full season arrives. */ suspend fun getEpisodePage( server: EmbyServer, diff --git a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt index 7799fed..ee9d35e 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt @@ -85,6 +85,15 @@ private data class TmdbRecommendationsResponse( @SerializedName("results") val results: List = emptyList(), ) +private data class TmdbImagesResponse( + @SerializedName("logos") val logos: List = emptyList(), +) + +private data class TmdbImage( + @SerializedName("file_path") val filePath: String? = null, + @SerializedName("iso_639_1") val language: String? = null, +) + private data class TmdbTvDetailResponse( @SerializedName("id") val id: Int = 0, @SerializedName("name") val name: String = "", @@ -95,6 +104,7 @@ private data class TmdbTvDetailResponse( @SerializedName("first_air_date") val firstAirDate: String = "", @SerializedName("networks") val networks: List = emptyList(), @SerializedName("logo_path") val logoPath: String? = null, + @SerializedName("images") val images: TmdbImagesResponse = TmdbImagesResponse(), ) private data class TmdbGenre( @@ -311,12 +321,14 @@ object TmdbRepository { /** Get calendar episodes for all watchlist items. */ suspend fun getCalendarEpisodes(watchlist: List): List = withContext(Dispatchers.IO) { if (!isConfigured || watchlist.isEmpty()) return@withContext emptyList() - val today = java.time.LocalDate.now().toString() // "2026-08-31" val results = mutableListOf() watchlist.forEach { entity -> val tmdbId = entity.tmdbId?.toIntOrNull() ?: return@forEach try { - val seriesDetail = get("$API_BASE/tv/$tmdbId?language=${language()}", TmdbTvDetailResponse::class.java) + val seriesDetail = get( + "$API_BASE/tv/$tmdbId?language=${language()}&append_to_response=images&include_image_language=zh,null,en", + TmdbTvDetailResponse::class.java, + ) ?: return@forEach val seriesName = seriesDetail.name.ifBlank { entity.name } val posterUrl = seriesDetail.posterPath?.let { IMAGE_BASE + it } ?: entity.posterUrl @@ -324,12 +336,62 @@ object TmdbRepository { val firstAirYear = seriesDetail.firstAirDate.take(4) val networkName = seriesDetail.networks.firstOrNull()?.name ?: "" val networkLogo = seriesDetail.networks.firstOrNull()?.logoPath?.let { IMAGE_BASE.replace("w342", "w154") + it } - val logoUrl = seriesDetail.logoPath?.let { IMAGE_BASE.replace("w342", "w300") + it } + val logoPath = seriesDetail.images.logos + .sortedBy { + when (it.language) { + "zh" -> 0 + "en" -> 1 + null -> 2 + else -> 3 + } + } + .firstOrNull { !it.filePath.isNullOrBlank() } + ?.filePath + ?: seriesDetail.logoPath + val logoUrl = logoPath?.let { IMAGE_BASE.replace("w342", "w300") + it } - // Get next episode if available + // Load all regular seasons so the calendar can also show past episodes. + seriesDetail.seasons + .filter { it.seasonNumber > 0 && it.episodeCount > 0 } + .forEach { season -> + val seasonDetail = get( + "$API_BASE/tv/$tmdbId/season/${season.seasonNumber}?language=${language()}", + TmdbSeasonDetailResponse::class.java, + ) + val episodes = seasonDetail?.episodes.orEmpty() + val totalInSeason = episodes.size.takeIf { it > 0 } ?: season.episodeCount + episodes.forEach { ep -> + if (ep.airDate.isNotBlank()) { + results.add(TmdbEpisodeCalendar( + tmdbId = tmdbId, + seriesName = seriesName, + seasonNumber = ep.seasonNumber, + episodeNumber = ep.episodeNumber, + episodeName = ep.name, + airDate = ep.airDate, + overview = ep.overview, + stillPath = ep.stillPath, + posterUrl = posterUrl, + totalEpisodes = totalInSeason, + genres = genres, + firstAirYear = firstAirYear, + networkName = networkName, + networkLogoPath = networkLogo, + logoPath = logoUrl, + )) + } + } + } + + // Keep a next episode if TMDB exposes it before the season endpoint does. seriesDetail.nextEpisodeToAir?.let { next -> - if (next.airDate >= today) { - val nextSeasonEpisodes = seriesDetail.seasons + val isDuplicate = results.any { + it.tmdbId == tmdbId && + it.seasonNumber == next.seasonNumber && + it.episodeNumber == next.episodeNumber + } + if (!isDuplicate && next.airDate.isNotBlank()) { + val totalInSeason = seriesDetail.seasons .firstOrNull { it.seasonNumber == next.seasonNumber } ?.episodeCount ?: 0 results.add(TmdbEpisodeCalendar( @@ -342,7 +404,7 @@ object TmdbRepository { overview = next.overview, stillPath = next.stillPath, posterUrl = posterUrl, - totalEpisodes = nextSeasonEpisodes, + totalEpisodes = totalInSeason, genres = genres, firstAirYear = firstAirYear, networkName = networkName, @@ -351,44 +413,6 @@ object TmdbRepository { )) } } - - // Also fetch upcoming episodes from current season - val currentSeason = seriesDetail.nextEpisodeToAir?.seasonNumber - ?: seriesDetail.seasons.lastOrNull { it.seasonNumber > 0 && !it.airDate.isNullOrBlank() }?.seasonNumber - if (currentSeason != null) { - val seasonDetail = get( - "$API_BASE/tv/$tmdbId/season/$currentSeason?language=${language()}", - TmdbSeasonDetailResponse::class.java, - ) - val totalInSeason = seasonDetail?.episodes?.size ?: 0 - seasonDetail?.episodes?.forEach { ep -> - if (ep.airDate >= today && ep.airDate.isNotBlank()) { - // Avoid duplicates with next_episode_to_air - val isDuplicate = seriesDetail.nextEpisodeToAir?.let { next -> - next.seasonNumber == ep.seasonNumber && next.episodeNumber == ep.episodeNumber - } ?: false - if (!isDuplicate) { - results.add(TmdbEpisodeCalendar( - tmdbId = tmdbId, - seriesName = seriesName, - seasonNumber = ep.seasonNumber, - episodeNumber = ep.episodeNumber, - episodeName = ep.name, - airDate = ep.airDate, - overview = ep.overview, - stillPath = ep.stillPath, - posterUrl = posterUrl, - totalEpisodes = totalInSeason, - genres = genres, - firstAirYear = firstAirYear, - networkName = networkName, - networkLogoPath = networkLogo, - logoPath = logoUrl, - )) - } - } - } - } } catch (e: Exception) { Log.w("TmdbRepository", "Failed to get calendar for ${entity.name}", e) } diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt index ac4662b..587f529 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt @@ -35,6 +35,7 @@ import com.example.myapplication.screens.AboutScreen import com.example.myapplication.screens.AddServerScreen import com.example.myapplication.screens.CacheManagementScreen import com.example.myapplication.screens.HomeScreen +import com.example.myapplication.screens.HomeLibraryAllScreen import com.example.myapplication.screens.IconLibraryManagerScreen import com.example.myapplication.screens.LibraryScreen import com.example.myapplication.screens.MediaDetailScreen @@ -118,6 +119,11 @@ fun AppNavHost( selected = selectedServer, onSelectServer = onSelectServer, onOpenDetail = { openDetail(selectedServer?.id.orEmpty(), it) }, + onViewAll = { serverId, libraryId, title -> + navController.navigate(AppRoute.HomeLibraryAll.create(serverId, libraryId, title)) { + launchSingleTop = true + } + }, ) } @@ -246,6 +252,26 @@ fun AppNavHost( ) } + composable( + route = AppRoute.HomeLibraryAll.pattern, + arguments = listOf( + navArgument("serverId") { type = NavType.StringType }, + navArgument("libraryId") { type = NavType.StringType }, + navArgument("title") { type = NavType.StringType }, + ), + ) { entry -> + HomeLibraryAllScreen( + serverId = entry.arguments?.getString("serverId").orEmpty(), + libraryId = entry.arguments?.getString("libraryId").orEmpty(), + title = entry.arguments?.getString("title").orEmpty(), + servers = servers, + onOpenDetail = { itemId -> + openDetail(entry.arguments?.getString("serverId").orEmpty(), itemId) + }, + onBack = { navController.popBackStack() }, + ) + } + composable( route = AppRoute.MediaDetail.pattern, arguments = listOf( diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt index b83f437..f2d13ff 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt @@ -34,6 +34,11 @@ sealed interface AppRoute { override val pattern = "favorites/all/{serverId}/{type}" fun create(serverId: String, type: String) = "favorites/all/${serverId.encode()}/${type.encode()}" } + data object HomeLibraryAll : AppRoute { + override val pattern = "home/library/all/{serverId}/{libraryId}/{title}" + fun create(serverId: String, libraryId: String, title: String) = + "home/library/all/${serverId.encode()}/${libraryId.encode()}/${title.encode()}" + } } private fun String.encode(): String = Uri.encode(this) diff --git a/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt index c5655b3..63f3994 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt @@ -171,7 +171,7 @@ fun FavoritesAllScreen( } } else { LazyVerticalGrid( - columns = GridCells.Adaptive(115.dp), + columns = GridCells.Fixed(3), modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeLibraryAllScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeLibraryAllScreen.kt new file mode 100644 index 0000000..62bfa14 --- /dev/null +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeLibraryAllScreen.kt @@ -0,0 +1,298 @@ +package com.example.myapplication.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.background +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +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.snapshotFlow +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.request.ImageRequest +import com.example.myapplication.R as Res +import com.example.myapplication.data.EmbyItem +import com.example.myapplication.data.EmbyRepository +import com.example.myapplication.data.EmbyServer +import com.example.myapplication.ui.GlassPopupItem +import com.example.myapplication.ui.LiquidGlassPopupOverlay +import com.example.myapplication.ui.design.LumaGlassLevel +import com.example.myapplication.ui.design.LumaColors +import com.example.myapplication.ui.design.LumaEmptyState +import com.example.myapplication.ui.design.LumaErrorState +import com.example.myapplication.ui.design.LumaIcon +import com.example.myapplication.ui.design.LumaIconButton +import com.example.myapplication.ui.design.LumaLoading +import com.example.myapplication.ui.design.LumaMediaCard +import com.example.myapplication.ui.design.rememberLumaLayerBackdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import top.yukonga.miuix.kmp.basic.Text + +private enum class HomeLibrarySort( + val sortBy: String, + val sortOrder: String, + val labelRes: Int, + val icon: LumaIcon, +) { + JOIN_DATE("DateCreated", "Descending", Res.string.sort_join_date, LumaIcon.Calendar), + TITLE("SortName", "Ascending", Res.string.sort_title, LumaIcon.Sort), + RATING("CommunityRating", "Descending", Res.string.sort_rating, LumaIcon.Star), + YEAR("ProductionYear", "Descending", Res.string.sort_year, LumaIcon.Calendar), + RUNTIME("Runtime", "Descending", Res.string.sort_runtime, LumaIcon.Speed), + PLAY_DATE("DatePlayed", "Descending", Res.string.sort_play_date, LumaIcon.History), +} + +@Composable +fun HomeLibraryAllScreen( + serverId: String, + libraryId: String, + title: String, + servers: List, + onOpenDetail: (String) -> Unit = {}, + onBack: () -> Unit = {}, +) { + val server = remember(serverId) { servers.firstOrNull { it.id == serverId } } + val gridState = rememberLazyGridState() + var items by remember(serverId, libraryId) { mutableStateOf>(emptyList()) } + var totalCount by remember(serverId, libraryId) { mutableIntStateOf(0) } + var loading by remember(serverId, libraryId) { mutableStateOf(true) } + var loadingMore by remember(serverId, libraryId) { mutableStateOf(false) } + var error by remember(serverId, libraryId) { mutableStateOf(false) } + var retryToken by remember(serverId, libraryId) { mutableIntStateOf(0) } + var selectedSort by remember(serverId, libraryId) { mutableStateOf(HomeLibrarySort.JOIN_DATE) } + var sortMenuVisible by remember(serverId, libraryId) { mutableStateOf(false) } + var sortMenuMounted by remember(serverId, libraryId) { mutableStateOf(false) } + var sortAnchorBounds by remember { mutableStateOf(null) } + var rootOrigin by remember { mutableStateOf(Offset.Zero) } + val sortBackdrop = if (sortMenuMounted) rememberLumaLayerBackdrop() else null + + LaunchedEffect(serverId, libraryId, selectedSort, retryToken) { + if (server == null || server.apiKey.isBlank()) { + loading = false + return@LaunchedEffect + } + loading = true + error = false + try { + val page = EmbyRepository.getItemsForLibrary( + server = server, + libraryId = libraryId, + sortBy = selectedSort.sortBy, + sortOrder = selectedSort.sortOrder, + limit = 30, + ) + items = page.items + totalCount = page.totalCount + } catch (_: Exception) { + items = emptyList() + totalCount = 0 + error = true + } finally { + loading = false + } + } + + LaunchedEffect(gridState, items.size, totalCount) { + snapshotFlow { + gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + } + .map { lastVisibleIndex -> lastVisibleIndex >= items.lastIndex - 5 } + .distinctUntilChanged() + .filter { nearEnd -> nearEnd } + .collect { + if (loading || loadingMore || items.size >= totalCount || server == null) return@collect + loadingMore = true + try { + val page = EmbyRepository.getItemsForLibrary( + server = server, + libraryId = libraryId, + sortBy = selectedSort.sortBy, + sortOrder = selectedSort.sortOrder, + startIndex = items.size, + limit = 30, + ) + val newItems = page.items.filterNot { next -> items.any { it.id == next.id } } + if (newItems.isNotEmpty()) items = items + newItems + } finally { + loadingMore = false + } + } + } + + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { rootOrigin = it.positionInRoot() }, + ) { + Box( + Modifier + .fillMaxSize() + .background(LumaColors.background) + .then(if (sortBackdrop != null) Modifier.layerBackdrop(sortBackdrop) else Modifier), + ) + Column(Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 18.dp, vertical = 14.dp), + ) { + LumaIconButton( + icon = LumaIcon.Back, + contentDescription = stringResource(Res.string.back), + onClick = onBack, + selected = false, + level = LumaGlassLevel.Floating, + iconSize = 20.dp, + radius = 24.dp, + modifier = Modifier.align(Alignment.CenterStart), + ) + Text( + text = title, + color = LumaColors.text, + fontSize = 20.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + maxLines = 1, + modifier = Modifier.align(Alignment.Center), + ) + Box( + Modifier + .align(Alignment.CenterEnd) + .onGloballyPositioned { sortAnchorBounds = it.boundsInRoot() }, + ) { + LumaIconButton( + icon = LumaIcon.Sort, + contentDescription = stringResource(Res.string.sort), + onClick = { + sortMenuMounted = true + sortMenuVisible = true + }, + selected = false, + level = LumaGlassLevel.Floating, + iconSize = 20.dp, + radius = 24.dp, + ) + } + } + Box( + Modifier + .weight(1f) + .fillMaxWidth(), + ) { + when { + loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaLoading(label = stringResource(Res.string.loading_home)) + } + error -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaErrorState( + title = stringResource(Res.string.error_content_title), + description = stringResource(Res.string.error_content_sub), + retryLabel = stringResource(Res.string.retry), + onRetry = { retryToken++ }, + ) + } + items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaEmptyState( + icon = LumaIcon.Media, + title = stringResource(Res.string.empty_home_no_data), + description = stringResource(Res.string.empty_home_no_data_sub), + showIconBackground = false, + ) + } + else -> { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + state = gridState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(items = items, key = { it.id }) { item -> + HomeLibraryMediaCard( + item = item, + serverUrl = server?.fullUrl.orEmpty(), + onClick = { onOpenDetail(item.id) }, + ) + } + } + } + } + } + } + val anchor = sortAnchorBounds + if (sortMenuMounted && anchor != null) { + LiquidGlassPopupOverlay( + anchorBounds = anchor, + rootOrigin = rootOrigin, + backdrop = sortBackdrop, + visible = sortMenuVisible, + onDismissRequest = { sortMenuVisible = false }, + onClosed = { sortMenuMounted = false }, + width = 170.dp, + height = 304.dp, + placeBelowAnchor = true, + containerColor = LumaColors.backgroundElevated.copy(alpha = 0.94f), + borderColor = LumaColors.textTertiary.copy(alpha = 0.32f), + ) { + HomeLibrarySort.entries.forEach { option -> + GlassPopupItem( + text = stringResource(option.labelRes), + icon = option.icon, + selected = option == selectedSort, + onClick = { + selectedSort = option + sortMenuVisible = false + }, + ) + } + } + } + } +} + +@Composable +private fun HomeLibraryMediaCard( + item: EmbyItem, + serverUrl: String, + onClick: () -> Unit, +) { + LumaMediaCard( + model = EmbyRepository.buildImageUrl(serverUrl, item)?.let { + ImageRequest.Builder(LocalContext.current).data(it).crossfade(true).build() + }, + title = item.name, + modifier = Modifier.fillMaxWidth(), + imageAspectRatio = 115f / 165f, + titleColor = LumaColors.text, + titleSize = 12.sp, + onClick = onClick, + ) +} diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index 5603488..f0d31ec 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -100,6 +100,7 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import top.yukonga.miuix.kmp.basic.Text import androidx.compose.runtime.mutableIntStateOf @@ -122,6 +123,7 @@ private data class HomeSnapshot( private object HomeContentCache { private val snapshots = mutableMapOf() private val libraryPages = mutableMapOf() + private val episodeCounts = mutableMapOf() // cacheKey + seriesId → total episode count @Synchronized fun snapshot(key: String): HomeSnapshot? = snapshots[key] @@ -139,10 +141,19 @@ private object HomeContentCache { libraryPages["$key|$libraryId"] = value } + @Synchronized + fun episodeCount(cacheKey: String, seriesId: String): Int? = episodeCounts["$cacheKey|$seriesId"] + + @Synchronized + fun putEpisodeCount(cacheKey: String, seriesId: String, count: Int) { + episodeCounts["$cacheKey|$seriesId"] = count + } + @Synchronized fun invalidate(key: String) { snapshots.remove(key) libraryPages.keys.removeAll { it.startsWith("$key|") } + episodeCounts.keys.removeAll { it.startsWith("$key|") } } } @@ -394,6 +405,7 @@ private fun MediaPoster( item: EmbyItem, serverUrl: String, modifier: Modifier = Modifier, + episodeCount: Int = 0, onItemClick: (EmbyItem) -> Unit = {}, ) { val posterUrl = EmbyRepository.buildImageUrl(serverUrl, item) @@ -408,6 +420,26 @@ private fun MediaPoster( titleSize = 12.sp, titleSpacing = 4.dp, onClick = { onItemClick(item) }, + overlay = { + if (episodeCount > 0) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 6.dp, bottom = 6.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color.Black.copy(alpha = 0.65f)) + .padding(horizontal = 6.dp, vertical = 3.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "$episodeCount", + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + ) + } + } + }, ) } @@ -473,6 +505,7 @@ private fun LazyLibrarySection( activeServer: EmbyServer, refreshToken: Int, onItemClick: (EmbyItem) -> Unit = {}, + onViewAll: () -> Unit = {}, ) { val cacheKey = remember(activeServer.id, activeServer.fullUrl, activeServer.userId) { homeCacheKey(activeServer) @@ -480,6 +513,9 @@ private fun LazyLibrarySection( var page by remember(cacheKey, folder.id, refreshToken) { mutableStateOf(HomeContentCache.libraryPage(cacheKey, folder.id)) } + var episodeCounts by remember(cacheKey, folder.id, refreshToken) { + mutableStateOf>(emptyMap()) + } LaunchedEffect(cacheKey, folder.id, refreshToken) { if (page == null) { @@ -491,10 +527,26 @@ private fun LazyLibrarySection( HomeContentCache.putLibraryPage(cacheKey, folder.id, loaded) } } + // Fetch accurate episode counts for series items (parallel) + val seriesItems = page?.items?.filter { it.type == "Series" }?.takeIf { it.isNotEmpty() } ?: return@LaunchedEffect + val counts = mutableMapOf() + coroutineScope { + seriesItems.forEach { item -> + async { + val cached = HomeContentCache.episodeCount(cacheKey, item.id) + val count = cached ?: try { + EmbyRepository.getSeriesEpisodeCount(activeServer, item.id) + } catch (_: Exception) { 0 } + if (cached == null) HomeContentCache.putEpisodeCount(cacheKey, item.id, count) + counts[item.id] = count + } + } + } + episodeCounts = counts } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionTitle(title = folder.name, count = page?.totalCount ?: 0) + SectionTitle(title = folder.name, count = page?.totalCount ?: 0, onMoreClick = onViewAll) val list = page?.items if (list == null) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { @@ -509,7 +561,12 @@ private fun LazyLibrarySection( } else if (list.isNotEmpty()) { LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { items(list) { item -> - MediaPoster(item = item, serverUrl = activeServer.fullUrl, onItemClick = onItemClick) + MediaPoster( + item = item, + serverUrl = activeServer.fullUrl, + episodeCount = episodeCounts[item.id]?.takeIf { it > 0 } ?: 0, + onItemClick = onItemClick, + ) } } } @@ -520,6 +577,7 @@ private fun LazyLibrarySection( private fun SectionTitle( title: String, count: Int = 0, + onMoreClick: (() -> Unit)? = null, ) { Row( Modifier.fillMaxWidth(), @@ -532,7 +590,8 @@ private fun SectionTitle( text = stringResource(Res.string.more) + " >", color = homeAccentColor(), fontSize = 13.sp, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + modifier = Modifier.clickable(onClick = { onMoreClick?.invoke() }), ) } } @@ -721,6 +780,7 @@ fun HomeScreen( selected: EmbyServer? = null, onSelectServer: (String) -> Unit = {}, onOpenDetail: (String) -> Unit = {}, + onViewAll: (String, String, String) -> Unit = { _, _, _ -> }, ) { val activeServer = remember(servers, selected) { selected ?: servers.firstOrNull { it.apiKey.isNotBlank() } } val homeKey = activeServer?.let(::homeCacheKey) @@ -934,7 +994,13 @@ fun HomeScreen( } libraries.forEach { folder -> - LazyLibrarySection(folder, server, refreshToken = reloadToken) { onOpenDetail(it.id) } + LazyLibrarySection( + folder = folder, + activeServer = server, + refreshToken = reloadToken, + onItemClick = { onOpenDetail(it.id) }, + onViewAll = { onViewAll(server.id, folder.id, folder.name) }, + ) } ResourceStatsCard( diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt index 1dc2c6f..beca8be 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -57,6 +57,8 @@ import com.example.myapplication.ui.PosterImmersivePalette import com.example.myapplication.ui.rememberPosterImmersiveBackground import com.example.myapplication.ui.LiquidGlassButton import com.example.myapplication.ui.GlassTokens +import com.example.myapplication.ui.GlassPopupItem +import com.example.myapplication.ui.LiquidGlassPopupOverlay import com.example.myapplication.ui.LocalGlassBackdrop import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaIconView @@ -68,7 +70,11 @@ import com.example.myapplication.ui.design.lumaGlass import com.example.myapplication.ui.design.LumaSpacing import com.kyant.backdrop.backdrops.layerBackdrop import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -115,6 +121,7 @@ private data class DetailCastMember( ) private val DetailPrimaryButtonWidth = 188.dp +private val DetailSmallButtonWidth = 90.dp @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -139,6 +146,10 @@ fun MediaDetailScreen( var completedSeasonIds by remember(item.id) { mutableStateOf>(emptySet()) } var selectedEpisodeId by rememberSaveable(item.id) { mutableStateOf(null) } var showAllEpisodes by remember(item.id) { mutableStateOf(false) } + var showSeasonSelector by remember(item.id) { mutableStateOf(false) } + var seasonSelectorMounted by remember(item.id) { mutableStateOf(false) } + var seasonAnchorBounds by remember(item.id) { mutableStateOf(null) } + var detailRootOrigin by remember(item.id) { mutableStateOf(Offset.Zero) } var loadingSeasons by remember(item.id) { mutableStateOf(detail.type == "Series") } var loadingEpisodes by remember(item.id) { mutableStateOf(false) } var loadingMoreEpisodes by remember(item.id) { mutableStateOf(false) } @@ -447,7 +458,11 @@ fun MediaDetailScreen( LocalDetailBorder provides detailBorderColor, LocalDetailAccent provides detailAccentColor, ) { - Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { detailRootOrigin = it.positionInRoot() }, + ) { Box( Modifier .fillMaxSize() @@ -472,7 +487,7 @@ fun MediaDetailScreen( Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState(), enabled = !seasonSelectorMounted), ) { DetailHero( item = detail, @@ -589,50 +604,26 @@ fun MediaDetailScreen( EpisodeSkeletonRow(label = stringResource(Res.string.detail_loading_seasons)) } else if (seasons.isNotEmpty()) { Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - LazyRow( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - items(seasons.size) { index -> - val season = seasons[index] - val selected = season.id == selectedSeasonId - Box( - Modifier - .height(42.dp) - .clip(RoundedCornerShape(999.dp)) - .background(if (selected) LocalDetailAccent.current else LocalDetailSurface.current) - .border(1.dp, if (selected) Color.Transparent else LocalDetailBorder.current, RoundedCornerShape(999.dp)) - .clickable { - useSelectedEpisodeForPlayback = true - selectedEpisodeId = null - selectedSeasonId = season.id - } - .padding(horizontal = 15.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = season.name, - color = if (selected) { - if (LocalDetailAccent.current.luminance() > 0.5f) Color(0xFF111418) else Color.White - } else { - LocalDetailText.current.copy(alpha = 0.84f) - }, - fontSize = 12.sp, - lineHeight = 16.sp, - maxLines = 1, - textAlign = TextAlign.Center, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, - ) - } - } - } + DetailSmallButton( + modifier = Modifier + .width(DetailSmallButtonWidth) + .onGloballyPositioned { seasonAnchorBounds = it.boundsInRoot() }, + icon = LumaIcon.Collection, + label = seasons.firstOrNull { it.id == selectedSeasonId }?.name.orEmpty(), + tint = GlassTokens.text, + onClick = { + seasonSelectorMounted = true + showSeasonSelector = true + }, + ) Box( modifier = Modifier + .weight(1f) .height(42.dp) .clickable(enabled = episodes.isNotEmpty()) { showAllEpisodes = true }, contentAlignment = Alignment.Center, @@ -770,6 +761,39 @@ fun MediaDetailScreen( }, ) } + if (seasonSelectorMounted) { + val anchor = seasonAnchorBounds + if (anchor != null) { + LiquidGlassPopupOverlay( + anchorBounds = anchor, + rootOrigin = detailRootOrigin, + backdrop = null, + visible = showSeasonSelector, + onDismissRequest = { showSeasonSelector = false }, + onClosed = { seasonSelectorMounted = false }, + width = 190.dp, + height = minOf(300.dp, (seasons.size * 52 + 8).dp), + autoPlaceAroundAnchor = true, + alignToAnchorStart = true, + anchorGap = 7.dp, + containerColor = immersive.pageBottom.copy(alpha = 0.98f), + ) { + seasons.forEach { season -> + GlassPopupItem( + text = season.name, + icon = LumaIcon.Collection, + selected = season.id == selectedSeasonId, + onClick = { + showSeasonSelector = false + useSelectedEpisodeForPlayback = true + selectedEpisodeId = null + selectedSeasonId = season.id + }, + ) + } + } + } + } } } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt index 98ccd35..7d2de1a 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -497,11 +497,7 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> }) LumaLoading(label = stringResource(Res.string.loading_search)) } } else { - val filteredEpisodes = if (selectedDate != null) { - calendarEpisodes.filter { it.airDate == selectedDate.toString() } - } else { - calendarEpisodes - } + val filteredEpisodes = filterCalendarEpisodes(calendarEpisodes, selectedDate, today) if (filteredEpisodes.isEmpty()) { LumaEmptyStateContainer( icon = LumaIcon.Calendar, @@ -556,6 +552,23 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> }) } } +internal fun filterCalendarEpisodes( + episodes: List, + selectedDate: java.time.LocalDate?, + today: java.time.LocalDate, +): List { + if (selectedDate != null) { + return episodes.filter { it.airDate == selectedDate.toString() } + } + return episodes.filter { + try { + !java.time.LocalDate.parse(it.airDate).isBefore(today) + } catch (_: Exception) { + false + } + } +} + private fun getRelativeDateLabel(date: java.time.LocalDate, today: java.time.LocalDate): String { val dayOfWeek = when (date.dayOfWeek) { java.time.DayOfWeek.MONDAY -> "周一" diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchAllScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchAllScreen.kt index a0813ee..93ee690 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchAllScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchAllScreen.kt @@ -203,7 +203,7 @@ fun SearchAllScreen( } else -> { LazyVerticalGrid( - columns = GridCells.Adaptive(115.dp), + columns = GridCells.Fixed(3), modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt index e0b3494..dee5eef 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -468,6 +468,7 @@ fun LiquidGlassPopupOverlay( expandToRight: Boolean = false, placeBelowAnchor: Boolean = false, autoPlaceAroundAnchor: Boolean = false, + alignToAnchorStart: Boolean = false, anchorGap: Dp = 3.dp, bottomInset: Dp = 0.dp, containerColor: Color? = null, @@ -526,7 +527,9 @@ fun LiquidGlassPopupOverlay( val initialSizePx = with(density) { initialSize.toPx() } val anchorCenterX = (anchorLeft + anchorRight) / 2f val anchorCenterY = (anchorTop + anchorBottom) / 2f - val menuLeft = if (autoPlaceAroundAnchor) { + val menuLeft = if (autoPlaceAroundAnchor && alignToAnchorStart) { + anchorLeft + } else if (autoPlaceAroundAnchor) { val expandRight = (containerWidthPx - anchorRight) >= anchorLeft if (expandRight) anchorRight + gapPx else anchorLeft - gapPx - menuWidthPx } else if (expandToRight) { @@ -615,6 +618,7 @@ fun GlassPopupItem( text: String, icon: LumaIcon, tint: Color = LumaColors.text, + selected: Boolean = false, onClick: () -> Unit, ) { Row( @@ -627,7 +631,21 @@ fun GlassPopupItem( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { LumaIconView(icon, contentDescription = null, tint = tint, modifier = Modifier.size(18.dp)) - Text(text = text, color = tint, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Text( + text = text, + color = tint, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + if (selected) { + LumaIconView( + icon = LumaIcon.Check, + contentDescription = null, + tint = LumaColors.accent, + modifier = Modifier.size(18.dp), + ) + } } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 0594c91..6ad0d8d 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -59,6 +59,13 @@ Personal Media Center Not connected More + Sort + Date Added + Title + Rating + Release Year + Runtime + Play Date Play Libraries All diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ae519f..5e8e3c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,6 +60,13 @@ 个人媒体中心 未连接 更多 + 排序 + 加入日期 + 标题 + 评分 + 出品年份 + 播放时长 + 播放日期 播放 媒体库 全部 diff --git a/app/src/test/kotlin/com/example/myapplication/screens/RecordsScreenDataTest.kt b/app/src/test/kotlin/com/example/myapplication/screens/RecordsScreenDataTest.kt new file mode 100644 index 0000000..2865caf --- /dev/null +++ b/app/src/test/kotlin/com/example/myapplication/screens/RecordsScreenDataTest.kt @@ -0,0 +1,39 @@ +package com.example.myapplication.screens + +import com.example.myapplication.data.TmdbEpisodeCalendar +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Test + +class RecordsScreenDataTest { + @Test + fun calendarHidesPastEpisodesUntilDateIsSelected() { + val today = LocalDate.of(2026, 9, 3) + val episodes = listOf( + episode("2026-09-02"), + episode("2026-09-03"), + episode("2026-09-04"), + ) + + assertEquals( + listOf("2026-09-03", "2026-09-04"), + filterCalendarEpisodes(episodes, selectedDate = null, today = today).map { it.airDate }, + ) + assertEquals( + listOf("2026-09-02"), + filterCalendarEpisodes(episodes, selectedDate = today.minusDays(1), today = today).map { it.airDate }, + ) + } + + private fun episode(airDate: String) = TmdbEpisodeCalendar( + tmdbId = 1, + seriesName = "Test", + seasonNumber = 1, + episodeNumber = 1, + episodeName = "Episode", + airDate = airDate, + overview = "", + stillPath = null, + posterUrl = null, + ) +}