小优化

This commit is contained in:
beyond committed 2026-09-03 15:22:46 +08:00
1 parent e81f5485c6
commit 67c602a5ef
15 files changed
+669 -105

No files matched your search

@@ -54,6 +54,7 @@ data class EmbyItem(
@SerializedName("Genres") val _genres: List<String>? = emptyList(),
@SerializedName("RunTimeTicks") val runTimeTicks: Long? = null,
@SerializedName("PremiereDate") val premiereDate: String? = null,
@SerializedName("DateCreated") val dateCreated: String? = null,
@SerializedName("ImageTags") val imageTags: Map<String, String>? = null,
@SerializedName("BackdropImageTags") val _backdropImageTags: List<String>? = 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(
@@ -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<EmbyItem> = 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,
@@ -85,6 +85,15 @@ private data class TmdbRecommendationsResponse(
@SerializedName("results") val results: List<TmdbRecommendationItem> = emptyList(),
)
private data class TmdbImagesResponse(
@SerializedName("logos") val logos: List<TmdbImage> = 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<TmdbNetwork> = 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<WatchlistEntity>): List<TmdbEpisodeCalendar> = withContext(Dispatchers.IO) {
if (!isConfigured || watchlist.isEmpty()) return@withContext emptyList()
val today = java.time.LocalDate.now().toString() // "2026-08-31"
val results = mutableListOf<TmdbEpisodeCalendar>()
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)
}
@@ -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(
@@ -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)
@@ -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),
@@ -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<EmbyServer>,
onOpenDetail: (String) -> Unit = {},
onBack: () -> Unit = {},
) {
val server = remember(serverId) { servers.firstOrNull { it.id == serverId } }
val gridState = rememberLazyGridState()
var items by remember(serverId, libraryId) { mutableStateOf<List<EmbyItem>>(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<Rect?>(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,
)
}
@@ -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<String, HomeSnapshot>()
private val libraryPages = mutableMapOf<String, EmbyLibraryPage>()
private val episodeCounts = mutableMapOf<String, Int>() // 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<Map<String, Int>>(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<String, Int>()
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(
@@ -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<Set<String>>(emptySet()) }
var selectedEpisodeId by rememberSaveable(item.id) { mutableStateOf<String?>(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<Rect?>(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
},
)
}
}
}
}
}
}
}
@@ -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<TmdbEpisodeCalendar>,
selectedDate: java.time.LocalDate?,
today: java.time.LocalDate,
): List<TmdbEpisodeCalendar> {
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 -> "周一"
@@ -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),
@@ -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),
)
}
}
}
+7
View File
@@ -59,6 +59,13 @@
<string name="home_media_center">Personal Media Center</string>
<string name="home_not_connected">Not connected</string>
<string name="more">More</string>
<string name="sort">Sort</string>
<string name="sort_join_date">Date Added</string>
<string name="sort_title">Title</string>
<string name="sort_rating">Rating</string>
<string name="sort_year">Release Year</string>
<string name="sort_runtime">Runtime</string>
<string name="sort_play_date">Play Date</string>
<string name="play">Play</string>
<string name="section_library">Libraries</string>
<string name="action_all">All</string>
+7
View File
@@ -60,6 +60,13 @@
<string name="home_media_center">个人媒体中心</string>
<string name="home_not_connected">未连接</string>
<string name="more">更多</string>
<string name="sort">排序</string>
<string name="sort_join_date">加入日期</string>
<string name="sort_title">标题</string>
<string name="sort_rating">评分</string>
<string name="sort_year">出品年份</string>
<string name="sort_runtime">播放时长</string>
<string name="sort_play_date">播放日期</string>
<string name="play">播放</string>
<string name="section_library">媒体库</string>
<string name="action_all">全部</string>
@@ -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,
)
}