diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3c3b3e8..3687fce 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/kotlin/com/example/myapplication/MainApp.kt b/app/src/main/kotlin/com/example/myapplication/MainApp.kt index e1edc1c..73a3f87 100644 --- a/app/src/main/kotlin/com/example/myapplication/MainApp.kt +++ b/app/src/main/kotlin/com/example/myapplication/MainApp.kt @@ -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(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() { } } } +} diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt index 4505f8d..10d154c 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt @@ -65,6 +65,7 @@ data class EmbyItem( @SerializedName("People") val _people: List? = emptyList(), @SerializedName("Studios") val _studios: List? = emptyList(), @SerializedName("MediaStreams") val _mediaStreams: List? = emptyList(), + @SerializedName("UserData") val userData: EmbyUserData? = null, ) { val genres: List get() = _genres.orEmpty() val backdropImageTags: List get() = _backdropImageTags.orEmpty() @@ -72,6 +73,8 @@ data class EmbyItem( val people: List get() = _people.orEmpty() val studios: List get() = _studios.orEmpty() val mediaStreams: List 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 = "", diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt index 309e558..83918b2 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt @@ -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 = 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 = 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>() {}.type gson.fromJson>(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 = 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 = 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( + "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" diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index 28b0d42..8bfa64a 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -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 }, + ) + } + } } } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt index 510b313..f2ae875 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -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(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( diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt index 094de6a..78e0dc6 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -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>(emptyList()) } + var loading by remember { mutableStateOf(false) } + var selectedFavorite by remember { mutableStateOf(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>(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() diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt index a801c0d..e5c81a3 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt @@ -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 = 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>(emptyList()) } + var loading by remember(activeServer?.id) { mutableStateOf(false) } + var selectedItem by remember { mutableStateOf(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, ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt index 40ac20e..b7fdb22 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt @@ -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(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), + ) + } +} diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt index c4681c7..8a93fe3 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt @@ -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 { null } fun VideoPlayerScreen( title: String, playbackUrl: String? = null, + initialPositionMs: Long = 0L, isLoading: Boolean = false, errorText: String? = null, episodes: List = 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(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 } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt index ce762b9..a4d1f77 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt @@ -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, ) } } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt index 6b41d2b..6e3b04d 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -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 { 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) } } } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt index b924a55..07b8769 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt @@ -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( } } - diff --git a/memory/2026-08-26.md b/memory/2026-08-26.md new file mode 100644 index 0000000..58d1011 --- /dev/null +++ b/memory/2026-08-26.md @@ -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 diff --git a/memory/2026-08-27.md b/memory/2026-08-27.md new file mode 100644 index 0000000..225ec86 --- /dev/null +++ b/memory/2026-08-27.md @@ -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 diff --git a/memory/MEMORY.md b/memory/MEMORY.md new file mode 100644 index 0000000..c6d0aeb --- /dev/null +++ b/memory/MEMORY.md @@ -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)。 diff --git a/memory/infra.md b/memory/infra.md new file mode 100644 index 0000000..ca556d8 --- /dev/null +++ b/memory/infra.md @@ -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 或部署配置。 diff --git a/memory/lessons.md b/memory/lessons.md new file mode 100644 index 0000000..622d24a --- /dev/null +++ b/memory/lessons.md @@ -0,0 +1,9 @@ +# 教训 + +- 中:本地目录原先没有 `.git`,不能直接 `pull`;应先获取远端历史,以共同基线做三方合并。 +- 中:合并后需编译检查跨文件 API;本次发现并修复资源别名、Backdrop 导入及底部导航 `mainTabs` 缺失问题。 +- 低:远端 URL 不保留账号密码,认证信息只用于本次读取。 +- 高:Kyant0 Backdrop 的 `layerBackdrop` 捕获范围内不能再直接绘制使用同一 backdrop 的玻璃控件,否则可能形成循环渲染并在 RenderThread 触发 SIGSEGV;页面源层与玻璃控件必须分离,视频源层只包 AndroidView。 +- 中:远端提交与本地大范围未提交 UI 改动并行时,先暂存本地改动、合并远端非冲突文件,再恢复本地改动并手动吸收远端业务行为,避免整文件覆盖。 +- 高:Backdrop 的来源层与消费玻璃必须是同级兄弟层;菜单外部手势层也必须位于菜单内容下方,否则打开手势会误触发关闭。 +- 中:Backdrop 的玻璃效果是否明显取决于来源内容;纯白卡片上无法显示明显折射,需依靠仓库的高光、阴影和 lens 边缘表现,不能用普通白色背景冒充。 diff --git a/memory/projects.md b/memory/projects.md new file mode 100644 index 0000000..232b2ba --- /dev/null +++ b/memory/projects.md @@ -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`;未纳入本地参考素材和压缩包。