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")