接入 AndroidLiquidGlass 液态玻璃交互

This commit is contained in:
BoatingCloud committed 2026-08-27 15:35:40 +08:00
1 parent 944291df49
commit d159ec4a99
19 files changed
+1315 -428

No files matched your search

+2 -2
View File
@@ -50,8 +50,8 @@ dependencies {
implementation("top.yukonga.miuix.kmp:miuix-ui:0.9.4-rc01")
implementation("top.yukonga.miuix.kmp:miuix-blur:0.9.4-rc01")
implementation("dev.chrisbanes.haze:haze:1.5.3")
implementation("io.github.kyant0:backdrop:2.0.0")
implementation("io.github.kyant0:shapes:1.2.0")
implementation("io.github.kyant0:backdrop:2.0.1")
implementation("io.github.kyant0:shapes:1.2.1")
implementation("top.yukonga.miuix.kmp:miuix-squircle:0.9.4-rc01")
implementation("top.yukonga.miuix.kmp:miuix-icons:0.9.4-rc01")
implementation("androidx.room:room-runtime:2.8.4")
@@ -12,7 +12,6 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -66,11 +65,9 @@ import com.example.myapplication.screens.SettingsTopBar
import com.example.myapplication.ui.AppTheme
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LiquidAppBottomBar
import com.example.myapplication.ui.LiquidGlassBackground
import com.example.myapplication.ui.LocalGlassBackdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Text
@@ -84,10 +81,10 @@ fun MainApp() {
var recordsSegment by remember { mutableIntStateOf(0) }
var searchQuery by remember { mutableStateOf("") }
var showLibraryAddDialog by remember { mutableStateOf(false) }
var libraryIsGrid by remember { mutableStateOf(true) }
var libraryIsGrid by remember { mutableStateOf(false) }
var showIconLibraryManager by remember { mutableStateOf(false) }
var showCacheDetail by remember { mutableStateOf(false) }
var homeDetailVisible by remember { mutableStateOf(false) }
var detailVisible by remember { mutableStateOf(false) }
var showAboutDetail by remember { mutableStateOf(false) }
var changeIconTarget by remember { mutableStateOf<EmbyServer?>(null) }
var showStartScreen by remember { mutableStateOf(true) }
@@ -104,6 +101,16 @@ fun MainApp() {
}
}
LaunchedEffect(servers) {
if (servers.isNotEmpty()) {
if (selectedServerId.isBlank()) {
selectedServerId = servers.firstOrNull { it.isSelected }?.id ?: servers.first().id
} else if (servers.none { it.id == selectedServerId }) {
selectedServerId = servers.first().id
}
}
}
LaunchedEffect(Unit) {
delay(2200)
showStartScreen = false
@@ -120,12 +127,11 @@ fun MainApp() {
if (!showStartScreen) {
MiuixTheme {
val hazeState = remember { HazeState() }
val glassBackdrop = rememberLayerBackdrop()
val bgColor = if (AppTheme.isDark) Color(0xFF0F1115) else Color(0xFFF2F4F7)
Box(Modifier.fillMaxSize().background(bgColor)) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
androidx.compose.runtime.CompositionLocalProvider(LocalGlassBackdrop provides glassBackdrop) {
Box(Modifier.fillMaxSize().background(bgColor)) {
if (selectedTab == 2 && showIconLibraryManager) {
IconLibraryManagerScreen(
libraries = iconLibraries,
@@ -147,78 +153,93 @@ fun MainApp() {
onRefreshIconLibrary = { id -> coroutineScope.launch { IconLibraryRepository.refreshLibrary(id) } },
)
} else {
Column(Modifier.fillMaxSize()) {
Box(Modifier.weight(1f).layerBackdrop(glassBackdrop)) {
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = selectedTab,
transitionSpec = {
if (targetState > initialState) {
(slideInHorizontally(tween(400)) { it } + fadeIn()).togetherWith(
slideOutHorizontally(tween(400)) { -it } + fadeOut())
} else {
(slideInHorizontally(tween(400)) { -it } + fadeIn()).togetherWith(
slideOutHorizontally(tween(400)) { it } + fadeOut())
}
val direction = if (targetState > initialState) 1 else -1
(slideInHorizontally(tween(320)) { direction * it } + fadeIn(tween(260))).togetherWith(
slideOutHorizontally(tween(260)) { -direction * it } + fadeOut(tween(180))
)
},
label = "MainTabTransition",
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopStart,
contentKey = { it },
) { tab ->
Column(Modifier.fillMaxSize()) {
when (tab) {
0 -> Spacer(Modifier.height(0.dp))
1 -> RecordsTopBar(recordsSegment) { recordsSegment = it }
2 -> LibraryTopBar(
isGrid = libraryIsGrid,
onViewToggle = { libraryIsGrid = !libraryIsGrid },
onAdd = { showLibraryAddDialog = true },
onOpenIconLibraryManager = { showIconLibraryManager = true },
)
3 -> {
if (showCacheDetail) {
Row(
Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 18.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.size(34.dp).clip(CircleShape).background(Color.White).border(0.5.dp, Color.LightGray, CircleShape)
.clickable { showCacheDetail = false },
contentAlignment = Alignment.Center,
if (!detailVisible) {
when (tab) {
0 -> Spacer(Modifier.height(0.dp))
1 -> RecordsTopBar(recordsSegment) { recordsSegment = it }
2 -> LibraryTopBar(
isGrid = libraryIsGrid,
onViewToggle = { libraryIsGrid = !libraryIsGrid },
onAdd = { showLibraryAddDialog = true },
onOpenIconLibraryManager = { showIconLibraryManager = true },
)
3 -> {
if (showCacheDetail) {
Row(
Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 18.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color(0xFF374151), modifier = Modifier.size(19.dp))
Box(
Modifier.size(34.dp).clip(CircleShape).background(GlassTokens.glassFill).border(0.5.dp, GlassTokens.glassBorder, CircleShape)
.clickable { showCacheDetail = false },
contentAlignment = Alignment.Center,
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = GlassTokens.text, modifier = Modifier.size(19.dp))
}
Spacer(Modifier.width(12.dp))
Text("缓存管理", color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(12.dp))
Text("缓存管理", color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
} else if (showAboutDetail) {
Row(
Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 18.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.size(34.dp).clip(CircleShape).background(Color.White).border(0.5.dp, Color.LightGray, CircleShape)
.clickable { showAboutDetail = false },
contentAlignment = Alignment.Center,
} else if (showAboutDetail) {
Row(
Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 18.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color(0xFF374151), modifier = Modifier.size(19.dp))
Box(
Modifier.size(34.dp).clip(CircleShape).background(GlassTokens.glassFill).border(0.5.dp, GlassTokens.glassBorder, CircleShape)
.clickable { showAboutDetail = false },
contentAlignment = Alignment.Center,
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = GlassTokens.text, modifier = Modifier.size(19.dp))
}
Spacer(Modifier.width(12.dp))
Text("关于", color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(12.dp))
Text("关于", color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold)
} else {
SettingsTopBar()
}
} else {
SettingsTopBar()
}
4 -> SearchTopBar(searchQuery, { searchQuery = it }, { })
}
4 -> SearchTopBar(searchQuery, { searchQuery = it }, { })
}
Box(Modifier.weight(1f)) {
when (tab) {
0 -> HomeScreen(servers = servers, selected = selectedServer, onDetailVisibilityChanged = { homeDetailVisible = it })
1 -> RecordsScreen(segment = recordsSegment, activeServer = selectedServer)
Box(Modifier.weight(1f).layerBackdrop(glassBackdrop)) {
androidx.compose.runtime.CompositionLocalProvider(LocalGlassBackdrop provides null) {
when (tab) {
0 -> HomeScreen(
servers = servers,
selected = selectedServer,
onDetailVisibilityChanged = { detailVisible = it },
onSelectServer = { id ->
selectedServerId = id
selectedTab = 0
coroutineScope.launch { ServerRepository.setSelectedServer(id) }
},
)
1 -> RecordsScreen(segment = recordsSegment, activeServer = selectedServer, onDetailVisibilityChanged = { detailVisible = it })
2 -> LibraryScreen(
servers = servers,
selectedServer = selectedServer,
onSelectServer = { selectedServerId = it },
onSelectServer = { id ->
selectedServerId = id
selectedTab = 0
coroutineScope.launch { ServerRepository.setSelectedServer(id) }
},
onAddServer = { server, onResult ->
coroutineScope.launch {
try {
@@ -249,11 +270,11 @@ fun MainApp() {
showAddDialog = showLibraryAddDialog,
onDismissAddDialog = { showLibraryAddDialog = false },
isGrid = libraryIsGrid,
showTopBar = false,
onChangeIconTarget = changeIconTarget,
onDismissChangeIcon = { changeIconTarget = null },
onPickIcon = { server, iconUrl -> ServerRepository.setRuntimeIcon(server.id, iconUrl) },
onRequestChangeIcon = { server -> changeIconTarget = server },
showTopBar = false,
)
3 -> SettingsScreen(
servers = servers,
@@ -264,20 +285,24 @@ fun MainApp() {
onOpenAbout = { showAboutDetail = true },
onBackFromAbout = { showAboutDetail = false },
)
4 -> SearchScreen(query = searchQuery)
4 -> SearchScreen(query = searchQuery, activeServer = selectedServer, onDetailVisibilityChanged = { detailVisible = it })
}
}
}
}
}
}
if (!homeDetailVisible) {
Box(Modifier.fillMaxWidth().padding(bottom = 12.dp), contentAlignment = Alignment.BottomCenter) {
LiquidAppBottomBar(
selectedTab = selectedTab,
onSelect = { selectedTab = it },
backdrop = glassBackdrop
)
}
if (!detailVisible) {
Box(
Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(bottom = 12.dp),
contentAlignment = Alignment.BottomCenter,
) {
LiquidAppBottomBar(
selectedTab = selectedTab,
onSelect = { selectedTab = it },
backdrop = glassBackdrop,
)
}
}
}
@@ -286,3 +311,4 @@ fun MainApp() {
}
}
}
}
@@ -65,6 +65,7 @@ data class EmbyItem(
@SerializedName("People") val _people: List<EmbyPerson>? = emptyList(),
@SerializedName("Studios") val _studios: List<EmbyNamedReference>? = emptyList(),
@SerializedName("MediaStreams") val _mediaStreams: List<EmbyMediaStream>? = emptyList(),
@SerializedName("UserData") val userData: EmbyUserData? = null,
) {
val genres: List<String> get() = _genres.orEmpty()
val backdropImageTags: List<String> get() = _backdropImageTags.orEmpty()
@@ -72,6 +73,8 @@ data class EmbyItem(
val people: List<EmbyPerson> get() = _people.orEmpty()
val studios: List<EmbyNamedReference> get() = _studios.orEmpty()
val mediaStreams: List<EmbyMediaStream> get() = _mediaStreams.orEmpty()
val playbackPositionMs: Long
get() = (userData?.playbackPositionTicks ?: 0L).coerceAtLeast(0L) / 10_000L
val primaryImageTag: String?
get() = imageTags?.get("Primary")
@@ -91,6 +94,11 @@ data class EmbyItem(
}
}
data class EmbyUserData(
@SerializedName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0L,
@SerializedName("Played") val played: Boolean = false,
)
data class EmbyPerson(
@SerializedName("Id") val id: String = "",
@SerializedName("Name") val name: String = "",
@@ -1,7 +1,9 @@
package com.example.myapplication.data
import android.net.Uri
import android.util.Log
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@@ -129,9 +131,18 @@ object EmbyRepository {
*/
suspend fun getFavoriteItems(server: EmbyServer, limit: Int = 50): List<EmbyItem> = withContext(Dispatchers.IO) {
try {
val url = "${server.fullUrl}/emby/Users/${server.userId}/Items?Recursive=true&IsFavorite=true&Limit=$limit&Fields=PrimaryImageAspectRatio,Overview,Genres,ProductionYear"
val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items").buildUpon()
.appendQueryParameter("Recursive", "true")
.appendQueryParameter("IsFavorite", "true")
.appendQueryParameter("IncludeItemTypes", "Movie,Series")
.appendQueryParameter("Limit", limit.toString())
.appendQueryParameter("Fields", "PrimaryImageAspectRatio,Overview,Genres,ProductionYear,UserData")
.appendQueryParameter("EnableImages", "true")
.appendQueryParameter("EnableUserData", "true")
.appendQueryParameter("EnableImageTypes", "Primary,Backdrop,Thumb,Logo")
.build()
val request = Request.Builder()
.url(url)
.url(url.toString())
.header("X-Emby-Token", server.apiKey)
.get()
.build()
@@ -172,9 +183,16 @@ object EmbyRepository {
*/
suspend fun getResumeItems(server: EmbyServer, limit: Int = 10): List<EmbyItem> = withContext(Dispatchers.IO) {
try {
val url = "${server.fullUrl}/emby/Users/${server.userId}/Items/Resume?Limit=$limit&Fields=ProductionYear,Genres&MediaTypes=Video"
val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items/Resume").buildUpon()
.appendQueryParameter("Limit", limit.toString())
.appendQueryParameter("Fields", "ProductionYear,Genres,UserData")
.appendQueryParameter("MediaTypes", "Video")
.appendQueryParameter("EnableImages", "true")
.appendQueryParameter("EnableUserData", "true")
.appendQueryParameter("EnableImageTypes", "Primary,Backdrop,Thumb,Logo")
.build()
val request = Request.Builder()
.url(url)
.url(url.toString())
.header("X-Emby-Token", server.apiKey)
.get()
.build()
@@ -201,22 +219,57 @@ object EmbyRepository {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use emptyList()
val body = response.body?.string() ?: return@use emptyList()
val listType = com.google.gson.reflect.TypeToken.getParameterized(
java.util.List::class.java, EmbyItem::class.java
).type
val listType = object : TypeToken<List<EmbyItem>>() {}.type
gson.fromJson<List<EmbyItem>>(body, listType) ?: emptyList()
}
} catch (_: Exception) { emptyList() }
}
/** Search movies and series in the authenticated Emby library. */
suspend fun searchItems(server: EmbyServer, query: String, limit: Int = 40): List<EmbyItem> = withContext(Dispatchers.IO) {
val term = query.trim()
if (term.isBlank()) return@withContext emptyList()
try {
val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items").buildUpon()
.appendQueryParameter("SearchTerm", term)
.appendQueryParameter("Recursive", "true")
.appendQueryParameter("IncludeItemTypes", "Movie,Series")
.appendQueryParameter("Limit", limit.toString())
.appendQueryParameter("SortBy", "SortName")
.appendQueryParameter("Fields", "PrimaryImageAspectRatio,Overview,Genres,ProductionYear,UserData")
.appendQueryParameter("EnableImages", "true")
.appendQueryParameter("EnableUserData", "true")
.appendQueryParameter("EnableImageTypes", "Primary,Backdrop,Thumb,Logo")
.build()
val request = Request.Builder()
.url(url.toString())
.header("X-Emby-Token", server.apiKey)
.get()
.build()
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()
}
} catch (e: Exception) {
Log.w("EmbySearch", "search failed", e)
emptyList()
}
}
/**
* Get the full detail for a single Emby item.
*/
suspend fun getItem(server: EmbyServer, itemId: String): EmbyItem? = withContext(Dispatchers.IO) {
try {
val url = "${server.fullUrl}/emby/Users/${server.userId}/Items/$itemId"
val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items/$itemId").buildUpon()
.appendQueryParameter("Fields", "Overview,Genres,People,Studios,MediaStreams,UserData")
.appendQueryParameter("EnableImages", "true")
.appendQueryParameter("EnableUserData", "true")
.appendQueryParameter("EnableImageTypes", "Primary,Backdrop,Thumb,Logo")
.build()
val request = Request.Builder()
.url(url)
.url(url.toString())
.header("X-Emby-Token", server.apiKey)
.get()
.build()
@@ -284,7 +337,7 @@ object EmbyRepository {
suspend fun getEpisodes(server: EmbyServer, seriesId: String, seasonId: String): List<EmbyItem> = withContext(Dispatchers.IO) {
val dedicated = try {
val url = "${server.fullUrl}/emby/Shows/$seriesId/Episodes" +
"?userId=${server.userId}&seasonId=$seasonId&Fields=PrimaryImageAspectRatio,Overview,ProductionYear"
"?userId=${server.userId}&seasonId=$seasonId&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,UserData&EnableUserData=true"
val request = Request.Builder()
.url(url)
.header("X-Emby-Token", server.apiKey)
@@ -306,7 +359,7 @@ object EmbyRepository {
return@withContext try {
val url = "${server.fullUrl}/emby/Users/${server.userId}/Items" +
"?ParentId=$seasonId&IncludeItemTypes=Episode&Recursive=true&SortBy=ParentIndexNumber,IndexNumber&SortOrder=Ascending" +
"&Fields=PrimaryImageAspectRatio,Overview,ProductionYear"
"&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,UserData&EnableUserData=true"
val request = Request.Builder()
.url(url)
.header("X-Emby-Token", server.apiKey)
@@ -346,23 +399,100 @@ object EmbyRepository {
val transcodingUrl = mediaSource?.transcodingUrl
if (!transcodingUrl.isNullOrBlank()) {
return@withContext if (transcodingUrl.startsWith("/")) server.fullUrl + transcodingUrl else transcodingUrl
return@withContext authenticatedPlaybackUrl(server, transcodingUrl)
}
val directStreamUrl = mediaSource?.directStreamUrl
if (!directStreamUrl.isNullOrBlank()) {
return@withContext if (directStreamUrl.startsWith("/")) server.fullUrl + directStreamUrl else directStreamUrl
return@withContext authenticatedPlaybackUrl(server, directStreamUrl)
}
val sourceId = mediaSource?.id ?: item.id
"${server.fullUrl}/emby/Videos/${item.id}/stream" +
"?Static=true&MediaSourceId=$sourceId&DeviceId=$deviceId&api_key=${server.apiKey}"
authenticatedPlaybackUrl(
server,
"${server.fullUrl}/emby/Videos/${item.id}/stream" +
"?Static=true&MediaSourceId=$sourceId&DeviceId=$deviceId",
)
} catch (e: Exception) {
Log.e("EmbyPlayback", "resolvePlaybackUrl failed", e)
null
}
}
suspend fun reportPlaybackStarted(server: EmbyServer, item: EmbyItem, positionMs: Long) {
sendPlaybackCheckIn(
server = server,
item = item,
positionMs = positionMs,
paused = false,
endpoint = "/emby/Sessions/Playing",
)
}
suspend fun reportPlaybackProgress(
server: EmbyServer,
item: EmbyItem,
positionMs: Long,
paused: Boolean,
) {
sendPlaybackCheckIn(
server = server,
item = item,
positionMs = positionMs,
paused = paused,
endpoint = "/emby/Sessions/Playing/Progress",
eventName = if (paused) "Pause" else "TimeUpdate",
)
}
suspend fun reportPlaybackStopped(server: EmbyServer, item: EmbyItem, positionMs: Long) {
sendPlaybackCheckIn(
server = server,
item = item,
positionMs = positionMs,
paused = true,
endpoint = "/emby/Sessions/Playing/Stopped",
)
}
private suspend fun sendPlaybackCheckIn(
server: EmbyServer,
item: EmbyItem,
positionMs: Long,
paused: Boolean,
endpoint: String,
eventName: String? = null,
) = withContext(Dispatchers.IO) {
try {
val payload = linkedMapOf<String, Any>(
"ItemId" to item.id,
"PositionTicks" to positionMs.coerceAtLeast(0L) * 10_000L,
"IsPaused" to paused,
"CanSeek" to true,
"QueueableMediaTypes" to "Video",
)
eventName?.let { payload["EventName"] = it }
val request = Request.Builder()
.url(server.fullUrl + endpoint)
.header("X-Emby-Token", server.apiKey)
.post(gson.toJson(payload).toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().close()
} catch (e: Exception) {
Log.w("EmbyPlayback", "playback check-in failed", e)
}
}
private fun authenticatedPlaybackUrl(server: EmbyServer, candidate: String): String {
val absoluteUrl = if (candidate.startsWith("/")) server.fullUrl + candidate else candidate
val uri = Uri.parse(absoluteUrl)
return if (uri.getQueryParameter("api_key").isNullOrBlank()) {
uri.buildUpon().appendQueryParameter("api_key", server.apiKey).build().toString()
} else {
absoluteUrl
}
}
fun buildBackdropUrl(serverUrl: String, item: EmbyItem, maxWidth: Int = 1200): String? {
val tag = item.backdropImageTags.firstOrNull() ?: return null
return "$serverUrl/emby/Items/${item.id}/Images/Backdrop/0?tag=$tag&maxWidth=$maxWidth&quality=75"
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
@@ -169,7 +170,17 @@ private fun HeroCarousel(
}
}
Column {
Column(
Modifier
.background(
Brush.verticalGradient(
0f to Color.Transparent,
0.72f to immersiveBackground.hero.copy(alpha = 0.16f),
1f to immersiveBackground.pageTop.copy(alpha = 0.68f),
)
)
.padding(bottom = 10.dp)
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
@@ -181,6 +192,55 @@ private fun HeroCarousel(
onClick = { onItemClick(items[page]) },
)
}
AnimatedContent(
targetState = items.getOrNull(pagerState.currentPage),
transitionSpec = { fadeIn(tween(260)) togetherWith fadeOut(tween(180)) },
label = "heroMetadataTransition",
) { item ->
item?.let { current ->
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp)
.offset(y = (-18).dp)
.padding(top = 22.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
current.communityRating?.takeIf { it > 0.0 }?.let {
Text(
text = "★ ${String.format("%.1f", it)}",
color = Color(0xFFFFC107),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.width(10.dp))
}
current.productionYear?.let {
Text("$it", color = homeContentColor().copy(alpha = 0.84f), fontSize = 12.sp)
Spacer(Modifier.width(10.dp))
}
Text(
text = listOfNotNull(current.type?.let { if (it == "Series") "剧集" else if (it == "Movie") "电影" else it }, current.genres.take(2).joinToString(" / ").takeIf { it.isNotBlank() }).joinToString(" · "),
color = homeSecondaryColor(),
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
current.overview?.takeIf { it.isNotBlank() }?.let { overview ->
Text(
text = overview,
color = homeSecondaryColor(),
fontSize = 13.sp,
lineHeight = 20.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
Spacer(Modifier.height(10.dp))
Row(
Modifier.fillMaxWidth(),
@@ -219,7 +279,10 @@ private fun FeaturedItemBanner(
val imageUrl = heroImageUrl(item, serverUrl)
Box(
modifier.fillMaxWidth().aspectRatio(1.2f)
modifier
.fillMaxWidth()
.aspectRatio(1.2f)
.clip(RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp))
.clickable(onClick = onClick)
.background(Color.Black),
) {
@@ -241,8 +304,9 @@ private fun FeaturedItemBanner(
Box(Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to Color.Transparent,
0.6f to immersive.hero.copy(alpha = 0.5f),
1f to immersive.hero,
0.54f to Color.Transparent,
0.78f to immersive.hero.copy(alpha = 0.52f),
1f to immersive.hero.copy(alpha = 0.96f),
)
))
@@ -269,26 +333,6 @@ private fun FeaturedItemBanner(
)
}
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
if (item.communityRating != null && item.communityRating!! > 0) {
Text(
text = "★ ${String.format("%.1f", item.communityRating)}",
color = Color(0xFFFFD740),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.width(10.dp))
}
if (item.productionYear != null) {
Text(text = "${item.productionYear}", color = Color.White.copy(alpha = 0.95f), fontSize = 12.sp)
Spacer(Modifier.width(10.dp))
}
val genres = item.genres.take(2).joinToString(" / ")
if (genres.isNotBlank()) {
Text(text = genres, color = Color.White.copy(alpha = 0.88f), fontSize = 12.sp)
}
}
}
}
}
@@ -527,7 +571,9 @@ fun HomeScreen(
) {
Box(Modifier.fillMaxSize().background(
Brush.verticalGradient(
listOf(immersiveBackground.hero, immersiveBackground.pageBottom)
0f to immersiveBackground.pageTop,
0.48f to immersiveBackground.hero,
1f to immersiveBackground.pageBottom,
)
)) {
AnimatedContent(
@@ -546,6 +592,7 @@ fun HomeScreen(
CircularProgressIndicator(color = adaptiveAccent)
}
} else {
val server = activeServer
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding())
@@ -554,7 +601,7 @@ fun HomeScreen(
item {
HeroCarousel(
items = heroItems,
serverUrl = activeServer!!.fullUrl,
serverUrl = server.fullUrl,
pagerState = heroPagerState,
immersiveBackground = immersiveBackground,
onItemClick = { detailItem = it }
@@ -563,26 +610,37 @@ fun HomeScreen(
}
item {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
Column(
Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
0f to immersiveBackground.pageTop.copy(alpha = 0.52f),
0.38f to Color.Transparent,
)
)
.padding(start = 16.dp, end = 16.dp, top = 8.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
if (resumeItems.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionTitle(title = "继续观看")
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(resumeItems) { item ->
ContinueWatchingPoster(item, activeServer!!.fullUrl) { detailItem = it }
ContinueWatchingPoster(item, server.fullUrl) { detailItem = it }
}
}
}
}
if (libraries.isNotEmpty()) {
MyMediaGrid(libraries, activeServer!!.fullUrl) { folder ->
MyMediaGrid(libraries, server.fullUrl) { folder ->
// Handle selection
}
}
libraries.forEach { folder ->
LazyLibrarySection(folder, activeServer!!) { detailItem = it }
LazyLibrarySection(folder, server) { detailItem = it }
}
}
}
@@ -590,19 +648,23 @@ fun HomeScreen(
}
}
HomeOverlayTopBar(
name = activeServer?.name ?: "未连接",
iconUrl = activeServer?.iconUrl ?: "",
modifier = Modifier.align(Alignment.TopCenter)
)
detailItem?.let { item ->
MediaDetailScreen(
item = item,
server = activeServer!!,
onClose = { detailItem = null }
if (detailItem == null) {
HomeOverlayTopBar(
name = activeServer?.name ?: "未连接",
iconUrl = activeServer?.iconUrl ?: "",
modifier = Modifier.align(Alignment.TopCenter),
)
}
activeServer?.let { server ->
detailItem?.let { item ->
MediaDetailScreen(
item = item,
server = server,
onClose = { detailItem = null },
)
}
}
}
}
}
@@ -40,6 +40,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
@@ -55,6 +56,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
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 androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@@ -68,6 +71,8 @@ import com.example.myapplication.data.EmbyItem
import com.example.myapplication.data.EmbyRepository
import com.example.myapplication.data.EmbyServer
import top.yukonga.miuix.kmp.basic.Text
import kotlinx.coroutines.launch
import java.util.Locale
private val LocalDetailText = staticCompositionLocalOf { Color.White }
private val LocalDetailSecondary = staticCompositionLocalOf { Color.White }
@@ -82,6 +87,7 @@ fun MediaDetailScreen(
onClose: () -> Unit,
) {
val context = LocalContext.current
val playbackScope = rememberCoroutineScope()
var detail by remember(item.id) { mutableStateOf(item) }
var loadError by remember(item.id) { mutableStateOf<String?>(null) }
var refreshing by remember(item.id) { mutableStateOf(false) }
@@ -199,13 +205,12 @@ fun MediaDetailScreen(
Spacer(Modifier.height(18.dp))
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
DetailPlayButton {
val playTarget = if (detail.type == "Series") {
episodes.firstOrNull { it.id == selectedEpisodeId } ?: episodes.firstOrNull()
} else {
detail
}
val playTarget = if (detail.type == "Series") {
episodes.firstOrNull { it.id == selectedEpisodeId } ?: episodes.firstOrNull()
} else {
detail
}
DetailPlayButton(resumePositionMs = playTarget?.playbackPositionMs ?: detail.playbackPositionMs) {
if (playTarget == null) {
Toast.makeText(context, "剧集尚未加载完成", Toast.LENGTH_SHORT).show()
} else {
@@ -367,6 +372,7 @@ fun MediaDetailScreen(
VideoPlayerScreen(
title = playTarget.name,
playbackUrl = playbackUrl,
initialPositionMs = playTarget.playbackPositionMs,
isLoading = playbackLoading,
errorText = playbackError,
episodes = episodes,
@@ -382,6 +388,27 @@ fun MediaDetailScreen(
playbackUrl = null
playbackError = null
},
onPlaybackStarted = { positionMs ->
server?.let { currentServer ->
playbackScope.launch {
EmbyRepository.reportPlaybackStarted(currentServer, playTarget, positionMs)
}
}
},
onPlaybackProgress = { positionMs, paused ->
server?.let { currentServer ->
playbackScope.launch {
EmbyRepository.reportPlaybackProgress(currentServer, playTarget, positionMs, paused)
}
}
},
onPlaybackStopped = { positionMs ->
server?.let { currentServer ->
playbackScope.launch {
EmbyRepository.reportPlaybackStopped(currentServer, playTarget, positionMs)
}
}
},
)
}
@@ -561,34 +588,37 @@ private fun DetailHeadline(detail: EmbyItem, refreshing: Boolean, errorText: Str
}
}
@Composable
private fun DetailPlayButton(onClick: () -> Unit) {
Box(
Modifier
.width(188.dp)
.height(47.dp)
.clip(RoundedCornerShape(999.dp))
.background(Color.White) // User wants white background
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
private fun DetailPlayButton(resumePositionMs: Long, onClick: () -> Unit) {
LiquidGlassButton(
onClick = onClick,
modifier = Modifier.width(188.dp).height(47.dp),
cornerRadius = 999.dp,
fill = GlassTokens.glassFillStrong,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = null,
tint = Color(0xFF1F2937), // User wants dark gray
tint = GlassTokens.accent,
modifier = Modifier.size(21.dp)
)
Spacer(Modifier.width(7.dp))
Text(
text = "立即播放",
color = Color(0xFF1F2937), // User wants dark gray
text = if (resumePositionMs > 0L) "继续 ${formatPlaybackTime(resumePositionMs)}" else "立即播放",
color = GlassTokens.text,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
private fun formatPlaybackTime(positionMs: Long): String {
val totalSeconds = positionMs.coerceAtLeast(0L) / 1000L
val hours = totalSeconds / 3600L
val minutes = (totalSeconds % 3600L) / 60L
val seconds = totalSeconds % 60L
return String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds)
}
@Composable
private fun DetailSectionCard(title: String, content: @Composable () -> Unit) {
Column(
@@ -1,5 +1,12 @@
package com.example.myapplication.screens
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -24,6 +31,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -36,6 +45,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -78,69 +88,121 @@ fun RecordsTopBar(
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun RecordsScreen(segment: Int, activeServer: EmbyServer? = null) {
if (segment == 1) {
Box(
Modifier.fillMaxSize().padding(top = 40.dp),
contentAlignment = Alignment.TopCenter,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(64.dp).clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f))
.border(1.dp, Color.White.copy(alpha = 0.3f), CircleShape),
contentAlignment = Alignment.Center,
fun RecordsScreen(
segment: Int,
activeServer: EmbyServer? = null,
onDetailVisibilityChanged: (Boolean) -> Unit = {},
) {
var favorites by remember { mutableStateOf<List<EmbyItem>>(emptyList()) }
var loading by remember { mutableStateOf(false) }
var selectedFavorite by remember { mutableStateOf<EmbyItem?>(null) }
BackHandler(enabled = selectedFavorite != null) { selectedFavorite = null }
LaunchedEffect(selectedFavorite != null) {
onDetailVisibilityChanged(selectedFavorite != null)
}
LaunchedEffect(activeServer?.id) {
if (activeServer != null && activeServer.apiKey.isNotBlank()) {
loading = true
favorites = EmbyRepository.getFavoriteItems(activeServer)
loading = false
}
}
Box(Modifier.fillMaxSize()) {
AnimatedContent(
targetState = segment,
transitionSpec = { fadeIn(tween(220)) togetherWith fadeOut(tween(160)) },
label = "recordsSegmentContent",
modifier = Modifier.fillMaxSize(),
) { selectedSegment ->
if (selectedSegment == 1) {
RecordsEmptyState(
icon = Icons.Outlined.CalendarMonth,
title = stringResource(Res.string.empty_calendar),
subtitle = stringResource(Res.string.empty_calendar_sub),
)
} else if (loading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = GlassTokens.accent)
}
} else if (favorites.isEmpty()) {
RecordsEmptyState(
icon = Icons.Outlined.FavoriteBorder,
title = stringResource(Res.string.empty_favorites),
subtitle = stringResource(Res.string.empty_favorites_sub),
)
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
contentPadding = PaddingValues(top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Outlined.CalendarMonth,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(30.dp),
)
items(favorites, key = { it.id }) { item ->
FavoriteMediaCard(
item = item,
serverUrl = activeServer?.fullUrl.orEmpty(),
onClick = { selectedFavorite = item },
)
}
}
Text(text = stringResource(Res.string.no_updates), color = GlassTokens.subtext, fontSize = 15.sp)
}
}
} else {
var favorites by remember { mutableStateOf<List<EmbyItem>>(emptyList()) }
var loading by remember { mutableStateOf(false) }
LaunchedEffect(activeServer?.id) {
if (activeServer != null && activeServer.apiKey.isNotBlank()) {
loading = true
favorites = EmbyRepository.getFavoriteItems(activeServer)
loading = false
}
}
if (favorites.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = if (loading) "加载中..." else "暂无收藏", color = GlassTokens.subtext)
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
contentPadding = PaddingValues(top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(favorites) { item ->
FavoriteMediaCard(item = item, serverUrl = activeServer?.fullUrl.orEmpty())
}
}
selectedFavorite?.let { item ->
MediaDetailScreen(
item = item,
server = activeServer,
onClose = { selectedFavorite = null },
)
}
}
}
@Composable
private fun FavoriteMediaCard(item: EmbyItem, serverUrl: String) {
private fun RecordsEmptyState(
icon: ImageVector,
title: String,
subtitle: String,
) {
Box(
Modifier
.fillMaxSize()
.padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.size(68.dp)
.clip(CircleShape)
.background(GlassTokens.glassFill)
.border(1.dp, GlassTokens.glassBorder, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = GlassTokens.subtext2, modifier = Modifier.size(30.dp))
}
Text(title, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Text(subtitle, color = GlassTokens.subtext, fontSize = 12.sp)
}
}
}
@Composable
private fun FavoriteMediaCard(item: EmbyItem, serverUrl: String, onClick: () -> Unit) {
val imageUrl = EmbyRepository.buildImageUrl(serverUrl, item, maxWidth = 400)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Column(
modifier = Modifier.clickable(onClick = onClick),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
Modifier
.fillMaxWidth()
@@ -2,11 +2,13 @@ package com.example.myapplication.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -16,27 +18,41 @@ 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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.example.myapplication.R as Res
import top.yukonga.miuix.kmp.basic.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import com.example.myapplication.model.MediaItem
import com.example.myapplication.model.sampleMovies
import com.example.myapplication.model.sampleTvShows
import com.example.myapplication.data.EmbyItem
import com.example.myapplication.data.EmbyRepository
import com.example.myapplication.data.EmbyServer
import com.example.myapplication.ui.GlassSearchField
import com.example.myapplication.ui.LiquidGlassTopBar
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.PosterBox
import com.example.myapplication.ui.LiquidGlassTopBar
import com.example.myapplication.ui.bottomBarContentPadding
import kotlinx.coroutines.delay
import top.yukonga.miuix.kmp.basic.Text
@Composable
fun SearchTopBar(
@@ -64,91 +80,147 @@ fun SearchTopBar(
@Composable
fun SearchScreen(
query: String,
onQueryChange: (String) -> Unit = {},
onGearClick: () -> Unit = {},
activeServer: EmbyServer? = null,
onDetailVisibilityChanged: (Boolean) -> Unit = {},
) {
val allMedia: List<MediaItem> = sampleMovies() + sampleTvShows()
val results = remember(query) {
val q = query.trim()
if (q.isBlank()) emptyList() else allMedia.filter {
it.title.contains(q, ignoreCase = true) || it.subtitle.contains(q, ignoreCase = true)
var results by remember(activeServer?.id) { mutableStateOf<List<EmbyItem>>(emptyList()) }
var loading by remember(activeServer?.id) { mutableStateOf(false) }
var selectedItem by remember { mutableStateOf<EmbyItem?>(null) }
LaunchedEffect(query, activeServer?.id, activeServer?.apiKey) {
val server = activeServer
val term = query.trim()
if (server == null || server.apiKey.isBlank() || term.isBlank()) {
results = emptyList()
loading = false
return@LaunchedEffect
}
delay(280)
loading = true
results = EmbyRepository.searchItems(server, term)
loading = false
}
LaunchedEffect(selectedItem != null) {
onDetailVisibilityChanged(selectedItem != null)
}
val bottomPadding = bottomBarContentPadding(90.dp).calculateBottomPadding()
Box(Modifier.fillMaxSize()) {
// Content area (full size, same as HomeScreen)
Box(
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.padding(bottom = bottomPadding),
.padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()),
) {
if (query.isBlank()) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
Modifier
.size(64.dp)
.clip(CircleShape)
.background(GlassTokens.glassFill)
.border(1.dp, GlassTokens.glassBorder, CircleShape),
contentAlignment = Alignment.Center,
) {
androidx.compose.material3.Icon(
imageVector = Icons.Outlined.Search,
contentDescription = null,
tint = GlassTokens.subtext2,
modifier = Modifier.size(32.dp),
)
}
Spacer(Modifier.height(16.dp))
Text(text = stringResource(Res.string.search_title), color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(6.dp))
Text(text = stringResource(Res.string.search_empty_input), color = GlassTokens.subtext, fontSize = 12.sp)
}
when {
activeServer == null -> SearchEmptyState(
title = "暂无可搜索的媒体库",
subtitle = "请先在资源库中连接 Emby 服务器",
)
query.isBlank() -> SearchEmptyState(
title = stringResource(Res.string.search_title),
subtitle = stringResource(Res.string.search_empty_input),
)
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = GlassTokens.accent)
}
} else if (results.isEmpty()) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(text = stringResource(Res.string.search_no_result_prefix) + "$query" + stringResource(Res.string.search_no_result_suffix), color = GlassTokens.subtext, fontSize = 14.sp)
results.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = stringResource(Res.string.search_no_result_prefix) + query.trim() + stringResource(Res.string.search_no_result_suffix),
color = GlassTokens.subtext,
fontSize = 14.sp,
)
}
} else {
LazyVerticalGrid(
else -> LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(
top = 16.dp,
bottom = bottomBarContentPadding(86.dp).calculateBottomPadding(),
),
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(results) { item ->
Box(Modifier.fillMaxWidth()) {
PosterBox(
colors = item.posterColors,
title = item.title,
subtitle = item.subtitle,
height = 190.dp,
)
}
items(results, key = { it.id }) { item ->
SearchMediaCard(
item = item,
serverUrl = activeServer.fullUrl,
onClick = { selectedItem = item },
)
}
}
}
}
// Overlay top bar (same pattern as HomeScreen)
SearchTopBar(
query = query,
onQueryChange = onQueryChange,
onGearClick = onGearClick,
modifier = Modifier.align(Alignment.TopCenter),
selectedItem?.let { item ->
MediaDetailScreen(
item = item,
server = activeServer,
onClose = { selectedItem = null },
)
}
}
}
@Composable
private fun SearchEmptyState(title: String, subtitle: String) {
Box(
Modifier
.fillMaxSize()
.padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
Modifier
.size(68.dp)
.clip(CircleShape)
.background(GlassTokens.glassFill)
.border(1.dp, GlassTokens.glassBorder, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Search, contentDescription = null, tint = GlassTokens.subtext2, modifier = Modifier.size(32.dp))
}
Spacer(Modifier.height(16.dp))
Text(title, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(6.dp))
Text(subtitle, color = GlassTokens.subtext, fontSize = 12.sp)
}
}
}
@Composable
private fun SearchMediaCard(item: EmbyItem, serverUrl: String, onClick: () -> Unit) {
val imageUrl = EmbyRepository.buildImageUrl(serverUrl, item, maxWidth = 500)
val fallback = listOf(Color(0xFF344054), Color(0xFF667085))
Column(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
verticalArrangement = Arrangement.spacedBy(7.dp),
) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(0.7f)
.clip(RoundedCornerShape(16.dp))
.background(Brush.verticalGradient(fallback)),
) {
if (imageUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(),
contentDescription = item.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
Text(
text = item.name,
color = GlassTokens.text,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -1,18 +1,23 @@
package com.example.myapplication.screens
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
@@ -28,25 +33,53 @@ import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.zIndex
import com.example.myapplication.R
import com.example.myapplication.data.EmbyServer
import com.example.myapplication.ui.AppLanguage
import com.example.myapplication.ui.GlassButton
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LiquidGlassTopBar
import com.example.myapplication.ui.bottomBarContentPadding
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.colorControls
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow
import com.kyant.shapes.RoundedRectangle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import top.yukonga.miuix.kmp.basic.Text
@Composable
@@ -69,54 +102,86 @@ fun SettingsScreen(
onBackFromAbout: () -> Unit = {},
) {
var showLanguageDialog by remember { mutableStateOf(false) }
var languageMenuMounted by remember { mutableStateOf(false) }
var languageEntryBounds by remember { mutableStateOf<Rect?>(null) }
var settingsRootOrigin by remember { mutableStateOf(Offset.Zero) }
val context = LocalContext.current
val languageScope = rememberCoroutineScope()
val languageBackdrop = rememberLayerBackdrop()
Column(
Box(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
.onGloballyPositioned { settingsRootOrigin = it.positionInRoot() },
) {
SettingSectionCard {
SettingRowItem(
icon = Icons.Outlined.Language,
title = "语言",
subtitle = AppLanguage.displayName,
onClick = { showLanguageDialog = true }
)
DividerLine()
SettingRowItem(
icon = Icons.Outlined.PlayCircle,
title = "播放设置",
subtitle = "自适应比特率",
onClick = {}
)
Column(
Modifier
.fillMaxSize()
.layerBackdrop(languageBackdrop)
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SettingSectionCard {
SettingRowItem(
icon = Icons.Outlined.Language,
title = "语言",
subtitle = AppLanguage.displayName,
modifier = Modifier.onGloballyPositioned { languageEntryBounds = it.boundsInRoot() },
onClick = {
languageMenuMounted = true
showLanguageDialog = true
},
)
DividerLine()
SettingRowItem(
icon = Icons.Outlined.PlayCircle,
title = "播放设置",
subtitle = "自适应比特率",
onClick = {},
)
}
SettingSectionCard {
SettingRowItem(
icon = Icons.Outlined.Folder,
title = "清除缓存",
subtitle = "管理应用缓存",
onClick = { onOpenCacheDetail() },
)
DividerLine()
SettingRowItem(
icon = Icons.Outlined.Info,
title = "关于",
subtitle = "版本 1.0",
onClick = { onOpenAbout() },
)
}
}
SettingSectionCard {
SettingRowItem(
icon = Icons.Outlined.Folder,
title = "清除缓存",
subtitle = "管理应用缓存",
onClick = { onOpenCacheDetail() }
)
DividerLine()
SettingRowItem(
icon = Icons.Outlined.Info,
title = "关于",
subtitle = "版本 1.0",
onClick = { onOpenAbout() }
val anchor = languageEntryBounds
if (languageMenuMounted && anchor != null) {
LanguageMenuOverlay(
anchorBounds = anchor,
rootOrigin = settingsRootOrigin,
backdrop = languageBackdrop,
visible = showLanguageDialog,
onDismiss = { showLanguageDialog = false },
onClosed = { languageMenuMounted = false },
onLanguageSelected = { language ->
showLanguageDialog = false
languageScope.launch {
delay(230)
AppLanguage.apply(context, language)
}
},
)
}
}
if (showLanguageDialog) {
LanguageDialog(onDismiss = { showLanguageDialog = false })
}
}
@Composable
private fun SettingSectionCard(content: @Composable () -> Unit) {
private fun SettingSectionCard(content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier
.fillMaxWidth()
@@ -130,9 +195,15 @@ private fun SettingSectionCard(content: @Composable () -> Unit) {
}
@Composable
private fun SettingRowItem(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit) {
private fun SettingRowItem(
icon: ImageVector,
title: String,
subtitle: String,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Row(
Modifier
modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 14.dp),
@@ -160,18 +231,166 @@ private fun SettingRowItem(icon: ImageVector, title: String, subtitle: String, o
@Composable
private fun DividerLine() {
Box(Modifier.fillMaxWidth().height(0.5.dp).padding(horizontal = 16.dp).background(Color(0xFFE5E7EB)))
Box(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFFE5E7EB)))
}
@Composable
private fun LanguageDialog(onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Box(Modifier.width(300.dp).clip(RoundedCornerShape(24.dp)).background(Color.White).padding(20.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("切换语言", color = Color(0xFF1F2937), fontSize = 18.sp, fontWeight = FontWeight.Bold)
GlassButton(text = "中文", onClick = { onDismiss() }, modifier = Modifier.fillMaxWidth())
GlassButton(text = "English", onClick = { onDismiss() }, modifier = Modifier.fillMaxWidth())
private fun LanguageMenuOverlay(
anchorBounds: Rect,
rootOrigin: Offset,
backdrop: Backdrop?,
visible: Boolean,
onDismiss: () -> Unit,
onClosed: () -> Unit,
onLanguageSelected: (Int) -> Unit,
) {
val density = LocalDensity.current
val progress = remember { Animatable(0f) }
var hasOpened by remember { mutableStateOf(false) }
LaunchedEffect(visible) {
if (visible) {
hasOpened = true
progress.animateTo(
targetValue = 1f,
animationSpec = tween(180, easing = CubicBezierEasing(0.18f, 0.82f, 0.28f, 1f)),
)
} else if (hasOpened) {
progress.animateTo(
targetValue = 0f,
animationSpec = tween(300, easing = CubicBezierEasing(0.35f, 0f, 0.72f, 0.18f)),
)
onClosed()
}
}
val rawProgress = progress.value.coerceIn(0f, 1f)
val widthProgress = 1f - (1f - rawProgress) * (1f - rawProgress) * (1f - rawProgress)
val heightProgress = 1f - (1f - rawProgress) * (1f - rawProgress)
val contentProgress = ((rawProgress - 0.38f) / 0.62f).coerceIn(0f, 1f)
val menuWidth = 46.dp + (220.dp - 46.dp) * widthProgress
val menuHeight = 46.dp + (128.dp - 46.dp) * heightProgress
val cornerRadius = 23.dp + (28.dp - 23.dp) * rawProgress
val menuWidthPx = with(density) { menuWidth.roundToPx() }
val menuHeightPx = with(density) { menuHeight.roundToPx() }
val menuLeft = (anchorBounds.right - rootOrigin.x - menuWidthPx).roundToInt()
val menuTop = (anchorBounds.top - rootOrigin.y).roundToInt()
val menuRect = Rect(
menuLeft.toFloat(),
menuTop.toFloat(),
(menuLeft + menuWidthPx).toFloat(),
(menuTop + menuHeightPx).toFloat(),
)
val currentMenuRect by rememberUpdatedState(menuRect)
val shape = RoundedCornerShape(cornerRadius)
val surface = if (GlassTokens.isDark) Color.White.copy(alpha = 0.10f) else Color.White.copy(alpha = 0.42f)
val border = if (GlassTokens.isDark) Color.White.copy(alpha = 0.24f) else Color.White.copy(alpha = 0.62f)
Box(
Modifier
.fillMaxSize()
.zIndex(10f),
) {
// Keep the outside gesture layer below the menu itself. The opening
// gesture cannot be reused to dismiss the newly mounted overlay.
Box(
Modifier
.fillMaxSize()
.pointerInput(visible) {
detectTapGestures { position ->
if (!currentMenuRect.contains(position)) {
onDismiss()
}
}
},
)
Box(
Modifier
.offset { IntOffset(menuLeft, menuTop) }
.width(menuWidth)
.height(menuHeight)
.then(
if (backdrop != null) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedRectangle(cornerRadius) },
effects = {
colorControls(
brightness = if (GlassTokens.isDark) 0f else 0.2f,
saturation = 1.5f,
)
vibrancy()
blur(if (GlassTokens.isDark) 8.dp.toPx() else 16.dp.toPx())
lens(24.dp.toPx(), 48.dp.toPx(), depthEffect = true)
},
highlight = { Highlight.Plain },
shadow = { Shadow(alpha = 0.16f) },
onDrawSurface = { drawRect(surface) },
)
} else {
Modifier.background(surface)
},
)
.border(0.7.dp, border, shape)
.zIndex(1f),
) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 8.dp)
.alpha(contentProgress)
.offset(y = 6.dp * (1f - contentProgress)),
verticalArrangement = Arrangement.Center,
) {
LanguageOption(
label = stringResource(R.string.lang_chinese),
selected = AppLanguage.language == 0,
onClick = { onLanguageSelected(0) },
)
LanguageOption(
label = stringResource(R.string.lang_english),
selected = AppLanguage.language == 1,
onClick = { onLanguageSelected(1) },
)
}
}
}
}
@Composable
private fun LanguageOption(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(20.dp)
.clip(CircleShape)
.background(if (selected) GlassTokens.accent else Color.Transparent)
.border(1.dp, if (selected) GlassTokens.accent else GlassTokens.subtext2, CircleShape),
contentAlignment = Alignment.Center,
) {
if (selected) {
Text("✓", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
Spacer(Modifier.width(10.dp))
Text(
text = label,
color = GlassTokens.text,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
}
}
@@ -54,12 +54,14 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
@@ -73,6 +75,8 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
@@ -113,22 +117,31 @@ private val LocalPlayerBackdrop = staticCompositionLocalOf<Backdrop?> { null }
fun VideoPlayerScreen(
title: String,
playbackUrl: String? = null,
initialPositionMs: Long = 0L,
isLoading: Boolean = false,
errorText: String? = null,
episodes: List<EmbyItem> = emptyList(),
currentEpisodeId: String? = null,
onSelectEpisode: (EmbyItem) -> Unit = {},
onClose: () -> Unit = {},
onPlaybackStarted: (positionMs: Long) -> Unit = {},
onPlaybackProgress: (positionMs: Long, paused: Boolean) -> Unit = { _, _ -> },
onPlaybackStopped: (positionMs: Long) -> Unit = {},
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var showSubtitleMenu by remember { mutableStateOf(false) }
var showEpisodeMenu by remember { mutableStateOf(false) }
val player = remember { ExoPlayer.Builder(context).build().apply { playWhenReady = true } }
val player = remember { ExoPlayer.Builder(context).build() }
val playerBackdrop = rememberLayerBackdrop()
val latestPlaybackUrl by rememberUpdatedState(playbackUrl)
val latestOnPlaybackStarted by rememberUpdatedState(onPlaybackStarted)
val latestOnPlaybackProgress by rememberUpdatedState(onPlaybackProgress)
val latestOnPlaybackStopped by rememberUpdatedState(onPlaybackStopped)
var progress by remember { mutableFloatStateOf(0f) }
var isPlaying by remember { mutableStateOf(true) }
var isPlaying by remember { mutableStateOf(false) }
var playerError by remember(playbackUrl) { mutableStateOf<String?>(null) }
var controlsVisible by remember { mutableStateOf(true) }
LaunchedEffect(controlsVisible, isPlaying) {
@@ -148,6 +161,15 @@ fun VideoPlayerScreen(
}
}
LaunchedEffect(player, playbackUrl) {
while (true) {
if (!playbackUrl.isNullOrBlank() && player.currentMediaItem != null) {
latestOnPlaybackProgress(player.currentPosition, !player.isPlaying)
}
delay(10_000)
}
}
DisposableEffect(Unit) {
val activity = context.findActivity()
val prev = activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
@@ -155,19 +177,49 @@ fun VideoPlayerScreen(
onDispose { activity?.requestedOrientation = prev }
}
DisposableEffect(playbackUrl) {
playerError = null
if (!playbackUrl.isNullOrBlank()) {
player.setMediaItem(MediaItem.fromUri(playbackUrl))
player.setMediaItem(MediaItem.fromUri(playbackUrl), initialPositionMs.coerceAtLeast(0L))
player.prepare()
player.playWhenReady = true
player.play()
latestOnPlaybackStarted(initialPositionMs.coerceAtLeast(0L))
}
onDispose { }
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
playerError = error.message ?: "播放器无法播放该资源"
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
DisposableEffect(lifecycleOwner) {
val obs = LifecycleEventObserver { _, e -> if (e == Lifecycle.Event.ON_STOP) player.pause() }
var resumeAfterStop = false
val obs = LifecycleEventObserver { _, e ->
when (e) {
Lifecycle.Event.ON_STOP -> {
resumeAfterStop = player.isPlaying || player.playWhenReady
if (resumeAfterStop) player.pause()
}
Lifecycle.Event.ON_START -> {
if (resumeAfterStop && !latestPlaybackUrl.isNullOrBlank()) player.play()
}
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
DisposableEffect(Unit) { onDispose { player.release() } }
DisposableEffect(Unit) {
onDispose {
if (!latestPlaybackUrl.isNullOrBlank()) {
latestOnPlaybackStopped(player.currentPosition)
}
player.release()
}
}
BackHandler(enabled = true, onBack = onClose)
CompositionLocalProvider(LocalPlayerBackdrop provides playerBackdrop) {
@@ -184,16 +236,30 @@ fun VideoPlayerScreen(
}
}
} else {
AndroidView(
factory = { ctx ->
PlayerView(ctx).also { pv ->
pv.player = player
pv.useController = false
pv.resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
},
modifier = Modifier.fillMaxSize(),
)
Box(Modifier.fillMaxSize().layerBackdrop(playerBackdrop)) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).also { pv ->
pv.player = player
pv.useController = false
pv.resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
},
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = 0.999f },
)
}
}
if (playerError != null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = playerError ?: "播放失败",
color = Color.White,
modifier = Modifier.padding(24.dp),
)
}
}
AnimatedVisibility(
@@ -205,7 +271,6 @@ fun VideoPlayerScreen(
Box(
Modifier
.fillMaxSize()
.layerBackdrop(playerBackdrop)
.background(
Brush.verticalGradient(
0f to Color.Black.copy(alpha = 0.40f),
@@ -231,7 +296,13 @@ fun VideoPlayerScreen(
if (idx > 0) onSelectEpisode(episodes[idx - 1])
}
LiquidSurfaceButton(icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, active = true, size = 72.dp, iconSize = 40.dp) {
if (player.isPlaying) player.pause() else player.play()
if (player.isPlaying) {
player.pause()
latestOnPlaybackProgress(player.currentPosition, true)
} else {
player.play()
latestOnPlaybackProgress(player.currentPosition, false)
}
}
LiquidSurfaceButton(icon = Icons.Filled.SkipNext, active = false, size = 56.dp) {
val idx = episodes.indexOfFirst { it.id == currentEpisodeId }
@@ -1,6 +1,5 @@
package com.example.myapplication.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -14,23 +13,13 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -41,21 +30,13 @@ import com.example.myapplication.ui.liquidglass.LiquidBottomTabs
import com.example.myapplication.ui.liquidglass.LocalLiquidBottomTabsGhostLayer
import com.example.myapplication.ui.liquidglass.LocalLiquidBottomTabsPillValue
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import com.kyant.shapes.Capsule
import top.yukonga.miuix.kmp.basic.Text
private data class MainTab(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val labelRes: Int,
)
private val mainTabs = listOf(
MainTab(Icons.Filled.Home, Icons.Outlined.Home, Res.string.nav_home),
MainTab(Icons.Filled.Favorite, Icons.Outlined.FavoriteBorder, Res.string.nav_records),
MainTab(Icons.Filled.Storage, Icons.Outlined.Storage, Res.string.nav_library),
MainTab(Icons.Filled.Settings, Icons.Outlined.Settings, Res.string.nav_settings),
)
@Composable
fun LiquidAppBottomBar(
selectedTab: Int,
@@ -64,14 +45,13 @@ fun LiquidAppBottomBar(
modifier: Modifier = Modifier,
) {
val isDark = GlassTokens.isDark
val accent = if (isDark) Color(0xFF0091FF) else Color(0xFF0A84FF)
val inactive = if (isDark) Color(0xFFF3F4F6) else Color(0xFF111111)
Row(
modifier = modifier
.navigationBarsPadding()
.padding(start = 20.dp, end = 20.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f)) {
LiquidBottomTabs(
@@ -79,9 +59,7 @@ fun LiquidAppBottomBar(
onTabSelected = onSelect,
backdrop = backdrop,
tabsCount = 4,
accentColor = accent,
) {
// Manually iterate to avoid unresolved reference / iterator ambiguity
val tabsCount = mainTabs.size
for (index in 0 until tabsCount) {
val tab = mainTabs[index]
@@ -96,7 +74,7 @@ fun LiquidAppBottomBar(
imageVector = if (selectedTab == index) tab.selectedIcon else tab.unselectedIcon,
contentDescription = stringResource(tab.labelRes),
tint = if (isGhostLayer) Color.White else inactive,
modifier = Modifier.size(21.dp).alpha(normalAlpha)
modifier = Modifier.size(21.dp).alpha(normalAlpha),
)
Spacer(Modifier.height(2.dp))
Text(
@@ -105,7 +83,7 @@ fun LiquidAppBottomBar(
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.alpha(normalAlpha)
modifier = Modifier.alpha(normalAlpha),
)
}
}
@@ -118,24 +96,36 @@ fun LiquidAppBottomBar(
Box(
modifier = Modifier
.size(width = 64.dp, height = 64.dp)
.clip(CircleShape)
.background(if (searchSelected) Color.White else Color.White.copy(alpha = 0.15f))
.drawBackdrop(
backdrop = backdrop,
shape = { Capsule() },
effects = {
vibrancy()
blur(2f.dp.toPx())
lens(12f.dp.toPx(), 24f.dp.toPx())
},
onDrawSurface = {
if (searchSelected) {
drawRect(Color.White.copy(alpha = 0.18f))
}
},
)
.border(1.dp, if (searchSelected) Color.LightGray else Color.White.copy(alpha = 0.3f), CircleShape)
.clickable { onSelect(4) },
contentAlignment = Alignment.Center
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = if (searchSelected) Color(0xFF1F2937) else inactive,
modifier = Modifier.size(24.dp)
modifier = Modifier.size(24.dp),
)
Text(
text = stringResource(Res.string.search),
color = if (searchSelected) Color(0xFF1F2937) else inactive,
fontSize = 10.sp,
fontWeight = FontWeight.ExtraBold
fontWeight = FontWeight.ExtraBold,
)
}
}
@@ -8,9 +8,12 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import top.yukonga.miuix.kmp.blur.Backdrop
import top.yukonga.miuix.kmp.blur.blur
import top.yukonga.miuix.kmp.blur.drawBackdrop
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import com.kyant.shapes.RoundedRectangle
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -41,6 +44,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -89,11 +93,28 @@ import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.outlined.GridView
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Storage
internal data class NavTab(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val labelRes: Int,
)
internal val mainTabs = listOf(
NavTab(Icons.Filled.Home, Icons.Outlined.Home, Res.string.nav_home),
NavTab(Icons.Filled.Favorite, Icons.Outlined.FavoriteBorder, Res.string.nav_records),
NavTab(Icons.Filled.Storage, Icons.Outlined.Storage, Res.string.nav_library),
NavTab(Icons.Filled.Settings, Icons.Outlined.Settings, Res.string.nav_settings),
)
val LocalGlassBackdrop = staticCompositionLocalOf<Backdrop?> { null }
@Composable
fun bottomBarContentPadding(extra: Dp = 86.dp): PaddingValues {
@@ -202,13 +223,6 @@ fun LiquidGlassTopBar(
Box(
modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
0f to Color.White.copy(alpha = 0.18f),
0.55f to Color.White.copy(alpha = 0.08f),
1f to Color.Transparent,
)
)
) {
Box(Modifier.statusBarsPadding()) {
content()
@@ -327,31 +341,93 @@ fun SegmentedControl(
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val backdrop = LocalGlassBackdrop.current
Row(
modifier
.clip(RoundedCornerShape(20.dp))
.background(Color.White.copy(alpha = 0.1f))
.border(0.5.dp, Color.LightGray.copy(alpha = 0.5f), RoundedCornerShape(20.dp))
.then(
if (backdrop != null) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedRectangle(20.dp) },
effects = {
vibrancy()
blur(8.dp.toPx())
lens(14.dp.toPx(), 14.dp.toPx())
},
onDrawSurface = { drawRect(GlassTokens.glassFillSoft) },
)
} else {
Modifier.background(GlassTokens.glassFillSoft)
}
)
.border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(20.dp))
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items.forEachIndexed { index, label ->
val sel = selected == index
val selectedFill by animateColorAsState(
targetValue = if (sel) GlassTokens.glassFillStrong else Color.Transparent,
animationSpec = tween(180),
label = "segmentFill$index",
)
val selectedText by animateColorAsState(
targetValue = if (sel) GlassTokens.text else GlassTokens.subtext,
animationSpec = tween(180),
label = "segmentText$index",
)
val selectedProgress by animateFloatAsState(
targetValue = if (sel) 1f else 0f,
animationSpec = tween(180),
label = "segmentProgress$index",
)
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(16.dp))
.background(if (sel) Color.White else Color.Transparent)
.then(
if (sel) Modifier.shadow(2.dp, RoundedCornerShape(16.dp)) else Modifier
if (sel && backdrop != null) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedRectangle(16.dp) },
effects = {
vibrancy()
blur(6.dp.toPx())
lens(10.dp.toPx(), 10.dp.toPx())
},
onDrawSurface = { drawRect(selectedFill) },
)
} else if (sel) {
Modifier.shadow(
elevation = 4.dp,
shape = RoundedCornerShape(16.dp),
ambientColor = GlassTokens.glassShadow,
spotColor = GlassTokens.glassShadow,
)
} else {
Modifier
}
)
.then(
if (sel && backdrop == null) {
Modifier.background(selectedFill)
} else {
Modifier
}
)
.border(
0.5.dp,
GlassTokens.glassBorder.copy(alpha = selectedProgress * 0.9f),
RoundedCornerShape(16.dp),
)
.clickable { onSelect(index) }
.padding(vertical = 9.dp),
.padding(horizontal = 10.dp, vertical = 9.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
color = if (sel) Color(0xFF1F2937) else GlassTokens.subtext,
color = selectedText,
fontSize = 14.sp,
fontWeight = if (sel) FontWeight.SemiBold else FontWeight.Normal,
)
@@ -368,19 +444,35 @@ fun GlassSearchField(
onGearClick: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val backdrop = LocalGlassBackdrop.current
Row(
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(22.dp))
.background(Color.White)
.border(0.5.dp, Color.LightGray, RoundedCornerShape(22.dp))
.then(
if (backdrop != null) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedRectangle(22.dp) },
effects = {
vibrancy()
blur(8.dp.toPx())
lens(12.dp.toPx(), 12.dp.toPx())
},
onDrawSurface = { drawRect(GlassTokens.glassFillSoft) },
)
} else {
Modifier.background(GlassTokens.glassFillSoft)
}
)
.border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = null,
tint = Color(0xFF6B7280),
tint = GlassTokens.subtext,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(10.dp))
@@ -389,13 +481,13 @@ fun GlassSearchField(
onValueChange = onValueChange,
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(
color = Color(0xFF111827),
color = GlassTokens.text,
fontSize = 15.sp,
),
modifier = Modifier.weight(1f),
decorationBox = { inner ->
if (value.isEmpty()) {
Text(text = placeholder, color = Color(0xFF9CA3AF), fontSize = 15.sp)
Text(text = placeholder, color = GlassTokens.subtext, fontSize = 15.sp)
} else {
inner()
}
@@ -417,8 +509,8 @@ fun GlassPopup(
modifier = modifier
.width(IntrinsicSize.Min)
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.border(0.5.dp, Color.LightGray, RoundedCornerShape(16.dp))
.background(GlassTokens.glassFillStrong)
.border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(16.dp))
.padding(vertical = 4.dp),
) {
content()
@@ -447,6 +539,45 @@ fun GlassPopupItem(
}
}
@Composable
fun LiquidGlassButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
cornerRadius: Dp = 14.dp,
enabled: Boolean = true,
fill: Color = GlassTokens.glassFillStrong,
content: @Composable RowScope.() -> Unit,
) {
val backdrop = LocalGlassBackdrop.current
val shape = RoundedCornerShape(cornerRadius)
Row(
modifier
.clip(shape)
.then(
if (backdrop != null) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedRectangle(cornerRadius) },
effects = {
vibrancy()
blur(8.dp.toPx())
lens(12.dp.toPx(), 12.dp.toPx())
},
onDrawSurface = { drawRect(fill) },
)
} else {
Modifier.background(fill)
}
)
.border(0.5.dp, GlassTokens.glassBorder, shape)
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
content = content,
)
}
@Composable
fun GlassButton(
text: String,
@@ -456,28 +587,20 @@ fun GlassButton(
accent: Boolean = false,
danger: Boolean = false,
) {
val bgColor = when {
danger -> Color(0xFFEF4444)
accent -> Color(0xFF3B82F6)
else -> Color.White
val fill = when {
danger -> GlassTokens.danger.copy(alpha = 0.84f)
accent -> GlassTokens.accent.copy(alpha = 0.78f)
else -> GlassTokens.glassFillStrong
}
val contentColor = when {
danger || accent -> Color.White
else -> Color(0xFF374151)
}
Box(
modifier
.clip(RoundedCornerShape(12.dp))
.background(bgColor)
.then(if (!accent && !danger) Modifier.border(0.5.dp, Color.LightGray, RoundedCornerShape(12.dp)) else Modifier)
.clickable(enabled = enabled) { onClick() }
.padding(vertical = 12.dp, horizontal = 16.dp),
contentAlignment = Alignment.Center,
LiquidGlassButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
fill = fill,
) {
Text(
text = text,
color = contentColor.copy(alpha = if (enabled) 1f else 0.6f),
color = if (accent || danger) Color.White else GlassTokens.text,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
)
@@ -498,7 +621,8 @@ fun GlassDialog(
modifier
.width(IntrinsicSize.Min)
.clip(RoundedCornerShape(24.dp))
.background(Color.White)
.background(GlassTokens.glassFillStrong)
.border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(24.dp))
.padding(20.dp),
) {
content()
@@ -524,8 +648,8 @@ fun GlassInputField(
Text(label, fontSize = 13.sp, color = Color(0xFF6B7280), fontWeight = FontWeight.Medium)
Box(
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
.background(Color(0xFFF9FAFB))
.border(1.dp, if (isError) Color.Red else Color.LightGray, RoundedCornerShape(12.dp))
.background(GlassTokens.glassFillSoft)
.border(1.dp, if (isError) GlassTokens.danger else GlassTokens.glassBorder, RoundedCornerShape(12.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
BasicTextField(
@@ -576,23 +700,23 @@ fun GlassSettingRow(
Modifier
.size(38.dp)
.clip(RoundedCornerShape(11.dp))
.background(Color.White)
.border(0.5.dp, Color.LightGray, RoundedCornerShape(11.dp)),
.background(bgTint.copy(alpha = 0.20f))
.border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(11.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color(0xFF374151),
tint = iconTint,
modifier = Modifier.size(19.dp),
)
}
Spacer(Modifier.width(14.dp))
Column {
Text(text = title, color = Color(0xFF1F2937), fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text(text = title, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium)
if (subtitle.isNotEmpty()) {
Spacer(Modifier.height(2.dp))
Text(text = subtitle, color = Color(0xFF6B7280), fontSize = 12.sp)
Text(text = subtitle, color = GlassTokens.subtext, fontSize = 12.sp)
}
}
}
@@ -268,6 +268,10 @@ fun LiquidBottomTabs(
val progress = dampedDragAnimation.pressProgress
Highlight.Default.copy(alpha = progress)
},
shadow = {
val progress = dampedDragAnimation.pressProgress
Shadow(alpha = progress)
},
innerShadow = {
val progress = dampedDragAnimation.pressProgress
InnerShadow(
@@ -298,4 +302,3 @@ fun LiquidBottomTabs(
}
}
+47
View File
@@ -0,0 +1,47 @@
[PROJECT:lumaplayer] 同步远端 master
结论: 保留本地改动并合并远端最新提交,最终 Debug 构建成功。
文件变更: 合并源码与资源,建立本地 Git 元数据,新增 memory 记录。
教训: 无法直接 pull 的快照应使用共同基线三方合并;认证信息不写入远端配置。
标签: #git #sync #merge #android
[PROJECT:lumaplayer] 复核 Compose 编译错误
结论: 当前源码已包含所需作用域和导入修复,compileDebugKotlin 与 assembleDebug 均成功。
文件变更: 无源码变更;补充本次编译验证记忆。
教训: Kotlin 中应导入顶层符号 HomeTopBar,不使用生成的 HomeScreenKt 文件名。
标签: #compose #kotlin #build #imports
[PROJECT:lumaplayer] 直接修复 Compose 编译问题
结论: 完成导航定义共享、Composable 作用域、ColumnScope、资源别名和 Backdrop 导入修复,assembleDebug 成功。
文件变更: ui/LiquidGlass.kt、ui/AppBottomBar.kt、screens/SettingsScreen.kt、MainApp.kt。
教训: 跨文件共享的顶层导航定义不能使用 private;Composable content 必须显式声明 RowScope/ColumnScope 接收者。
标签: #compose #navigation #imports #build
[PROJECT:lumaplayer] 底栏透明化
结论: 移除底部导航容器及搜索按钮的白色背景,保留玻璃模糊和交互效果,assembleDebug 成功。
文件变更: ui/AppBottomBar.kt、ui/liquidglass/LiquidBottomTabs.kt。
教训: Backdrop 的可见底色由 onDrawSurface 控制,透明需求需同时处理外层按钮背景。
标签: #ui #transparent #backdrop #android
[PROJECT:lumaplayer] 首页与 Vision 视觉过渡优化
结论: Banner 与内容区改为连续环境色渐变,Vision 收藏/日历切换改为玻璃选中态和淡入淡出空状态。
文件变更: screens/HomeScreen.kt、screens/RecordsScreen.kt、ui/LiquidGlass.kt。
教训: 分段切换的内容和选中态需要使用同一套动态玻璃颜色,避免固定白色造成跳变。
标签: #ui #home #vision #animation
[PROJECT:lumaplayer] 播放与导航完整修复
结论: Emby 播放地址补认证参数并恢复/回报断点,底栏去除整条白色背景,资源库避免重复顶部栏,主页面增加转场动画。
文件变更: data/EmbyApiModels.kt、data/EmbyRepository.kt、screens/MediaDetailScreen.kt、screens/VideoPlayerScreen.kt、MainApp.kt、screens/HomeScreen.kt、ui/LiquidGlass.kt、ui/liquidglass/LiquidBottomTabs.kt。
教训: ExoPlayer 需要明确设置起播位置并在生命周期恢复时调用 play;Emby 播放进度必须通过 Playing/Progress 和 Playing/Stopped 回报。
标签: #playback #emby #resume #liquid-glass #transition #build
[PROJECT:lumaplayer] 搜索、收藏与液态玻璃交互完善
结论: Emby 搜索可输入并打开详情,收藏列表可显示图片并进入详情,播放断点显示继续时间,首页 Banner 与内容区通过元数据淡入衔接,主页面和控件转场/玻璃效果已补齐。
文件变更: MainApp.kt、data/EmbyApiModels.kt、data/EmbyRepository.kt、screens/HomeScreen.kt、screens/MediaDetailScreen.kt、screens/RecordsScreen.kt、screens/SearchScreen.kt、screens/VideoPlayerScreen.kt、ui/AppBottomBar.kt、ui/LiquidGlass.kt、ui/liquidglass/LiquidBottomTabs.kt。
教训: Backdrop 玻璃控件必须挂在同一棵 `layerBackdrop` 来源树下;搜索选中态不能把无效索引传给固定数量的底栏动画。
标签: #search #favorites #resume #emby #backdrop #transition #build
[PROJECT:lumaplayer] 修复 RenderThread 崩溃与底栏视觉
结论: 根因是 Backdrop 捕获层内重复采样同一 backdrop 形成循环渲染;已收窄捕获范围,移除顶部玻璃底色,底栏仅保留透明轮廓、选中胶囊和搜索圆钮,模拟器启动无崩溃。
文件变更: MainApp.kt、screens/VideoPlayerScreen.kt、ui/LiquidGlass.kt、ui/AppBottomBar.kt、ui/liquidglass/LiquidBottomTabs.kt。
教训: `layerBackdrop` 只包背景/视频源,使用 `drawBackdrop` 的控件必须位于捕获层外。
标签: #renderthread #sigsegv #backdrop #transparent #bottom-bar #android
+6
View File
@@ -0,0 +1,6 @@
[PROJECT:lumaplayer] AndroidLiquidGlass 液态玻璃菜单与底栏
结论: 语言菜单和底栏主体/搜索入口已接入 AndroidLiquidGlass-kmp 对应的 Backdrop 2.0.1 实现,展开、关闭、外部点击、语言选择和 Debug 构建均通过。
文件变更: app/build.gradle.kts、MainApp.kt、screens/SettingsScreen.kt、ui/AppBottomBar.kt、ui/liquidglass/LiquidBottomTabs.kt,以及此前待推送的播放器、首页、搜索、收藏和数据文件。
教训: 参考仓库没有现成菜单组件,需复用其 drawBackdrop 组件模式;来源层与玻璃消费层必须分离,外部手势层需放在菜单内容下方。
标签: #lumaplayer #android-liquid-glass #backdrop #language-menu #bottom-bar
+8
View File
@@ -0,0 +1,8 @@
# 项目记忆索引
- 项目:lumaplayer,Android Kotlin/Compose 媒体库客户端。
- 当前 Git 分支:`master`。
- 最近同步:远端 `fc35ae9`,本地合并提交 `944291d`。
- 当前实现:语言菜单与底栏使用 AndroidLiquidGlass/Backdrop 2.0.1,交互验证通过。
- 构建验证:`assembleDebug` 成功。
- 详细状态见 [projects.md](projects.md),踩坑见 [lessons.md](lessons.md)。
+5
View File
@@ -0,0 +1,5 @@
# 基础设施速查
- Git 远端:`https://git.leee.xin/beyond/lumaplayer.git`。
- 本地构建 JDK:Android Studio JBR,路径 `/Applications/Android Studio.app/Contents/jbr/Contents/Home`。
- 本次未修改服务器、API 或部署配置。
+9
View File
@@ -0,0 +1,9 @@
# 教训
- 中:本地目录原先没有 `.git`,不能直接 `pull`;应先获取远端历史,以共同基线做三方合并。
- 中:合并后需编译检查跨文件 API;本次发现并修复资源别名、Backdrop 导入及底部导航 `mainTabs` 缺失问题。
- 低:远端 URL 不保留账号密码,认证信息只用于本次读取。
- 高:Kyant0 Backdrop 的 `layerBackdrop` 捕获范围内不能再直接绘制使用同一 backdrop 的玻璃控件,否则可能形成循环渲染并在 RenderThread 触发 SIGSEGV;页面源层与玻璃控件必须分离,视频源层只包 AndroidView。
- 中:远端提交与本地大范围未提交 UI 改动并行时,先暂存本地改动、合并远端非冲突文件,再恢复本地改动并手动吸收远端业务行为,避免整文件覆盖。
- 高:Backdrop 的来源层与消费玻璃必须是同级兄弟层;菜单外部手势层也必须位于菜单内容下方,否则打开手势会误触发关闭。
- 中:Backdrop 的玻璃效果是否明显取决于来源内容;纯白卡片上无法显示明显折射,需依靠仓库的高光、阴影和 lens 边缘表现,不能用普通白色背景冒充。
+15
View File
@@ -0,0 +1,15 @@
# 项目状态
- 2026-08-26:完成远端 `master` 同步;保留本地首页动画、收藏数据和媒体资料,合并远端资源库及图标库管理更新。
- 当前状态:本地 `master` 已包含远端最新提交,工作区无源码冲突。
- 验证:`JAVA_HOME=/Applications/Android Studio.app/Contents/jbr/Contents/Home bash ./gradlew assembleDebug` 成功。
- 待办:无本次同步遗留待办。
- 2026-08-26:修复导航项共享定义、Compose content 作用域、Settings `ColumnScope`、资源别名及 Backdrop 导入;Debug 构建通过。
- 2026-08-26:底部导航栏改为透明底,保留 Backdrop 模糊和交互效果;Debug 构建通过。
- 2026-08-26:首页 Banner 增加环境色过渡,Vision 分段选中态与收藏/日历空状态统一;Debug 构建通过。
- 2026-08-26:修复 Emby 播放认证、断点恢复与播放进度回报;移除资源库重复顶部栏、底栏整条白色背景,补强 Vision 液态玻璃选中态和页面转场;Debug 构建通过。
- 2026-08-26:搜索改为 Emby 实时查询并支持进入详情;收藏补齐图片、用户数据和详情点击;详情按钮显示继续时间;首页 Banner 元数据增加淡入过渡;顶部栏、分段控件、按钮、搜索和底栏统一使用 Backdrop 液态玻璃;Debug 构建通过。
- 2026-08-26:修复启动阶段 Backdrop 循环渲染导致的 RenderThread SIGSEGV;收窄页面捕获层,视频只捕获 AndroidView,移除顶部栏玻璃底色,底栏改为透明外壳、选中胶囊和搜索圆钮;模拟器启动验证通过,Debug 构建通过。
- 2026-08-27:同步远端 `fc35ae9`;合并服务器选择持久化和资源库点击后回首页逻辑,保留本地详情/播放器全屏与透明底栏修复;Debug 构建通过。
- 2026-08-27:语言菜单改为真实锚点的 Backdrop 液态玻璃 Morph Overlay;底栏主体和搜索圆钮复用 AndroidLiquidGlass-kmp 的 `drawBackdrop`、`vibrancy`、`blur`、`lens`、高光和阴影实现;展开 180ms、关闭 300ms,外部点击和语言选择验证通过。
- 当前依赖:`io.github.kyant0:backdrop:2.0.1`、`io.github.kyant0:shapes:1.2.1`;未纳入本地参考素材和压缩包。