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