搜索界面小调整
This commit is contained in:
1 parent
d6149d08a1
commit
b37cf69068
2 files changed
+103
-72
No files matched your search
@@ -111,6 +111,8 @@ private data class DetailCastMember(
|
||||
val imageUrl: String?,
|
||||
)
|
||||
|
||||
private val DetailPrimaryButtonWidth = 188.dp
|
||||
|
||||
@Composable
|
||||
fun MediaDetailScreen(
|
||||
item: EmbyItem,
|
||||
@@ -140,6 +142,7 @@ fun MediaDetailScreen(
|
||||
var useSelectedEpisodeForPlayback by remember(item.id) { mutableStateOf(false) }
|
||||
var loadingResources by remember(item.id) { mutableStateOf(false) }
|
||||
var resources by remember(item.id) { mutableStateOf<List<DetailResource>>(emptyList()) }
|
||||
var selectedResourceServerId by remember(item.id) { mutableStateOf<String?>(null) }
|
||||
var tmdbExtras by remember(item.id) { mutableStateOf(TmdbSeriesExtras()) }
|
||||
var loadingTmdb by remember(item.id) {
|
||||
mutableStateOf(TmdbRepository.isConfigured)
|
||||
@@ -327,6 +330,14 @@ fun MediaDetailScreen(
|
||||
loadingResources = false
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedPlayableItem?.id, resources, server?.id) {
|
||||
selectedResourceServerId = resources
|
||||
.firstOrNull { it.server.id == server?.id }
|
||||
?.server
|
||||
?.id
|
||||
?: resources.firstOrNull()?.server?.id
|
||||
}
|
||||
|
||||
val tmdbId = detail.providerIds.entries
|
||||
.firstOrNull { it.key.equals("Tmdb", ignoreCase = true) }
|
||||
?.value
|
||||
@@ -462,12 +473,20 @@ fun MediaDetailScreen(
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
// Favorite + Watchlist buttons (bottom, smaller)
|
||||
val showWatchlistButton = detail.type == "Series"
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier
|
||||
.then(if (showWatchlistButton) Modifier.width(DetailPrimaryButtonWidth) else Modifier)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DetailSmallButton(
|
||||
modifier = if (showWatchlistButton) {
|
||||
Modifier.weight(1f)
|
||||
} else {
|
||||
Modifier.widthIn(min = 100.dp)
|
||||
},
|
||||
icon = LumaIcon.Favorite,
|
||||
label = if (isFavorited) stringResource(Res.string.detail_unfavorite) else stringResource(Res.string.detail_favorite),
|
||||
tint = if (isFavorited) Color(0xFFFF3B30) else GlassTokens.text,
|
||||
@@ -480,8 +499,9 @@ fun MediaDetailScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
if (detail.type == "Series") {
|
||||
if (showWatchlistButton) {
|
||||
DetailSmallButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
icon = LumaIcon.Calendar,
|
||||
label = if (isWatching) stringResource(Res.string.detail_unwatch) else stringResource(Res.string.detail_watch),
|
||||
tint = if (isWatching) Color(0xFF34C759) else GlassTokens.text,
|
||||
@@ -641,7 +661,12 @@ fun MediaDetailScreen(
|
||||
|
||||
if (selectedPlayableItem != null) {
|
||||
Spacer(Modifier.height(22.dp))
|
||||
ResourceSection(resources = resources, loading = loadingResources)
|
||||
ResourceSection(
|
||||
resources = resources,
|
||||
loading = loadingResources,
|
||||
selectedResourceServerId = selectedResourceServerId,
|
||||
onResourceSelected = { selectedResourceServerId = it.server.id },
|
||||
)
|
||||
}
|
||||
|
||||
if (castMembers.isNotEmpty() || loadingTmdb) {
|
||||
@@ -840,54 +865,55 @@ private fun DetailHeadline(detail: EmbyItem, serverUrl: String) {
|
||||
}
|
||||
}
|
||||
|
||||
detail.taglines.firstOrNull()?.let { tagline ->
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Spacer(Modifier.height(9.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(20.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
detail.communityRating?.takeIf { it > 0.0 }?.let { rating ->
|
||||
Text(
|
||||
text = "★ ${String.format("%.1f", rating)}",
|
||||
color = Color(0xFFFFC107),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
detail.productionYear?.let { year ->
|
||||
Text(
|
||||
text = "$year",
|
||||
color = LocalDetailText.current.copy(alpha = 0.88f),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = tagline,
|
||||
color = LocalDetailAccent.current.copy(alpha = 0.90f),
|
||||
text = listOfNotNull(
|
||||
detail.type?.let {
|
||||
when (it) {
|
||||
"Series" -> stringResource(Res.string.detail_type_series)
|
||||
"Movie" -> stringResource(Res.string.detail_type_movie)
|
||||
else -> it
|
||||
}
|
||||
},
|
||||
detail.genres.take(2).joinToString(" / ").takeIf { it.isNotBlank() },
|
||||
).joinToString(" · "),
|
||||
color = LocalDetailSecondary.current.copy(alpha = 0.82f),
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(9.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
detail.communityRating?.takeIf { it > 0.0 }?.let { rating ->
|
||||
LumaIconView(LumaIcon.Star, contentDescription = null, tint = Color(0xFFFFD54F), modifier = Modifier.size(14.dp))
|
||||
Spacer(Modifier.width(3.dp))
|
||||
Text(text = String.format("%.1f", rating), color = LocalDetailText.current, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.width(9.dp))
|
||||
}
|
||||
if (detail.productionYear != null) {
|
||||
Text(text = "${detail.productionYear}", color = LocalDetailSecondary.current, fontSize = 12.sp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
formatRuntime(detail.runTimeTicks)?.let { runtime ->
|
||||
Text(text = runtime, color = LocalDetailSecondary.current, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = when {
|
||||
detail.type == "Series" -> stringResource(Res.string.detail_type_series)
|
||||
detail.type == "Movie" -> stringResource(Res.string.detail_type_movie)
|
||||
else -> detail.type ?: stringResource(Res.string.detail_type_media)
|
||||
},
|
||||
color = Color(0xFFBFDBFE),
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun DetailPlayButton(resumePositionMs: Long, loading: Boolean, onClick: () -> Unit) {
|
||||
LiquidGlassButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.width(188.dp).height(48.dp),
|
||||
modifier = Modifier.width(DetailPrimaryButtonWidth).height(48.dp),
|
||||
enabled = !loading,
|
||||
cornerRadius = 999.dp,
|
||||
fill = GlassTokens.glassFillStrong,
|
||||
@@ -930,7 +956,7 @@ private fun DetailSmallButton(
|
||||
) {
|
||||
LiquidGlassButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(36.dp).widthIn(min = 100.dp),
|
||||
modifier = modifier.height(36.dp),
|
||||
cornerRadius = 999.dp,
|
||||
fill = GlassTokens.glassFillStrong,
|
||||
) {
|
||||
@@ -944,7 +970,7 @@ private fun DetailSmallButton(
|
||||
Text(
|
||||
text = label,
|
||||
color = GlassTokens.text,
|
||||
fontSize = 12.sp,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
@@ -1062,7 +1088,12 @@ private fun ResourceSkeletonRow(label: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResourceSection(resources: List<DetailResource>, loading: Boolean) {
|
||||
private fun ResourceSection(
|
||||
resources: List<DetailResource>,
|
||||
loading: Boolean,
|
||||
selectedResourceServerId: String?,
|
||||
onResourceSelected: (DetailResource) -> Unit,
|
||||
) {
|
||||
DetailSectionTitle(stringResource(Res.string.detail_resources), LumaIcon.Server)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
when {
|
||||
@@ -1083,6 +1114,8 @@ private fun ResourceSection(resources: List<DetailResource>, loading: Boolean) {
|
||||
ResourceCard(
|
||||
resource = resource,
|
||||
isBest = resources.size > 1 && bestBitrate > 0 && resourceVideoBitrate(resource) == bestBitrate,
|
||||
selected = resource.server.id == selectedResourceServerId,
|
||||
onClick = { onResourceSelected(resource) },
|
||||
modifier = Modifier.width(cardWidth),
|
||||
)
|
||||
}
|
||||
@@ -1112,7 +1145,13 @@ private fun ResourceSection(resources: List<DetailResource>, loading: Boolean) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResourceCard(resource: DetailResource, isBest: Boolean, modifier: Modifier = Modifier) {
|
||||
private fun ResourceCard(
|
||||
resource: DetailResource,
|
||||
isBest: Boolean,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val source = resource.item.mediaSources.firstOrNull()
|
||||
val streams = source?.mediaStreams.orEmpty().ifEmpty { resource.item.mediaStreams }
|
||||
val video = streams.firstOrNull { it.type.equals("Video", ignoreCase = true) }
|
||||
@@ -1131,11 +1170,16 @@ private fun ResourceCard(resource: DetailResource, isBest: Boolean, modifier: Mo
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
LocalDetailSurface.current,
|
||||
LocalDetailAccent.current.copy(alpha = 0.08f),
|
||||
LocalDetailAccent.current.copy(alpha = if (selected) 0.18f else 0.08f),
|
||||
)
|
||||
)
|
||||
)
|
||||
.border(1.dp, LocalDetailBorder.current, shape)
|
||||
.border(
|
||||
width = if (selected) 1.5.dp else 1.dp,
|
||||
color = if (selected) LocalDetailAccent.current else LocalDetailBorder.current,
|
||||
shape = shape,
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
@@ -1891,20 +1935,3 @@ private fun EpisodeCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun formatRuntime(ticks: Long?): String? {
|
||||
if (ticks == null || ticks <= 0) return null
|
||||
val minutes = ticks / 600_000_000L
|
||||
if (minutes <= 0) return null
|
||||
return if (minutes < 60) {
|
||||
stringResource(Res.string.detail_runtime_minutes, minutes.toInt())
|
||||
} else {
|
||||
val hours = (minutes / 60).toInt()
|
||||
val remainingMinutes = (minutes % 60).toInt()
|
||||
if (remainingMinutes == 0) {
|
||||
stringResource(Res.string.detail_runtime_hours, hours)
|
||||
} else {
|
||||
stringResource(Res.string.detail_runtime_hours_minutes, hours, remainingMinutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,16 +218,20 @@ fun SearchScreen(
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
// ★ Cache check #2: same query + cache hit → restore from cache
|
||||
if (term == lastSearchedQuery) {
|
||||
val cached = SearchResultsCache.get(term)
|
||||
if (cached != null) {
|
||||
completedResults.clear()
|
||||
completedResults.addAll(cached.completedResults)
|
||||
pendingServers.clear()
|
||||
searching = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
// ★ Cache check #2: restore results even when this screen was recreated
|
||||
// after returning from SearchAllScreen. The remembered query is lost
|
||||
// with the old composition, but the process-level cache survives it.
|
||||
val cached = SearchResultsCache.get(term)
|
||||
if (cached != null) {
|
||||
completedResults.clear()
|
||||
completedResults.addAll(cached.completedResults)
|
||||
completedIds.clear()
|
||||
completedIds.addAll(cached.completedResults.map { it.server.id })
|
||||
pendingServers.clear()
|
||||
pendingServers.addAll(servers.filter { it.id in cached.pendingServerIds })
|
||||
lastSearchedQuery = term
|
||||
searching = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
// ★ Cache check #3: same query + still searching → don't restart
|
||||
|
||||
Reference in new issue
Block a user