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