小优化
This commit is contained in:
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user