diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..2e01d21
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,68 @@
+plugins {
+ id("com.android.application") version "9.2.1"
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+ namespace = "com.example.myapplication"
+ compileSdk = 37
+
+ defaultConfig {
+ applicationId = "com.example.myapplication"
+ minSdk = 24
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ annotationProcessor("androidx.room:room-compiler:2.8.4")
+ val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
+ implementation(composeBom)
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ implementation("io.coil-kt:coil-compose:2.7.0")
+ implementation("androidx.palette:palette-ktx:1.0.0")
+ implementation("androidx.activity:activity-compose:1.13.0")
+ implementation("androidx.core:core-splashscreen:1.0.1")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.1")
+ 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("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")
+ implementation("androidx.room:room-ktx:2.8.4")
+ implementation("androidx.media3:media3-exoplayer:1.5.1")
+ implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
+ implementation("androidx.media3:media3-ui:1.5.1")
+ implementation("com.squareup.retrofit2:retrofit:2.11.0")
+ implementation("com.squareup.retrofit2:converter-gson:2.11.0")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+}
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..fb967d3
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/example/myapplication/data/AppDatabase.java b/app/src/main/java/com/example/myapplication/data/AppDatabase.java
new file mode 100644
index 0000000..6d9d660
--- /dev/null
+++ b/app/src/main/java/com/example/myapplication/data/AppDatabase.java
@@ -0,0 +1,87 @@
+package com.example.myapplication.data;
+
+import android.content.Context;
+import androidx.room.Database;
+import androidx.room.Room;
+import androidx.room.RoomDatabase;
+import androidx.room.migration.Migration;
+import androidx.sqlite.db.SupportSQLiteDatabase;
+
+@Database(
+ entities = {IconLibraryEntity.class, ServerEntity.class},
+ version = 4,
+ exportSchema = false
+)
+public abstract class AppDatabase extends RoomDatabase {
+ public abstract IconLibraryDao iconLibraryDao();
+ public abstract ServerDao serverDao();
+
+ private static volatile AppDatabase instance;
+
+ private static final Migration MIGRATION_1_2 = new Migration(1, 2) {
+ @Override
+ public void migrate(SupportSQLiteDatabase db) {
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS servers (" +
+ "`id` TEXT NOT NULL, " +
+ "`name` TEXT, " +
+ "`host` TEXT, " +
+ "`basePath` TEXT, " +
+ "`port` TEXT, " +
+ "`username` TEXT, " +
+ "`password` TEXT, " +
+ "`note` TEXT, " +
+ "PRIMARY KEY(`id`))"
+ );
+
+ db.execSQL("ALTER TABLE icon_libraries RENAME TO icon_libraries_old");
+ db.execSQL(
+ "CREATE TABLE icon_libraries (" +
+ "`id` TEXT NOT NULL, " +
+ "`name` TEXT, " +
+ "`sourceUrl` TEXT, " +
+ "`isDefault` INTEGER NOT NULL, " +
+ "`iconCount` INTEGER NOT NULL, " +
+ "PRIMARY KEY(`id`))"
+ );
+ db.execSQL(
+ "INSERT INTO icon_libraries (`id`, `name`, `sourceUrl`, `isDefault`, `iconCount`) " +
+ "SELECT old.`id`, old.`name`, old.`sourceUrl`, old.`isDefault`, " +
+ "COALESCE((SELECT COUNT(*) FROM icon_items WHERE libraryId = old.`id`), 0) " +
+ "FROM icon_libraries_old old"
+ );
+ db.execSQL("DROP TABLE IF EXISTS icon_items");
+ db.execSQL("DROP TABLE icon_libraries_old");
+ }
+ };
+
+ private static final Migration MIGRATION_2_3 = new Migration(2, 3) {
+ @Override
+ public void migrate(SupportSQLiteDatabase db) {
+ db.execSQL("ALTER TABLE servers ADD COLUMN accessToken TEXT");
+ db.execSQL("ALTER TABLE servers ADD COLUMN userId TEXT");
+ }
+ };
+ private static final Migration MIGRATION_3_4 = new Migration(3, 4) {
+ @Override
+ public void migrate(SupportSQLiteDatabase db) {
+ db.execSQL("ALTER TABLE servers ADD COLUMN iconUrl TEXT");
+ }
+ };
+
+ public static AppDatabase getInstance(Context context) {
+ if (instance == null) {
+ synchronized (AppDatabase.class) {
+ if (instance == null) {
+ instance = Room.databaseBuilder(
+ context.getApplicationContext(),
+ AppDatabase.class,
+ "emby_player.db"
+ ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4).build();
+ }
+ }
+ }
+ return instance;
+ }
+}
+
diff --git a/app/src/main/java/com/example/myapplication/data/IconLibraryDao.java b/app/src/main/java/com/example/myapplication/data/IconLibraryDao.java
new file mode 100644
index 0000000..b892576
--- /dev/null
+++ b/app/src/main/java/com/example/myapplication/data/IconLibraryDao.java
@@ -0,0 +1,34 @@
+package com.example.myapplication.data;
+
+import androidx.room.Dao;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+import androidx.room.Transaction;
+
+import java.util.List;
+
+@Dao
+public interface IconLibraryDao {
+ @Query("SELECT * FROM icon_libraries ORDER BY name COLLATE NOCASE")
+ List getLibraries();
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ void insertLibrary(IconLibraryEntity library);
+
+ @Query("UPDATE icon_libraries SET isDefault = 0 WHERE id != :excludeId")
+ void clearDefaultFlags(String excludeId);
+
+ @Query("UPDATE icon_libraries SET iconCount = :count WHERE id = :libraryId")
+ void updateIconCount(String libraryId, int count);
+ @Query("DELETE FROM icon_libraries WHERE id = :libraryId")
+ void deleteLibrary(String libraryId);
+
+ @Transaction
+ default void saveLibrary(IconLibraryEntity library) {
+ if (library.isDefault) {
+ clearDefaultFlags(library.id);
+ }
+ insertLibrary(library);
+ }
+}
diff --git a/app/src/main/java/com/example/myapplication/data/IconLibraryEntity.java b/app/src/main/java/com/example/myapplication/data/IconLibraryEntity.java
new file mode 100644
index 0000000..9f8a373
--- /dev/null
+++ b/app/src/main/java/com/example/myapplication/data/IconLibraryEntity.java
@@ -0,0 +1,24 @@
+package com.example.myapplication.data;
+
+import androidx.annotation.NonNull;
+import androidx.room.Entity;
+import androidx.room.PrimaryKey;
+
+@Entity(tableName = "icon_libraries")
+public class IconLibraryEntity {
+ @PrimaryKey
+ @NonNull
+ public String id;
+ public String name;
+ public String sourceUrl;
+ public boolean isDefault;
+ public int iconCount;
+
+ public IconLibraryEntity(String id, String name, String sourceUrl, boolean isDefault, int iconCount) {
+ this.id = id;
+ this.name = name;
+ this.sourceUrl = sourceUrl;
+ this.isDefault = isDefault;
+ this.iconCount = iconCount;
+ }
+}
diff --git a/app/src/main/java/com/example/myapplication/data/ServerDao.java b/app/src/main/java/com/example/myapplication/data/ServerDao.java
new file mode 100644
index 0000000..07d3275
--- /dev/null
+++ b/app/src/main/java/com/example/myapplication/data/ServerDao.java
@@ -0,0 +1,25 @@
+package com.example.myapplication.data;
+
+import androidx.room.Dao;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+
+import java.util.List;
+
+@Dao
+public interface ServerDao {
+ @Query("SELECT * FROM servers ORDER BY rowid")
+ List getServers();
+
+ @Query("SELECT COUNT(*) FROM servers")
+ int getServerCount();
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ void insertServer(ServerEntity server);
+
+ @Query("DELETE FROM servers WHERE (id = 'server_1' AND host = 'xying.local') OR (id = 'server_2' AND host = 'film.example.com')")
+ void deleteLegacySampleServers();
+ @Query("DELETE FROM servers WHERE id = :serverId")
+ void deleteServer(String serverId);
+}
diff --git a/app/src/main/java/com/example/myapplication/data/ServerEntity.java b/app/src/main/java/com/example/myapplication/data/ServerEntity.java
new file mode 100644
index 0000000..bcf0c8d
--- /dev/null
+++ b/app/src/main/java/com/example/myapplication/data/ServerEntity.java
@@ -0,0 +1,65 @@
+package com.example.myapplication.data;
+
+import androidx.annotation.NonNull;
+import androidx.room.Entity;
+import androidx.room.Ignore;
+import androidx.room.PrimaryKey;
+
+@Entity(tableName = "servers")
+public class ServerEntity {
+ @PrimaryKey
+ @NonNull
+ public String id;
+ public String name;
+ public String host;
+ public String basePath;
+ public String port;
+ public String username;
+ public String password;
+ public String note;
+ public String accessToken;
+ public String userId;
+ public String iconUrl;
+
+ @Ignore
+ public ServerEntity(
+ String id,
+ String name,
+ String host,
+ String basePath,
+ String port,
+ String username,
+ String password,
+ String note,
+ String accessToken,
+ String userId
+ ) {
+ this(id, name, host, basePath, port, username, password, note, accessToken, userId, "");
+ }
+
+ public ServerEntity(
+ String id,
+ String name,
+ String host,
+ String basePath,
+ String port,
+ String username,
+ String password,
+ String note,
+ String accessToken,
+ String userId,
+ String iconUrl
+ ) {
+ this.id = id;
+ this.name = name;
+ this.host = host;
+ this.basePath = basePath;
+ this.port = port;
+ this.username = username;
+ this.password = password;
+ this.note = note;
+ this.accessToken = accessToken;
+ this.userId = userId;
+ this.iconUrl = iconUrl == null ? "" : iconUrl;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/example/myapplication/MainActivity.kt b/app/src/main/kotlin/com/example/myapplication/MainActivity.kt
new file mode 100644
index 0000000..831c27e
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/MainActivity.kt
@@ -0,0 +1,24 @@
+package com.example.myapplication
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import com.example.myapplication.data.IconLibraryRepository
+import com.example.myapplication.data.ServerRepository
+import com.example.myapplication.ui.AppLanguage
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen()
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ AppLanguage.init(this)
+ IconLibraryRepository.init(this)
+ ServerRepository.init(this)
+ setContent {
+ MainApp()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/MainApp.kt b/app/src/main/kotlin/com/example/myapplication/MainApp.kt
new file mode 100644
index 0000000..eaefcf5
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/MainApp.kt
@@ -0,0 +1,334 @@
+package com.example.myapplication
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+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.automirrored.filled.ArrowBack
+import androidx.compose.material3.Icon
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import com.example.myapplication.R as Res
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+import com.kyant.backdrop.backdrops.layerBackdrop
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.hazeSource
+import com.example.myapplication.data.EmbyServer
+import com.example.myapplication.data.IconItem
+import com.example.myapplication.data.IconLibrary
+import com.example.myapplication.data.IconLibraryRepository
+import com.example.myapplication.data.EmbyRepository
+import com.example.myapplication.data.ServerRepository
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import com.example.myapplication.screens.HomeScreen
+import com.example.myapplication.screens.HomeUiState
+import com.example.myapplication.screens.HomeTopBar
+import com.example.myapplication.screens.IconLibraryManagerScreen
+import com.example.myapplication.screens.LibraryScreen
+import com.example.myapplication.screens.LibraryTopBar
+import com.example.myapplication.screens.RecordsScreen
+import com.example.myapplication.screens.RecordsTopBar
+import com.example.myapplication.screens.SearchScreen
+import com.example.myapplication.screens.SearchTopBar
+import com.example.myapplication.screens.SettingsScreen
+import com.example.myapplication.screens.SettingsTopBar
+import androidx.compose.foundation.isSystemInDarkTheme
+import com.example.myapplication.ui.AppTheme
+import com.example.myapplication.ui.GlassTokens
+import com.example.myapplication.ui.LiquidGlassBackground
+import com.example.myapplication.ui.LiquidAppBottomBar
+import com.example.myapplication.ui.LiquidGlassNavBar
+
+@Composable
+fun MainApp() {
+ var selectedTab by remember { mutableIntStateOf(0) }
+ var selectedServerId by remember { mutableStateOf("") }
+ var recordsSegment by remember { mutableIntStateOf(1) }
+ var searchQuery by remember { mutableStateOf("") }
+ var showLibraryAddDialog by remember { mutableStateOf(false) }
+ 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 showAboutDetail by remember { mutableStateOf(false) }
+ var changeIconTarget by remember { mutableStateOf(null) }
+ var showStartScreen by remember { mutableStateOf(true) }
+
+ val servers by ServerRepository.servers.collectAsState()
+ val iconLibraries by IconLibraryRepository.libraries.collectAsState()
+ val coroutineScope = rememberCoroutineScope()
+
+ val selectedServer = servers.find { it.id == selectedServerId } ?: servers.firstOrNull()
+
+ // Check all servers' online status when app starts or server list changes
+ LaunchedEffect(servers.size) {
+ if (servers.isNotEmpty()) {
+ ServerRepository.checkAllServersOnline()
+ }
+ }
+
+ val addIconLibrary: (String, String, List, Boolean) -> Unit = { name, url, icons, isDefault ->
+ val library = IconLibrary(
+ id = "lib_${System.currentTimeMillis()}",
+ name = name,
+ sourceUrl = url,
+ iconCount = icons.size,
+ isDefault = isDefault,
+ icons = icons,
+ )
+ coroutineScope.launch {
+ IconLibraryRepository.addLibrary(library)
+ }
+ }
+
+ val deleteIconLibrary: (String) -> Unit = { id ->
+ coroutineScope.launch {
+ IconLibraryRepository.removeLibrary(id)
+ }
+ }
+
+ val updateIconLibrary: (IconLibrary) -> Unit = { library ->
+ coroutineScope.launch {
+ IconLibraryRepository.updateLibrary(library)
+ }
+ }
+
+ val refreshIconLibrary: (String) -> Unit = { id ->
+ coroutineScope.launch {
+ IconLibraryRepository.refreshLibrary(id)
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ delay(2200)
+ showStartScreen = false
+ }
+
+ if (showStartScreen) {
+ Box(Modifier.fillMaxSize().background(Color.White)) {
+ Image(
+ painter = painterResource(Res.drawable.app_start),
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ }
+ }
+ AppTheme.systemIsDark = isSystemInDarkTheme()
+
+ if (!showStartScreen) {
+ MiuixTheme {
+ val hazeState = remember { HazeState() }
+ val glassBackdrop = rememberLayerBackdrop()
+ Box(Modifier.fillMaxSize()) {
+ LiquidGlassBackground()
+ Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
+
+ if (selectedTab == 2 && showIconLibraryManager) {
+ IconLibraryManagerScreen(
+ libraries = iconLibraries,
+ modifier = Modifier.fillMaxSize(),
+ onBack = { showIconLibraryManager = false },
+ onAddIconLibrary = addIconLibrary,
+ onUpdateIconLibrary = updateIconLibrary,
+ onDeleteIconLibrary = deleteIconLibrary,
+ onRefreshIconLibrary = refreshIconLibrary,
+ )
+ } else {
+ Box(Modifier.fillMaxSize()) {
+ Column(Modifier.fillMaxSize().layerBackdrop(glassBackdrop)) {
+ when (selectedTab) {
+ 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.Black.copy(alpha = 0.12f))
+ .border(1.dp, Color.Black.copy(alpha = 0.15f), CircleShape)
+ .clickable { showCacheDetail = false },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(19.dp))
+ }
+ Spacer(Modifier.width(12.dp))
+ Text(stringResource(Res.string.cache_detail_title), 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.Black.copy(alpha = 0.12f))
+ .border(1.dp, Color.Black.copy(alpha = 0.15f), CircleShape)
+ .clickable { showAboutDetail = false },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(19.dp))
+ }
+ Spacer(Modifier.width(12.dp))
+ Text(stringResource(Res.string.about), color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold)
+ }
+ } else {
+ SettingsTopBar()
+ }
+ }
+ 4 -> SearchTopBar(searchQuery, { searchQuery = it }, { })
+ }
+
+ Box(Modifier.weight(1f)) {
+ when (selectedTab) {
+ 0 -> HomeScreen(
+ servers = servers,
+ selected = selectedServer,
+ onDetailVisibilityChanged = { homeDetailVisible = it },
+ )
+ 1 -> RecordsScreen(segment = recordsSegment)
+ 2 -> LibraryScreen(
+ servers = servers,
+ selectedServer = selectedServer,
+ onSelectServer = { selectedServerId = it },
+ onAddServer = { server, onResult ->
+ coroutineScope.launch {
+ try {
+ val authResult = EmbyRepository.authenticate(server)
+ if (authResult.success) {
+ ServerRepository.addServer(
+ server.copy(
+ apiKey = authResult.accessToken,
+ userId = authResult.userId,
+ )
+ )
+ if (selectedServerId.isBlank()) {
+ selectedServerId = server.id
+ }
+ onResult(null)
+ } else {
+ onResult(authResult.errorMessage ?: "\u767B\u5F55\u5931\u8D25")
+ }
+ } catch (e: Exception) {
+ onResult("\u7F51\u7EDC\u9519\u8BEF: " + (e.message ?: "\u672A\u77E5"))
+ }
+ }
+ },
+ onDeleteServer = { id ->
+ coroutineScope.launch {
+ ServerRepository.deleteServer(id)
+ }
+ if (selectedServerId == id) {
+ selectedServerId = ""
+ }
+ },
+ onUpdateServer = { updated, onResult ->
+ coroutineScope.launch {
+ try {
+ val authResult = EmbyRepository.authenticate(updated)
+ if (authResult.success) {
+ ServerRepository.updateServer(
+ updated.copy(
+ apiKey = authResult.accessToken,
+ userId = authResult.userId,
+ )
+ )
+ onResult(null)
+ } else {
+ onResult(authResult.errorMessage ?: "\u767B\u5F55\u5931\u8D25")
+ }
+ } catch (e: Exception) {
+ onResult("\u7F51\u7EDC\u9519\u8BEF: " + (e.message ?: "\u672A\u77E5"))
+ }
+ }
+ },
+ showAddDialog = showLibraryAddDialog,
+ onDismissAddDialog = { showLibraryAddDialog = false },
+ isGrid = libraryIsGrid,
+ onChangeIconTarget = changeIconTarget,
+ onDismissChangeIcon = { changeIconTarget = null },
+ onPickIcon = { server, iconUrl ->
+ ServerRepository.setRuntimeIcon(server.id, iconUrl)
+ },
+ onRequestChangeIcon = { server -> changeIconTarget = server },
+ )
+ 3 -> SettingsScreen(
+ servers = servers,
+ showCacheDetail = showCacheDetail,
+ onOpenCacheDetail = { showCacheDetail = true },
+ onBackFromCache = { showCacheDetail = false },
+ showAboutDetail = showAboutDetail,
+ onOpenAbout = { showAboutDetail = true },
+ onBackFromAbout = { showAboutDetail = false },
+ )
+ 4 -> SearchScreen(query = searchQuery)
+ }
+ }
+ }
+ }
+ }
+
+ if (!homeDetailVisible) {
+
+
+ LiquidAppBottomBar(
+ selectedTab = selectedTab,
+ onSelect = { selectedTab = it },
+ backdrop = glassBackdrop,
+ modifier = Modifier.align(Alignment.BottomCenter),
+ )
+
+ }
+ }
+ }
+ }
+ }
+}
+
+
+
+
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
new file mode 100644
index 0000000..5d62773
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
@@ -0,0 +1,123 @@
+package com.example.myapplication.data
+
+import com.google.gson.annotations.SerializedName
+
+/* ---------- Auth ---------- */
+
+data class EmbyAuthRequest(
+ val Username: String,
+ val Pw: String,
+)
+
+data class EmbyAuthResponse(
+ @SerializedName("User") val user: EmbyUser?,
+ @SerializedName("AccessToken") val accessToken: String?,
+ @SerializedName("ServerId") val serverId: String?,
+)
+
+data class EmbyUser(
+ @SerializedName("Id") val id: String,
+ @SerializedName("Name") val name: String,
+)
+
+/* ---------- Libraries ---------- */
+
+data class EmbyMediaFoldersResponse(
+ @SerializedName("Items") val items: List = emptyList(),
+ @SerializedName("TotalRecordCount") val totalRecordCount: Int = 0,
+)
+
+data class EmbyMediaFolder(
+ @SerializedName("Id") val id: String,
+ @SerializedName("Name") val name: String,
+ @SerializedName("CollectionType") val collectionType: String? = null,
+ @SerializedName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
+)
+
+/* ---------- Media Items ---------- */
+
+data class EmbyItemsResponse(
+ @SerializedName("Items") val items: List = emptyList(),
+ @SerializedName("TotalRecordCount") val totalRecordCount: Int = 0,
+ @SerializedName("StartIndex") val startIndex: Int = 0,
+)
+
+data class EmbyItem(
+ @SerializedName("Id") val id: String,
+ @SerializedName("Name") val name: String,
+ @SerializedName("Type") val type: String? = null,
+ @SerializedName("CollectionType") val collectionType: String? = null,
+ @SerializedName("OfficialRating") val officialRating: String? = null,
+ @SerializedName("CommunityRating") val communityRating: Double? = null,
+ @SerializedName("ProductionYear") val productionYear: Int? = null,
+ @SerializedName("Overview") val overview: String? = null,
+ @SerializedName("Genres") val _genres: List? = emptyList(),
+ @SerializedName("RunTimeTicks") val runTimeTicks: Long? = null,
+ @SerializedName("PremiereDate") val premiereDate: String? = null,
+ @SerializedName("ImageTags") val imageTags: Map? = null,
+ @SerializedName("BackdropImageTags") val _backdropImageTags: List? = emptyList(),
+ @SerializedName("SeriesName") val seriesName: String? = null,
+ @SerializedName("ParentId") val parentId: String? = null,
+ @SerializedName("IndexNumber") val indexNumber: Int? = null,
+ @SerializedName("ParentIndexNumber") val parentIndexNumber: Int? = null,
+ @SerializedName("OriginalTitle") val originalTitle: String? = null,
+ @SerializedName("Taglines") val _taglines: List? = emptyList(),
+ @SerializedName("People") val _people: List? = emptyList(),
+ @SerializedName("Studios") val _studios: List? = emptyList(),
+ @SerializedName("MediaStreams") val _mediaStreams: List? = emptyList(),
+) {
+ val genres: List get() = _genres.orEmpty()
+ val backdropImageTags: List get() = _backdropImageTags.orEmpty()
+ val taglines: List get() = _taglines.orEmpty()
+ val people: List get() = _people.orEmpty()
+ val studios: List get() = _studios.orEmpty()
+ val mediaStreams: List get() = _mediaStreams.orEmpty()
+
+ val primaryImageTag: String?
+ get() = imageTags?.get("Primary")
+
+ val posterUrlPath: String?
+ get() {
+ if (primaryImageTag.isNullOrBlank()) return null
+ return "/emby/Items/$id/Images/Primary?tag=$primaryImageTag&maxWidth=400&quality=80"
+ }
+}
+
+data class EmbyPerson(
+ @SerializedName("Id") val id: String = "",
+ @SerializedName("Name") val name: String = "",
+ @SerializedName("Role") val role: String? = null,
+ @SerializedName("PrimaryImageTag") val primaryImageTag: String? = null,
+)
+
+data class EmbyNamedReference(
+ @SerializedName("Name") val name: String = "",
+)
+
+/* ---------- Detail ---------- */
+
+data class EmbyMediaStream(
+ @SerializedName("DisplayTitle") val displayTitle: String? = null,
+ @SerializedName("Type") val type: String? = null,
+ @SerializedName("Codec") val codec: String? = null,
+ @SerializedName("Language") val language: String? = null,
+ @SerializedName("Height") val height: Int? = null,
+ @SerializedName("Width") val width: Int? = null,
+)
+
+/* ---------- Playback Info ---------- */
+
+data class EmbyPlaybackInfoResponse(
+ @SerializedName("MediaSources") val mediaSources: List? = null,
+)
+
+data class EmbyMediaSource(
+ @SerializedName("Id") val id: String,
+ @SerializedName("Path") val path: String? = null,
+ @SerializedName("Container") val container: String? = null,
+ @SerializedName("SupportsDirectPlay") val supportsDirectPlay: Boolean = false,
+ @SerializedName("SupportsDirectStream") val supportsDirectStream: Boolean = false,
+ @SerializedName("SupportsTranscoding") val supportsTranscoding: Boolean = false,
+ @SerializedName("TranscodingUrl") val transcodingUrl: String? = null,
+ @SerializedName("DirectStreamUrl") val directStreamUrl: String? = null,
+)
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiService.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiService.kt
new file mode 100644
index 0000000..30fdb9d
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiService.kt
@@ -0,0 +1,50 @@
+package com.example.myapplication.data
+
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.Header
+import retrofit2.http.POST
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface EmbyApiService {
+
+ @POST("emby/Users/AuthenticateByName")
+ suspend fun authenticateByName(
+ @Header("X-Emby-Authorization") authorization: String,
+ @Body body: EmbyAuthRequest,
+ ): Response
+
+ @GET("emby/Library/MediaFolders")
+ suspend fun getMediaFolders(
+ @Header("X-Emby-Token") token: String,
+ ): Response
+
+ @GET("emby/Users/{userId}/Items")
+ suspend fun getItems(
+ @Path("userId") userId: String,
+ @Header("X-Emby-Token") token: String,
+ @Query("ParentId") parentId: String? = null,
+ @Query("SortBy") sortBy: String = "SortName",
+ @Query("SortOrder") sortOrder: String = "Ascending",
+ @Query("Recursive") recursive: Boolean = true,
+ @Query("IncludeItemTypes") includeItemTypes: String = "Movie,Series",
+ @Query("Limit") limit: Int = 50,
+ @Query("Fields") fields: String = "PrimaryImageAspectRatio,Overview,Genres,ProductionYear",
+ ): Response
+
+ @GET("emby/Items/{itemId}/PlaybackInfo")
+ suspend fun getPlaybackInfo(
+ @Path("itemId") itemId: String,
+ @Query("UserId") userId: String,
+ @Header("X-Emby-Token") token: String,
+ ): Response
+
+ @POST("emby/Items/{itemId}/PlaybackInfo")
+ suspend fun postPlaybackInfo(
+ @Path("itemId") itemId: String,
+ @Query("UserId") userId: String,
+ @Header("X-Emby-Token") token: String,
+ ): Response
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
new file mode 100644
index 0000000..d633697
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
@@ -0,0 +1,450 @@
+package com.example.myapplication.data
+
+import android.util.Log
+import com.google.gson.Gson
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+
+data class EmbyAuthResult(
+ val success: Boolean,
+ val accessToken: String = "",
+ val userId: String = "",
+ val userName: String = "",
+ val errorMessage: String? = null,
+)
+
+data class EmbyLibraryPage(
+ val items: List = emptyList(),
+ val totalCount: Int = 0,
+)
+
+object EmbyRepository {
+
+ private const val CLIENT_NAME = "Luma Player"
+ private const val APP_VERSION = "1.0"
+ private val deviceId: String = UUID.randomUUID().toString()
+
+ private val gson = Gson()
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(5, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .writeTimeout(15, TimeUnit.SECONDS)
+ .build()
+
+ /**
+ * Authenticate with an Emby server using username + password.
+ * Returns [EmbyAuthResult] containing the access token and user id on success.
+ */
+ suspend fun authenticate(server: EmbyServer): EmbyAuthResult = withContext(Dispatchers.IO) {
+ val authUrl = "${server.fullUrl}/emby/Users/AuthenticateByName"
+ Log.d("EmbyAuth", "authenticate url=$authUrl")
+ try {
+ val url = "${server.fullUrl}/emby/Users/AuthenticateByName"
+ val authHeader = "MediaBrowser Client=\"$CLIENT_NAME\", " +
+ "Device=\"Android\", DeviceId=\"$deviceId\", Version=\"$APP_VERSION\""
+
+ val body = gson.toJson(EmbyAuthRequest(
+ Username = server.username,
+ Pw = server.password,
+ )).toRequestBody("application/json".toMediaType())
+
+ val request = Request.Builder()
+ .url(url)
+ .header("X-Emby-Authorization", authHeader)
+ .post(body)
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ return@use EmbyAuthResult(
+ success = false,
+ errorMessage = when (response.code) {
+ 401 -> "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF"
+ else -> "\u8FDE\u63A5\u5931\u8D25 (HTTP ${response.code})"
+ }
+ )
+ }
+ val responseBody = response.body?.string() ?: return@use EmbyAuthResult(
+ success = false, errorMessage = "\u670D\u52A1\u5668\u8FD4\u56DE\u6570\u636E\u4E3A\u7A7A"
+ )
+ val authResponse = gson.fromJson(responseBody, EmbyAuthResponse::class.java)
+ val token = authResponse?.accessToken
+ val userId = authResponse?.user?.id
+
+ if (token.isNullOrBlank()) {
+ return@use EmbyAuthResult(success = false, errorMessage = "\u8BA4\u8BC1\u5931\u8D25")
+ }
+
+ EmbyAuthResult(
+ success = true,
+ accessToken = token,
+ userId = userId ?: "",
+ userName = authResponse.user?.name ?: "",
+ )
+ }
+ } catch (e: Exception) {
+ Log.e("EmbyAuth", "authenticate failed", e)
+ EmbyAuthResult(
+ success = false,
+ errorMessage = when (e) {
+ is java.net.ConnectException -> "\u65E0\u6CD5\u8FDE\u63A5\u5230\u670D\u52A1\u5668"
+ is java.net.UnknownHostException -> "\u627E\u4E0D\u5230\u670D\u52A1\u5668\u5730\u5740"
+ is java.net.SocketTimeoutException -> "\u8FDE\u63A5\u8D85\u65F6"
+ else -> "\u7F51\u7EDC\u9519\u8BEF: ${e.message ?: "\u672A\u77E5"}"
+ }
+ )
+ }
+ }
+
+ /**
+ * Get media library folders from the server.
+ */
+ suspend fun getLibraries(server: EmbyServer): List = withContext(Dispatchers.IO) {
+ try {
+ val url = "${server.fullUrl}/emby/Library/MediaFolders"
+ val request = Request.Builder()
+ .url(url)
+ .header("X-Emby-Token", server.apiKey)
+ .get()
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return@use emptyList()
+ val responseBody = response.body?.string() ?: return@use emptyList()
+ val result = gson.fromJson(responseBody, EmbyMediaFoldersResponse::class.java)
+ result?.items ?: emptyList()
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+ }
+
+ /**
+ * Get media items for a specific library.
+ */
+ suspend fun getItems(
+ server: EmbyServer,
+ libraryId: String? = null,
+ includeItemTypes: String = "Movie,Series",
+ limit: Int = 50,
+ ): List = withContext(Dispatchers.IO) {
+ try {
+ val params = mutableListOf()
+ if (libraryId != null) params.add("ParentId=$libraryId")
+ params.add("SortBy=SortName")
+ params.add("SortOrder=Ascending")
+ params.add("Recursive=true")
+ params.add("IncludeItemTypes=$includeItemTypes")
+ params.add("Limit=$limit")
+ params.add("Fields=PrimaryImageAspectRatio,Overview,Genres,ProductionYear")
+
+ val url = "${server.fullUrl}/emby/Users/${server.userId}/Items?${params.joinToString("&")}"
+ val request = Request.Builder()
+ .url(url)
+ .header("X-Emby-Token", server.apiKey)
+ .get()
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return@use emptyList()
+ val responseBody = response.body?.string() ?: return@use emptyList()
+ val result = gson.fromJson(responseBody, EmbyItemsResponse::class.java)
+ result?.items ?: emptyList()
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+ }
+
+ /**
+ * Get items for a specific library folder.
+ */
+ suspend fun getItemsForLibrary(server: EmbyServer, libraryId: String, limit: Int = 6): EmbyLibraryPage = withContext(Dispatchers.IO) {
+ try {
+ val url = "${server.fullUrl}/emby/Users/${server.userId}/Items?ParentId=$libraryId&SortBy=DateCreated&SortOrder=Descending&Limit=$limit&IncludeItemTypes=Movie,Series&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,Genres&Recursive=true"
+ val request = Request.Builder()
+ .url(url)
+ .header("X-Emby-Token", server.apiKey)
+ .get()
+ .build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return@use EmbyLibraryPage()
+ val body = response.body?.string() ?: return@use EmbyLibraryPage()
+ val result = gson.fromJson(body, EmbyItemsResponse::class.java)
+ EmbyLibraryPage(
+ items = result?.items ?: emptyList(),
+ totalCount = result?.totalRecordCount ?: 0,
+ )
+ }
+ } catch (_: Exception) { EmbyLibraryPage() }
+ }
+
+ /**
+ * Get resume items (continue watching) for the current user.
+ */
+ suspend fun getResumeItems(server: EmbyServer, limit: Int = 10): List = withContext(Dispatchers.IO) {
+ try {
+ val url = "${server.fullUrl}/emby/Users/${server.userId}/Items/Resume?Limit=$limit&Fields=ProductionYear,Genres&MediaTypes=Video"
+ val request = Request.Builder()
+ .url(url)
+ .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()
+ val result = gson.fromJson(body, EmbyItemsResponse::class.java)
+ result?.items ?: emptyList()
+ }
+ } catch (_: Exception) { emptyList() }
+ }
+
+ /**
+ * Get latest added items for the current user.
+ * Note: /Items/Latest returns a plain array, not {Items:[...]}.
+ */
+ suspend fun getLatestItems(server: EmbyServer, limit: Int = 16): List = withContext(Dispatchers.IO) {
+ try {
+ val url = "${server.fullUrl}/emby/Users/${server.userId}/Items/Latest?Limit=$limit&Fields=ProductionYear,Genres"
+ val request = Request.Builder()
+ .url(url)
+ .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()
+ val listType = com.google.gson.reflect.TypeToken.getParameterized(
+ java.util.List::class.java, EmbyItem::class.java
+ ).type
+ gson.fromJson>(body, listType) ?: emptyList()
+ }
+ } catch (_: Exception) { 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 request = Request.Builder()
+ .url(url)
+ .header("X-Emby-Token", server.apiKey)
+ .get()
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return@use null
+ val body = response.body?.string() ?: return@use null
+ gson.fromJson(body, EmbyItem::class.java)
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ /**
+ * Get seasons for a series. Prefer the dedicated Shows endpoint, then fall back to user items.
+ */
+ suspend fun getSeasons(server: EmbyServer, seriesId: String): List = withContext(Dispatchers.IO) {
+ val dedicated = try {
+ val url = "${server.fullUrl}/emby/Shows/$seriesId/Seasons?userId=${server.userId}&Fields=PrimaryImageAspectRatio,Overview"
+ val request = Request.Builder()
+ .url(url)
+ .header("X-Emby-Token", server.apiKey)
+ .get()
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) emptyList()
+ else {
+ val body = response.body?.string().orEmpty()
+ val parsed = gson.fromJson(body, EmbyItemsResponse::class.java)
+ parsed?.items.orEmpty()
+ }
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+ if (dedicated.isNotEmpty()) return@withContext dedicated
+
+ return@withContext try {
+ val url = "${server.fullUrl}/emby/Users/${server.userId}/Items" +
+ "?ParentId=$seriesId&IncludeItemTypes=Season&Recursive=true&SortBy=SortName&SortOrder=Ascending" +
+ "&Fields=PrimaryImageAspectRatio,Overview"
+ val request = Request.Builder()
+ .url(url)
+ .header("X-Emby-Token", server.apiKey)
+ .get()
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) emptyList()
+ else {
+ val body = response.body?.string().orEmpty()
+ gson.fromJson(body, EmbyItemsResponse::class.java)?.items.orEmpty()
+ }
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+ }
+
+ /**
+ * Get episodes for one season. Prefer the dedicated Shows endpoint, then fall back to user items.
+ */
+ suspend fun getEpisodes(server: EmbyServer, seriesId: String, seasonId: String): List = withContext(Dispatchers.IO) {
+ val dedicated = try {
+ val url = "${server.fullUrl}/emby/Shows/$seriesId/Episodes" +
+ "?userId=${server.userId}&seasonId=$seasonId&Fields=PrimaryImageAspectRatio,Overview,ProductionYear"
+ val request = Request.Builder()
+ .url(url)
+ .header("X-Emby-Token", server.apiKey)
+ .get()
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) emptyList()
+ else {
+ val body = response.body?.string().orEmpty()
+ gson.fromJson(body, EmbyItemsResponse::class.java)?.items.orEmpty()
+ }
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+ if (dedicated.isNotEmpty()) return@withContext dedicated
+
+ 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"
+ val request = Request.Builder()
+ .url(url)
+ .header("X-Emby-Token", server.apiKey)
+ .get()
+ .build()
+
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) emptyList()
+ else {
+ val body = response.body?.string().orEmpty()
+ gson.fromJson(body, EmbyItemsResponse::class.java)?.items.orEmpty()
+ }
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+ }
+ /**
+ * Resolve a playable URL through Emby PlaybackInfo. Prefer an explicit Emby-provided
+ * direct/transcoding URL; otherwise build the standard direct-stream endpoint.
+ */
+ suspend fun resolvePlaybackUrl(server: EmbyServer, item: EmbyItem): String? = withContext(Dispatchers.IO) {
+ try {
+ val infoUrl = "${server.fullUrl}/emby/Items/${item.id}/PlaybackInfo?UserId=${server.userId}"
+ val body = "{}".toRequestBody("application/json".toMediaType())
+ val request = Request.Builder()
+ .url(infoUrl)
+ .header("X-Emby-Token", server.apiKey)
+ .post(body)
+ .build()
+
+ val mediaSource = client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) null
+ else {
+ val responseBody = response.body?.string().orEmpty()
+ gson.fromJson(responseBody, EmbyPlaybackInfoResponse::class.java)
+ ?.mediaSources?.firstOrNull()
+ }
+ }
+
+ val transcodingUrl = mediaSource?.transcodingUrl
+ if (!transcodingUrl.isNullOrBlank()) {
+ return@withContext if (transcodingUrl.startsWith("/")) server.fullUrl + transcodingUrl else transcodingUrl
+ }
+
+ val directStreamUrl = mediaSource?.directStreamUrl
+ if (!directStreamUrl.isNullOrBlank()) {
+ return@withContext if (directStreamUrl.startsWith("/")) server.fullUrl + directStreamUrl else directStreamUrl
+ }
+
+ val sourceId = mediaSource?.id ?: item.id
+ "${server.fullUrl}/emby/Videos/${item.id}/stream" +
+ "?Static=true&MediaSourceId=$sourceId&DeviceId=$deviceId&api_key=${server.apiKey}"
+ } catch (e: Exception) {
+ Log.e("EmbyPlayback", "resolvePlaybackUrl failed", e)
+ null
+ }
+ }
+
+ 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"
+ }
+
+ fun buildEpisodeImageUrl(serverUrl: String, item: EmbyItem, maxWidth: Int = 500): String? {
+ // Many Emby episode responses expose a Primary tag but no Thumb tag. Prefer the tag
+ // that is actually present; returning a guessed URL would produce hidden 404/500s.
+ val thumbTag = item.imageTags?.get("Thumb")
+ if (!thumbTag.isNullOrBlank()) {
+ return "$serverUrl/emby/Items/${item.id}/Images/Thumb?tag=$thumbTag&maxWidth=$maxWidth&quality=78"
+ }
+ val primaryTag = item.imageTags?.get("Primary")
+ if (!primaryTag.isNullOrBlank()) {
+ return "$serverUrl/emby/Items/${item.id}/Images/Primary?tag=$primaryTag&maxWidth=$maxWidth&quality=78"
+ }
+ return item.backdropImageTags.firstOrNull()?.let { tag ->
+ "$serverUrl/emby/Items/${item.id}/Images/Backdrop/0?tag=$tag&maxWidth=$maxWidth&quality=78"
+ }
+ }
+ fun buildPersonImageUrl(serverUrl: String, person: EmbyPerson): String? {
+ if (person.primaryImageTag.isNullOrBlank()) return null
+ return "$serverUrl/emby/Items/${person.id}/Images/Primary?tag=${person.primaryImageTag}&maxWidth=180&quality=70"
+ }
+
+ /**
+ * Check if a server is online by calling the system info endpoint.
+ */
+ suspend fun checkOnline(server: EmbyServer): Boolean = withContext(Dispatchers.IO) {
+ val url = "${server.fullUrl}/emby/System/Info/Public"
+ Log.d("EmbyCheck", "checkOnline url=$url")
+ try {
+ val url = "${server.fullUrl}/emby/System/Info/Public"
+ val clientNoRedirect = OkHttpClient.Builder()
+ .connectTimeout(5, TimeUnit.SECONDS)
+ .readTimeout(5, TimeUnit.SECONDS)
+ .followRedirects(false)
+ .followSslRedirects(false)
+ .build()
+ val request = Request.Builder()
+ .url(url)
+ .header("Accept", "application/json")
+ .get()
+ .build()
+
+ clientNoRedirect.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return@use false
+ // Verify the response actually contains Emby system info,
+ // not just a random 200 from a captive portal or redirect target
+ val body = response.body?.string() ?: return@use false
+ body.contains("ProductName") || body.contains("Emby") || body.contains("ServerName")
+ }
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ fun buildImageUrl(serverUrl: String, item: EmbyItem, maxWidth: Int = 400): String? {
+ val path = item.posterUrlPath ?: return null
+ return "$serverUrl$path"
+ }
+}
+
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt
new file mode 100644
index 0000000..37a669d
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt
@@ -0,0 +1,75 @@
+package com.example.myapplication.data
+
+import androidx.annotation.StringRes
+import com.example.myapplication.R
+import java.util.UUID
+
+data class EmbyLibrary(
+ val id: String,
+ val name: String,
+ val type: LibraryType,
+ val enabled: Boolean = true,
+)
+
+enum class LibraryType(@StringRes val labelRes: Int) {
+ MOVIES(R.string.library_movies),
+ TV_SHOWS(R.string.library_tv_shows),
+ MUSIC(R.string.library_music),
+ BOOKS(R.string.library_books),
+ HOME_VIDEOS(R.string.library_home_videos),
+ PHOTOS(R.string.library_photos),
+}
+
+data class EmbyServer(
+ val id: String,
+ val name: String = "",
+ val protocol: String = "https",
+ val host: String = "",
+ val port: String = "",
+ val basePath: String = "",
+ val username: String = "",
+ val password: String = "",
+ val apiKey: String = "",
+ val userId: String = "",
+ val note: String = "",
+ val libraries: List = emptyList(),
+ val isEnabled: Boolean = true,
+ val isOnline: Boolean = true,
+ val lastActive: String = "",
+ val iconUrl: String = "",
+ val authError: String = "",
+) {
+ val fullUrl: String
+ get() {
+ // Strip protocol prefix from host if user already included it
+ var cleanHost = host.trim()
+ var effectiveProtocol = protocol
+ if (cleanHost.startsWith("https://")) {
+ effectiveProtocol = "https"
+ cleanHost = cleanHost.removePrefix("https://")
+ } else if (cleanHost.startsWith("http://")) {
+ effectiveProtocol = "http"
+ cleanHost = cleanHost.removePrefix("http://")
+ }
+ // Strip trailing slashes and port if embedded in host
+ cleanHost = cleanHost.trimEnd('/')
+ if (port.isBlank()) {
+ // Check if host already contains a port like "example.com:8096"
+ val portMatch = Regex("^(.+):(\\d+)$").find(cleanHost)
+ if (portMatch != null) {
+ cleanHost = portMatch.groupValues[1]
+ }
+ }
+ val base = if (port.isNotBlank()) "$effectiveProtocol://$cleanHost:$port" else "$effectiveProtocol://$cleanHost"
+ val segments = listOf(base.trimEnd('/'), basePath.trim().trim('/')).filter { it.isNotBlank() }
+ return segments.joinToString("/")
+ }
+
+ val displayHost: String
+ get() = if (port.isNotBlank()) "$host:$port" else host
+
+ companion object {
+ fun generateId(): String = "server_${UUID.randomUUID()}"
+ }
+}
+
diff --git a/app/src/main/kotlin/com/example/myapplication/data/IconLibrary.kt b/app/src/main/kotlin/com/example/myapplication/data/IconLibrary.kt
new file mode 100644
index 0000000..19e36b7
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/data/IconLibrary.kt
@@ -0,0 +1,377 @@
+package com.example.myapplication.data
+
+import android.content.Context
+import com.example.myapplication.R
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.net.URL
+
+data class IconItem(
+ val name: String,
+ val url: String,
+)
+
+data class IconLibrary(
+ val id: String,
+ val name: String,
+ val sourceUrl: String,
+ val iconCount: Int = 0,
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val isDefault: Boolean = false,
+ val icons: List = emptyList(),
+)
+
+object IconLibraryRepository {
+
+ private val _libraries = MutableStateFlow>(emptyList())
+ val libraries: StateFlow> = _libraries.asStateFlow()
+ private var database: AppDatabase? = null
+ private var appContext: Context? = null
+ private var initialized = false
+ private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var cacheDir: File? = null
+ private val cachedIcons = mutableMapOf>()
+
+ fun init(context: Context) {
+ if (initialized) return
+ initialized = true
+ appContext = context.applicationContext
+ database = AppDatabase.getInstance(context)
+ cacheDir = File(context.filesDir, "icons_cache").apply { if (!exists()) mkdirs() }
+ repositoryScope.launch {
+ migrateLegacyPreferences(context)
+ loadIconCaches()
+ reload()
+ }
+ }
+
+ suspend fun addLibrary(library: IconLibrary) {
+ save(library)
+ setRuntimeIcons(library.id, library.icons)
+ }
+
+ suspend fun removeLibrary(id: String) {
+ withContext(Dispatchers.IO) {
+ database?.iconLibraryDao()?.deleteLibrary(id)
+ }
+ deleteIconCache(id)
+ cachedIcons.remove(id)
+ reload()
+ }
+
+ suspend fun updateLibrary(library: IconLibrary) {
+ val existing = _libraries.value.find { it.id == library.id }
+ val urlChanged = existing != null && existing.sourceUrl != library.sourceUrl
+ if (urlChanged) {
+ setRuntimeIcons(library.id, emptyList())
+ deleteIconCache(library.id)
+ cachedIcons.remove(library.id)
+ }
+ save(library)
+ if (!urlChanged && library.icons.isNotEmpty()) {
+ setRuntimeIcons(library.id, library.icons)
+ }
+ }
+
+ fun getDefaultLibrary(): IconLibrary? {
+ for (library in _libraries.value) {
+ if (library.isDefault && library.iconCount > 0) {
+ return library
+ }
+ }
+ return null
+ }
+
+ suspend fun getPickerIcons(): List {
+ var current = _libraries.value
+ if (current.isEmpty()) {
+ current = reload()
+ }
+
+ var defaultLibrary: IconLibrary? = null
+ val fallbackIcons = mutableListOf()
+
+ for (library in current) {
+ if (library.isDefault && defaultLibrary == null) {
+ defaultLibrary = library
+ }
+ fallbackIcons.addAll(library.icons)
+ }
+
+ if (defaultLibrary != null && defaultLibrary.icons.isNotEmpty()) {
+ return defaultLibrary.icons
+ }
+
+ if (defaultLibrary != null && defaultLibrary.sourceUrl.isNotBlank()) {
+ val result = fetchIcons(defaultLibrary.sourceUrl)
+ if (result.isSuccess) {
+ val icons = result.getOrDefault(emptyList())
+ setRuntimeIcons(defaultLibrary.id, icons)
+ return icons
+ }
+ }
+
+ return fallbackIcons
+ }
+
+ fun setLibraryStatus(id: String, isLoading: Boolean? = null, error: String? = null) {
+ val updated = mutableListOf()
+ for (library in _libraries.value) {
+ if (library.id == id) {
+ updated.add(
+ library.copy(
+ isLoading = isLoading ?: library.isLoading,
+ error = error,
+ )
+ )
+ } else {
+ updated.add(library)
+ }
+ }
+ _libraries.value = updated
+ }
+
+ fun setRuntimeIcons(id: String, icons: List) {
+ val updated = mutableListOf()
+ for (library in _libraries.value) {
+ updated.add(
+ if (library.id == id) {
+ library.copy(icons = icons)
+ } else {
+ library
+ }
+ )
+ }
+ _libraries.value = updated
+ }
+
+ suspend fun fetchIcons(url: String): Result> = withContext(Dispatchers.IO) {
+ try {
+ val text = URL(url).readText()
+ val items = parseIcons(text)
+ if (items.isEmpty()) {
+ Result.failure(Exception(appContext?.getString(R.string.icon_error_no_icons) ?: "No icons found"))
+ } else {
+ Result.success(items)
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun refreshLibrary(libraryId: String): Result {
+ val library = _libraries.value.find { it.id == libraryId }
+ ?: return Result.failure(Exception(appContext?.getString(R.string.icon_error_library_not_found) ?: "Icon library not found"))
+ if (library.sourceUrl.isBlank()) return Result.failure(Exception(appContext?.getString(R.string.icon_error_url_empty) ?: "Icon library URL is empty"))
+
+ setLibraryStatus(libraryId, isLoading = true, error = null)
+ val result = fetchIcons(library.sourceUrl)
+ result.fold(
+ onSuccess = { icons ->
+ setRuntimeIcons(libraryId, icons)
+ cachedIcons[libraryId] = icons
+ saveIconsToCache(libraryId, icons)
+ withContext(Dispatchers.IO) {
+ database?.iconLibraryDao()?.updateIconCount(libraryId, icons.size)
+ }
+ reload()
+ setLibraryStatus(libraryId, isLoading = false, error = null)
+ },
+ onFailure = { error ->
+ setLibraryStatus(
+ libraryId,
+ isLoading = false,
+ error = error.message ?: appContext?.getString(R.string.icon_error_load_failed) ?: "Failed to load icons",
+ )
+ },
+ )
+ return result.map { }
+ }
+
+
+ fun getIconCacheSize(): Long {
+ val dir = cacheDir ?: return 0L
+ return dir.listFiles { file -> file.extension == "json" }?.sumOf { it.length() } ?: 0L
+ }
+
+ fun clearAllIconCache() {
+ val dir = cacheDir ?: return
+ dir.listFiles()?.forEach { it.delete() }
+ cachedIcons.clear()
+ }
+ private suspend fun loadIconCaches() = withContext(Dispatchers.IO) {
+ val dir = cacheDir ?: return@withContext
+ val files = dir.listFiles { file -> file.extension == "json" } ?: return@withContext
+ for (file in files) {
+ try {
+ val libraryId = file.nameWithoutExtension
+ val json = file.readText()
+ val items = mutableListOf()
+ val array = JSONArray(json)
+ for (index in 0 until array.length()) {
+ val value = array.optJSONObject(index) ?: continue
+ items.add(
+ IconItem(
+ name = value.optString("name"),
+ url = value.optString("url"),
+ )
+ )
+ }
+ if (items.isNotEmpty()) {
+ cachedIcons[libraryId] = items
+ }
+ } catch (_: Exception) {
+ }
+ }
+ }
+
+ private fun saveIconsToCache(libraryId: String, icons: List) {
+ val dir = cacheDir ?: return
+ try {
+ val array = JSONArray()
+ for (icon in icons) {
+ array.put(JSONObject().apply {
+ put("name", icon.name)
+ put("url", icon.url)
+ })
+ }
+ File(dir, "$libraryId.json").writeText(array.toString())
+ } catch (_: Exception) {
+ }
+ }
+
+ private fun deleteIconCache(libraryId: String) {
+ val dir = cacheDir ?: return
+ try {
+ File(dir, "$libraryId.json").delete()
+ } catch (_: Exception) {
+ }
+ }
+ private suspend fun save(library: IconLibrary) {
+ withContext(Dispatchers.IO) {
+ database?.iconLibraryDao()?.saveLibrary(
+ IconLibraryEntity(
+ library.id,
+ library.name,
+ library.sourceUrl,
+ library.isDefault,
+ library.iconCount,
+ )
+ )
+ }
+ reload()
+ }
+
+ private suspend fun reload(): List {
+ val entities = withContext(Dispatchers.IO) {
+ database?.iconLibraryDao()?.getLibraries().orEmpty()
+ }
+ val previousIcons = mutableMapOf>()
+ for (lib in _libraries.value) {
+ previousIcons[lib.id] = lib.icons
+ }
+ for ((id, icons) in cachedIcons) {
+ if (!previousIcons.containsKey(id) || previousIcons[id].orEmpty().isEmpty()) {
+ previousIcons[id] = icons
+ }
+ }
+ val result = mutableListOf()
+ for (entity in entities) {
+ result.add(
+ IconLibrary(
+ id = entity.id,
+ name = entity.name,
+ sourceUrl = entity.sourceUrl,
+ iconCount = entity.iconCount,
+ isDefault = entity.isDefault,
+ icons = previousIcons[entity.id].orEmpty(),
+ )
+ )
+ }
+ _libraries.value = result
+ return result
+ }
+
+ private suspend fun migrateLegacyPreferences(context: Context) {
+ val prefs = context.getSharedPreferences("icon_libraries", Context.MODE_PRIVATE)
+ val json = prefs.getString("libraries", null) ?: return
+
+ try {
+ val array = JSONArray(json)
+ for (index in 0 until array.length()) {
+ val value = array.getJSONObject(index)
+ val iconsArray = value.optJSONArray("icons") ?: JSONArray()
+ save(
+ IconLibrary(
+ id = value.getString("id"),
+ name = value.getString("name"),
+ sourceUrl = value.getString("sourceUrl"),
+ iconCount = iconsArray.length(),
+ isDefault = value.optBoolean("isDefault", false),
+ )
+ )
+ }
+ prefs.edit().clear().apply()
+ } catch (_: Exception) {
+ }
+ }
+
+ private fun parseIcons(json: String): List {
+ val items = mutableListOf()
+ try {
+ val trimmed = json.trim()
+ if (trimmed.startsWith("[")) {
+ parseJsonArray(items, JSONArray(trimmed))
+ } else if (trimmed.startsWith("{")) {
+ val objectValue = JSONObject(trimmed)
+ for (key in listOf("icons", "data", "list", "items", "results")) {
+ if (objectValue.has(key)) {
+ parseJsonArray(items, objectValue.getJSONArray(key))
+ if (items.isNotEmpty()) break
+ }
+ }
+ }
+ } catch (_: Exception) {
+ for (line in json.lines()) {
+ val value = line.trim().removeSurrounding("\"").removeSurrounding(",")
+ if (value.startsWith("http") && value.contains("/")) {
+ items.add(IconItem(name = extractNameFromUrl(value), url = value))
+ }
+ }
+ }
+ return items
+ }
+
+ private fun parseJsonArray(items: MutableList, array: JSONArray) {
+ for (index in 0 until array.length()) {
+ val value = array.optJSONObject(index) ?: continue
+ val url = value.optString("icon")
+ .ifBlank { value.optString("url") }
+ .ifBlank { value.optString("image") }
+ .ifBlank { value.optString("img") }
+ .ifBlank { value.optString("icon_url") }
+ if (url.isBlank()) continue
+
+ val name = value.optString("name")
+ .ifBlank { value.optString("title") }
+ .ifBlank { extractNameFromUrl(url) }
+ items.add(IconItem(name = name, url = url))
+ }
+ }
+
+ private fun extractNameFromUrl(url: String): String {
+ val path = url.substringAfterLast("/").substringBeforeLast(".")
+ return path.take(30).ifBlank { "icon" }
+ }
+}
+
diff --git a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt
new file mode 100644
index 0000000..fc976e2
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt
@@ -0,0 +1,204 @@
+package com.example.myapplication.data
+
+import android.content.Context
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.json.JSONArray
+
+object ServerRepository {
+
+ private val _servers = MutableStateFlow>(emptyList())
+ val servers: StateFlow> = _servers.asStateFlow()
+
+ private var database: AppDatabase? = null
+ private var initialized = false
+ private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ fun init(context: Context) {
+ if (initialized) return
+ initialized = true
+ database = AppDatabase.getInstance(context)
+ repositoryScope.launch {
+ migrateLegacyPreferences(context)
+ removeLegacySampleServers()
+ reload()
+ }
+ }
+
+ suspend fun addServer(server: EmbyServer) {
+ withContext(Dispatchers.IO) {
+ database?.serverDao()?.insertServer(toEntity(server))
+ }
+ reload()
+ }
+
+ suspend fun updateServer(server: EmbyServer) {
+ withContext(Dispatchers.IO) {
+ database?.serverDao()?.insertServer(toEntity(server))
+ }
+ reload()
+ }
+
+ suspend fun deleteServer(serverId: String) {
+ withContext(Dispatchers.IO) {
+ database?.serverDao()?.deleteServer(serverId)
+ }
+ reload()
+ }
+
+ fun setRuntimeIcon(serverId: String, iconUrl: String) {
+ // Update memory immediately for responsive UI...
+ val updated = mutableListOf()
+ var changed: EmbyServer? = null
+ for (server in _servers.value) {
+ if (server.id == serverId) {
+ changed = server.copy(iconUrl = iconUrl)
+ updated.add(changed)
+ } else {
+ updated.add(server)
+ }
+ }
+ _servers.value = updated
+ // ...and persist to Room so the icon survives app restarts
+ changed?.let { srv ->
+ repositoryScope.launch {
+ database?.serverDao()?.insertServer(toEntity(srv))
+ }
+ }
+ }
+
+ fun updateServerOnlineStatus(serverId: String, isOnline: Boolean) {
+ _servers.value = _servers.value.map { server ->
+ if (server.id == serverId) server.copy(isOnline = isOnline) else server
+ }
+ }
+
+ suspend fun checkAllServersOnline() {
+ val currentServers = _servers.value.toList()
+ for (server in currentServers) {
+ // First verify server is reachable
+ val reachable = EmbyRepository.checkOnline(server)
+ if (!reachable) {
+ updateServerStatus(server.id, false, "无法连接到服务器")
+ continue
+ }
+ // Then try to authenticate to verify credentials
+ val authResult = EmbyRepository.authenticate(server)
+ if (authResult.success) {
+ // Update token if it's different from stored one
+ if (server.apiKey != authResult.accessToken) {
+ val updated = server.copy(
+ apiKey = authResult.accessToken,
+ userId = authResult.userId,
+ )
+ updateServer(updated)
+ }
+ updateServerStatus(server.id, true, "")
+ } else {
+ updateServerStatus(server.id, false, authResult.errorMessage ?: "登录失败")
+ }
+ }
+ }
+
+ fun updateServerStatus(serverId: String, isOnline: Boolean, error: String) {
+ _servers.value = _servers.value.map { server ->
+ if (server.id == serverId) server.copy(isOnline = isOnline, authError = error) else server
+ }
+ }
+
+ private suspend fun reload(): List {
+ val previousServers = _servers.value.toList()
+ val entities = withContext(Dispatchers.IO) {
+ database?.serverDao()?.getServers().orEmpty()
+ }
+ val result = mutableListOf()
+ for (entity in entities) {
+ // Preserve runtime-only fields (isOnline, lastActive) from previous state
+ val prev = previousServers.find { it.id == entity.id }
+ result.add(
+ toModel(entity).copy(
+ isOnline = prev?.isOnline ?: true,
+ lastActive = prev?.lastActive ?: "",
+ authError = prev?.authError ?: "",
+ )
+ )
+ }
+ _servers.value = result
+ return result
+ }
+
+ private suspend fun migrateLegacyPreferences(context: Context) {
+ val prefs = context.getSharedPreferences("emby_servers", Context.MODE_PRIVATE)
+ val json = prefs.getString("servers", null) ?: return
+
+ try {
+ val array = JSONArray(json)
+ for (index in 0 until array.length()) {
+ val value = array.getJSONObject(index)
+ database?.serverDao()?.insertServer(
+ ServerEntity(
+ value.getString("id"),
+ value.optString("name"),
+ value.optString("host"),
+ value.optString("basePath"),
+ value.optString("port"),
+ value.optString("username"),
+ value.optString("password"),
+ value.optString("note"),
+ value.optString("accessToken"),
+ value.optString("userId"),
+ )
+ )
+ }
+ prefs.edit().clear().apply()
+ } catch (_: Exception) {
+ }
+ }
+
+
+ private suspend fun removeLegacySampleServers() {
+ withContext(Dispatchers.IO) {
+ database?.serverDao()?.deleteLegacySampleServers()
+ }
+ }
+ private fun toEntity(server: EmbyServer): ServerEntity {
+ return ServerEntity(
+ server.id,
+ server.name,
+ server.host,
+ server.basePath,
+ server.port,
+ server.username,
+ server.password,
+ server.note,
+ server.apiKey,
+ server.userId,
+ server.iconUrl,
+ )
+ }
+
+ private fun toModel(entity: ServerEntity): EmbyServer {
+ return EmbyServer(
+ id = entity.id,
+ name = entity.name,
+ host = entity.host,
+ basePath = entity.basePath,
+ port = entity.port,
+ username = entity.username,
+ password = entity.password,
+ note = entity.note,
+ apiKey = entity.accessToken ?: "",
+ userId = entity.userId ?: "",
+ iconUrl = entity.iconUrl ?: "",
+ )
+ }
+}
+
+
+
diff --git a/app/src/main/kotlin/com/example/myapplication/model/MediaItem.kt b/app/src/main/kotlin/com/example/myapplication/model/MediaItem.kt
new file mode 100644
index 0000000..9d41dc8
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/model/MediaItem.kt
@@ -0,0 +1,38 @@
+package com.example.myapplication.model
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import com.example.myapplication.R as Res
+
+data class MediaItem(
+ val title: String,
+ val subtitle: String,
+ val posterColors: List,
+ val rating: String = "",
+)
+
+@Composable
+fun sampleMovies(): List = listOf(
+ MediaItem(stringResource(Res.string.movie_interstellar), stringResource(Res.string.genre_sci_fi_adventure), listOf(Color(0xFF6A5BEF), Color(0xFF3A2BBF)), "9.4"),
+ MediaItem(stringResource(Res.string.movie_inception), stringResource(Res.string.genre_sci_fi_action), listOf(Color(0xFF42A5F5), Color(0xFF1B5E20)), "9.3"),
+ MediaItem(stringResource(Res.string.movie_matrix), stringResource(Res.string.genre_sci_fi_action), listOf(Color(0xFF2E7D32), Color(0xFF004D40)), "9.0"),
+ MediaItem(stringResource(Res.string.movie_titanic), stringResource(Res.string.genre_romance_disaster), listOf(Color(0xFFE53935), Color(0xFF6A1B9A)), "9.4"),
+ MediaItem(stringResource(Res.string.movie_avatar), stringResource(Res.string.genre_sci_fi_adventure), listOf(Color(0xFF00897B), Color(0xFF01579B)), "8.7"),
+ MediaItem(stringResource(Res.string.movie_dune), stringResource(Res.string.genre_sci_fi_adventure), listOf(Color(0xFFE65100), Color(0xFFBF360C)), "8.8"),
+ MediaItem(stringResource(Res.string.movie_tenet), stringResource(Res.string.genre_sci_fi_mystery), listOf(Color(0xFF37474F), Color(0xFF263238)), "8.6"),
+ MediaItem(stringResource(Res.string.movie_batman), stringResource(Res.string.genre_action_crime), listOf(Color(0xFF212121), Color(0xFF311B92)), "8.5"),
+ MediaItem(stringResource(Res.string.movie_blade_runner), stringResource(Res.string.genre_sci_fi_mystery), listOf(Color(0xFFEF6C00), Color(0xFF4E342E)), "8.4"),
+ MediaItem(stringResource(Res.string.movie_arrival), stringResource(Res.string.genre_sci_fi_drama), listOf(Color(0xFF5C6BC0), Color(0xFF1A237E)), "8.3"),
+ MediaItem(stringResource(Res.string.movie_martian), stringResource(Res.string.genre_sci_fi_adventure), listOf(Color(0xFFC62828), Color(0xFFE65100)), "8.4"),
+ MediaItem(stringResource(Res.string.movie_gravity), stringResource(Res.string.genre_sci_fi_thriller), listOf(Color(0xFF0D47A1), Color(0xFF000000)), "7.7"),
+)
+
+@Composable
+fun sampleTvShows(): List = listOf(
+ MediaItem(stringResource(Res.string.tv_black_mirror), stringResource(Res.string.genre_sci_fi_drama), listOf(Color(0xFF424242), Color(0xFF212121)), "9.0"),
+ MediaItem(stringResource(Res.string.tv_game_of_thrones), stringResource(Res.string.genre_fantasy_adventure), listOf(Color(0xFF1B5E20), Color(0xFFBF360C)), "9.3"),
+ MediaItem(stringResource(Res.string.tv_breaking_bad), stringResource(Res.string.genre_crime_drama), listOf(Color(0xFFF9A825), Color(0xFF1B5E20)), "9.5"),
+ MediaItem(stringResource(Res.string.tv_stranger_things), stringResource(Res.string.genre_sci_fi_horror), listOf(Color(0xFFB71C1C), Color(0xFF000000)), "8.7"),
+ MediaItem(stringResource(Res.string.tv_mandalorian), stringResource(Res.string.genre_sci_fi_adventure), listOf(Color(0xFF455A64), Color(0xFFE65100)), "8.8"),
+)
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
new file mode 100644
index 0000000..5edf789
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
@@ -0,0 +1,813 @@
+package com.example.myapplication.screens
+
+import androidx.compose.animation.animateColorAsState
+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.interaction.MutableInteractionSource
+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.Row
+import androidx.compose.foundation.layout.Spacer
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.outlined.MenuBook
+import androidx.compose.material.icons.outlined.Movie
+import androidx.compose.material.icons.outlined.MusicNote
+import androidx.compose.material.icons.outlined.Person
+import androidx.compose.material.icons.outlined.PhotoLibrary
+import androidx.compose.material.icons.outlined.QueueMusic
+import androidx.compose.material.icons.outlined.Storage
+import androidx.compose.material.icons.outlined.Tv
+import androidx.compose.material.icons.outlined.VideoLibrary
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+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.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
+import coil.imageLoader
+import coil.request.ImageRequest
+import android.graphics.drawable.BitmapDrawable
+import androidx.palette.graphics.Palette
+import androidx.compose.ui.graphics.toArgb
+import kotlin.random.Random
+import com.example.myapplication.R as Res
+import com.example.myapplication.data.EmbyItem
+import com.example.myapplication.data.EmbyLibraryPage
+import com.example.myapplication.data.EmbyMediaFolder
+import com.example.myapplication.data.EmbyRepository
+import com.example.myapplication.data.EmbyServer
+import com.example.myapplication.ui.GlassTokens
+import com.example.myapplication.ui.PosterImmersivePalette
+import com.example.myapplication.ui.rememberPosterImmersiveBackground
+import com.example.myapplication.ui.LiquidGlassTopBar
+import com.example.myapplication.ui.bottomBarContentPadding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.pager.rememberPagerState
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.delay
+import top.yukonga.miuix.kmp.basic.Text
+import androidx.activity.compose.BackHandler
+
+/* ---- Adaptive content colors for the immersive home background ---- */
+val LocalHomeContentColor = staticCompositionLocalOf { Color.Unspecified }
+val LocalHomeSecondaryColor = staticCompositionLocalOf { Color.Unspecified }
+val LocalHomeAccentColor = staticCompositionLocalOf { Color.Unspecified }
+
+@Composable
+private fun homeContentColor(): Color {
+ val value = LocalHomeContentColor.current
+ return if (value == Color.Unspecified) GlassTokens.text else value
+}
+
+@Composable
+private fun homeSecondaryColor(): Color {
+ val value = LocalHomeSecondaryColor.current
+ return if (value == Color.Unspecified) GlassTokens.subtext else value
+}
+
+@Composable
+private fun homeAccentColor(): Color {
+ val value = LocalHomeAccentColor.current
+ return if (value == Color.Unspecified) GlassTokens.accent else value
+}
+
+/* ---- Per-library item holder ---- */
+data class LibrarySection(val folder: EmbyMediaFolder, val items: List)
+
+/* ---- Shared home UI state (top bar shows selected library) ---- */
+object HomeUiState {
+ var selectedLibrary by mutableStateOf(null)
+ var serverName by mutableStateOf("")
+ var immersiveSurfaceIsDark by mutableStateOf(false)
+}
+
+/* ---- Top Bar ---- */
+@Composable
+fun HomeTopBar(modifier: Modifier = Modifier) {
+ LiquidGlassTopBar(modifier) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ Modifier.size(38.dp).clip(CircleShape)
+ .background(Brush.linearGradient(listOf(Color(0xFF6EA8FF), Color(0xFF9B6BFF)))),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(imageVector = Icons.Outlined.Person, contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp))
+ }
+ Column(Modifier.padding(start = 10.dp)) {
+ Text(text = stringResource(Res.string.home_media_center), color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+}
+
+/* ---- Hero Poster-Wall Carousel ---- */
+@Composable
+private fun HeroCarousel(
+ items: List,
+ serverUrl: String,
+ pagerState: PagerState,
+ immersiveBackground: PosterImmersivePalette,
+ onItemClick: (EmbyItem) -> Unit = {},
+) {
+
+ LaunchedEffect(pagerState, items.size) {
+ while (true) {
+ delay(5000)
+ if (items.isEmpty()) break
+ val next = (pagerState.currentPage + 1) % items.size
+ pagerState.animateScrollToPage(next)
+ }
+ }
+
+ Column {
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier.fillMaxWidth(),
+ ) { page ->
+ FeaturedItemBanner(
+ item = items[page],
+ serverUrl = serverUrl,
+ immersive = immersiveBackground,
+ onClick = { onItemClick(items[page]) },
+ )
+ }
+ Spacer(Modifier.height(10.dp))
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ for (i in items.indices) {
+ val selected = i == pagerState.currentPage
+ Box(
+ Modifier
+ .padding(horizontal = 3.dp)
+ .size(width = if (selected) 18.dp else 6.dp, height = 6.dp)
+ .clip(RoundedCornerShape(3.dp))
+ .background(if (selected) homeAccentColor() else homeSecondaryColor().copy(alpha = 0.38f)),
+ )
+ }
+ }
+ }
+}
+
+private fun heroImageUrl(item: EmbyItem?, serverUrl: String): String? {
+ val imagePath = item?.posterUrlPath
+ ?: item?.backdropImageTags?.firstOrNull()?.let { tag ->
+ "/emby/Items/${item.id}/Images/Backdrop/0?tag=$tag&maxWidth=800&quality=70"
+ }
+ return if (imagePath == null || serverUrl.isBlank()) null else serverUrl + imagePath
+}
+
+/* ---- Featured Item Banner (large hero) ---- */
+@Composable
+private fun FeaturedItemBanner(
+ item: EmbyItem,
+ serverUrl: String,
+ modifier: Modifier = Modifier,
+ immersive: PosterImmersivePalette,
+ onClick: () -> Unit = {},
+) {
+ // Vertical poster preferred; fall back to backdrop if missing
+ val posterPath = item.posterUrlPath
+ ?: item.backdropImageTags.firstOrNull()?.let { tag ->
+ "/emby/Items/${item.id}/Images/Backdrop/0?tag=$tag&maxWidth=800&quality=70"
+ }
+ val imageUrl = if (posterPath != null) "$serverUrl$posterPath" else null
+
+ Box(
+ modifier.fillMaxWidth().aspectRatio(0.84f)
+ .clickable(onClick = onClick)
+ .background(Brush.verticalGradient(listOf(Color(0xFF263238), Color(0xFF37474F)))),
+ ) {
+ if (imageUrl != null) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(),
+ contentDescription = item.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ // Status-bar/top protection, matching the detail page’s TMDB-style hero.
+ Box(Modifier.fillMaxSize().background(
+ Brush.verticalGradient(
+ 0f to Color.Black.copy(alpha = 0.30f),
+ 0.13f to Color.Black.copy(alpha = 0.12f),
+ 0.26f to Color.Transparent,
+ )
+ ))
+
+ // Light side vignette keeps bright artwork from washing out the edges.
+ Box(Modifier.fillMaxSize().background(
+ Brush.horizontalGradient(
+ 0f to Color.Black.copy(alpha = 0.14f),
+ 0.26f to Color.Transparent,
+ 0.74f to Color.Transparent,
+ 1f to Color.Black.copy(alpha = 0.14f),
+ )
+ ))
+
+ // Long bottom ramp into the same poster-derived ambient palette as DetailHero.
+ Box(Modifier.fillMaxSize().background(
+ Brush.verticalGradient(
+ 0f to Color.Transparent,
+ 0.34f to immersive.hero.copy(alpha = 0.03f),
+ 0.50f to immersive.hero.copy(alpha = 0.10f),
+ 0.63f to immersive.hero.copy(alpha = 0.23f),
+ 0.73f to immersive.hero.copy(alpha = 0.41f),
+ 0.82f to immersive.hero.copy(alpha = 0.60f),
+ 0.90f to immersive.hero.copy(alpha = 0.78f),
+ 0.96f to immersive.hero.copy(alpha = 0.92f),
+ 1f to immersive.hero,
+ )
+ ))
+
+ Column(Modifier.align(Alignment.BottomStart).padding(start = 18.dp, end = 18.dp, bottom = 18.dp)) {
+ // Rating + Year + Type row
+ 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 typeLabel = if (item.type == "Series") "剧集" else "电影"
+ Text(text = typeLabel, color = Color.White.copy(alpha = 0.95f), fontSize = 12.sp)
+ }
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = item.name,
+ color = Color.White,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = LocalTextStyle.current.copy(
+ shadow = Shadow(color = Color.Black.copy(alpha = 0.60f), offset = Offset(0f, 2f), blurRadius = 10f),
+ ),
+ )
+ Spacer(Modifier.height(4.dp))
+ val genres = item.genres.take(3).joinToString(" / ")
+ if (genres.isNotBlank()) {
+ Text(text = genres, color = Color.White.copy(alpha = 0.88f), fontSize = 12.sp)
+ }
+ }
+ }
+}
+
+/* ---- Media Poster Card ---- */
+@Composable
+private fun MediaPoster(
+ item: EmbyItem,
+ serverUrl: String,
+ modifier: Modifier = Modifier,
+ onItemClick: (EmbyItem) -> Unit = {},
+) {
+ val posterUrl = EmbyRepository.buildImageUrl(serverUrl, item)
+ val fallbackColors = listOf(Color(0xFF455A64), Color(0xFF607D8B))
+
+ Column(modifier.width(115.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Box(
+ Modifier.fillMaxWidth().height(155.dp).clip(RoundedCornerShape(8.dp))
+ .clickable { onItemClick(item) }
+ .background(Brush.verticalGradient(fallbackColors)),
+ ) {
+ if (posterUrl != null) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current).data(posterUrl).crossfade(true).build(),
+ contentDescription = item.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ } else {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(text = "\u6682\u65E0\u5C01\u9762", color = Color.White.copy(alpha = 0.5f), fontSize = 11.sp)
+ }
+ }
+ }
+ Text(
+ text = item.seriesName ?: item.name,
+ color = homeContentColor(),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+/* ---- Continue Watching Card (landscape backdrop) ---- */
+@Composable
+private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClick: (EmbyItem) -> Unit = {}) {
+ val backdropTag = item.backdropImageTags.firstOrNull()
+ val imageUrl = backdropTag?.let {
+ "$serverUrl/emby/Items/${item.id}/Images/Backdrop/0?tag=$it&maxWidth=700&quality=75"
+ } ?: item.posterUrlPath?.let { "$serverUrl$it" }
+ val fallbackColors = listOf(Color(0xFF37474F), Color(0xFF546E7A))
+
+ Column(Modifier.width(216.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
+ Box(
+ Modifier.fillMaxWidth().aspectRatio(16f / 9f)
+ .clip(RoundedCornerShape(12.dp))
+ .clickable { onItemClick(item) }
+ .background(Brush.linearGradient(fallbackColors)),
+ ) {
+ if (imageUrl != null) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(),
+ contentDescription = item.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ } else {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(text = "暂无横版封面", color = Color.White.copy(alpha = 0.52f), fontSize = 11.sp)
+ }
+ }
+ Box(
+ Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.16f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Filled.PlayArrow,
+ contentDescription = null,
+ tint = Color.White.copy(alpha = 0.82f),
+ modifier = Modifier.size(30.dp),
+ )
+ }
+ }
+ Text(
+ text = item.seriesName ?: item.name,
+ color = homeContentColor(),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (item.seriesName != null && item.name != item.seriesName) {
+ Text(
+ text = item.name,
+ color = homeSecondaryColor(),
+ fontSize = 10.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+/* ---- Library section whose items load only when it becomes visible ---- */
+@Composable
+private fun LazyLibrarySection(folder: EmbyMediaFolder, activeServer: EmbyServer, onItemClick: (EmbyItem) -> Unit = {}) {
+ var page by remember(folder.id) { mutableStateOf(null) }
+
+ LaunchedEffect(folder.id) {
+ page = try {
+ EmbyRepository.getItemsForLibrary(activeServer, folder.id, limit = 8)
+ } catch (_: Exception) {
+ EmbyLibraryPage()
+ }
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SectionTitle(title = folder.name, count = page?.totalCount ?: 0)
+ val list = page?.items
+ if (list == null) {
+ // skeleton placeholder while loading
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ repeat(3) {
+ Box(
+ Modifier.width(115.dp).height(155.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(homeSecondaryColor().copy(alpha = 0.10f))
+ )
+ }
+ }
+ } else if (list.isNotEmpty()) {
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(list) { item ->
+ MediaPoster(item = item, serverUrl = activeServer.fullUrl, onItemClick = onItemClick)
+ }
+ }
+ }
+ }
+}
+
+/* ---- Section Header with title + count + "更多 >" ---- */
+@Composable
+private fun SectionTitle(
+ title: String,
+ count: Int = 0,
+ onMoreClick: (() -> Unit)? = null,
+) {
+ val rainbowBrush = remember(title) {
+ val random = Random(title.hashCode())
+ val basePalette = listOf(
+ Color(0xFFFF8FA3), // bright rose
+ Color(0xFFFFB570), // warm apricot
+ Color(0xFFFFE066), // vivid lemon
+ Color(0xFF7BE495), // fresh mint
+ Color(0xFF66C7FF), // clear sky
+ Color(0xFF9E8CFF), // periwinkle
+ Color(0xFFF783AC), // bright orchid
+ )
+ val startIndex = random.nextInt(basePalette.size)
+ val colors = List(basePalette.size) { index ->
+ basePalette[(startIndex + index) % basePalette.size]
+ }
+ Brush.horizontalGradient(if (random.nextBoolean()) colors else colors.reversed())
+ }
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 0.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(text = title, color = homeContentColor(), fontSize = 16.sp, fontWeight = FontWeight.Bold)
+ Text(
+ text = buildAnnotatedString {
+ if (count > 0) {
+ withStyle(
+ style = SpanStyle(
+ brush = rainbowBrush,
+ fontWeight = FontWeight.Bold,
+ )
+ ) {
+ append(count.toString())
+ }
+ append(" ")
+ }
+ withStyle(style = SpanStyle(brush = rainbowBrush, fontWeight = FontWeight.Bold)) {
+ append("\u66F4\u591A \u203A")
+ }
+ },
+ fontSize = 13.sp,
+ )
+ }
+}
+
+/* ---- Library Section Row ---- */
+@Composable
+private fun LibrarySectionRow(section: LibrarySection, serverUrl: String) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SectionTitle(title = section.folder.name, count = section.items.size)
+ if (section.items.isNotEmpty()) {
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(section.items) { item ->
+ MediaPoster(item = item, serverUrl = serverUrl)
+ }
+ }
+ } else {
+ Box(
+ Modifier.fillMaxWidth().height(155.dp).clip(RoundedCornerShape(10.dp))
+ .background(GlassTokens.subtext.copy(alpha = 0.08f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text = "\u6682\u65E0\u5185\u5BB9", color = homeSecondaryColor(), fontSize = 12.sp)
+ }
+ }
+ }
+}
+
+/* ---- My Media Row (horizontal scroll, same layout as Continue Watching) ---- */
+@Composable
+private fun MyMediaGrid(libraries: List, serverUrl: String, onSelect: (EmbyMediaFolder) -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SectionTitle(title = "我的媒体", count = libraries.size)
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(libraries) { folder ->
+ val isSelected = HomeUiState.selectedLibrary?.id == folder.id
+ Column(
+ Modifier.width(168.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ val folderImageUrl = "$serverUrl/emby/Items/${folder.id}/Images/Primary?maxWidth=400&quality=80"
+ Box(
+ Modifier.fillMaxWidth().height(104.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .border(
+ 1.dp,
+ Brush.linearGradient(
+ listOf(
+ Color.White.copy(alpha = 0.48f),
+ Color.White.copy(alpha = 0.30f),
+ Color.White.copy(alpha = 0.16f),
+ )
+ ),
+ RoundedCornerShape(12.dp),
+ )
+ .clickable { onSelect(folder) },
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current).data(folderImageUrl).crossfade(true).build(),
+ contentDescription = folder.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ Text(
+ text = folder.name,
+ color = homeContentColor(),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
+}
+/* ---- Empty State ---- */
+@Composable
+private fun EmptyHomeState() {
+ Column(Modifier.fillMaxWidth().padding(vertical = 80.dp), horizontalAlignment = Alignment.CenterHorizontally) {
+ Box(Modifier.size(64.dp).clip(CircleShape).background(homeSecondaryColor().copy(alpha = 0.16f)), contentAlignment = Alignment.Center) {
+ Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null, tint = homeSecondaryColor(), modifier = Modifier.size(32.dp))
+ }
+ Spacer(Modifier.height(16.dp))
+ Text(text = "\u6CA1\u6709\u53EF\u7528\u7684\u5A92\u4F53\u5E93", color = homeSecondaryColor(), fontSize = 15.sp)
+ Spacer(Modifier.height(6.dp))
+ Text(text = "\u8BF7\u5728\u8D44\u6E90\u5E93\u4E2D\u6DFB\u52A0\u670D\u52A1\u5668", color = homeSecondaryColor().copy(alpha = 0.72f), fontSize = 12.sp)
+ }
+}
+
+/* ================== Main HomeScreen ================== */
+@Composable
+fun HomeScreen(
+ servers: List = emptyList(),
+ selected: EmbyServer? = null,
+ onDetailVisibilityChanged: (Boolean) -> Unit = {},
+) {
+ val activeServer = remember(servers, selected) { selected ?: servers.firstOrNull { it.apiKey.isNotBlank() } }
+
+ var detailItem by remember { mutableStateOf(null) }
+ BackHandler(enabled = detailItem != null) { detailItem = null }
+ LaunchedEffect(detailItem != null) {
+ onDetailVisibilityChanged(detailItem != null)
+ }
+
+ var libraries by remember { mutableStateOf>(emptyList()) }
+ var resumeItems by remember { mutableStateOf>(emptyList()) }
+ var latestItems by remember { mutableStateOf>(emptyList()) }
+ var loading by remember { mutableStateOf(false) }
+ val heroItems = remember(latestItems) { latestItems.take(8) }
+ val heroPagerState = rememberPagerState(pageCount = { heroItems.size })
+ val activeHeroItem = heroItems.getOrNull(heroPagerState.currentPage)
+ val immersiveBackground = rememberPosterImmersiveBackground(heroImageUrl(activeHeroItem, activeServer?.fullUrl.orEmpty()))
+
+ LaunchedEffect(activeServer?.id, activeServer?.apiKey) {
+ if (activeServer == null || activeServer.apiKey.isBlank()) return@LaunchedEffect
+ loading = true
+ try {
+ // Fetch everything concurrently; commit state only when all data is ready
+ val libsDeferred = async { EmbyRepository.getLibraries(activeServer) }
+ val latestDeferred = async { EmbyRepository.getLatestItems(activeServer, limit = 10) }
+ val resumeDeferred = async { EmbyRepository.getResumeItems(activeServer, limit = 10) }
+
+ val libs = libsDeferred.await()
+ val latest = latestDeferred.await()
+ val resume = resumeDeferred.await()
+
+ HomeUiState.serverName = activeServer.name
+ if (HomeUiState.selectedLibrary == null || libs.none { it.id == HomeUiState.selectedLibrary?.id }) {
+ HomeUiState.selectedLibrary = libs.firstOrNull()
+ }
+
+ // First screen renders immediately; per-library sections load on demand when scrolled into view
+ libraries = libs
+ latestItems = latest
+ resumeItems = resume
+ } finally {
+ loading = false
+ }
+ }
+
+ val surfaceIsDark = immersiveBackground.isDark
+ val adaptiveText = if (surfaceIsDark) Color(0xFFF4F7FB) else Color(0xFF121417)
+ val adaptiveSecondary = if (surfaceIsDark) Color.White.copy(alpha = 0.74f) else Color.Black.copy(alpha = 0.64f)
+ val adaptiveAccent = if (surfaceIsDark) Color(0xFF74B6FF) else Color(0xFF0A6DD4)
+ LaunchedEffect(surfaceIsDark) {
+ HomeUiState.immersiveSurfaceIsDark = surfaceIsDark
+ }
+
+ CompositionLocalProvider(
+ LocalHomeContentColor provides adaptiveText,
+ LocalHomeSecondaryColor provides adaptiveSecondary,
+ LocalHomeAccentColor provides adaptiveAccent,
+ ) {
+ Box(Modifier.fillMaxSize().background(
+ Brush.verticalGradient(
+ 0f to immersiveBackground.pageTop,
+ 0.53f to immersiveBackground.hero,
+ 1f to immersiveBackground.pageBottom,
+ )
+ )) {
+ LazyColumn(
+ Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()),
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ ) {
+ if (activeServer == null) {
+ item { EmptyHomeState() }
+ } else if (loading) {
+ item {
+ Column(
+ Modifier.fillMaxWidth().padding(vertical = 80.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(text = "加载中...", color = homeSecondaryColor(), fontSize = 14.sp)
+ }
+ }
+ } else {
+ // 1) Hero poster-wall carousel (full-bleed under status bar)
+ if (latestItems.isNotEmpty()) {
+ item {
+ HeroCarousel(
+ items = heroItems,
+ serverUrl = activeServer.fullUrl,
+ pagerState = heroPagerState,
+ immersiveBackground = immersiveBackground,
+ onItemClick = { detailItem = it },
+ )
+ }
+ }
+
+ // 2) Continue Watching
+ if (resumeItems.isNotEmpty()) {
+ item {
+ Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp)) {
+ SectionTitle(title = "继续观看")
+ Spacer(Modifier.height(8.dp))
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(resumeItems) { item ->
+ ContinueWatchingPoster(item = item, serverUrl = activeServer.fullUrl) { detailItem = it }
+ }
+ }
+ }
+ }
+ }
+
+ // 3) My Media Grid (tap to select -> top bar updates)
+ if (libraries.isNotEmpty()) {
+ item {
+ Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp)) {
+ MyMediaGrid(libraries = libraries, serverUrl = activeServer.fullUrl, onSelect = { folder ->
+ HomeUiState.selectedLibrary = folder
+ })
+ }
+ }
+ }
+
+ // 4) Per-library poster sections
+ libraries.forEach { folder ->
+ item(key = "section-" + folder.id) {
+ Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp)) {
+ LazyLibrarySection(folder = folder, activeServer = activeServer) { detailItem = it }
+ }
+ }
+ }
+ }
+ }
+
+ // Overlay top bar: selected library name + matching icon, floats over poster
+ HomeOverlayTopBar(
+ name = activeServer?.name ?: "",
+ iconUrl = activeServer?.iconUrl ?: "",
+ modifier = Modifier.align(Alignment.TopCenter),
+ )
+
+ detailItem?.let { item ->
+ activeServer?.let { server ->
+ MediaDetailScreen(
+ item = item,
+ server = server,
+ onClose = { detailItem = null },
+ )
+ }
+ }
+ }
+ }
+}
+
+/* ---- Overlay top bar showing selected server (live name + custom icon) ---- */
+@Composable
+private fun HomeOverlayTopBar(
+ name: String,
+ iconUrl: String,
+ modifier: Modifier = Modifier,
+) {
+ val title = name.ifBlank { "个人媒体中心" }
+ Row(
+ modifier.fillMaxWidth()
+ .statusBarsPadding()
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ Modifier.widthIn(min = 168.dp)
+ .clip(RoundedCornerShape(22.dp))
+ .background(
+ Brush.verticalGradient(
+ listOf(
+ Color.White.copy(alpha = 0.42f),
+ Color.White.copy(alpha = 0.28f),
+ )
+ )
+ )
+ .border(0.8.dp, Color.White.copy(alpha = 0.60f), RoundedCornerShape(22.dp))
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (iconUrl.isNotBlank()) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current).data(iconUrl).crossfade(true).build(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(22.dp).clip(RoundedCornerShape(6.dp)),
+ )
+ } else {
+ Icon(imageVector = Icons.Outlined.Storage, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp))
+ }
+ Spacer(Modifier.width(7.dp))
+ Text(
+ text = title,
+ color = Color.White,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = LocalTextStyle.current.copy(
+ shadow = Shadow(color = Color.Black.copy(alpha = 0.45f), offset = Offset(0f, 1f), blurRadius = 6f),
+ ),
+ )
+ }
+
+
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt
new file mode 100644
index 0000000..cb2e8da
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt
@@ -0,0 +1,1510 @@
+package com.example.myapplication.screens
+
+import androidx.compose.animation.animateColorAsState
+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.interaction.MutableInteractionSource
+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.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.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.itemsIndexed
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.CircularProgressIndicator
+import com.example.myapplication.ui.GlassButton
+import com.example.myapplication.ui.GlassDialog
+import com.example.myapplication.ui.GlassInputField
+import com.example.myapplication.ui.GlassPopup
+import com.example.myapplication.ui.GlassPopupItem
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.text.style.TextAlign
+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 com.example.myapplication.R as Res
+import top.yukonga.miuix.kmp.basic.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.GridView
+import androidx.compose.material.icons.filled.ViewAgenda
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.outlined.Add
+import androidx.compose.material.icons.outlined.MoreHoriz
+import androidx.compose.material.icons.outlined.PhotoLibrary
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.Image
+import androidx.compose.material.icons.outlined.Search
+import androidx.compose.material.icons.outlined.Visibility
+import androidx.compose.material.icons.outlined.VisibilityOff
+import com.example.myapplication.data.EmbyServer
+import com.example.myapplication.data.IconItem
+import com.example.myapplication.data.IconLibrary
+import com.example.myapplication.data.IconLibraryRepository
+import com.example.myapplication.ui.GlassSurface
+import com.example.myapplication.ui.GlassTokens
+import com.example.myapplication.ui.LiquidGlassTopBar
+import com.example.myapplication.ui.bottomBarContentPadding
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+private val serverTints = listOf(
+ Color(0xFF9CC4FF), Color(0xFFA8E6C0), Color(0xFFD6B8FF), Color(0xFFFFB8D0),
+)
+private val onlineGreen = Color(0xFF34C759)
+private val offlineRed = Color(0xFFFF3B30)
+
+@Composable
+fun LibraryTopBar(
+ isGrid: Boolean,
+ onViewToggle: () -> Unit,
+ onAdd: () -> Unit,
+ onOpenIconLibraryManager: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LiquidGlassTopBar(modifier) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(text = "\u8D44\u6E90\u5E93", color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold)
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Box(Modifier.size(38.dp).clip(CircleShape).background(GlassTokens.glassFill).border(1.dp, GlassTokens.glassBorder, CircleShape).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onViewToggle() }, contentAlignment = Alignment.Center) {
+ Icon(imageVector = if (isGrid) Icons.Filled.GridView else Icons.Filled.ViewAgenda, contentDescription = "\u89C6\u56FE\u5207\u6362", tint = GlassTokens.text, modifier = Modifier.size(21.dp))
+ }
+ Box(Modifier.size(38.dp).clip(CircleShape).background(GlassTokens.glassFill).border(1.dp, GlassTokens.glassBorder, CircleShape).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onOpenIconLibraryManager() }, contentAlignment = Alignment.Center) {
+ Icon(imageVector = Icons.Outlined.PhotoLibrary, contentDescription = "\u56FE\u6807\u5E93\u7BA1\u7406", tint = GlassTokens.text, modifier = Modifier.size(21.dp))
+ }
+ Box(Modifier.size(38.dp).clip(CircleShape).background(GlassTokens.glassFill).border(1.dp, GlassTokens.glassBorder, CircleShape).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onAdd() }, contentAlignment = Alignment.Center) {
+ Icon(imageVector = Icons.Outlined.Add, contentDescription = "\u6DFB\u52A0\u670D\u52A1\u5668", tint = GlassTokens.text, modifier = Modifier.size(21.dp))
+ }
+ }
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Icon Library Section */
+/* ------------------------------------------------------------------ */
+
+@Composable
+private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit) {
+ var showPopup by remember { mutableStateOf(false) }
+ var clicked by remember { mutableStateOf(false) }
+ val pressBg by animateColorAsState(targetValue = if (clicked) Color.White.copy(alpha = 0.18f) else Color.Transparent, animationSpec = tween(durationMillis = if (clicked) 80 else 200), label = "pb")
+ LaunchedEffect(clicked) { if (clicked) { delay(150); clicked = false } }
+ GlassSurface(modifier = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { clicked = true; onClick() }, fill = tint.copy(alpha = 0.50f), cornerRadius = 20.dp, padding = PaddingValues(0.dp)) {
+ Row(modifier = Modifier.fillMaxWidth().background(pressBg).padding(14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
+ if (server.iconUrl.isNotBlank()) {
+ AsyncImage(model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(), contentDescription = null, modifier = Modifier.size(46.dp).clip(RoundedCornerShape(14.dp)), contentScale = ContentScale.Crop)
+ } else {
+ Box(Modifier.size(46.dp).clip(RoundedCornerShape(14.dp)).background(Brush.linearGradient(listOf(tint, tint.copy(alpha = 0.7f)))), contentAlignment = Alignment.Center) {
+ Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp))
+ }
+ }
+ Spacer(Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(text = server.name.ifBlank { server.displayHost }, color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Spacer(Modifier.width(7.dp))
+ Box(Modifier.size(8.dp).clip(CircleShape).background(if (server.isOnline) onlineGreen else offlineRed))
+ }
+ if (!server.isOnline && server.authError.isNotBlank()) {
+ Text(text = server.authError, color = offlineRed, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ } else {
+ Text(text = if (server.isOnline) server.lastActive.ifEmpty { "\u5728\u7EBF" } else "\u79BB\u7EBF", color = GlassTokens.subtext, fontSize = 12.sp)
+ }
+ if (server.note.isNotBlank()) {
+ Text(
+ text = server.note,
+ color = GlassTokens.subtext,
+ fontSize = 11.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+ }
+ }
+ Box {
+ Icon(imageVector = Icons.Outlined.MoreHoriz, contentDescription = "\u64CD\u4F5C", tint = GlassTokens.subtext, modifier = Modifier.size(32.dp).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { showPopup = true }.padding(4.dp))
+ GlassPopup(expanded = showPopup, onDismissRequest = { showPopup = false }) { GlassPopupItem(text = "\u7F16\u8F91", icon = Icons.Outlined.Edit, onClick = { showPopup = false; onEdit() }); GlassPopupItem(text = "\u66F4\u6362\u56FE\u6807", icon = Icons.Outlined.Image, onClick = { showPopup = false; onChangeIcon() }); GlassPopupItem(text = "\u5220\u9664", icon = Icons.Outlined.Delete, tint = offlineRed, onClick = { showPopup = false; onDelete() }) }
+ }
+ }
+ }
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Icon Library Section */
+/* ------------------------------------------------------------------ */
+
+@Composable
+private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit) {
+ var showPopup by remember { mutableStateOf(false) }
+ var clicked by remember { mutableStateOf(false) }
+ val pressBg by animateColorAsState(targetValue = if (clicked) Color.White.copy(alpha = 0.18f) else Color.Transparent, animationSpec = tween(durationMillis = if (clicked) 80 else 200), label = "pb")
+ LaunchedEffect(clicked) { if (clicked) { delay(150); clicked = false } }
+ GlassSurface(modifier = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { clicked = true; onClick() }, fill = tint.copy(alpha = 0.50f), cornerRadius = 20.dp, padding = PaddingValues(0.dp)) {
+ Column(modifier = Modifier.fillMaxWidth().background(pressBg).padding(14.dp), horizontalAlignment = Alignment.Start) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (server.iconUrl.isNotBlank()) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(),
+ contentDescription = null,
+ modifier = Modifier.size(48.dp).clip(RoundedCornerShape(14.dp)),
+ contentScale = ContentScale.Crop,
+ )
+ } else {
+ Box(
+ Modifier.size(48.dp).clip(RoundedCornerShape(14.dp)).background(Brush.linearGradient(listOf(tint, tint.copy(alpha = 0.7f)))),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp))
+ }
+ }
+ Spacer(Modifier.width(10.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = server.name.ifBlank { server.displayHost },
+ color = GlassTokens.text,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(Modifier.height(3.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(Modifier.size(7.dp).clip(CircleShape).background(if (server.isOnline) onlineGreen else offlineRed))
+ Spacer(Modifier.width(4.dp))
+ if (!server.isOnline && server.authError.isNotBlank()) {
+ Text(text = server.authError, color = offlineRed, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ } else {
+ Text(text = if (server.isOnline) server.lastActive.ifEmpty { "\u5728\u7EBF" } else "\u79BB\u7EBF", color = GlassTokens.subtext, fontSize = 11.sp)
+ }
+ }
+ }
+ Box {
+ Icon(imageVector = Icons.Outlined.MoreHoriz, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(28.dp).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { showPopup = true }.padding(4.dp))
+ GlassPopup(expanded = showPopup, onDismissRequest = { showPopup = false }) { GlassPopupItem(text = "\u7F16\u8F91", icon = Icons.Outlined.Edit, onClick = { showPopup = false; onEdit() }); GlassPopupItem(text = "\u66F4\u6362\u56FE\u6807", icon = Icons.Outlined.Image, onClick = { showPopup = false; onChangeIcon() }); GlassPopupItem(text = "\u5220\u9664", icon = Icons.Outlined.Delete, tint = offlineRed, onClick = { showPopup = false; onDelete() }) }
+ }
+ }
+ if (server.note.isNotBlank()) {
+ Text(
+ text = server.note,
+ color = GlassTokens.subtext,
+ fontSize = 11.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+ }
+ }
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Icon Library Section */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun LibraryScreen(
+ servers: List,
+ selectedServer: EmbyServer? = null,
+ onSelectServer: (String) -> Unit = {},
+ onAddServer: (EmbyServer, (String?) -> Unit) -> Unit = { _, _ -> },
+ onDeleteServer: (String) -> Unit = {},
+ onUpdateServer: (EmbyServer, (String?) -> Unit) -> Unit = { _, _ -> },
+ showAddDialog: Boolean = false,
+ onDismissAddDialog: () -> Unit = {},
+ isGrid: Boolean = false,
+ onChangeIconTarget: EmbyServer? = null,
+ onDismissChangeIcon: () -> Unit = {},
+ onPickIcon: (EmbyServer, String) -> Unit = { _, _ -> },
+ onRequestChangeIcon: (EmbyServer) -> Unit = {},
+) {
+ var editingServer by remember { mutableStateOf(null) }
+
+ if (showAddDialog) {
+ AddServerDialog(onDismiss = onDismissAddDialog, onConfirm = { srv, onResult ->
+ onAddServer(srv, onResult)
+ })
+ }
+ if (editingServer != null) {
+ AddServerDialog(
+ initial = editingServer,
+ onDismiss = { editingServer = null },
+ onConfirm = { updated, onResult ->
+ onUpdateServer(updated, onResult)
+ },
+ )
+ }
+ if (onChangeIconTarget != null) {
+ IconPickerDialog(
+ server = onChangeIconTarget,
+ onDismiss = onDismissChangeIcon,
+ onPick = { srv, url -> onPickIcon(srv, url); onDismissChangeIcon() },
+ )
+ }
+ LazyVerticalGrid(
+ columns = if (isGrid) GridCells.Fixed(2) else GridCells.Fixed(1),
+ modifier = Modifier.fillMaxSize().padding(horizontal = if (isGrid) 16.dp else 0.dp),
+ contentPadding = PaddingValues(top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
+ verticalArrangement = Arrangement.spacedBy(if (isGrid) 12.dp else 10.dp),
+ horizontalArrangement = Arrangement.spacedBy(if (isGrid) 12.dp else 0.dp),
+ ) {
+ // Servers section
+ items(servers.size) { index ->
+ val server = servers[index]
+ val tint = serverTints[index % serverTints.size]
+ if (isGrid) {
+ ServerGridCard(
+ server = server, tint = tint,
+ onClick = { onSelectServer(server.id) },
+ onChangeIcon = { onRequestChangeIcon(server) },
+ onDelete = { onDeleteServer(server.id) },
+ onEdit = { editingServer = server },
+ )
+ } else {
+ ServerCard(
+ server = server, tint = tint,
+ onClick = { onSelectServer(server.id) },
+ onChangeIcon = { onRequestChangeIcon(server) },
+ onDelete = { onDeleteServer(server.id) },
+ onEdit = { editingServer = server },
+ )
+ }
+ }
+ }
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Icon Library Section */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun IconLibraryManagerScreen(
+ libraries: List,
+ modifier: Modifier = Modifier,
+ onBack: () -> Unit = {},
+ onAddIconLibrary: (String, String, List, Boolean) -> Unit = { _, _, _, _ -> },
+ onUpdateIconLibrary: (IconLibrary) -> Unit = { _ -> },
+ onDeleteIconLibrary: (String) -> Unit = {},
+ onRefreshIconLibrary: (String) -> Unit = {},
+) {
+ var showAddDialog by remember { mutableStateOf(false) }
+ var editingLibraryId by remember { mutableStateOf(null) }
+
+
+ val editingLibrary = editingLibraryId?.let { id -> libraries.find { it.id == id } }
+
+ Column(modifier.fillMaxSize()) {
+ LiquidGlassTopBar {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(Modifier.width(86.dp)) {
+ Row(
+ Modifier.clip(RoundedCornerShape(20.dp))
+ .background(GlassTokens.accent.copy(alpha = 0.15f))
+ .border(1.dp, GlassTokens.accent.copy(alpha = 0.38f), RoundedCornerShape(20.dp))
+ .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { showAddDialog = true }
+ .padding(horizontal = 10.dp, vertical = 7.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(3.dp),
+ ) {
+ Icon(Icons.Outlined.Add, contentDescription = null, tint = GlassTokens.accent, modifier = Modifier.size(17.dp))
+ Text("\u6DFB\u52A0", color = GlassTokens.accent, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
+ }
+ }
+ Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
+ Text("\u56FE\u6807\u5E93\u7BA1\u7406", color = GlassTokens.text, fontSize = 19.sp, fontWeight = FontWeight.Bold)
+ }
+ Box(Modifier.width(86.dp), contentAlignment = Alignment.CenterEnd) {
+ Box(
+ Modifier.size(34.dp)
+ .clip(CircleShape)
+ .background(Color.White.copy(alpha = 0.42f))
+ .border(1.dp, Color.White.copy(alpha = 0.55f), CircleShape)
+ .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onBack() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Close, contentDescription = "\u8FD4\u56DE", tint = GlassTokens.text, modifier = Modifier.size(19.dp))
+ }
+ }
+ }
+ }
+
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(2),
+ modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
+ contentPadding = PaddingValues(top = 16.dp, bottom = 28.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (libraries.isEmpty()) {
+ item(span = { GridItemSpan(2) }) {
+ Box(
+ Modifier.fillMaxWidth()
+ .clip(RoundedCornerShape(22.dp))
+ .background(Color.White.copy(alpha = 0.45f))
+ .border(0.5.dp, Color.White.copy(alpha = 0.58f), RoundedCornerShape(22.dp))
+ .padding(vertical = 54.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Icon(Icons.Outlined.Image, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(32.dp))
+ Text("\u8FD8\u6CA1\u6709\u56FE\u6807\u5E93", color = GlassTokens.subtext, fontSize = 15.sp)
+ Text("\u70B9\u51FB\u5DE6\u4E0A\u89D2\u6DFB\u52A0", color = GlassTokens.subtext.copy(alpha = 0.75f), fontSize = 12.sp)
+ }
+ }
+ }
+ } else {
+ items(libraries) { library ->
+ IconLibraryManagerCard(library = library, onClick = { editingLibraryId = library.id })
+ }
+ }
+ }
+ }
+
+ if (showAddDialog) {
+ AddIconLibraryDialog(
+ onDismiss = { showAddDialog = false },
+ onSave = { name, url, icons, isDefault ->
+ onAddIconLibrary(name, url, icons, isDefault)
+ showAddDialog = false
+ },
+ )
+ }
+
+ if (editingLibrary != null) {
+ ManageIconLibraryDialog(
+ library = editingLibrary,
+ onDismiss = { editingLibraryId = null },
+ onUpdate = { updated ->
+ onUpdateIconLibrary(updated)
+ editingLibraryId = null
+ },
+ onDelete = {
+ onDeleteIconLibrary(editingLibrary.id)
+ editingLibraryId = null
+ },
+ onRefresh = { onRefreshIconLibrary(editingLibrary.id) },
+ )
+ }
+}
+
+@Composable
+private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) {
+ Box(
+ Modifier.fillMaxWidth()
+ .clip(RoundedCornerShape(20.dp))
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = 0.74f),
+ 1f to Color.White.copy(alpha = 0.50f),
+ )
+ )
+ .border(0.5.dp, Color.White.copy(alpha = 0.62f), RoundedCornerShape(20.dp))
+ .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
+ .padding(14.dp),
+ ) {
+ Column {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Box(
+ Modifier.size(38.dp).clip(RoundedCornerShape(12.dp))
+ .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp))
+ }
+ Column(Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
+ Text(library.name, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ if (library.isDefault) {
+ Box(
+ Modifier.clip(RoundedCornerShape(50))
+ .background(GlassTokens.accent.copy(alpha = 0.14f))
+ .border(0.5.dp, GlassTokens.accent.copy(alpha = 0.35f), RoundedCornerShape(50))
+ .padding(horizontal = 6.dp, vertical = 2.dp)
+ ) {
+ Text(stringResource(Res.string.icon_default_badge), color = GlassTokens.accent, fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+ Text("${library.iconCount} \u4E2A\u56FE\u6807", color = GlassTokens.subtext, fontSize = 11.sp)
+ }
+ }
+
+ Spacer(Modifier.height(11.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy((-5).dp), verticalAlignment = Alignment.CenterVertically) {
+ library.icons.take(4).forEach { icon ->
+ Box(
+ Modifier.size(34.dp).clip(RoundedCornerShape(9.dp))
+ .background(Color.White.copy(alpha = 0.55f))
+ .border(0.5.dp, Color.White.copy(alpha = 0.60f), RoundedCornerShape(9.dp)),
+ contentAlignment = Alignment.Center,
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
+ contentDescription = icon.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(6.dp)),
+ )
+ }
+ }
+ if (library.iconCount > 0 && library.icons.isEmpty()) {
+ Box(
+ Modifier.weight(1f).height(34.dp).clip(RoundedCornerShape(9.dp))
+ .background(Color.White.copy(alpha = 0.30f)),
+ contentAlignment = Alignment.Center,
+ ) { Text("\u56fe\u6807\u672a\u52a0\u8f7d", color = GlassTokens.subtext, fontSize = 11.sp) }
+ } else if (library.icons.isEmpty()) {
+ Box(
+ Modifier.weight(1f).height(34.dp).clip(RoundedCornerShape(9.dp))
+ .background(Color.White.copy(alpha = 0.30f)),
+ contentAlignment = Alignment.Center,
+ ) { Text("\u6682\u65E0\u56FE\u6807", color = GlassTokens.subtext, fontSize = 11.sp) }
+ } else if (library.iconCount > 4) {
+ Box(
+ Modifier.size(34.dp).clip(RoundedCornerShape(9.dp))
+ .background(GlassTokens.accent.copy(alpha = 0.12f)),
+ contentAlignment = Alignment.Center,
+ ) { Text("+${library.iconCount - 4}", color = GlassTokens.accent, fontSize = 10.sp, fontWeight = FontWeight.Bold) }
+ }
+ }
+
+ Spacer(Modifier.height(11.dp))
+ Box(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
+ .background(Color.White.copy(alpha = 0.35f))
+ .border(0.5.dp, Color.White.copy(alpha = 0.48f), RoundedCornerShape(12.dp))
+ .padding(vertical = 7.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("\u9884\u89C8 \u00B7 \u4FEE\u6539", color = GlassTokens.text, fontSize = 12.sp, fontWeight = FontWeight.Medium)
+ }
+ }
+ }
+}
+
+@Composable
+private fun IconLibrarySection(
+ libraries: List,
+ isGrid: Boolean,
+ onClick: (IconLibrary) -> Unit,
+ onManage: (IconLibrary) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("\u56FE\u6807\u5E93", fontSize = 15.sp, fontWeight = FontWeight.Bold, color = GlassTokens.text)
+ Text("${libraries.size} \u4E2A", fontSize = 13.sp, color = GlassTokens.subtext)
+ }
+ if (isGrid) {
+ // Grid layout for icon libraries — use Column+Row to avoid nested scrolling
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ libraries.chunked(2).forEach { row ->
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ row.forEach { lib ->
+ IconLibraryCard(
+ library = lib,
+ modifier = Modifier.weight(1f),
+ onClick = { onClick(lib) },
+ onLongClick = { onManage(lib) },
+ )
+ }
+ // Fill remaining space if odd number
+ if (row.size < 2) {
+ Spacer(Modifier.weight(1f))
+ }
+ }
+ }
+ }
+ } else {
+ // List layout for icon libraries — use Row to avoid nested scrolling
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ libraries.forEach { lib ->
+ IconLibraryCard(
+ library = lib,
+ modifier = Modifier.width(140.dp),
+ onClick = { onClick(lib) },
+ onLongClick = { onManage(lib) },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun IconLibraryCard(
+ library: IconLibrary,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+) {
+ Box(
+ modifier = modifier
+ .clip(RoundedCornerShape(18.dp))
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = 0.72f),
+ 1f to Color.White.copy(alpha = 0.48f),
+ )
+ )
+ .border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(18.dp))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onClick,
+ )
+ .padding(12.dp),
+ ) {
+ Column {
+ // Icon preview row
+ Row(
+ horizontalArrangement = Arrangement.spacedBy((-6).dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ library.icons.take(3).forEach { icon ->
+ Box(
+ Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))
+ .background(Brush.verticalGradient(listOf(Color(0xFFFF6B9D).copy(alpha = 0.3f), Color(0xFFFF8A65).copy(alpha = 0.3f))))
+ .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(8.dp)),
+ contentAlignment = Alignment.Center,
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
+ contentDescription = icon.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(24.dp).clip(RoundedCornerShape(6.dp)),
+ )
+ }
+ }
+ if (library.iconCount > 3) {
+ Box(
+ Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))
+ .background(Brush.verticalGradient(listOf(Color(0xFF9B9B9B).copy(alpha = 0.2f), Color(0xFF9B9B9B).copy(alpha = 0.1f))))
+ .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(8.dp)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("+${library.iconCount - 3}", fontSize = 10.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
+ }
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ Text(library.name, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = GlassTokens.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text("${library.iconCount} \u4E2A\u56FE\u6807", fontSize = 11.sp, color = GlassTokens.subtext)
+ Spacer(Modifier.height(6.dp))
+ // Manage button
+ Box(
+ modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))
+ .background(Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = 0.12f), GlassTokens.accent.copy(alpha = 0.06f))))
+ .border(0.5.dp, GlassTokens.accent.copy(alpha = 0.2f), RoundedCornerShape(10.dp))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onLongClick,
+ )
+ .padding(vertical = 6.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("\u7BA1\u7406", fontSize = 12.sp, fontWeight = FontWeight.Medium, color = GlassTokens.accent)
+ }
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Icon Library Detail Dialog — browse icons */
+/* ------------------------------------------------------------------ */
+
+@Composable
+private fun IconLibraryDetailDialog(library: IconLibrary, onDismiss: () -> Unit) {
+ Dialog(onDismissRequest = onDismiss) {
+ Box(
+ Modifier
+ .width(340.dp)
+ .shadow(24.dp, RoundedCornerShape(28.dp), ambientColor = Color.White.copy(alpha = 0.18f), spotColor = Color.Black.copy(alpha = 0.05f))
+ .clip(RoundedCornerShape(28.dp))
+ .background(
+ Brush.verticalGradient(0f to Color.White.copy(alpha = 0.88f), 0.5f to Color.White.copy(alpha = 0.75f), 1f to Color.White.copy(alpha = 0.65f))
+ )
+ .border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(28.dp))
+ .padding(20.dp)
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ // Header
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))
+ .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
+ }
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(library.name, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text)
+ Text("${library.iconCount} \u4E2A\u56FE\u6807", fontSize = 12.sp, color = GlassTokens.subtext)
+ }
+ }
+
+ if (library.iconCount > 0 && library.icons.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
+ .padding(vertical = 36.dp),
+ contentAlignment = Alignment.Center,
+ ) { Text("\u56fe\u6807\u672a\u52a0\u8f7d", color = GlassTokens.subtext, fontSize = 14.sp) }
+ } else if (library.icons.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
+ .padding(vertical = 36.dp),
+ contentAlignment = Alignment.Center,
+ ) { Text("\u6682\u65E0\u56FE\u6807", color = GlassTokens.subtext, fontSize = 14.sp) }
+ } else {
+ Box(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
+ .padding(8.dp)
+ ) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(5),
+ modifier = Modifier.heightIn(min = 120.dp, max = 400.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ items(library.icons) { icon ->
+ Box(
+ modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(10.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.55f), 1f to Color.White.copy(alpha = 0.35f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
+ .padding(4.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
+ contentDescription = icon.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Close button
+ Box(
+ modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.45f), 1f to Color.White.copy(alpha = 0.25f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
+ .clickable { onDismiss() }
+ .padding(vertical = 14.dp),
+ contentAlignment = Alignment.Center,
+ ) { Text("\u5173\u95ED", color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) }
+ }
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Manage Icon Library Dialog — rename, refresh, delete */
+/* ------------------------------------------------------------------ */
+
+@Composable
+private fun ManageIconLibraryDialog(
+ library: IconLibrary,
+ onDismiss: () -> Unit,
+ onUpdate: (IconLibrary) -> Unit,
+ onDelete: () -> Unit,
+ onRefresh: () -> Unit,
+) {
+ var editName by remember { mutableStateOf(TextFieldValue(library.name)) }
+ var editUrl by remember { mutableStateOf(TextFieldValue(library.sourceUrl)) }
+ var setAsDefault by remember { mutableStateOf(library.isDefault) }
+ var isRefreshing by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+
+ GlassDialog(onDismissRequest = onDismiss) {
+ Column(
+ modifier = Modifier.width(300.dp)
+ .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.78f).dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ // Header
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))
+ .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
+ }
+ Spacer(Modifier.width(10.dp))
+ Text(text = "\u7BA1\u7406\u56FE\u6807\u5E93", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text)
+ }
+
+ // Info
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Column {
+ Text("\u56FE\u6807\u6570\u91CF", fontSize = 12.sp, color = GlassTokens.subtext)
+ Text("${library.iconCount}", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = GlassTokens.text)
+ }
+ }
+
+ // Editable library metadata
+ GlassInputField(label = "\u56FE\u6807\u5E93\u540D\u79F0", value = editName, placeholder = "\u56FE\u6807\u5E93\u540D\u79F0", onValueChange = { editName = it })
+ GlassInputField(label = "\u56FE\u6807\u5E93\u5730\u5740 (JSON)", value = editUrl, placeholder = "https://...", onValueChange = { editUrl = it })
+
+ // Default library switch
+ Row(
+ Modifier.fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(if (setAsDefault) GlassTokens.accent.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.35f))
+ .border(0.5.dp, if (setAsDefault) GlassTokens.accent.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp))
+ .clickable { setAsDefault = !setAsDefault }
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(Res.string.set_default_library), color = GlassTokens.text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text(stringResource(Res.string.set_default_library_sub), color = GlassTokens.subtext, fontSize = 11.sp)
+ }
+ Box(
+ Modifier.size(width = 42.dp, height = 25.dp)
+ .clip(CircleShape)
+ .background(if (setAsDefault) GlassTokens.accent else Color.White.copy(alpha = 0.65f))
+ .border(0.5.dp, if (setAsDefault) Color.Transparent else Color.White.copy(alpha = 0.75f), CircleShape)
+ .padding(3.dp),
+ contentAlignment = if (setAsDefault) Alignment.CenterEnd else Alignment.CenterStart,
+ ) {
+ Box(Modifier.size(19.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.92f)))
+ }
+ }
+
+ // Icon preview
+ if (library.icons.isNotEmpty()) {
+ Text("\u56FE\u6807\u9884\u89C8", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(6),
+ modifier = Modifier.heightIn(min = 80.dp, max = 180.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ items(library.icons) { icon ->
+ Box(
+ modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(8.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.55f), 1f to Color.White.copy(alpha = 0.35f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
+ .padding(2.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
+ contentDescription = icon.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(6.dp)),
+ )
+ }
+ }
+ }
+ }
+
+ // Refresh button
+ Box(
+ modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.50f), 1f to Color.White.copy(alpha = 0.30f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp))
+ .clickable(enabled = !isRefreshing) {
+ isRefreshing = true
+ scope.launch {
+ onRefresh()
+ delay(500)
+ isRefreshing = false
+ }
+ }
+ .padding(vertical = 12.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (isRefreshing) {
+ CircularProgressIndicator(modifier = Modifier.size(18.dp), color = GlassTokens.accent, strokeWidth = 2.dp)
+ } else {
+ Text("\u5237\u65B0\u56FE\u6807", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = GlassTokens.accent)
+ }
+ }
+
+ // Save / Delete row
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ GlassButton(
+ text = "\u5220\u9664",
+ onClick = onDelete,
+ modifier = Modifier.weight(1f),
+ danger = true,
+ )
+ GlassButton(
+ text = "\u4FDD\u5B58",
+ onClick = {
+ if (editName.text.isNotBlank() && editUrl.text.isNotBlank()) {
+ onUpdate(library.copy(name = editName.text.trim(), sourceUrl = editUrl.text.trim(), isDefault = setAsDefault))
+ }
+ },
+ modifier = Modifier.weight(1f),
+ accent = true,
+ enabled = editName.text.isNotBlank() && editUrl.text.isNotBlank(),
+ )
+ }
+ }
+ }
+}
+@Composable
+private fun AddIconLibraryDialog(
+ onDismiss: () -> Unit,
+ onSave: (String, String, List, Boolean) -> Unit = { _, _, _, _ -> }
+) {
+ var name by remember { mutableStateOf(TextFieldValue("")) }
+ var url by remember { mutableStateOf(TextFieldValue("")) }
+ var isDefault by remember { mutableStateOf(false) }
+ var icons by remember { mutableStateOf>(emptyList()) }
+ var isLoading by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(null) }
+ var fetched by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+
+ Dialog(onDismissRequest = onDismiss) {
+ Box(
+ Modifier
+ .width(320.dp)
+ .shadow(24.dp, RoundedCornerShape(28.dp), ambientColor = Color.White.copy(alpha = 0.18f), spotColor = Color.Black.copy(alpha = 0.05f))
+ .clip(RoundedCornerShape(28.dp))
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = 0.88f),
+ 0.5f to Color.White.copy(alpha = 0.75f),
+ 1f to Color.White.copy(alpha = 0.65f),
+ )
+ )
+ .border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(28.dp))
+ .padding(20.dp)
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))
+ .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
+ }
+ Spacer(Modifier.width(12.dp))
+ Text(text = "\u589E\u52A0\u56FE\u6807\u5E93", fontWeight = FontWeight.Bold, fontSize = 19.sp, color = GlassTokens.text)
+ }
+
+ Text("\u56FE\u6807\u5E93\u540D\u79F0", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
+ Box(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.50f), 1f to Color.White.copy(alpha = 0.30f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp))
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ ) {
+ androidx.compose.foundation.text.BasicTextField(
+ value = name,
+ onValueChange = { name = it },
+ singleLine = true,
+ textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 16.sp),
+ modifier = Modifier.fillMaxWidth(),
+ cursorBrush = androidx.compose.ui.graphics.SolidColor(GlassTokens.accent),
+ decorationBox = { inner ->
+ Box(Modifier.fillMaxWidth().height(24.dp)) {
+ if (name.text.isEmpty()) Text("\u4F8B\u5982: Emby \u56FE\u6807\u5E93", color = GlassTokens.subtext, fontSize = 16.sp)
+ inner()
+ }
+ }
+ )
+ }
+
+ // Default library switch
+ Row(
+ Modifier.fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(if (isDefault) GlassTokens.accent.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.35f))
+ .border(0.5.dp, if (isDefault) GlassTokens.accent.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp))
+ .clickable { isDefault = !isDefault }
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(Res.string.set_default_library), color = GlassTokens.text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text(stringResource(Res.string.set_default_library_sub), color = GlassTokens.subtext, fontSize = 11.sp)
+ }
+ Box(
+ Modifier.size(width = 42.dp, height = 25.dp)
+ .clip(CircleShape)
+ .background(if (isDefault) GlassTokens.accent else Color.White.copy(alpha = 0.65f))
+ .border(0.5.dp, if (isDefault) Color.Transparent else Color.White.copy(alpha = 0.75f), CircleShape)
+ .padding(3.dp),
+ contentAlignment = if (isDefault) Alignment.CenterEnd else Alignment.CenterStart,
+ ) {
+ Box(Modifier.size(19.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.92f)))
+ }
+ }
+
+ Text("\u56FE\u6807\u5E93\u5730\u5740 (JSON)", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
+ Box(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.50f), 1f to Color.White.copy(alpha = 0.30f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp))
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ ) {
+ androidx.compose.foundation.text.BasicTextField(
+ value = url,
+ onValueChange = { url = it; fetched = false; error = null; icons = emptyList() },
+ singleLine = true,
+ textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 16.sp),
+ modifier = Modifier.fillMaxWidth(),
+ cursorBrush = androidx.compose.ui.graphics.SolidColor(GlassTokens.accent),
+ decorationBox = { inner ->
+ Box(Modifier.fillMaxWidth().height(24.dp)) {
+ if (url.text.isEmpty()) Text("https://...", color = GlassTokens.subtext, fontSize = 16.sp)
+ inner()
+ }
+ }
+ )
+ }
+
+ Box(
+ modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
+ .background(
+ if (url.text.isNotBlank() && !isLoading)
+ Brush.verticalGradient(listOf(GlassTokens.accent, Color(0xFF0A6DD4)))
+ else
+ Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = 0.4f), GlassTokens.accent.copy(alpha = 0.3f)))
+ )
+ .then(if (url.text.isNotBlank() && !isLoading) Modifier.border(0.5.dp, Color.White.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) else Modifier)
+ .clickable(enabled = url.text.isNotBlank() && !isLoading) {
+ scope.launch {
+ isLoading = true; error = null
+ val result = IconLibraryRepository.fetchIcons(url.text)
+ result.fold(
+ onSuccess = { items -> icons = items; fetched = true },
+ onFailure = { e -> error = e.message ?: "\u83B7\u53D6\u5931\u8D25" }
+ )
+ isLoading = false
+ }
+ }
+ .padding(vertical = 14.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(modifier = Modifier.size(22.dp), color = Color.White, strokeWidth = 2.5.dp)
+ } else {
+ Text("\u83B7\u53D6\u9884\u89C8", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
+ }
+ }
+
+ if (error != null) Text(text = error!!, color = offlineRed, fontSize = 13.sp)
+
+ if (icons.isNotEmpty()) {
+ Text("\u9884\u89C8 (${icons.size} \u4E2A\u56FE\u6807)", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
+ Box(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
+ .padding(8.dp)
+ ) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(5),
+ modifier = Modifier.heightIn(min = 80.dp, max = 320.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ items(icons) { icon ->
+ Box(
+ modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(12.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.55f), 1f to Color.White.copy(alpha = 0.35f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(12.dp)),
+ contentAlignment = Alignment.Center,
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
+ contentDescription = icon.name,
+ modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)),
+ contentScale = ContentScale.Crop,
+ )
+ }
+ }
+ }
+ }
+ Text("\u2713 \u5DF2\u52A0\u8F7D ${icons.size} \u4E2A\u56FE\u6807", fontSize = 12.sp, color = onlineGreen)
+ }
+
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Box(
+ modifier = Modifier.weight(1f).clip(RoundedCornerShape(16.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.45f), 1f to Color.White.copy(alpha = 0.25f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
+ .clickable { onDismiss() }
+ .padding(vertical = 14.dp),
+ contentAlignment = Alignment.Center,
+ ) { Text("\u53D6\u6D88", color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) }
+ Box(
+ modifier = Modifier.weight(1f).clip(RoundedCornerShape(16.dp))
+ .background(
+ if (icons.isNotEmpty() && name.text.isNotBlank())
+ Brush.verticalGradient(listOf(GlassTokens.accent, Color(0xFF0A6DD4)))
+ else
+ Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = 0.4f), GlassTokens.accent.copy(alpha = 0.3f)))
+ )
+ .then(if (icons.isNotEmpty() && name.text.isNotBlank()) Modifier.border(0.5.dp, Color.White.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) else Modifier)
+ .clickable(enabled = icons.isNotEmpty() && name.text.isNotBlank()) { onSave(name.text.trim(), url.text.trim(), icons, isDefault); onDismiss() }
+ .padding(vertical = 14.dp),
+ contentAlignment = Alignment.Center,
+ ) { Text("\u4FDD\u5B58", color = if (icons.isNotEmpty() && name.text.isNotBlank()) Color.White else Color.White.copy(alpha = 0.6f), fontSize = 15.sp, fontWeight = FontWeight.SemiBold) }
+ }
+ }
+ }
+ }
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Icon Library Section */
+/* ------------------------------------------------------------------ */
+
+@Composable
+private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: (EmbyServer, String) -> Unit) {
+ var allIcons by remember { mutableStateOf>(emptyList()) }
+ var query by remember { mutableStateOf(TextFieldValue("")) }
+ var isLoading by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(null) }
+ val scope = rememberCoroutineScope()
+ val filteredIcons = remember(allIcons, query) {
+ val keyword = query.text.trim()
+ if (keyword.isEmpty()) allIcons else allIcons.filter { icon ->
+ icon.name.contains(keyword, ignoreCase = true)
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ scope.launch {
+ isLoading = true
+ allIcons = IconLibraryRepository.getPickerIcons()
+ isLoading = false
+ }
+ }
+
+ Dialog(onDismissRequest = onDismiss) {
+ Box(
+ Modifier
+ .width(320.dp)
+ .shadow(24.dp, RoundedCornerShape(28.dp), ambientColor = Color.White.copy(alpha = 0.18f), spotColor = Color.Black.copy(alpha = 0.05f))
+ .clip(RoundedCornerShape(28.dp))
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = 0.88f),
+ 0.5f to Color.White.copy(alpha = 0.75f),
+ 1f to Color.White.copy(alpha = 0.65f),
+ )
+ )
+ .border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(28.dp))
+ .padding(20.dp)
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.80f).dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))
+ .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
+ }
+ Spacer(Modifier.width(12.dp))
+ Text(text = "\u9009\u62E9\u56FE\u6807", fontWeight = FontWeight.Bold, fontSize = 19.sp, color = GlassTokens.text)
+ }
+
+ if (!isLoading && allIcons.isNotEmpty()) {
+ Box(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp))
+ .background(Color.White.copy(alpha = 0.42f))
+ .border(0.5.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(14.dp))
+ .padding(horizontal = 12.dp, vertical = 9.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ Icons.Outlined.Search,
+ contentDescription = null,
+ tint = GlassTokens.subtext,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(Modifier.width(8.dp))
+ Box(Modifier.weight(1f)) {
+ if (query.text.isEmpty()) {
+ Text(
+ text = "\u641C\u7D22\u56FE\u6807\u540D\u79F0",
+ color = GlassTokens.subtext,
+ fontSize = 14.sp,
+ )
+ }
+ BasicTextField(
+ value = query,
+ onValueChange = { query = it },
+ singleLine = true,
+ textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 14.sp),
+ modifier = Modifier.fillMaxWidth(),
+ cursorBrush = androidx.compose.ui.graphics.SolidColor(GlassTokens.accent),
+ )
+ }
+ if (query.text.isNotEmpty()) {
+ Spacer(Modifier.width(8.dp))
+ Icon(
+ Icons.Outlined.Close,
+ contentDescription = "\u6E05\u9664\u641C\u7D22",
+ tint = GlassTokens.subtext,
+ modifier = Modifier
+ .size(18.dp)
+ .clickable { query = TextFieldValue("") },
+ )
+ }
+ }
+ }
+ }
+
+ if (isLoading) {
+ Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(modifier = Modifier.size(28.dp), color = GlassTokens.accent, strokeWidth = 2.5.dp)
+ }
+ } else if (allIcons.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxWidth().weight(1f).clip(RoundedCornerShape(20.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(20.dp))
+ .padding(vertical = 36.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Box(Modifier.size(56.dp).clip(CircleShape)
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.45f), 1f to Color.White.copy(alpha = 0.25f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.45f), CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(Icons.Outlined.Image, contentDescription = null, tint = GlassTokens.subtext2, modifier = Modifier.size(28.dp))
+ }
+ Spacer(Modifier.height(14.dp))
+ Text(text = "\u6682\u65E0\u56FE\u6807\u5E93", color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.Medium)
+ Spacer(Modifier.height(4.dp))
+ Text(text = "\u8BF7\u5148\u5728\u56FE\u6807\u5E93\u7BA1\u7406\u4E2D\u6DFB\u52A0\u56FE\u6807\u5E93", color = GlassTokens.subtext, fontSize = 13.sp)
+ }
+ }
+ } else {
+ Text("\u5DF2\u52A0\u8F7D ${allIcons.size} \u4E2A\u56FE\u6807\uFF0C\u70B9\u51FB\u9009\u62E9", fontSize = 13.sp, color = GlassTokens.subtext)
+ Box(
+ Modifier.fillMaxWidth().weight(1f).clip(RoundedCornerShape(20.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(20.dp))
+ .padding(8.dp)
+ ) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(5),
+ modifier = Modifier.fillMaxSize(),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ items(filteredIcons) { icon ->
+ val isSelected = server.iconUrl == icon.url
+ Box(
+ modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(12.dp))
+ .background(
+ if (isSelected)
+ Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = 0.2f), GlassTokens.accent.copy(alpha = 0.1f)))
+ else
+ Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.55f), Color.White.copy(alpha = 0.35f)))
+ )
+ .border(if (isSelected) 1.5.dp else 0.5.dp, if (isSelected) GlassTokens.accent else Color.White.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
+ .clickable { onPick(server, icon.url) },
+ contentAlignment = Alignment.Center,
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
+ contentDescription = icon.name,
+ modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)),
+ contentScale = ContentScale.Crop,
+ )
+ if (isSelected) {
+ Box(
+ Modifier.align(Alignment.TopEnd).padding(3.dp).size(18.dp).clip(CircleShape)
+ .background(Brush.verticalGradient(listOf(GlassTokens.accent, Color(0xFF0A6DD4))))
+ .border(0.5.dp, Color.White.copy(alpha = 0.4f), CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Filled.GridView, contentDescription = null, tint = Color.White, modifier = Modifier.size(11.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (filteredIcons.isEmpty()) {
+ Box(
+ Modifier.fillMaxWidth().height(90.dp).clip(RoundedCornerShape(20.dp))
+ .background(Color.White.copy(alpha = 0.24f))
+ .border(0.5.dp, Color.White.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "\u672A\u627E\u5230\u5339\u914D\u7684\u56FE\u6807",
+ color = GlassTokens.subtext,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
+ .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.45f), 1f to Color.White.copy(alpha = 0.25f)))
+ .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
+ .clickable { onDismiss() }
+ .padding(vertical = 14.dp),
+ contentAlignment = Alignment.Center,
+ ) { Text(text = "\u5173\u95ED", color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) }
+ }
+ }
+ }
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Icon Library Section */
+/* ------------------------------------------------------------------ */
+
+@Composable
+private fun AddServerDialog(initial: EmbyServer? = null, onDismiss: () -> Unit, onConfirm: (EmbyServer, (String?) -> Unit) -> Unit) {
+ var name by remember { mutableStateOf(TextFieldValue(initial?.name ?: "")) }
+ var host by remember { mutableStateOf(TextFieldValue(initial?.host ?: "")) }
+ var port by remember { mutableStateOf(TextFieldValue(initial?.port ?: "")) }
+ var basePath by remember { mutableStateOf(TextFieldValue(initial?.basePath ?: "")) }
+ var username by remember { mutableStateOf(TextFieldValue(initial?.username ?: "")) }
+ var password by remember { mutableStateOf(TextFieldValue(initial?.password ?: "")) }
+ var note by remember { mutableStateOf(TextFieldValue(initial?.note ?: "")) }
+ var isSaving by remember { mutableStateOf(false) }
+ var authError by remember { mutableStateOf("") }
+ var showPassword by remember { mutableStateOf(false) }
+ var showValidation by remember { mutableStateOf(false) }
+ val hostFocusRequester = remember { FocusRequester() }
+ val portFocusRequester = remember { FocusRequester() }
+ val usernameFocusRequester = remember { FocusRequester() }
+ val serverInputValid = host.text.trim().isNotEmpty() &&
+ port.text.trim().isNotEmpty() &&
+ username.text.trim().isNotEmpty()
+ val hostError = showValidation && host.text.trim().isEmpty()
+ val portError = showValidation && port.text.trim().isEmpty()
+ val usernameError = showValidation && username.text.trim().isEmpty()
+
+ GlassDialog(onDismissRequest = onDismiss) {
+ Column(
+ modifier = Modifier.width(300.dp).verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))
+ .background(Brush.linearGradient(listOf(Color(0xFF6EA8FF), Color(0xFF9B6BFF)))),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Add, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
+ }
+ Spacer(Modifier.width(10.dp))
+ Text(text = if (initial == null) "\u6DFB\u52A0\u670D\u52A1\u5668" else "\u7F16\u8F91\u670D\u52A1\u5668", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text)
+ }
+ GlassInputField(label = "\u540D\u79F0", value = name, placeholder = "\u6211\u7684 Emby", onValueChange = { name = it })
+ GlassInputField(
+ label = "\u5730\u5740",
+ value = host,
+ placeholder = "emby.example.com",
+ onValueChange = { host = it },
+ isError = hostError,
+ errorMessage = if (hostError) "\u8BF7\u8F93\u5165\u5730\u5740" else null,
+ focusRequester = hostFocusRequester,
+ )
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ GlassInputField(
+ label = "\u8DEF\u5F84",
+ value = basePath,
+ placeholder = "/emby",
+ onValueChange = { basePath = it },
+ modifier = Modifier.weight(1f),
+ )
+ GlassInputField(
+ label = "\u7AEF\u53E3",
+ value = port,
+ placeholder = "8096",
+ onValueChange = { port = it },
+ modifier = Modifier.weight(1f),
+ isError = portError,
+ errorMessage = if (portError) "\u8BF7\u8F93\u5165\u7AEF\u53E3" else null,
+ focusRequester = portFocusRequester,
+ )
+ }
+ GlassInputField(
+ label = "\u7528\u6237\u540D",
+ value = username,
+ placeholder = "\u7528\u6237\u540D",
+ onValueChange = { username = it },
+ isError = usernameError,
+ errorMessage = if (usernameError) "\u8BF7\u8F93\u5165\u7528\u6237\u540D" else null,
+ focusRequester = usernameFocusRequester,
+ )
+ GlassInputField(
+ label = "\u5BC6\u7801",
+ value = password,
+ placeholder = "\u5BC6\u7801",
+ onValueChange = { password = it },
+ visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
+ trailingContent = {
+ Icon(
+ imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
+ contentDescription = if (showPassword) "\u9690\u85CF\u5BC6\u7801" else "\u663E\u793A\u5BC6\u7801",
+ tint = GlassTokens.subtext,
+ modifier = Modifier
+ .size(24.dp)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) { showPassword = !showPassword },
+ )
+ },
+ )
+ GlassInputField(label = "\u5907\u6CE8", value = note, placeholder = "\u5907\u6CE8", onValueChange = { note = it })
+ if (authError.isNotBlank()) {
+ Text(text = authError, color = offlineRed, fontSize = 13.sp, modifier = Modifier.padding(bottom = 4.dp))
+ }
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ GlassButton(text = "\u53D6\u6D88", onClick = onDismiss, modifier = Modifier.weight(1f))
+ GlassButton(
+ text = if (isSaving) "\u4FDD\u5B58\u4E2D..." else "\u4FDD\u5B58",
+ onClick = {
+ if (isSaving) return@GlassButton
+ authError = ""
+ showValidation = true
+ when {
+ host.text.trim().isEmpty() -> runCatching { hostFocusRequester.requestFocus() }
+ port.text.trim().isEmpty() -> runCatching { portFocusRequester.requestFocus() }
+ username.text.trim().isEmpty() -> runCatching { usernameFocusRequester.requestFocus() }
+ else -> {
+ isSaving = true
+ val server = (initial ?: EmbyServer(id = EmbyServer.generateId())).copy(
+ name = name.text.trim(), host = host.text.trim(), port = port.text.trim(),
+ basePath = basePath.text.trim().trim('/'),
+ username = username.text.trim(), password = password.text.trim(), note = note.text.trim(),
+ isOnline = initial?.isOnline ?: true, lastActive = initial?.lastActive ?: "\u521A\u521A"
+ )
+ onConfirm(server) { error ->
+ isSaving = false
+ if (error != null) {
+ authError = error
+ } else {
+ onDismiss()
+ }
+ }
+ }
+ }
+ },
+ modifier = Modifier.weight(1f),
+ accent = true,
+ enabled = !isSaving,
+ )
+ }
+ }
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
new file mode 100644
index 0000000..06adba9
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt
@@ -0,0 +1,965 @@
+package com.example.myapplication.screens
+
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+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
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material3.Icon
+import androidx.compose.ui.window.Dialog
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+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.runtime.staticCompositionLocalOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.drawscope.Stroke
+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 androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
+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
+
+private val LocalDetailText = staticCompositionLocalOf { Color.White }
+private val LocalDetailSecondary = staticCompositionLocalOf { Color.White }
+private val LocalDetailSurface = staticCompositionLocalOf { Color.White.copy(alpha = 0.06f) }
+private val LocalDetailBorder = staticCompositionLocalOf { Color.White.copy(alpha = 0.10f) }
+private val LocalDetailAccent = staticCompositionLocalOf { Color(0xFFBFDBFE) }
+
+@Composable
+fun MediaDetailScreen(
+ item: EmbyItem,
+ server: EmbyServer?,
+ onClose: () -> Unit,
+) {
+ val context = LocalContext.current
+ var detail by remember(item.id) { mutableStateOf(item) }
+ var loadError by remember(item.id) { mutableStateOf(null) }
+ var refreshing by remember(item.id) { mutableStateOf(false) }
+
+ var seasons by remember(item.id) { mutableStateOf>(emptyList()) }
+ var selectedSeasonId by remember(item.id) { mutableStateOf(null) }
+ var episodes by remember(selectedSeasonId) { mutableStateOf>(emptyList()) }
+ var selectedEpisodeId by remember(item.id) { mutableStateOf(null) }
+ var showAllEpisodes by remember(item.id) { mutableStateOf(false) }
+ var playingItem by remember(item.id) { mutableStateOf(null) }
+ var playbackUrl by remember(item.id) { mutableStateOf(null) }
+ var playbackLoading by remember(item.id) { mutableStateOf(false) }
+ var playbackError by remember(item.id) { mutableStateOf(null) }
+
+ LaunchedEffect(episodes) {
+ if (selectedEpisodeId == null || episodes.none { it.id == selectedEpisodeId }) {
+ selectedEpisodeId = episodes.firstOrNull()?.id
+ }
+ }
+
+ LaunchedEffect(item.id, server?.id, server?.apiKey) {
+ if (server == null || server.apiKey.isBlank()) return@LaunchedEffect
+ refreshing = true
+ val fullItem = EmbyRepository.getItem(server, item.id)
+ if (fullItem == null) {
+ loadError = "详情加载失败"
+ } else {
+ detail = fullItem
+ loadError = null
+ }
+ refreshing = false
+
+ if (detail.type == "Series") {
+ val loadedSeasons = EmbyRepository.getSeasons(server, item.id)
+ seasons = loadedSeasons
+ selectedSeasonId = loadedSeasons.firstOrNull()?.id
+ } else {
+ seasons = emptyList()
+ selectedSeasonId = null
+ }
+ }
+
+ LaunchedEffect(server?.id, detail.type, selectedSeasonId) {
+ val currentServer = server ?: return@LaunchedEffect
+ val seriesId = item.id
+ val seasonId = selectedSeasonId ?: return@LaunchedEffect
+ if (detail.type != "Series" || seasonId.isBlank()) return@LaunchedEffect
+ episodes = EmbyRepository.getEpisodes(currentServer, seriesId, seasonId)
+ }
+
+ LaunchedEffect(playingItem?.id, server?.id, server?.apiKey) {
+ val target = playingItem
+ val currentServer = server
+ if (target == null || currentServer == null || currentServer.apiKey.isBlank()) return@LaunchedEffect
+
+ playbackLoading = true
+ playbackError = null
+ playbackUrl = try {
+ EmbyRepository.resolvePlaybackUrl(currentServer, target)
+ } catch (e: Exception) {
+ null
+ }
+ playbackLoading = false
+ if (playbackUrl.isNullOrBlank()) {
+ playbackError = "获取播放地址失败"
+ }
+ }
+
+ val seriesFallbackImageUrl = EmbyRepository.buildImageUrl(server?.fullUrl.orEmpty(), detail, maxWidth = 640)
+ ?: EmbyRepository.buildBackdropUrl(server?.fullUrl.orEmpty(), detail, maxWidth = 640)
+ val immersive = rememberDetailImmersiveBackground(detail, server?.fullUrl.orEmpty())
+ val immersiveBackground = immersive.hero
+ val lightContent = !immersive.isDark
+ val detailTextColor = if (lightContent) Color(0xFF111418) else Color.White
+ val detailSecondaryColor = if (lightContent) Color.Black.copy(alpha = 0.66f) else Color.White.copy(alpha = 0.72f)
+ val detailSurfaceColor = if (lightContent) Color.Black.copy(alpha = 0.055f) else Color.White.copy(alpha = 0.06f)
+ val detailBorderColor = if (lightContent) Color.Black.copy(alpha = 0.10f) else Color.White.copy(alpha = 0.10f)
+ val detailAccentColor = if (lightContent) Color(0xFF1D4ED8) else Color(0xFFBFDBFE)
+
+ CompositionLocalProvider(
+ LocalDetailText provides detailTextColor,
+ LocalDetailSecondary provides detailSecondaryColor,
+ LocalDetailSurface provides detailSurfaceColor,
+ LocalDetailBorder provides detailBorderColor,
+ LocalDetailAccent provides detailAccentColor,
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ 0f to immersive.pageTop,
+ 0.53f to immersive.hero,
+ 1f to immersive.pageBottom,
+ )
+ ),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ ) {
+ DetailHero(
+ item = detail,
+ serverUrl = server?.fullUrl.orEmpty(),
+ background = immersiveBackground,
+ )
+
+ Column(
+ Modifier
+ .offset(y = (-34).dp)
+ .padding(horizontal = 16.dp),
+ ) {
+ DetailHeadline(detail = detail, refreshing = refreshing, errorText = loadError)
+
+ 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
+ }
+
+ if (playTarget == null) {
+ Toast.makeText(context, "剧集尚未加载完成", Toast.LENGTH_SHORT).show()
+ } else {
+ playingItem = playTarget
+ }
+ }
+ }
+
+ if (detail.overview.isNullOrBlank().not()) {
+ Spacer(Modifier.height(20.dp))
+ DetailSectionCard(title = "简介") {
+ Text(
+ text = detail.overview.orEmpty(),
+ color = LocalDetailText.current.copy(alpha = 0.86f),
+ fontSize = 13.sp,
+ lineHeight = 21.sp,
+ )
+ }
+ }
+
+ if (detail.genres.isNotEmpty()) {
+ Spacer(Modifier.height(18.dp))
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(detail.genres.size) { index ->
+ val genre = detail.genres[index]
+ Box(
+ Modifier
+ .clip(RoundedCornerShape(999.dp))
+ .background(LocalDetailSurface.current)
+ .border(1.dp, LocalDetailBorder.current, RoundedCornerShape(999.dp))
+ .padding(horizontal = 13.dp, vertical = 7.dp),
+ ) {
+ Text(text = genre, color = LocalDetailText.current.copy(alpha = 0.88f), fontSize = 12.sp)
+ }
+ }
+ }
+ }
+
+ if (seasons.isNotEmpty()) {
+ Spacer(Modifier.height(20.dp))
+ Text(text = "剧集", color = LocalDetailText.current, fontSize = 17.sp, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.height(10.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ LazyRow(
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(seasons.size) { index ->
+ val season = seasons[index]
+ val selected = season.id == selectedSeasonId
+ Box(
+ Modifier
+ .clip(RoundedCornerShape(999.dp))
+ .background(if (selected) LocalDetailAccent.current else LocalDetailSurface.current)
+ .border(1.dp, if (selected) Color.Transparent else LocalDetailBorder.current, RoundedCornerShape(999.dp))
+ .clickable { selectedSeasonId = season.id }
+ .padding(horizontal = 14.dp, vertical = 7.dp),
+ ) {
+ Text(
+ text = season.name,
+ color = if (selected) {
+ if (LocalDetailAccent.current.luminance() > 0.5f) Color(0xFF111418) else Color.White
+ } else {
+ LocalDetailText.current.copy(alpha = 0.84f)
+ },
+ fontSize = 12.sp,
+ fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
+ )
+ }
+ }
+ }
+
+ Text(
+ text = "查看全部",
+ color = LocalDetailAccent.current,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.clickable(enabled = episodes.isNotEmpty()) { showAllEpisodes = true },
+ )
+ }
+
+ if (episodes.isEmpty()) {
+ Spacer(Modifier.height(12.dp))
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(68.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(LocalDetailSurface.current),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text = "正在加载剧集...", color = LocalDetailSecondary.current.copy(alpha = 0.82f), fontSize = 13.sp)
+ }
+ } else {
+ Spacer(Modifier.height(12.dp))
+ BoxWithConstraints(Modifier.fillMaxWidth()) {
+ val episodeSpacing = 9.dp
+ val episodeWidth = (maxWidth - episodeSpacing) / 2
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(episodeSpacing),
+ ) {
+ items(episodes.size) { index ->
+ val episode = episodes[index]
+ Box(Modifier.width(episodeWidth)) {
+ EpisodeCard(
+ item = episode,
+ serverUrl = server?.fullUrl.orEmpty(),
+ fallbackImageUrl = seriesFallbackImageUrl,
+ selected = episode.id == selectedEpisodeId,
+ onClick = { selectedEpisodeId = episode.id },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (showAllEpisodes) {
+ EpisodeListDialog(
+ title = seasons.firstOrNull { it.id == selectedSeasonId }?.name ?: "全部剧集",
+ episodes = episodes,
+ selectedEpisodeId = selectedEpisodeId,
+ serverUrl = server?.fullUrl.orEmpty(),
+ fallbackImageUrl = seriesFallbackImageUrl,
+ background = immersive,
+ onDismiss = { showAllEpisodes = false },
+ onSelect = { episode ->
+ selectedEpisodeId = episode.id
+ showAllEpisodes = false
+ },
+ )
+ }
+
+ if (detail.people.isNotEmpty()) {
+ Spacer(Modifier.height(22.dp))
+ Text(text = "演职人员", color = LocalDetailText.current, fontSize = 17.sp, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.height(12.dp))
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(13.dp)) {
+ items(detail.people.take(18).size) { index ->
+ PersonCard(
+ person = detail.people[index],
+ serverUrl = server?.fullUrl.orEmpty(),
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.height(22.dp))
+ DetailInfoCard(item = detail)
+ Spacer(Modifier.height(38.dp))
+ }
+ }
+
+ playingItem?.let { playTarget ->
+ VideoPlayerScreen(
+ title = playTarget.name,
+ playbackUrl = playbackUrl,
+ isLoading = playbackLoading,
+ errorText = playbackError,
+ episodes = episodes,
+ currentEpisodeId = if (detail.type == "Series") selectedEpisodeId else null,
+ onSelectEpisode = { episode ->
+ selectedEpisodeId = episode.id
+ playingItem = episode
+ playbackUrl = null
+ playbackError = null
+ },
+ onClose = {
+ playingItem = null
+ playbackUrl = null
+ playbackError = null
+ },
+ )
+ }
+
+ // Pinned above the scrolling content so it stays visible while browsing episodes.
+ Box(
+ Modifier
+ .align(Alignment.TopStart)
+ .statusBarsPadding()
+ .padding(start = 14.dp, top = 10.dp)
+ .size(39.dp)
+ .clip(CircleShape)
+ .background(Color.Black.copy(alpha = 0.32f))
+ .border(1.dp, Color.White.copy(alpha = 0.16f), CircleShape)
+ .clickable(onClick = onClose),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "返回",
+ tint = Color.White,
+ modifier = Modifier.size(19.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun rememberDetailImmersiveBackground(item: EmbyItem, serverUrl: String): PosterImmersivePalette {
+ // Extract from the same landscape artwork shown by DetailHero. Falling back to
+ // the poster keeps the ambient color correct for titles without a backdrop.
+ val imagePath = item.backdropImageTags.firstOrNull()?.let { tag ->
+ "/emby/Items/${item.id}/Images/Backdrop/0?tag=$tag&maxWidth=800&quality=70"
+ } ?: item.posterUrlPath
+ return rememberPosterImmersiveBackground(if (imagePath == null) null else serverUrl + imagePath)
+}
+@Composable
+private fun DetailHero(
+ item: EmbyItem,
+ serverUrl: String,
+ background: Color,
+) {
+ val backdropUrl = EmbyRepository.buildBackdropUrl(serverUrl, item)
+ val posterUrl = EmbyRepository.buildImageUrl(serverUrl, item, maxWidth = 900)
+
+ // Keep the detail cover at the same height as the home hero.
+ Box(Modifier.fillMaxWidth().aspectRatio(0.84f)) {
+ if (backdropUrl != null || posterUrl != null) {
+ AsyncImage(
+ model = backdropUrl ?: posterUrl,
+ contentDescription = item.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ } else {
+ Box(
+ Modifier.fillMaxSize().background(Brush.linearGradient(listOf(Color(0xFF17223A), Color(0xFF0C1222)))),
+ )
+ }
+
+ // Status-bar/top protection, similar to TMDB’s upper scrim.
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.Black.copy(alpha = 0.30f),
+ 0.13f to Color.Black.copy(alpha = 0.12f),
+ 0.26f to Color.Transparent,
+ )
+ ),
+ )
+
+ // Very light side vignette keeps bright backdrops from washing out at the edges.
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(
+ Brush.horizontalGradient(
+ 0f to Color.Black.copy(alpha = 0.14f),
+ 0.26f to Color.Transparent,
+ 0.74f to Color.Transparent,
+ 1f to Color.Black.copy(alpha = 0.14f),
+ )
+ ),
+ )
+
+ // Long bottom ramp into the same palette-derived background. Most of the
+ // artwork stays visible; the transition happens mainly in the lower third.
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.Transparent,
+ 0.34f to background.copy(alpha = 0.03f),
+ 0.50f to background.copy(alpha = 0.10f),
+ 0.63f to background.copy(alpha = 0.23f),
+ 0.73f to background.copy(alpha = 0.41f),
+ 0.82f to background.copy(alpha = 0.60f),
+ 0.90f to background.copy(alpha = 0.78f),
+ 0.96f to background.copy(alpha = 0.92f),
+ 1f to background,
+ )
+ ),
+ )
+
+ }
+}
+
+@Composable
+private fun DetailHeadline(detail: EmbyItem, refreshing: Boolean, errorText: String?) {
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = detail.name,
+ color = LocalDetailText.current,
+ fontSize = 23.sp,
+ fontWeight = FontWeight.Bold,
+ lineHeight = 28.sp,
+ textAlign = TextAlign.Center,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ detail.taglines.firstOrNull()?.let { tagline ->
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = tagline,
+ color = LocalDetailAccent.current.copy(alpha = 0.90f),
+ fontSize = 12.sp,
+ textAlign = TextAlign.Center,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ Spacer(Modifier.height(9.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (detail.communityRating != null && detail.communityRating!! > 0) {
+ Icon(imageVector = Icons.Filled.Star, contentDescription = null, tint = Color(0xFFFFD54F), modifier = Modifier.size(14.dp))
+ Spacer(Modifier.width(3.dp))
+ Text(text = String.format("%.1f", detail.communityRating), color = LocalDetailText.current, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
+ Spacer(Modifier.width(9.dp))
+ }
+ if (detail.productionYear != null) {
+ Text(text = "${detail.productionYear}", color = LocalDetailSecondary.current, fontSize = 12.sp)
+ Spacer(Modifier.width(8.dp))
+ }
+ formatRuntime(detail.runTimeTicks)?.let { runtime ->
+ Text(text = runtime, color = LocalDetailSecondary.current, fontSize = 12.sp)
+ }
+ }
+
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = when {
+ detail.type == "Series" -> "剧集"
+ detail.type == "Movie" -> "电影"
+ else -> detail.type ?: "媒体"
+ },
+ color = Color(0xFFBFDBFE),
+ fontSize = 11.sp,
+ textAlign = TextAlign.Center,
+ )
+
+ if (refreshing) {
+ Spacer(Modifier.height(7.dp))
+ Text(text = "同步详情中...", color = LocalDetailSecondary.current.copy(alpha = 0.75f), fontSize = 11.sp)
+ }
+ errorText?.let {
+ Spacer(Modifier.height(7.dp))
+ Text(text = it, color = Color(0xFFFCA5A5), fontSize = 11.sp)
+ }
+ }
+}
+@Composable
+private fun DetailPlayButton(onClick: () -> Unit) {
+ Box(
+ Modifier
+ .width(188.dp)
+ .height(47.dp)
+ .clip(RoundedCornerShape(999.dp))
+ .background(
+ Brush.horizontalGradient(listOf(Color(0xFF2563EB), Color(0xFF7C3AED)))
+ )
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(21.dp))
+ Spacer(Modifier.width(7.dp))
+ Text(text = "立即播放", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
+ }
+ }
+}
+
+@Composable
+private fun DetailSectionCard(title: String, content: @Composable () -> Unit) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(18.dp))
+ .background(LocalDetailSurface.current)
+ .border(1.dp, LocalDetailBorder.current, RoundedCornerShape(18.dp))
+ .padding(16.dp),
+ ) {
+ Text(text = title, color = LocalDetailText.current, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
+ Spacer(Modifier.height(9.dp))
+ content()
+ }
+}
+
+@Composable
+private fun EpisodeListDialog(
+ title: String,
+ episodes: List,
+ selectedEpisodeId: String?,
+ serverUrl: String,
+ fallbackImageUrl: String?,
+ background: PosterImmersivePalette,
+ onDismiss: () -> Unit,
+ onSelect: (EmbyItem) -> Unit,
+) {
+ val dialogBorder = if (background.isDark) {
+ Color.White.copy(alpha = 0.12f)
+ } else {
+ Color.Black.copy(alpha = 0.10f)
+ }
+
+ Dialog(onDismissRequest = onDismiss) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(24.dp))
+ .background(
+ Brush.verticalGradient(
+ 0f to background.pageTop,
+ 0.53f to background.hero,
+ 1f to background.pageBottom,
+ )
+ )
+ .border(1.dp, dialogBorder, RoundedCornerShape(24.dp))
+ .padding(16.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = title,
+ color = LocalDetailText.current,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = "关闭",
+ color = LocalDetailSecondary.current,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.clickable(onClick = onDismiss),
+ )
+ }
+ Spacer(Modifier.height(12.dp))
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth().heightIn(max = 440.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(episodes.size) { index ->
+ val episode = episodes[index]
+ CompactEpisodeRow(
+ item = episode,
+ serverUrl = serverUrl,
+ fallbackImageUrl = fallbackImageUrl,
+ selected = episode.id == selectedEpisodeId,
+ onClick = { onSelect(episode) },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CompactEpisodeRow(
+ item: EmbyItem,
+ serverUrl: String,
+ fallbackImageUrl: String?,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ val imageUrl = EmbyRepository.buildEpisodeImageUrl(serverUrl, item, maxWidth = 280)
+ ?: fallbackImageUrl
+ val rawName = item.name.trim()
+ val nameAlreadyNumbered = rawName.contains("第") && rawName.contains("集")
+ val title = when {
+ rawName.isNotBlank() && !nameAlreadyNumbered && item.indexNumber != null -> "第 ${item.indexNumber} 集 · $rawName"
+ rawName.isNotBlank() -> rawName
+ item.indexNumber != null -> "第 ${item.indexNumber} 集"
+ else -> "剧集"
+ }
+ val rowBackground = if (selected) {
+ LocalDetailAccent.current.copy(alpha = 0.13f)
+ } else {
+ LocalDetailSurface.current
+ }
+ val rowBorder = if (selected) {
+ LocalDetailAccent.current.copy(alpha = 0.48f)
+ } else {
+ LocalDetailBorder.current
+ }
+
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(15.dp))
+ .background(rowBackground)
+ .border(1.dp, rowBorder, RoundedCornerShape(15.dp))
+ .clickable(onClick = onClick)
+ .padding(7.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ Modifier
+ .size(width = 94.dp, height = 53.dp)
+ .clip(RoundedCornerShape(11.dp))
+ .background(Color(0xFF152036)),
+ ) {
+ if (imageUrl != null) {
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = item.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ } else {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(
+ text = item.indexNumber?.toString() ?: "TV",
+ color = Color.White.copy(alpha = 0.70f),
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ }
+
+ Column(
+ Modifier
+ .weight(1f)
+ .padding(start = 9.dp),
+ ) {
+ Text(
+ text = title,
+ color = if (selected) Color.Unspecified else LocalDetailText.current,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = if (selected) {
+ androidx.compose.ui.text.TextStyle(
+ brush = Brush.linearGradient(
+ listOf(
+ Color(0xFFFF8FA3),
+ Color(0xFF7BE495),
+ Color(0xFF66C7FF),
+ Color(0xFF9E8CFF),
+ )
+ )
+ )
+ } else {
+ LocalTextStyle.current
+ },
+ )
+ Spacer(Modifier.height(3.dp))
+ Text(
+ text = item.overview?.takeIf { it.isNotBlank() } ?: "暂无简介",
+ color = LocalDetailSecondary.current.copy(alpha = if (selected) 0.90f else 0.76f),
+ fontSize = 10.sp,
+ lineHeight = 13.sp,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+private fun EpisodeCard(
+ item: EmbyItem,
+ serverUrl: String,
+ fallbackImageUrl: String?,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ val imageUrl = EmbyRepository.buildEpisodeImageUrl(serverUrl, item, maxWidth = 560)
+ ?: fallbackImageUrl
+
+ val rainbowColors = listOf(
+ Color(0xFFE06A5F),
+ Color(0xFFDE9455),
+ Color(0xFFCDB254),
+ Color(0xFF61A47C),
+ Color(0xFF58A3AB),
+ Color(0xFF6079CB),
+ Color(0xFF9A72C7),
+ Color(0xFFBC6A92),
+ )
+ val rainbowBrush = Brush.linearGradient(rainbowColors)
+ val normalBorderBrush = SolidColor(LocalDetailBorder.current.copy(alpha = 0.48f))
+
+ val rawName = item.name.trim()
+ val nameAlreadyNumbered = rawName.contains("第") && rawName.contains("集")
+ val title = when {
+ rawName.isNotBlank() && !nameAlreadyNumbered && item.indexNumber != null -> "第 ${item.indexNumber} 集 · $rawName"
+ rawName.isNotBlank() -> rawName
+ item.indexNumber != null -> "第 ${item.indexNumber} 集"
+ else -> "剧集"
+ }
+
+ val interactionSource = remember { MutableInteractionSource() }
+
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = onClick,
+ ),
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(16f / 9f)
+ .clip(RoundedCornerShape(21.dp))
+ .background(Color(0xFF152036))
+ .drawWithContent {
+ drawContent()
+ val strokeWidth = if (selected) 1.15.dp else 0.62.dp
+ val widthPx = strokeWidth.toPx()
+ val half = widthPx / 2f
+ val radius = (21.dp - strokeWidth / 2f).toPx()
+ drawRoundRect(
+ brush = if (selected) {
+ rainbowBrush
+ } else {
+ normalBorderBrush
+ },
+ topLeft = Offset(half, half),
+ size = Size(size.width - widthPx, size.height - widthPx),
+ cornerRadius = CornerRadius(radius, radius),
+ style = Stroke(width = widthPx),
+ )
+ },
+ ) {
+ if (imageUrl != null) {
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = item.name,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ } else {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Brush.linearGradient(listOf(Color(0xFF1F2937), Color(0xFF0B1220)))),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = item.indexNumber?.toString() ?: "TV",
+ color = Color.White.copy(alpha = 0.72f),
+ fontSize = 28.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+
+ }
+
+ Spacer(Modifier.height(9.dp))
+ Text(
+ text = title,
+ color = if (selected) Color.Unspecified else LocalDetailText.current,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 18.sp,
+ style = if (selected) {
+ androidx.compose.ui.text.TextStyle(
+ brush = Brush.linearGradient(rainbowColors.map { it.copy(alpha = 0.92f) })
+ )
+ } else {
+ LocalTextStyle.current
+ },
+ )
+
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = item.overview?.takeIf { it.isNotBlank() } ?: "暂无简介",
+ color = LocalDetailSecondary.current.copy(alpha = if (selected) 0.94f else 0.82f),
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+@Composable
+private fun PersonCard(person: com.example.myapplication.data.EmbyPerson, serverUrl: String) {
+ val imageUrl = EmbyRepository.buildPersonImageUrl(serverUrl, person)
+ Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(64.dp)) {
+ Box(
+ Modifier
+ .size(59.dp)
+ .clip(CircleShape)
+ .background(Color(0xFF17233A))
+ .border(1.dp, LocalDetailBorder.current, CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (imageUrl != null) {
+ AsyncImage(model = imageUrl, contentDescription = person.name, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
+ } else {
+ Text(text = person.name.take(1), color = LocalDetailText.current.copy(alpha = 0.72f), fontSize = 18.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ Spacer(Modifier.height(5.dp))
+ Text(person.name, color = LocalDetailText.current.copy(alpha = 0.92f), fontSize = 11.sp, maxLines = 1, textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis)
+ person.role?.let {
+ Text(it, color = LocalDetailSecondary.current.copy(alpha = 0.70f), fontSize = 10.sp, maxLines = 1, textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis)
+ }
+ }
+}
+
+@Composable
+private fun DetailInfoCard(item: EmbyItem) {
+ val studios = item.studios.joinToString(" / ") { it.name }
+ val videoStream = item.mediaStreams.firstOrNull { it.type == "Video" }
+ val audioStreams = item.mediaStreams.filter { it.type == "Audio" }
+
+ DetailSectionCard(title = "详细信息") {
+ InfoRow(label = "类型", value = item.type ?: "未知")
+ item.originalTitle?.takeIf { it.isNotBlank() && it != item.name }?.let {
+ InfoRow(label = "原名", value = it)
+ }
+ studios.takeIf { it.isNotBlank() }?.let { InfoRow(label = "制作方", value = it) }
+ videoStream?.let {
+ val quality = listOfNotNull(it.width, it.height).joinToString("×").ifBlank { null }
+ val value = listOfNotNull(quality, it.codec?.uppercase()).joinToString(" · ")
+ if (value.isNotBlank()) InfoRow(label = "画质", value = value)
+ }
+ if (audioStreams.isNotEmpty()) {
+ InfoRow(label = "音轨", value = audioStreams.count().toString() + " 条")
+ }
+ }
+}
+
+@Composable
+private fun InfoRow(label: String, value: String) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Text(label, color = LocalDetailSecondary.current.copy(alpha = 0.68f), fontSize = 12.sp, modifier = Modifier.width(58.dp))
+ Spacer(Modifier.width(10.dp))
+ Text(value, color = LocalDetailText.current.copy(alpha = 0.90f), fontSize = 12.sp, modifier = Modifier.weight(1f))
+ }
+}
+
+private fun formatRuntime(ticks: Long?): String? {
+ if (ticks == null || ticks <= 0) return null
+ val minutes = ticks / 600_000_000L
+ if (minutes <= 0) return null
+ return if (minutes < 60) "${minutes}分钟" else "${minutes / 60}小时${if (minutes % 60 == 0L) "" else " ${minutes % 60}分"}"
+}
+
+
+
+
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt
new file mode 100644
index 0000000..f987b7f
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt
@@ -0,0 +1,113 @@
+package com.example.myapplication.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+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.runtime.Composable
+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.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.res.stringResource
+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.CalendarMonth
+import com.example.myapplication.model.sampleMovies
+import com.example.myapplication.ui.GlassTokens
+import com.example.myapplication.ui.bottomBarContentPadding
+import com.example.myapplication.ui.LiquidGlassTopBar
+import com.example.myapplication.ui.PosterBox
+import com.example.myapplication.ui.SegmentedControl
+
+@Composable
+fun RecordsTopBar(
+ selected: Int,
+ modifier: Modifier = Modifier,
+ onSelect: (Int) -> Unit,
+) {
+ LiquidGlassTopBar(modifier) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ SegmentedControl(
+ items = listOf(stringResource(Res.string.records_fav), stringResource(Res.string.records_calendar)),
+ selected = selected,
+ onSelect = onSelect,
+ modifier = Modifier.width(220.dp),
+ )
+ }
+ }
+}
+
+@Composable
+fun RecordsScreen(segment: Int) {
+ 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(GlassTokens.glassFill)
+ .border(1.dp, GlassTokens.glassBorder, CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ androidx.compose.material3.Icon(
+ imageVector = Icons.Outlined.CalendarMonth,
+ contentDescription = null,
+ tint = GlassTokens.subtext2,
+ modifier = Modifier.size(30.dp),
+ )
+ }
+ Text(text = stringResource(Res.string.no_updates), color = GlassTokens.subtext, fontSize = 15.sp)
+ }
+ }
+ } else {
+ val favorites = sampleMovies().take(6)
+ 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 ->
+ Box(Modifier.fillMaxWidth()) {
+ PosterBox(
+ colors = item.posterColors,
+ title = item.title,
+ subtitle = item.subtitle,
+ height = 200.dp,
+ )
+ }
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt
new file mode 100644
index 0000000..2063294
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt
@@ -0,0 +1,146 @@
+package com.example.myapplication.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+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.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.res.stringResource
+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.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.bottomBarContentPadding
+
+@Composable
+fun SearchTopBar(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ onGearClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LiquidGlassTopBar(modifier) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ ) {
+ GlassSearchField(
+ value = query,
+ onValueChange = onQueryChange,
+ placeholder = stringResource(Res.string.search_hint),
+ onGearClick = onGearClick,
+ )
+ }
+ }
+}
+
+@Composable
+fun SearchScreen(query: String) {
+ val allMedia: List = sampleMovies() + sampleTvShows()
+ val results = remember(query) {
+ val q = query.trim()
+ if (q.isBlank()) emptyList() else allMedia.filter {
+ it.title.contains(q, ignoreCase = true) || it.subtitle.contains(q, ignoreCase = true)
+ }
+ }
+
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ ) {
+ if (query.isBlank()) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .padding(top = 80.dp),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Box(
+ Modifier
+ .size(72.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(34.dp),
+ )
+ }
+ Text(text = stringResource(Res.string.search_title), color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
+ Text(text = stringResource(Res.string.search_empty_input), color = GlassTokens.subtext, fontSize = 13.sp)
+ }
+ }
+ } else if (results.isEmpty()) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .padding(top = 80.dp),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Text(text = stringResource(Res.string.search_no_result_prefix) + "$query" + stringResource(Res.string.search_no_result_suffix), color = GlassTokens.subtext, fontSize = 14.sp)
+ }
+ } else {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(2),
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
+ verticalArrangement = Arrangement.spacedBy(12.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,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt
new file mode 100644
index 0000000..18edc38
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt
@@ -0,0 +1,569 @@
+package com.example.myapplication.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+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.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+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.draw.rotate
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.material3.Icon
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.ui.res.stringResource
+import com.example.myapplication.R as Res
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.platform.LocalContext
+import top.yukonga.miuix.kmp.basic.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Language
+import androidx.compose.material.icons.outlined.Palette
+import androidx.compose.material.icons.outlined.PlayCircle
+import androidx.compose.material.icons.outlined.ChevronRight
+import androidx.compose.material.icons.outlined.Dashboard
+import androidx.compose.material.icons.outlined.Folder
+import androidx.compose.material.icons.outlined.Contrast
+import androidx.compose.material.icons.outlined.DarkMode
+import androidx.compose.material.icons.outlined.LightMode
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material.icons.outlined.Subtitles
+import com.example.myapplication.data.EmbyServer
+import com.example.myapplication.data.IconLibraryRepository
+import com.example.myapplication.ui.GlassButton
+import com.example.myapplication.ui.GlassSettingRow
+import com.example.myapplication.ui.GlassSurface
+import com.example.myapplication.ui.bottomBarContentPadding
+import com.example.myapplication.ui.AppLanguage
+import com.example.myapplication.ui.AppTheme
+import com.example.myapplication.ui.GlassTokens
+import com.example.myapplication.ui.LiquidGlassTopBar
+
+private data class SettingItem(
+ val icon: ImageVector,
+ val iconTint: Color,
+ val bgTint: Color,
+ val title: String,
+ val subtitle: String,
+)
+
+@Composable
+fun SettingsTopBar(modifier: Modifier = Modifier) {
+ LiquidGlassTopBar(modifier) {
+ Box(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 14.dp)) {
+ Text(text = stringResource(Res.string.settings_title), color = GlassTokens.text, fontSize = 22.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+}
+
+@Composable
+private fun SettingsCard(items: List, onItemClick: (Int) -> Unit = {}) {
+ GlassSurface(
+ cornerRadius = 18.dp,
+ padding = PaddingValues(horizontal = 14.dp, vertical = 4.dp),
+ ) {
+ items.forEachIndexed { index, item ->
+ GlassSettingRow(
+ icon = item.icon,
+ iconTint = item.iconTint,
+ bgTint = item.bgTint,
+ title = item.title,
+ subtitle = item.subtitle,
+ onClick = { onItemClick(index) },
+ )
+ if (index < items.lastIndex) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(0.5.dp)
+ .background(GlassTokens.separator.copy(alpha = 0.4f)),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun SettingsScreen(
+ servers: List = emptyList(),
+ showCacheDetail: Boolean = false,
+ onOpenCacheDetail: () -> Unit = {},
+ onBackFromCache: () -> Unit = {},
+ showAboutDetail: Boolean = false,
+ onOpenAbout: () -> Unit = {},
+ onBackFromAbout: () -> Unit = {},
+) {
+ if (showCacheDetail) {
+ CacheInfoScreen(onBack = onBackFromCache)
+ return
+ }
+
+ if (showAboutDetail) {
+ AboutScreen(onBack = onBackFromAbout)
+ return
+ }
+
+ var showLanguageDialog by remember { mutableStateOf(false) }
+
+ val groupAppearance = listOf(
+ SettingItem(
+ icon = Icons.Outlined.Language,
+ iconTint = Color(0xFF8E5BEF),
+ bgTint = Color(0xFFEDE4FB),
+ title = stringResource(Res.string.settings_language),
+ subtitle = com.example.myapplication.ui.AppLanguage.displayName,
+ ),
+ )
+
+ val groupPlayback = listOf(
+ SettingItem(
+ icon = Icons.Outlined.PlayCircle,
+ iconTint = Color(0xFF1FA463),
+ bgTint = Color(0xFFE2F6EA),
+ title = stringResource(Res.string.settings_playback),
+ subtitle = stringResource(Res.string.settings_playback_sub),
+ ),
+ SettingItem(
+ icon = Icons.Outlined.Subtitles,
+ iconTint = Color(0xFFE14B8A),
+ bgTint = Color(0xFFFBE3EE),
+ title = stringResource(Res.string.settings_danmaku),
+ subtitle = stringResource(Res.string.danmaku_off),
+ ),
+ )
+
+ val cacheSize = remember { IconLibraryRepository.getIconCacheSize() }
+ val groupStorage = listOf(
+ SettingItem(
+ icon = Icons.Outlined.Folder,
+ iconTint = Color(0xFFC79A12),
+ bgTint = Color(0xFFFBF3D8),
+ title = stringResource(Res.string.settings_cache),
+ subtitle = formatFileSize(cacheSize),
+ ),
+ )
+ val groupAbout = listOf(
+ SettingItem(
+ icon = Icons.Outlined.Info,
+ iconTint = Color(0xFF8E8E93),
+ bgTint = Color(0xFFECECEF),
+ title = stringResource(Res.string.about),
+ subtitle = stringResource(Res.string.about_sub),
+ ),
+ )
+
+ Column(
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ AppearanceCard(groupAppearance, onLanguageClick = { showLanguageDialog = true })
+ SettingsCard(groupPlayback)
+ SettingsCard(groupStorage, onItemClick = { index -> if (index == 0) onOpenCacheDetail() })
+ SettingsCard(groupAbout, onItemClick = { onOpenAbout() })
+ Spacer(Modifier.height(8.dp))
+ }
+ if (showLanguageDialog) {
+ LanguageDialog(onDismiss = { showLanguageDialog = false })
+ }
+}
+/* ------------------------------------------------------------------ */
+/* Appearance Card (Theme + Appearance items) */
+/* ------------------------------------------------------------------ */
+
+@Composable
+private fun AppearanceCard(items: List, onLanguageClick: () -> Unit = {}) {
+ GlassSurface(
+ cornerRadius = 18.dp,
+ padding = PaddingValues(horizontal = 14.dp, vertical = 4.dp),
+ ) {
+ ThemeSelectorRow()
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(0.5.dp)
+ .background(GlassTokens.separator.copy(alpha = 0.4f)),
+ )
+ items.forEachIndexed { index, item ->
+ GlassSettingRow(
+ icon = item.icon,
+ iconTint = item.iconTint,
+ bgTint = item.bgTint,
+ title = item.title,
+ subtitle = item.subtitle,
+ onClick = if (index == 0) onLanguageClick else ({} as () -> Unit),
+ )
+ if (index < items.lastIndex) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(0.5.dp)
+ .background(GlassTokens.separator.copy(alpha = 0.4f)),
+ )
+ }
+ }
+ }
+}
+
+
+/* ------------------------------------------------------------------ */
+/* Theme Selector Row */
+/* ------------------------------------------------------------------ */
+
+@Composable
+private fun ThemeSelectorRow() {
+ val themeMode = AppTheme.themeMode
+
+
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ Modifier.size(38.dp).clip(RoundedCornerShape(11.dp)).background(Color(0xFFEDE4FB)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Contrast, contentDescription = null, tint = Color(0xFF8E5BEF), modifier = Modifier.size(22.dp))
+ }
+ Spacer(Modifier.width(14.dp))
+ Text(stringResource(Res.string.settings_theme), color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
+ Spacer(Modifier.width(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ // Light
+ ThemeOptionButton(
+ icon = Icons.Outlined.LightMode,
+ label = stringResource(Res.string.theme_light),
+ selected = themeMode == 0,
+ onClick = { AppTheme.themeMode = 0 },
+ )
+ // Dark
+ ThemeOptionButton(
+ icon = Icons.Outlined.DarkMode,
+ label = stringResource(Res.string.theme_dark),
+ selected = themeMode == 1,
+ onClick = { AppTheme.themeMode = 1 },
+ )
+ // System
+ ThemeOptionButton(
+ icon = Icons.Outlined.Settings,
+ label = stringResource(Res.string.theme_system),
+ selected = themeMode == 2,
+ onClick = { AppTheme.themeMode = 2 },
+ )
+ }
+ }
+}
+
+@Composable
+private fun ThemeOptionButton(
+ icon: ImageVector,
+ label: String,
+ selected: Boolean,
+ onClick: () -> Unit,
+) {
+ val bgColor by animateColorAsState(
+ targetValue = if (selected) GlassTokens.accent else GlassTokens.glassFillSoft,
+ animationSpec = tween(200),
+ label = "themeBtnBg",
+ )
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .clip(RoundedCornerShape(14.dp))
+ .clickable(onClick = onClick)
+ .padding(4.dp),
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = label,
+ tint = if (selected) Color.White else GlassTokens.subtext,
+ modifier = Modifier
+ .size(34.dp)
+ .clip(CircleShape)
+ .background(bgColor)
+ .padding(7.dp),
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(label, fontSize = 10.sp, color = if (selected) GlassTokens.text else GlassTokens.subtext)
+ }
+}
+/* ------------------------------------------------------------------ */
+/* Cache Info Screen */
+/* ------------------------------------------------------------------ */
+
+@Composable
+private fun CacheInfoScreen(onBack: () -> Unit) {
+ var totalSize by remember { mutableStateOf(IconLibraryRepository.getIconCacheSize()) }
+
+ Column(
+ Modifier.fillMaxSize()
+ .pointerInput(Unit) {
+ var totalDrag = 0f
+ detectHorizontalDragGestures(
+ onDragStart = { totalDrag = 0f },
+ onDragEnd = {
+ if (totalDrag > 300f) onBack()
+ },
+ ) { _, dragAmount ->
+ totalDrag += dragAmount
+ }
+ }
+ ) {
+ Column(
+ Modifier.fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ // Total size card
+ GlassSurface(
+ cornerRadius = 18.dp,
+ padding = PaddingValues(horizontal = 14.dp, vertical = 20.dp),
+ ) {
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(Icons.Outlined.Folder, contentDescription = null, tint = Color(0xFFC79A12), modifier = Modifier.size(32.dp))
+ Spacer(Modifier.height(10.dp))
+ Text(stringResource(Res.string.cache_total_size), color = GlassTokens.subtext, fontSize = 13.sp)
+ Spacer(Modifier.height(4.dp))
+ Text(formatFileSize(totalSize), color = GlassTokens.text, fontSize = 24.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+
+ // Icon cache row with delete button
+ GlassSurface(
+ cornerRadius = 18.dp,
+ padding = PaddingValues(horizontal = 14.dp, vertical = 6.dp),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ Modifier.size(38.dp).clip(RoundedCornerShape(11.dp)).background(Color(0xFFFBEAD6)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Palette, contentDescription = null, tint = Color(0xFFE0810F), modifier = Modifier.size(22.dp))
+ }
+ Spacer(Modifier.width(14.dp))
+ Column {
+ Text(stringResource(Res.string.icon_cache), color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium)
+ Spacer(Modifier.height(2.dp))
+ Text(formatFileSize(totalSize), color = GlassTokens.subtext, fontSize = 12.sp)
+ }
+ }
+ GlassButton(
+ text = stringResource(Res.string.delete_cache),
+ onClick = {
+ IconLibraryRepository.clearAllIconCache()
+ totalSize = IconLibraryRepository.getIconCacheSize()
+ },
+ modifier = Modifier.width(width = 96.dp),
+ danger = true,
+ )
+ }
+ }
+
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+}
+
+private fun formatFileSize(bytes: Long): String {
+ if (bytes < 1024) return "${bytes} B"
+ val kb = bytes / 1024.0
+ if (kb < 1024.0) return String.format("%.1f KB", kb)
+ val mb = kb / 1024.0
+ if (mb < 1024.0) return String.format("%.1f MB", mb)
+ return String.format("%.2f GB", mb / 1024.0)
+}
+
+/* ------------------------------------------------------------------ */
+/* About Screen */
+/* ------------------------------------------------------------------ */
+
+@Composable
+private fun AboutScreen(onBack: () -> Unit) {
+ val versionName = "1.0"
+
+ Column(
+ Modifier.fillMaxSize()
+ .pointerInput(Unit) {
+ var totalDrag = 0f
+ detectHorizontalDragGestures(
+ onDragStart = { totalDrag = 0f },
+ onDragEnd = {
+ if (totalDrag > 300f) onBack()
+ },
+ ) { _, dragAmount ->
+ totalDrag += dragAmount
+ }
+ }
+ ) {
+ Column(
+ Modifier.fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ // App info card
+ GlassSurface(
+ cornerRadius = 18.dp,
+ padding = PaddingValues(horizontal = 14.dp, vertical = 6.dp),
+ ) {
+ Text(
+ stringResource(Res.string.app_info),
+ color = GlassTokens.subtext,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(top = 10.dp, bottom = 4.dp),
+ )
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ Modifier.size(38.dp).clip(RoundedCornerShape(11.dp)).background(Color(0xFFE3F0FC)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.PlayCircle, contentDescription = null, tint = Color(0xFF2F7DE1), modifier = Modifier.size(22.dp))
+ }
+ Spacer(Modifier.width(14.dp))
+ Column {
+ Text(stringResource(Res.string.about_app_name), color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium)
+ Spacer(Modifier.height(2.dp))
+ Text(stringResource(Res.string.app_desc), color = GlassTokens.subtext, fontSize = 12.sp)
+ }
+ }
+ }
+
+ // Version info card
+ GlassSurface(
+ cornerRadius = 18.dp,
+ padding = PaddingValues(horizontal = 14.dp, vertical = 6.dp),
+ ) {
+ Text(
+ stringResource(Res.string.version_info),
+ color = GlassTokens.subtext,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(top = 10.dp, bottom = 4.dp),
+ )
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ Modifier.size(38.dp).clip(RoundedCornerShape(11.dp)).background(Color(0xFFECECEF)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Info, contentDescription = null, tint = Color(0xFF8E8E93), modifier = Modifier.size(22.dp))
+ }
+ Spacer(Modifier.width(14.dp))
+ Column {
+ Text(stringResource(Res.string.current_version), color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium)
+ Spacer(Modifier.height(2.dp))
+ Text("v$versionName", color = GlassTokens.subtext, fontSize = 12.sp)
+ }
+ }
+ }
+
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Language Dialog */
+/* ------------------------------------------------------------------ */
+
+@Composable
+private fun LanguageDialog(onDismiss: () -> Unit) {
+ val context = LocalContext.current
+ Dialog(onDismissRequest = onDismiss) {
+ GlassSurface(
+ modifier = Modifier.fillMaxWidth(),
+ cornerRadius = 20.dp,
+ padding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
+ ) {
+ Text(
+ stringResource(Res.string.language_switch_title),
+ color = GlassTokens.text,
+ fontSize = 17.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.height(12.dp))
+ listOf(0 to stringResource(Res.string.lang_chinese), 1 to stringResource(Res.string.lang_english)).forEach { (lang, name) ->
+ val selected = AppLanguage.language == lang
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .clickable {
+ onDismiss()
+ if (AppLanguage.language != lang) {
+ val activity = AppLanguage.apply(context, lang)
+ activity?.recreate()
+ }
+ }
+ .padding(vertical = 12.dp, horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ Modifier.size(22.dp).clip(CircleShape)
+ .background(if (selected) GlassTokens.accent else Color.Transparent)
+ .border(2.dp, if (selected) GlassTokens.accent else GlassTokens.subtext, CircleShape),
+ )
+ Spacer(Modifier.width(12.dp))
+ Text(name, color = GlassTokens.text, fontSize = 15.sp)
+ if (selected) {
+ Spacer(Modifier.weight(1f))
+ Text("✓", color = GlassTokens.accent, fontSize = 16.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+ }
+ }
+}
+
+
+
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt
new file mode 100644
index 0000000..92d8e46
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt
@@ -0,0 +1,661 @@
+package com.example.myapplication.screens
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.pm.ActivityInfo
+import android.graphics.SurfaceTexture
+import android.view.Surface
+import android.view.TextureView
+import androidx.activity.compose.BackHandler
+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.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+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.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.List
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
+import androidx.compose.material.icons.filled.FastForward
+import androidx.compose.material.icons.filled.FastRewind
+import androidx.compose.material.icons.filled.Subtitles
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+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.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+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.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.TrackSelectionOverride
+import androidx.media3.exoplayer.ExoPlayer
+import com.example.myapplication.data.EmbyItem
+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.lens
+import com.kyant.backdrop.effects.vibrancy
+import com.kyant.backdrop.highlight.Highlight
+import com.kyant.backdrop.shadow.InnerShadow
+import com.kyant.backdrop.shadow.Shadow
+import com.kyant.shapes.Capsule
+import com.kyant.shapes.RoundedRectangle
+import kotlinx.coroutines.delay
+import top.yukonga.miuix.kmp.basic.Text
+
+private fun formatMs(ms: Long): String {
+ val totalSec = ms / 1000
+ val h = totalSec / 3600
+ val m = (totalSec % 3600) / 60
+ val s = totalSec % 60
+ return if (h > 0) String.format("%d:%02d:%02d", h, m, s) else String.format("%02d:%02d", m, s)
+}
+
+private tailrec fun Context.findActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
+
+private val LocalPlayerBackdrop = staticCompositionLocalOf { null }
+
+@Composable
+fun VideoPlayerScreen(
+ title: String,
+ playbackUrl: String? = null,
+ isLoading: Boolean = false,
+ errorText: String? = null,
+ episodes: List = emptyList(),
+ currentEpisodeId: String? = null,
+ onSelectEpisode: (EmbyItem) -> Unit = {},
+ onClose: () -> 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 playerBackdrop = rememberLayerBackdrop()
+
+ // Progress
+ var progress by remember { mutableFloatStateOf(0f) }
+ var isPlaying by remember { mutableStateOf(true) }
+
+ LaunchedEffect(Unit) {
+ while (true) {
+ if (player.duration > 0) {
+ progress = player.currentPosition.toFloat() / player.duration.toFloat()
+ }
+ isPlaying = player.isPlaying
+ delay(250)
+ }
+ }
+
+ DisposableEffect(Unit) {
+ val activity = context.findActivity()
+ val prev = activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
+ activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ onDispose { activity?.requestedOrientation = prev }
+ }
+ DisposableEffect(playbackUrl) {
+ if (!playbackUrl.isNullOrBlank()) {
+ player.setMediaItem(MediaItem.fromUri(playbackUrl))
+ player.prepare()
+ player.playWhenReady = true
+ }
+ onDispose { }
+ }
+ DisposableEffect(lifecycleOwner) {
+ val obs = LifecycleEventObserver { _, e -> if (e == Lifecycle.Event.ON_STOP) player.pause() }
+ lifecycleOwner.lifecycle.addObserver(obs)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
+ }
+ DisposableEffect(Unit) { onDispose { player.release() } }
+ BackHandler(enabled = true, onBack = onClose)
+
+ CompositionLocalProvider(LocalPlayerBackdrop provides playerBackdrop) {
+ Box(Modifier.fillMaxSize().background(Color.Black)) {
+ // Video
+ if (playbackUrl.isNullOrBlank()) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ when {
+ isLoading -> CircularProgressIndicator(color = Color.White)
+ errorText != null -> Text(text = errorText, color = Color.White, modifier = Modifier.padding(24.dp))
+ else -> Text(text = "\u6B63\u5728\u51C6\u5907\u64AD\u653E...", color = Color.White)
+ }
+ }
+ } else {
+ AndroidView(
+ factory = { ctx ->
+ TextureView(ctx).also { tv ->
+ tv.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
+ override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) {
+ player.setVideoSurface(Surface(st))
+ player.playWhenReady = true
+ }
+ override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) = Unit
+ override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean {
+ player.setVideoSurface(null)
+ return true
+ }
+ override fun onSurfaceTextureUpdated(st: SurfaceTexture) = Unit
+ }
+ }
+ },
+ modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),)
+ }
+
+ // Backdrop capture layer
+ Box(
+ Modifier
+ .fillMaxSize()
+ .layerBackdrop(playerBackdrop)
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.Black.copy(alpha = 0.40f),
+ 0.10f to Color.Black.copy(alpha = 0.20f),
+ 0.5f to Color.Black.copy(alpha = 0.0f),
+ 0.90f to Color.Black.copy(alpha = 0.20f),
+ 1f to Color.Black.copy(alpha = 0.40f),
+ )
+ ),
+ )
+
+ // Center playback controls
+ Row(
+ Modifier.align(Alignment.Center),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Rewind 10s
+ LiquidSurfaceButton(
+ icon = Icons.Filled.FastRewind,
+ active = false,
+ size = 42.dp,
+ iconSize = 22.dp,
+ ) {
+ val pos = player.currentPosition - 10_000L
+ player.seekTo(maxOf(0L, pos))
+ }
+ // Previous episode
+ LiquidSurfaceButton(
+ icon = Icons.Filled.SkipPrevious,
+ active = false,
+ size = 48.dp,
+ iconSize = 26.dp,
+ ) {
+ val idx = episodes.indexOfFirst { it.id == currentEpisodeId }
+ if (idx > 0) onSelectEpisode(episodes[idx - 1])
+ }
+ // Play/Pause
+ LiquidSurfaceButton(
+ icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
+ active = isPlaying,
+ size = 60.dp,
+ iconSize = 32.dp,
+ ) {
+ if (player.isPlaying) player.pause() else player.play()
+ }
+ // Next episode
+ LiquidSurfaceButton(
+ icon = Icons.Filled.SkipNext,
+ active = false,
+ size = 48.dp,
+ iconSize = 26.dp,
+ ) {
+ val idx = episodes.indexOfFirst { it.id == currentEpisodeId }
+ if (idx >= 0 && idx < episodes.lastIndex) onSelectEpisode(episodes[idx + 1])
+ }
+ // Forward 10s
+ LiquidSurfaceButton(
+ icon = Icons.Filled.FastForward,
+ active = false,
+ size = 42.dp,
+ iconSize = 22.dp,
+ ) {
+ val pos = player.currentPosition + 10_000L
+ player.seekTo(minOf(pos, player.duration))
+ }
+ }
+
+ // Subtitle & episode buttons (top right, horizontal)
+ Row(
+ Modifier
+ .align(Alignment.TopEnd)
+ .padding(top = 16.dp, end = 24.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LiquidSurfaceButton(icon = Icons.Filled.Subtitles, active = showSubtitleMenu, size = 40.dp, iconSize = 20.dp) {
+ showSubtitleMenu = !showSubtitleMenu; showEpisodeMenu = false
+ }
+ LiquidSurfaceButton(icon = Icons.Filled.List, active = showEpisodeMenu, size = 40.dp, iconSize = 20.dp) {
+ showEpisodeMenu = !showEpisodeMenu; showSubtitleMenu = false
+ }
+ }
+
+ // Progress bar with time display at bottom
+ if (playbackUrl != null) {
+ var isSeeking by remember { mutableStateOf(false) }
+ var seekPosition by remember { mutableFloatStateOf(0f) }
+ var totalDurationMs by remember { mutableLongStateOf(0L) }
+ var currentPositionMs by remember { mutableLongStateOf(0L) }
+
+ LaunchedEffect(Unit) {
+ while (true) {
+ delay(250)
+ if (player.duration > 0) {
+ totalDurationMs = player.duration
+ if (!isSeeking) {
+ currentPositionMs = player.currentPosition
+ progress = player.currentPosition.toFloat() / player.duration.toFloat()
+ }
+ }
+ isPlaying = player.isPlaying
+ }
+ }
+
+ Column(
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth(0.80f)
+ .padding(bottom = 48.dp)
+ .padding(horizontal = 16.dp)
+ .drawBackdrop(
+ backdrop = playerBackdrop,
+ shape = { Capsule() },
+ effects = {
+ blur(16f.dp.toPx())
+ vibrancy()
+ lens(4f.dp.toPx(), 8f.dp.toPx())
+ },
+ highlight = { Highlight.Plain },
+ shadow = { Shadow(radius = 8f.dp, color = Color.Black.copy(alpha = 0.15f)) },
+ onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.30f)) },
+ )
+ .clip(RoundedCornerShape(50.dp))
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ // Time labels + seekable slider
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Current time
+ Text(
+ text = formatMs(currentPositionMs),
+ color = Color.White,
+ fontSize = 11.sp,
+ )
+ // Seekable slider
+ Box(
+ Modifier
+ .weight(1f)
+ .padding(horizontal = 8.dp)
+ .height(24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Slider(
+ value = if (isSeeking) seekPosition else progress,
+ onValueChange = { v ->
+ isSeeking = true
+ seekPosition = v
+ progress = v
+ },
+ onValueChangeFinished = {
+ player.seekTo((seekPosition * player.duration).toLong())
+ isSeeking = false
+ },
+ modifier = Modifier.fillMaxWidth(),
+ colors = SliderDefaults.colors(
+ thumbColor = Color.White,
+ activeTrackColor = Color.White,
+ inactiveTrackColor = Color.White.copy(alpha = 0.25f),
+ ),
+ )
+ }
+ // Total time
+ Text(
+ text = formatMs(totalDurationMs),
+ color = Color.White,
+ fontSize = 11.sp,
+ )
+ }
+ }
+ }
+
+ // Subtitle menu
+ if (showSubtitleMenu) {
+ LiquidGlassPanel(title = "\u5B57\u5E55", onDismiss = { showSubtitleMenu = false }) {
+ SubtitleMenuContent(player = player) { showSubtitleMenu = false }
+ }
+ }
+ // Episode menu
+ if (showEpisodeMenu) {
+ LiquidGlassPanel(title = title.ifBlank { "\u5267\u96C6\u5217\u8868" }, onDismiss = { showEpisodeMenu = false }) {
+ LazyColumn(verticalArrangement = Arrangement.spacedBy(3.dp)) {
+ items(episodes.size) { idx ->
+ val ep = episodes[idx]
+ LiquidEpisodeItem(
+ episode = ep,
+ displayIndex = ep.indexNumber ?: (idx + 1),
+ selected = ep.id == currentEpisodeId,
+ ) { showEpisodeMenu = false; onSelectEpisode(ep) }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// ===== Liquid Surface Button =====
+
+@Composable
+private fun LiquidSurfaceButton(
+ icon: ImageVector,
+ active: Boolean,
+ size: Dp = 44.dp,
+ iconSize: Dp = 22.dp,
+ onClick: () -> Unit,
+) {
+ val bd = LocalPlayerBackdrop.current
+ val circleShape = CircleShape
+ val surfaceColor = if (active) Color.White.copy(alpha = 0.40f) else Color.White.copy(alpha = 0.18f)
+
+ Box(
+ Modifier
+ .size(size)
+ .clip(circleShape)
+ .then(
+ if (bd != null) {
+ Modifier.drawBackdrop(
+ backdrop = bd,
+ shape = { Capsule() },
+ effects = {
+ blur(16f.dp.toPx())
+ vibrancy()
+ lens(8f.dp.toPx(), 14f.dp.toPx())
+ },
+ highlight = { if (active) Highlight.Default else Highlight.Plain },
+ shadow = { Shadow(radius = 10f.dp, color = Color.Black.copy(alpha = 0.20f)) },
+ innerShadow = { InnerShadow(radius = 5f.dp, color = Color.White.copy(alpha = 0.20f)) },
+ onDrawSurface = { drawRect(surfaceColor) },
+ )
+ } else {
+ Modifier.background(surfaceColor)
+ }
+ )
+ .border(0.5.dp, Color.White.copy(alpha = 0.40f), circleShape)
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(iconSize),
+ )
+ }
+}
+
+// ===== Liquid Glass Panel =====
+
+@Composable
+private fun LiquidGlassPanel(
+ title: String,
+ onDismiss: () -> Unit,
+ content: @Composable () -> Unit,
+) {
+ val bd = LocalPlayerBackdrop.current
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.25f))
+ .clickable(onClick = onDismiss),
+ contentAlignment = Alignment.CenterEnd,
+ ) {
+ val panelShape = RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp)
+ val panelMod = Modifier
+ .fillMaxHeight()
+ .width(300.dp)
+ .clip(panelShape)
+
+ val glassPanel = if (bd != null) {
+ panelMod.drawBackdrop(
+ backdrop = bd,
+ shape = { RoundedRectangle(20.dp) },
+ effects = {
+ blur(24f.dp.toPx())
+ vibrancy()
+ lens(8f.dp.toPx(), 16f.dp.toPx())
+ },
+ highlight = { Highlight.Plain },
+ shadow = { Shadow(radius = 16f.dp, color = Color.Black.copy(alpha = 0.25f)) },
+ innerShadow = { InnerShadow(radius = 6f.dp, color = Color.White.copy(alpha = 0.12f)) },
+ onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.45f)) },
+ )
+ } else {
+ panelMod.background(Color.Black.copy(alpha = 0.80f))
+ }
+
+ Column(
+ glassPanel
+ .border(0.5.dp, Brush.verticalGradient(0f to Color.White.copy(alpha = 0.30f), 1f to Color.White.copy(alpha = 0.05f)), panelShape)
+ .clickable(enabled = false, onClick = {})
+ .padding(14.dp),
+ ) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(title, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Bold)
+ Text("\u2715", color = Color.White.copy(alpha = 0.7f), fontSize = 18.sp, modifier = Modifier.clickable(onClick = onDismiss))
+ }
+ Spacer(Modifier.height(12.dp))
+ Box(Modifier.weight(1f)) { content() }
+ }
+ }
+}
+
+// ===== Subtitle Menu Content =====
+
+@Composable
+private fun SubtitleMenuContent(player: ExoPlayer, onDismiss: () -> Unit) {
+ val textTracks = remember(player.currentTracks) {
+ buildList {
+ player.currentTracks.groups.forEach { group ->
+ if (group.type == C.TRACK_TYPE_TEXT) {
+ repeat(group.length) { i ->
+ val fmt = group.getTrackFormat(i)
+ val label = listOfNotNull(
+ fmt.label, fmt.language?.uppercase(),
+ fmt.id?.takeIf { it.isNotBlank() && it != fmt.label && it != fmt.language },
+ ).distinct().joinToString(" \u00B7 ").ifBlank { "\u5B57\u5E55 {size + 1}" }
+ add(Triple(group, i, label))
+ }
+ }
+ }
+ }
+ }
+ val selId = remember(player.currentTracks) {
+ player.currentTracks.groups.firstOrNull {
+ it.type == C.TRACK_TYPE_TEXT && it.isSelected
+ }?.mediaTrackGroup?.hashCode()
+ }
+ LiquidMenuItem(text = "\u5173\u95ED\u5B57\u5E55", selected = selId == null) {
+ player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
+ .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build()
+ onDismiss()
+ }
+ textTracks.forEach { (grp, idx, label) ->
+ LiquidMenuItem(text = label, selected = selId == grp.mediaTrackGroup.hashCode()) {
+ player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
+ .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
+ .clearOverridesOfType(C.TRACK_TYPE_TEXT)
+ .addOverride(TrackSelectionOverride(grp.mediaTrackGroup, idx))
+ .build()
+ onDismiss()
+ }
+ }
+ if (textTracks.isEmpty()) {
+ Text(
+ "\u5F53\u524D\u89C6\u9891\u6CA1\u6709\u53EF\u7528\u5B57\u5E55",
+ color = Color.White.copy(alpha = 0.68f),
+ fontSize = 12.sp,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
+ )
+ }
+}
+
+// ===== Liquid Glass Menu Item =====
+
+@Composable
+private fun LiquidMenuItem(text: String, selected: Boolean, onClick: () -> Unit) {
+ val bd = LocalPlayerBackdrop.current
+ val shape = RoundedCornerShape(12.dp)
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 6.dp)
+ .clip(shape)
+ .then(
+ if (bd != null) {
+ Modifier.drawBackdrop(
+ backdrop = bd,
+ shape = { RoundedRectangle(12.dp) },
+ effects = { blur(10f.dp.toPx()); vibrancy() },
+ onDrawSurface = { drawRect(Color.White.copy(alpha = if (selected) 0.25f else 0.08f)) },
+ )
+ } else {
+ Modifier.background(Color.White.copy(alpha = if (selected) 0.25f else 0.08f))
+ }
+ )
+ .border(0.5.dp, if (selected) Color.White.copy(alpha = 0.40f) else Color.White.copy(alpha = 0.10f), shape)
+ .clickable(onClick = onClick)
+ .padding(horizontal = 13.dp, vertical = 11.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text, color = Color.White.copy(alpha = if (selected) 1f else 0.85f),
+ fontSize = 13.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
+ maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f),
+ )
+ if (selected) Text("\u2713", color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Bold)
+ }
+}
+
+// ===== Liquid Glass Episode Item =====
+
+@Composable
+private fun LiquidEpisodeItem(
+ episode: EmbyItem, displayIndex: Int, selected: Boolean, onClick: () -> Unit,
+) {
+ val bd = LocalPlayerBackdrop.current
+ val shape = RoundedCornerShape(14.dp)
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 6.dp)
+ .clip(shape)
+ .then(
+ if (bd != null) {
+ Modifier.drawBackdrop(
+ backdrop = bd,
+ shape = { RoundedRectangle(14.dp) },
+ effects = { blur(10f.dp.toPx()); vibrancy() },
+ onDrawSurface = { drawRect(Color.White.copy(alpha = if (selected) 0.25f else 0.08f)) },
+ )
+ } else {
+ Modifier.background(Color.White.copy(alpha = if (selected) 0.25f else 0.08f))
+ }
+ )
+ .border(0.5.dp, if (selected) Color.White.copy(alpha = 0.40f) else Color.White.copy(alpha = 0.10f), shape)
+ .clickable(onClick = onClick)
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ Modifier
+ .size(30.dp)
+ .clip(CircleShape)
+ .then(
+ if (bd != null) {
+ Modifier.drawBackdrop(
+ backdrop = bd,
+ shape = { RoundedCornerShape(50) },
+ effects = { blur(8f.dp.toPx()); vibrancy() },
+ onDrawSurface = { drawRect(Color.White.copy(alpha = if (selected) 0.28f else 0.14f)) },
+ )
+ } else {
+ Modifier.background(Color.White.copy(alpha = if (selected) 0.28f else 0.14f))
+ }
+ )
+ .border(0.5.dp, Color.White.copy(alpha = 0.25f), CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(displayIndex.toString(), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold)
+ }
+ Column(Modifier.weight(1f).padding(start = 10.dp)) {
+ val raw = episode.name.trim()
+ val numbered = raw.contains("\u7B2C") && raw.contains("\u96C6")
+ val label = when {
+ raw.isNotBlank() && !numbered && episode.indexNumber != null -> raw
+ raw.isNotBlank() -> raw
+ else -> "\u7B2C {episode.indexNumber ?: displayIndex} \u96C6"
+ }
+ Text(
+ label, color = Color.White, fontSize = 13.sp,
+ fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
+ maxLines = 1, overflow = TextOverflow.Ellipsis,
+ )
+ if (!selected) {
+ Text("\u5207\u6362\u64AD\u653E", color = Color.White.copy(alpha = 0.55f), fontSize = 10.sp)
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt
new file mode 100644
index 0000000..6669b0e
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt
@@ -0,0 +1,111 @@
+package com.example.myapplication.ui
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+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
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.example.myapplication.R as Res
+import com.example.myapplication.ui.liquidglass.LiquidBottomTab
+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 top.yukonga.miuix.kmp.basic.Text
+import kotlin.math.abs
+
+/* Bottom navigation backed by the Kyant0 Backdrop liquid-glass library */
+@Composable
+fun LiquidAppBottomBar(
+ selectedTab: Int,
+ onSelect: (Int) -> Unit,
+ backdrop: Backdrop,
+ 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)
+
+ LiquidBottomTabs(
+ selectedTabIndex = { selectedTab },
+ onTabSelected = onSelect,
+ backdrop = backdrop,
+ tabsCount = 5,
+ accentColor = accent,
+ modifier = modifier
+ .navigationBarsPadding()
+ .padding(start = 20.dp, end = 20.dp, bottom = 12.dp),
+ ) {
+ mainTabs.forEachIndexed { index, tab ->
+ LiquidBottomTab(onClick = { onSelect(index) }) {
+ LiquidTabContent(
+ position = index.toFloat(),
+ icon = if (selectedTab == index) tab.selectedIcon else tab.unselectedIcon,
+ label = stringResource(tab.labelRes),
+ iconSize = 21.dp,
+ color = inactive,
+ )
+ }
+ }
+ LiquidBottomTab(onClick = { onSelect(4) }) {
+ LiquidTabContent(
+ position = 4f,
+ icon = Icons.Filled.Search,
+ label = stringResource(Res.string.search),
+ iconSize = 20.dp,
+ color = inactive,
+ )
+ }
+ }
+}
+
+@Composable
+private fun LiquidTabContent(
+ position: Float,
+ icon: ImageVector,
+ label: String,
+ iconSize: Dp,
+ color: Color,
+) {
+ val isGhostLayer = LocalLiquidBottomTabsGhostLayer.current
+ val pillValue = LocalLiquidBottomTabsPillValue.current
+
+ // The glass pill samples the invisible blue capture layer. Hide the normal layer
+ // wherever the pill currently is, otherwise its dark content shows through the glass.
+ val distance = pillValue - position
+ val pillDistance = if (distance < 0f) -distance else distance
+ val normalAlpha = if (isGhostLayer || pillDistance > 0.58f) 1f else 0f
+
+ Icon(
+ imageVector = icon,
+ contentDescription = label,
+ tint = color,
+ modifier = Modifier
+ .size(iconSize)
+ .alpha(normalAlpha),
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = label,
+ color = color,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ modifier = Modifier.alpha(normalAlpha),
+ )
+}
+
+
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppLanguage.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppLanguage.kt
new file mode 100644
index 0000000..50da752
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/ui/AppLanguage.kt
@@ -0,0 +1,60 @@
+package com.example.myapplication.ui
+
+import android.app.Activity
+import android.content.Context
+import android.content.SharedPreferences
+import android.os.Build
+import android.app.LocaleManager
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.setValue
+import android.os.LocaleList
+
+object AppLanguage {
+ private const val PREF_NAME = "app_settings"
+ private const val KEY_LANGUAGE = "language"
+
+ // 0 = 简体中文, 1 = English
+ var language by mutableIntStateOf(0)
+
+ fun init(context: Context) {
+ language =
+ if (Build.VERSION.SDK_INT >= 33) {
+ val locales = context
+ .getSystemService(LocaleManager::class.java)
+ .applicationLocales
+ when (locales.get(0)?.language?.lowercase()) {
+ "en" -> 1
+ else -> 0
+ }
+ } else {
+ preferences(context).getInt(KEY_LANGUAGE, 0)
+ }
+ }
+
+ val displayName: String
+ get() = if (language == 0) "简体中文" else "English"
+
+ fun apply(context: Context, lang: Int): Activity? {
+ language = lang
+ preferences(context).edit().putInt(KEY_LANGUAGE, lang).apply()
+ val tag = if (lang == 0) "zh-CN" else "en"
+ if (Build.VERSION.SDK_INT >= 33) {
+ val lm = context.getSystemService(LocaleManager::class.java)
+ lm.applicationLocales = LocaleList.forLanguageTags(tag)
+ } else {
+ val locale = java.util.Locale.forLanguageTag(tag)
+ java.util.Locale.setDefault(locale)
+ val res = context.resources
+ val config = res.configuration
+ config.setLocale(locale)
+ @Suppress("DEPRECATION")
+ res.updateConfiguration(config, res.displayMetrics)
+ }
+ return context as? Activity
+ }
+
+ private fun preferences(context: Context): SharedPreferences {
+ return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+ }
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppTheme.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppTheme.kt
new file mode 100644
index 0000000..67b61a2
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/ui/AppTheme.kt
@@ -0,0 +1,19 @@
+package com.example.myapplication.ui
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+
+object AppTheme {
+ // 0 = Light, 1 = Dark, 2 = Follow System
+ var themeMode by mutableIntStateOf(0)
+ var systemIsDark by mutableStateOf(false)
+
+ val isDark: Boolean
+ get() = when (themeMode) {
+ 0 -> false
+ 1 -> true
+ else -> systemIsDark
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/ImmersiveBackground.kt b/app/src/main/kotlin/com/example/myapplication/ui/ImmersiveBackground.kt
new file mode 100644
index 0000000..e404714
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/ui/ImmersiveBackground.kt
@@ -0,0 +1,166 @@
+package com.example.myapplication.ui
+
+import android.graphics.drawable.BitmapDrawable
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+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.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.palette.graphics.Palette
+import coil.imageLoader
+import coil.request.ImageRequest
+import kotlin.math.abs
+
+data class PosterImmersivePalette(
+ val hero: Color,
+ val pageTop: Color,
+ val pageBottom: Color,
+ val isDark: Boolean,
+)
+
+/**
+ * Extracts an ambient palette from a poster/backdrop while preserving the real
+ * artwork hue. Light theme produces a clean pastel version; dark theme keeps a
+ * cinematic deep tone.
+ */
+@Composable
+fun rememberPosterImmersiveBackground(
+ imageUrl: String?,
+ themeDark: Boolean = GlassTokens.isDark,
+): PosterImmersivePalette {
+ var extracted by remember(imageUrl, themeDark) {
+ mutableStateOf(defaultPosterPalette(themeDark))
+ }
+ val context = LocalContext.current
+
+ LaunchedEffect(imageUrl, themeDark) {
+ if (imageUrl.isNullOrBlank()) {
+ extracted = defaultPosterPalette(themeDark)
+ return@LaunchedEffect
+ }
+
+ try {
+ val request = ImageRequest.Builder(context)
+ .data(imageUrl)
+ .allowHardware(false)
+ .size(48)
+ .build()
+ val bitmap = (context.imageLoader.execute(request).drawable as? BitmapDrawable)?.bitmap
+
+ if (bitmap != null && !bitmap.isRecycled) {
+ val palette = Palette.from(bitmap).maximumColorCount(32).generate()
+ val sourceArgb = selectRepresentativeColor(palette)
+ extracted = deriveAmbientPalette(Color(sourceArgb), themeDark)
+ }
+ } catch (_: Exception) {
+ extracted = defaultPosterPalette(themeDark)
+ }
+ }
+
+ val animatedHero by animateColorAsState(
+ targetValue = extracted.hero,
+ animationSpec = tween(durationMillis = 700),
+ label = "posterImmersiveHero",
+ )
+ val animatedPageTop by animateColorAsState(
+ targetValue = extracted.pageTop,
+ animationSpec = tween(durationMillis = 700),
+ label = "posterImmersivePageTop",
+ )
+ val animatedPageBottom by animateColorAsState(
+ targetValue = extracted.pageBottom,
+ animationSpec = tween(durationMillis = 700),
+ label = "posterImmersivePageBottom",
+ )
+
+ return PosterImmersivePalette(
+ hero = animatedHero,
+ pageTop = animatedPageTop,
+ pageBottom = animatedPageBottom,
+ isDark = extracted.isDark,
+ )
+}
+
+private fun defaultPosterPalette(themeDark: Boolean): PosterImmersivePalette = if (themeDark) {
+ PosterImmersivePalette(
+ hero = Color(0xFF10161E),
+ pageTop = Color(0xFF0D1219),
+ pageBottom = Color(0xFF090E13),
+ isDark = true,
+ )
+} else {
+ PosterImmersivePalette(
+ hero = Color(0xFFCBDBEC),
+ pageTop = Color(0xFFDCE8F4),
+ pageBottom = Color(0xFFEDF3F9),
+ isDark = false,
+ )
+}
+
+private fun selectRepresentativeColor(palette: Palette): Int {
+ val fallback = palette.getDominantColor(Color(0xFF37474F).toArgb())
+ val swatches = palette.swatches
+ .filter { it.population > 0 && it.rgb != 0 }
+ .sortedByDescending { it.population }
+ .take(8)
+
+ if (swatches.isEmpty()) return fallback
+
+ val maxPopulation = swatches.maxOf { it.population }.toFloat().coerceAtLeast(1f)
+ val hsv = FloatArray(3)
+
+ return swatches.maxByOrNull { swatch ->
+ android.graphics.Color.colorToHSV(swatch.rgb, hsv)
+ val saturation = hsv[1]
+ val value = hsv[2]
+ val colorfulness = saturation * 0.72f + (1f - abs(value - 0.56f)) * 0.28f
+ (swatch.population / maxPopulation) * 0.70f + colorfulness * 0.30f
+ }?.rgb ?: fallback
+}
+
+private fun deriveAmbientPalette(source: Color, themeDark: Boolean): PosterImmersivePalette {
+ val hsv = FloatArray(3)
+ android.graphics.Color.colorToHSV(source.toArgb(), hsv)
+
+ val hue = hsv[0]
+ val sourceSaturation = hsv[1]
+ val sourceValue = hsv[2]
+
+ return if (themeDark) {
+ val heroSaturation = (sourceSaturation * 0.58f).coerceIn(0.09f, 0.32f)
+ val heroValue = (0.055f + sourceValue * 0.13f).coerceIn(0.065f, 0.17f)
+ val pageSaturation = heroSaturation * 0.86f
+ val pageValue = (heroValue * 0.76f).coerceIn(0.035f, 0.13f)
+
+ PosterImmersivePalette(
+ hero = hsvColor(hue, heroSaturation, heroValue),
+ pageTop = hsvColor(hue, pageSaturation, heroValue * 0.92f),
+ pageBottom = hsvColor(hue, pageSaturation, pageValue),
+ isDark = true,
+ )
+ } else {
+ // Keep the dominant hue but lift it into a gentle pastel range. A deep-blue
+ // poster therefore becomes a soft blue surface rather than white/gray.
+ // Slightly richer than before, while still staying clean/light in light theme.
+ val heroSaturation = (sourceSaturation * 0.38f + 0.035f).coerceIn(0.06f, 0.21f)
+ val heroValue = (0.77f + sourceValue * 0.07f).coerceIn(0.79f, 0.85f)
+ val pageSaturation = (heroSaturation * 0.68f).coerceIn(0.04f, 0.15f)
+ val pageValue = (heroValue + 0.06f).coerceIn(0.86f, 0.91f)
+
+ PosterImmersivePalette(
+ hero = hsvColor(hue, heroSaturation, heroValue),
+ pageTop = hsvColor(hue, heroSaturation * 0.82f, heroValue + 0.025f),
+ pageBottom = hsvColor(hue, pageSaturation, pageValue),
+ isDark = false,
+ )
+ }
+}
+
+private fun hsvColor(hue: Float, saturation: Float, value: Float): Color =
+ Color(android.graphics.Color.HSVToColor(255, floatArrayOf(hue, saturation.coerceIn(0f, 1f), value.coerceIn(0f, 1f))))
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt
new file mode 100644
index 0000000..5e9c8b4
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt
@@ -0,0 +1,1101 @@
+package com.example.myapplication.ui
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+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 androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+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.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+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
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.res.stringResource
+import com.example.myapplication.R as Res
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.Dp
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.LaunchedEffect
+import kotlinx.coroutines.delay
+import androidx.compose.material.icons.outlined.ChevronRight
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.Icon
+import top.yukonga.miuix.kmp.basic.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Apps
+import androidx.compose.material.icons.filled.GridView
+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.Movie
+import androidx.compose.material.icons.filled.Settings
+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
+
+@Composable
+fun bottomBarContentPadding(extra: Dp = 86.dp): PaddingValues {
+ val systemInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
+ return PaddingValues(bottom = systemInset + extra)
+}
+
+/* ------------------------------------------------------------------ */
+/* Apple-style Liquid Glass design tokens (light) */
+/* ------------------------------------------------------------------ */
+
+object GlassTokens {
+ val isDark get() = AppTheme.isDark
+ val text: Color get() = if (isDark) Color(0xFFF2F2F7) else Color(0xFF1C1C1E)
+ val subtext: Color get() = if (isDark) Color(0xFF98989F) else Color(0xFF8E8E93)
+ val subtext2: Color get() = if (isDark) Color(0xFF636366) else Color(0xFFAEAEB2)
+ val accent = Color(0xFF0A84FF)
+ val accentNav = Color(0xFF0A84FF)
+ val danger = Color(0xFFFF3B30)
+ val dangerDark = Color(0xFFD70015)
+ val navIconSelected: Color get() = if (isDark) Color(0xFFF2F2F7) else Color(0xFF1C1C1E)
+ val navIconUnselected: Color get() = if (isDark) Color(0xFF98989F) else Color(0xFF8E8E93)
+ val navPillSelected: Color get() = if (isDark) Color(0xFF48484A) else Color(0xFFE5E5EA)
+ val separator: Color get() = if (isDark) Color(0xFF38383A) else Color(0xFFD1D1D6)
+
+ /* Liquid glass fills */
+ val glassFill: Color get() = if (isDark) Color(0xFF1C1C20).copy(alpha = 0.55f) else Color.White.copy(alpha = 0.42f)
+ val glassFillStrong: Color get() = if (isDark) Color(0xFF2C2C30).copy(alpha = 0.70f) else Color.White.copy(alpha = 0.58f)
+ val glassFillSoft: Color get() = if (isDark) Color(0xFF1C1C20).copy(alpha = 0.35f) else Color.White.copy(alpha = 0.28f)
+ val glassBorder: Color get() = if (isDark) Color(0xFF5A5A60).copy(alpha = 0.50f) else Color.White.copy(alpha = 0.65f)
+ val glassShadow: Color get() = if (isDark) Color.Black.copy(alpha = 0.25f) else Color(0xFF4A3B6B).copy(alpha = 0.08f)
+
+ /* Nav bar */
+ val navBg: Color get() = if (isDark) Color(0xFF1C1C20).copy(alpha = 0.70f) else Color.White.copy(alpha = 0.60f)
+ val navSelectedPill: Color get() = if (isDark) Color(0xFF48484A).copy(alpha = 0.80f) else Color.White.copy(alpha = 0.75f)
+ val navBgTop: Color get() = if (isDark) Color(0xFF3A3A44).copy(alpha = 0.85f) else Color.White.copy(alpha = 0.65f)
+ val navBgBottom: Color get() = if (isDark) Color(0xFF2A2A32).copy(alpha = 0.80f) else Color.White.copy(alpha = 0.42f)
+ val navBorder: Color get() = if (isDark) Color(0xFF6A6A78).copy(alpha = 0.60f) else Color.White.copy(alpha = 0.70f)
+}
+
+/* ------------------------------------------------------------------ */
+/* Colorful wallpaper so the translucent glass reads as real glass */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun LiquidGlassBackground(modifier: Modifier = Modifier) {
+ val density = androidx.compose.ui.platform.LocalDensity.current
+ val dark = AppTheme.isDark
+
+ val gradientColors = if (dark) {
+ listOf(
+ Color(0xFF0D0D12),
+ Color(0xFF12101A),
+ Color(0xFF141218),
+ Color(0xFF0E1614),
+ )
+ } else {
+ listOf(
+ Color(0xFFD4E4FF),
+ Color(0xFFE4D8FA),
+ Color(0xFFF8E0EC),
+ Color(0xFFD8F0E8),
+ )
+ }
+
+ val blobSpecs = remember(density, dark) {
+ if (dark) {
+ listOf(
+ Triple(-80f, -100f, 210f) to Color(0xFF2A3A6B).copy(alpha = 0.50f),
+ Triple(480f, 20f, 190f) to Color(0xFF3A2060).copy(alpha = 0.45f),
+ Triple(-80f, 500f, 190f) to Color(0xFF501A30).copy(alpha = 0.40f),
+ Triple(520f, 1300f, 180f) to Color(0xFF1A4038).copy(alpha = 0.40f),
+ Triple(200f, 800f, 250f) to Color(0xFF40300A).copy(alpha = 0.25f),
+ )
+ } else {
+ listOf(
+ Triple(-80f, -100f, 210f) to Color(0xFF8BB8FF).copy(alpha = 0.55f),
+ Triple(480f, 20f, 190f) to Color(0xFFD0A8FF).copy(alpha = 0.50f),
+ Triple(-80f, 500f, 190f) to Color(0xFFFFA0C8).copy(alpha = 0.45f),
+ Triple(520f, 1300f, 180f) to Color(0xFF90E0CC).copy(alpha = 0.45f),
+ Triple(200f, 800f, 250f) to Color(0xFFFFD890).copy(alpha = 0.25f),
+ )
+ }
+ }
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .drawBehind {
+ drawRect(
+ brush = Brush.verticalGradient(gradientColors)
+ )
+ val d = density.density
+ for ((pos, color) in blobSpecs) {
+ val cx = pos.first * d
+ val cy = pos.second * d
+ val r = pos.third * d
+ drawCircle(
+ brush = Brush.radialGradient(
+ colors = listOf(color, Color.Transparent),
+ center = Offset(cx, cy),
+ radius = r,
+ ),
+ radius = r,
+ center = Offset(cx, cy),
+ )
+ }
+ }
+ )
+}
+/* ------------------------------------------------------------------ */
+/* Reusable liquid-glass surface */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun GlassSurface(
+ modifier: Modifier = Modifier,
+ cornerRadius: androidx.compose.ui.unit.Dp = 22.dp,
+ fill: Color = GlassTokens.glassFill,
+ border: Color = GlassTokens.glassBorder,
+ shadowElevation: androidx.compose.ui.unit.Dp = 8.dp,
+ padding: PaddingValues = PaddingValues(16.dp),
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+ verticalArrangement: Arrangement.Vertical = Arrangement.Top,
+ highlight: Boolean = true,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Box(
+ modifier
+ .shadow(
+ elevation = shadowElevation,
+ shape = RoundedCornerShape(cornerRadius),
+ ambientColor = GlassTokens.glassShadow,
+ spotColor = GlassTokens.glassShadow,
+ )
+ .clip(RoundedCornerShape(cornerRadius))
+ .background(fill)
+ .then(
+ if (border != Color.Unspecified) {
+ Modifier.border(0.5.dp, border, RoundedCornerShape(cornerRadius))
+ } else {
+ Modifier
+ }
+ )
+ ) {
+ if (highlight) {
+ // Specular highlight — subtle in light mode, very soft in dark mode
+ val hl = if (GlassTokens.isDark) 0.06f else 0.50f
+ val hlSoft = if (GlassTokens.isDark) 0.03f else 0.22f
+ val sheen = if (GlassTokens.isDark) 0.02f else 0.10f
+ Box(
+ Modifier
+ .matchParentSize()
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = hl),
+ 0.04f to Color.White.copy(alpha = hlSoft),
+ 0.15f to Color.Transparent,
+ 0.85f to Color.Transparent,
+ 1f to Color.White.copy(alpha = sheen),
+ )
+ )
+ )
+ }
+ Column(
+ Modifier.padding(padding),
+ horizontalAlignment = horizontalAlignment,
+ verticalArrangement = verticalArrangement,
+ content = content,
+ )
+ }
+}
+/* ------------------------------------------------------------------ */
+/* Liquid glass top bar — gradient fade into content */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun LiquidGlassTopBar(
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ 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()
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Section header (title + optional trailing action) */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun SectionHeader(
+ title: String,
+ modifier: Modifier = Modifier,
+ actionLabel: String? = null,
+ onAction: (() -> Unit)? = null,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = title,
+ color = GlassTokens.text,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ if (actionLabel != null) {
+ Box(
+ Modifier
+ .clickable(enabled = onAction != null) { onAction?.invoke() }
+ .padding(4.dp)
+ ) {
+ Text(
+ text = actionLabel,
+ color = GlassTokens.accent,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Gradient "poster" used wherever a thumbnail is needed */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun PosterBox(
+ colors: List,
+ title: String,
+ subtitle: String = "",
+ modifier: Modifier = Modifier,
+ height: androidx.compose.ui.unit.Dp = 150.dp,
+ progress: Float? = null,
+) {
+ Box(
+ modifier
+ .fillMaxWidth()
+ .height(height)
+ .clip(RoundedCornerShape(16.dp))
+ .background(
+ Brush.verticalGradient(
+ colors + listOf(colors.last().copy(alpha = 0.9f))
+ )
+ )
+ ) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = 0.22f),
+ 0.4f to Color.Transparent,
+ )
+ )
+ )
+ Column(
+ Modifier
+ .align(Alignment.BottomStart)
+ .padding(12.dp)
+ ) {
+ Text(
+ text = title,
+ color = Color.White,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ if (subtitle.isNotEmpty()) {
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = subtitle,
+ color = Color.White.copy(alpha = 0.82f),
+ fontSize = 11.sp,
+ )
+ }
+ }
+ if (progress != null) {
+ Box(
+ Modifier
+ .align(Alignment.BottomStart)
+ .fillMaxWidth()
+ .height(3.dp)
+ .background(Color.White.copy(alpha = 0.35f))
+ ) {
+ Box(
+ Modifier
+ .fillMaxWidth(progress.coerceIn(0f, 1f))
+ .height(3.dp)
+ .background(Color.White)
+ )
+ }
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Bottom navigation: 4 glass tabs + floating search FAB */
+/* ------------------------------------------------------------------ */
+
+data class NavTab(
+ val labelRes: Int,
+ val selectedIcon: ImageVector,
+ val unselectedIcon: ImageVector,
+)
+
+val mainTabs = listOf(
+ NavTab(com.example.myapplication.R.string.nav_home, Icons.Outlined.Home, Icons.Outlined.Home),
+ NavTab(com.example.myapplication.R.string.nav_records, Icons.Filled.Apps, Icons.Filled.Apps),
+ NavTab(com.example.myapplication.R.string.nav_library, Icons.Filled.Movie, Icons.Filled.Movie),
+ NavTab(com.example.myapplication.R.string.nav_settings, Icons.Filled.Settings, Icons.Outlined.Settings),
+)
+
+
+
+
+@Composable
+fun LiquidGlassNavBar(
+ selectedTab: Int,
+ onSelect: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+ darkSurface: Boolean = GlassTokens.isDark,
+ backdrop: Backdrop? = null,
+ hazeState: HazeState? = null,
+) {
+ val surfaceSpec = tween(durationMillis = 260)
+ val activeColor by animateColorAsState(
+ targetValue = if (darkSurface) Color(0xFF4ADE80) else Color(0xFF16A34A),
+ animationSpec = surfaceSpec,
+ label = "navActiveColor",
+ )
+ val inactiveColor by animateColorAsState(
+ targetValue = if (darkSurface) Color(0xFFF3F4F6) else Color(0xFF111111),
+ animationSpec = surfaceSpec,
+ label = "navInactiveColor",
+ )
+ val glassTint = if (darkSurface) {
+ Color(0xFF15171B).copy(alpha = 1f)
+ } else {
+ Color(0xFFEDF0F6).copy(alpha = 1f)
+ }
+ val activeGradient = if (darkSurface) {
+ listOf(Color(0xFF7DD3FC), Color(0xFF60A5FA), Color(0xFFA5B4FC))
+ } else {
+ listOf(Color(0xFF38BDF8), Color(0xFF3B82F6), Color(0xFF818CF8))
+ }
+
+ val barShape = RoundedCornerShape(28.dp)
+
+ fun Modifier.liquidGlass(): Modifier =
+ if (hazeState != null) {
+ hazeEffect(
+ state = hazeState,
+ style = HazeStyle(
+ backgroundColor = Color.Transparent,
+ tint = HazeTint(glassTint),
+ blurRadius = 50.dp,
+ noiseFactor = 0.02f,
+ ),
+ )
+ } else this
+
+ @Composable
+ fun GlassBar(
+ modifier: Modifier = Modifier,
+ content: @Composable RowScope.() -> Unit,
+ ) {
+ Box(
+ modifier = modifier
+ .height(56.dp)
+ .shadow(
+ 10.dp,
+ barShape,
+ ambientColor = Color.Black.copy(alpha = 0.12f),
+ spotColor = Color.Black.copy(alpha = 0.12f),
+ )
+ .clip(barShape)
+ .liquidGlass()
+ .background(
+ Brush.verticalGradient(
+ listOf(
+ Color.White.copy(alpha = if (darkSurface) 0.06f else 0.40f),
+ Color.Transparent,
+ Color(0xFFCBD0DA).copy(alpha = if (darkSurface) 0.08f else 0.30f),
+ )
+ )
+ )
+ .border(0.6.dp, Color.White.copy(alpha = if (darkSurface) 0.24f else 0.42f), barShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically,
+ content = content,
+ )
+ }
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(start = 26.dp, end = 26.dp, top = 4.dp, bottom = 14.dp),
+ contentAlignment = Alignment.BottomCenter,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .widthIn(max = 350.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ GlassBar(modifier = Modifier.weight(1f)) {
+ mainTabs.forEachIndexed { index, tab ->
+ val isSelected = selectedTab == index
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val scale by animateFloatAsState(
+ targetValue = if (isPressed) 0.92f else 1f,
+ animationSpec = spring(stiffness = 550f),
+ label = "navScale",
+ )
+
+ val pillShape = RoundedCornerShape(23.dp)
+ val selectedHaze = if (isSelected) {
+ Modifier.background(
+ Color.White.copy(alpha = if (darkSurface) 0.16f else 0.45f),
+ pillShape,
+ )
+ } else {
+ Modifier
+ }
+
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .padding(horizontal = 3.dp, vertical = 4.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ modifier = Modifier
+ .widthIn(min = 58.dp)
+ .fillMaxHeight()
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ }
+ .clip(pillShape)
+ .then(selectedHaze)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ ) { onSelect(index) },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = if (isSelected) tab.selectedIcon else tab.unselectedIcon,
+ contentDescription = stringResource(tab.labelRes),
+ modifier = Modifier
+ .size(21.dp)
+ .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
+ .then(
+ if (isSelected) {
+ Modifier.drawWithCache {
+ val brush = Brush.linearGradient(activeGradient)
+ onDrawWithContent {
+ drawContent()
+ drawRect(brush, blendMode = BlendMode.SrcIn)
+ }
+ }
+ } else {
+ Modifier
+ }
+ ),
+ tint = if (isSelected) Color.Unspecified else inactiveColor,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = stringResource(tab.labelRes),
+ fontSize = 10.sp,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
+ maxLines = 1,
+ style = if (isSelected) {
+ androidx.compose.ui.text.TextStyle(
+ brush = Brush.linearGradient(activeGradient),
+ )
+ } else {
+ androidx.compose.ui.text.TextStyle(color = inactiveColor)
+ },
+ )
+ }
+ }
+ }
+ }
+
+ // Compact search entry shares the same row and liquid-glass material.
+ val searchInteraction = remember { MutableInteractionSource() }
+ val searchPressed by searchInteraction.collectIsPressedAsState()
+ val searchScale by animateFloatAsState(
+ targetValue = if (searchPressed) 0.94f else 1f,
+ animationSpec = spring(stiffness = 550f),
+ label = "navSearchScale",
+ )
+ val searchSelected = selectedTab == 4
+ val searchPillShape = RoundedCornerShape(23.dp)
+ val searchHaze = if (searchSelected) {
+ Modifier.background(
+ Color.White.copy(alpha = if (darkSurface) 0.16f else 0.45f),
+ searchPillShape,
+ )
+ } else {
+ Modifier
+ }
+
+ GlassBar(modifier = Modifier.width(84.dp)) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer {
+ scaleX = searchScale
+ scaleY = searchScale
+ }
+ .clickable(
+ interactionSource = searchInteraction,
+ indication = null,
+ ) { onSelect(4) },
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(
+ modifier = Modifier
+ .clip(searchPillShape)
+ .then(searchHaze)
+ .padding(horizontal = 14.dp, vertical = 7.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Search,
+ contentDescription = stringResource(Res.string.search),
+ tint = if (searchSelected) Color.Unspecified else inactiveColor,
+ modifier = Modifier
+ .size(19.dp)
+ .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
+ .then(
+ if (searchSelected) {
+ Modifier.drawWithCache {
+ val brush = Brush.linearGradient(activeGradient)
+ onDrawWithContent {
+ drawContent()
+ drawRect(brush, blendMode = BlendMode.SrcIn)
+ }
+ }
+ } else {
+ Modifier
+ }
+ ),
+ )
+ Text(
+ text = stringResource(Res.string.search),
+ fontSize = 11.sp,
+ maxLines = 1,
+ style = if (searchSelected) {
+ androidx.compose.ui.text.TextStyle(
+ brush = Brush.linearGradient(activeGradient),
+ )
+ } else {
+ androidx.compose.ui.text.TextStyle(color = inactiveColor)
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+/* ------------------------------------------------------------------ */
+/* Segmented control (收藏 / 日历) */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun SegmentedControl(
+ items: List,
+ selected: Int,
+ onSelect: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier
+ .clip(RoundedCornerShape(20.dp))
+ .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
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(16.dp))
+ .background(
+ if (sel) Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = 0.70f),
+ 1f to Color.White.copy(alpha = 0.50f),
+ ) else Brush.verticalGradient(
+ listOf(Color.Transparent, Color.Transparent),
+ )
+ )
+ .then(
+ if (sel) Modifier.border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) else Modifier
+ )
+ .clickable { onSelect(index) }
+ .padding(vertical = 9.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = label,
+ color = if (sel) GlassTokens.text else GlassTokens.subtext,
+ fontSize = 14.sp,
+ fontWeight = if (sel) FontWeight.SemiBold else FontWeight.Normal,
+ )
+ }
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Glass search field (magnifier + placeholder + gear) */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun GlassSearchField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ placeholder: String,
+ onGearClick: () -> Unit = {},
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(22.dp))
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = 0.40f),
+ 1f to Color.White.copy(alpha = 0.25f),
+ )
+ )
+ .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(22.dp))
+ .padding(horizontal = 14.dp, vertical = 13.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ androidx.compose.material3.Icon(
+ imageVector = Icons.Outlined.Search,
+ contentDescription = null,
+ tint = GlassTokens.subtext,
+ modifier = Modifier.size(20.dp),
+ )
+ Spacer(Modifier.width(10.dp))
+ androidx.compose.foundation.text.BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ singleLine = true,
+ textStyle = androidx.compose.ui.text.TextStyle(
+ color = GlassTokens.text,
+ fontSize = 15.sp,
+ ),
+ modifier = Modifier.weight(1f),
+ decorationBox = { inner ->
+ if (value.isEmpty()) {
+ Text(text = placeholder, color = GlassTokens.subtext, fontSize = 15.sp)
+ } else {
+ inner()
+ }
+ },
+ )
+ Spacer(Modifier.width(6.dp))
+ Box(
+ Modifier
+ .size(28.dp)
+ .clickable { onGearClick() },
+ contentAlignment = Alignment.Center,
+ ) {
+ androidx.compose.material3.Icon(
+ imageVector = Icons.Outlined.Settings,
+ contentDescription = stringResource(Res.string.search_settings),
+ tint = GlassTokens.subtext,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+}
+/* ------------------------------------------------------------------ */
+/* Glass-styled dropdown popup (replaces Material3 DropdownMenu) */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun GlassPopup(
+ expanded: Boolean,
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = onDismissRequest,
+ modifier = modifier
+ .width(IntrinsicSize.Min)
+ .shadow(20.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black.copy(alpha = 0.1f), spotColor = Color.Black.copy(alpha = 0.06f))
+ .clip(RoundedCornerShape(20.dp))
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = 0.85f),
+ 0.5f to Color.White.copy(alpha = 0.72f),
+ 1f to Color.White.copy(alpha = 0.62f),
+ )
+ )
+ .border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(20.dp))
+ .padding(vertical = 6.dp),
+ shape = RoundedCornerShape(20.dp),
+ containerColor = Color.Transparent,
+ tonalElevation = 0.dp,
+ shadowElevation = 0.dp,
+ ) {
+ content()
+ }
+}
+
+@Composable
+fun GlassPopupItem(
+ text: String,
+ icon: ImageVector? = null,
+ tint: Color = GlassTokens.text,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick() }
+ .padding(horizontal = 18.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (icon != null) {
+ Icon(imageVector = icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
+ }
+ Text(text = text, color = tint, fontSize = 15.sp, fontWeight = FontWeight.Medium)
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Glass press feedback modifier */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun Modifier.glassPressEffect(): Modifier {
+ var pressed by remember { mutableStateOf(false) }
+ val bg by animateColorAsState(
+ targetValue = if (pressed) Color.White.copy(alpha = 0.15f) else Color.Transparent,
+ animationSpec = tween(durationMillis = if (pressed) 80 else 200),
+ label = "glassPress",
+ )
+ LaunchedEffect(pressed) {
+ if (pressed) { delay(150); pressed = false }
+ }
+ return this
+ .background(bg)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) { pressed = true }
+}
+
+/* ------------------------------------------------------------------ */
+/* Glass-styled button (accent or secondary) */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun GlassButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ accent: Boolean = false,
+ danger: Boolean = false,
+) {
+ Box(
+ modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(
+ when {
+ danger -> Brush.verticalGradient(listOf(GlassTokens.danger, GlassTokens.dangerDark))
+ accent -> Brush.verticalGradient(listOf(GlassTokens.accent, Color(0xFF0A6DD4)))
+ else -> Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = 0.45f),
+ 1f to Color.White.copy(alpha = 0.25f),
+ )
+ }
+ )
+ .then(
+ when {
+ danger || accent -> Modifier.border(0.5.dp, Color.White.copy(alpha = 0.3f), RoundedCornerShape(16.dp))
+ else -> Modifier.border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
+ }
+ )
+ .clickable(enabled = enabled) { onClick() }
+ .padding(vertical = 14.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = text,
+ color = if (danger || accent) Color.White.copy(alpha = if (enabled) 1f else 0.65f) else GlassTokens.subtext,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Glass-styled dialog wrapper */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun GlassDialog(
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ Dialog(onDismissRequest = onDismissRequest) {
+ Box(
+ modifier
+ .width(IntrinsicSize.Min)
+ .shadow(24.dp, RoundedCornerShape(24.dp), ambientColor = Color.Black.copy(alpha = 0.08f), spotColor = Color.Black.copy(alpha = 0.04f))
+ .clip(RoundedCornerShape(24.dp))
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = 0.88f),
+ 0.5f to Color.White.copy(alpha = 0.75f),
+ 1f to Color.White.copy(alpha = 0.65f),
+ )
+ )
+ .border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(24.dp))
+ .padding(20.dp),
+ ) {
+ content()
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Glass-styled input field with label */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun GlassInputField(
+ label: String,
+ value: TextFieldValue,
+ placeholder: String,
+ onValueChange: (TextFieldValue) -> Unit,
+ modifier: Modifier = Modifier,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ trailingContent: (@Composable () -> Unit)? = null,
+ isError: Boolean = false,
+ errorMessage: String? = null,
+ focusRequester: FocusRequester? = null,
+) {
+ Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(label, fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
+ Box(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(if (isError) 16.dp else 16.dp))
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = if (isError) 0.42f else 0.50f),
+ 1f to Color.White.copy(alpha = if (isError) 0.26f else 0.30f),
+ )
+ )
+ .border(
+ 1.dp,
+ if (isError) Color(0xFFFF3B30).copy(alpha = 0.90f) else Color.White.copy(alpha = 0.55f),
+ RoundedCornerShape(16.dp),
+ )
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ ) {
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ singleLine = true,
+ textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 16.sp),
+ modifier = Modifier.fillMaxWidth().then(
+ focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier
+ ),
+ cursorBrush = androidx.compose.ui.graphics.SolidColor(GlassTokens.accent),
+ visualTransformation = visualTransformation,
+ decorationBox = { inner ->
+ Row(Modifier.fillMaxWidth().height(24.dp), verticalAlignment = Alignment.CenterVertically) {
+ Box(Modifier.weight(1f)) {
+ if (value.text.isEmpty()) Text(placeholder, color = GlassTokens.subtext, fontSize = 16.sp)
+ inner()
+ }
+ if (trailingContent != null) {
+ Spacer(Modifier.width(8.dp))
+ trailingContent()
+ }
+ }
+ }
+ )
+ }
+ if (isError && !errorMessage.isNullOrEmpty()) {
+ Text(
+ text = errorMessage,
+ color = Color(0xFFFF3B30),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(start = 4.dp),
+ )
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Glass-styled settings row */
+/* ------------------------------------------------------------------ */
+
+@Composable
+fun GlassSettingRow(
+ icon: ImageVector,
+ iconTint: Color,
+ bgTint: Color,
+ title: String,
+ subtitle: String = "",
+ onClick: () -> Unit = {},
+) {
+ var pressed by remember { mutableStateOf(false) }
+ val pressBg by animateColorAsState(
+ targetValue = if (pressed) Color.White.copy(alpha = 0.18f) else Color.Transparent,
+ animationSpec = tween(durationMillis = if (pressed) 80 else 200),
+ label = "glassSettingPress",
+ )
+ LaunchedEffect(pressed) { if (pressed) { delay(150); pressed = false } }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(pressBg)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) { pressed = true; onClick() }
+ .padding(vertical = 12.dp, horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier
+ .size(38.dp)
+ .clip(RoundedCornerShape(11.dp))
+ .background(bgTint),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = iconTint,
+ modifier = Modifier.size(19.dp),
+ )
+ }
+ Spacer(Modifier.width(14.dp))
+ Column {
+ Text(text = title, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium)
+ if (subtitle.isNotEmpty()) {
+ Spacer(Modifier.height(2.dp))
+ Text(text = subtitle, color = GlassTokens.subtext, fontSize = 12.sp)
+ }
+ }
+ }
+ Icon(
+ imageVector = Icons.Outlined.ChevronRight,
+ contentDescription = null,
+ tint = GlassTokens.subtext2,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+}
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/DampedDragAnimation.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/DampedDragAnimation.kt
new file mode 100644
index 0000000..9c012ae
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/DampedDragAnimation.kt
@@ -0,0 +1,137 @@
+package com.example.myapplication.ui.liquidglass
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.MutatorMutex
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.input.pointer.util.VelocityTracker
+import androidx.compose.ui.unit.IntSize
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.android.awaitFrame
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+
+class DampedDragAnimation(
+ private val animationScope: CoroutineScope,
+ val initialValue: Float,
+ val valueRange: ClosedRange,
+ val visibilityThreshold: Float,
+ val initialScale: Float,
+ val pressedScale: Float,
+ val onDragStarted: DampedDragAnimation.(position: Offset) -> Unit,
+ val onDragStopped: DampedDragAnimation.() -> Unit,
+ val onDrag: DampedDragAnimation.(size: IntSize, dragAmount: Offset) -> Unit,
+) {
+
+ private val valueAnimationSpec =
+ spring(1f, 1000f, visibilityThreshold)
+ private val velocityAnimationSpec =
+ spring(0.5f, 300f, visibilityThreshold * 10f)
+ private val pressProgressAnimationSpec =
+ spring(1f, 1000f, 0.001f)
+ private val scaleXAnimationSpec =
+ spring(0.6f, 250f, 0.001f)
+ private val scaleYAnimationSpec =
+ spring(0.7f, 250f, 0.001f)
+
+ private val valueAnimation =
+ Animatable(initialValue, visibilityThreshold)
+ private val velocityAnimation =
+ Animatable(0f, 5f)
+ private val pressProgressAnimation =
+ Animatable(0f, 0.001f)
+ private val scaleXAnimation =
+ Animatable(initialScale, 0.001f)
+ private val scaleYAnimation =
+ Animatable(initialScale, 0.001f)
+
+ private val mutatorMutex = MutatorMutex()
+
+ private val velocityTracker = VelocityTracker()
+
+ val value: Float get() = valueAnimation.value
+ val progress: Float get() = (value - valueRange.start) / (valueRange.endInclusive - valueRange.start)
+ val targetValue: Float get() = valueAnimation.targetValue
+ val pressProgress: Float get() = pressProgressAnimation.value
+ val scaleX: Float get() = scaleXAnimation.value
+ val scaleY: Float get() = scaleYAnimation.value
+ val velocity: Float get() = velocityAnimation.value
+
+ val modifier: Modifier = Modifier.pointerInput(Unit) {
+ inspectDragGestures(
+ onDragStart = { down ->
+ onDragStarted(down.position)
+ press()
+ },
+ onDragEnd = {
+ onDragStopped()
+ release()
+ },
+ onDragCancel = {
+ onDragStopped()
+ release()
+ }
+ ) { change, dragAmount ->
+ onDrag(size, dragAmount)
+ }
+ }
+
+ fun press() {
+ velocityTracker.resetTracking()
+ animationScope.launch {
+ launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) }
+ launch { scaleXAnimation.animateTo(pressedScale, scaleXAnimationSpec) }
+ launch { scaleYAnimation.animateTo(pressedScale, scaleYAnimationSpec) }
+ }
+ }
+
+ fun release() {
+ animationScope.launch {
+ awaitFrame()
+ if (value != targetValue) {
+ val threshold = (valueRange.endInclusive - valueRange.start) * 0.025f
+ snapshotFlow { valueAnimation.value }
+ .filter { abs(it - valueAnimation.targetValue) < threshold }
+ .first()
+ }
+ launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) }
+ launch { scaleXAnimation.animateTo(initialScale, scaleXAnimationSpec) }
+ launch { scaleYAnimation.animateTo(initialScale, scaleYAnimationSpec) }
+ }
+ }
+
+ fun updateValue(value: Float) {
+ val targetValue = value.coerceIn(valueRange)
+ animationScope.launch {
+ launch { valueAnimation.animateTo(targetValue, valueAnimationSpec) { updateVelocity() } }
+ }
+ }
+
+ fun animateToValue(value: Float) {
+ animationScope.launch {
+ mutatorMutex.mutate {
+ press()
+ val targetValue = value.coerceIn(valueRange)
+ launch { valueAnimation.animateTo(targetValue, valueAnimationSpec) }
+ if (velocity != 0f) {
+ launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) }
+ }
+ release()
+ }
+ }
+ }
+
+ private fun updateVelocity() {
+ velocityTracker.addPosition(
+ System.currentTimeMillis(),
+ Offset(value, 0f)
+ )
+ val targetVelocity = velocityTracker.calculateVelocity().x / (valueRange.endInclusive - valueRange.start)
+ animationScope.launch { velocityAnimation.animateTo(targetVelocity, velocityAnimationSpec) }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/DragGestureInspector.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/DragGestureInspector.kt
new file mode 100644
index 0000000..678deba
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/DragGestureInspector.kt
@@ -0,0 +1,85 @@
+package com.example.myapplication.ui.liquidglass
+
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.AwaitPointerEventScope
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerId
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
+import androidx.compose.ui.input.pointer.positionChange
+import androidx.compose.ui.util.fastFirstOrNull
+
+suspend fun PointerInputScope.inspectDragGestures(
+ onDragStart: (down: PointerInputChange) -> Unit = {},
+ onDragEnd: (change: PointerInputChange) -> Unit = {},
+ onDragCancel: () -> Unit = {},
+ onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
+) {
+ awaitEachGesture {
+ val initialDown = awaitFirstDown(false, PointerEventPass.Initial)
+
+ val down = awaitFirstDown(false)
+ val drag = initialDown
+
+ onDragStart(down)
+ onDrag(drag, Offset.Zero)
+ val upEvent =
+ drag(
+ pointerId = drag.id,
+ onDrag = { onDrag(it, it.positionChange()) }
+ )
+ if (upEvent == null) {
+ onDragCancel()
+ } else {
+ onDragEnd(upEvent)
+ }
+ }
+}
+
+private suspend inline fun AwaitPointerEventScope.drag(
+ pointerId: PointerId,
+ onDrag: (PointerInputChange) -> Unit
+): PointerInputChange? {
+ val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true
+ if (isPointerUp) {
+ return null
+ }
+ var pointer = pointerId
+ while (true) {
+ val change = awaitDragOrUp(pointer) ?: return null
+ if (change.isConsumed) {
+ return null
+ }
+ if (change.changedToUpIgnoreConsumed()) {
+ return change
+ }
+ onDrag(change)
+ pointer = change.id
+ }
+}
+
+private suspend inline fun AwaitPointerEventScope.awaitDragOrUp(
+ pointerId: PointerId
+): PointerInputChange? {
+ var pointer = pointerId
+ while (true) {
+ val event = awaitPointerEvent()
+ val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null
+ if (dragEvent.changedToUpIgnoreConsumed()) {
+ val otherDown = event.changes.fastFirstOrNull { it.pressed }
+ if (otherDown == null) {
+ return dragEvent
+ } else {
+ pointer = otherDown.id
+ }
+ } else {
+ val hasDragged = dragEvent.previousPosition != dragEvent.position
+ if (hasDragged) {
+ return dragEvent
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/InteractiveHighlight.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/InteractiveHighlight.kt
new file mode 100644
index 0000000..788ff51
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/InteractiveHighlight.kt
@@ -0,0 +1,121 @@
+package com.example.myapplication.ui.liquidglass
+
+import android.graphics.RuntimeShader
+import android.os.Build
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ShaderBrush
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.util.fastCoerceIn
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+class InteractiveHighlight(
+ val animationScope: CoroutineScope,
+ val position: (size: Size, offset: Offset) -> Offset = { _, offset -> offset }
+) {
+
+ private val pressProgressAnimationSpec =
+ spring(0.5f, 300f, 0.001f)
+ private val positionAnimationSpec =
+ spring(0.5f, 300f, Offset.VisibilityThreshold)
+
+ private val pressProgressAnimation =
+ Animatable(0f, 0.001f)
+ private val positionAnimation =
+ Animatable(Offset.Zero, Offset.VectorConverter, Offset.VisibilityThreshold)
+
+ private var startPosition = Offset.Zero
+ val pressProgress: Float get() = pressProgressAnimation.value
+ val offset: Offset get() = positionAnimation.value - startPosition
+
+ private val shader =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ RuntimeShader(
+ """
+uniform float2 size;
+layout(color) uniform half4 color;
+uniform float radius;
+uniform float2 position;
+
+half4 main(float2 coord) {
+ float dist = distance(coord, position);
+ float intensity = smoothstep(radius, radius * 0.5, dist);
+ return color * intensity;
+}"""
+ )
+ } else {
+ null
+ }
+
+ val modifier: Modifier =
+ Modifier.drawWithContent {
+ val progress = pressProgressAnimation.value
+ if (progress > 0f) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && shader != null) {
+ drawRect(
+ Color.White.copy(0.08f * progress),
+ blendMode = BlendMode.Plus
+ )
+ shader.apply {
+ val position = position(size, positionAnimation.value)
+ setFloatUniform("size", size.width, size.height)
+ setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
+ setFloatUniform("radius", size.minDimension * 1.5f)
+ setFloatUniform(
+ "position",
+ position.x.fastCoerceIn(0f, size.width),
+ position.y.fastCoerceIn(0f, size.height)
+ )
+ }
+ drawRect(
+ ShaderBrush(shader),
+ blendMode = BlendMode.Plus
+ )
+ } else {
+ drawRect(
+ Color.White.copy(0.25f * progress),
+ blendMode = BlendMode.Plus
+ )
+ }
+ }
+
+ drawContent()
+ }
+
+ val gestureModifier: Modifier =
+ Modifier.pointerInput(animationScope) {
+ inspectDragGestures(
+ onDragStart = { down ->
+ startPosition = down.position
+ animationScope.launch {
+ launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) }
+ launch { positionAnimation.snapTo(startPosition) }
+ }
+ },
+ onDragEnd = {
+ animationScope.launch {
+ launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) }
+ launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) }
+ }
+ },
+ onDragCancel = {
+ animationScope.launch {
+ launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) }
+ launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) }
+ }
+ }
+ ) { change, _ ->
+ animationScope.launch { positionAnimation.snapTo(change.position) }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTab.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTab.kt
new file mode 100644
index 0000000..9f3df6b
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTab.kt
@@ -0,0 +1,49 @@
+package com.example.myapplication.ui.liquidglass
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.dp
+import com.kyant.shapes.Capsule
+
+internal val LocalLiquidBottomTabScale =
+ staticCompositionLocalOf { { 1f } }
+
+@Composable
+fun RowScope.LiquidBottomTab(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ val scale = LocalLiquidBottomTabScale.current
+ Column(
+ modifier
+ .clip(Capsule())
+ .clickable(
+ interactionSource = null,
+ indication = null,
+ role = Role.Tab,
+ onClick = onClick
+ )
+ .fillMaxHeight()
+ .weight(1f)
+ .graphicsLayer {
+ val scale = scale()
+ scaleX = scale
+ scaleY = scale
+ },
+ verticalArrangement = Arrangement.spacedBy(2f.dp, Alignment.CenterVertically),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ content = content
+ )
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt
new file mode 100644
index 0000000..b924a55
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt
@@ -0,0 +1,301 @@
+package com.example.myapplication.ui.liquidglass
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.EaseOut
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastCoerceIn
+import androidx.compose.ui.util.fastRoundToInt
+import androidx.compose.ui.util.lerp
+import com.kyant.backdrop.Backdrop
+import com.kyant.backdrop.backdrops.layerBackdrop
+import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
+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.backdrop.highlight.Highlight
+import com.kyant.backdrop.shadow.InnerShadow
+import com.kyant.backdrop.shadow.Shadow
+import com.kyant.shapes.Capsule
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+import kotlin.math.sign
+
+/** true = the hidden accent-capture layer, false = the visible base layer */
+val LocalLiquidBottomTabsGhostLayer = staticCompositionLocalOf { false }
+
+/** Current liquid pill position. Used to hide duplicated visible content beneath the pill. */
+val LocalLiquidBottomTabsPillValue = staticCompositionLocalOf { 0f }
+
+@Composable
+fun LiquidBottomTabs(
+ selectedTabIndex: () -> Int,
+ onTabSelected: (index: Int) -> Unit,
+ backdrop: Backdrop,
+ tabsCount: Int,
+ modifier: Modifier = Modifier,
+ accentColor: Color = if (!isSystemInDarkTheme()) Color(0xFF0A84FF) else Color(0xFF0091FF),
+ content: @Composable RowScope.() -> Unit
+) {
+ val isLightTheme = !isSystemInDarkTheme()
+ val containerColor =
+ if (isLightTheme) Color(0xFFFAFAFA).copy(0.4f)
+ else Color(0xFF121212).copy(0.4f)
+
+ val tabsBackdrop = rememberLayerBackdrop()
+
+ BoxWithConstraints(
+ modifier,
+ contentAlignment = Alignment.CenterStart
+ ) {
+ val density = LocalDensity.current
+ val tabWidth = with(density) {
+ (constraints.maxWidth.toFloat() - 8f.dp.toPx()) / tabsCount
+ }
+
+ val offsetAnimation = remember { Animatable(0f) }
+ val panelOffset by remember(density) {
+ derivedStateOf {
+ val fraction = (offsetAnimation.value / constraints.maxWidth).fastCoerceIn(-1f, 1f)
+ with(density) {
+ 4f.dp.toPx() * fraction.sign * EaseOut.transform(abs(fraction))
+ }
+ }
+ }
+
+ val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
+ val animationScope = rememberCoroutineScope()
+ var currentIndex by remember {
+ mutableIntStateOf(selectedTabIndex())
+ }
+ val dampedDragAnimation = remember(animationScope) {
+ DampedDragAnimation(
+ animationScope = animationScope,
+ initialValue = selectedTabIndex().toFloat(),
+ valueRange = 0f..(tabsCount - 1).toFloat(),
+ visibilityThreshold = 0.001f,
+ initialScale = 1f,
+ pressedScale = 78f / 56f,
+ onDragStarted = {},
+ onDragStopped = {
+ val targetIndex = targetValue.fastRoundToInt().fastCoerceIn(0, tabsCount - 1)
+ currentIndex = targetIndex
+ animateToValue(targetIndex.toFloat())
+ animationScope.launch {
+ offsetAnimation.animateTo(
+ 0f,
+ spring(1f, 300f, 0.5f)
+ )
+ }
+ },
+ onDrag = { _, dragAmount ->
+ updateValue(
+ (targetValue + dragAmount.x / tabWidth * if (isLtr) 1f else -1f)
+ .fastCoerceIn(0f, (tabsCount - 1).toFloat())
+ )
+ animationScope.launch {
+ offsetAnimation.snapTo(offsetAnimation.value + dragAmount.x)
+ }
+ }
+ )
+ }
+ val externalIndex = selectedTabIndex()
+ LaunchedEffect(externalIndex) {
+ if (externalIndex != currentIndex) {
+ currentIndex = externalIndex
+ dampedDragAnimation.animateToValue(externalIndex.toFloat())
+ }
+ }
+ LaunchedEffect(dampedDragAnimation) {
+ snapshotFlow { currentIndex }
+ .drop(1)
+ .collectLatest { index ->
+ dampedDragAnimation.animateToValue(index.toFloat())
+ onTabSelected(index)
+ }
+ }
+
+ val interactiveHighlight = remember(animationScope) {
+ InteractiveHighlight(
+ animationScope = animationScope,
+ position = { size, offset ->
+ Offset(
+ if (isLtr) (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset
+ else size.width - (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset,
+ size.height / 2f
+ )
+ }
+ )
+ }
+
+ Row(
+ Modifier
+ .graphicsLayer {
+ translationX = panelOffset
+ }
+ .drawBackdrop(
+ backdrop = backdrop,
+ shape = { Capsule() },
+ effects = {
+ vibrancy()
+ blur(8f.dp.toPx())
+ lens(24f.dp.toPx(), 24f.dp.toPx())
+ },
+ layerBlock = {
+ val progress = dampedDragAnimation.pressProgress
+ val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress)
+ scaleX = scale
+ scaleY = scale
+ },
+ onDrawSurface = { drawRect(containerColor) }
+ )
+ .then(interactiveHighlight.modifier)
+ .height(64f.dp)
+ .fillMaxWidth()
+ .padding(4f.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ content = {
+ CompositionLocalProvider(
+ LocalLiquidBottomTabsGhostLayer provides false,
+ LocalLiquidBottomTabsPillValue provides dampedDragAnimation.value,
+ ) {
+ content()
+ }
+ }
+ )
+
+ CompositionLocalProvider(
+ LocalLiquidBottomTabScale provides {
+ lerp(1f, 1.2f, dampedDragAnimation.pressProgress)
+ }
+ ) {
+ Row(
+ Modifier
+ .clearAndSetSemantics {}
+ .alpha(0f)
+ .layerBackdrop(tabsBackdrop)
+ .graphicsLayer {
+ translationX = panelOffset
+ }
+ .drawBackdrop(
+ backdrop = backdrop,
+ shape = { Capsule() },
+ effects = {
+ val progress = dampedDragAnimation.pressProgress
+ vibrancy()
+ blur(8f.dp.toPx())
+ lens(
+ 24f.dp.toPx() * progress,
+ 24f.dp.toPx() * progress
+ )
+ },
+ highlight = {
+ val progress = dampedDragAnimation.pressProgress
+ Highlight.Default.copy(alpha = progress)
+ },
+ onDrawSurface = { drawRect(containerColor) }
+ )
+ .then(interactiveHighlight.modifier)
+ .height(56f.dp)
+ .fillMaxWidth()
+ .padding(horizontal = 4f.dp)
+ .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)),
+ verticalAlignment = Alignment.CenterVertically,
+ content = {
+ CompositionLocalProvider(LocalLiquidBottomTabsGhostLayer provides true) {
+ content()
+ }
+ }
+ )
+ }
+
+ Box(
+ Modifier
+ .padding(horizontal = 4f.dp)
+ .graphicsLayer {
+ translationX =
+ if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset
+ else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset
+ }
+ .then(interactiveHighlight.gestureModifier)
+ .then(dampedDragAnimation.modifier)
+ .drawBackdrop(
+ backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop),
+ shape = { Capsule() },
+ effects = {
+ val progress = dampedDragAnimation.pressProgress
+ lens(
+ 10f.dp.toPx() * progress,
+ 14f.dp.toPx() * progress,
+ chromaticAberration = true
+ )
+ },
+ highlight = {
+ val progress = dampedDragAnimation.pressProgress
+ Highlight.Default.copy(alpha = progress)
+ },
+ innerShadow = {
+ val progress = dampedDragAnimation.pressProgress
+ InnerShadow(
+ radius = 8f.dp * progress,
+ alpha = progress
+ )
+ },
+ layerBlock = {
+ scaleX = dampedDragAnimation.scaleX
+ scaleY = dampedDragAnimation.scaleY
+ val velocity = dampedDragAnimation.velocity / 10f
+ scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f)
+ scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f)
+ },
+ onDrawSurface = {
+ val progress = dampedDragAnimation.pressProgress
+ drawRect(
+ if (isLightTheme) Color.Black.copy(0.1f)
+ else Color.White.copy(0.1f),
+ alpha = 1f - progress
+ )
+ drawRect(Color.Black.copy(alpha = 0.03f * progress))
+ }
+ )
+ .height(56f.dp)
+ .fillMaxWidth(1f / tabsCount)
+ )
+ }
+}
+
+
diff --git a/app/src/main/res/drawable-nodpi/app_start.png b/app/src/main/res/drawable-nodpi/app_start.png
new file mode 100644
index 0000000..4d3328d
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/app_start.png differ
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..eba48dc
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..eba48dc
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..a072795
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
new file mode 100644
index 0000000..33a2f35
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..c946fff
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..7816585
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..51542d7
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
new file mode 100644
index 0000000..652c73e
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..62d3170
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..b776493
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..1fb4ac0
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..95b4a4b
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..4841f19
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..6f51950
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..8d31334
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..61d4093
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..2662ffa
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..7b8afe5
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..cbcbe74
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..dde9f80
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..dac2e1d
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..0a7e774
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
new file mode 100644
index 0000000..e7889b9
--- /dev/null
+++ b/app/src/main/res/values-en/strings.xml
@@ -0,0 +1,104 @@
+
+
+ Home
+ Vision
+ Server
+ Settings
+ Settings
+ Theme
+ Light
+ Dark
+ System
+ Language
+ Switch Language
+ 简体中文
+ English
+ Playback
+ Behavior · Subtitles
+ Danmaku
+ Off
+ Cache
+ Total Cache Size
+ Icon Cache
+ Clear Cache
+ About
+ Version & Credits
+ App Info
+ A clean and elegant Emby media library manager
+ Version Info
+ Current Version
+ Cache Info
+ Back
+ Favorites
+ Calendar
+ No updates yet
+ Search media
+ Search your media
+ Type keywords to start searching
+ No results for "
+ "
+ Personal Media Center
+ More
+ Play
+ Libraries
+ All
+ Latest
+ Continue Watching
+ Default
+ Set as default icon library
+ Used first when changing library icons My Application
+ Cold War 2
+ Jul 8, 2016 Crime / Mystery
+ TV - Korea
+ TV - Chinese
+ 12 TV shows
+ 8 TV shows
+ Interstellar
+ Sci-Fi / Adventure
+ Inception
+ Sci-Fi / Action
+ The Matrix
+ Titanic
+ Romance / Disaster
+ Avatar
+ Dune
+ Tenet
+ Sci-Fi / Mystery
+ The Batman
+ Action / Crime
+ Blade Runner 2049
+ Arrival
+ Sci-Fi / Drama
+ The Martian
+ Gravity
+ Sci-Fi / Thriller
+ Black Mirror
+ Game of Thrones
+ Fantasy / Adventure
+ Breaking Bad
+ Crime / Drama
+ Stranger Things
+ Sci-Fi / Horror
+ The Mandalorian
+ Movies
+ TV Shows
+ Music
+ Books
+ Home Videos
+ Photos
+ No icons found
+ Icon library not found
+ Icon library URL is empty
+ Failed to load icons
+ Search
+ Search settings
+ Luma Player
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..e5b3487
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,14 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+ #DDE8FF
+ #FF010101
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..2e9fda5
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,104 @@
+
+
+ My Application
+ 首页
+ Vision
+ 服务器
+ 设置
+ 设置
+ 主题
+ 浅色
+ 深色
+ 系统
+ 语言
+ 切换语言
+ 简体中文
+ English
+ 播放
+ 播放行为·字幕
+ 弹幕
+ 未开启
+ 缓存
+ 缓存数据总大小
+ 图标数据缓存
+ 删除缓存
+ 关于
+ 版本与致谢
+ 应用信息
+ 一款简洁优雅的 Emby 媒体库管理工具
+ 版本信息
+ 当前版本
+ 缓存信息
+ 返回
+ 收藏
+ 日历
+ 暂无更新
+ 搜索媒体
+ 搜索你的媒体
+ 输入关键词开始搜索
+ 未找到「
+ 」相关结果
+ 个人媒体中心
+ 更多
+ 播放
+ 媒体库
+ 全部
+ 继续观看
+ 最新添加
+ 默认
+ 设为默认图标库
+ 资源库更换图标时优先使用该库 寒战2
+ 2016.7.8 警匪 / 悬疑
+ 电视 - 韩国
+ 电视 - 华语
+ 12 部剧集
+ 8 部剧集
+ 星际穿越
+ 科幻 / 冒险
+ 盗梦空间
+ 科幻 / 动作
+ 黑客帝国
+ 泰坦尼克号
+ 爱情 / 灾难
+ 阿凡达
+ 沙丘
+ 信条
+ 科幻 / 悬疑
+ 蝙蝠侠
+ 动作 / 犯罪
+ 银翼杀手2049
+ 降临
+ 科幻 / 剧情
+ 火星救援
+ 地心引力
+ 科幻 / 惊悚
+ 黑镜
+ 权力的游戏
+ 奇幻 / 冒险
+ 绝命毒师
+ 犯罪 / 剧情
+ 怪奇物语
+ 科幻 / 恐怖
+ 曼达洛人
+ 电影
+ 剧集
+ 音乐
+ 书籍
+ 家庭视频
+ 照片
+ 未找到任何图标
+ 图标库不存在
+ 图标库地址为空
+ 图标加载失败
+ 搜索
+ 搜索设置
+ Luma Player
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..a1bc890
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..4df9255
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..9122f41
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1 @@
+// Root build script. Module plugins are applied in app/build.gradle.kts.
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f304b7a
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,12 @@
+# Project-wide Gradle settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.configuration-cache=false
+kotlin.code.style=official
+
+# AndroidX
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+
+# Local JDK (Android Studio JBR)
+org.gradle.java.installations.paths=C\:\\Program Files\\Android\\Android Studio\\jbr
+android.suppressUnsupportedCompileSdk=37.0
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..6c1139e
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,12 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
+toolchainVersion=21
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..0181433
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,10 @@
+[versions]
+agp = "9.2.1"
+kotlin = "2.3.20"
+activityCompose = "1.13.0"
+
+[libraries]
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8bdaf60
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..14abb0c
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.5.0-bin.zip
+networkTimeout=300000
+validateDistributionUrl=false
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..ef07e01
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..68ae130
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,31 @@
+pluginManagement {
+ repositories {
+ maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
+ maven { url = uri("https://maven.aliyun.com/repository/google") }
+ maven { url = uri("https://maven.aliyun.com/repository/central") }
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+ resolutionStrategy {
+ eachPlugin {
+ if (requested.id.id == "org.jetbrains.kotlin.plugin.compose") {
+ val ver = requested.version ?: "2.3.20"
+ useModule("org.jetbrains.kotlin:compose-compiler-gradle-plugin:" + ver)
+ }
+ }
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ maven { url = uri("https://maven.aliyun.com/repository/google") }
+ maven { url = uri("https://maven.aliyun.com/repository/central") }
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "EmbyPlayer"
+include(":app")