From d3ce44e3daabed5db0d6468c840990bcdb8233a4 Mon Sep 17 00:00:00 2001 From: BoatingCloud <1759888028@qq.com> Date: Thu, 27 Aug 2026 22:08:35 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E5=B9=B6=E5=AE=8C=E5=96=84=E8=87=AA=E9=80=82?= =?UTF-8?q?=E5=BA=94=E6=B6=B2=E6=80=81=E7=8E=BB=E7=92=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 8 +- .../com/example/myapplication/MainActivity.kt | 9 + .../com/example/myapplication/MainApp.kt | 39 +- .../myapplication/data/EmbyApiModels.kt | 2 + .../myapplication/data/EmbyRepository.kt | 235 ++++- .../example/myapplication/model/MediaItem.kt | 38 - .../myapplication/navigation/AppNavHost.kt | 42 +- .../navigation/NavigationTransitions.kt | 14 +- .../myapplication/screens/HomeScreen.kt | 209 ++-- .../myapplication/screens/LibraryScreen.kt | 908 +++++++++--------- .../screens/MediaDetailScreen.kt | 287 +++--- .../myapplication/screens/RecordsScreen.kt | 70 +- .../myapplication/screens/SearchScreen.kt | 66 +- .../screens/SettingsChildScreens.kt | 360 +++++-- .../myapplication/screens/SettingsScreen.kt | 267 +++-- .../screens/VideoPlayerScreen.kt | 337 ++++--- .../example/myapplication/ui/AppBottomBar.kt | 101 +- .../com/example/myapplication/ui/AppTheme.kt | 22 +- .../example/myapplication/ui/LiquidGlass.kt | 517 +++++----- .../myapplication/ui/design/LumaComponents.kt | 217 +++++ .../ui/design/LumaDesignSystem.kt | 387 ++++++++ .../myapplication/ui/design/LumaIcons.kt | 138 +++ .../ui/liquidglass/InteractiveHighlight.kt | 66 +- .../ui/liquidglass/LiquidBottomTab.kt | 16 +- .../ui/liquidglass/LiquidBottomTabs.kt | 200 ++-- app/src/main/res/drawable/aurora_activity.xml | 14 + .../res/drawable/aurora_activity_selected.xml | 14 + app/src/main/res/drawable/aurora_add.xml | 28 + .../main/res/drawable/aurora_add_selected.xml | 24 + app/src/main/res/drawable/aurora_archive.xml | 35 + .../res/drawable/aurora_archive_selected.xml | 27 + .../main/res/drawable/aurora_attachment.xml | 14 + .../drawable/aurora_attachment_selected.xml | 14 + app/src/main/res/drawable/aurora_badge.xml | 17 + .../res/drawable/aurora_badge_selected.xml | 13 + app/src/main/res/drawable/aurora_calendar.xml | 50 + .../res/drawable/aurora_calendar_selected.xml | 46 + app/src/main/res/drawable/aurora_cast.xml | 31 + .../res/drawable/aurora_cast_selected.xml | 31 + app/src/main/res/drawable/aurora_check.xml | 21 + .../res/drawable/aurora_check_selected.xml | 17 + app/src/main/res/drawable/aurora_close.xml | 28 + .../res/drawable/aurora_close_selected.xml | 28 + app/src/main/res/drawable/aurora_cloud.xml | 14 + .../res/drawable/aurora_cloud_selected.xml | 10 + .../main/res/drawable/aurora_collection.xml | 21 + .../drawable/aurora_collection_selected.xml | 14 + app/src/main/res/drawable/aurora_database.xml | 28 + .../res/drawable/aurora_database_selected.xml | 24 + app/src/main/res/drawable/aurora_delete.xml | 42 + .../res/drawable/aurora_delete_selected.xml | 38 + app/src/main/res/drawable/aurora_discover.xml | 21 + .../res/drawable/aurora_discover_selected.xml | 17 + app/src/main/res/drawable/aurora_download.xml | 28 + .../res/drawable/aurora_download_selected.xml | 28 + app/src/main/res/drawable/aurora_edit.xml | 21 + .../res/drawable/aurora_edit_selected.xml | 17 + app/src/main/res/drawable/aurora_eye.xml | 21 + .../main/res/drawable/aurora_eye_selected.xml | 17 + app/src/main/res/drawable/aurora_favorite.xml | 14 + .../res/drawable/aurora_favorite_selected.xml | 10 + app/src/main/res/drawable/aurora_file.xml | 21 + .../main/res/drawable/aurora_file_audio.xml | 38 + .../drawable/aurora_file_audio_selected.xml | 34 + .../res/drawable/aurora_file_selected.xml | 17 + .../main/res/drawable/aurora_file_video.xml | 24 + .../drawable/aurora_file_video_selected.xml | 20 + app/src/main/res/drawable/aurora_filter.xml | 14 + .../res/drawable/aurora_filter_selected.xml | 14 + app/src/main/res/drawable/aurora_folder.xml | 14 + .../res/drawable/aurora_folder_selected.xml | 10 + app/src/main/res/drawable/aurora_globe.xml | 42 + .../res/drawable/aurora_globe_selected.xml | 42 + .../main/res/drawable/aurora_headphones.xml | 28 + .../drawable/aurora_headphones_selected.xml | 20 + app/src/main/res/drawable/aurora_history.xml | 35 + .../res/drawable/aurora_history_selected.xml | 35 + app/src/main/res/drawable/aurora_home.xml | 21 + .../res/drawable/aurora_home_selected.xml | 13 + app/src/main/res/drawable/aurora_image.xml | 24 + .../res/drawable/aurora_image_selected.xml | 20 + app/src/main/res/drawable/aurora_info.xml | 24 + .../res/drawable/aurora_info_selected.xml | 24 + app/src/main/res/drawable/aurora_key.xml | 35 + .../main/res/drawable/aurora_key_selected.xml | 31 + app/src/main/res/drawable/aurora_laptop.xml | 21 + .../res/drawable/aurora_laptop_selected.xml | 16 + app/src/main/res/drawable/aurora_library.xml | 28 + .../res/drawable/aurora_library_selected.xml | 21 + app/src/main/res/drawable/aurora_link.xml | 28 + .../res/drawable/aurora_link_selected.xml | 28 + app/src/main/res/drawable/aurora_lock.xml | 31 + .../res/drawable/aurora_lock_selected.xml | 27 + app/src/main/res/drawable/aurora_login.xml | 28 + .../res/drawable/aurora_login_selected.xml | 28 + app/src/main/res/drawable/aurora_logout.xml | 28 + .../res/drawable/aurora_logout_selected.xml | 28 + app/src/main/res/drawable/aurora_media.xml | 17 + .../res/drawable/aurora_media_selected.xml | 13 + app/src/main/res/drawable/aurora_network.xml | 49 + .../res/drawable/aurora_network_selected.xml | 37 + app/src/main/res/drawable/aurora_next.xml | 17 + .../res/drawable/aurora_next_selected.xml | 17 + .../main/res/drawable/aurora_notification.xml | 28 + .../drawable/aurora_notification_selected.xml | 13 + app/src/main/res/drawable/aurora_pause.xml | 20 + .../res/drawable/aurora_pause_selected.xml | 16 + app/src/main/res/drawable/aurora_phone.xml | 21 + .../res/drawable/aurora_phone_selected.xml | 16 + app/src/main/res/drawable/aurora_play.xml | 17 + .../res/drawable/aurora_play_selected.xml | 13 + app/src/main/res/drawable/aurora_playlist.xml | 45 + .../res/drawable/aurora_playlist_selected.xml | 45 + app/src/main/res/drawable/aurora_previous.xml | 17 + .../res/drawable/aurora_previous_selected.xml | 17 + .../main/res/drawable/aurora_profile_edit.xml | 35 + .../drawable/aurora_profile_edit_selected.xml | 35 + app/src/main/res/drawable/aurora_qr.xml | 40 + .../main/res/drawable/aurora_qr_selected.xml | 40 + app/src/main/res/drawable/aurora_refresh.xml | 28 + .../res/drawable/aurora_refresh_selected.xml | 28 + app/src/main/res/drawable/aurora_remote.xml | 34 + .../res/drawable/aurora_remote_selected.xml | 22 + app/src/main/res/drawable/aurora_remove.xml | 21 + .../res/drawable/aurora_remove_selected.xml | 21 + app/src/main/res/drawable/aurora_replay.xml | 35 + .../res/drawable/aurora_replay_selected.xml | 35 + .../main/res/drawable/aurora_resources.xml | 24 + .../drawable/aurora_resources_selected.xml | 16 + app/src/main/res/drawable/aurora_search.xml | 21 + .../res/drawable/aurora_search_selected.xml | 21 + app/src/main/res/drawable/aurora_server.xml | 37 + .../res/drawable/aurora_server_selected.xml | 25 + app/src/main/res/drawable/aurora_settings.xml | 21 + .../res/drawable/aurora_settings_selected.xml | 13 + app/src/main/res/drawable/aurora_share.xml | 42 + .../res/drawable/aurora_share_selected.xml | 30 + app/src/main/res/drawable/aurora_shield.xml | 21 + .../res/drawable/aurora_shield_selected.xml | 17 + app/src/main/res/drawable/aurora_shuffle.xml | 35 + .../res/drawable/aurora_shuffle_selected.xml | 35 + app/src/main/res/drawable/aurora_sort.xml | 35 + .../res/drawable/aurora_sort_selected.xml | 35 + app/src/main/res/drawable/aurora_speaker.xml | 28 + .../res/drawable/aurora_speaker_selected.xml | 16 + app/src/main/res/drawable/aurora_speed.xml | 24 + .../res/drawable/aurora_speed_selected.xml | 24 + app/src/main/res/drawable/aurora_stop.xml | 17 + .../res/drawable/aurora_stop_selected.xml | 13 + app/src/main/res/drawable/aurora_subtitle.xml | 42 + .../res/drawable/aurora_subtitle_selected.xml | 38 + .../res/drawable/aurora_switch_account.xml | 35 + .../aurora_switch_account_selected.xml | 35 + app/src/main/res/drawable/aurora_tablet.xml | 17 + .../res/drawable/aurora_tablet_selected.xml | 13 + app/src/main/res/drawable/aurora_tv.xml | 35 + .../main/res/drawable/aurora_tv_selected.xml | 34 + app/src/main/res/drawable/aurora_unlock.xml | 31 + .../res/drawable/aurora_unlock_selected.xml | 31 + app/src/main/res/drawable/aurora_upload.xml | 28 + .../res/drawable/aurora_upload_selected.xml | 28 + app/src/main/res/drawable/aurora_user.xml | 21 + .../res/drawable/aurora_user_selected.xml | 13 + app/src/main/res/drawable/aurora_users.xml | 37 + .../res/drawable/aurora_users_selected.xml | 23 + app/src/main/res/drawable/aurora_warning.xml | 24 + .../res/drawable/aurora_warning_selected.xml | 20 + app/src/main/res/drawable/aurora_watch.xml | 22 + .../res/drawable/aurora_watch_selected.xml | 14 + app/src/main/res/values-en/strings.xml | 141 ++- app/src/main/res/values/strings.xml | 139 ++- memory/2026-08-27.md | 54 ++ memory/MEMORY.md | 8 +- memory/lessons.md | 9 + memory/projects.md | 10 + 175 files changed, 6873 insertions(+), 1675 deletions(-) delete mode 100644 app/src/main/kotlin/com/example/myapplication/model/MediaItem.kt create mode 100644 app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt create mode 100644 app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt create mode 100644 app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt create mode 100644 app/src/main/res/drawable/aurora_activity.xml create mode 100644 app/src/main/res/drawable/aurora_activity_selected.xml create mode 100644 app/src/main/res/drawable/aurora_add.xml create mode 100644 app/src/main/res/drawable/aurora_add_selected.xml create mode 100644 app/src/main/res/drawable/aurora_archive.xml create mode 100644 app/src/main/res/drawable/aurora_archive_selected.xml create mode 100644 app/src/main/res/drawable/aurora_attachment.xml create mode 100644 app/src/main/res/drawable/aurora_attachment_selected.xml create mode 100644 app/src/main/res/drawable/aurora_badge.xml create mode 100644 app/src/main/res/drawable/aurora_badge_selected.xml create mode 100644 app/src/main/res/drawable/aurora_calendar.xml create mode 100644 app/src/main/res/drawable/aurora_calendar_selected.xml create mode 100644 app/src/main/res/drawable/aurora_cast.xml create mode 100644 app/src/main/res/drawable/aurora_cast_selected.xml create mode 100644 app/src/main/res/drawable/aurora_check.xml create mode 100644 app/src/main/res/drawable/aurora_check_selected.xml create mode 100644 app/src/main/res/drawable/aurora_close.xml create mode 100644 app/src/main/res/drawable/aurora_close_selected.xml create mode 100644 app/src/main/res/drawable/aurora_cloud.xml create mode 100644 app/src/main/res/drawable/aurora_cloud_selected.xml create mode 100644 app/src/main/res/drawable/aurora_collection.xml create mode 100644 app/src/main/res/drawable/aurora_collection_selected.xml create mode 100644 app/src/main/res/drawable/aurora_database.xml create mode 100644 app/src/main/res/drawable/aurora_database_selected.xml create mode 100644 app/src/main/res/drawable/aurora_delete.xml create mode 100644 app/src/main/res/drawable/aurora_delete_selected.xml create mode 100644 app/src/main/res/drawable/aurora_discover.xml create mode 100644 app/src/main/res/drawable/aurora_discover_selected.xml create mode 100644 app/src/main/res/drawable/aurora_download.xml create mode 100644 app/src/main/res/drawable/aurora_download_selected.xml create mode 100644 app/src/main/res/drawable/aurora_edit.xml create mode 100644 app/src/main/res/drawable/aurora_edit_selected.xml create mode 100644 app/src/main/res/drawable/aurora_eye.xml create mode 100644 app/src/main/res/drawable/aurora_eye_selected.xml create mode 100644 app/src/main/res/drawable/aurora_favorite.xml create mode 100644 app/src/main/res/drawable/aurora_favorite_selected.xml create mode 100644 app/src/main/res/drawable/aurora_file.xml create mode 100644 app/src/main/res/drawable/aurora_file_audio.xml create mode 100644 app/src/main/res/drawable/aurora_file_audio_selected.xml create mode 100644 app/src/main/res/drawable/aurora_file_selected.xml create mode 100644 app/src/main/res/drawable/aurora_file_video.xml create mode 100644 app/src/main/res/drawable/aurora_file_video_selected.xml create mode 100644 app/src/main/res/drawable/aurora_filter.xml create mode 100644 app/src/main/res/drawable/aurora_filter_selected.xml create mode 100644 app/src/main/res/drawable/aurora_folder.xml create mode 100644 app/src/main/res/drawable/aurora_folder_selected.xml create mode 100644 app/src/main/res/drawable/aurora_globe.xml create mode 100644 app/src/main/res/drawable/aurora_globe_selected.xml create mode 100644 app/src/main/res/drawable/aurora_headphones.xml create mode 100644 app/src/main/res/drawable/aurora_headphones_selected.xml create mode 100644 app/src/main/res/drawable/aurora_history.xml create mode 100644 app/src/main/res/drawable/aurora_history_selected.xml create mode 100644 app/src/main/res/drawable/aurora_home.xml create mode 100644 app/src/main/res/drawable/aurora_home_selected.xml create mode 100644 app/src/main/res/drawable/aurora_image.xml create mode 100644 app/src/main/res/drawable/aurora_image_selected.xml create mode 100644 app/src/main/res/drawable/aurora_info.xml create mode 100644 app/src/main/res/drawable/aurora_info_selected.xml create mode 100644 app/src/main/res/drawable/aurora_key.xml create mode 100644 app/src/main/res/drawable/aurora_key_selected.xml create mode 100644 app/src/main/res/drawable/aurora_laptop.xml create mode 100644 app/src/main/res/drawable/aurora_laptop_selected.xml create mode 100644 app/src/main/res/drawable/aurora_library.xml create mode 100644 app/src/main/res/drawable/aurora_library_selected.xml create mode 100644 app/src/main/res/drawable/aurora_link.xml create mode 100644 app/src/main/res/drawable/aurora_link_selected.xml create mode 100644 app/src/main/res/drawable/aurora_lock.xml create mode 100644 app/src/main/res/drawable/aurora_lock_selected.xml create mode 100644 app/src/main/res/drawable/aurora_login.xml create mode 100644 app/src/main/res/drawable/aurora_login_selected.xml create mode 100644 app/src/main/res/drawable/aurora_logout.xml create mode 100644 app/src/main/res/drawable/aurora_logout_selected.xml create mode 100644 app/src/main/res/drawable/aurora_media.xml create mode 100644 app/src/main/res/drawable/aurora_media_selected.xml create mode 100644 app/src/main/res/drawable/aurora_network.xml create mode 100644 app/src/main/res/drawable/aurora_network_selected.xml create mode 100644 app/src/main/res/drawable/aurora_next.xml create mode 100644 app/src/main/res/drawable/aurora_next_selected.xml create mode 100644 app/src/main/res/drawable/aurora_notification.xml create mode 100644 app/src/main/res/drawable/aurora_notification_selected.xml create mode 100644 app/src/main/res/drawable/aurora_pause.xml create mode 100644 app/src/main/res/drawable/aurora_pause_selected.xml create mode 100644 app/src/main/res/drawable/aurora_phone.xml create mode 100644 app/src/main/res/drawable/aurora_phone_selected.xml create mode 100644 app/src/main/res/drawable/aurora_play.xml create mode 100644 app/src/main/res/drawable/aurora_play_selected.xml create mode 100644 app/src/main/res/drawable/aurora_playlist.xml create mode 100644 app/src/main/res/drawable/aurora_playlist_selected.xml create mode 100644 app/src/main/res/drawable/aurora_previous.xml create mode 100644 app/src/main/res/drawable/aurora_previous_selected.xml create mode 100644 app/src/main/res/drawable/aurora_profile_edit.xml create mode 100644 app/src/main/res/drawable/aurora_profile_edit_selected.xml create mode 100644 app/src/main/res/drawable/aurora_qr.xml create mode 100644 app/src/main/res/drawable/aurora_qr_selected.xml create mode 100644 app/src/main/res/drawable/aurora_refresh.xml create mode 100644 app/src/main/res/drawable/aurora_refresh_selected.xml create mode 100644 app/src/main/res/drawable/aurora_remote.xml create mode 100644 app/src/main/res/drawable/aurora_remote_selected.xml create mode 100644 app/src/main/res/drawable/aurora_remove.xml create mode 100644 app/src/main/res/drawable/aurora_remove_selected.xml create mode 100644 app/src/main/res/drawable/aurora_replay.xml create mode 100644 app/src/main/res/drawable/aurora_replay_selected.xml create mode 100644 app/src/main/res/drawable/aurora_resources.xml create mode 100644 app/src/main/res/drawable/aurora_resources_selected.xml create mode 100644 app/src/main/res/drawable/aurora_search.xml create mode 100644 app/src/main/res/drawable/aurora_search_selected.xml create mode 100644 app/src/main/res/drawable/aurora_server.xml create mode 100644 app/src/main/res/drawable/aurora_server_selected.xml create mode 100644 app/src/main/res/drawable/aurora_settings.xml create mode 100644 app/src/main/res/drawable/aurora_settings_selected.xml create mode 100644 app/src/main/res/drawable/aurora_share.xml create mode 100644 app/src/main/res/drawable/aurora_share_selected.xml create mode 100644 app/src/main/res/drawable/aurora_shield.xml create mode 100644 app/src/main/res/drawable/aurora_shield_selected.xml create mode 100644 app/src/main/res/drawable/aurora_shuffle.xml create mode 100644 app/src/main/res/drawable/aurora_shuffle_selected.xml create mode 100644 app/src/main/res/drawable/aurora_sort.xml create mode 100644 app/src/main/res/drawable/aurora_sort_selected.xml create mode 100644 app/src/main/res/drawable/aurora_speaker.xml create mode 100644 app/src/main/res/drawable/aurora_speaker_selected.xml create mode 100644 app/src/main/res/drawable/aurora_speed.xml create mode 100644 app/src/main/res/drawable/aurora_speed_selected.xml create mode 100644 app/src/main/res/drawable/aurora_stop.xml create mode 100644 app/src/main/res/drawable/aurora_stop_selected.xml create mode 100644 app/src/main/res/drawable/aurora_subtitle.xml create mode 100644 app/src/main/res/drawable/aurora_subtitle_selected.xml create mode 100644 app/src/main/res/drawable/aurora_switch_account.xml create mode 100644 app/src/main/res/drawable/aurora_switch_account_selected.xml create mode 100644 app/src/main/res/drawable/aurora_tablet.xml create mode 100644 app/src/main/res/drawable/aurora_tablet_selected.xml create mode 100644 app/src/main/res/drawable/aurora_tv.xml create mode 100644 app/src/main/res/drawable/aurora_tv_selected.xml create mode 100644 app/src/main/res/drawable/aurora_unlock.xml create mode 100644 app/src/main/res/drawable/aurora_unlock_selected.xml create mode 100644 app/src/main/res/drawable/aurora_upload.xml create mode 100644 app/src/main/res/drawable/aurora_upload_selected.xml create mode 100644 app/src/main/res/drawable/aurora_user.xml create mode 100644 app/src/main/res/drawable/aurora_user_selected.xml create mode 100644 app/src/main/res/drawable/aurora_users.xml create mode 100644 app/src/main/res/drawable/aurora_users_selected.xml create mode 100644 app/src/main/res/drawable/aurora_warning.xml create mode 100644 app/src/main/res/drawable/aurora_warning_selected.xml create mode 100644 app/src/main/res/drawable/aurora_watch.xml create mode 100644 app/src/main/res/drawable/aurora_watch_selected.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 935899e..d8db8ad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,10 @@ android { namespace = "com.example.myapplication" compileSdk = 35 + buildFeatures { + buildConfig = true + } + defaultConfig { applicationId = "com.example.myapplication" minSdk = 24 @@ -49,12 +53,8 @@ dependencies { 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.1") implementation("io.github.kyant0:shapes:1.2.1") - implementation("top.yukonga.miuix.kmp:miuix-squircle:0.9.4-rc01") - implementation("top.yukonga.miuix.kmp:miuix-icons:0.9.4-rc01") implementation("androidx.room:room-runtime:2.8.4") implementation("androidx.room:room-ktx:2.8.4") implementation("androidx.media3:media3-exoplayer:1.5.1") diff --git a/app/src/main/kotlin/com/example/myapplication/MainActivity.kt b/app/src/main/kotlin/com/example/myapplication/MainActivity.kt index 87ab7c0..610e14a 100644 --- a/app/src/main/kotlin/com/example/myapplication/MainActivity.kt +++ b/app/src/main/kotlin/com/example/myapplication/MainActivity.kt @@ -8,6 +8,8 @@ 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 +import com.example.myapplication.ui.AppTheme +import com.example.myapplication.ui.design.LumaGlassRuntime class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -20,12 +22,19 @@ class MainActivity : ComponentActivity() { // Initialize on main thread (these are fast SharedPreferences/DB reads) AppLanguage.init(this) + AppTheme.init(this) IconLibraryRepository.init(this) ServerRepository.init(this) + LumaGlassRuntime.startFrameMonitor() dataReady = true setContent { MainApp() } } + + override fun onDestroy() { + LumaGlassRuntime.stopFrameMonitor() + super.onDestroy() + } } diff --git a/app/src/main/kotlin/com/example/myapplication/MainApp.kt b/app/src/main/kotlin/com/example/myapplication/MainApp.kt index aec1f26..161b283 100644 --- a/app/src/main/kotlin/com/example/myapplication/MainApp.kt +++ b/app/src/main/kotlin/com/example/myapplication/MainApp.kt @@ -25,10 +25,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyRepository import com.example.myapplication.data.EmbyServer import com.example.myapplication.data.IconLibrary @@ -41,8 +43,10 @@ import com.example.myapplication.navigation.rootTabIndex import com.example.myapplication.ui.AppTheme import com.example.myapplication.ui.LiquidAppBottomBar import com.example.myapplication.ui.LocalGlassBackdrop +import com.example.myapplication.ui.design.LumaColors +import com.example.myapplication.ui.design.LumaGlassRuntime +import com.example.myapplication.ui.design.rememberLumaLayerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop import kotlinx.coroutines.delay import kotlinx.coroutines.launch import top.yukonga.miuix.kmp.theme.MiuixTheme @@ -96,8 +100,19 @@ fun MainApp() { } val selectedTab = rootTab ?: lastRootTab val showBottomBar = isRootRoute(currentRoute) - val glassBackdrop = rememberLayerBackdrop() - val bgColor = if (AppTheme.isDark) Color(0xFF0F1115) else Color(0xFFF2F4F7) + val glassBackdrop = rememberLumaLayerBackdrop() + val loginFailedMessage = stringResource(Res.string.error_login_failed) + val networkErrorMessage = stringResource( + Res.string.error_network, + stringResource(Res.string.error_unknown), + ) + val backgroundBrush = Brush.linearGradient( + listOf( + LumaColors.background, + LumaColors.backgroundElevated, + LumaColors.background, + ), + ) val selectServer: (String) -> Unit = { id -> selectedServerId = id @@ -112,10 +127,10 @@ fun MainApp() { if (selectedServerId.isBlank()) selectedServerId = server.id onResult(null) } else { - onResult(authResult.errorMessage ?: "登录失败") + onResult(authResult.errorMessage ?: loginFailedMessage) } } catch (e: Exception) { - onResult("网络错误: " + (e.message ?: "未知")) + onResult(networkErrorMessage) } } } @@ -127,10 +142,10 @@ fun MainApp() { ServerRepository.updateServer(server.copy(apiKey = authResult.accessToken, userId = authResult.userId)) onResult(null) } else { - onResult(authResult.errorMessage ?: "登录失败") + onResult(authResult.errorMessage ?: loginFailedMessage) } } catch (e: Exception) { - onResult("网络错误: " + (e.message ?: "未知")) + onResult(networkErrorMessage) } } } @@ -145,8 +160,12 @@ fun MainApp() { } androidx.compose.runtime.CompositionLocalProvider(LocalGlassBackdrop provides glassBackdrop) { - Box(Modifier.fillMaxSize().background(bgColor)) { - Box(Modifier.fillMaxSize().layerBackdrop(glassBackdrop)) { + Box(Modifier.fillMaxSize().background(backgroundBrush)) { + Box( + Modifier + .fillMaxSize() + .then(if (showBottomBar && glassBackdrop != null) Modifier.layerBackdrop(glassBackdrop) else Modifier), + ) { androidx.compose.runtime.CompositionLocalProvider(LocalGlassBackdrop provides null) { AppNavHost( navController = navController, diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt index 10d154c..988bffe 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt @@ -125,10 +125,12 @@ data class EmbyMediaStream( data class EmbyPlaybackInfoResponse( @SerializedName("MediaSources") val mediaSources: List? = null, + @SerializedName("PlaySessionId") val playSessionId: String? = null, ) data class EmbyMediaSource( @SerializedName("Id") val id: String, + @SerializedName("PlaySessionId") val playSessionId: String? = null, @SerializedName("Path") val path: String? = null, @SerializedName("Container") val container: String? = null, @SerializedName("SupportsDirectPlay") val supportsDirectPlay: Boolean = false, diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt index 83918b2..7c17047 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt @@ -1,15 +1,21 @@ package com.example.myapplication.data import android.net.Uri +import android.os.SystemClock import android.util.Log import com.google.gson.Gson import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import kotlin.math.abs import java.util.UUID import java.util.concurrent.TimeUnit @@ -26,6 +32,13 @@ data class EmbyLibraryPage( val totalCount: Int = 0, ) +data class EmbyPlaybackSession( + val streamUrl: String, + val mediaSourceId: String, + val playSessionId: String, + val playMethod: String, +) + object EmbyRepository { private const val CLIENT_NAME = "Luma Player" @@ -39,6 +52,49 @@ object EmbyRepository { .writeTimeout(15, TimeUnit.SECONDS) .build() + private sealed interface PlaybackCommand { + val server: EmbyServer + val item: EmbyItem + val positionMs: Long + val session: EmbyPlaybackSession? + + data class Started( + override val server: EmbyServer, + override val item: EmbyItem, + override val positionMs: Long, + override val session: EmbyPlaybackSession?, + ) : PlaybackCommand + + data class Progress( + override val server: EmbyServer, + override val item: EmbyItem, + override val positionMs: Long, + val paused: Boolean, + val eventName: String, + override val session: EmbyPlaybackSession?, + ) : PlaybackCommand + + data class Stopped( + override val server: EmbyServer, + override val item: EmbyItem, + override val positionMs: Long, + override val session: EmbyPlaybackSession?, + ) : PlaybackCommand + } + + private data class PlaybackReportState( + var lastPositionMs: Long = -1L, + var lastSentAtMs: Long = 0L, + ) + + private val playbackQueue = Channel(Channel.UNLIMITED) + private val playbackReportState = mutableMapOf() + private val playbackWorker = CoroutineScope(SupervisorJob() + Dispatchers.IO).apply { + launch { + for (command in playbackQueue) processPlaybackCommand(command) + } + } + /** * Authenticate with an Emby server using username + password. */ @@ -378,7 +434,7 @@ object EmbyRepository { } } - suspend fun resolvePlaybackUrl(server: EmbyServer, item: EmbyItem): String? = withContext(Dispatchers.IO) { + suspend fun resolvePlaybackSession(server: EmbyServer, item: EmbyItem): EmbyPlaybackSession? = withContext(Dispatchers.IO) { try { val infoUrl = "${server.fullUrl}/emby/Items/${item.id}/PlaybackInfo?UserId=${server.userId}" val body = "{}".toRequestBody("application/json".toMediaType()) @@ -388,30 +444,37 @@ object EmbyRepository { .post(body) .build() - val mediaSource = client.newCall(request).execute().use { response -> + val playbackInfo = 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 mediaSource = playbackInfo?.mediaSources?.firstOrNull() + val mediaSourceId = mediaSource?.id ?: item.id + val playSessionId = mediaSource?.playSessionId + ?.takeUnless { it.isBlank() } + ?: playbackInfo?.playSessionId?.takeUnless { it.isBlank() } + ?: UUID.randomUUID().toString().replace("-", "") val transcodingUrl = mediaSource?.transcodingUrl - if (!transcodingUrl.isNullOrBlank()) { - return@withContext authenticatedPlaybackUrl(server, transcodingUrl) - } - val directStreamUrl = mediaSource?.directStreamUrl - if (!directStreamUrl.isNullOrBlank()) { - return@withContext authenticatedPlaybackUrl(server, directStreamUrl) + val (candidateUrl, playMethod) = when { + !transcodingUrl.isNullOrBlank() -> transcodingUrl to "Transcode" + !directStreamUrl.isNullOrBlank() -> directStreamUrl to "DirectStream" + else -> { + "${server.fullUrl}/emby/Videos/${item.id}/stream" + + "?Static=true&MediaSourceId=$mediaSourceId&DeviceId=$deviceId&PlaySessionId=$playSessionId" to + "DirectStream" + } } - val sourceId = mediaSource?.id ?: item.id - authenticatedPlaybackUrl( - server, - "${server.fullUrl}/emby/Videos/${item.id}/stream" + - "?Static=true&MediaSourceId=$sourceId&DeviceId=$deviceId", + EmbyPlaybackSession( + streamUrl = authenticatedPlaybackUrl(server, candidateUrl, playSessionId), + mediaSourceId = mediaSourceId, + playSessionId = playSessionId, + playMethod = playMethod, ) } catch (e: Exception) { Log.e("EmbyPlayback", "resolvePlaybackUrl failed", e) @@ -419,21 +482,41 @@ object EmbyRepository { } } - suspend fun reportPlaybackStarted(server: EmbyServer, item: EmbyItem, positionMs: Long) { + suspend fun resolvePlaybackUrl(server: EmbyServer, item: EmbyItem): String? = + resolvePlaybackSession(server, item)?.streamUrl + + suspend fun reportPlaybackStarted( + server: EmbyServer, + item: EmbyItem, + positionMs: Long, + session: EmbyPlaybackSession? = null, + ) { sendPlaybackCheckIn( server = server, item = item, positionMs = positionMs, paused = false, endpoint = "/emby/Sessions/Playing", + session = session, ) } + fun enqueuePlaybackStarted( + server: EmbyServer, + item: EmbyItem, + positionMs: Long, + session: EmbyPlaybackSession?, + ) { + playbackQueue.trySend(PlaybackCommand.Started(server, item, positionMs, session)) + } + suspend fun reportPlaybackProgress( server: EmbyServer, item: EmbyItem, positionMs: Long, paused: Boolean, + eventName: String = if (paused) "Pause" else "TimeUpdate", + session: EmbyPlaybackSession? = null, ) { sendPlaybackCheckIn( server = server, @@ -441,20 +524,109 @@ object EmbyRepository { positionMs = positionMs, paused = paused, endpoint = "/emby/Sessions/Playing/Progress", - eventName = if (paused) "Pause" else "TimeUpdate", + eventName = eventName, + session = session, ) } - suspend fun reportPlaybackStopped(server: EmbyServer, item: EmbyItem, positionMs: Long) { + fun enqueuePlaybackProgress( + server: EmbyServer, + item: EmbyItem, + positionMs: Long, + paused: Boolean, + eventName: String, + session: EmbyPlaybackSession?, + ) { + playbackQueue.trySend( + PlaybackCommand.Progress(server, item, positionMs, paused, eventName, session), + ) + } + + suspend fun reportPlaybackStopped( + server: EmbyServer, + item: EmbyItem, + positionMs: Long, + session: EmbyPlaybackSession? = null, + ) { sendPlaybackCheckIn( server = server, item = item, positionMs = positionMs, paused = true, endpoint = "/emby/Sessions/Playing/Stopped", + session = session, ) } + fun enqueuePlaybackStopped( + server: EmbyServer, + item: EmbyItem, + positionMs: Long, + session: EmbyPlaybackSession?, + ) { + playbackQueue.trySend(PlaybackCommand.Stopped(server, item, positionMs, session)) + } + + private suspend fun processPlaybackCommand(command: PlaybackCommand) { + val key = playbackSessionKey(command.server, command.item, command.session) + when (command) { + is PlaybackCommand.Started -> { + sendPlaybackCheckIn( + server = command.server, + item = command.item, + positionMs = command.positionMs, + paused = false, + endpoint = "/emby/Sessions/Playing", + session = command.session, + ) + playbackReportState[key] = PlaybackReportState( + lastPositionMs = command.positionMs.coerceAtLeast(0L), + lastSentAtMs = SystemClock.elapsedRealtime(), + ) + } + + is PlaybackCommand.Progress -> { + val now = SystemClock.elapsedRealtime() + val state = playbackReportState.getOrPut(key) { PlaybackReportState() } + val isUserInteraction = command.eventName != "TimeUpdate" + val positionChangedEnough = state.lastPositionMs < 0L || + abs(command.positionMs - state.lastPositionMs) >= PLAYBACK_REPORT_INTERVAL_MS + val intervalElapsed = now - state.lastSentAtMs >= PLAYBACK_REPORT_INTERVAL_MS + if (!isUserInteraction && !positionChangedEnough && !intervalElapsed) return + + sendPlaybackCheckIn( + server = command.server, + item = command.item, + positionMs = command.positionMs, + paused = command.paused, + endpoint = "/emby/Sessions/Playing/Progress", + eventName = command.eventName, + session = command.session, + ) + state.lastPositionMs = command.positionMs.coerceAtLeast(0L) + state.lastSentAtMs = now + } + + is PlaybackCommand.Stopped -> { + sendPlaybackCheckIn( + server = command.server, + item = command.item, + positionMs = command.positionMs, + paused = true, + endpoint = "/emby/Sessions/Playing/Stopped", + session = command.session, + ) + playbackReportState.remove(key) + } + } + } + + private fun playbackSessionKey( + server: EmbyServer, + item: EmbyItem, + session: EmbyPlaybackSession?, + ): String = listOf(server.id, server.userId, item.id, session?.playSessionId.orEmpty()).joinToString("|") + private suspend fun sendPlaybackCheckIn( server: EmbyServer, item: EmbyItem, @@ -462,6 +634,7 @@ object EmbyRepository { paused: Boolean, endpoint: String, eventName: String? = null, + session: EmbyPlaybackSession? = null, ) = withContext(Dispatchers.IO) { try { val payload = linkedMapOf( @@ -469,8 +642,15 @@ object EmbyRepository { "PositionTicks" to positionMs.coerceAtLeast(0L) * 10_000L, "IsPaused" to paused, "CanSeek" to true, - "QueueableMediaTypes" to "Video", + "QueueableMediaTypes" to listOf("Video"), + "MediaSourceId" to (session?.mediaSourceId ?: item.id), + "PlaySessionId" to (session?.playSessionId ?: deviceId), + "IsMuted" to false, + "VolumeLevel" to 100, + "PlayMethod" to (session?.playMethod ?: "DirectStream"), + "PlaybackRate" to 1.0, ) + item.runTimeTicks?.let { payload["RunTimeTicks"] = it } eventName?.let { payload["EventName"] = it } val request = Request.Builder() .url(server.fullUrl + endpoint) @@ -483,14 +663,21 @@ object EmbyRepository { } } - private fun authenticatedPlaybackUrl(server: EmbyServer, candidate: String): String { + private fun authenticatedPlaybackUrl( + server: EmbyServer, + candidate: String, + playSessionId: String? = null, + ): String { val absoluteUrl = if (candidate.startsWith("/")) server.fullUrl + candidate else candidate val uri = Uri.parse(absoluteUrl) - return if (uri.getQueryParameter("api_key").isNullOrBlank()) { - uri.buildUpon().appendQueryParameter("api_key", server.apiKey).build().toString() - } else { - absoluteUrl + val builder = uri.buildUpon() + if (uri.getQueryParameter("api_key").isNullOrBlank()) { + builder.appendQueryParameter("api_key", server.apiKey) } + if (!playSessionId.isNullOrBlank() && uri.getQueryParameter("PlaySessionId").isNullOrBlank()) { + builder.appendQueryParameter("PlaySessionId", playSessionId) + } + return builder.build().toString() } fun buildBackdropUrl(serverUrl: String, item: EmbyItem, maxWidth: Int = 1200): String? { @@ -546,4 +733,6 @@ object EmbyRepository { val path = item.posterUrlPath ?: return null return "$serverUrl$path" } + + private const val PLAYBACK_REPORT_INTERVAL_MS = 10_000L } diff --git a/app/src/main/kotlin/com/example/myapplication/model/MediaItem.kt b/app/src/main/kotlin/com/example/myapplication/model/MediaItem.kt deleted file mode 100644 index 9d41dc8..0000000 --- a/app/src/main/kotlin/com/example/myapplication/model/MediaItem.kt +++ /dev/null @@ -1,38 +0,0 @@ -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/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt index ee43bfa..fa69db5 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt @@ -5,28 +5,29 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.background import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.material3.CircularProgressIndicator 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument import com.example.myapplication.data.EmbyItem +import com.example.myapplication.data.EmbyPlaybackSession import com.example.myapplication.data.EmbyRepository import com.example.myapplication.data.EmbyServer import com.example.myapplication.data.IconItem import com.example.myapplication.data.IconLibrary +import com.example.myapplication.R as Res import com.example.myapplication.screens.AboutScreen import com.example.myapplication.screens.AddServerScreen import com.example.myapplication.screens.CacheManagementScreen @@ -41,7 +42,7 @@ import com.example.myapplication.screens.SearchTopBar import com.example.myapplication.screens.SettingsScreen import com.example.myapplication.screens.SettingsTopBar import com.example.myapplication.screens.VideoPlayerScreen -import kotlinx.coroutines.launch +import com.example.myapplication.ui.design.LumaLoading import top.yukonga.miuix.kmp.basic.Text @OptIn(ExperimentalAnimationApi::class) @@ -254,7 +255,7 @@ private fun MediaDetailDestination( when { item != null -> MediaDetailScreen(item = item!!, server = server, onClose = onClose, onPlay = onPlay) loading -> DestinationLoading() - else -> DestinationMessage("详情加载失败", background = Color.Transparent) + else -> DestinationMessage(stringResource(Res.string.detail_load_failed), background = Color.Transparent) } } @@ -267,12 +268,13 @@ private fun PlayerDestination( seasonId: String?, onClose: () -> Unit, ) { - val playbackScope = rememberCoroutineScope() + val playbackUrlFailedMessage = stringResource(Res.string.playback_url_failed) var item by remember(server?.id, itemId) { mutableStateOf(null) } var itemLoading by remember(server?.id, itemId) { mutableStateOf(true) } var episodes by remember(server?.id, seriesId, seasonId) { mutableStateOf>(emptyList()) } var selectedItem by remember(server?.id, itemId) { mutableStateOf(null) } var playbackUrl by remember(server?.id, itemId) { mutableStateOf(null) } + var playbackSession by remember(server?.id, itemId) { mutableStateOf(null) } var playbackLoading by remember(server?.id, itemId) { mutableStateOf(false) } var playbackError by remember(server?.id, itemId) { mutableStateOf(null) } @@ -299,9 +301,10 @@ private fun PlayerDestination( if (server == null || server.apiKey.isBlank() || target == null) return@LaunchedEffect playbackLoading = true playbackError = null - playbackUrl = EmbyRepository.resolvePlaybackUrl(server, target) + playbackSession = EmbyRepository.resolvePlaybackSession(server, target) + playbackUrl = playbackSession?.streamUrl playbackLoading = false - if (playbackUrl.isNullOrBlank()) playbackError = "获取播放地址失败" + if (playbackUrl.isNullOrBlank()) playbackError = playbackUrlFailedMessage } when { @@ -321,28 +324,29 @@ private fun PlayerDestination( onClose = onClose, onPlaybackStarted = { positionMs -> server?.let { currentServer -> - playbackScope.launch { - EmbyRepository.reportPlaybackStarted(currentServer, target, positionMs) - } + EmbyRepository.enqueuePlaybackStarted(currentServer, target, positionMs, playbackSession) } }, - onPlaybackProgress = { positionMs, paused -> + onPlaybackProgress = { positionMs, paused, eventName -> server?.let { currentServer -> - playbackScope.launch { - EmbyRepository.reportPlaybackProgress(currentServer, target, positionMs, paused) - } + EmbyRepository.enqueuePlaybackProgress( + currentServer, + target, + positionMs, + paused, + eventName, + playbackSession, + ) } }, onPlaybackStopped = { positionMs -> server?.let { currentServer -> - playbackScope.launch { - EmbyRepository.reportPlaybackStopped(currentServer, target, positionMs) - } + EmbyRepository.enqueuePlaybackStopped(currentServer, target, positionMs, playbackSession) } }, ) itemLoading -> DestinationLoading(background = Color.Black) - else -> DestinationMessage("播放资源加载失败", background = Color.Black) + else -> DestinationMessage(stringResource(Res.string.playback_resource_failed), background = Color.Black) } } @@ -351,7 +355,7 @@ private fun DestinationLoading(background: Color = Color.Transparent) = Box( Modifier.fillMaxSize().background(background), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator(color = Color(0xFF3B82F6)) + LumaLoading() } @Composable diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt b/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt index 0381222..6d1d167 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt @@ -8,20 +8,20 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith -import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween import androidx.navigation.NavBackStackEntry +import com.example.myapplication.ui.design.LumaMotion object NavigationMotionTokens { - const val detailPushDuration = 320 - const val detailPopDuration = 280 - const val tabDuration = 250 + const val detailPushDuration = LumaMotion.page + const val detailPopDuration = LumaMotion.pagePop + const val tabDuration = LumaMotion.standard const val detailParallax = 0.20f const val tabOffset = 0.25f - val pushEasing = CubicBezierEasing(0.20f, 0.82f, 0.24f, 1f) - val popEasing = CubicBezierEasing(0.32f, 0f, 0.68f, 1f) - val tabEasing = CubicBezierEasing(0.22f, 0.78f, 0.28f, 1f) + val pushEasing = LumaMotion.enter + val popEasing = LumaMotion.exit + val tabEasing = LumaMotion.native } fun AnimatedContentTransitionScope.appEnterTransition(): EnterTransition { diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index 6953d53..c11a597 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -32,10 +32,6 @@ import androidx.compose.foundation.lazy.items 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.Person -import androidx.compose.material.icons.outlined.Storage -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.staticCompositionLocalOf @@ -60,7 +56,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyItem @@ -73,6 +68,15 @@ 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 com.example.myapplication.ui.design.LumaIcon +import com.example.myapplication.ui.design.LumaIconView +import com.example.myapplication.ui.design.LumaAsyncImage +import com.example.myapplication.ui.design.LumaEmptyState +import com.example.myapplication.ui.design.LumaErrorState +import com.example.myapplication.ui.design.LumaLoading +import com.example.myapplication.ui.design.LumaMotion +import com.example.myapplication.ui.design.LumaShapes +import com.example.myapplication.ui.design.LumaSpacing import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState @@ -93,10 +97,6 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow import androidx.compose.ui.text.TextStyle import com.example.myapplication.ui.AppTheme -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.HazeStyle -import dev.chrisbanes.haze.HazeTint import androidx.compose.runtime.mutableIntStateOf /* ---- Adaptive content colors for the immersive home background ---- */ @@ -104,6 +104,42 @@ val LocalHomeContentColor = staticCompositionLocalOf { Color.Unspecified } val LocalHomeSecondaryColor = staticCompositionLocalOf { Color.Unspecified } val LocalHomeAccentColor = staticCompositionLocalOf { Color.Unspecified } +private data class HomeSnapshot( + val libraries: List, + val resumeItems: List, + val latestItems: List, +) + +private object HomeContentCache { + private val snapshots = mutableMapOf() + private val libraryPages = mutableMapOf() + + @Synchronized + fun snapshot(key: String): HomeSnapshot? = snapshots[key] + + @Synchronized + fun putSnapshot(key: String, value: HomeSnapshot) { + snapshots[key] = value + } + + @Synchronized + fun libraryPage(key: String, libraryId: String): EmbyLibraryPage? = libraryPages["$key|$libraryId"] + + @Synchronized + fun putLibraryPage(key: String, libraryId: String, value: EmbyLibraryPage) { + libraryPages["$key|$libraryId"] = value + } + + @Synchronized + fun invalidate(key: String) { + snapshots.remove(key) + libraryPages.keys.removeAll { it.startsWith("$key|") } + } +} + +private fun homeCacheKey(server: EmbyServer): String = + listOf(server.id, server.fullUrl, server.userId).joinToString("|") + @Composable private fun homeContentColor(): Color { val value = LocalHomeContentColor.current @@ -142,10 +178,10 @@ fun HomeTopBar(modifier: Modifier = Modifier) { .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)) + LumaIconView(LumaIcon.User, contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp)) } Column(Modifier.padding(start = 10.dp)) { - Text(text = "媒体中心", color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(text = stringResource(Res.string.home_media_center), color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) } } } @@ -178,7 +214,7 @@ private fun HeroCarousel( 1f to immersiveBackground.pageTop.copy(alpha = 0.68f), ) ) - .padding(bottom = 10.dp) + .padding(bottom = LumaSpacing.sm) ) { HorizontalPager( state = pagerState, @@ -220,7 +256,16 @@ private fun HeroCarousel( Spacer(Modifier.width(10.dp)) } Text( - text = listOfNotNull(current.type?.let { if (it == "Series") "剧集" else if (it == "Movie") "电影" else it }, current.genres.take(2).joinToString(" / ").takeIf { it.isNotBlank() }).joinToString(" · "), + text = listOfNotNull( + current.type?.let { + when (it) { + "Series" -> stringResource(Res.string.detail_type_series) + "Movie" -> stringResource(Res.string.detail_type_movie) + else -> it + } + }, + current.genres.take(2).joinToString(" / ").takeIf { it.isNotBlank() }, + ).joinToString(" · "), color = homeSecondaryColor(), fontSize = 12.sp, maxLines = 1, @@ -240,7 +285,7 @@ private fun HeroCarousel( } } } - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(LumaSpacing.sm)) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, @@ -286,7 +331,7 @@ private fun FeaturedItemBanner( .background(Color.Black), ) { if (imageUrl != null) { - AsyncImage( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(), contentDescription = item.name, contentScale = ContentScale.Crop, @@ -312,7 +357,7 @@ private fun FeaturedItemBanner( Column(Modifier.align(Alignment.BottomStart).padding(start = 18.dp, end = 18.dp, bottom = 24.dp)) { val logoUrl = item.getLogoUrl(serverUrl) if (logoUrl != null) { - AsyncImage( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(logoUrl).crossfade(true).build(), contentDescription = null, contentScale = ContentScale.Fit, @@ -348,12 +393,12 @@ private fun MediaPoster( Column(modifier.width(115.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Box( - Modifier.fillMaxWidth().height(165.dp).clip(RoundedCornerShape(12.dp)) + Modifier.fillMaxWidth().height(165.dp).clip(RoundedCornerShape(LumaShapes.small)) .clickable { onItemClick(item) } .background(Brush.verticalGradient(fallbackColors)), ) { if (posterUrl != null) { - AsyncImage( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(posterUrl).crossfade(true).build(), contentDescription = item.name, contentScale = ContentScale.Crop, @@ -383,12 +428,12 @@ private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClic Column(Modifier.width(220.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Box( Modifier.fillMaxWidth().aspectRatio(16f / 9f) - .clip(RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(LumaShapes.small)) .clickable { onItemClick(item) } .background(Brush.linearGradient(fallbackColors)), ) { if (imageUrl != null) { - AsyncImage( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(), contentDescription = item.name, contentScale = ContentScale.Crop, @@ -399,8 +444,8 @@ private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClic Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.2f)), contentAlignment = Alignment.Center, ) { - Icon( - imageVector = Icons.Filled.PlayArrow, + LumaIconView( + icon = LumaIcon.Play, contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp), @@ -420,13 +465,22 @@ private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClic @Composable private fun LazyLibrarySection(folder: EmbyMediaFolder, activeServer: EmbyServer, onItemClick: (EmbyItem) -> Unit = {}) { - var page by remember(folder.id) { mutableStateOf(null) } + val cacheKey = remember(activeServer.id, activeServer.fullUrl, activeServer.userId) { + homeCacheKey(activeServer) + } + var page by remember(cacheKey, folder.id) { + mutableStateOf(HomeContentCache.libraryPage(cacheKey, folder.id)) + } - LaunchedEffect(folder.id) { - page = try { - EmbyRepository.getItemsForLibrary(activeServer, folder.id, limit = 8) - } catch (_: Exception) { - EmbyLibraryPage() + LaunchedEffect(cacheKey, folder.id) { + if (page == null) { + page = try { + EmbyRepository.getItemsForLibrary(activeServer, folder.id, limit = 8) + } catch (_: Exception) { + EmbyLibraryPage() + }.also { loaded -> + HomeContentCache.putLibraryPage(cacheKey, folder.id, loaded) + } } } @@ -438,7 +492,7 @@ private fun LazyLibrarySection(folder: EmbyMediaFolder, activeServer: EmbyServer repeat(3) { Box( Modifier.width(115.dp).height(165.dp) - .clip(RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(LumaShapes.small)) .background(homeSecondaryColor().copy(alpha = 0.10f)) ) } @@ -463,10 +517,10 @@ private fun SectionTitle( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - Text(text = title, color = homeContentColor(), fontSize = 17.sp, fontWeight = FontWeight.ExtraBold) + Text(text = title, color = homeContentColor(), fontSize = 18.sp, fontWeight = FontWeight.ExtraBold) if (count > 0) { Text( - text = "更多 >", + text = stringResource(Res.string.more) + " >", color = homeAccentColor(), fontSize = 13.sp, fontWeight = FontWeight.Bold @@ -478,7 +532,7 @@ private fun SectionTitle( @Composable private fun MyMediaGrid(libraries: List, serverUrl: String, onSelect: (EmbyMediaFolder) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionTitle(title = "我的媒体") + SectionTitle(title = stringResource(Res.string.section_library)) LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { items(libraries) { folder -> Column( @@ -489,11 +543,11 @@ private fun MyMediaGrid(libraries: List, serverUrl: String, onS val folderImageUrl = "$serverUrl/emby/Items/${folder.id}/Images/Primary?maxWidth=300&quality=80" Box( Modifier.size(120.dp) - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(LumaShapes.medium)) .background(homeSecondaryColor().copy(alpha = 0.1f)) .clickable { onSelect(folder) }, ) { - AsyncImage( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(folderImageUrl).crossfade(true).build(), contentDescription = folder.name, contentScale = ContentScale.Crop, @@ -520,34 +574,60 @@ fun HomeScreen( selected: EmbyServer? = null, onSelectServer: (String) -> Unit = {}, onOpenDetail: (String) -> Unit = {}, - hazeState: HazeState? = null, ) { val activeServer = remember(servers, selected) { selected ?: servers.firstOrNull { it.apiKey.isNotBlank() } } + val homeKey = activeServer?.let(::homeCacheKey) var libraries by remember { mutableStateOf>(emptyList()) } var resumeItems by remember { mutableStateOf>(emptyList()) } var latestItems by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(false) } + var loadError by remember { mutableStateOf(false) } + var reloadToken by remember { mutableIntStateOf(0) } 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())) - var lastLoadedServerId by remember { mutableStateOf("") } + LaunchedEffect(homeKey, reloadToken) { + val server = activeServer + val cacheKey = homeKey + if (server == null || server.apiKey.isBlank() || cacheKey == null) return@LaunchedEffect + + if (reloadToken == 0) { + HomeContentCache.snapshot(cacheKey)?.let { snapshot -> + libraries = snapshot.libraries + latestItems = snapshot.latestItems + resumeItems = snapshot.resumeItems + loadError = false + loading = false + return@LaunchedEffect + } + } else { + HomeContentCache.invalidate(cacheKey) + } - LaunchedEffect(activeServer?.id) { - if (activeServer == null || activeServer.apiKey.isBlank()) return@LaunchedEffect - if (activeServer.id == lastLoadedServerId) return@LaunchedEffect loading = true try { - val libsDeferred = async { EmbyRepository.getLibraries(activeServer) } - val latestDeferred = async { EmbyRepository.getLatestItems(activeServer, limit = 10) } - val resumeDeferred = async { EmbyRepository.getResumeItems(activeServer, limit = 10) } + val libsDeferred = async { EmbyRepository.getLibraries(server) } + val latestDeferred = async { EmbyRepository.getLatestItems(server, limit = 10) } + val resumeDeferred = async { EmbyRepository.getResumeItems(server, limit = 10) } libraries = libsDeferred.await() latestItems = latestDeferred.await() resumeItems = resumeDeferred.await() + HomeContentCache.putSnapshot( + cacheKey, + HomeSnapshot( + libraries = libraries, + resumeItems = resumeItems, + latestItems = latestItems, + ), + ) + loadError = false + } catch (_: Exception) { + loadError = true } finally { loading = false } @@ -572,7 +652,7 @@ fun HomeScreen( AnimatedContent( targetState = loading, transitionSpec = { - fadeIn(tween(500)) togetherWith fadeOut(tween(500)) + fadeIn(tween(LumaMotion.standard)) togetherWith fadeOut(tween(LumaMotion.standard)) }, label = "HomeLoadingTransition" ) { isLoading -> @@ -582,7 +662,16 @@ fun HomeScreen( } } else if (isLoading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = adaptiveAccent) + LumaLoading(label = stringResource(Res.string.loading_home)) + } + } else if (loadError) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaErrorState( + title = stringResource(Res.string.error_content_title), + description = stringResource(Res.string.error_content_sub), + retryLabel = stringResource(Res.string.retry), + onRetry = { reloadToken++ }, + ) } } else { val server = activeServer @@ -612,12 +701,24 @@ fun HomeScreen( 0.38f to Color.Transparent, ) ) - .padding(start = 16.dp, end = 16.dp, top = 8.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), + .padding(start = LumaSpacing.md, end = LumaSpacing.md, top = LumaSpacing.md), + verticalArrangement = Arrangement.spacedBy(LumaSpacing.xl), ) { + if (resumeItems.isEmpty() && latestItems.isEmpty() && libraries.isEmpty()) { + Box( + Modifier.fillMaxWidth().padding(vertical = LumaSpacing.xxl), + contentAlignment = Alignment.Center, + ) { + LumaEmptyState( + icon = LumaIcon.Media, + title = stringResource(Res.string.empty_home_no_data), + description = stringResource(Res.string.empty_home_no_data_sub), + ) + } + } if (resumeItems.isNotEmpty()) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionTitle(title = "继续观看") + SectionTitle(title = stringResource(Res.string.continue_watching)) LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { items(resumeItems) { item -> ContinueWatchingPoster(item, server.fullUrl) { onOpenDetail(it.id) } @@ -642,7 +743,7 @@ fun HomeScreen( } HomeOverlayTopBar( - name = activeServer?.name ?: "未连接", + name = activeServer?.name ?: stringResource(Res.string.home_not_connected), iconUrl = activeServer?.iconUrl ?: "", modifier = Modifier.align(Alignment.TopCenter), ) @@ -660,12 +761,12 @@ private fun EmptyHomeState() { Modifier.size(64.dp).clip(CircleShape).background(homeSecondaryColor().copy(alpha = 0.16f)), contentAlignment = Alignment.Center, ) { - Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = homeSecondaryColor(), modifier = Modifier.size(32.dp)) + LumaIconView(LumaIcon.Media, contentDescription = null, tint = homeSecondaryColor(), modifier = Modifier.size(32.dp)) } Spacer(Modifier.height(16.dp)) - Text("没有可用的媒体库", color = homeSecondaryColor(), fontSize = 15.sp) + Text(stringResource(Res.string.empty_home_no_server), color = homeSecondaryColor(), fontSize = 15.sp) Spacer(Modifier.height(6.dp)) - Text("请在资源库中添加服务器", color = homeSecondaryColor().copy(alpha = 0.72f), fontSize = 12.sp) + Text(stringResource(Res.string.empty_home_no_server_sub), color = homeSecondaryColor().copy(alpha = 0.72f), fontSize = 12.sp) } } @@ -677,13 +778,13 @@ private fun HomeOverlayTopBar(name: String, iconUrl: String, modifier: Modifier verticalAlignment = Alignment.CenterVertically ) { Box( - Modifier.size(36.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.2f)), + Modifier.size(40.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.16f)), contentAlignment = Alignment.Center ) { if (iconUrl.isNotEmpty()) { - AsyncImage(model = iconUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + LumaAsyncImage(model = iconUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) } else { - Icon(Icons.Outlined.Storage, null, tint = Color.White) + LumaIconView(LumaIcon.Server, tint = Color.White, modifier = Modifier.size(22.dp)) } } Spacer(Modifier.width(12.dp)) diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt index ebe71cc..2ddc76d 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -21,6 +21,7 @@ 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.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.lazy.LazyRow @@ -39,16 +40,18 @@ 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.GlassConfirmDialog import com.example.myapplication.ui.GlassDialog +import com.example.myapplication.ui.GlassSheet import com.example.myapplication.ui.GlassInputField import com.example.myapplication.ui.GlassPopupItem import androidx.compose.material3.Icon 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.CompositionLocalProvider import com.example.myapplication.ui.AppLanguage import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -101,14 +104,24 @@ 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.LocalGlassBackdrop import com.example.myapplication.ui.LiquidGlassPopupOverlay import com.example.myapplication.ui.LiquidGlassTopBar import com.example.myapplication.ui.bottomBarContentPadding -import coil.compose.AsyncImage +import com.example.myapplication.ui.design.LumaColors +import com.example.myapplication.ui.design.LumaGlassRuntime +import com.example.myapplication.ui.design.rememberLumaLayerBackdrop +import com.example.myapplication.ui.design.LumaEmptyState +import com.example.myapplication.ui.design.LumaIcon +import com.example.myapplication.ui.design.LumaIconView +import com.example.myapplication.ui.design.LumaAsyncImage +import com.example.myapplication.ui.design.LumaIconButton +import com.example.myapplication.ui.design.LumaGlassLevel +import com.example.myapplication.ui.design.LumaShapes +import com.example.myapplication.ui.design.lumaGlass import coil.request.ImageRequest import com.kyant.backdrop.Backdrop import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -132,17 +145,29 @@ fun LibraryTopBar( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(text = "\u8D44\u6E90\u5E93", color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Text(text = stringResource(Res.string.nav_library), 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)) - } + LumaIconButton( + icon = if (isGrid) LumaIcon.Collection else LumaIcon.Library, + contentDescription = stringResource(Res.string.view_toggle), + onClick = onViewToggle, + tint = GlassTokens.text, + iconSize = 21.dp, + ) + LumaIconButton( + icon = LumaIcon.Library, + contentDescription = stringResource(Res.string.server_library_manager), + onClick = onOpenIconLibraryManager, + tint = GlassTokens.text, + iconSize = 21.dp, + ) + LumaIconButton( + icon = LumaIcon.Add, + contentDescription = stringResource(Res.string.add_server_action), + onClick = onAdd, + tint = GlassTokens.text, + iconSize = 21.dp, + ) } } } @@ -155,16 +180,16 @@ fun LibraryTopBar( @Composable private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, onMorePositioned: (LayoutCoordinates) -> Unit, onMoreClick: () -> Unit) { 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") + val pressBg by animateColorAsState(targetValue = if (clicked) LumaColors.glassHighlight.copy(alpha = 0.24f) 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) + LumaAsyncImage(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)) + LumaIconView(LumaIcon.Play, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp)) } } Spacer(Modifier.width(12.dp)) @@ -177,7 +202,7 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC 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) + Text(text = if (server.isOnline) server.lastActive.ifEmpty { stringResource(Res.string.server_online) } else stringResource(Res.string.server_offline), color = GlassTokens.subtext, fontSize = 12.sp) } if (server.note.isNotBlank()) { Text( @@ -191,9 +216,15 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC } } } - Box { - Icon(imageVector = Icons.Outlined.MoreHoriz, contentDescription = "\u64CD\u4F5C", tint = GlassTokens.subtext, modifier = Modifier.size(32.dp).onGloballyPositioned(onMorePositioned).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onMoreClick() }.padding(4.dp)) - } + Box( + Modifier + .size(48.dp) + .onGloballyPositioned(onMorePositioned) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onMoreClick() }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.MoreHoriz, contentDescription = stringResource(Res.string.server_actions), tint = GlassTokens.subtext, modifier = Modifier.size(24.dp)) + } } } } @@ -205,13 +236,13 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC @Composable private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, onMorePositioned: (LayoutCoordinates) -> Unit, onMoreClick: () -> Unit) { 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") + val pressBg by animateColorAsState(targetValue = if (clicked) LumaColors.glassHighlight.copy(alpha = 0.24f) 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( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(), contentDescription = null, modifier = Modifier.size(48.dp).clip(RoundedCornerShape(14.dp)), @@ -222,7 +253,7 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, 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)) + LumaIconView(LumaIcon.Play, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp)) } } Spacer(Modifier.width(10.dp)) @@ -242,12 +273,18 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, 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) + Text(text = if (server.isOnline) server.lastActive.ifEmpty { stringResource(Res.string.server_online) } else stringResource(Res.string.server_offline), color = GlassTokens.subtext, fontSize = 11.sp) } } } - Box { - Icon(imageVector = Icons.Outlined.MoreHoriz, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(28.dp).onGloballyPositioned(onMorePositioned).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onMoreClick() }.padding(4.dp)) + Box( + Modifier + .size(48.dp) + .onGloballyPositioned(onMorePositioned) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onMoreClick() }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.MoreHoriz, contentDescription = stringResource(Res.string.server_actions), tint = GlassTokens.subtext, modifier = Modifier.size(24.dp)) } } Box(Modifier.height(22.dp)) { @@ -289,8 +326,9 @@ fun LibraryScreen( var popupServer by remember { mutableStateOf(null) } var popupAnchor by remember { mutableStateOf(null) } var popupVisible by remember { mutableStateOf(false) } + var deleteServerTarget by remember { mutableStateOf(null) } var rootOrigin by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } - val libraryBackdrop = rememberLayerBackdrop() + val libraryBackdrop = rememberLumaLayerBackdrop() if (editingServer != null) { AddServerDialog( @@ -305,13 +343,21 @@ fun LibraryScreen( if (onChangeIconTarget != null) { IconPickerDialog( server = onChangeIconTarget, + backdrop = libraryBackdrop, onDismiss = onDismissChangeIcon, onPick = { srv, url -> onPickIcon(srv, url); onDismissChangeIcon() }, ) } Box(Modifier.fillMaxSize().onGloballyPositioned { rootOrigin = it.positionInRoot() }) { - Box(Modifier.fillMaxSize().layerBackdrop(libraryBackdrop)) { - + Box( + Modifier + .fillMaxSize() + .background(LumaColors.background) + .then( + if (libraryBackdrop != null) Modifier.layerBackdrop(libraryBackdrop) else Modifier, + ), + ) + CompositionLocalProvider(LocalGlassBackdrop provides libraryBackdrop) { if (servers.isEmpty()) { Box( Modifier.fillMaxSize().padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()), @@ -320,30 +366,16 @@ fun LibraryScreen( Column( horizontalAlignment = Alignment.CenterHorizontally, ) { - Box( - Modifier - .size(64.dp) - .clip(CircleShape) - .background(GlassTokens.glassFill) - .border(1.dp, GlassTokens.glassBorder, CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = null, - tint = GlassTokens.subtext2, - modifier = Modifier.size(32.dp), - ) - } - Spacer(Modifier.height(16.dp)) - Text(text = stringResource(Res.string.empty_servers), color = GlassTokens.subtext, fontSize = 15.sp) - Spacer(Modifier.height(6.dp)) - Text(text = stringResource(Res.string.empty_servers_sub), color = GlassTokens.subtext, fontSize = 12.sp) + LumaEmptyState( + icon = LumaIcon.Server, + title = stringResource(Res.string.empty_servers), + description = stringResource(Res.string.empty_servers_sub), + ) } } } else { LazyVerticalGrid( - columns = if (isGrid) GridCells.Fixed(2) else GridCells.Fixed(1), + columns = if (isGrid) GridCells.Adaptive(minSize = 150.dp) else GridCells.Fixed(1), modifier = Modifier.fillMaxSize().statusBarsPadding().padding(horizontal = if (isGrid) 16.dp else 0.dp, vertical = 0.dp).padding(top = 78.dp), contentPadding = PaddingValues(bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()), verticalArrangement = Arrangement.spacedBy(if (isGrid) 12.dp else 10.dp), @@ -389,7 +421,6 @@ fun LibraryScreen( } } } - if (showTopBar) { LibraryTopBar( isGrid = isGrid, @@ -400,8 +431,6 @@ fun LibraryScreen( ) } - } - val currentPopupServer = popupServer val currentPopupAnchor = popupAnchor if (currentPopupServer != null && currentPopupAnchor != null) { @@ -417,23 +446,42 @@ fun LibraryScreen( }, ) { GlassPopupItem( - text = "\u7F16\u8F91", - icon = Icons.Outlined.Edit, + text = stringResource(Res.string.server_edit), + icon = LumaIcon.Edit, onClick = { popupVisible = false; editingServer = currentPopupServer }, ) GlassPopupItem( - text = "\u66F4\u6362\u56FE\u6807", - icon = Icons.Outlined.Image, + text = stringResource(Res.string.change_icon_action), + icon = LumaIcon.Library, onClick = { popupVisible = false; onRequestChangeIcon(currentPopupServer) }, ) GlassPopupItem( - text = "\u5220\u9664", - icon = Icons.Outlined.Delete, + text = stringResource(Res.string.icon_lib_delete), + icon = LumaIcon.Delete, tint = offlineRed, - onClick = { popupVisible = false; onDeleteServer(currentPopupServer.id) }, + onClick = { + popupVisible = false + deleteServerTarget = currentPopupServer + }, ) } } + + deleteServerTarget?.let { serverToDelete -> + GlassConfirmDialog( + title = stringResource(Res.string.confirm_delete_title), + message = stringResource(Res.string.confirm_delete_server_message, serverToDelete.name), + confirmText = stringResource(Res.string.confirm_delete), + dismissText = stringResource(Res.string.confirm_cancel), + backdrop = libraryBackdrop, + onDismissRequest = { deleteServerTarget = null }, + onConfirm = { + onDeleteServer(serverToDelete.id) + deleteServerTarget = null + }, + ) + } + } } } @@ -453,115 +501,138 @@ internal fun IconLibraryManagerScreenContent( ) { var showAddDialog by remember { mutableStateOf(false) } var editingLibraryId by remember { mutableStateOf(null) } + var deleteLibraryTarget by remember { mutableStateOf(null) } + val managerBackdrop = rememberLumaLayerBackdrop() val editingLibrary = editingLibraryId?.let { id -> libraries.find { it.id == id } } - - Column(modifier.fillMaxSize() - .pointerInput(Unit) { - var totalDrag = 0f - detectHorizontalDragGestures( - onDragStart = { totalDrag = 0f }, - onDragEnd = { - if (totalDrag > 300f) onBack() + Box(modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .background(LumaColors.background) + .then( + if (managerBackdrop != null) Modifier.layerBackdrop(managerBackdrop) else Modifier, + ), + ) + CompositionLocalProvider(LocalGlassBackdrop provides managerBackdrop) { + Column( + Modifier + .fillMaxSize() + .pointerInput(Unit) { + var totalDrag = 0f + detectHorizontalDragGestures( + onDragStart = { totalDrag = 0f }, + onDragEnd = { if (totalDrag > 300f) onBack() }, + ) { _, dragAmount -> totalDrag += dragAmount } }, - ) { _, dragAmount -> - totalDrag += dragAmount - } - } - ) { - LiquidGlassTopBar { - Row( - Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, ) { - Box(Modifier.width(86.dp)) { - 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)) - } - } - 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) { + LiquidGlassTopBar { 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), + Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.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) - } - } - } - } - - 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\u53F3\u4E0A\u89D2\u6DFB\u52A0", color = GlassTokens.subtext.copy(alpha = 0.75f), fontSize = 12.sp) + Box(Modifier.width(86.dp)) { + Box( + Modifier.size(34.dp) + .lumaGlass(radius = 17.dp, level = com.example.myapplication.ui.design.LumaGlassLevel.Floating) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onBack() }, + contentAlignment = Alignment.Center, + ) { + LumaIconView(LumaIcon.Close, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(19.dp)) + } + } + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text(stringResource(Res.string.icon_lib_manager_title), color = GlassTokens.text, fontSize = 19.sp, fontWeight = FontWeight.Bold) + } + Box(Modifier.width(86.dp), contentAlignment = Alignment.CenterEnd) { + Row( + Modifier + .lumaGlass(radius = 20.dp, level = com.example.myapplication.ui.design.LumaGlassLevel.Interactive) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { showAddDialog = true } + .padding(horizontal = 10.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + LumaIconView(LumaIcon.Add, contentDescription = null, tint = GlassTokens.accent, modifier = Modifier.size(17.dp)) + Text(stringResource(Res.string.icon_lib_add), color = GlassTokens.accent, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } } } } - } else { - items(libraries) { library -> - IconLibraryManagerCard(library = library, onClick = { editingLibraryId = library.id }) + + 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() + .lumaGlass(radius = LumaShapes.large, level = com.example.myapplication.ui.design.LumaGlassLevel.Regular) + .padding(vertical = 54.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + LumaIconView(LumaIcon.Library, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(32.dp)) + Text(stringResource(Res.string.icon_lib_empty), color = GlassTokens.subtext, fontSize = 15.sp) + Text(stringResource(Res.string.icon_lib_empty_hint), color = GlassTokens.subtext.copy(alpha = 0.75f), fontSize = 12.sp) + } + } + } + } else { + items(libraries) { library -> + IconLibraryManagerCard(library = library, onClick = { editingLibraryId = library.id }) + } + } } } + + if (showAddDialog) { + AddIconLibraryDialog( + backdrop = managerBackdrop, + onDismiss = { showAddDialog = false }, + onSave = { name, url, icons, isDefault -> + onAddIconLibrary(name, url, icons, isDefault) + showAddDialog = false + }, + ) } - } - if (showAddDialog) { - AddIconLibraryDialog( - onDismiss = { showAddDialog = false }, - onSave = { name, url, icons, isDefault -> - onAddIconLibrary(name, url, icons, isDefault) - showAddDialog = false - }, - ) - } + if (editingLibrary != null) { + ManageIconLibraryDialog( + library = editingLibrary, + backdrop = managerBackdrop, + onDismiss = { editingLibraryId = null }, + onUpdate = { updated -> + onUpdateIconLibrary(updated) + editingLibraryId = null + }, + onDelete = { + editingLibraryId = null + deleteLibraryTarget = editingLibrary + }, + onRefresh = { onRefreshIconLibrary(editingLibrary.id) }, + ) + } - 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) }, - ) + deleteLibraryTarget?.let { libraryToDelete -> + GlassConfirmDialog( + title = stringResource(Res.string.confirm_delete_title), + message = stringResource(Res.string.confirm_delete_icon_library_message, libraryToDelete.name), + confirmText = stringResource(Res.string.confirm_delete), + dismissText = stringResource(Res.string.confirm_cancel), + backdrop = managerBackdrop, + onDismissRequest = { deleteLibraryTarget = null }, + onConfirm = { + onDeleteIconLibrary(libraryToDelete.id) + deleteLibraryTarget = null + }, + ) + } + } } } @@ -569,14 +640,7 @@ internal fun IconLibraryManagerScreenContent( 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)) + .lumaGlass(radius = LumaShapes.large, level = com.example.myapplication.ui.design.LumaGlassLevel.Regular) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) .padding(14.dp), ) { @@ -590,7 +654,7 @@ private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) { .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)) + LumaIconView(LumaIcon.Library, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp)) } Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { @@ -606,7 +670,7 @@ private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) { } } } - Text("${library.iconCount} \u4E2A\u56FE\u6807", color = GlassTokens.subtext, fontSize = 11.sp) + Text(stringResource(Res.string.icon_count_suffix, library.iconCount), color = GlassTokens.subtext, fontSize = 11.sp) } } @@ -614,12 +678,11 @@ private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, 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)), + Modifier.size(34.dp) + .lumaGlass(radius = 9.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp), contentAlignment = Alignment.Center, ) { - AsyncImage( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), contentDescription = icon.name, contentScale = ContentScale.Crop, @@ -629,28 +692,27 @@ private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) { } 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)), + Modifier.weight(1f).height(34.dp) + .lumaGlass(radius = 9.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp), contentAlignment = Alignment.Center, - ) { Text("\u56fe\u6807\u672a\u52a0\u8f7d", color = GlassTokens.subtext, fontSize = 11.sp) } + ) { Text(stringResource(Res.string.icon_lib_not_loaded), 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)), + Modifier.weight(1f).height(34.dp) + .lumaGlass(radius = 9.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp), contentAlignment = Alignment.Center, - ) { Text("\u6682\u65E0\u56FE\u6807", color = GlassTokens.subtext, fontSize = 11.sp) } + ) { Text(stringResource(Res.string.icon_lib_no_icons), color = GlassTokens.subtext, fontSize = 11.sp) } } } 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)) + Modifier.fillMaxWidth() + .lumaGlass(radius = 12.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp) .padding(vertical = 7.dp), contentAlignment = Alignment.Center, ) { - Text("\u9884\u89C8 \u00B7 \u4FEE\u6539", color = GlassTokens.text, fontSize = 12.sp, fontWeight = FontWeight.Medium) + Text(stringResource(Res.string.icon_lib_preview_edit), color = GlassTokens.text, fontSize = 12.sp, fontWeight = FontWeight.Medium) } } } @@ -669,8 +731,8 @@ private fun IconLibrarySection( 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) + Text(stringResource(Res.string.icon_lib_section_title), fontSize = 15.sp, fontWeight = FontWeight.Bold, color = GlassTokens.text) + Text(stringResource(Res.string.icon_count_unit, libraries.size), fontSize = 13.sp, color = GlassTokens.subtext) } if (isGrid) { // Grid layout for icon libraries — use Column+Row to avoid nested scrolling @@ -719,14 +781,7 @@ private fun IconLibraryCard( ) { 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)) + .lumaGlass(radius = 18.dp, level = LumaGlassLevel.Regular) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -743,11 +798,10 @@ private fun IconLibraryCard( 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)), + .lumaGlass(radius = 8.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp, tint = Color(0xFFFF8A65).copy(alpha = 0.18f)), contentAlignment = Alignment.Center, ) { - AsyncImage( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), contentDescription = icon.name, contentScale = ContentScale.Crop, @@ -759,13 +813,12 @@ private fun IconLibraryCard( } 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) + Text(stringResource(Res.string.icon_count_suffix, library.iconCount), 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)) + modifier = Modifier.fillMaxWidth() + .lumaGlass(radius = 10.dp, level = LumaGlassLevel.Interactive, tint = LumaColors.accentSoft, borderColor = GlassTokens.accent.copy(alpha = 0.2f)) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -774,7 +827,7 @@ private fun IconLibraryCard( .padding(vertical = 6.dp), contentAlignment = Alignment.Center, ) { - Text("\u7BA1\u7406", fontSize = 12.sp, fontWeight = FontWeight.Medium, color = GlassTokens.accent) + Text(stringResource(Res.string.icon_lib_manage), fontSize = 12.sp, fontWeight = FontWeight.Medium, color = GlassTokens.accent) } } } @@ -785,22 +838,9 @@ private fun IconLibraryCard( /* ------------------------------------------------------------------ */ @Composable -private fun IconLibraryDetailDialog(library: IconLibrary, onDismiss: () -> Unit) { - Dialog(onDismissRequest = onDismiss) { - val dialogCtx = LocalContext.current - val localizedCtx = remember(AppLanguage.language) { AppLanguage.getLocalizedContext(dialogCtx) } - CompositionLocalProvider(LocalContext provides localizedCtx) { - 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) - ) { +private fun IconLibraryDetailDialog(library: IconLibrary, backdrop: Backdrop? = null, onDismiss: () -> Unit) { + GlassDialog(backdrop = backdrop, onDismissRequest = onDismiss, modifier = Modifier.width(340.dp)) { + Box(Modifier.fillMaxWidth()) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { // Header Row(verticalAlignment = Alignment.CenterVertically) { @@ -809,36 +849,33 @@ private fun IconLibraryDetailDialog(library: IconLibrary, onDismiss: () -> Unit) .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)) + LumaIconView(LumaIcon.Library, 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) + Text(stringResource(Res.string.icon_count_suffix, library.iconCount), 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)) + modifier = Modifier.fillMaxWidth() + .lumaGlass(radius = 16.dp, level = LumaGlassLevel.Clear) .padding(vertical = 36.dp), contentAlignment = Alignment.Center, - ) { Text("\u56fe\u6807\u672a\u52a0\u8f7d", color = GlassTokens.subtext, fontSize = 14.sp) } + ) { Text(stringResource(Res.string.icon_lib_not_loaded), 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)) + modifier = Modifier.fillMaxWidth() + .lumaGlass(radius = 16.dp, level = LumaGlassLevel.Clear) .padding(vertical = 36.dp), contentAlignment = Alignment.Center, - ) { Text("\u6682\u65E0\u56FE\u6807", color = GlassTokens.subtext, fontSize = 14.sp) } + ) { Text(stringResource(Res.string.icon_lib_no_icons), 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)) + Modifier.fillMaxWidth() + .lumaGlass(radius = 16.dp, level = LumaGlassLevel.Clear) .padding(8.dp) ) { LazyVerticalGrid( @@ -849,13 +886,12 @@ private fun IconLibraryDetailDialog(library: IconLibrary, onDismiss: () -> Unit) ) { 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)) + modifier = Modifier.aspectRatio(1f) + .lumaGlass(radius = 10.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp) .padding(4.dp), contentAlignment = Alignment.Center, ) { - AsyncImage( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), contentDescription = icon.name, contentScale = ContentScale.Crop, @@ -869,16 +905,14 @@ private fun IconLibraryDetailDialog(library: IconLibrary, onDismiss: () -> Unit) // 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)) + modifier = Modifier.fillMaxWidth() + .lumaGlass(radius = 16.dp, level = LumaGlassLevel.Interactive) .clickable { onDismiss() } .padding(vertical = 14.dp), contentAlignment = Alignment.Center, - ) { Text("\u5173\u95ED", color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) } + ) { Text(stringResource(Res.string.icon_lib_close), color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) } } } - } } } @@ -889,6 +923,7 @@ private fun IconLibraryDetailDialog(library: IconLibrary, onDismiss: () -> Unit) @Composable private fun ManageIconLibraryDialog( library: IconLibrary, + backdrop: Backdrop? = null, onDismiss: () -> Unit, onUpdate: (IconLibrary) -> Unit, onDelete: () -> Unit, @@ -900,7 +935,7 @@ private fun ManageIconLibraryDialog( var isRefreshing by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - GlassDialog(onDismissRequest = onDismiss) { + GlassDialog(backdrop = backdrop, onDismissRequest = onDismiss) { Column( modifier = Modifier.width(300.dp) .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.78f).dp) @@ -914,30 +949,33 @@ private fun ManageIconLibraryDialog( .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)) + LumaIconView(LumaIcon.Library, 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) + Text(text = stringResource(Res.string.icon_lib_manage_dialog_title), 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(stringResource(Res.string.icon_lib_count), 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 }) + GlassInputField(label = stringResource(Res.string.icon_lib_name_label), value = editName, placeholder = stringResource(Res.string.icon_lib_name_label), onValueChange = { editName = it }) + GlassInputField(label = stringResource(Res.string.icon_lib_url_label), 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)) + .lumaGlass( + radius = 16.dp, + level = LumaGlassLevel.Interactive, + tint = if (setAsDefault) LumaColors.accentSoft else LumaColors.clearTint, + borderColor = if (setAsDefault) GlassTokens.accent.copy(alpha = 0.35f) else LumaColors.glassBorder, + ) .clickable { setAsDefault = !setAsDefault } .padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -960,8 +998,8 @@ private fun ManageIconLibraryDialog( // Icon preview if (library.icons.isNotEmpty()) { - Text("\u56FE\u6807\u9884\u89C8", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) - LazyVerticalGrid( + Text(stringResource(Res.string.icon_lib_preview), fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) + LazyVerticalGrid( columns = GridCells.Fixed(5), modifier = Modifier.heightIn(min = 80.dp, max = 320.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), @@ -969,13 +1007,12 @@ private fun ManageIconLibraryDialog( ) { 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)) + modifier = Modifier.aspectRatio(1f) + .lumaGlass(radius = 8.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp) .padding(2.dp), contentAlignment = Alignment.Center, ) { - AsyncImage( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), contentDescription = icon.name, contentScale = ContentScale.Crop, @@ -988,9 +1025,12 @@ private fun ManageIconLibraryDialog( // 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)) + modifier = Modifier.fillMaxWidth() + .lumaGlass( + radius = 16.dp, + level = LumaGlassLevel.Interactive, + tint = if (isRefreshing) LumaColors.clearTint else LumaColors.regularTint, + ) .clickable(enabled = !isRefreshing) { isRefreshing = true scope.launch { @@ -1005,20 +1045,20 @@ private fun ManageIconLibraryDialog( 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) + Text(stringResource(Res.string.icon_lib_refresh), fontSize = 14.sp, fontWeight = FontWeight.Medium, color = GlassTokens.accent) } } // Save / Delete row Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { GlassButton( - text = "\u5220\u9664", + text = stringResource(Res.string.icon_lib_delete), onClick = onDelete, modifier = Modifier.weight(1f), danger = true, ) GlassButton( - text = "\u4FDD\u5B58", + text = stringResource(Res.string.icon_lib_save), onClick = { if (editName.text.isNotBlank() && editUrl.text.isNotBlank()) { onUpdate(library.copy(name = editName.text.trim(), sourceUrl = editUrl.text.trim(), isDefault = setAsDefault)) @@ -1034,6 +1074,7 @@ private fun ManageIconLibraryDialog( } @Composable private fun AddIconLibraryDialog( + backdrop: Backdrop? = null, onDismiss: () -> Unit, onSave: (String, String, List, Boolean) -> Unit = { _, _, _, _ -> } ) { @@ -1045,26 +1086,10 @@ private fun AddIconLibraryDialog( var error by remember { mutableStateOf(null) } var fetched by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + val fetchErrorFallback = stringResource(Res.string.icon_lib_fetch_error) - Dialog(onDismissRequest = onDismiss) { - val dialogCtx = LocalContext.current - val localizedCtx = remember(AppLanguage.language) { AppLanguage.getLocalizedContext(dialogCtx) } - CompositionLocalProvider(LocalContext provides localizedCtx) { - 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) - ) { + GlassDialog(backdrop = backdrop, onDismissRequest = onDismiss, modifier = Modifier.width(320.dp)) { + Box(Modifier.fillMaxWidth()) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.verticalScroll(rememberScrollState()), @@ -1075,41 +1100,28 @@ private fun AddIconLibraryDialog( .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)) + LumaIconView(LumaIcon.Library, 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(text = stringResource(Res.string.icon_lib_add_title), 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() - } - } - ) - } + GlassInputField( + label = stringResource(Res.string.icon_lib_name_label), + value = name, + placeholder = stringResource(Res.string.icon_lib_name_hint), + onValueChange = { name = it }, + ) // 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)) + .lumaGlass( + radius = 16.dp, + level = LumaGlassLevel.Interactive, + tint = if (isDefault) LumaColors.accentSoft else LumaColors.clearTint, + borderColor = if (isDefault) GlassTokens.accent.copy(alpha = 0.35f) else LumaColors.glassBorder, + ) .clickable { isDefault = !isDefault } .padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -1130,45 +1142,33 @@ private fun AddIconLibraryDialog( } } - 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() - } - } - ) - } + GlassInputField( + label = stringResource(Res.string.icon_lib_url_label), + value = url, + placeholder = "https://...", + onValueChange = { + url = it + fetched = false + error = null + icons = emptyList() + }, + ) 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))) + modifier = Modifier.fillMaxWidth() + .lumaGlass( + radius = 16.dp, + level = LumaGlassLevel.Interactive, + tint = if (url.text.isNotBlank() && !isLoading) GlassTokens.accent else LumaColors.accentSoft, + borderColor = GlassTokens.accent.copy(alpha = 0.35f), ) - .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" } + onFailure = { error = fetchErrorFallback } ) isLoading = false } @@ -1179,18 +1179,17 @@ private fun AddIconLibraryDialog( 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) + Text(stringResource(Res.string.icon_lib_fetch_preview), 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) + Text(stringResource(Res.string.icon_lib_preview_count, icons.size), 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)) + Modifier.fillMaxWidth() + .lumaGlass(radius = 16.dp, level = LumaGlassLevel.Clear) .padding(8.dp) ) { LazyVerticalGrid( @@ -1201,12 +1200,11 @@ private fun AddIconLibraryDialog( ) { 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)), + modifier = Modifier.aspectRatio(1f) + .lumaGlass(radius = 12.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp), contentAlignment = Alignment.Center, ) { - AsyncImage( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), contentDescription = icon.name, modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)), @@ -1216,35 +1214,25 @@ private fun AddIconLibraryDialog( } } } - Text("\u2713 \u5DF2\u52A0\u8F7D ${icons.size} \u4E2A\u56FE\u6807", fontSize = 12.sp, color = onlineGreen) + Text(stringResource(Res.string.icon_lib_loaded_count, icons.size), 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) } + GlassButton( + text = stringResource(Res.string.icon_lib_cancel), + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) + GlassButton( + text = stringResource(Res.string.icon_lib_save), + onClick = { onSave(name.text.trim(), url.text.trim(), icons, isDefault); onDismiss() }, + modifier = Modifier.weight(1f), + accent = true, + enabled = icons.isNotEmpty() && name.text.isNotBlank(), + ) } } } - } } } @@ -1253,7 +1241,12 @@ private fun AddIconLibraryDialog( /* ------------------------------------------------------------------ */ @Composable -private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: (EmbyServer, String) -> Unit) { +private fun IconPickerDialog( + server: EmbyServer, + backdrop: Backdrop? = null, + onDismiss: () -> Unit, + onPick: (EmbyServer, String) -> Unit, +) { var allIcons by remember { mutableStateOf>(emptyList()) } var query by remember { mutableStateOf(TextFieldValue("")) } var isLoading by remember { mutableStateOf(false) } @@ -1274,25 +1267,8 @@ private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: } } - Dialog(onDismissRequest = onDismiss) { - val dialogCtx = LocalContext.current - val localizedCtx = remember(AppLanguage.language) { AppLanguage.getLocalizedContext(dialogCtx) } - CompositionLocalProvider(LocalContext provides localizedCtx) { - 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) - ) { + GlassSheet(backdrop = backdrop, onDismissRequest = onDismiss, modifier = Modifier.widthIn(max = 420.dp)) { + Box(Modifier.fillMaxWidth()) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.80f).dp), @@ -1303,25 +1279,24 @@ private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: .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)) + LumaIconView(LumaIcon.Library, 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) + Text(text = stringResource(Res.string.icon_picker_title), 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)) + Modifier.fillMaxWidth() + .lumaGlass(radius = 14.dp, level = LumaGlassLevel.Regular, shadowElevation = 0.dp) .padding(horizontal = 12.dp, vertical = 9.dp), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - Icons.Outlined.Search, + LumaIconView( + LumaIcon.Search, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(18.dp), @@ -1330,7 +1305,7 @@ private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: Box(Modifier.weight(1f)) { if (query.text.isEmpty()) { Text( - text = "\u641C\u7D22\u56FE\u6807\u540D\u79F0", + text = stringResource(Res.string.icon_picker_search_hint), color = GlassTokens.subtext, fontSize = 14.sp, ) @@ -1346,13 +1321,11 @@ private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: } if (query.text.isNotEmpty()) { Spacer(Modifier.width(8.dp)) - Icon( - Icons.Outlined.Close, - contentDescription = "\u6E05\u9664\u641C\u7D22", + LumaIconView( + LumaIcon.Close, + contentDescription = stringResource(Res.string.icon_picker_clear_search), tint = GlassTokens.subtext, - modifier = Modifier - .size(18.dp) - .clickable { query = TextFieldValue("") }, + modifier = Modifier.size(18.dp).clickable { query = TextFieldValue("") }, ) } } @@ -1365,32 +1338,29 @@ private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: } } 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)) + modifier = Modifier.fillMaxWidth().weight(1f) + .lumaGlass(radius = 20.dp, level = LumaGlassLevel.Clear) .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), + Box(Modifier.size(56.dp) + .lumaGlass(radius = 28.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp), contentAlignment = Alignment.Center ) { - Icon(Icons.Outlined.Image, contentDescription = null, tint = GlassTokens.subtext2, modifier = Modifier.size(28.dp)) + LumaIconView(LumaIcon.Library, 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) + Text(text = stringResource(Res.string.icon_picker_no_lib), 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) + Text(text = stringResource(Res.string.icon_picker_no_lib_hint), 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) + Text(stringResource(Res.string.icon_picker_loaded_hint, allIcons.size), 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)) + Modifier.fillMaxWidth().weight(1f) + .lumaGlass(radius = 20.dp, level = LumaGlassLevel.Clear) .padding(8.dp) ) { LazyVerticalGrid( @@ -1402,18 +1372,18 @@ private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: 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))) + modifier = Modifier.aspectRatio(1f) + .lumaGlass( + radius = 12.dp, + level = if (isSelected) LumaGlassLevel.Interactive else LumaGlassLevel.Clear, + tint = if (isSelected) LumaColors.accentSoft else null, + borderColor = if (isSelected) GlassTokens.accent else LumaColors.glassBorder, + shadowElevation = 0.dp, ) - .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( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), contentDescription = icon.name, modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)), @@ -1421,12 +1391,11 @@ private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: ) 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), + Modifier.align(Alignment.TopEnd).padding(3.dp).size(18.dp) + .lumaGlass(radius = 9.dp, level = LumaGlassLevel.Interactive, tint = GlassTokens.accent, shadowElevation = 0.dp), contentAlignment = Alignment.Center, ) { - Icon(Icons.Filled.GridView, contentDescription = null, tint = Color.White, modifier = Modifier.size(11.dp)) + LumaIconView(LumaIcon.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(11.dp)) } } } @@ -1436,13 +1405,12 @@ private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: 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)), + Modifier.fillMaxWidth().height(90.dp) + .lumaGlass(radius = 20.dp, level = LumaGlassLevel.Clear), contentAlignment = Alignment.Center, ) { Text( - text = "\u672A\u627E\u5230\u5339\u914D\u7684\u56FE\u6807", + text = stringResource(Res.string.icon_picker_no_match), color = GlassTokens.subtext, fontSize = 14.sp, fontWeight = FontWeight.Medium, @@ -1452,16 +1420,14 @@ private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: } 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)) + modifier = Modifier.fillMaxWidth() + .lumaGlass(radius = 16.dp, level = LumaGlassLevel.Interactive) .clickable { onDismiss() } .padding(vertical = 14.dp), contentAlignment = Alignment.Center, - ) { Text(text = "\u5173\u95ED", color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) } + ) { Text(text = stringResource(Res.string.icon_lib_close), color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) } } } - } } } @@ -1474,23 +1440,23 @@ fun AddServerScreen( onDismiss: () -> Unit, onConfirm: (EmbyServer, (String?) -> Unit) -> Unit, ) { - val pageBackground = if (GlassTokens.isDark) Color(0xFF0F1115) else Color(0xFFF2F4F7) - val buttonBackground = if (GlassTokens.isDark) Color(0xFF25272C) else Color.White + val pageBackground = LumaColors.background + val buttonBackground = LumaColors.floatingTint Column(Modifier.fillMaxSize().background(pageBackground)) { Row( Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 18.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( - Modifier.size(36.dp).clip(CircleShape).background(buttonBackground) - .border(1.dp, if (GlassTokens.isDark) Color(0xFF44474F) else Color(0xFFD1D5DB), CircleShape) - .clickable(onClick = onDismiss), + Modifier.size(48.dp).clip(CircleShape).background(buttonBackground) + .border(0.6.dp, LumaColors.glassBorder, CircleShape) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onDismiss), contentAlignment = Alignment.Center, ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "\u8FD4\u56DE", tint = GlassTokens.text, modifier = Modifier.size(20.dp)) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(20.dp)) } Spacer(Modifier.width(12.dp)) - Text("\u6DFB\u52A0\u670D\u52A1\u5668", color = GlassTokens.text, fontSize = 22.sp, fontWeight = FontWeight.Bold) + Text(stringResource(Res.string.add_server), color = GlassTokens.text, fontSize = 22.sp, fontWeight = FontWeight.Bold) } ServerFormContent( initial = null, @@ -1544,6 +1510,7 @@ private fun ServerFormContent( var showPassword by remember { mutableStateOf(false) } var showValidation by remember { mutableStateOf(false) } var useHttps by remember { mutableStateOf(initial?.protocol?.equals("https", ignoreCase = true) ?: true) } + val justNowLabel = stringResource(Res.string.server_just_now) val hostFocusRequester = remember { FocusRequester() } val portFocusRequester = remember { FocusRequester() } val usernameFocusRequester = remember { FocusRequester() } @@ -1566,7 +1533,7 @@ private fun ServerFormContent( protocol = if (useHttps) "https" else "http", 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", + isOnline = initial?.isOnline ?: true, lastActive = initial?.lastActive ?: justNowLabel, ) onConfirm(server) { error -> isSaving = false @@ -1584,28 +1551,28 @@ private fun ServerFormContent( .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)) + LumaIconView(LumaIcon.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) + Text(text = stringResource(if (initial == null) Res.string.add_server else Res.string.edit_server), fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text) } } - ServerFormInput(useGlass = useGlass, label = "\u540D\u79F0", value = name, placeholder = "\u6211\u7684 Emby", onValueChange = { name = it }) + ServerFormInput(useGlass = useGlass, label = stringResource(Res.string.server_name_label), value = name, placeholder = stringResource(Res.string.server_name_placeholder), onValueChange = { name = it }) ServerFormInput( useGlass = useGlass, - label = "\u5730\u5740", + label = stringResource(Res.string.server_url_label), value = host, placeholder = "emby.example.com", onValueChange = { host = it }, isError = hostError, - errorMessage = if (hostError) "\u8BF7\u8F93\u5165\u5730\u5740" else null, + errorMessage = if (hostError) stringResource(Res.string.server_url_error) else null, focusRequester = hostFocusRequester, ) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text("HTTPS", color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium) + Text(stringResource(Res.string.server_protocol), color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium) Text( - if (useHttps) "\u4F7F\u7528 https:// \u8BF7\u6C42" else "\u4F7F\u7528 http:// \u8BF7\u6C42", + stringResource(if (useHttps) Res.string.server_protocol_https_sub else Res.string.server_protocol_http_sub), color = GlassTokens.subtext, fontSize = 12.sp, ) @@ -1615,7 +1582,7 @@ private fun ServerFormContent( Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { ServerFormInput( useGlass = useGlass, - label = "\u8DEF\u5F84", + label = stringResource(Res.string.server_path_label), value = basePath, placeholder = "/emby", onValueChange = { basePath = it }, @@ -1623,37 +1590,37 @@ private fun ServerFormContent( ) ServerFormInput( useGlass = useGlass, - label = "\u7AEF\u53E3", + label = stringResource(Res.string.server_port_label), value = port, placeholder = "8096", onValueChange = { port = it }, modifier = Modifier.weight(1f), isError = portError, - errorMessage = if (portError) "\u8BF7\u8F93\u5165\u7AEF\u53E3" else null, + errorMessage = if (portError) stringResource(Res.string.server_port_error) else null, focusRequester = portFocusRequester, ) } ServerFormInput( useGlass = useGlass, - label = "\u7528\u6237\u540D", + label = stringResource(Res.string.server_username_label), value = username, - placeholder = "\u7528\u6237\u540D", + placeholder = stringResource(Res.string.server_username_placeholder), onValueChange = { username = it }, isError = usernameError, - errorMessage = if (usernameError) "\u8BF7\u8F93\u5165\u7528\u6237\u540D" else null, + errorMessage = if (usernameError) stringResource(Res.string.server_username_error) else null, focusRequester = usernameFocusRequester, ) ServerFormInput( useGlass = useGlass, - label = "\u5BC6\u7801", + label = stringResource(Res.string.server_password_label), value = password, - placeholder = "\u5BC6\u7801", + placeholder = stringResource(Res.string.server_password_placeholder), 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", + contentDescription = stringResource(if (showPassword) Res.string.server_hide_password else Res.string.server_show_password), tint = GlassTokens.subtext, modifier = Modifier.size(24.dp).clickable( interactionSource = remember { MutableInteractionSource() }, @@ -1662,24 +1629,24 @@ private fun ServerFormContent( ) }, ) - ServerFormInput(useGlass = useGlass, label = "\u5907\u6CE8", value = note, placeholder = "\u5907\u6CE8", onValueChange = { note = it }) + ServerFormInput(useGlass = useGlass, label = stringResource(Res.string.server_note_label), value = note, placeholder = stringResource(Res.string.server_note_placeholder), 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)) { if (useGlass) { - GlassButton(text = "\u53D6\u6D88", onClick = onDismiss, modifier = Modifier.weight(1f)) + GlassButton(text = stringResource(Res.string.server_cancel), onClick = onDismiss, modifier = Modifier.weight(1f)) GlassButton( - text = if (isSaving) "\u4FDD\u5B58\u4E2D..." else "\u4FDD\u5B58", + text = if (isSaving) stringResource(Res.string.server_saving) else stringResource(Res.string.server_save), onClick = { submit() }, modifier = Modifier.weight(1f), accent = true, enabled = !isSaving, ) } else { - PlainButton(text = "\u53D6\u6D88", onClick = onDismiss, modifier = Modifier.weight(1f)) + PlainButton(text = stringResource(Res.string.server_cancel), onClick = onDismiss, modifier = Modifier.weight(1f)) PlainButton( - text = if (isSaving) "\u4FDD\u5B58\u4E2D..." else "\u4FDD\u5B58", + text = if (isSaving) stringResource(Res.string.server_saving) else stringResource(Res.string.server_save), onClick = { submit() }, modifier = Modifier.weight(1f), accent = true, @@ -1727,6 +1694,7 @@ private fun ServerFormInput( visualTransformation = visualTransformation, trailingContent = trailingContent, isError = isError, + errorMessage = errorMessage, focusRequester = focusRequester, ) } @@ -1742,15 +1710,16 @@ private fun PlainInputField( visualTransformation: VisualTransformation = VisualTransformation.None, trailingContent: (@Composable () -> Unit)? = null, isError: Boolean = false, + errorMessage: String? = null, focusRequester: FocusRequester? = null, ) { - val shape = RoundedCornerShape(12.dp) - val fieldBackground = if (GlassTokens.isDark) Color(0xFF24262B) else Color.White + val shape = RoundedCornerShape(LumaShapes.small) + val fieldBackground = LumaColors.backgroundElevated Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(label, fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) Box( Modifier.fillMaxWidth().clip(shape).background(fieldBackground) - .border(1.dp, if (isError) GlassTokens.danger else if (GlassTokens.isDark) Color(0xFF44474F) else Color(0xFFD1D5DB), shape) + .border(0.8.dp, if (isError) GlassTokens.danger else LumaColors.glassBorder, shape) .padding(horizontal = 14.dp, vertical = 12.dp), ) { BasicTextField( @@ -1774,6 +1743,9 @@ private fun PlainInputField( }, ) } + if (isError && !errorMessage.isNullOrBlank()) { + Text(errorMessage, color = offlineRed, fontSize = 11.sp) + } } } @@ -1785,12 +1757,12 @@ private fun PlainButton( enabled: Boolean = true, accent: Boolean = false, ) { - val shape = RoundedCornerShape(12.dp) - val background = if (accent) GlassTokens.accent else if (GlassTokens.isDark) Color(0xFF25272C) else Color.White + val shape = RoundedCornerShape(LumaShapes.small) + val background = if (accent) GlassTokens.accent else LumaColors.floatingTint Box( modifier.height(48.dp).clip(shape).background(background) - .border(1.dp, if (accent) GlassTokens.accent else if (GlassTokens.isDark) Color(0xFF44474F) else Color(0xFFD1D5DB), shape) - .clickable(enabled = enabled, onClick = onClick), + .border(0.8.dp, if (accent) GlassTokens.accent else LumaColors.glassBorder, shape) + .clickable(enabled = enabled, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onClick), contentAlignment = Alignment.Center, ) { Text(text, color = if (accent) Color.White else GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) @@ -1803,10 +1775,10 @@ private fun ProtocolSwitch( onCheckedChange: (Boolean) -> Unit, ) { val shape = RoundedCornerShape(15.dp) - val trackColor = if (checked) GlassTokens.accent else if (GlassTokens.isDark) Color(0xFF48484A) else Color(0xFFD1D1D6) + val trackColor = if (checked) GlassTokens.accent else LumaColors.backgroundElevated Box( Modifier.width(50.dp).height(30.dp).clip(shape).background(trackColor) - .border(1.dp, if (checked) GlassTokens.accent else GlassTokens.glassBorder, shape) + .border(0.8.dp, if (checked) GlassTokens.accent else GlassTokens.glassBorder, shape) .toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange) .padding(3.dp), ) { diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt index bbe0b88..512ea13 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -21,6 +21,7 @@ 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.rememberScrollState @@ -32,7 +33,6 @@ 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 @@ -58,18 +58,32 @@ import com.example.myapplication.ui.PosterImmersivePalette import com.example.myapplication.ui.rememberPosterImmersiveBackground import com.example.myapplication.ui.LiquidGlassButton import com.example.myapplication.ui.GlassTokens +import com.example.myapplication.ui.GlassDialog +import com.example.myapplication.ui.LocalGlassBackdrop +import com.example.myapplication.ui.design.LumaIcon +import com.example.myapplication.ui.design.LumaIconView +import com.example.myapplication.ui.design.LumaLoading +import com.example.myapplication.ui.design.LumaAsyncImage +import com.example.myapplication.ui.design.LumaGlassLevel +import com.example.myapplication.ui.design.LumaGlassRuntime +import com.example.myapplication.ui.design.rememberLumaLayerBackdrop +import com.example.myapplication.ui.design.lumaGlass +import com.example.myapplication.ui.design.LumaSpacing +import com.kyant.backdrop.backdrops.layerBackdrop 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.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 com.example.myapplication.R as Res +import com.kyant.backdrop.Backdrop import top.yukonga.miuix.kmp.basic.Text import java.util.Locale @@ -87,6 +101,8 @@ fun MediaDetailScreen( onPlay: (itemId: String, seriesId: String?, seasonId: String?) -> Unit = { _, _, _ -> }, ) { val context = LocalContext.current + val detailLoadFailed = stringResource(Res.string.detail_load_failed) + val episodeNotReadyMessage = stringResource(Res.string.detail_episode_not_ready) var detail by remember(item.id) { mutableStateOf(item) } var loadError by remember(item.id) { mutableStateOf(null) } var refreshing by remember(item.id) { mutableStateOf(false) } @@ -108,7 +124,7 @@ fun MediaDetailScreen( refreshing = true val fullItem = EmbyRepository.getItem(server, item.id) if (fullItem == null) { - loadError = "详情加载失败" + loadError = detailLoadFailed } else { detail = fullItem loadError = null @@ -143,6 +159,7 @@ fun MediaDetailScreen( 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) + val detailBackdrop = rememberLumaLayerBackdrop() CompositionLocalProvider( LocalDetailText provides detailTextColor, @@ -151,17 +168,23 @@ fun MediaDetailScreen( 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, + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to immersive.pageTop, + 0.53f to immersive.hero, + 1f to immersive.pageBottom, + ) ) - ), - ) { + .then( + if (detailBackdrop != null) Modifier.layerBackdrop(detailBackdrop) else Modifier, + ), + ) + CompositionLocalProvider(LocalGlassBackdrop provides detailBackdrop) { + Box(Modifier.fillMaxSize()) { Column( modifier = Modifier .fillMaxSize() @@ -189,7 +212,7 @@ fun MediaDetailScreen( } DetailPlayButton(resumePositionMs = playTarget?.playbackPositionMs ?: detail.playbackPositionMs) { if (playTarget == null) { - Toast.makeText(context, "剧集尚未加载完成", Toast.LENGTH_SHORT).show() + Toast.makeText(context, episodeNotReadyMessage, Toast.LENGTH_SHORT).show() } else { onPlay( playTarget.id, @@ -202,7 +225,7 @@ fun MediaDetailScreen( if (detail.overview.isNullOrBlank().not()) { Spacer(Modifier.height(20.dp)) - DetailSectionCard(title = "简介") { + DetailSectionCard(title = stringResource(Res.string.detail_overview)) { Text( text = detail.overview.orEmpty(), color = LocalDetailText.current.copy(alpha = 0.86f), @@ -232,7 +255,7 @@ fun MediaDetailScreen( if (seasons.isNotEmpty()) { Spacer(Modifier.height(20.dp)) - Text(text = "剧集", color = LocalDetailText.current, fontSize = 17.sp, fontWeight = FontWeight.Bold) + Text(text = stringResource(Res.string.detail_episodes), color = LocalDetailText.current, fontSize = 17.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(10.dp)) Row( verticalAlignment = Alignment.CenterVertically, @@ -268,11 +291,13 @@ fun MediaDetailScreen( } Text( - text = "查看全部", + text = stringResource(Res.string.detail_view_all), color = LocalDetailAccent.current, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, - modifier = Modifier.clickable(enabled = episodes.isNotEmpty()) { showAllEpisodes = true }, + modifier = Modifier + .heightIn(min = 48.dp) + .clickable(enabled = episodes.isNotEmpty()) { showAllEpisodes = true }, ) } @@ -286,7 +311,10 @@ fun MediaDetailScreen( .background(LocalDetailSurface.current), contentAlignment = Alignment.Center, ) { - Text(text = "正在加载剧集...", color = LocalDetailSecondary.current.copy(alpha = 0.82f), fontSize = 13.sp) + LumaLoading( + label = stringResource(Res.string.detail_loading_episodes), + modifier = Modifier.padding(vertical = LumaSpacing.xs), + ) } } else { Spacer(Modifier.height(12.dp)) @@ -313,25 +341,9 @@ fun MediaDetailScreen( } } - 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) + Text(text = stringResource(Res.string.detail_cast), 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 -> @@ -348,27 +360,50 @@ fun MediaDetailScreen( Spacer(Modifier.height(38.dp)) } } - // 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) + .size(48.dp) + .lumaGlass( + backdrop = LocalGlassBackdrop.current, + radius = 24.dp, + level = LumaGlassLevel.Floating, + tint = Color.Black.copy(alpha = 0.30f), + borderColor = Color.White.copy(alpha = 0.16f), + shadowElevation = 4.dp, + ) .clickable(onClick = onClose), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回", + contentDescription = stringResource(Res.string.back), tint = Color.White, modifier = Modifier.size(19.dp), ) } + } + + if (showAllEpisodes) { + EpisodeListDialog( + title = seasons.firstOrNull { it.id == selectedSeasonId }?.name ?: stringResource(Res.string.detail_all_episodes), + episodes = episodes, + selectedEpisodeId = selectedEpisodeId, + serverUrl = server?.fullUrl.orEmpty(), + fallbackImageUrl = seriesFallbackImageUrl, + background = immersive, + backdrop = detailBackdrop, + onDismiss = { showAllEpisodes = false }, + onSelect = { episode -> + selectedEpisodeId = episode.id + showAllEpisodes = false + }, + ) + } + } } } } @@ -394,7 +429,7 @@ private fun DetailHero( // Keep the detail cover at the same height as the home hero. Box(Modifier.fillMaxWidth().aspectRatio(0.84f)) { if (backdropUrl != null || posterUrl != null) { - AsyncImage( + LumaAsyncImage( model = backdropUrl ?: posterUrl, contentDescription = item.name, contentScale = ContentScale.Crop, @@ -487,10 +522,10 @@ private fun DetailHeadline(detail: EmbyItem, refreshing: Boolean, errorText: Str Spacer(Modifier.height(9.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - if (detail.communityRating != null && detail.communityRating!! > 0) { + detail.communityRating?.takeIf { it > 0.0 }?.let { rating -> 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) + Text(text = String.format("%.1f", rating), color = LocalDetailText.current, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) Spacer(Modifier.width(9.dp)) } if (detail.productionYear != null) { @@ -505,9 +540,9 @@ private fun DetailHeadline(detail: EmbyItem, refreshing: Boolean, errorText: Str Spacer(Modifier.height(6.dp)) Text( text = when { - detail.type == "Series" -> "剧集" - detail.type == "Movie" -> "电影" - else -> detail.type ?: "媒体" + detail.type == "Series" -> stringResource(Res.string.detail_type_series) + detail.type == "Movie" -> stringResource(Res.string.detail_type_movie) + else -> detail.type ?: stringResource(Res.string.detail_type_media) }, color = Color(0xFFBFDBFE), fontSize = 11.sp, @@ -516,7 +551,7 @@ private fun DetailHeadline(detail: EmbyItem, refreshing: Boolean, errorText: Str if (refreshing) { Spacer(Modifier.height(7.dp)) - Text(text = "同步详情中...", color = LocalDetailSecondary.current.copy(alpha = 0.75f), fontSize = 11.sp) + Text(text = stringResource(Res.string.detail_loading), color = LocalDetailSecondary.current.copy(alpha = 0.75f), fontSize = 11.sp) } errorText?.let { Spacer(Modifier.height(7.dp)) @@ -528,19 +563,23 @@ private fun DetailHeadline(detail: EmbyItem, refreshing: Boolean, errorText: Str private fun DetailPlayButton(resumePositionMs: Long, onClick: () -> Unit) { LiquidGlassButton( onClick = onClick, - modifier = Modifier.width(188.dp).height(47.dp), + modifier = Modifier.width(188.dp).height(48.dp), cornerRadius = 999.dp, fill = GlassTokens.glassFillStrong, ) { - Icon( - imageVector = Icons.Filled.PlayArrow, + LumaIconView( + icon = LumaIcon.Play, contentDescription = null, tint = GlassTokens.accent, - modifier = Modifier.size(21.dp) + modifier = Modifier.size(21.dp), ) Spacer(Modifier.width(7.dp)) Text( - text = if (resumePositionMs > 0L) "继续 ${formatPlaybackTime(resumePositionMs)}" else "立即播放", + text = if (resumePositionMs > 0L) { + stringResource(Res.string.detail_continue_format, formatPlaybackTime(resumePositionMs)) + } else { + stringResource(Res.string.detail_play_now) + }, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold @@ -561,9 +600,14 @@ 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)) + .lumaGlass( + backdrop = LocalGlassBackdrop.current, + radius = 18.dp, + level = LumaGlassLevel.Clear, + tint = LocalDetailSurface.current, + borderColor = LocalDetailBorder.current, + shadowElevation = 2.dp, + ) .padding(16.dp), ) { Text(text = title, color = LocalDetailText.current, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) @@ -580,6 +624,7 @@ private fun EpisodeListDialog( serverUrl: String, fallbackImageUrl: String?, background: PosterImmersivePalette, + backdrop: Backdrop?, onDismiss: () -> Unit, onSelect: (EmbyItem) -> Unit, ) { @@ -589,20 +634,19 @@ private fun EpisodeListDialog( Color.Black.copy(alpha = 0.10f) } - Dialog(onDismissRequest = onDismiss) { + GlassDialog(backdrop = backdrop, onDismissRequest = onDismiss, modifier = Modifier.widthIn(max = 360.dp)) { Column( Modifier .fillMaxWidth() - .clip(RoundedCornerShape(24.dp)) - .background( - Brush.verticalGradient( - 0f to background.pageTop, - 0.53f to background.hero, - 1f to background.pageBottom, - ) + .lumaGlass( + backdrop = null, + radius = 18.dp, + level = LumaGlassLevel.Strong, + tint = background.hero.copy(alpha = if (background.isDark) 0.20f else 0.12f), + borderColor = dialogBorder, + shadowElevation = 8.dp, ) - .border(1.dp, dialogBorder, RoundedCornerShape(24.dp)) - .padding(16.dp), + .padding(4.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -613,11 +657,13 @@ private fun EpisodeListDialog( modifier = Modifier.weight(1f), ) Text( - text = "关闭", + text = stringResource(Res.string.detail_close), color = LocalDetailSecondary.current, fontSize = 12.sp, fontWeight = FontWeight.Medium, - modifier = Modifier.clickable(onClick = onDismiss), + modifier = Modifier + .heightIn(min = 48.dp) + .clickable(onClick = onDismiss), ) } Spacer(Modifier.height(12.dp)) @@ -653,10 +699,10 @@ private fun CompactEpisodeRow( 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() && !nameAlreadyNumbered && item.indexNumber != null -> stringResource(Res.string.detail_episode_format, item.indexNumber, rawName) rawName.isNotBlank() -> rawName - item.indexNumber != null -> "第 ${item.indexNumber} 集" - else -> "剧集" + item.indexNumber != null -> stringResource(Res.string.detail_episode_number, item.indexNumber) + else -> stringResource(Res.string.detail_episode_short) } val rowBackground = if (selected) { LocalDetailAccent.current.copy(alpha = 0.13f) @@ -672,9 +718,14 @@ private fun CompactEpisodeRow( Row( Modifier .fillMaxWidth() - .clip(RoundedCornerShape(15.dp)) - .background(rowBackground) - .border(1.dp, rowBorder, RoundedCornerShape(15.dp)) + .lumaGlass( + backdrop = LocalGlassBackdrop.current, + radius = 15.dp, + level = LumaGlassLevel.Interactive, + tint = rowBackground, + borderColor = rowBorder, + shadowElevation = 0.dp, + ) .clickable(onClick = onClick) .padding(7.dp), verticalAlignment = Alignment.CenterVertically, @@ -686,7 +737,7 @@ private fun CompactEpisodeRow( .background(Color(0xFF152036)), ) { if (imageUrl != null) { - AsyncImage( + LumaAsyncImage( model = imageUrl, contentDescription = item.name, contentScale = ContentScale.Crop, @@ -695,7 +746,7 @@ private fun CompactEpisodeRow( } else { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( - text = item.indexNumber?.toString() ?: "TV", + text = item.indexNumber?.toString() ?: stringResource(Res.string.detail_tv_short), color = Color.White.copy(alpha = 0.70f), fontSize = 15.sp, fontWeight = FontWeight.Bold, @@ -711,29 +762,16 @@ private fun CompactEpisodeRow( ) { Text( text = title, - color = if (selected) Color.Unspecified else LocalDetailText.current, + color = if (selected) LocalDetailAccent.current 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 - }, + style = LocalTextStyle.current, ) Spacer(Modifier.height(3.dp)) Text( - text = item.overview?.takeIf { it.isNotBlank() } ?: "暂无简介", + text = item.overview?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.detail_no_overview), color = LocalDetailSecondary.current.copy(alpha = if (selected) 0.90f else 0.76f), fontSize = 10.sp, lineHeight = 13.sp, @@ -755,26 +793,16 @@ private fun EpisodeCard( 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 selectedBorderBrush = SolidColor(LocalDetailAccent.current.copy(alpha = 0.84f)) 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() && !nameAlreadyNumbered && item.indexNumber != null -> stringResource(Res.string.detail_episode_format, item.indexNumber, rawName) rawName.isNotBlank() -> rawName - item.indexNumber != null -> "第 ${item.indexNumber} 集" - else -> "剧集" + item.indexNumber != null -> stringResource(Res.string.detail_episode_number, item.indexNumber) + else -> stringResource(Res.string.detail_episode_short) } val interactionSource = remember { MutableInteractionSource() } @@ -802,7 +830,7 @@ private fun EpisodeCard( val radius = (21.dp - strokeWidth / 2f).toPx() drawRoundRect( brush = if (selected) { - rainbowBrush + selectedBorderBrush } else { normalBorderBrush }, @@ -814,7 +842,7 @@ private fun EpisodeCard( }, ) { if (imageUrl != null) { - AsyncImage( + LumaAsyncImage( model = imageUrl, contentDescription = item.name, contentScale = ContentScale.Crop, @@ -828,7 +856,7 @@ private fun EpisodeCard( contentAlignment = Alignment.Center, ) { Text( - text = item.indexNumber?.toString() ?: "TV", + text = item.indexNumber?.toString() ?: stringResource(Res.string.detail_tv_short), color = Color.White.copy(alpha = 0.72f), fontSize = 28.sp, fontWeight = FontWeight.Bold, @@ -841,22 +869,16 @@ private fun EpisodeCard( Spacer(Modifier.height(9.dp)) Text( text = title, - color = if (selected) Color.Unspecified else LocalDetailText.current, + color = if (selected) LocalDetailAccent.current 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 - }, + style = LocalTextStyle.current, ) Spacer(Modifier.height(4.dp)) Text( - text = item.overview?.takeIf { it.isNotBlank() } ?: "暂无简介", + text = item.overview?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.detail_no_overview), color = LocalDetailSecondary.current.copy(alpha = if (selected) 0.94f else 0.82f), fontSize = 11.sp, lineHeight = 16.sp, @@ -878,7 +900,7 @@ private fun PersonCard(person: com.example.myapplication.data.EmbyPerson, server contentAlignment = Alignment.Center, ) { if (imageUrl != null) { - AsyncImage(model = imageUrl, contentDescription = person.name, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + LumaAsyncImage(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) } @@ -897,19 +919,19 @@ private fun DetailInfoCard(item: EmbyItem) { val videoStream = item.mediaStreams.firstOrNull { it.type == "Video" } val audioStreams = item.mediaStreams.filter { it.type == "Audio" } - DetailSectionCard(title = "详细信息") { - InfoRow(label = "类型", value = item.type ?: "未知") + DetailSectionCard(title = stringResource(Res.string.detail_information)) { + InfoRow(label = stringResource(Res.string.detail_type), value = item.type ?: stringResource(Res.string.detail_unknown)) item.originalTitle?.takeIf { it.isNotBlank() && it != item.name }?.let { - InfoRow(label = "原名", value = it) + InfoRow(label = stringResource(Res.string.detail_original_title), value = it) } - studios.takeIf { it.isNotBlank() }?.let { InfoRow(label = "制作方", value = it) } + studios.takeIf { it.isNotBlank() }?.let { InfoRow(label = stringResource(Res.string.detail_studio), 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 (value.isNotBlank()) InfoRow(label = stringResource(Res.string.detail_quality), value = value) } if (audioStreams.isNotEmpty()) { - InfoRow(label = "音轨", value = audioStreams.count().toString() + " 条") + InfoRow(label = stringResource(Res.string.detail_audio_track), value = stringResource(Res.string.detail_audio_track_count, audioStreams.count())) } } } @@ -928,9 +950,20 @@ private fun InfoRow(label: String, value: String) { } } +@Composable 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}分"}" + return if (minutes < 60) { + stringResource(Res.string.detail_runtime_minutes, minutes.toInt()) + } else { + val hours = (minutes / 60).toInt() + val remainingMinutes = (minutes % 60).toInt() + if (remainingMinutes == 0) { + stringResource(Res.string.detail_runtime_hours, hours) + } else { + stringResource(Res.string.detail_runtime_hours_minutes, hours, remainingMinutes) + } + } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt index 698839f..13f0e7e 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -28,11 +28,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.CalendarMonth -import androidx.compose.material.icons.outlined.FavoriteBorder -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -52,7 +47,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyItem @@ -62,6 +56,13 @@ import com.example.myapplication.ui.GlassTokens import com.example.myapplication.ui.LiquidGlassTopBar import com.example.myapplication.ui.SegmentedControl import com.example.myapplication.ui.bottomBarContentPadding +import com.example.myapplication.ui.design.LumaColors +import com.example.myapplication.ui.design.LumaEmptyState +import com.example.myapplication.ui.design.LumaErrorState +import com.example.myapplication.ui.design.LumaIcon +import com.example.myapplication.ui.design.LumaAsyncImage +import com.example.myapplication.ui.design.LumaLoading +import com.example.myapplication.ui.design.LumaShapes import top.yukonga.miuix.kmp.basic.Text @Composable @@ -96,12 +97,22 @@ fun RecordsScreen( ) { var favorites by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(false) } + var loadError by remember { mutableStateOf(false) } + var reloadToken by remember { mutableStateOf(0) } - LaunchedEffect(activeServer?.id) { + LaunchedEffect(activeServer?.id, reloadToken) { if (activeServer != null && activeServer.apiKey.isNotBlank()) { loading = true - favorites = EmbyRepository.getFavoriteItems(activeServer) + try { + favorites = EmbyRepository.getFavoriteItems(activeServer) + loadError = false + } catch (_: Exception) { + favorites = emptyList() + loadError = true + } loading = false + } else { + loadError = false } } @@ -114,23 +125,32 @@ fun RecordsScreen( ) { selectedSegment -> if (selectedSegment == 1) { RecordsEmptyState( - icon = Icons.Outlined.CalendarMonth, + icon = LumaIcon.Calendar, title = stringResource(Res.string.empty_calendar), subtitle = stringResource(Res.string.empty_calendar_sub), ) } else if (loading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = GlassTokens.accent) + LumaLoading(label = stringResource(Res.string.loading_favorites)) + } + } else if (loadError) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaErrorState( + title = stringResource(Res.string.error_content_title), + description = stringResource(Res.string.error_content_sub), + retryLabel = stringResource(Res.string.retry), + onRetry = { reloadToken++ }, + ) } } else if (favorites.isEmpty()) { RecordsEmptyState( - icon = Icons.Outlined.FavoriteBorder, + icon = LumaIcon.Favorite, title = stringResource(Res.string.empty_favorites), subtitle = stringResource(Res.string.empty_favorites_sub), ) } else { LazyVerticalGrid( - columns = GridCells.Fixed(2), + columns = GridCells.Adaptive(minSize = 150.dp), modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), contentPadding = PaddingValues(top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -152,7 +172,7 @@ fun RecordsScreen( @Composable private fun RecordsEmptyState( - icon: ImageVector, + icon: LumaIcon, title: String, subtitle: String, ) { @@ -162,23 +182,7 @@ private fun RecordsEmptyState( .padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()), contentAlignment = Alignment.Center, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - Modifier - .size(68.dp) - .clip(CircleShape) - .background(GlassTokens.glassFill) - .border(1.dp, GlassTokens.glassBorder, CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon(icon, contentDescription = null, tint = GlassTokens.subtext2, modifier = Modifier.size(30.dp)) - } - Text(title, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) - Text(subtitle, color = GlassTokens.subtext, fontSize = 12.sp) - } + LumaEmptyState(icon = icon, title = title, description = subtitle) } } @@ -193,11 +197,11 @@ private fun FavoriteMediaCard(item: EmbyItem, serverUrl: String, onClick: () -> Modifier .fillMaxWidth() .aspectRatio(0.7f) - .clip(RoundedCornerShape(12.dp)) - .background(Color.DarkGray) + .clip(RoundedCornerShape(LumaShapes.small)) + .background(LumaColors.backgroundElevated) ) { if (imageUrl != null) { - AsyncImage( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(), contentDescription = item.name, contentScale = ContentScale.Crop, diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt index ca13eb1..d9f27df 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt @@ -19,10 +19,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -41,7 +37,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyItem @@ -51,6 +46,11 @@ import com.example.myapplication.ui.GlassSearchField import com.example.myapplication.ui.GlassTokens import com.example.myapplication.ui.LiquidGlassTopBar import com.example.myapplication.ui.bottomBarContentPadding +import com.example.myapplication.ui.design.LumaEmptyState +import com.example.myapplication.ui.design.LumaErrorState +import com.example.myapplication.ui.design.LumaIcon +import com.example.myapplication.ui.design.LumaAsyncImage +import com.example.myapplication.ui.design.LumaLoading import kotlinx.coroutines.delay import top.yukonga.miuix.kmp.basic.Text @@ -85,18 +85,27 @@ fun SearchScreen( ) { var results by remember(activeServer?.id) { mutableStateOf>(emptyList()) } var loading by remember(activeServer?.id) { mutableStateOf(false) } + var loadError by remember(activeServer?.id) { mutableStateOf(false) } + var reloadToken by remember { mutableStateOf(0) } - LaunchedEffect(query, activeServer?.id, activeServer?.apiKey) { + LaunchedEffect(query, activeServer?.id, activeServer?.apiKey, reloadToken) { val server = activeServer val term = query.trim() if (server == null || server.apiKey.isBlank() || term.isBlank()) { results = emptyList() loading = false + loadError = false return@LaunchedEffect } delay(280) loading = true - results = EmbyRepository.searchItems(server, term) + try { + results = EmbyRepository.searchItems(server, term) + loadError = false + } catch (_: Exception) { + results = emptyList() + loadError = true + } loading = false } Box(Modifier.fillMaxSize()) { @@ -108,25 +117,33 @@ fun SearchScreen( ) { when { activeServer == null -> SearchEmptyState( - title = "暂无可搜索的媒体库", - subtitle = "请先在资源库中连接 Emby 服务器", + title = stringResource(Res.string.search_no_server), + subtitle = stringResource(Res.string.search_no_server_sub), ) query.isBlank() -> SearchEmptyState( title = stringResource(Res.string.search_title), subtitle = stringResource(Res.string.search_empty_input), ) loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = GlassTokens.accent) + LumaLoading(label = stringResource(Res.string.loading_search)) + } + loadError -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaErrorState( + title = stringResource(Res.string.error_content_title), + description = stringResource(Res.string.error_content_sub), + retryLabel = stringResource(Res.string.retry), + onRetry = { reloadToken++ }, + ) } results.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = stringResource(Res.string.search_no_result_prefix) + query.trim() + stringResource(Res.string.search_no_result_suffix), - color = GlassTokens.subtext, - fontSize = 14.sp, + LumaEmptyState( + icon = LumaIcon.Search, + title = stringResource(Res.string.search_no_result_title), + description = stringResource(Res.string.search_no_result_prefix) + query.trim() + stringResource(Res.string.search_no_result_suffix), ) } else -> LazyVerticalGrid( - columns = GridCells.Fixed(2), + columns = GridCells.Adaptive(minSize = 150.dp), modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( top = 16.dp, @@ -156,22 +173,7 @@ private fun SearchEmptyState(title: String, subtitle: String) { .padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()), contentAlignment = Alignment.Center, ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Box( - Modifier - .size(68.dp) - .clip(CircleShape) - .background(GlassTokens.glassFill) - .border(1.dp, GlassTokens.glassBorder, CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.Search, contentDescription = null, tint = GlassTokens.subtext2, modifier = Modifier.size(32.dp)) - } - Spacer(Modifier.height(16.dp)) - Text(title, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(6.dp)) - Text(subtitle, color = GlassTokens.subtext, fontSize = 12.sp) - } + LumaEmptyState(icon = LumaIcon.Search, title = title, description = subtitle) } } @@ -193,7 +195,7 @@ private fun SearchMediaCard(item: EmbyItem, serverUrl: String, onClick: () -> Un .background(Brush.verticalGradient(fallback)), ) { if (imageUrl != null) { - AsyncImage( + LumaAsyncImage( model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(), contentDescription = item.name, contentScale = ContentScale.Crop, diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt index 46c4413..e3dca02 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt @@ -3,9 +3,11 @@ package com.example.myapplication.screens import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.Image 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.Spacer @@ -24,21 +26,39 @@ import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue 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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.myapplication.R +import com.example.myapplication.BuildConfig import com.example.myapplication.data.IconLibraryRepository +import com.example.myapplication.ui.GlassConfirmDialog import com.example.myapplication.ui.GlassTokens +import com.example.myapplication.ui.LocalGlassBackdrop +import com.example.myapplication.ui.design.LumaColors +import com.example.myapplication.ui.design.LumaGlassRuntime +import com.example.myapplication.ui.design.rememberLumaLayerBackdrop +import com.example.myapplication.ui.design.LumaIcon +import com.example.myapplication.ui.design.LumaIconView +import com.example.myapplication.ui.design.LumaGlassLevel +import com.example.myapplication.ui.design.lumaGlass +import com.example.myapplication.ui.design.LumaShapes +import com.example.myapplication.ui.design.LumaSpacing +import com.example.myapplication.ui.design.LumaTypography import top.yukonga.miuix.kmp.basic.Text +import com.kyant.backdrop.backdrops.layerBackdrop @Composable private fun PageTopBar(title: String, onBack: () -> Unit) { @@ -48,14 +68,12 @@ private fun PageTopBar(title: String, onBack: () -> Unit) { ) { Box( Modifier - .size(36.dp) - .clip(CircleShape) - .background(GlassTokens.glassFill) - .border(0.5.dp, GlassTokens.glassBorder, CircleShape) - .clickable(onClick = onBack), + .size(48.dp) + .lumaGlass(radius = 24.dp, level = LumaGlassLevel.Floating) + .clickable(indication = null, interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, onClick = onBack), contentAlignment = Alignment.Center, ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回", tint = GlassTokens.text, modifier = Modifier.size(20.dp)) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back), tint = LumaColors.text, modifier = Modifier.size(20.dp)) } Spacer(Modifier.size(12.dp)) Text(title, color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold) @@ -65,39 +83,236 @@ private fun PageTopBar(title: String, onBack: () -> Unit) { @Composable fun CacheManagementScreen(onBack: () -> Unit) { var cacheSize by remember { mutableLongStateOf(IconLibraryRepository.getIconCacheSize()) } - - Column(Modifier.fillMaxSize()) { - PageTopBar(title = "缓存管理", onBack = onBack) - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - CacheInfoCard( - title = "图标数据缓存", - value = formatCacheSize(cacheSize), - icon = Icons.Outlined.Info, - ) - } - item { - Box( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) - .background(GlassTokens.glassFill) - .border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(18.dp)) - .clickable { - IconLibraryRepository.clearAllIconCache() - cacheSize = 0L - } - .padding(horizontal = 16.dp, vertical = 15.dp), - contentAlignment = Alignment.CenterStart, + var showClearCacheConfirm by remember { mutableStateOf(false) } + val cacheBackdrop = rememberLumaLayerBackdrop() + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .background(LumaColors.background) + .then( + if (cacheBackdrop != null) Modifier.layerBackdrop(cacheBackdrop) else Modifier, + ), + ) + CompositionLocalProvider(LocalGlassBackdrop provides cacheBackdrop) { + Column(Modifier.fillMaxSize()) { + PageTopBar(title = stringResource(R.string.cache_detail_title), onBack = onBack) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Outlined.Delete, contentDescription = null, tint = Color(0xFFE5484D), modifier = Modifier.size(21.dp)) - Spacer(Modifier.size(12.dp)) - Text("删除缓存", color = Color(0xFFE5484D), fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + item { + CacheInfoCard( + title = stringResource(R.string.icon_cache), + value = formatCacheSize(cacheSize), + icon = LumaIcon.Info, + ) + } + item { + Box( + Modifier + .fillMaxWidth() + .lumaGlass(radius = LumaShapes.medium, level = LumaGlassLevel.Regular) + .clickable { showClearCacheConfirm = true } + .padding(horizontal = 16.dp, vertical = 15.dp), + contentAlignment = Alignment.CenterStart, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + LumaIconView(LumaIcon.Delete, contentDescription = null, tint = LumaColors.danger, modifier = Modifier.size(21.dp)) + Spacer(Modifier.size(12.dp)) + Text(stringResource(R.string.delete_cache), color = LumaColors.danger, fontSize = LumaTypography.body, fontWeight = FontWeight.SemiBold) + } + } + } + } + } + } + if (showClearCacheConfirm) { + GlassConfirmDialog( + title = stringResource(R.string.confirm_delete_title), + message = stringResource(R.string.confirm_clear_cache_message), + confirmText = stringResource(R.string.confirm_clear), + dismissText = stringResource(R.string.confirm_cancel), + backdrop = cacheBackdrop, + onDismissRequest = { showClearCacheConfirm = false }, + onConfirm = { + IconLibraryRepository.clearAllIconCache() + cacheSize = 0L + showClearCacheConfirm = false + }, + ) + } + } +} + +@Composable +private fun CacheInfoCard(title: String, value: String, icon: LumaIcon) { + Row( + Modifier + .fillMaxWidth() + .lumaGlass(radius = LumaShapes.medium, level = LumaGlassLevel.Regular) + .padding(horizontal = 16.dp, vertical = 15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.size(40.dp).clip(RoundedCornerShape(LumaShapes.small)).background(LumaColors.accentSoft), + contentAlignment = Alignment.Center, + ) { + LumaIconView(icon, contentDescription = null, tint = LumaColors.accent, modifier = Modifier.size(21.dp)) + } + Spacer(Modifier.size(12.dp)) + Column(Modifier.weight(1f)) { + Text(title, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(3.dp)) + Text(stringResource(R.string.cache_total_size), color = GlassTokens.subtext, fontSize = LumaTypography.caption) + } + Text(value, color = GlassTokens.subtext, fontSize = 13.sp) + } +} + +@Composable +fun AboutScreen(onBack: () -> Unit) { + val aboutBackdrop = rememberLumaLayerBackdrop() + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .background(LumaColors.background) + .then( + if (aboutBackdrop != null) Modifier.layerBackdrop(aboutBackdrop) else Modifier, + ), + ) + CompositionLocalProvider(LocalGlassBackdrop provides aboutBackdrop) { + Column(Modifier.fillMaxSize()) { + PageTopBar(title = stringResource(R.string.about), onBack = onBack) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = LumaSpacing.md, vertical = LumaSpacing.sm), + verticalArrangement = Arrangement.spacedBy(LumaSpacing.md), + ) { + item { + Column( + Modifier.fillMaxWidth().padding(vertical = LumaSpacing.sm), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier + .size(88.dp) + .clip(RoundedCornerShape(LumaShapes.large)) + .background(LumaColors.accentSoft) + .border(0.7.dp, LumaColors.glassBorder, RoundedCornerShape(LumaShapes.large)), + contentAlignment = Alignment.Center, + ) { + Image( + // Adaptive mipmap XML cannot be loaded by Compose painterResource on API 35. + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = stringResource(R.string.about_app_name), + modifier = Modifier.size(56.dp), + ) + } + Spacer(Modifier.height(LumaSpacing.md)) + Text(stringResource(R.string.about_app_name), color = GlassTokens.text, fontSize = 24.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(LumaSpacing.xs)) + Text( + stringResource(R.string.about_version_format, BuildConfig.VERSION_NAME), + color = GlassTokens.subtext, + fontSize = LumaTypography.secondary, + ) + Spacer(Modifier.height(LumaSpacing.md)) + Text( + stringResource(R.string.app_desc), + color = GlassTokens.subtext, + fontSize = LumaTypography.body, + ) + } + } + item { + AboutSectionCard(title = stringResource(R.string.about_technology_title)) { + AboutInfoRow( + LumaIcon.Media, + stringResource(R.string.about_technology_compose), + stringResource(R.string.about_technology_compose_sub), + ) + AboutInfoRow( + LumaIcon.Play, + stringResource(R.string.about_technology_media3), + stringResource(R.string.about_technology_media3_sub), + ) + AboutInfoRow( + LumaIcon.Folder, + stringResource(R.string.about_technology_navigation), + stringResource(R.string.about_technology_navigation_sub), + ) + AboutInfoRow( + LumaIcon.Library, + stringResource(R.string.about_technology_backdrop), + stringResource(R.string.about_technology_backdrop_sub), + ) + } + } + item { + AboutSectionCard(title = stringResource(R.string.about_licenses_title)) { + AboutInfoRow( + LumaIcon.Media, + stringResource(R.string.about_license_compose), + stringResource(R.string.about_license_apache), + ) + AboutInfoRow( + LumaIcon.Play, + stringResource(R.string.about_license_media3), + stringResource(R.string.about_license_apache), + ) + AboutInfoRow( + LumaIcon.Folder, + stringResource(R.string.about_license_navigation), + stringResource(R.string.about_license_apache), + ) + AboutInfoRow( + LumaIcon.Image, + stringResource(R.string.about_license_coil), + stringResource(R.string.about_license_apache), + ) + AboutInfoRow( + LumaIcon.Network, + stringResource(R.string.about_license_retrofit), + stringResource(R.string.about_license_apache), + ) + AboutInfoRow( + LumaIcon.Network, + stringResource(R.string.about_license_okhttp), + stringResource(R.string.about_license_apache), + ) + AboutInfoRow( + LumaIcon.Database, + stringResource(R.string.about_license_room), + stringResource(R.string.about_license_apache), + ) + AboutInfoRow( + LumaIcon.Library, + stringResource(R.string.about_license_backdrop), + stringResource(R.string.about_license_apache), + ) + AboutInfoRow( + LumaIcon.Settings, + stringResource(R.string.about_license_miuix), + stringResource(R.string.about_license_apache), + ) + } + } + item { + AboutSectionCard(title = stringResource(R.string.about_credits_title)) { + AboutInfoRow( + LumaIcon.Library, + stringResource(R.string.about_aurora_title), + stringResource(R.string.about_aurora_subtitle), + ) + AboutInfoRow( + LumaIcon.Info, + stringResource(R.string.about_open_source_title), + stringResource(R.string.about_open_source_subtitle), + ) + } } } } @@ -106,57 +321,40 @@ fun CacheManagementScreen(onBack: () -> Unit) { } @Composable -private fun CacheInfoCard(title: String, value: String, icon: androidx.compose.ui.graphics.vector.ImageVector) { - Row( +private fun AboutSectionCard(title: String, content: @Composable ColumnScope.() -> Unit) { + Column( Modifier .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) - .background(GlassTokens.glassFill) - .border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(18.dp)) - .padding(horizontal = 16.dp, vertical = 15.dp), - verticalAlignment = Alignment.CenterVertically, + .lumaGlass(radius = LumaShapes.large, level = LumaGlassLevel.Regular) + .padding(vertical = LumaSpacing.xs), ) { - Box( - Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(GlassTokens.accent.copy(alpha = 0.14f)), - contentAlignment = Alignment.Center, - ) { - Icon(icon, contentDescription = null, tint = GlassTokens.accent, modifier = Modifier.size(21.dp)) - } - Spacer(Modifier.size(12.dp)) - Column(Modifier.weight(1f)) { - Text(title, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(3.dp)) - Text("缓存数据总大小", color = GlassTokens.subtext, fontSize = 12.sp) - } - Text(value, color = GlassTokens.subtext, fontSize = 13.sp) + Text( + title, + color = GlassTokens.subtext, + fontSize = LumaTypography.secondary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = LumaSpacing.md, vertical = LumaSpacing.xs), + ) + content() } } @Composable -fun AboutScreen(onBack: () -> Unit) { - Column(Modifier.fillMaxSize()) { - PageTopBar(title = "关于", onBack = onBack) - Column( - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, +private fun AboutInfoRow(icon: LumaIcon, title: String, subtitle: String) { + Row( + Modifier.fillMaxWidth().padding(horizontal = LumaSpacing.md, vertical = LumaSpacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.size(38.dp).clip(RoundedCornerShape(LumaShapes.small)).background(LumaColors.accentSoft), + contentAlignment = Alignment.Center, ) { - Box( - Modifier.size(72.dp).clip(RoundedCornerShape(22.dp)).background(GlassTokens.accent.copy(alpha = 0.16f)), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.Info, contentDescription = null, tint = GlassTokens.accent, modifier = Modifier.size(36.dp)) - } - Spacer(Modifier.height(14.dp)) - Text("LumaPlayer", color = GlassTokens.text, fontSize = 21.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(6.dp)) - Text("版本 1.0", color = GlassTokens.subtext, fontSize = 13.sp) - Spacer(Modifier.height(22.dp)) - Box( - Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(GlassTokens.glassFill) - .border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(18.dp)).padding(16.dp), - ) { - Text("一款简洁优雅的 Emby 媒体库管理工具", color = GlassTokens.text, fontSize = 14.sp) - } + LumaIconView(icon, contentDescription = null, tint = GlassTokens.accent, modifier = Modifier.size(20.dp)) + } + Spacer(Modifier.size(LumaSpacing.sm)) + Column(Modifier.weight(1f)) { + Text(title, color = GlassTokens.text, fontSize = LumaTypography.body, fontWeight = FontWeight.SemiBold) + Text(subtitle, color = GlassTokens.subtext, fontSize = LumaTypography.caption) } } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt index 255a656..94a5854 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt @@ -1,11 +1,12 @@ package com.example.myapplication.screens import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.CubicBezierEasing +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.collectIsPressedAsState import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,6 +17,7 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size @@ -27,12 +29,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ChevronRight -import androidx.compose.material.icons.outlined.Folder -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Language -import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -47,7 +46,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned @@ -64,16 +62,27 @@ import androidx.compose.ui.zIndex import com.example.myapplication.R import com.example.myapplication.data.EmbyServer import com.example.myapplication.ui.AppLanguage +import com.example.myapplication.ui.AppTheme import com.example.myapplication.ui.GlassTokens import com.example.myapplication.ui.LiquidGlassTopBar +import com.example.myapplication.ui.LiquidGlassPopupOverlay +import com.example.myapplication.ui.LocalGlassBackdrop import com.example.myapplication.ui.bottomBarContentPadding +import com.example.myapplication.ui.design.LumaColors +import com.example.myapplication.ui.design.LumaIcon +import com.example.myapplication.ui.design.LumaIconView +import com.example.myapplication.ui.design.LumaGlassRuntime +import com.example.myapplication.ui.design.rememberLumaLayerBackdrop +import com.example.myapplication.ui.design.LumaMotion +import com.example.myapplication.ui.design.LumaShapes +import com.example.myapplication.ui.design.LumaSpacing +import com.example.myapplication.ui.design.LumaTypography +import com.example.myapplication.ui.design.lumaGlass import com.kyant.backdrop.Backdrop import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.colorControls -import com.kyant.backdrop.effects.lens import com.kyant.backdrop.effects.vibrancy import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.shadow.Shadow @@ -87,7 +96,7 @@ import top.yukonga.miuix.kmp.basic.Text fun SettingsTopBar(modifier: Modifier = Modifier) { LiquidGlassTopBar(modifier) { Box(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 14.dp)) { - Text(text = "设置", color = GlassTokens.text, fontSize = 22.sp, fontWeight = FontWeight.Bold) + Text(text = stringResource(R.string.settings_title), color = GlassTokens.text, fontSize = LumaTypography.titleLarge, fontWeight = FontWeight.Bold) } } } @@ -101,28 +110,50 @@ fun SettingsScreen( var showLanguageDialog by remember { mutableStateOf(false) } var languageMenuMounted by remember { mutableStateOf(false) } var languageEntryBounds by remember { mutableStateOf(null) } + var themeMenuVisible by remember { mutableStateOf(false) } + var themeMenuMounted by remember { mutableStateOf(false) } + var themeEntryBounds by remember { mutableStateOf(null) } var settingsRootOrigin by remember { mutableStateOf(Offset.Zero) } val context = LocalContext.current val languageScope = rememberCoroutineScope() - val languageBackdrop = rememberLayerBackdrop() + val languageBackdrop = rememberLumaLayerBackdrop() Box( Modifier .fillMaxSize() .onGloballyPositioned { settingsRootOrigin = it.positionInRoot() }, ) { - Column( + Box( Modifier .fillMaxSize() - .layerBackdrop(languageBackdrop) - .verticalScroll(rememberScrollState()) - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + .background(LumaColors.background) + .then( + if (languageBackdrop != null) Modifier.layerBackdrop(languageBackdrop) else Modifier, + ), + ) + CompositionLocalProvider(LocalGlassBackdrop provides languageBackdrop) { + 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), + ) { SettingSectionCard { SettingRowItem( - icon = Icons.Outlined.Language, - title = "语言", + icon = LumaIcon.Image, + title = stringResource(R.string.settings_theme), + subtitle = themeModeLabel(), + onTrailingIconPositioned = { themeEntryBounds = it.boundsInRoot() }, + onClick = { + themeMenuMounted = true + themeMenuVisible = true + }, + ) + DividerLine() + SettingRowItem( + icon = LumaIcon.Globe, + title = stringResource(R.string.settings_language), subtitle = AppLanguage.displayName, onTrailingIconPositioned = { languageEntryBounds = it.boundsInRoot() }, onClick = { @@ -132,47 +163,63 @@ fun SettingsScreen( ) DividerLine() SettingRowItem( - icon = Icons.Outlined.PlayCircle, - title = "播放设置", - subtitle = "自适应比特率", + icon = LumaIcon.Play, + title = stringResource(R.string.settings_playback_row), + subtitle = stringResource(R.string.settings_playback_row_sub), onClick = {}, ) } SettingSectionCard { SettingRowItem( - icon = Icons.Outlined.Folder, - title = "清除缓存", - subtitle = "管理应用缓存", + icon = LumaIcon.Folder, + title = stringResource(R.string.settings_cache), + subtitle = stringResource(R.string.settings_cache_sub), onClick = { onOpenCacheDetail() }, ) DividerLine() SettingRowItem( - icon = Icons.Outlined.Info, - title = "关于", - subtitle = "版本 1.0", + icon = LumaIcon.Info, + title = stringResource(R.string.about), + subtitle = stringResource(R.string.about_sub), onClick = { onOpenAbout() }, ) } } - val anchor = languageEntryBounds - if (languageMenuMounted && anchor != null) { - LanguageMenuOverlay( - anchorBounds = anchor, - rootOrigin = settingsRootOrigin, - backdrop = languageBackdrop, - visible = showLanguageDialog, - onDismiss = { showLanguageDialog = false }, - onClosed = { languageMenuMounted = false }, - onLanguageSelected = { language -> - showLanguageDialog = false - languageScope.launch { - delay(230) - AppLanguage.apply(context, language) - } - }, - ) + val anchor = languageEntryBounds + if (languageMenuMounted && anchor != null) { + LanguageMenuOverlay( + anchorBounds = anchor, + rootOrigin = settingsRootOrigin, + backdrop = languageBackdrop, + visible = showLanguageDialog, + onDismiss = { showLanguageDialog = false }, + onClosed = { languageMenuMounted = false }, + onLanguageSelected = { language -> + showLanguageDialog = false + languageScope.launch { + delay(230) + AppLanguage.apply(context, language) + } + }, + ) + } + val themeAnchor = themeEntryBounds + if (themeMenuMounted && themeAnchor != null) { + ThemeMenuOverlay( + anchorBounds = themeAnchor, + rootOrigin = settingsRootOrigin, + backdrop = languageBackdrop, + visible = themeMenuVisible, + onDismiss = { themeMenuVisible = false }, + onClosed = { themeMenuMounted = false }, + onThemeSelected = { mode -> + themeMenuVisible = false + AppTheme.setMode(context, mode) + }, + ) + } } } } @@ -182,9 +229,7 @@ private fun SettingSectionCard(content: @Composable ColumnScope.() -> Unit) { Column( Modifier .fillMaxWidth() - .clip(RoundedCornerShape(20.dp)) - .background(Color.White) - .border(0.5.dp, Color.LightGray, RoundedCornerShape(20.dp)) + .lumaGlass(radius = LumaShapes.large, level = com.example.myapplication.ui.design.LumaGlassLevel.Regular) .padding(vertical = 4.dp), ) { content() @@ -193,40 +238,48 @@ private fun SettingSectionCard(content: @Composable ColumnScope.() -> Unit) { @Composable private fun SettingRowItem( - icon: ImageVector, + icon: LumaIcon, title: String, subtitle: String, modifier: Modifier = Modifier, onTrailingIconPositioned: ((LayoutCoordinates) -> Unit)? = null, onClick: () -> Unit, ) { + val interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val pressColor by animateColorAsState( + targetValue = if (pressed) LumaColors.glassHighlight else Color.Transparent, + animationSpec = tween(LumaMotion.fast), + label = "settingRowPress", + ) Row( modifier .fillMaxWidth() - .clickable { onClick() } - .padding(horizontal = 16.dp, vertical = 14.dp), + .background(pressColor) + .clickable(indication = null, interactionSource = interactionSource) { onClick() } + .padding(horizontal = LumaSpacing.md, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically ) { Box( Modifier .size(38.dp) - .clip(RoundedCornerShape(10.dp)) - .background(Color(0xFFF3F4F6)), + .clip(RoundedCornerShape(LumaShapes.small)) + .background(LumaColors.accentSoft), contentAlignment = Alignment.Center ) { - Icon(icon, null, tint = Color(0xFF374151), modifier = Modifier.size(20.dp)) + LumaIconView(icon, tint = LumaColors.accent, contentDescription = null, modifier = Modifier.size(20.dp)) } Spacer(Modifier.width(14.dp)) Column(Modifier.weight(1f)) { - Text(title, color = Color(0xFF1F2937), fontSize = 15.sp, fontWeight = FontWeight.Bold) + Text(title, color = LumaColors.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) if (subtitle.isNotEmpty()) { - Text(subtitle, color = Color(0xFF6B7280), fontSize = 12.sp) + Text(subtitle, color = LumaColors.textSecondary, fontSize = 12.sp) } } Icon( Icons.Outlined.ChevronRight, null, - tint = Color.LightGray, + tint = LumaColors.textTertiary, modifier = Modifier .size(20.dp) .then( @@ -242,7 +295,80 @@ private fun SettingRowItem( @Composable private fun DividerLine() { - Box(Modifier.fillMaxWidth().height(0.5.dp).padding(horizontal = 16.dp).background(Color(0xFFE5E7EB))) + Box(Modifier.fillMaxWidth().height(0.5.dp).padding(horizontal = LumaSpacing.md).background(LumaColors.divider)) +} + +@Composable +private fun themeModeLabel(): String = when (AppTheme.themeMode) { + 1 -> stringResource(R.string.theme_dark) + 2 -> stringResource(R.string.theme_system) + else -> stringResource(R.string.theme_light) +} + +@Composable +private fun ThemeMenuOverlay( + anchorBounds: Rect, + rootOrigin: Offset, + backdrop: Backdrop?, + visible: Boolean, + onDismiss: () -> Unit, + onClosed: () -> Unit, + onThemeSelected: (Int) -> Unit, +) { + LiquidGlassPopupOverlay( + anchorBounds = anchorBounds, + rootOrigin = rootOrigin, + backdrop = backdrop, + visible = visible, + onDismissRequest = onDismiss, + onClosed = onClosed, + width = 190.dp, + height = 184.dp, + ) { + ThemeOption( + icon = LumaIcon.Image, + label = stringResource(R.string.theme_light), + selected = AppTheme.themeMode == 0, + onClick = { onThemeSelected(0) }, + ) + ThemeOption( + icon = LumaIcon.Eye, + label = stringResource(R.string.theme_dark), + selected = AppTheme.themeMode == 1, + onClick = { onThemeSelected(1) }, + ) + ThemeOption( + icon = LumaIcon.Cloud, + label = stringResource(R.string.theme_system), + selected = AppTheme.themeMode == 2, + onClick = { onThemeSelected(2) }, + ) + } +} + +@Composable +private fun ThemeOption( + icon: LumaIcon, + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable(indication = null, interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LumaIconView(icon, contentDescription = null, tint = if (selected) LumaColors.accent else LumaColors.textSecondary, modifier = Modifier.size(19.dp)) + Spacer(Modifier.width(10.dp)) + Text(label, color = LumaColors.text, fontSize = 14.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, modifier = Modifier.weight(1f)) + if (selected) { + LumaIconView(LumaIcon.Check, contentDescription = stringResource(R.string.selected), tint = LumaColors.accent, modifier = Modifier.size(18.dp)) + } + } } @Composable @@ -264,12 +390,12 @@ private fun LanguageMenuOverlay( hasOpened = true progress.animateTo( targetValue = 1f, - animationSpec = tween(180, easing = CubicBezierEasing(0.18f, 0.82f, 0.28f, 1f)), + animationSpec = tween(LumaMotion.menu, easing = LumaMotion.enter), ) } else if (hasOpened) { progress.animateTo( targetValue = 0f, - animationSpec = tween(300, easing = CubicBezierEasing(0.35f, 0f, 0.72f, 0.18f)), + animationSpec = tween(LumaMotion.sheet, easing = LumaMotion.exit), ) onClosed() } @@ -301,12 +427,8 @@ private fun LanguageMenuOverlay( ) val currentMenuRect by rememberUpdatedState(menuRect) val shape = RoundedCornerShape(cornerRadius) - val surface = if (GlassTokens.isDark) { - Color.White.copy(alpha = 0.08f) - } else { - Color.Black.copy(alpha = 0.10f) - } - val border = if (GlassTokens.isDark) Color.White.copy(alpha = 0.24f) else Color.White.copy(alpha = 0.62f) + val surface = LumaColors.floatingTint + val border = LumaColors.glassBorder Box( Modifier @@ -331,25 +453,24 @@ private fun LanguageMenuOverlay( .width(menuWidth) .height(menuHeight) .then( - if (backdrop != null) { + if (backdrop != null && LumaGlassRuntime.useNativeBackdrop) { Modifier.drawBackdrop( backdrop = backdrop, shape = { RoundedRectangle(cornerRadius) }, effects = { colorControls( - brightness = if (GlassTokens.isDark) 0f else 0.2f, - saturation = 1.5f, + brightness = if (GlassTokens.isDark) 0f else 0.05f, + saturation = 1.10f, ) vibrancy() - blur(if (GlassTokens.isDark) 8.dp.toPx() else 16.dp.toPx()) - lens(24.dp.toPx(), 48.dp.toPx(), depthEffect = true) + blur(if (GlassTokens.isDark) 10.dp.toPx() else 14.dp.toPx()) }, highlight = { Highlight.Plain }, shadow = { Shadow(alpha = 0.16f) }, onDrawSurface = { drawRect(surface) }, ) } else { - Modifier.background(surface) + Modifier.background(LumaColors.fallbackGlass) }, ) .border(0.7.dp, border, shape) @@ -397,11 +518,11 @@ private fun LanguageOption( .size(20.dp) .clip(CircleShape) .background(if (selected) GlassTokens.accent else Color.Transparent) - .border(1.dp, if (selected) GlassTokens.accent else GlassTokens.subtext2, CircleShape), + .border(1.dp, if (selected) GlassTokens.accent else LumaColors.textTertiary, CircleShape), contentAlignment = Alignment.Center, ) { if (selected) { - Text("✓", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold) + LumaIconView(LumaIcon.Check, tint = Color.White, modifier = Modifier.size(14.dp), contentDescription = stringResource(R.string.selected)) } } Spacer(Modifier.width(10.dp)) diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt index d3f747f..2765a01 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt @@ -7,12 +7,15 @@ import android.content.pm.ActivityInfo import android.view.Surface import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut 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.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,21 +34,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items 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.Slider import androidx.compose.material3.SliderDefaults 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 @@ -62,8 +53,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -82,18 +73,14 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView 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.colorControls -import com.kyant.backdrop.effects.lens -import com.kyant.backdrop.effects.vibrancy -import com.kyant.backdrop.highlight.Highlight -import com.kyant.backdrop.shadow.Shadow -import com.kyant.shapes.Capsule -import com.kyant.shapes.RoundedRectangle +import com.example.myapplication.R as Res +import com.example.myapplication.ui.design.LumaIcon +import com.example.myapplication.ui.design.LumaIconView +import com.example.myapplication.ui.design.LumaLoading +import com.example.myapplication.ui.design.LumaGlassLevel +import com.example.myapplication.ui.design.lumaGlass +import com.example.myapplication.ui.design.LumaShapes +import com.example.myapplication.ui.LocalGlassBackdrop import kotlinx.coroutines.delay import top.yukonga.miuix.kmp.basic.Text @@ -111,8 +98,6 @@ private tailrec fun Context.findActivity(): Activity? = when (this) { else -> null } -private val LocalPlayerBackdrop = staticCompositionLocalOf { null } - @androidx.annotation.OptIn(UnstableApi::class) @Composable fun VideoPlayerScreen( @@ -126,19 +111,20 @@ fun VideoPlayerScreen( onSelectEpisode: (EmbyItem) -> Unit = {}, onClose: () -> Unit = {}, onPlaybackStarted: (positionMs: Long) -> Unit = {}, - onPlaybackProgress: (positionMs: Long, paused: Boolean) -> Unit = { _, _ -> }, + onPlaybackProgress: (positionMs: Long, paused: Boolean, eventName: String) -> Unit = { _, _, _ -> }, onPlaybackStopped: (positionMs: Long) -> Unit = {}, ) { val context = LocalContext.current + val cannotPlayMessage = stringResource(Res.string.player_cannot_play) val lifecycleOwner = LocalLifecycleOwner.current var showSubtitleMenu by remember { mutableStateOf(false) } var showEpisodeMenu by remember { mutableStateOf(false) } val player = remember { ExoPlayer.Builder(context).build() } - val playerBackdrop = rememberLayerBackdrop() val latestPlaybackUrl by rememberUpdatedState(playbackUrl) val latestOnPlaybackStarted by rememberUpdatedState(onPlaybackStarted) val latestOnPlaybackProgress by rememberUpdatedState(onPlaybackProgress) val latestOnPlaybackStopped by rememberUpdatedState(onPlaybackStopped) + var stopReported by remember(playbackUrl) { mutableStateOf(false) } var progress by remember { mutableFloatStateOf(0f) } var isPlaying by remember { mutableStateOf(false) } @@ -164,8 +150,8 @@ fun VideoPlayerScreen( LaunchedEffect(player, playbackUrl) { while (true) { - if (!playbackUrl.isNullOrBlank() && player.currentMediaItem != null) { - latestOnPlaybackProgress(player.currentPosition, !player.isPlaying) + if (!playbackUrl.isNullOrBlank() && player.currentMediaItem != null && player.isPlaying) { + latestOnPlaybackProgress(player.currentPosition, false, "TimeUpdate") } delay(10_000) } @@ -185,16 +171,30 @@ fun VideoPlayerScreen( player.play() latestOnPlaybackStarted(initialPositionMs.coerceAtLeast(0L)) } - onDispose { } + onDispose { + if (!playbackUrl.isNullOrBlank() && !stopReported) { + stopReported = true + latestOnPlaybackStopped(player.currentPosition) + } + } } DisposableEffect(player) { val listener = object : Player.Listener { override fun onPlayerError(error: PlaybackException) { - playerError = error.message ?: "播放器无法播放该资源" + playerError = cannotPlayMessage + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (!latestPlaybackUrl.isNullOrBlank()) { + latestOnPlaybackProgress(player.currentPosition, !isPlaying, if (isPlaying) "Unpause" else "Pause") + } } } player.addListener(listener) - onDispose { player.removeListener(listener) } + onDispose { + player.removeListener(listener) + player.release() + } } DisposableEffect(lifecycleOwner) { var resumeAfterStop = false @@ -213,31 +213,22 @@ fun VideoPlayerScreen( lifecycleOwner.lifecycle.addObserver(obs) onDispose { lifecycleOwner.lifecycle.removeObserver(obs) } } - DisposableEffect(Unit) { - onDispose { - if (!latestPlaybackUrl.isNullOrBlank()) { - latestOnPlaybackStopped(player.currentPosition) - } - player.release() - } - } BackHandler(enabled = true, onBack = onClose) - CompositionLocalProvider(LocalPlayerBackdrop provides playerBackdrop) { - Box(Modifier.fillMaxSize().background(Color.Black).clickable( + Box(Modifier.fillMaxSize().background(Color.Black).clickable( interactionSource = remember { MutableInteractionSource() }, indication = null ) { controlsVisible = !controlsVisible }) { if (playbackUrl.isNullOrBlank()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { when { - isLoading -> CircularProgressIndicator(color = Color.White) + isLoading -> LumaLoading(label = stringResource(Res.string.player_preparing)) errorText != null -> Text(text = errorText, color = Color.White, modifier = Modifier.padding(24.dp)) - else -> Text(text = "正在准备播放...", color = Color.White) + else -> Text(text = stringResource(Res.string.player_preparing), color = Color.White) } } } else { - Box(Modifier.fillMaxSize().layerBackdrop(playerBackdrop)) { + Box(Modifier.fillMaxSize()) { AndroidView( factory = { ctx -> PlayerView(ctx).also { pv -> @@ -246,9 +237,7 @@ fun VideoPlayerScreen( pv.resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT } }, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = 0.999f }, + modifier = Modifier.fillMaxSize(), ) } } @@ -256,7 +245,7 @@ fun VideoPlayerScreen( if (playerError != null) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( - text = playerError ?: "播放失败", + text = playerError ?: stringResource(Res.string.player_failed), color = Color.White, modifier = Modifier.padding(24.dp), ) @@ -288,28 +277,53 @@ fun VideoPlayerScreen( horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically, ) { - LiquidSurfaceButton(icon = Icons.Filled.FastRewind, active = false, size = 48.dp) { + LiquidSurfaceButton( + icon = LumaIcon.Replay, + contentDescription = stringResource(Res.string.player_seek_backward), + active = false, + size = 48.dp, + ) { val pos = player.currentPosition - 10_000L player.seekTo(maxOf(0L, pos)) + latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, "TimeUpdate") } - LiquidSurfaceButton(icon = Icons.Filled.SkipPrevious, active = false, size = 56.dp) { + LiquidSurfaceButton( + icon = LumaIcon.Previous, + contentDescription = stringResource(Res.string.player_previous_episode), + active = false, + size = 56.dp, + ) { val idx = episodes.indexOfFirst { it.id == currentEpisodeId } if (idx > 0) onSelectEpisode(episodes[idx - 1]) } - LiquidSurfaceButton(icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, active = true, size = 72.dp, iconSize = 40.dp) { + LiquidSurfaceButton( + icon = if (isPlaying) LumaIcon.Pause else LumaIcon.Play, + contentDescription = stringResource(if (isPlaying) Res.string.player_pause else Res.string.player_play), + active = true, + size = 72.dp, + iconSize = 40.dp, + ) { if (player.isPlaying) { player.pause() - latestOnPlaybackProgress(player.currentPosition, true) } else { player.play() - latestOnPlaybackProgress(player.currentPosition, false) } } - LiquidSurfaceButton(icon = Icons.Filled.SkipNext, active = false, size = 56.dp) { + LiquidSurfaceButton( + icon = LumaIcon.Next, + contentDescription = stringResource(Res.string.player_next_episode), + active = false, + size = 56.dp, + ) { val idx = episodes.indexOfFirst { it.id == currentEpisodeId } if (idx >= 0 && idx < episodes.lastIndex) onSelectEpisode(episodes[idx + 1]) } - LiquidSurfaceButton(icon = Icons.Filled.FastForward, active = false, size = 48.dp) { + LiquidSurfaceButton( + icon = LumaIcon.Speed, + contentDescription = stringResource(Res.string.player_seek_forward), + active = false, + size = 48.dp, + ) { val pos = player.currentPosition + 10_000L player.seekTo(minOf(pos, player.duration)) } @@ -322,10 +336,20 @@ fun VideoPlayerScreen( ) { Text(title, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - LiquidSurfaceButton(icon = Icons.Filled.Subtitles, active = showSubtitleMenu, size = 40.dp) { + LiquidSurfaceButton( + icon = LumaIcon.Subtitle, + contentDescription = stringResource(Res.string.player_subtitle), + active = showSubtitleMenu, + size = 40.dp, + ) { showSubtitleMenu = !showSubtitleMenu; showEpisodeMenu = false } - LiquidSurfaceButton(icon = Icons.Filled.List, active = showEpisodeMenu, size = 40.dp) { + LiquidSurfaceButton( + icon = LumaIcon.Watch, + contentDescription = stringResource(Res.string.player_episode_list), + active = showEpisodeMenu, + size = 40.dp, + ) { showEpisodeMenu = !showEpisodeMenu; showSubtitleMenu = false } } @@ -337,11 +361,13 @@ fun VideoPlayerScreen( .align(Alignment.BottomCenter) .fillMaxWidth(0.85f) .padding(bottom = 32.dp) - .drawBackdrop( - backdrop = playerBackdrop, - shape = { Capsule() }, - effects = { blur(16f.dp.toPx()); vibrancy() }, - onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.4f)) }, + .lumaGlass( + backdrop = null, + radius = 24.dp, + level = LumaGlassLevel.Strong, + tint = Color.Black.copy(alpha = 0.40f), + borderColor = Color.White.copy(alpha = 0.16f), + shadowElevation = 4.dp, ) .padding(horizontal = 20.dp, vertical = 12.dp), ) { @@ -368,7 +394,11 @@ fun VideoPlayerScreen( Slider( value = if (isSeeking) seekPosition else progress, onValueChange = { v -> isSeeking = true; seekPosition = v }, - onValueChangeFinished = { player.seekTo((seekPosition * player.duration).toLong()); isSeeking = false }, + onValueChangeFinished = { + player.seekTo((seekPosition * player.duration).toLong()) + isSeeking = false + latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, "TimeUpdate") + }, modifier = Modifier.weight(1f).padding(horizontal = 12.dp), colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = Color.White) ) @@ -380,12 +410,12 @@ fun VideoPlayerScreen( } if (showSubtitleMenu) { - LiquidGlassPanel(title = "字幕", onDismiss = { showSubtitleMenu = false }) { + LiquidGlassPanel(title = stringResource(Res.string.player_subtitle), onDismiss = { showSubtitleMenu = false }) { SubtitleMenuContent(player = player) { showSubtitleMenu = false } } } if (showEpisodeMenu) { - LiquidGlassPanel(title = title.ifBlank { "剧集列表" }, onDismiss = { showEpisodeMenu = false }) { + LiquidGlassPanel(title = title.ifBlank { stringResource(Res.string.player_episode_list) }, onDismiss = { showEpisodeMenu = false }) { LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { items(items = episodes) { ep -> LiquidEpisodeItem( @@ -398,41 +428,79 @@ fun VideoPlayerScreen( } } } - } } @Composable private fun LiquidSurfaceButton( - icon: ImageVector, + icon: LumaIcon, + contentDescription: String? = null, active: Boolean, size: Dp = 44.dp, iconSize: Dp = 24.dp, onClick: () -> Unit, ) { - val bd = LocalPlayerBackdrop.current + val bd = LocalGlassBackdrop.current val surfaceColor = if (active) Color.White.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.1f) + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.94f else 1f, + animationSpec = tween(180), + label = "playerMaterialIconPress", + ) Box( Modifier .size(size) - .clip(CircleShape) - .then( - if (bd != null) { - Modifier.drawBackdrop( - backdrop = bd, - shape = { Capsule() }, - effects = { blur(12f.dp.toPx()); vibrancy() }, - onDrawSurface = { drawRect(surfaceColor) }, - ) - } else { - Modifier.background(surfaceColor) - } + .graphicsLayer { scaleX = scale; scaleY = scale } + .lumaGlass( + backdrop = bd, + radius = size / 2f, + level = LumaGlassLevel.Interactive, + tint = surfaceColor, + borderColor = Color.White.copy(alpha = 0.3f), + shadowElevation = 4.dp, ) - .border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape) - .clickable(onClick = onClick), + .clickable(indication = null, interactionSource = interactionSource, onClick = onClick), contentAlignment = Alignment.Center, ) { - Icon(icon, null, tint = Color.White, modifier = Modifier.size(iconSize)) + LumaIconView(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(iconSize)) + } +} + +@Composable +private fun LiquidSurfaceButton( + icon: LumaIcon, + active: Boolean, + size: Dp = 44.dp, + iconSize: Dp = 24.dp, + onClick: () -> Unit, +) { + val bd = LocalGlassBackdrop.current + val surfaceColor = if (active) Color.White.copy(alpha = 0.26f) else Color.White.copy(alpha = 0.09f) + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.94f else 1f, + animationSpec = tween(180), + label = "playerIconPress", + ) + Box( + Modifier + .size(size) + .graphicsLayer { scaleX = scale; scaleY = scale } + .lumaGlass( + backdrop = bd, + radius = size / 2f, + level = LumaGlassLevel.Interactive, + tint = surfaceColor, + borderColor = Color.White.copy(alpha = 0.22f), + shadowElevation = 4.dp, + ) + .clickable(indication = null, interactionSource = interactionSource, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + LumaIconView(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(iconSize)) } } @@ -442,39 +510,32 @@ private fun LiquidGlassPanel( onDismiss: () -> Unit, content: @Composable () -> Unit, ) { - val bd = LocalPlayerBackdrop.current + val bd = LocalGlassBackdrop.current Box( Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.3f)) - .clickable(onClick = onDismiss), + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onDismiss), contentAlignment = Alignment.CenterEnd, ) { Column( Modifier .fillMaxHeight() .width(320.dp) - .clip(RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp)) - .then( - if (bd != null) { - Modifier.drawBackdrop( - backdrop = bd, - shape = { RoundedRectangle(24.dp) }, - effects = { - colorControls(brightness = 0f, saturation = 1.15f) - vibrancy() - blur(24f.dp.toPx()) - lens(24f.dp.toPx(), 42f.dp.toPx(), depthEffect = true) - }, - highlight = { Highlight.Plain }, - shadow = { Shadow(alpha = 0.22f) }, - onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.5f)) }, - ) - } else { - Modifier.background(Color.Black.copy(alpha = 0.8f)) - } + .lumaGlass( + backdrop = bd, + radius = 24.dp, + level = LumaGlassLevel.Strong, + tint = Color.White.copy(alpha = 0.08f), + borderColor = Color.White.copy(alpha = 0.16f), + shadowElevation = 8.dp, + ) + .clickable( + enabled = false, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = {}, ) - .clickable(enabled = false) {} .padding(20.dp), ) { Text(title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) @@ -501,7 +562,7 @@ private fun SubtitleMenuContent(player: ExoPlayer, onDismiss: () -> Unit) { } LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { item { - LiquidMenuItem(text = "关闭字幕", selected = !player.currentTracks.groups.any { it.type == C.TRACK_TYPE_TEXT && it.isSelected }) { + LiquidMenuItem(text = stringResource(Res.string.player_disable_subtitle), selected = !player.currentTracks.groups.any { it.type == C.TRACK_TYPE_TEXT && it.isSelected }) { player.trackSelectionParameters = player.trackSelectionParameters.buildUpon().setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build() onDismiss() } @@ -519,65 +580,51 @@ private fun SubtitleMenuContent(player: ExoPlayer, onDismiss: () -> Unit) { } private data class SubtitleTrackInfo( - @androidx.annotation.OptIn(UnstableApi::class) val group: androidx.media3.common.Tracks.Group, + @param:androidx.annotation.OptIn(UnstableApi::class) val group: androidx.media3.common.Tracks.Group, val index: Int, val label: String ) @Composable private fun LiquidMenuItem(text: String, selected: Boolean, onClick: () -> Unit) { - val bd = LocalPlayerBackdrop.current - val shape = RoundedCornerShape(12.dp) - val surface = if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f) + val bd = LocalGlassBackdrop.current + val surface = if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.06f) Row( Modifier .fillMaxWidth() - .clip(shape) - .then( - if (bd != null) { - Modifier.drawBackdrop( - backdrop = bd, - shape = { RoundedRectangle(12.dp) }, - effects = { vibrancy(); blur(8.dp.toPx()); lens(10.dp.toPx(), 16.dp.toPx()) }, - onDrawSurface = { drawRect(surface) }, - ) - } else { - Modifier.background(surface) - } + .lumaGlass( + backdrop = bd, + radius = LumaShapes.small, + level = LumaGlassLevel.Interactive, + tint = surface, + borderColor = Color.White.copy(alpha = 0.16f), + shadowElevation = 0.dp, ) - .clickable(onClick = onClick) - .border(0.5.dp, Color.White.copy(alpha = 0.18f), shape) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onClick) .padding(14.dp), verticalAlignment = Alignment.CenterVertically ) { Text(text, color = Color.White, fontSize = 14.sp, modifier = Modifier.weight(1f)) - if (selected) Icon(Icons.Filled.PlayArrow, null, tint = Color.White, modifier = Modifier.size(16.dp)) + if (selected) LumaIconView(LumaIcon.Play, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) } } @Composable private fun LiquidEpisodeItem(episode: EmbyItem, displayIndex: Int, selected: Boolean, onClick: () -> Unit) { - val bd = LocalPlayerBackdrop.current - val shape = RoundedCornerShape(12.dp) - val surface = if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f) + val bd = LocalGlassBackdrop.current + val surface = if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.06f) Row( Modifier .fillMaxWidth() - .clip(shape) - .then( - if (bd != null) { - Modifier.drawBackdrop( - backdrop = bd, - shape = { RoundedRectangle(12.dp) }, - effects = { vibrancy(); blur(8.dp.toPx()); lens(10.dp.toPx(), 16.dp.toPx()) }, - onDrawSurface = { drawRect(surface) }, - ) - } else { - Modifier.background(surface) - } + .lumaGlass( + backdrop = bd, + radius = LumaShapes.small, + level = LumaGlassLevel.Interactive, + tint = surface, + borderColor = Color.White.copy(alpha = 0.16f), + shadowElevation = 0.dp, ) - .clickable(onClick = onClick) - .border(0.5.dp, Color.White.copy(alpha = 0.18f), shape) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onClick) .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt index a4d1f77..e791ab2 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt @@ -12,13 +12,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -28,7 +26,13 @@ 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.example.myapplication.ui.design.LumaColors +import com.example.myapplication.ui.design.LumaGlassRuntime +import com.example.myapplication.ui.design.LumaIcon +import com.example.myapplication.ui.design.LumaAnimatedIcon +import com.example.myapplication.ui.design.LumaIconView +import com.example.myapplication.ui.design.LumaSpacing +import com.example.myapplication.ui.design.lumaFallbackGlass import com.kyant.backdrop.Backdrop import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur @@ -41,89 +45,102 @@ import top.yukonga.miuix.kmp.basic.Text fun LiquidAppBottomBar( selectedTab: Int, onSelect: (Int) -> Unit, - backdrop: Backdrop, + backdrop: Backdrop?, modifier: Modifier = Modifier, ) { - val isDark = GlassTokens.isDark - val inactive = if (isDark) Color(0xFFF3F4F6) else Color(0xFF111111) + val inactive = LumaColors.text Row( modifier = modifier .navigationBarsPadding() - .padding(start = 20.dp, end = 20.dp, bottom = 12.dp), + .padding(start = LumaSpacing.lg, end = LumaSpacing.lg, bottom = LumaSpacing.sm), verticalAlignment = Alignment.CenterVertically, ) { Box(modifier = Modifier.weight(1f)) { LiquidBottomTabs( - selectedTabIndex = { if (selectedTab < 4) selectedTab else -1 }, + selectedTabIndex = { selectedTab.coerceIn(0, 3) }, onTabSelected = onSelect, backdrop = backdrop, tabsCount = 4, + showSelection = { selectedTab < 4 }, ) { val tabsCount = mainTabs.size for (index in 0 until tabsCount) { val tab = mainTabs[index] LiquidBottomTab(onClick = { onSelect(index) }) { val isGhostLayer = LocalLiquidBottomTabsGhostLayer.current - val pillValue = LocalLiquidBottomTabsPillValue.current - val distance = pillValue - index.toFloat() - val pillDistance = if (distance < 0f) -distance else distance - val normalAlpha = if (isGhostLayer || pillDistance > 0.58f || selectedTab == 4) 1f else 0f - - Icon( - imageVector = if (selectedTab == index) tab.selectedIcon else tab.unselectedIcon, + LumaAnimatedIcon( + icon = tab.icon, + selected = selectedTab == index, contentDescription = stringResource(tab.labelRes), - tint = if (isGhostLayer) Color.White else inactive, - modifier = Modifier.size(21.dp).alpha(normalAlpha), + tint = if (isGhostLayer) Color.White else if (selectedTab == index) LumaColors.accent else inactive, + modifier = Modifier.size(30.dp), ) - Spacer(Modifier.height(2.dp)) + Spacer(Modifier.height(3.dp)) Text( text = stringResource(tab.labelRes), - color = if (isGhostLayer) Color.White else inactive, + color = if (isGhostLayer) Color.White else if (selectedTab == index) LumaColors.accent else inactive, fontSize = 10.sp, fontWeight = FontWeight.Medium, maxLines = 1, - modifier = Modifier.alpha(normalAlpha), ) } } } } - Spacer(Modifier.width(12.dp)) + Spacer(Modifier.width(LumaSpacing.sm)) val searchSelected = selectedTab == 4 + val safeBackdrop = backdrop?.takeIf { LumaGlassRuntime.useNativeBackdrop } Box( modifier = Modifier .size(width = 64.dp, height = 64.dp) - .drawBackdrop( - backdrop = backdrop, - shape = { Capsule() }, - effects = { - vibrancy() - blur(2f.dp.toPx()) - lens(12f.dp.toPx(), 24f.dp.toPx()) - }, - onDrawSurface = { - if (searchSelected) { - drawRect(Color.White.copy(alpha = 0.18f)) - } + .clip(CircleShape) + .then( + if (safeBackdrop != null) { + Modifier.drawBackdrop( + backdrop = safeBackdrop, + shape = { Capsule() }, + effects = { + vibrancy() + blur(2f.dp.toPx()) + lens(12f.dp.toPx(), 16f.dp.toPx()) + }, + onDrawSurface = { + if (searchSelected) { + drawRect(LumaColors.glassHighlight.copy(alpha = 0.40f)) + } + }, + ) + } else { + Modifier.lumaFallbackGlass(CircleShape, selected = searchSelected) }, ) - .border(1.dp, if (searchSelected) Color.LightGray else Color.White.copy(alpha = 0.3f), CircleShape) - .clickable { onSelect(4) }, + .then( + if (safeBackdrop != null) { + Modifier.border( + 0.6.dp, + if (searchSelected) LumaColors.glassBorder else LumaColors.glassBorder.copy(alpha = 0.72f), + CircleShape, + ) + } else { + Modifier + }, + ) + .clickable(indication = null, interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }) { onSelect(4) }, contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon( - imageVector = Icons.Filled.Search, + LumaIconView( + icon = LumaIcon.Search, contentDescription = null, - tint = if (searchSelected) Color(0xFF1F2937) else inactive, - modifier = Modifier.size(24.dp), + tint = if (searchSelected) LumaColors.accent else inactive, + modifier = Modifier.size(34.dp), ) Text( text = stringResource(Res.string.search), - color = if (searchSelected) Color(0xFF1F2937) else inactive, + color = if (searchSelected) LumaColors.accent else inactive, fontSize = 10.sp, fontWeight = FontWeight.ExtraBold, ) diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppTheme.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppTheme.kt index 67b61a2..42ccf1b 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/AppTheme.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/AppTheme.kt @@ -1,11 +1,15 @@ package com.example.myapplication.ui +import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue object AppTheme { + private const val preferencesName = "app_settings" + private const val themeKey = "theme" + // 0 = Light, 1 = Dark, 2 = Follow System var themeMode by mutableIntStateOf(0) var systemIsDark by mutableStateOf(false) @@ -16,4 +20,20 @@ object AppTheme { 1 -> true else -> systemIsDark } -} \ No newline at end of file + + fun init(context: Context) { + themeMode = context + .getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + .getInt(themeKey, 0) + .coerceIn(0, 2) + } + + fun setMode(context: Context, mode: Int) { + themeMode = mode.coerceIn(0, 2) + context + .getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + .edit() + .putInt(themeKey, themeMode) + .apply() + } +} diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt index c4f36e3..95fa978 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -1,20 +1,14 @@ package com.example.myapplication.ui import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.CubicBezierEasing 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 com.kyant.backdrop.Backdrop import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.colorControls -import com.kyant.backdrop.effects.lens import com.kyant.backdrop.effects.vibrancy import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.shadow.Shadow @@ -56,6 +50,7 @@ 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.focus.onFocusChanged import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawBehind @@ -79,6 +74,7 @@ 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.window.DialogProperties import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Dp @@ -96,6 +92,15 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.material3.Icon import top.yukonga.miuix.kmp.basic.Text +import com.example.myapplication.ui.design.LumaColors +import com.example.myapplication.ui.design.LumaIcon +import com.example.myapplication.ui.design.LumaIconView +import com.example.myapplication.ui.design.LumaGlassLevel +import com.example.myapplication.ui.design.LumaMotion +import com.example.myapplication.ui.design.LumaShapes +import com.example.myapplication.ui.design.LumaTypography +import com.example.myapplication.ui.design.LumaGlassRuntime +import com.example.myapplication.ui.design.lumaGlass import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Apps import androidx.compose.material.icons.filled.GridView @@ -113,16 +118,15 @@ import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Storage internal data class NavTab( - val selectedIcon: ImageVector, - val unselectedIcon: ImageVector, + val icon: LumaIcon, val labelRes: Int, ) internal val mainTabs = listOf( - NavTab(Icons.Filled.Home, Icons.Outlined.Home, Res.string.nav_home), - NavTab(Icons.Filled.Favorite, Icons.Outlined.FavoriteBorder, Res.string.nav_records), - NavTab(Icons.Filled.Storage, Icons.Outlined.Storage, Res.string.nav_library), - NavTab(Icons.Filled.Settings, Icons.Outlined.Settings, Res.string.nav_settings), + NavTab(LumaIcon.Home, Res.string.nav_home), + NavTab(LumaIcon.Favorite, Res.string.nav_records), + NavTab(LumaIcon.Resources, Res.string.nav_library), + NavTab(LumaIcon.Settings, Res.string.nav_settings), ) val LocalGlassBackdrop = staticCompositionLocalOf { null } @@ -135,37 +139,34 @@ fun bottomBarContentPadding(extra: Dp = 86.dp): PaddingValues { 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) + val text: Color get() = LumaColors.text + val subtext: Color get() = LumaColors.textSecondary + val subtext2: Color get() = LumaColors.textTertiary + val accent: Color get() = LumaColors.accent + val accentNav: Color get() = LumaColors.accent + val danger: Color get() = LumaColors.danger + val dangerDark: Color get() = LumaColors.danger + val navIconSelected: Color get() = LumaColors.text + val navIconUnselected: Color get() = LumaColors.textSecondary + val navPillSelected: Color get() = LumaColors.strongTint + val separator: Color get() = LumaColors.divider - 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) + val glassFill: Color get() = LumaColors.regularTint + val glassFillStrong: Color get() = LumaColors.strongTint + val glassFillSoft: Color get() = LumaColors.clearTint + val glassBorder: Color get() = LumaColors.glassBorder + val glassShadow: Color get() = LumaColors.glassShadow - 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) + val navBg: Color get() = LumaColors.floatingTint + val navSelectedPill: Color get() = LumaColors.strongTint + val navBgTop: Color get() = LumaColors.regularTint + val navBgBottom: Color get() = LumaColors.clearTint + val navBorder: Color get() = LumaColors.glassBorder } @Composable fun LiquidGlassBackground(modifier: Modifier = Modifier) { - val density = androidx.compose.ui.platform.LocalDensity.current - val dark = AppTheme.isDark - val bgColor = if (dark) Color(0xFF0F1115) else Color(0xFFF2F4F7) - Box(modifier.fillMaxSize().background(bgColor)) + Box(modifier.fillMaxSize().background(LumaColors.background)) } @Composable @@ -183,40 +184,17 @@ fun GlassSurface( ) { 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 - } + .lumaGlass( + backdrop = LocalGlassBackdrop.current, + radius = cornerRadius, + level = LumaGlassLevel.Regular, + border = border != Color.Unspecified, + tint = fill, + borderColor = border.takeUnless { it == Color.Unspecified }, + shadowElevation = shadowElevation, + highlight = highlight, ) ) { - if (highlight) { - 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, @@ -241,110 +219,6 @@ fun LiquidGlassTopBar( } } -@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, - ) - } - } - } -} - -@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) - ) - } - } - } -} - @Composable fun SegmentedControl( items: List, @@ -357,14 +231,13 @@ fun SegmentedControl( modifier .clip(RoundedCornerShape(20.dp)) .then( - if (backdrop != null) { + if (backdrop != null && LumaGlassRuntime.useNativeBackdrop) { Modifier.drawBackdrop( backdrop = backdrop, shape = { RoundedRectangle(20.dp) }, effects = { vibrancy() blur(8.dp.toPx()) - lens(14.dp.toPx(), 14.dp.toPx()) }, onDrawSurface = { drawRect(GlassTokens.glassFillSoft) }, ) @@ -380,17 +253,17 @@ fun SegmentedControl( val sel = selected == index val selectedFill by animateColorAsState( targetValue = if (sel) GlassTokens.glassFillStrong else Color.Transparent, - animationSpec = tween(180), + animationSpec = tween(LumaMotion.fast), label = "segmentFill$index", ) val selectedText by animateColorAsState( targetValue = if (sel) GlassTokens.text else GlassTokens.subtext, - animationSpec = tween(180), + animationSpec = tween(LumaMotion.fast), label = "segmentText$index", ) val selectedProgress by animateFloatAsState( targetValue = if (sel) 1f else 0f, - animationSpec = tween(180), + animationSpec = tween(LumaMotion.fast), label = "segmentProgress$index", ) Box( @@ -398,14 +271,13 @@ fun SegmentedControl( .weight(1f) .clip(RoundedCornerShape(16.dp)) .then( - if (sel && backdrop != null) { + if (sel && backdrop != null && LumaGlassRuntime.useNativeBackdrop) { Modifier.drawBackdrop( backdrop = backdrop, shape = { RoundedRectangle(16.dp) }, effects = { vibrancy() blur(6.dp.toPx()) - lens(10.dp.toPx(), 10.dp.toPx()) }, onDrawSurface = { drawRect(selectedFill) }, ) @@ -421,7 +293,7 @@ fun SegmentedControl( } ) .then( - if (sel && backdrop == null) { + if (sel && (backdrop == null || !LumaGlassRuntime.useNativeBackdrop)) { Modifier.background(selectedFill) } else { Modifier @@ -456,32 +328,27 @@ fun GlassSearchField( modifier: Modifier = Modifier, ) { val backdrop = LocalGlassBackdrop.current + var focused by remember { mutableStateOf(false) } + val borderColor by animateColorAsState( + targetValue = if (focused) GlassTokens.accent.copy(alpha = 0.72f) else GlassTokens.glassBorder, + animationSpec = tween(LumaMotion.fast), + label = "searchFieldFocus", + ) Row( modifier .fillMaxWidth() - .clip(RoundedCornerShape(22.dp)) - .then( - if (backdrop != null) { - Modifier.drawBackdrop( - backdrop = backdrop, - shape = { RoundedRectangle(22.dp) }, - effects = { - vibrancy() - blur(8.dp.toPx()) - lens(12.dp.toPx(), 12.dp.toPx()) - }, - onDrawSurface = { drawRect(GlassTokens.glassFillSoft) }, - ) - } else { - Modifier.background(GlassTokens.glassFillSoft) - } + .lumaGlass( + backdrop = backdrop, + radius = LumaShapes.large, + level = LumaGlassLevel.Regular, + borderColor = borderColor, + shadowElevation = 0.dp, ) - .border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(22.dp)) .padding(horizontal = 14.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = Icons.Outlined.Search, + LumaIconView( + icon = LumaIcon.Search, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(20.dp), @@ -495,7 +362,7 @@ fun GlassSearchField( color = GlassTokens.text, fontSize = 15.sp, ), - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).onFocusChanged { focused = it.isFocused }, decorationBox = { inner -> if (value.isEmpty()) { Text(text = placeholder, color = GlassTokens.subtext, fontSize = 15.sp) @@ -504,6 +371,25 @@ fun GlassSearchField( } }, ) + if (value.isNotEmpty()) { + Box( + Modifier + .size(40.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { onValueChange("") }, + ), + contentAlignment = Alignment.Center, + ) { + LumaIconView( + icon = LumaIcon.Close, + contentDescription = stringResource(Res.string.clear_search), + tint = GlassTokens.subtext, + modifier = Modifier.size(18.dp), + ) + } + } } } @@ -528,12 +414,12 @@ fun LiquidGlassPopupOverlay( if (visible) { progress.animateTo( targetValue = 1f, - animationSpec = tween(180, easing = CubicBezierEasing(0.18f, 0.82f, 0.28f, 1f)), + animationSpec = tween(LumaMotion.menu, easing = LumaMotion.enter), ) } else { progress.animateTo( targetValue = 0f, - animationSpec = tween(260, easing = CubicBezierEasing(0.35f, 0f, 0.72f, 0.18f)), + animationSpec = tween(LumaMotion.sheet, easing = LumaMotion.exit), ) currentOnClosed() } @@ -587,7 +473,7 @@ fun LiquidGlassPopupOverlay( .height(menuHeight) .clip(shape) .then( - if (backdrop != null) { + if (backdrop != null && LumaGlassRuntime.useNativeBackdrop) { Modifier.drawBackdrop( backdrop = backdrop, shape = { RoundedRectangle(cornerRadius) }, @@ -598,14 +484,13 @@ fun LiquidGlassPopupOverlay( ) vibrancy() blur(if (GlassTokens.isDark) 8.dp.toPx() else 16.dp.toPx()) - lens(18.dp.toPx(), 32.dp.toPx(), depthEffect = true) }, highlight = { Highlight.Plain }, shadow = { Shadow(alpha = 0.18f) }, onDrawSurface = { drawRect(surface) }, ) } else { - Modifier.background(surface) + Modifier.background(LumaColors.fallbackGlass) } ) .border(0.7.dp, border, shape) @@ -631,10 +516,12 @@ fun GlassPopupItem( tint: Color = Color(0xFF374151), onClick: () -> Unit, ) { + val interactionSource = remember { MutableInteractionSource() } Row( modifier = Modifier .fillMaxWidth() - .clickable { onClick() } + .heightIn(min = 48.dp) + .clickable(indication = null, interactionSource = interactionSource, onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -646,6 +533,27 @@ fun GlassPopupItem( } } +@Composable +fun GlassPopupItem( + text: String, + icon: LumaIcon, + tint: Color = LumaColors.text, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + LumaIconView(icon, contentDescription = null, tint = tint, modifier = Modifier.size(18.dp)) + Text(text = text, color = tint, fontSize = 14.sp, fontWeight = FontWeight.Medium) + } +} + @Composable fun LiquidGlassButton( onClick: () -> Unit, @@ -657,18 +565,29 @@ fun LiquidGlassButton( ) { val backdrop = LocalGlassBackdrop.current val shape = RoundedCornerShape(cornerRadius) + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.97f else 1f, + animationSpec = tween(LumaMotion.fast), + label = "liquidGlassButtonPress", + ) Row( modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + alpha = if (enabled) 1f else 0.52f + } .clip(shape) .then( - if (backdrop != null) { + if (backdrop != null && LumaGlassRuntime.useNativeBackdrop) { Modifier.drawBackdrop( backdrop = backdrop, shape = { RoundedRectangle(cornerRadius) }, effects = { vibrancy() blur(8.dp.toPx()) - lens(12.dp.toPx(), 12.dp.toPx()) }, onDrawSurface = { drawRect(fill) }, ) @@ -677,7 +596,7 @@ fun LiquidGlassButton( } ) .border(0.5.dp, GlassTokens.glassBorder, shape) - .clickable(enabled = enabled, onClick = onClick) + .clickable(enabled = enabled, indication = null, interactionSource = interactionSource, onClick = onClick) .padding(horizontal = 16.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, @@ -725,7 +644,6 @@ fun GlassDialog( val dialogCtx = LocalContext.current val localizedCtx = remember(AppLanguage.language) { AppLanguage.getLocalizedContext(dialogCtx) } val glassBackdrop = backdrop ?: LocalGlassBackdrop.current - val shape = RoundedCornerShape(24.dp) CompositionLocalProvider( LocalContext provides localizedCtx, LocalGlassBackdrop provides glassBackdrop, @@ -733,38 +651,13 @@ fun GlassDialog( Box( modifier .width(IntrinsicSize.Min) - .clip(shape) - .then( - if (glassBackdrop != null) { - Modifier.drawBackdrop( - backdrop = glassBackdrop, - shape = { RoundedRectangle(24.dp) }, - effects = { - colorControls( - brightness = if (GlassTokens.isDark) 0f else 0.08f, - saturation = 1.18f, - ) - vibrancy() - blur(if (GlassTokens.isDark) 10.dp.toPx() else 18.dp.toPx()) - lens(24.dp.toPx(), 42.dp.toPx(), depthEffect = true) - }, - highlight = { Highlight.Plain }, - shadow = { Shadow(alpha = 0.20f) }, - onDrawSurface = { - drawRect( - if (GlassTokens.isDark) { - Color(0xFF171A20).copy(alpha = 0.42f) - } else { - Color.White.copy(alpha = 0.24f) - } - ) - }, - ) - } else { - Modifier.background(GlassTokens.glassFillStrong) - } + .lumaGlass( + backdrop = glassBackdrop, + radius = 24.dp, + level = LumaGlassLevel.Strong, + borderColor = GlassTokens.glassBorder, + shadowElevation = 10.dp, ) - .border(0.7.dp, GlassTokens.glassBorder, shape) .padding(20.dp), ) { content() @@ -773,6 +666,80 @@ fun GlassDialog( } } +@Composable +fun GlassSheet( + onDismissRequest: () -> Unit, + backdrop: Backdrop? = null, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + val dialogCtx = LocalContext.current + val localizedCtx = remember(AppLanguage.language) { AppLanguage.getLocalizedContext(dialogCtx) } + val glassBackdrop = backdrop ?: LocalGlassBackdrop.current + CompositionLocalProvider( + LocalContext provides localizedCtx, + LocalGlassBackdrop provides glassBackdrop, + ) { + Box( + Modifier + .fillMaxSize() + .navigationBarsPadding(), + contentAlignment = Alignment.BottomCenter, + ) { + Column( + modifier + .fillMaxWidth() + .widthIn(max = 520.dp) + .lumaGlass( + backdrop = glassBackdrop, + radius = 28.dp, + level = LumaGlassLevel.Floating, + borderColor = GlassTokens.glassBorder, + shadowElevation = 12.dp, + ) + .padding(20.dp), + content = content, + ) + } + } + } +} + +@Composable +fun GlassConfirmDialog( + title: String, + message: String, + confirmText: String, + dismissText: String, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, + backdrop: Backdrop? = null, +) { + GlassDialog(backdrop = backdrop, onDismissRequest = onDismissRequest, modifier = Modifier.width(300.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(title, color = GlassTokens.text, fontSize = LumaTypography.title, fontWeight = FontWeight.Bold) + Text(message, color = GlassTokens.subtext, fontSize = LumaTypography.body, lineHeight = 21.sp) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + GlassButton( + text = dismissText, + onClick = onDismissRequest, + modifier = Modifier.weight(1f), + ) + GlassButton( + text = confirmText, + onClick = onConfirm, + modifier = Modifier.weight(1f), + danger = true, + ) + } + } + } +} + @Composable fun GlassInputField( label: String, @@ -788,16 +755,16 @@ fun GlassInputField( ) { val backdrop = LocalGlassBackdrop.current Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(label, fontSize = 13.sp, color = Color(0xFF6B7280), fontWeight = FontWeight.Medium) + Text(label, fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) val shape = RoundedCornerShape(12.dp) Box( Modifier.fillMaxWidth().clip(shape) .then( - if (backdrop != null) { + if (backdrop != null && LumaGlassRuntime.useNativeBackdrop) { Modifier.drawBackdrop( backdrop = backdrop, shape = { RoundedRectangle(12.dp) }, - effects = { vibrancy(); blur(8.dp.toPx()); lens(10.dp.toPx(), 18.dp.toPx()) }, + effects = { vibrancy(); blur(8.dp.toPx()) }, onDrawSurface = { drawRect(GlassTokens.glassFillSoft) }, ) } else { @@ -811,7 +778,7 @@ fun GlassInputField( value = value, onValueChange = onValueChange, singleLine = true, - textStyle = androidx.compose.ui.text.TextStyle(color = Color(0xFF111827), fontSize = 15.sp), + textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 15.sp), modifier = Modifier.fillMaxWidth().then( focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier ), @@ -819,7 +786,7 @@ fun GlassInputField( decorationBox = { inner -> Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Box(Modifier.weight(1f)) { - if (value.text.isEmpty()) Text(placeholder, color = Color(0xFF9CA3AF), fontSize = 15.sp) + if (value.text.isEmpty()) Text(placeholder, color = GlassTokens.subtext2, fontSize = 15.sp) inner() } if (trailingContent != null) { @@ -830,56 +797,12 @@ fun GlassInputField( } ) } - } -} - -@Composable -fun GlassSettingRow( - icon: ImageVector, - iconTint: Color, - bgTint: Color, - title: String, - subtitle: String = "", - onClick: () -> Unit = {}, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { 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.copy(alpha = 0.20f)) - .border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(11.dp)), - 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) - } - } + if (isError && !errorMessage.isNullOrBlank()) { + Text( + text = errorMessage, + color = GlassTokens.danger, + fontSize = LumaTypography.caption, + ) } - Icon( - imageVector = Icons.Outlined.ChevronRight, - contentDescription = null, - tint = Color(0xFF9CA3AF), - modifier = Modifier.size(20.dp), - ) } } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt new file mode 100644 index 0000000..9d3f877 --- /dev/null +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt @@ -0,0 +1,217 @@ +package com.example.myapplication.ui.design + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import top.yukonga.miuix.kmp.basic.Text + +@Composable +fun LumaAsyncImage( + model: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Crop, +) { + val fallback = ColorPainter(LumaColors.backgroundElevated) + AsyncImage( + model = model, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier, + placeholder = fallback, + error = fallback, + ) +} + +@Composable +fun LumaIconButton( + icon: LumaIcon, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false, + tint: Color = LumaColors.text, + radius: Dp = 18.dp, + level: LumaGlassLevel = LumaGlassLevel.Floating, + iconSize: Dp = 23.dp, + enabled: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.94f else 1f, + animationSpec = tween(LumaMotion.fast), + label = "lumaIconPress", + ) + Box( + modifier + .size(48.dp) + .graphicsLayer { scaleX = scale; scaleY = scale } + .lumaGlass(radius = radius, level = level) + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + LumaAnimatedIcon( + icon = icon, + selected = selected, + tint = tint, + contentDescription = contentDescription, + modifier = Modifier.size(iconSize), + ) + } +} + +@Composable +fun LumaSectionTitle( + title: String, + modifier: Modifier = Modifier, + action: String? = null, + onAction: (() -> Unit)? = null, +) { + Row( + modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(title, color = LumaColors.text, fontSize = LumaTypography.title, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) + if (action != null) { + Text( + action, + color = LumaColors.accent, + fontSize = LumaTypography.secondary, + modifier = Modifier.clickable(enabled = onAction != null, indication = null, interactionSource = remember { MutableInteractionSource() }) { onAction?.invoke() }, + ) + } + } +} + +@Composable +fun LumaLoading( + modifier: Modifier = Modifier, + label: String? = null, +) { + Column( + modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(LumaSpacing.sm), + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = LumaColors.accent, + strokeWidth = 2.5.dp, + ) + if (label != null) Text(label, color = LumaColors.textSecondary, fontSize = LumaTypography.secondary) + } +} + +@Composable +fun LumaEmptyState( + icon: LumaIcon, + title: String, + description: String, + modifier: Modifier = Modifier, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, +) { + Column( + modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(LumaSpacing.sm), + ) { + Box( + Modifier + .size(72.dp) + .lumaGlass(radius = 24.dp, level = LumaGlassLevel.Clear), + contentAlignment = Alignment.Center, + ) { + LumaIconView(icon, modifier = Modifier.size(30.dp), tint = LumaColors.textTertiary) + } + Text(title, color = LumaColors.text, fontSize = LumaTypography.body, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold) + Text(description, color = LumaColors.textSecondary, fontSize = LumaTypography.secondary) + if (actionLabel != null) { + Spacer(Modifier.size(LumaSpacing.xxs)) + Box( + Modifier + .heightIn(min = 48.dp) + .clickable( + enabled = onAction != null, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { onAction?.invoke() }, + contentAlignment = Alignment.Center, + ) { + Text(actionLabel, color = LumaColors.accent, fontSize = LumaTypography.secondary) + } + } + } +} + +@Composable +fun LumaErrorState( + title: String, + description: String, + retryLabel: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(LumaSpacing.sm), + ) { + Box( + Modifier + .size(72.dp) + .lumaGlass(radius = 24.dp, level = LumaGlassLevel.Clear), + contentAlignment = Alignment.Center, + ) { + LumaIconView(LumaIcon.Warning, modifier = Modifier.size(30.dp), tint = LumaColors.danger) + } + Text(title, color = LumaColors.text, fontSize = LumaTypography.body, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold) + Text(description, color = LumaColors.textSecondary, fontSize = LumaTypography.secondary) + Box( + Modifier + .heightIn(min = 48.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onRetry, + ) + .padding(horizontal = LumaSpacing.md), + contentAlignment = Alignment.Center, + ) { + Text(retryLabel, color = LumaColors.accent, fontSize = LumaTypography.secondary, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold) + } + } +} diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt new file mode 100644 index 0000000..0aff493 --- /dev/null +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt @@ -0,0 +1,387 @@ +package com.example.myapplication.ui.design + +import android.os.Build +import android.view.Choreographer +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.animation.core.CubicBezierEasing +import com.example.myapplication.ui.AppTheme +import com.example.myapplication.ui.LocalGlassBackdrop +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.LayerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.colorControls +import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.Shadow +import com.kyant.shapes.RoundedRectangle + +object LumaSpacing { + val xxs = 4.dp + val xs = 8.dp + val sm = 12.dp + val md = 16.dp + val lg = 20.dp + val xl = 24.dp + val xxl = 32.dp +} + +object LumaShapes { + val extraSmall = 10.dp + val small = 14.dp + val medium = 18.dp + val large = 24.dp + val extraLarge = 30.dp +} + +object LumaTypography { + val display: TextUnit = 32.sp + val titleLarge: TextUnit = 22.sp + val title: TextUnit = 18.sp + val body: TextUnit = 15.sp + val secondary: TextUnit = 13.sp + val caption: TextUnit = 11.sp +} + +object LumaMotion { + const val menu = 140 + const val icon = 240 + const val tab = 280 + const val dialog = 220 + const val fast = 180 + const val standard = 240 + const val page = 320 + const val pagePop = 280 + const val sheet = 300 + const val slow = 380 + + val enter = CubicBezierEasing(0.18f, 0.82f, 0.28f, 1f) + val exit = CubicBezierEasing(0.35f, 0f, 0.72f, 0.18f) + val native = CubicBezierEasing(0.2f, 0.75f, 0.25f, 1f) +} + +/** Kyant Backdrop uses RenderEffect/GraphicsLayer and is unstable on some emulator GPUs. */ +internal object LumaGlassRuntime { + val isBackdropSafe: Boolean by lazy { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return@lazy false + val fingerprint = Build.FINGERPRINT.lowercase() + val board = Build.BOARD.lowercase() + val hardware = Build.HARDWARE.lowercase() + val product = Build.PRODUCT.lowercase() + val device = Build.DEVICE.lowercase() + val model = Build.MODEL.lowercase() + val brand = Build.BRAND.lowercase() + val manufacturer = Build.MANUFACTURER.lowercase() + val isX86Emulator = Build.SUPPORTED_ABIS.any { it.contains("x86") } + !(isX86Emulator || + fingerprint.startsWith("generic") || + fingerprint.contains("emulator") || + board.contains("goldfish") || + board.contains("ranchu") || + board.contains("emulator") || + hardware.contains("goldfish") || + hardware.contains("ranchu") || + hardware.contains("vbox") || + product.contains("sdk_gphone") || + product.contains("emulator") || + device.contains("generic") || + device.contains("emulator") || + device.contains("emu") || + device.contains("vbox") || + model.contains("sdk_gphone") || + model.contains("emulator") || + model.contains("android sdk built for") || + brand == "generic" || + manufacturer.contains("genymotion")) + } + + private val frameRateState = mutableFloatStateOf(0f) + private var measuredSamples = 0 + private var frameCallback: Choreographer.FrameCallback? = null + private var sampleStartNanos = 0L + private var frameCount = 0 + + val useNativeBackdrop: Boolean + get() = isBackdropSafe && measuredSamples > 0 && frameRateState.floatValue >= 48f + + fun startFrameMonitor() { + if (frameCallback != null) return + sampleStartNanos = 0L + frameCount = 0 + measuredSamples = 0 + frameRateState.floatValue = 0f + + val callback = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + if (sampleStartNanos == 0L) { + sampleStartNanos = frameTimeNanos + } else { + frameCount++ + val elapsedNanos = frameTimeNanos - sampleStartNanos + if (elapsedNanos >= 500_000_000L) { + frameRateState.floatValue = + (frameCount * 1_000_000_000f / elapsedNanos.toFloat()).coerceIn(1f, 120f) + measuredSamples++ + sampleStartNanos = frameTimeNanos + frameCount = 0 + } + } + Choreographer.getInstance().postFrameCallback(this) + } + } + frameCallback = callback + Choreographer.getInstance().postFrameCallback(callback) + } + + fun stopFrameMonitor() { + frameCallback?.let(Choreographer.getInstance()::removeFrameCallback) + frameCallback = null + measuredSamples = 0 + frameRateState.floatValue = 0f + } +} + +@Composable +fun rememberLumaLayerBackdrop(): LayerBackdrop? { + return if (LumaGlassRuntime.useNativeBackdrop) rememberLayerBackdrop() else null +} + +object LumaColors { + val isDark: Boolean get() = AppTheme.isDark + + val background: Color + get() = if (isDark) Color(0xFF0B0E12) else Color(0xFFF5F6F8) + val backgroundElevated: Color + get() = if (isDark) Color(0xFF131820) else Color(0xFFEEF1F5) + val text: Color + get() = if (isDark) Color(0xFFF4F6FA) else Color(0xFF18202B) + val textSecondary: Color + get() = if (isDark) Color(0xFFA9B1BE) else Color(0xFF687382) + val textTertiary: Color + get() = if (isDark) Color(0xFF727C8B) else Color(0xFF98A2AF) + val accent: Color get() = Color(0xFF3B82F6) + val accentSoft: Color get() = accent.copy(alpha = if (isDark) 0.22f else 0.13f) + val success: Color get() = Color(0xFF32C36C) + val danger: Color get() = if (isDark) Color(0xFFFF6B6B) else Color(0xFFD9485F) + val divider: Color + get() = if (isDark) Color.White.copy(alpha = 0.10f) else Color(0xFF1C2733).copy(alpha = 0.10f) + + val clearTint: Color + get() = if (isDark) Color(0xFF18202A).copy(alpha = 0.28f) else Color.White.copy(alpha = 0.16f) + val regularTint: Color + get() = if (isDark) Color(0xFF19222D).copy(alpha = 0.50f) else Color.White.copy(alpha = 0.38f) + val strongTint: Color + get() = if (isDark) Color(0xFF202A36).copy(alpha = 0.68f) else Color.White.copy(alpha = 0.58f) + val floatingTint: Color + get() = if (isDark) Color(0xFF1B2430).copy(alpha = 0.62f) else Color.White.copy(alpha = 0.48f) + val glassBorder: Color + get() = if (isDark) Color.White.copy(alpha = 0.16f) else Color.White.copy(alpha = 0.56f) + val glassHighlight: Color + get() = if (isDark) Color.White.copy(alpha = 0.07f) else Color.White.copy(alpha = 0.40f) + val glassShadow: Color + get() = if (isDark) Color.Black.copy(alpha = 0.32f) else Color(0xFF344054).copy(alpha = 0.10f) + + /** Stable tint used when native backdrop sampling is disabled on an emulator or old API. */ + val fallbackGlass: Color + get() = if (isDark) Color(0xFF1A2330).copy(alpha = 0.86f) else Color(0xFFE4E9F0).copy(alpha = 0.86f) +} + +/** Stable frosted fallback for devices where Kyant Backdrop is disabled. */ +fun Modifier.lumaFallbackGlass( + shape: Shape, + selected: Boolean = false, +): Modifier { + val light = !LumaColors.isDark + val top = if (selected) { + if (light) Color.White.copy(alpha = 0.34f) else Color.White.copy(alpha = 0.15f) + } else { + if (light) Color.White.copy(alpha = 0.24f) else Color.White.copy(alpha = 0.08f) + } + val bottom = if (light) { + Color(0xFFB8C5D5).copy(alpha = 0.14f) + } else { + Color(0xFF121B27).copy(alpha = 0.46f) + } + val edge = if (selected) { + LumaColors.glassHighlight.copy(alpha = if (light) 0.34f else 0.26f) + } else { + LumaColors.glassHighlight.copy(alpha = if (light) 0.18f else 0.14f) + } + val fill = if (selected) { + if (light) { + Brush.horizontalGradient( + listOf( + LumaColors.accent.copy(alpha = 0.24f), + Color.White.copy(alpha = 0.36f), + Color.White.copy(alpha = 0.22f), + LumaColors.accent.copy(alpha = 0.12f), + ), + ) + } else { + Brush.horizontalGradient( + listOf( + LumaColors.accent.copy(alpha = 0.28f), + Color.White.copy(alpha = 0.16f), + Color.White.copy(alpha = 0.08f), + LumaColors.accent.copy(alpha = 0.18f), + ), + ) + } + } else { + Brush.verticalGradient(listOf(top, bottom)) + } + return this + .background(fill, shape) + .then( + if (selected) { + Modifier.drawWithCache { + val leftGlow = Brush.radialGradient( + colors = listOf( + LumaColors.accent.copy(alpha = if (light) 0.22f else 0.26f), + LumaColors.accent.copy(alpha = 0.06f), + Color.Transparent, + ), + center = Offset(size.width * 0.14f, size.height * 0.18f), + radius = size.height * 1.25f, + ) + val rightGlow = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = if (light) 0.22f else 0.12f), + Color.Transparent, + ), + center = Offset(size.width * 0.86f, size.height * 0.22f), + radius = size.height * 0.92f, + ) + onDrawWithContent { + drawCircle(leftGlow, center = Offset(size.width * 0.14f, size.height * 0.18f), radius = size.height * 1.25f) + drawCircle(rightGlow, center = Offset(size.width * 0.86f, size.height * 0.22f), radius = size.height * 0.92f) + drawContent() + } + } + } else { + Modifier + }, + ) + .border(0.6.dp, edge, shape) +} + +enum class LumaGlassLevel { + Clear, + Regular, + Strong, + Floating, + Interactive, +} + +private data class GlassSpec( + val tint: Color, + val blur: Dp, + val shadowAlpha: Float, +) + +private fun GlassSpec.forLevel(level: LumaGlassLevel): GlassSpec = when (level) { + LumaGlassLevel.Clear -> this.copy(tint = LumaColors.clearTint, blur = 5.dp) + LumaGlassLevel.Regular -> this.copy(tint = LumaColors.regularTint, blur = 8.dp) + LumaGlassLevel.Strong -> this.copy(tint = LumaColors.strongTint, blur = 12.dp, shadowAlpha = 0.16f) + LumaGlassLevel.Floating -> this.copy(tint = LumaColors.floatingTint, blur = 10.dp, shadowAlpha = 0.18f) + LumaGlassLevel.Interactive -> this.copy(tint = LumaColors.floatingTint, blur = 11.dp, shadowAlpha = 0.20f) +} + +@Composable +fun Modifier.lumaGlass( + backdrop: Backdrop? = LocalGlassBackdrop.current, + radius: Dp = LumaShapes.medium, + level: LumaGlassLevel = LumaGlassLevel.Regular, + border: Boolean = true, + tint: Color? = null, + borderColor: Color? = null, + shadowElevation: Dp = 8.dp, + highlight: Boolean = true, +): Modifier { + val spec = remember(AppTheme.isDark, level) { + GlassSpec( + tint = LumaColors.regularTint, + blur = 8.dp, + shadowAlpha = 0.10f, + ).forLevel(level) + } + val shape = RoundedCornerShape(radius) + val surfaceTint = tint ?: spec.tint + val usesDefaultGlassTint = tint == null || tint == LumaColors.clearTint || + tint == LumaColors.regularTint || tint == LumaColors.strongTint || tint == LumaColors.floatingTint + val fallbackSurface = if (usesDefaultGlassTint) LumaColors.fallbackGlass else surfaceTint + val edgeColor = borderColor ?: LumaColors.glassBorder + val safeBackdrop = backdrop.takeIf { LumaGlassRuntime.useNativeBackdrop } + return this + .shadow(shadowElevation, shape, ambientColor = LumaColors.glassShadow, spotColor = LumaColors.glassShadow) + .clip(shape) + .then( + if (safeBackdrop != null) { + Modifier.drawBackdrop( + backdrop = safeBackdrop, + shape = { RoundedRectangle(radius) }, + effects = { + colorControls( + brightness = if (LumaColors.isDark) 0f else 0.04f, + saturation = 1.08f, + ) + vibrancy() + blur(spec.blur.toPx()) + }, + highlight = if (highlight) ({ Highlight.Plain.copy(alpha = 0.75f) }) else null, + shadow = { Shadow(alpha = spec.shadowAlpha) }, + onDrawSurface = { drawRect(surfaceTint) }, + ) + } else { + Modifier.background(fallbackSurface) + }, + ) + .then(if (border) Modifier.border(0.6.dp, edgeColor, shape) else Modifier) +} + +@Composable +fun LumaGlassSurface( + modifier: Modifier = Modifier, + backdrop: Backdrop? = LocalGlassBackdrop.current, + radius: Dp = LumaShapes.medium, + level: LumaGlassLevel = LumaGlassLevel.Regular, + content: @Composable () -> Unit, +) { + Box(modifier.lumaGlass(backdrop = backdrop, radius = radius, level = level), content = { content() }) +} + +@Composable +fun LumaPageBackground(modifier: Modifier = Modifier) { + Box( + modifier + .background( + Brush.linearGradient( + listOf( + LumaColors.background, + LumaColors.backgroundElevated, + LumaColors.background, + ), + ), + ), + ) +} diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt new file mode 100644 index 0000000..8b8a053 --- /dev/null +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt @@ -0,0 +1,138 @@ +package com.example.myapplication.ui.design + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import com.example.myapplication.R + +enum class LumaIcon( + val regular: Int, + val selected: Int, +) { + Activity(R.drawable.aurora_activity, R.drawable.aurora_activity_selected), + Home(R.drawable.aurora_home, R.drawable.aurora_home_selected), + Search(R.drawable.aurora_search, R.drawable.aurora_search_selected), + Library(R.drawable.aurora_library, R.drawable.aurora_library_selected), + Discover(R.drawable.aurora_discover, R.drawable.aurora_discover_selected), + Media(R.drawable.aurora_media, R.drawable.aurora_media_selected), + Watch(R.drawable.aurora_watch, R.drawable.aurora_watch_selected), + Resources(R.drawable.aurora_resources, R.drawable.aurora_resources_selected), + Settings(R.drawable.aurora_settings, R.drawable.aurora_settings_selected), + Favorite(R.drawable.aurora_favorite, R.drawable.aurora_favorite_selected), + Play(R.drawable.aurora_play, R.drawable.aurora_play_selected), + Pause(R.drawable.aurora_pause, R.drawable.aurora_pause_selected), + Stop(R.drawable.aurora_stop, R.drawable.aurora_stop_selected), + Previous(R.drawable.aurora_previous, R.drawable.aurora_previous_selected), + Next(R.drawable.aurora_next, R.drawable.aurora_next_selected), + Replay(R.drawable.aurora_replay, R.drawable.aurora_replay_selected), + Shuffle(R.drawable.aurora_shuffle, R.drawable.aurora_shuffle_selected), + Speed(R.drawable.aurora_speed, R.drawable.aurora_speed_selected), + Folder(R.drawable.aurora_folder, R.drawable.aurora_folder_selected), + Collection(R.drawable.aurora_collection, R.drawable.aurora_collection_selected), + Playlist(R.drawable.aurora_playlist, R.drawable.aurora_playlist_selected), + History(R.drawable.aurora_history, R.drawable.aurora_history_selected), + Download(R.drawable.aurora_download, R.drawable.aurora_download_selected), + Upload(R.drawable.aurora_upload, R.drawable.aurora_upload_selected), + Subtitle(R.drawable.aurora_subtitle, R.drawable.aurora_subtitle_selected), + Server(R.drawable.aurora_server, R.drawable.aurora_server_selected), + Users(R.drawable.aurora_users, R.drawable.aurora_users_selected), + Badge(R.drawable.aurora_badge, R.drawable.aurora_badge_selected), + User(R.drawable.aurora_user, R.drawable.aurora_user_selected), + ProfileEdit(R.drawable.aurora_profile_edit, R.drawable.aurora_profile_edit_selected), + SwitchAccount(R.drawable.aurora_switch_account, R.drawable.aurora_switch_account_selected), + Login(R.drawable.aurora_login, R.drawable.aurora_login_selected), + Logout(R.drawable.aurora_logout, R.drawable.aurora_logout_selected), + Qr(R.drawable.aurora_qr, R.drawable.aurora_qr_selected), + Database(R.drawable.aurora_database, R.drawable.aurora_database_selected), + Cloud(R.drawable.aurora_cloud, R.drawable.aurora_cloud_selected), + Network(R.drawable.aurora_network, R.drawable.aurora_network_selected), + Globe(R.drawable.aurora_globe, R.drawable.aurora_globe_selected), + Shield(R.drawable.aurora_shield, R.drawable.aurora_shield_selected), + Key(R.drawable.aurora_key, R.drawable.aurora_key_selected), + Phone(R.drawable.aurora_phone, R.drawable.aurora_phone_selected), + Tablet(R.drawable.aurora_tablet, R.drawable.aurora_tablet_selected), + Laptop(R.drawable.aurora_laptop, R.drawable.aurora_laptop_selected), + Tv(R.drawable.aurora_tv, R.drawable.aurora_tv_selected), + Cast(R.drawable.aurora_cast, R.drawable.aurora_cast_selected), + Speaker(R.drawable.aurora_speaker, R.drawable.aurora_speaker_selected), + Headphones(R.drawable.aurora_headphones, R.drawable.aurora_headphones_selected), + Remote(R.drawable.aurora_remote, R.drawable.aurora_remote_selected), + Add(R.drawable.aurora_add, R.drawable.aurora_add_selected), + Remove(R.drawable.aurora_remove, R.drawable.aurora_remove_selected), + Edit(R.drawable.aurora_edit, R.drawable.aurora_edit_selected), + Delete(R.drawable.aurora_delete, R.drawable.aurora_delete_selected), + Refresh(R.drawable.aurora_refresh, R.drawable.aurora_refresh_selected), + Filter(R.drawable.aurora_filter, R.drawable.aurora_filter_selected), + Sort(R.drawable.aurora_sort, R.drawable.aurora_sort_selected), + Share(R.drawable.aurora_share, R.drawable.aurora_share_selected), + Info(R.drawable.aurora_info, R.drawable.aurora_info_selected), + Warning(R.drawable.aurora_warning, R.drawable.aurora_warning_selected), + Notification(R.drawable.aurora_notification, R.drawable.aurora_notification_selected), + Lock(R.drawable.aurora_lock, R.drawable.aurora_lock_selected), + Unlock(R.drawable.aurora_unlock, R.drawable.aurora_unlock_selected), + Eye(R.drawable.aurora_eye, R.drawable.aurora_eye_selected), + File(R.drawable.aurora_file, R.drawable.aurora_file_selected), + FileVideo(R.drawable.aurora_file_video, R.drawable.aurora_file_video_selected), + FileAudio(R.drawable.aurora_file_audio, R.drawable.aurora_file_audio_selected), + Image(R.drawable.aurora_image, R.drawable.aurora_image_selected), + Archive(R.drawable.aurora_archive, R.drawable.aurora_archive_selected), + Link(R.drawable.aurora_link, R.drawable.aurora_link_selected), + Attachment(R.drawable.aurora_attachment, R.drawable.aurora_attachment_selected), + Calendar(R.drawable.aurora_calendar, R.drawable.aurora_calendar_selected), + Check(R.drawable.aurora_check, R.drawable.aurora_check_selected), + Close(R.drawable.aurora_close, R.drawable.aurora_close_selected), +} + +@Composable +fun LumaIconView( + icon: LumaIcon, + modifier: Modifier = Modifier, + selected: Boolean = false, + tint: Color = Color.Unspecified, + contentDescription: String? = null, +) { + Icon( + painter = painterResource(if (selected) icon.selected else icon.regular), + contentDescription = contentDescription, + tint = tint, + modifier = modifier.graphicsLayer { + scaleX = 1.08f + scaleY = 1.08f + }, + ) +} + +@Composable +fun LumaAnimatedIcon( + icon: LumaIcon, + selected: Boolean, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = Color.Unspecified, +) { + val scale by animateFloatAsState( + targetValue = if (selected) 1.02f else 1.08f, + animationSpec = spring(dampingRatio = 0.78f, stiffness = 650f), + label = "lumaIconStateScale", + ) + Crossfade( + targetState = selected, + animationSpec = tween(LumaMotion.icon), + label = "lumaIconState", + modifier = modifier.graphicsLayer { scaleX = scale; scaleY = scale }, + ) { isSelected -> + Icon( + painter = painterResource(if (isSelected) icon.selected else icon.regular), + contentDescription = contentDescription, + tint = tint, + ) + } +} 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 index 788ff51..654e03a 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/InteractiveHighlight.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/InteractiveHighlight.kt @@ -1,7 +1,5 @@ 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 @@ -11,9 +9,8 @@ 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.Brush 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 @@ -38,55 +35,30 @@ class InteractiveHighlight( 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 + drawRect(Color.White.copy(0.08f * progress), blendMode = BlendMode.Plus) + val center = position(size, positionAnimation.value).let { + Offset( + it.x.fastCoerceIn(0f, size.width), + it.y.fastCoerceIn(0f, size.height), ) } + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.15f * progress), + Color.Transparent, + ), + center = center, + radius = size.minDimension * 1.5f, + ), + center = center, + radius = size.minDimension * 1.5f, + blendMode = BlendMode.Plus, + ) } drawContent() 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 index 9f3df6b..5007e98 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTab.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTab.kt @@ -29,11 +29,17 @@ fun RowScope.LiquidBottomTab( Column( modifier .clip(Capsule()) - .clickable( - interactionSource = null, - indication = null, - role = Role.Tab, - onClick = onClick + .then( + if (LocalLiquidBottomTabsGhostLayer.current) { + Modifier + } else { + Modifier.clickable( + interactionSource = null, + indication = null, + role = Role.Tab, + onClick = onClick, + ) + }, ) .fillMaxHeight() .weight(1f) diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt index 07b8769..445573d 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt @@ -3,7 +3,6 @@ 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 @@ -11,6 +10,7 @@ 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.foundation.border import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -25,6 +25,7 @@ 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.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter @@ -40,7 +41,6 @@ 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 @@ -49,6 +49,12 @@ 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.example.myapplication.ui.design.LumaColors +import com.example.myapplication.ui.design.LumaGlassRuntime +import com.example.myapplication.ui.design.LumaShapes +import com.example.myapplication.ui.design.LumaSpacing +import com.example.myapplication.ui.design.lumaFallbackGlass +import com.example.myapplication.ui.design.rememberLumaLayerBackdrop import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch @@ -65,18 +71,18 @@ val LocalLiquidBottomTabsPillValue = staticCompositionLocalOf { 0f } fun LiquidBottomTabs( selectedTabIndex: () -> Int, onTabSelected: (index: Int) -> Unit, - backdrop: Backdrop, + backdrop: Backdrop?, tabsCount: Int, + showSelection: () -> Boolean = { true }, modifier: Modifier = Modifier, - accentColor: Color = if (!isSystemInDarkTheme()) Color(0xFF0A84FF) else Color(0xFF0091FF), + accentColor: Color = LumaColors.accent, content: @Composable RowScope.() -> Unit ) { - val isLightTheme = !isSystemInDarkTheme() - val containerColor = - if (isLightTheme) Color(0xFFFAFAFA).copy(0.4f) - else Color(0xFF121212).copy(0.4f) + val isLightTheme = !LumaColors.isDark + val containerColor = LumaColors.clearTint + val useBackdrop = LumaGlassRuntime.useNativeBackdrop && backdrop != null - val tabsBackdrop = rememberLayerBackdrop() + val tabsBackdrop = rememberLumaLayerBackdrop() BoxWithConstraints( modifier, @@ -100,12 +106,12 @@ fun LiquidBottomTabs( val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val animationScope = rememberCoroutineScope() var currentIndex by remember { - mutableIntStateOf(selectedTabIndex()) + mutableIntStateOf(selectedTabIndex().fastCoerceIn(0, tabsCount - 1)) } val dampedDragAnimation = remember(animationScope) { DampedDragAnimation( animationScope = animationScope, - initialValue = selectedTabIndex().toFloat(), + initialValue = selectedTabIndex().fastCoerceIn(0, tabsCount - 1).toFloat(), valueRange = 0f..(tabsCount - 1).toFloat(), visibilityThreshold = 0.001f, initialScale = 1f, @@ -133,7 +139,7 @@ fun LiquidBottomTabs( } ) } - val externalIndex = selectedTabIndex() + val externalIndex = selectedTabIndex().fastCoerceIn(0, tabsCount - 1) LaunchedEffect(externalIndex) { if (externalIndex != currentIndex) { currentIndex = externalIndex @@ -167,26 +173,40 @@ fun LiquidBottomTabs( .graphicsLayer { translationX = panelOffset } - .drawBackdrop( - backdrop = backdrop, - shape = { Capsule() }, - effects = { - vibrancy() - blur(8f.dp.toPx()) - lens(24f.dp.toPx(), 24f.dp.toPx()) + .clip(Capsule()) + .then( + if (useBackdrop) { + Modifier.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) }, + ) + } else { + Modifier.lumaFallbackGlass(Capsule()) }, - layerBlock = { - val progress = dampedDragAnimation.pressProgress - val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress) - scaleX = scale - scaleY = scale + ) + .then( + if (useBackdrop) { + Modifier.border(0.6.dp, LumaColors.glassBorder.copy(alpha = 0.72f), Capsule()) + } else { + Modifier }, - onDrawSurface = { drawRect(containerColor) } ) .then(interactiveHighlight.modifier) .height(64f.dp) .fillMaxWidth() - .padding(4f.dp), + .padding(LumaSpacing.xxs), verticalAlignment = Alignment.CenterVertically, content = { CompositionLocalProvider( @@ -207,27 +227,34 @@ fun LiquidBottomTabs( Modifier .clearAndSetSemantics {} .alpha(0f) - .layerBackdrop(tabsBackdrop) + .clip(Capsule()) + .then(if (useBackdrop && tabsBackdrop != null) Modifier.layerBackdrop(tabsBackdrop) else Modifier) .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 + .then( + if (useBackdrop) { + Modifier.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.Plain.copy(alpha = progress) + }, + onDrawSurface = { drawRect(containerColor) }, ) + } else { + Modifier }, - highlight = { - val progress = dampedDragAnimation.pressProgress - Highlight.Default.copy(alpha = progress) - }, - onDrawSurface = { drawRect(containerColor) } ) .then(interactiveHighlight.modifier) .height(56f.dp) @@ -246,6 +273,7 @@ fun LiquidBottomTabs( Box( Modifier .padding(horizontal = 4f.dp) + .clip(Capsule()) .graphicsLayer { translationX = if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset @@ -253,52 +281,58 @@ fun LiquidBottomTabs( } .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 + .then( + if (useBackdrop) { + 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.Plain.copy(alpha = progress) + }, + shadow = { + val progress = dampedDragAnimation.pressProgress + Shadow(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)) + }, ) + } else { + Modifier.lumaFallbackGlass(Capsule(), selected = true) }, - highlight = { - val progress = dampedDragAnimation.pressProgress - Highlight.Default.copy(alpha = progress) - }, - shadow = { - val progress = dampedDragAnimation.pressProgress - Shadow(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) + .alpha(if (showSelection()) 1f else 0f) ) } } - diff --git a/app/src/main/res/drawable/aurora_activity.xml b/app/src/main/res/drawable/aurora_activity.xml new file mode 100644 index 0000000..56534a7 --- /dev/null +++ b/app/src/main/res/drawable/aurora_activity.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_activity_selected.xml b/app/src/main/res/drawable/aurora_activity_selected.xml new file mode 100644 index 0000000..2757b7b --- /dev/null +++ b/app/src/main/res/drawable/aurora_activity_selected.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_add.xml b/app/src/main/res/drawable/aurora_add.xml new file mode 100644 index 0000000..bad83d9 --- /dev/null +++ b/app/src/main/res/drawable/aurora_add.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_add_selected.xml b/app/src/main/res/drawable/aurora_add_selected.xml new file mode 100644 index 0000000..a73a2e9 --- /dev/null +++ b/app/src/main/res/drawable/aurora_add_selected.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_archive.xml b/app/src/main/res/drawable/aurora_archive.xml new file mode 100644 index 0000000..f0cf280 --- /dev/null +++ b/app/src/main/res/drawable/aurora_archive.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_archive_selected.xml b/app/src/main/res/drawable/aurora_archive_selected.xml new file mode 100644 index 0000000..83da83b --- /dev/null +++ b/app/src/main/res/drawable/aurora_archive_selected.xml @@ -0,0 +1,27 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_attachment.xml b/app/src/main/res/drawable/aurora_attachment.xml new file mode 100644 index 0000000..9583a42 --- /dev/null +++ b/app/src/main/res/drawable/aurora_attachment.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_attachment_selected.xml b/app/src/main/res/drawable/aurora_attachment_selected.xml new file mode 100644 index 0000000..72ce08d --- /dev/null +++ b/app/src/main/res/drawable/aurora_attachment_selected.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_badge.xml b/app/src/main/res/drawable/aurora_badge.xml new file mode 100644 index 0000000..45d81c5 --- /dev/null +++ b/app/src/main/res/drawable/aurora_badge.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_badge_selected.xml b/app/src/main/res/drawable/aurora_badge_selected.xml new file mode 100644 index 0000000..f370a14 --- /dev/null +++ b/app/src/main/res/drawable/aurora_badge_selected.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_calendar.xml b/app/src/main/res/drawable/aurora_calendar.xml new file mode 100644 index 0000000..e603e42 --- /dev/null +++ b/app/src/main/res/drawable/aurora_calendar.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_calendar_selected.xml b/app/src/main/res/drawable/aurora_calendar_selected.xml new file mode 100644 index 0000000..c76a66c --- /dev/null +++ b/app/src/main/res/drawable/aurora_calendar_selected.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_cast.xml b/app/src/main/res/drawable/aurora_cast.xml new file mode 100644 index 0000000..e0983f3 --- /dev/null +++ b/app/src/main/res/drawable/aurora_cast.xml @@ -0,0 +1,31 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_cast_selected.xml b/app/src/main/res/drawable/aurora_cast_selected.xml new file mode 100644 index 0000000..d1d0970 --- /dev/null +++ b/app/src/main/res/drawable/aurora_cast_selected.xml @@ -0,0 +1,31 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_check.xml b/app/src/main/res/drawable/aurora_check.xml new file mode 100644 index 0000000..3c221b1 --- /dev/null +++ b/app/src/main/res/drawable/aurora_check.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_check_selected.xml b/app/src/main/res/drawable/aurora_check_selected.xml new file mode 100644 index 0000000..4718d3a --- /dev/null +++ b/app/src/main/res/drawable/aurora_check_selected.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_close.xml b/app/src/main/res/drawable/aurora_close.xml new file mode 100644 index 0000000..afaf864 --- /dev/null +++ b/app/src/main/res/drawable/aurora_close.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_close_selected.xml b/app/src/main/res/drawable/aurora_close_selected.xml new file mode 100644 index 0000000..e0af5e5 --- /dev/null +++ b/app/src/main/res/drawable/aurora_close_selected.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_cloud.xml b/app/src/main/res/drawable/aurora_cloud.xml new file mode 100644 index 0000000..933cbb5 --- /dev/null +++ b/app/src/main/res/drawable/aurora_cloud.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_cloud_selected.xml b/app/src/main/res/drawable/aurora_cloud_selected.xml new file mode 100644 index 0000000..ae7ab91 --- /dev/null +++ b/app/src/main/res/drawable/aurora_cloud_selected.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_collection.xml b/app/src/main/res/drawable/aurora_collection.xml new file mode 100644 index 0000000..0a34ef0 --- /dev/null +++ b/app/src/main/res/drawable/aurora_collection.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_collection_selected.xml b/app/src/main/res/drawable/aurora_collection_selected.xml new file mode 100644 index 0000000..ad19e55 --- /dev/null +++ b/app/src/main/res/drawable/aurora_collection_selected.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_database.xml b/app/src/main/res/drawable/aurora_database.xml new file mode 100644 index 0000000..fc58024 --- /dev/null +++ b/app/src/main/res/drawable/aurora_database.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_database_selected.xml b/app/src/main/res/drawable/aurora_database_selected.xml new file mode 100644 index 0000000..b7136e9 --- /dev/null +++ b/app/src/main/res/drawable/aurora_database_selected.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_delete.xml b/app/src/main/res/drawable/aurora_delete.xml new file mode 100644 index 0000000..06756e2 --- /dev/null +++ b/app/src/main/res/drawable/aurora_delete.xml @@ -0,0 +1,42 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_delete_selected.xml b/app/src/main/res/drawable/aurora_delete_selected.xml new file mode 100644 index 0000000..0909f84 --- /dev/null +++ b/app/src/main/res/drawable/aurora_delete_selected.xml @@ -0,0 +1,38 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_discover.xml b/app/src/main/res/drawable/aurora_discover.xml new file mode 100644 index 0000000..cad8997 --- /dev/null +++ b/app/src/main/res/drawable/aurora_discover.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_discover_selected.xml b/app/src/main/res/drawable/aurora_discover_selected.xml new file mode 100644 index 0000000..ef04730 --- /dev/null +++ b/app/src/main/res/drawable/aurora_discover_selected.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_download.xml b/app/src/main/res/drawable/aurora_download.xml new file mode 100644 index 0000000..c67af1f --- /dev/null +++ b/app/src/main/res/drawable/aurora_download.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_download_selected.xml b/app/src/main/res/drawable/aurora_download_selected.xml new file mode 100644 index 0000000..c40ae13 --- /dev/null +++ b/app/src/main/res/drawable/aurora_download_selected.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_edit.xml b/app/src/main/res/drawable/aurora_edit.xml new file mode 100644 index 0000000..a59550d --- /dev/null +++ b/app/src/main/res/drawable/aurora_edit.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_edit_selected.xml b/app/src/main/res/drawable/aurora_edit_selected.xml new file mode 100644 index 0000000..b7f6292 --- /dev/null +++ b/app/src/main/res/drawable/aurora_edit_selected.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_eye.xml b/app/src/main/res/drawable/aurora_eye.xml new file mode 100644 index 0000000..b514b5a --- /dev/null +++ b/app/src/main/res/drawable/aurora_eye.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_eye_selected.xml b/app/src/main/res/drawable/aurora_eye_selected.xml new file mode 100644 index 0000000..9d7c59d --- /dev/null +++ b/app/src/main/res/drawable/aurora_eye_selected.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_favorite.xml b/app/src/main/res/drawable/aurora_favorite.xml new file mode 100644 index 0000000..eb35c49 --- /dev/null +++ b/app/src/main/res/drawable/aurora_favorite.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_favorite_selected.xml b/app/src/main/res/drawable/aurora_favorite_selected.xml new file mode 100644 index 0000000..847421b --- /dev/null +++ b/app/src/main/res/drawable/aurora_favorite_selected.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_file.xml b/app/src/main/res/drawable/aurora_file.xml new file mode 100644 index 0000000..59bd947 --- /dev/null +++ b/app/src/main/res/drawable/aurora_file.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_file_audio.xml b/app/src/main/res/drawable/aurora_file_audio.xml new file mode 100644 index 0000000..54b258b --- /dev/null +++ b/app/src/main/res/drawable/aurora_file_audio.xml @@ -0,0 +1,38 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_file_audio_selected.xml b/app/src/main/res/drawable/aurora_file_audio_selected.xml new file mode 100644 index 0000000..f7ee644 --- /dev/null +++ b/app/src/main/res/drawable/aurora_file_audio_selected.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_file_selected.xml b/app/src/main/res/drawable/aurora_file_selected.xml new file mode 100644 index 0000000..6a67ffb --- /dev/null +++ b/app/src/main/res/drawable/aurora_file_selected.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_file_video.xml b/app/src/main/res/drawable/aurora_file_video.xml new file mode 100644 index 0000000..a932b79 --- /dev/null +++ b/app/src/main/res/drawable/aurora_file_video.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_file_video_selected.xml b/app/src/main/res/drawable/aurora_file_video_selected.xml new file mode 100644 index 0000000..53d94f5 --- /dev/null +++ b/app/src/main/res/drawable/aurora_file_video_selected.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_filter.xml b/app/src/main/res/drawable/aurora_filter.xml new file mode 100644 index 0000000..2fee3b8 --- /dev/null +++ b/app/src/main/res/drawable/aurora_filter.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_filter_selected.xml b/app/src/main/res/drawable/aurora_filter_selected.xml new file mode 100644 index 0000000..bb89675 --- /dev/null +++ b/app/src/main/res/drawable/aurora_filter_selected.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_folder.xml b/app/src/main/res/drawable/aurora_folder.xml new file mode 100644 index 0000000..051065a --- /dev/null +++ b/app/src/main/res/drawable/aurora_folder.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_folder_selected.xml b/app/src/main/res/drawable/aurora_folder_selected.xml new file mode 100644 index 0000000..04dd132 --- /dev/null +++ b/app/src/main/res/drawable/aurora_folder_selected.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_globe.xml b/app/src/main/res/drawable/aurora_globe.xml new file mode 100644 index 0000000..9547ef1 --- /dev/null +++ b/app/src/main/res/drawable/aurora_globe.xml @@ -0,0 +1,42 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_globe_selected.xml b/app/src/main/res/drawable/aurora_globe_selected.xml new file mode 100644 index 0000000..631f963 --- /dev/null +++ b/app/src/main/res/drawable/aurora_globe_selected.xml @@ -0,0 +1,42 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_headphones.xml b/app/src/main/res/drawable/aurora_headphones.xml new file mode 100644 index 0000000..f9c170a --- /dev/null +++ b/app/src/main/res/drawable/aurora_headphones.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_headphones_selected.xml b/app/src/main/res/drawable/aurora_headphones_selected.xml new file mode 100644 index 0000000..6d22917 --- /dev/null +++ b/app/src/main/res/drawable/aurora_headphones_selected.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_history.xml b/app/src/main/res/drawable/aurora_history.xml new file mode 100644 index 0000000..1d8efb3 --- /dev/null +++ b/app/src/main/res/drawable/aurora_history.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_history_selected.xml b/app/src/main/res/drawable/aurora_history_selected.xml new file mode 100644 index 0000000..842b2cc --- /dev/null +++ b/app/src/main/res/drawable/aurora_history_selected.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_home.xml b/app/src/main/res/drawable/aurora_home.xml new file mode 100644 index 0000000..3154c25 --- /dev/null +++ b/app/src/main/res/drawable/aurora_home.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_home_selected.xml b/app/src/main/res/drawable/aurora_home_selected.xml new file mode 100644 index 0000000..2f5bb39 --- /dev/null +++ b/app/src/main/res/drawable/aurora_home_selected.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_image.xml b/app/src/main/res/drawable/aurora_image.xml new file mode 100644 index 0000000..65f75d2 --- /dev/null +++ b/app/src/main/res/drawable/aurora_image.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_image_selected.xml b/app/src/main/res/drawable/aurora_image_selected.xml new file mode 100644 index 0000000..c24eb66 --- /dev/null +++ b/app/src/main/res/drawable/aurora_image_selected.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_info.xml b/app/src/main/res/drawable/aurora_info.xml new file mode 100644 index 0000000..e568268 --- /dev/null +++ b/app/src/main/res/drawable/aurora_info.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_info_selected.xml b/app/src/main/res/drawable/aurora_info_selected.xml new file mode 100644 index 0000000..0efa412 --- /dev/null +++ b/app/src/main/res/drawable/aurora_info_selected.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_key.xml b/app/src/main/res/drawable/aurora_key.xml new file mode 100644 index 0000000..74313c9 --- /dev/null +++ b/app/src/main/res/drawable/aurora_key.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_key_selected.xml b/app/src/main/res/drawable/aurora_key_selected.xml new file mode 100644 index 0000000..1e0ea8e --- /dev/null +++ b/app/src/main/res/drawable/aurora_key_selected.xml @@ -0,0 +1,31 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_laptop.xml b/app/src/main/res/drawable/aurora_laptop.xml new file mode 100644 index 0000000..f2abd99 --- /dev/null +++ b/app/src/main/res/drawable/aurora_laptop.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_laptop_selected.xml b/app/src/main/res/drawable/aurora_laptop_selected.xml new file mode 100644 index 0000000..f823ba7 --- /dev/null +++ b/app/src/main/res/drawable/aurora_laptop_selected.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_library.xml b/app/src/main/res/drawable/aurora_library.xml new file mode 100644 index 0000000..eec2bf6 --- /dev/null +++ b/app/src/main/res/drawable/aurora_library.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_library_selected.xml b/app/src/main/res/drawable/aurora_library_selected.xml new file mode 100644 index 0000000..90cb334 --- /dev/null +++ b/app/src/main/res/drawable/aurora_library_selected.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_link.xml b/app/src/main/res/drawable/aurora_link.xml new file mode 100644 index 0000000..3f4b3fa --- /dev/null +++ b/app/src/main/res/drawable/aurora_link.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_link_selected.xml b/app/src/main/res/drawable/aurora_link_selected.xml new file mode 100644 index 0000000..f3f5e57 --- /dev/null +++ b/app/src/main/res/drawable/aurora_link_selected.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_lock.xml b/app/src/main/res/drawable/aurora_lock.xml new file mode 100644 index 0000000..bf8da25 --- /dev/null +++ b/app/src/main/res/drawable/aurora_lock.xml @@ -0,0 +1,31 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_lock_selected.xml b/app/src/main/res/drawable/aurora_lock_selected.xml new file mode 100644 index 0000000..725546e --- /dev/null +++ b/app/src/main/res/drawable/aurora_lock_selected.xml @@ -0,0 +1,27 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_login.xml b/app/src/main/res/drawable/aurora_login.xml new file mode 100644 index 0000000..328cd94 --- /dev/null +++ b/app/src/main/res/drawable/aurora_login.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_login_selected.xml b/app/src/main/res/drawable/aurora_login_selected.xml new file mode 100644 index 0000000..cc4a6fb --- /dev/null +++ b/app/src/main/res/drawable/aurora_login_selected.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_logout.xml b/app/src/main/res/drawable/aurora_logout.xml new file mode 100644 index 0000000..f76d242 --- /dev/null +++ b/app/src/main/res/drawable/aurora_logout.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_logout_selected.xml b/app/src/main/res/drawable/aurora_logout_selected.xml new file mode 100644 index 0000000..94a909d --- /dev/null +++ b/app/src/main/res/drawable/aurora_logout_selected.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_media.xml b/app/src/main/res/drawable/aurora_media.xml new file mode 100644 index 0000000..9eda5f7 --- /dev/null +++ b/app/src/main/res/drawable/aurora_media.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_media_selected.xml b/app/src/main/res/drawable/aurora_media_selected.xml new file mode 100644 index 0000000..26557cd --- /dev/null +++ b/app/src/main/res/drawable/aurora_media_selected.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_network.xml b/app/src/main/res/drawable/aurora_network.xml new file mode 100644 index 0000000..016003a --- /dev/null +++ b/app/src/main/res/drawable/aurora_network.xml @@ -0,0 +1,49 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_network_selected.xml b/app/src/main/res/drawable/aurora_network_selected.xml new file mode 100644 index 0000000..29dab80 --- /dev/null +++ b/app/src/main/res/drawable/aurora_network_selected.xml @@ -0,0 +1,37 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_next.xml b/app/src/main/res/drawable/aurora_next.xml new file mode 100644 index 0000000..c07d004 --- /dev/null +++ b/app/src/main/res/drawable/aurora_next.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_next_selected.xml b/app/src/main/res/drawable/aurora_next_selected.xml new file mode 100644 index 0000000..7012f9e --- /dev/null +++ b/app/src/main/res/drawable/aurora_next_selected.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_notification.xml b/app/src/main/res/drawable/aurora_notification.xml new file mode 100644 index 0000000..cb7b827 --- /dev/null +++ b/app/src/main/res/drawable/aurora_notification.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_notification_selected.xml b/app/src/main/res/drawable/aurora_notification_selected.xml new file mode 100644 index 0000000..c593ecd --- /dev/null +++ b/app/src/main/res/drawable/aurora_notification_selected.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_pause.xml b/app/src/main/res/drawable/aurora_pause.xml new file mode 100644 index 0000000..2f0a0d9 --- /dev/null +++ b/app/src/main/res/drawable/aurora_pause.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_pause_selected.xml b/app/src/main/res/drawable/aurora_pause_selected.xml new file mode 100644 index 0000000..dc065c1 --- /dev/null +++ b/app/src/main/res/drawable/aurora_pause_selected.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_phone.xml b/app/src/main/res/drawable/aurora_phone.xml new file mode 100644 index 0000000..4a7a3a8 --- /dev/null +++ b/app/src/main/res/drawable/aurora_phone.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_phone_selected.xml b/app/src/main/res/drawable/aurora_phone_selected.xml new file mode 100644 index 0000000..5b6141b --- /dev/null +++ b/app/src/main/res/drawable/aurora_phone_selected.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_play.xml b/app/src/main/res/drawable/aurora_play.xml new file mode 100644 index 0000000..d55e027 --- /dev/null +++ b/app/src/main/res/drawable/aurora_play.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_play_selected.xml b/app/src/main/res/drawable/aurora_play_selected.xml new file mode 100644 index 0000000..f36ff12 --- /dev/null +++ b/app/src/main/res/drawable/aurora_play_selected.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_playlist.xml b/app/src/main/res/drawable/aurora_playlist.xml new file mode 100644 index 0000000..6475fa3 --- /dev/null +++ b/app/src/main/res/drawable/aurora_playlist.xml @@ -0,0 +1,45 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_playlist_selected.xml b/app/src/main/res/drawable/aurora_playlist_selected.xml new file mode 100644 index 0000000..88e71db --- /dev/null +++ b/app/src/main/res/drawable/aurora_playlist_selected.xml @@ -0,0 +1,45 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_previous.xml b/app/src/main/res/drawable/aurora_previous.xml new file mode 100644 index 0000000..12e44da --- /dev/null +++ b/app/src/main/res/drawable/aurora_previous.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_previous_selected.xml b/app/src/main/res/drawable/aurora_previous_selected.xml new file mode 100644 index 0000000..5289249 --- /dev/null +++ b/app/src/main/res/drawable/aurora_previous_selected.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_profile_edit.xml b/app/src/main/res/drawable/aurora_profile_edit.xml new file mode 100644 index 0000000..d0b7c24 --- /dev/null +++ b/app/src/main/res/drawable/aurora_profile_edit.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_profile_edit_selected.xml b/app/src/main/res/drawable/aurora_profile_edit_selected.xml new file mode 100644 index 0000000..fe60a7f --- /dev/null +++ b/app/src/main/res/drawable/aurora_profile_edit_selected.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_qr.xml b/app/src/main/res/drawable/aurora_qr.xml new file mode 100644 index 0000000..b77fd0c --- /dev/null +++ b/app/src/main/res/drawable/aurora_qr.xml @@ -0,0 +1,40 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_qr_selected.xml b/app/src/main/res/drawable/aurora_qr_selected.xml new file mode 100644 index 0000000..238cbbe --- /dev/null +++ b/app/src/main/res/drawable/aurora_qr_selected.xml @@ -0,0 +1,40 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_refresh.xml b/app/src/main/res/drawable/aurora_refresh.xml new file mode 100644 index 0000000..44a773d --- /dev/null +++ b/app/src/main/res/drawable/aurora_refresh.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_refresh_selected.xml b/app/src/main/res/drawable/aurora_refresh_selected.xml new file mode 100644 index 0000000..945eb99 --- /dev/null +++ b/app/src/main/res/drawable/aurora_refresh_selected.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_remote.xml b/app/src/main/res/drawable/aurora_remote.xml new file mode 100644 index 0000000..2948da2 --- /dev/null +++ b/app/src/main/res/drawable/aurora_remote.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_remote_selected.xml b/app/src/main/res/drawable/aurora_remote_selected.xml new file mode 100644 index 0000000..75dd870 --- /dev/null +++ b/app/src/main/res/drawable/aurora_remote_selected.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_remove.xml b/app/src/main/res/drawable/aurora_remove.xml new file mode 100644 index 0000000..9ff9946 --- /dev/null +++ b/app/src/main/res/drawable/aurora_remove.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_remove_selected.xml b/app/src/main/res/drawable/aurora_remove_selected.xml new file mode 100644 index 0000000..71ad509 --- /dev/null +++ b/app/src/main/res/drawable/aurora_remove_selected.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_replay.xml b/app/src/main/res/drawable/aurora_replay.xml new file mode 100644 index 0000000..f5c0e2c --- /dev/null +++ b/app/src/main/res/drawable/aurora_replay.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_replay_selected.xml b/app/src/main/res/drawable/aurora_replay_selected.xml new file mode 100644 index 0000000..60789a5 --- /dev/null +++ b/app/src/main/res/drawable/aurora_replay_selected.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_resources.xml b/app/src/main/res/drawable/aurora_resources.xml new file mode 100644 index 0000000..5641023 --- /dev/null +++ b/app/src/main/res/drawable/aurora_resources.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_resources_selected.xml b/app/src/main/res/drawable/aurora_resources_selected.xml new file mode 100644 index 0000000..d5e6a97 --- /dev/null +++ b/app/src/main/res/drawable/aurora_resources_selected.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_search.xml b/app/src/main/res/drawable/aurora_search.xml new file mode 100644 index 0000000..72d4847 --- /dev/null +++ b/app/src/main/res/drawable/aurora_search.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_search_selected.xml b/app/src/main/res/drawable/aurora_search_selected.xml new file mode 100644 index 0000000..85a57e1 --- /dev/null +++ b/app/src/main/res/drawable/aurora_search_selected.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_server.xml b/app/src/main/res/drawable/aurora_server.xml new file mode 100644 index 0000000..abd2fa0 --- /dev/null +++ b/app/src/main/res/drawable/aurora_server.xml @@ -0,0 +1,37 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_server_selected.xml b/app/src/main/res/drawable/aurora_server_selected.xml new file mode 100644 index 0000000..fc8b2d6 --- /dev/null +++ b/app/src/main/res/drawable/aurora_server_selected.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_settings.xml b/app/src/main/res/drawable/aurora_settings.xml new file mode 100644 index 0000000..204c029 --- /dev/null +++ b/app/src/main/res/drawable/aurora_settings.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_settings_selected.xml b/app/src/main/res/drawable/aurora_settings_selected.xml new file mode 100644 index 0000000..d567358 --- /dev/null +++ b/app/src/main/res/drawable/aurora_settings_selected.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_share.xml b/app/src/main/res/drawable/aurora_share.xml new file mode 100644 index 0000000..7d69594 --- /dev/null +++ b/app/src/main/res/drawable/aurora_share.xml @@ -0,0 +1,42 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_share_selected.xml b/app/src/main/res/drawable/aurora_share_selected.xml new file mode 100644 index 0000000..c3fa35c --- /dev/null +++ b/app/src/main/res/drawable/aurora_share_selected.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_shield.xml b/app/src/main/res/drawable/aurora_shield.xml new file mode 100644 index 0000000..bd04e28 --- /dev/null +++ b/app/src/main/res/drawable/aurora_shield.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_shield_selected.xml b/app/src/main/res/drawable/aurora_shield_selected.xml new file mode 100644 index 0000000..9c0ce0c --- /dev/null +++ b/app/src/main/res/drawable/aurora_shield_selected.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_shuffle.xml b/app/src/main/res/drawable/aurora_shuffle.xml new file mode 100644 index 0000000..23e37df --- /dev/null +++ b/app/src/main/res/drawable/aurora_shuffle.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_shuffle_selected.xml b/app/src/main/res/drawable/aurora_shuffle_selected.xml new file mode 100644 index 0000000..6daacee --- /dev/null +++ b/app/src/main/res/drawable/aurora_shuffle_selected.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_sort.xml b/app/src/main/res/drawable/aurora_sort.xml new file mode 100644 index 0000000..c2ee50c --- /dev/null +++ b/app/src/main/res/drawable/aurora_sort.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_sort_selected.xml b/app/src/main/res/drawable/aurora_sort_selected.xml new file mode 100644 index 0000000..4489df6 --- /dev/null +++ b/app/src/main/res/drawable/aurora_sort_selected.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_speaker.xml b/app/src/main/res/drawable/aurora_speaker.xml new file mode 100644 index 0000000..c549543 --- /dev/null +++ b/app/src/main/res/drawable/aurora_speaker.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_speaker_selected.xml b/app/src/main/res/drawable/aurora_speaker_selected.xml new file mode 100644 index 0000000..fe2863e --- /dev/null +++ b/app/src/main/res/drawable/aurora_speaker_selected.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_speed.xml b/app/src/main/res/drawable/aurora_speed.xml new file mode 100644 index 0000000..cd5be6d --- /dev/null +++ b/app/src/main/res/drawable/aurora_speed.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_speed_selected.xml b/app/src/main/res/drawable/aurora_speed_selected.xml new file mode 100644 index 0000000..d3ac4d0 --- /dev/null +++ b/app/src/main/res/drawable/aurora_speed_selected.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_stop.xml b/app/src/main/res/drawable/aurora_stop.xml new file mode 100644 index 0000000..a5869a8 --- /dev/null +++ b/app/src/main/res/drawable/aurora_stop.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_stop_selected.xml b/app/src/main/res/drawable/aurora_stop_selected.xml new file mode 100644 index 0000000..743bef4 --- /dev/null +++ b/app/src/main/res/drawable/aurora_stop_selected.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_subtitle.xml b/app/src/main/res/drawable/aurora_subtitle.xml new file mode 100644 index 0000000..5807fca --- /dev/null +++ b/app/src/main/res/drawable/aurora_subtitle.xml @@ -0,0 +1,42 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_subtitle_selected.xml b/app/src/main/res/drawable/aurora_subtitle_selected.xml new file mode 100644 index 0000000..c0761b5 --- /dev/null +++ b/app/src/main/res/drawable/aurora_subtitle_selected.xml @@ -0,0 +1,38 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_switch_account.xml b/app/src/main/res/drawable/aurora_switch_account.xml new file mode 100644 index 0000000..874c59e --- /dev/null +++ b/app/src/main/res/drawable/aurora_switch_account.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_switch_account_selected.xml b/app/src/main/res/drawable/aurora_switch_account_selected.xml new file mode 100644 index 0000000..138f45e --- /dev/null +++ b/app/src/main/res/drawable/aurora_switch_account_selected.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_tablet.xml b/app/src/main/res/drawable/aurora_tablet.xml new file mode 100644 index 0000000..5b1b098 --- /dev/null +++ b/app/src/main/res/drawable/aurora_tablet.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_tablet_selected.xml b/app/src/main/res/drawable/aurora_tablet_selected.xml new file mode 100644 index 0000000..28a55a9 --- /dev/null +++ b/app/src/main/res/drawable/aurora_tablet_selected.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_tv.xml b/app/src/main/res/drawable/aurora_tv.xml new file mode 100644 index 0000000..1e35aac --- /dev/null +++ b/app/src/main/res/drawable/aurora_tv.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_tv_selected.xml b/app/src/main/res/drawable/aurora_tv_selected.xml new file mode 100644 index 0000000..0bd4b13 --- /dev/null +++ b/app/src/main/res/drawable/aurora_tv_selected.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_unlock.xml b/app/src/main/res/drawable/aurora_unlock.xml new file mode 100644 index 0000000..4bdcf5f --- /dev/null +++ b/app/src/main/res/drawable/aurora_unlock.xml @@ -0,0 +1,31 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_unlock_selected.xml b/app/src/main/res/drawable/aurora_unlock_selected.xml new file mode 100644 index 0000000..4efc02c --- /dev/null +++ b/app/src/main/res/drawable/aurora_unlock_selected.xml @@ -0,0 +1,31 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_upload.xml b/app/src/main/res/drawable/aurora_upload.xml new file mode 100644 index 0000000..c25c5ba --- /dev/null +++ b/app/src/main/res/drawable/aurora_upload.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_upload_selected.xml b/app/src/main/res/drawable/aurora_upload_selected.xml new file mode 100644 index 0000000..2bb42ea --- /dev/null +++ b/app/src/main/res/drawable/aurora_upload_selected.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_user.xml b/app/src/main/res/drawable/aurora_user.xml new file mode 100644 index 0000000..b9b7e64 --- /dev/null +++ b/app/src/main/res/drawable/aurora_user.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_user_selected.xml b/app/src/main/res/drawable/aurora_user_selected.xml new file mode 100644 index 0000000..52d74bd --- /dev/null +++ b/app/src/main/res/drawable/aurora_user_selected.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_users.xml b/app/src/main/res/drawable/aurora_users.xml new file mode 100644 index 0000000..f8cec09 --- /dev/null +++ b/app/src/main/res/drawable/aurora_users.xml @@ -0,0 +1,37 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_users_selected.xml b/app/src/main/res/drawable/aurora_users_selected.xml new file mode 100644 index 0000000..cf03ee0 --- /dev/null +++ b/app/src/main/res/drawable/aurora_users_selected.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_warning.xml b/app/src/main/res/drawable/aurora_warning.xml new file mode 100644 index 0000000..901c711 --- /dev/null +++ b/app/src/main/res/drawable/aurora_warning.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_warning_selected.xml b/app/src/main/res/drawable/aurora_warning_selected.xml new file mode 100644 index 0000000..2a8f1c4 --- /dev/null +++ b/app/src/main/res/drawable/aurora_warning_selected.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_watch.xml b/app/src/main/res/drawable/aurora_watch.xml new file mode 100644 index 0000000..c3f299d --- /dev/null +++ b/app/src/main/res/drawable/aurora_watch.xml @@ -0,0 +1,22 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/aurora_watch_selected.xml b/app/src/main/res/drawable/aurora_watch_selected.xml new file mode 100644 index 0000000..520972c --- /dev/null +++ b/app/src/main/res/drawable/aurora_watch_selected.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index b397bac..59d8f84 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -18,6 +18,9 @@ Danmaku Off Cache + Manage app cache + Playback settings + Adaptive bitrate Total Cache Size Icon Cache Clear Cache @@ -29,6 +32,7 @@ Current Version Cache Info Back + Selected Favorites Calendar No updates yet @@ -37,7 +41,11 @@ Type keywords to start searching No results for " " + No searchable media library + Connect an Emby server in the Server tab first + No matching content Personal Media Center + Not connected More Play Libraries @@ -46,40 +54,8 @@ Continue Watching Default Set as default icon library - Used first when changing library icons LumaPlayer - 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 + Used first when changing library icons + LumaPlayer Movies TV Shows Music @@ -93,6 +69,84 @@ Search Search settings LumaPlayer + Version %1$s + Technology + Jetpack Compose + Kotlin UI toolkit + Media3 / ExoPlayer + Playback engine + Navigation Compose + Page routing + Kyant0 Backdrop + Liquid glass rendering + Open-source licenses + Jetpack Compose / AndroidX + Media3 / ExoPlayer + AndroidX Navigation + Coil + Retrofit + OkHttp + Room + Kyant0 Backdrop / Shapes + Miuix + Apache License 2.0 + Credits & Licenses + Aurora Symbols + In-app icon resources + Open-source components + Compose, Coil, Room, Retrofit, OkHttp and Media3 + Loading + Searching + Syncing favorites + Failed to load content + Check the network or server connection and try again + Retry + Loading episodes... + Syncing details... + Play now + Continue %1$s + No synopsis + All episodes + View all + Close + Failed to load details + Episodes are still loading + Synopsis + Episodes + Cast & Crew + Details + Type + Original title + Studio + Quality + Audio tracks + %1$d tracks + Unknown + Media + Episode %1$d · %2$s + Episode %1$d + Episode + TV + Series + Movie + Media + %1$d min + %1$d hr + %1$d hr %2$d min + Preparing playback... + Playback failed + Subtitles + Episode list + Seek back 10 seconds + Previous episode + Play + Pause + Next episode + Seek forward 10 seconds + Disable subtitles + This resource cannot be played + Failed to resolve playback URL + Failed to load playback resource No media libraries available Add a server in the server tab @@ -153,6 +207,9 @@ Name My Emby URL + Protocol + Use an encrypted connection + Use an unencrypted connection Please enter URL Path Port @@ -178,16 +235,16 @@ Toggle View Icon Library Add Server + Confirm deletion + Delete server “%1$s”? This action cannot be undone. + Delete icon library “%1$s”? This action cannot be undone. + Clear the icon cache? This action cannot be undone. + Cancel + Delete + Clear Login failed Network error: %s Unknown + Clear search - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d8397f..80e41eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,9 @@ 弹幕 未开启 缓存 + 管理应用缓存 + 播放设置 + 自适应比特率 缓存数据总大小 图标数据缓存 删除缓存 @@ -30,6 +33,7 @@ 当前版本 缓存信息 返回 + 已选中 收藏 日历 暂无更新 @@ -38,7 +42,11 @@ 输入关键词开始搜索 未找到「 」相关结果 + 暂无可搜索的媒体库 + 请先在资源库中连接 Emby 服务器 + 没有找到匹配内容 个人媒体中心 + 未连接 更多 播放 媒体库 @@ -47,39 +55,7 @@ 最新添加 默认 设为默认图标库 - 资源库更换图标时优先使用该库 寒战2 - 2016.7.8 警匪 / 悬疑 - 电视 - 韩国 - 电视 - 华语 - 12 部剧集 - 8 部剧集 - 星际穿越 - 科幻 / 冒险 - 盗梦空间 - 科幻 / 动作 - 黑客帝国 - 泰坦尼克号 - 爱情 / 灾难 - 阿凡达 - 沙丘 - 信条 - 科幻 / 悬疑 - 蝙蝠侠 - 动作 / 犯罪 - 银翼杀手2049 - 降临 - 科幻 / 剧情 - 火星救援 - 地心引力 - 科幻 / 惊悚 - 黑镜 - 权力的游戏 - 奇幻 / 冒险 - 绝命毒师 - 犯罪 / 剧情 - 怪奇物语 - 科幻 / 恐怖 - 曼达洛人 + 资源库更换图标时优先使用该库 电影 剧集 音乐 @@ -93,6 +69,84 @@ 搜索 搜索设置 LumaPlayer + 版本 %1$s + 技术栈 + Jetpack Compose + Kotlin UI toolkit + Media3 / ExoPlayer + 播放引擎 + Navigation Compose + 页面路由 + Kyant0 Backdrop + 液态玻璃渲染 + 开源许可 + Jetpack Compose / AndroidX + Media3 / ExoPlayer + AndroidX Navigation + Coil + Retrofit + OkHttp + Room + Kyant0 Backdrop / Shapes + Miuix + Apache License 2.0 + 致谢与许可 + Aurora Symbols + 应用内图标资源 + 开源组件 + Compose、Coil、Room、Retrofit、OkHttp 与 Media3 + 正在加载 + 正在搜索 + 正在同步收藏 + 内容加载失败 + 请检查网络或服务器连接后重试 + 重试 + 正在加载剧集... + 同步详情中... + 立即播放 + 继续 %1$s + 暂无简介 + 全部剧集 + 查看全部 + 关闭 + 详情加载失败 + 剧集尚未加载完成 + 简介 + 剧集 + 演职人员 + 详细信息 + 类型 + 原名 + 制作方 + 画质 + 音轨 + %1$d 条 + 未知 + 媒体 + 第 %1$d 集 · %2$s + 第 %1$d 集 + 剧集 + TV + 剧集 + 电影 + 媒体 + %1$d 分钟 + %1$d 小时 + %1$d 小时 %2$d 分钟 + 正在准备播放... + 播放失败 + 字幕 + 剧集列表 + 后退 10 秒 + 上一集 + 播放 + 暂停 + 下一集 + 前进 10 秒 + 关闭字幕 + 播放器无法播放该资源 + 获取播放地址失败 + 播放资源加载失败 暂无可用的媒体库 请在资源库中添加服务器 @@ -153,6 +207,9 @@ 名称 我的 Emby 地址 + 协议 + 使用加密连接 + 使用普通连接 请输入地址 路径 端口 @@ -178,16 +235,16 @@ 视图切换 图标库管理 添加服务器 + 确认删除 + 确定要删除服务器“%1$s”吗?此操作无法撤销。 + 确定要删除图标库“%1$s”吗?此操作无法撤销。 + 确定要清理图标缓存吗?此操作无法撤销。 + 取消 + 删除 + 清理 登录失败 网络错误: %s 未知 + 清除搜索 - - - - - - - - diff --git a/memory/2026-08-27.md b/memory/2026-08-27.md index a6b6178..86ce9a1 100644 --- a/memory/2026-08-27.md +++ b/memory/2026-08-27.md @@ -58,3 +58,57 @@ 文件变更: memory/MEMORY.md、memory/projects.md、memory/2026-08-27.md。 教训: 无。 标签: #lumaplayer #git #sync + +[PROJECT:lumaplayer] 完成全局 UI 设计系统与玻璃弹层统一 +结论: 新增统一颜色/间距/圆角/动效/图标/加载空状态组件,主要页面与弹窗接入 Kyant0 Backdrop,APK 构建成功。 +文件变更: app/src/main/kotlin/com/example/myapplication/ui/design/*、主要 screens、LiquidGlass.kt、MainApp.kt、NavigationTransitions.kt、Aurora drawable 子集、licenses/Aurora-Symbols-NOTICE.md +教训: Backdrop source 与 consumer 必须保持同级分离;当前环境没有 adb,未进行实机逐帧比对。 +标签: #ui #liquid-glass #aurora #build + +[PROJECT:lumaplayer] 最终构建与静态核验 +结论: 旧页面导航状态、Haze/旧模糊依赖和普通下拉菜单无残留,`:app:assembleDebug` 最终成功。 +文件变更: ui/design/*、navigation/*、主要 screens、LiquidGlass.kt、资源字符串与 Aurora 图标资源。 +教训: 无新增;当前环境无 adb,未做实机录屏和参考帧逐帧比对。 +标签: #lumaplayer #build #navigation #verification + +[PROJECT:lumaplayer] UI 国际化与玻璃管线收尾 +结论: 详情类型/时长、资源库状态及入口无障碍文案完成中英文资源化,图标库卡片统一接入 Luma glass;Debug 构建和静态检查通过。 +文件变更: screens/HomeScreen.kt、screens/LibraryScreen.kt、screens/MediaDetailScreen.kt、res/values/strings.xml、res/values-en/strings.xml。 +教训: Compose 普通回调不能直接调用 stringResource,需在 Composable 作用域先取得文案。 +标签: #lumaplayer #i18n #accessibility #liquid-glass #build + +[PROJECT:lumaplayer] 修复 RenderThread 原生崩溃风险 +结论: 基于本地 AndroidLiquidGlass-kmp 源码移除应用内 AGSL lens/RuntimeShader 折射链,保留 Backdrop、blur、vibrancy、Plain highlight、阴影和 Compose 径向按压高光;Debug 构建成功。 +文件变更: ui/design/LumaDesignSystem.kt、ui/LiquidGlass.kt、ui/AppBottomBar.kt、ui/liquidglass/LiquidBottomTabs.kt、ui/liquidglass/InteractiveHighlight.kt、screens/SettingsScreen.kt。 +教训: RenderThread SIGSEGV 无法由 Kotlin try/catch 恢复;高风险 RuntimeShader 必须在源码层移除或降级,当前环境无 adb 不能做设备复现确认。 +标签: #lumaplayer #renderthread #sigsegv #backdrop #stability + +[PROJECT:lumaplayer] 收敛 Backdrop 捕获层并完成玻璃 Sheet +结论: 主 Tab 才启用根 Backdrop,子页和播放器移除循环采样;图标选择改为底部 GlassSheet,About 使用真实应用图标;Debug 构建成功,未能 adb 实机验证。 +文件变更: MainApp.kt、screens/SettingsScreen.kt、screens/LibraryScreen.kt、screens/MediaDetailScreen.kt、screens/SettingsChildScreens.kt、screens/VideoPlayerScreen.kt、ui/LiquidGlass.kt、memory/*。 +教训: 原生播放器 View 不应进入根玻璃捕获层;没有 adb 时只能以静态层级检查和构建结果确认风险已收敛。 +标签: #lumaplayer #renderthread #backdrop #glass-sheet #build + +[PROJECT:lumaplayer] RenderThread 崩溃安全降级 +结论: 识别常见 Android Emulator GPU 后跳过 Kyant RenderEffect/GraphicsLayer 采样,保留安全的玻璃 tint、边框、圆角与交互视觉;真机仍保留 Backdrop,播放器不捕获 AndroidView。 +文件变更: ui/design/LumaDesignSystem.kt、ui/LiquidGlass.kt、ui/AppBottomBar.kt、ui/liquidglass/LiquidBottomTabs.kt、MainApp.kt、主要 screens。 +教训: RenderThread SIGSEGV 不可由 Kotlin try/catch 恢复;必须在进入原生采样路径前阻断,且不能依赖没有 adb 的静态环境声称已实机复现。 +标签: #lumaplayer #renderthread #sigsegv #emulator #backdrop #stability + +[PROJECT:lumaplayer] RenderThread 防护前移与构建复核 +结论: 模拟器安全判断已前移到 LayerBackdrop 创建之前,播放器移除额外透明离屏合成,图片统一提供安全占位和错误降级;assemble、lint、unit test 均成功。 +文件变更: ui/design/LumaDesignSystem.kt、MainApp.kt、VideoPlayerScreen.kt、AppBottomBar.kt、LiquidBottomTabs.kt、各主要 Screen。 +教训: 当前环境无 adb,不能将构建成功表述为设备启动已验证;设备仍需复测崩溃场景。 +标签: #lumaplayer #renderthread #backdrop #player #build + +[PROJECT:lumaplayer] 加强 RenderThread 模拟器防护并修复详情页收尾编译问题 +结论: Backdrop 安全门新增 ABI、板级标识和设备名判定;详情返回按钮统一为 Luma glass,操作目标补足 48dp;Debug 构建、Lint 和单元测试任务成功。 +文件变更: ui/design/LumaDesignSystem.kt、screens/MediaDetailScreen.kt。 +教训: 原生 RenderThread 崩溃必须在 LayerBackdrop 创建前阻断;Compose 绘制回调不能直接读取 CompositionLocal 状态。 +标签: #lumaplayer #renderthread #accessibility #build + +[PROJECT:lumaplayer] 播放上报、主页缓存与自适应玻璃收尾 +结论: 完成 Emby 串行播放回报和 10 秒节流、主页返回缓存、帧率门控液态玻璃及安全降级底栏;模拟器启动无 RenderThread 崩溃,Debug/Lint 成功。 +文件变更: data/EmbyApiModels.kt、data/EmbyRepository.kt、navigation/AppNavHost.kt、screens/VideoPlayerScreen.kt、screens/HomeScreen.kt、ui/design/LumaDesignSystem.kt、ui/AppBottomBar.kt、ui/liquidglass/LiquidBottomTabs.kt、ui/liquidglass/LiquidBottomTab.kt、memory/*。 +教训: 模拟器不能安全采样 Kyant Backdrop,降级态需用连续胶囊渐变和局部高光保持玻璃层次;提交时排除本地参考仓库和媒体素材。 +标签: #lumaplayer #emby #playback #cache #liquid-glass #renderthread diff --git a/memory/MEMORY.md b/memory/MEMORY.md index 0ffef7a..17ec76b 100644 --- a/memory/MEMORY.md +++ b/memory/MEMORY.md @@ -2,7 +2,9 @@ - 项目:lumaplayer,Android Kotlin/Compose 媒体库客户端。 - 当前 Git 分支:`master`。 -- 最近同步:远端与本地均为 `fa645b0`。 -- 当前实现:语言菜单、资源库操作菜单、播放器菜单与底栏使用 AndroidLiquidGlass/Backdrop 2.0.1;添加服务器为独立普通表单页面并支持 HTTP/HTTPS 配置持久化;页面导航已迁移到 Navigation Compose。 -- 构建验证:`assembleDebug` 成功。 +- 最近同步:远端与本地基线均为 `5df288b`,当前工作区有 UI 设计改动。 +- 当前实现:底栏与安全的 Glass 组件使用 Kyant0 Backdrop 2.0.1;安全且实测帧率不低于 48fps 时使用 Backdrop + blur/vibrancy/lens,模拟器、低帧率或低版本使用渐变毛玻璃降级;页面捕获层仅服务根 Tab/底栏,子页玻璃无源时安全降级;添加服务器为独立普通表单页面并支持 HTTP/HTTPS;页面导航使用 Navigation Compose。 +- 最新稳定性策略:通过 `LumaGlassRuntime` 识别常见 Android Emulator GPU,模拟器跳过 Kyant RenderEffect/GraphicsLayer 采样,真机保留 Backdrop;播放器不捕获 AndroidView。 +- 当前 UI:新增 `ui/design` 令牌、公共加载/空状态/按钮、Aurora 图标子集和授权说明;主要页面已接入统一颜色、间距、圆角和动效。 +- 构建验证:Backdrop 层级收敛、模拟器安全降级、播放器采样隔离和 GlassSheet 收尾后 `assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功;已在 `sdk_gphone64_arm64` 模拟器安装启动复核,未出现 `SIGSEGV/RenderThread`,尚未做真机录屏比对。 - 详细状态见 [projects.md](projects.md),踩坑见 [lessons.md](lessons.md)。 diff --git a/memory/lessons.md b/memory/lessons.md index 72836ea..ec8a397 100644 --- a/memory/lessons.md +++ b/memory/lessons.md @@ -9,3 +9,12 @@ - 中:Backdrop 的玻璃效果是否明显取决于来源内容;纯白卡片上无法显示明显折射,需依靠仓库的高光、阴影和 lens 边缘表现,不能用普通白色背景冒充。 - 高:Root Overlay 若直接消费外层页面 `layerBackdrop`,外层 source 会把 Overlay 再捕获形成 RenderNode 循环;必须为局部页面建立只包裹底层内容的 source,并让 Overlay 作为兄弟 consumer。 - 高:Room 实体由 Java 可空字段映射时,迁移列定义必须与 Room 的 `notNull` 预期一致;新增 `protocol` 使用可空列并在模型读取时回退 HTTPS,否则旧数据库启动会因迁移校验失败崩溃。 +- 低:Compose 非 Composable 回调中不能直接调用 `stringResource`;需在 Composable 作用域先缓存文案,再传入回调。 +- 低:全量 `lintDebug` 在当前环境分析阶段长时间无输出且无报告;最终以 `assembleDebug`、`git diff --check`、资源键和关键残留静态检查收尾。 +- 高:本地 AndroidLiquidGlass-kmp 的 `lens` 和高光默认样式会创建 Android 13 AGSL RuntimeShader;出现 RenderThread SIGSEGV 时不能只改 Kotlin 异常处理,应移除 RuntimeShader/折射链并以 Backdrop blur、vibrancy、Plain highlight 和 Compose 渐变降级。 +- 高:根 `layerBackdrop` 不应包住播放器等原生 View;子页面应避免把同一个 source 同时作为父捕获层和子消费层,否则即使没有 AGSL 也可能形成 RenderNode 循环。 +- 高:Android Emulator 的 Kyant RenderEffect/GraphicsLayer 采样可能直接在 RenderThread 触发 SIGSEGV,Kotlin 异常无法兜底;应按运行环境在进入 `drawBackdrop`/`layerBackdrop` 前降级,且播放器永不捕获 AndroidView。 +- 高:仅跳过 `drawBackdrop` 仍可能提前创建 `rememberGraphicsLayer`;Backdrop 来源应在不安全设备上直接返回 `null`,源层和消费层都不能挂载。 +- 高:模拟器不一定使用标准 `sdk_gphone` 指纹,RenderThread 防护还应检查 x86 ABI、board 和 `emu` 设备名;详情页绘制回调中的 Compose 状态也必须提前快照。 +- 中:Emby 播放回报必须沿用 PlaybackInfo 返回的 `PlaySessionId`/`MediaSourceId`,并将开始、进度、停止事件串行化;把回报协程绑定到播放器 Composable 生命周期会在返回页面时丢失停止事件。 +- 高:液态玻璃不能以“API 版本高”作为唯一启用条件;Kyant Backdrop 还需通过设备安全门和运行帧率门控,低帧率/不安全 GPU 必须在创建 LayerBackdrop 前降级,避免 RenderThread 崩溃。 diff --git a/memory/projects.md b/memory/projects.md index b530791..b005a5b 100644 --- a/memory/projects.md +++ b/memory/projects.md @@ -22,3 +22,13 @@ - 2026-08-27:建立 Navigation Compose 页面架构;Home/Records/Library/Settings/Search 为根路由,详情、播放器、图标库管理、缓存、关于、添加服务器为独立 destination;统一 Push/Pop 与 Tab 转场,移除页面导航 Boolean 和嵌套页面;`assembleDebug` 成功。 - 2026-08-27:修复新 destination 打开后回到 Home 的问题;NavController 提升到 MainApp 稳定生命周期,静态页面和详情/播放器增加当前 destination 去重;`assembleDebug` 成功。 - 2026-08-27:定位并修复仍会回 Home 的实际触发源;子页面打开时底栏退出动画的默认 Tab=0 被液态 Tab 组件误当成点击,现保留上个根 Tab 且子页面期间屏蔽底栏回调;`assembleDebug` 成功。 +- 2026-08-27:基于本地 AndroidLiquidGlass-kmp、Convx、MeloX 和授权 Aurora 资源完成 UI 设计令牌、图标子集、首页/记录/资源库/设置/详情/播放器统一视觉接入;所有弹窗统一经 `GlassDialog`,资源库管理和剧集列表使用独立 Backdrop consumer;`:app:assembleDebug` 成功,未提交参考素材。 +- 2026-08-27:最终静态核验通过,旧页面 Boolean、Haze 依赖和普通下拉菜单均无残留;`:app:assembleDebug` 再次成功,APK 位于 `app/build/outputs/apk/debug/app-debug.apk`。 +- 2026-08-27:完成 UI 收尾审计;详情类型/时长、资源库状态和入口无障碍文案接入中英文资源,图标库卡片统一使用 Luma glass;`assembleDebug` 成功,`git diff --check` 和资源键一致性检查通过。 +- 2026-08-27:针对设备 `RenderThread SIGSEGV`,依据本地 AndroidLiquidGlass-kmp 源码移除应用所有 `lens`/AGSL RuntimeShader,并将底栏交互高光改为 Compose 径向渐变;`:app:assembleDebug` 成功,源码无 RuntimeShader/lens 残留。当前环境无 adb,未能设备复测。 +- 2026-08-27:继续收敛 RenderThread 风险;根 Backdrop 仅在主 Tab 捕获内容,详情/播放器/设置子页移除页面内 source-consumer 循环,播放器不再捕获 AndroidView;图标选择改为 GlassSheet,About 使用真实应用图标,Debug 构建成功。当前环境无 adb,未能实机复测。 +- 2026-08-27:针对再次出现的 RenderThread SIGSEGV,新增 `LumaGlassRuntime`:常见 Android Emulator 跳过 Kyant RenderEffect/GraphicsLayer 采样,统一回退为安全玻璃 tint/边框/圆角;真机仍使用 Backdrop。播放器保持不捕获 AndroidView;`assembleDebug`、`lintDebug`、`testDebugUnitTest` 均成功。 +- 2026-08-27:进一步将安全判断前移到 Backdrop 图层创建前,模拟器不再创建 `rememberGraphicsLayer`;播放器移除 `PlayerView` 的额外透明离屏合成,并统一图片占位/错误降级;三项 Gradle 验证继续成功。 +- 2026-08-27:再次加强模拟器识别,增加 x86 ABI、board 和 emu 设备名拦截;详情返回按钮统一为 Luma glass,详情操作目标补足 48dp;`assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功。 +- 2026-08-27:播放器按 Emby Playback Check-in 约定补齐 `PlaySessionId`、`MediaSourceId`、`PositionTicks`、`CanSeek`、`QueueableMediaTypes` 和事件字段;播放开始/暂停/继续/拖动/播放中进度/停止统一进入串行队列,普通进度按 10 秒或位置变化阈值上报。 +- 2026-08-27:首页增加按服务器身份隔离的进程内内容缓存,普通页面返回不重复请求,显式重试才刷新;玻璃运行时增加 Choreographer 帧率门控,底栏高帧率安全设备走本地 AndroidLiquidGlass-kmp 的 Lens,模拟器/低帧率使用连续胶囊毛玻璃和选中高光;`assembleDebug`、`lintDebug`、模拟器安装启动复核成功。 From f24286d758f4895df581b6e82fea9883dfb2fe3f Mon Sep 17 00:00:00 2001 From: BoatingCloud <1759888028@qq.com> Date: Thu, 27 Aug 2026 22:26:27 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=BA=95=E6=A0=8F?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E4=B8=8E=E7=8E=BB=E7=92=83=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../myapplication/navigation/AppNavHost.kt | 49 ++++++++++++++----- .../myapplication/screens/HomeScreen.kt | 3 -- .../myapplication/screens/LibraryScreen.kt | 26 ++-------- .../screens/MediaDetailScreen.kt | 11 ++--- .../screens/SettingsChildScreens.kt | 7 +-- .../myapplication/screens/SettingsScreen.kt | 9 ++-- .../example/myapplication/ui/AppBottomBar.kt | 4 +- .../example/myapplication/ui/LiquidGlass.kt | 42 ---------------- .../ui/design/LumaDesignSystem.kt | 16 +++--- .../myapplication/ui/design/LumaIcons.kt | 5 ++ .../ui/liquidglass/LiquidBottomTabs.kt | 9 ++-- app/src/main/res/drawable/aurora_back.xml | 10 ++++ .../res/drawable/aurora_back_selected.xml | 10 ++++ .../res/drawable/aurora_chevron_right.xml | 14 ++++++ .../aurora_chevron_right_selected.xml | 14 ++++++ app/src/main/res/drawable/aurora_eye_off.xml | 20 ++++++++ .../res/drawable/aurora_eye_off_selected.xml | 20 ++++++++ app/src/main/res/drawable/aurora_more.xml | 10 ++++ .../res/drawable/aurora_more_selected.xml | 10 ++++ app/src/main/res/drawable/aurora_star.xml | 10 ++++ .../res/drawable/aurora_star_selected.xml | 10 ++++ memory/2026-08-27.md | 6 +++ memory/MEMORY.md | 4 +- memory/projects.md | 1 + 24 files changed, 207 insertions(+), 113 deletions(-) create mode 100644 app/src/main/res/drawable/aurora_back.xml create mode 100644 app/src/main/res/drawable/aurora_back_selected.xml create mode 100644 app/src/main/res/drawable/aurora_chevron_right.xml create mode 100644 app/src/main/res/drawable/aurora_chevron_right_selected.xml create mode 100644 app/src/main/res/drawable/aurora_eye_off.xml create mode 100644 app/src/main/res/drawable/aurora_eye_off_selected.xml create mode 100644 app/src/main/res/drawable/aurora_more.xml create mode 100644 app/src/main/res/drawable/aurora_more_selected.xml create mode 100644 app/src/main/res/drawable/aurora_star.xml create mode 100644 app/src/main/res/drawable/aurora_star_selected.xml diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt index fa69db5..70a2170 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt @@ -43,6 +43,7 @@ import com.example.myapplication.screens.SettingsScreen import com.example.myapplication.screens.SettingsTopBar import com.example.myapplication.screens.VideoPlayerScreen import com.example.myapplication.ui.design.LumaLoading +import kotlinx.coroutines.CancellationException import top.yukonga.miuix.kmp.basic.Text @OptIn(ExperimentalAnimationApi::class) @@ -244,10 +245,16 @@ private fun MediaDetailDestination( LaunchedEffect(server?.id, itemId) { loading = true - item = if (server == null || server.apiKey.isBlank() || itemId.isBlank()) { + item = try { + if (server == null || server.apiKey.isBlank() || itemId.isBlank()) { + null + } else { + EmbyRepository.getItem(server, itemId) + } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { null - } else { - EmbyRepository.getItem(server, itemId) } loading = false } @@ -280,19 +287,31 @@ private fun PlayerDestination( LaunchedEffect(server?.id, itemId) { itemLoading = true - item = if (server == null || server.apiKey.isBlank() || itemId.isBlank()) { + item = try { + if (server == null || server.apiKey.isBlank() || itemId.isBlank()) { + null + } else { + EmbyRepository.getItem(server, itemId) + } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { null - } else { - EmbyRepository.getItem(server, itemId) } itemLoading = false } LaunchedEffect(server?.id, seriesId, seasonId) { - episodes = if (server == null || seriesId.isNullOrBlank() || seasonId.isNullOrBlank()) { + episodes = try { + if (server == null || seriesId.isNullOrBlank() || seasonId.isNullOrBlank()) { + emptyList() + } else { + EmbyRepository.getEpisodes(server, seriesId, seasonId) + } + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { emptyList() - } else { - EmbyRepository.getEpisodes(server, seriesId, seasonId) } } @@ -301,8 +320,16 @@ private fun PlayerDestination( if (server == null || server.apiKey.isBlank() || target == null) return@LaunchedEffect playbackLoading = true playbackError = null - playbackSession = EmbyRepository.resolvePlaybackSession(server, target) - playbackUrl = playbackSession?.streamUrl + try { + playbackSession = EmbyRepository.resolvePlaybackSession(server, target) + playbackUrl = playbackSession?.streamUrl + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + playbackSession = null + playbackUrl = null + playbackError = playbackUrlFailedMessage + } playbackLoading = false if (playbackUrl.isNullOrBlank()) playbackError = playbackUrlFailedMessage } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index c11a597..dcd6a81 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -31,8 +31,6 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.Composable @@ -92,7 +90,6 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.shadow import androidx.compose.ui.text.TextStyle diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt index 2ddc76d..d2cd865 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -45,7 +45,6 @@ import com.example.myapplication.ui.GlassDialog import com.example.myapplication.ui.GlassSheet import com.example.myapplication.ui.GlassInputField import com.example.myapplication.ui.GlassPopupItem -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -83,21 +82,6 @@ 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.automirrored.filled.ArrowBack -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 @@ -223,7 +207,7 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onMoreClick() }, contentAlignment = Alignment.Center, ) { - Icon(Icons.Outlined.MoreHoriz, contentDescription = stringResource(Res.string.server_actions), tint = GlassTokens.subtext, modifier = Modifier.size(24.dp)) + LumaIconView(LumaIcon.More, contentDescription = stringResource(Res.string.server_actions), tint = GlassTokens.subtext, modifier = Modifier.size(24.dp)) } } } @@ -284,7 +268,7 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onMoreClick() }, contentAlignment = Alignment.Center, ) { - Icon(Icons.Outlined.MoreHoriz, contentDescription = stringResource(Res.string.server_actions), tint = GlassTokens.subtext, modifier = Modifier.size(24.dp)) + LumaIconView(LumaIcon.More, contentDescription = stringResource(Res.string.server_actions), tint = GlassTokens.subtext, modifier = Modifier.size(24.dp)) } } Box(Modifier.height(22.dp)) { @@ -1453,7 +1437,7 @@ fun AddServerScreen( .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onDismiss), contentAlignment = Alignment.Center, ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(20.dp)) + LumaIconView(LumaIcon.Back, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(20.dp)) } Spacer(Modifier.width(12.dp)) Text(stringResource(Res.string.add_server), color = GlassTokens.text, fontSize = 22.sp, fontWeight = FontWeight.Bold) @@ -1618,8 +1602,8 @@ private fun ServerFormContent( onValueChange = { password = it }, visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), trailingContent = { - Icon( - imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + LumaIconView( + icon = if (showPassword) LumaIcon.EyeOff else LumaIcon.Eye, contentDescription = stringResource(if (showPassword) Res.string.server_hide_password else Res.string.server_show_password), tint = GlassTokens.subtext, modifier = Modifier.size(24.dp).clickable( diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt index 512ea13..448809e 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -28,11 +28,6 @@ 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.material3.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -378,8 +373,8 @@ fun MediaDetailScreen( .clickable(onClick = onClose), contentAlignment = Alignment.Center, ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + LumaIconView( + icon = LumaIcon.Back, contentDescription = stringResource(Res.string.back), tint = Color.White, modifier = Modifier.size(19.dp), @@ -523,7 +518,7 @@ private fun DetailHeadline(detail: EmbyItem, refreshing: Boolean, errorText: Str Spacer(Modifier.height(9.dp)) Row(verticalAlignment = Alignment.CenterVertically) { detail.communityRating?.takeIf { it > 0.0 }?.let { rating -> - Icon(imageVector = Icons.Filled.Star, contentDescription = null, tint = Color(0xFFFFD54F), modifier = Modifier.size(14.dp)) + LumaIconView(LumaIcon.Star, contentDescription = null, tint = Color(0xFFFFD54F), modifier = Modifier.size(14.dp)) Spacer(Modifier.width(3.dp)) Text(text = String.format("%.1f", rating), color = LocalDetailText.current, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) Spacer(Modifier.width(9.dp)) diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt index e3dca02..268cfdb 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt @@ -20,11 +20,6 @@ import androidx.compose.foundation.layout.statusBarsPadding 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.automirrored.filled.ArrowBack -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -73,7 +68,7 @@ private fun PageTopBar(title: String, onBack: () -> Unit) { .clickable(indication = null, interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, onClick = onBack), contentAlignment = Alignment.Center, ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back), tint = LumaColors.text, modifier = Modifier.size(20.dp)) + LumaIconView(LumaIcon.Back, contentDescription = stringResource(R.string.back), tint = LumaColors.text, modifier = Modifier.size(20.dp)) } Spacer(Modifier.size(12.dp)) Text(title, color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold) diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt index 94a5854..8cf6294 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt @@ -27,9 +27,6 @@ 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.outlined.ChevronRight -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -276,9 +273,9 @@ private fun SettingRowItem( Text(subtitle, color = LumaColors.textSecondary, fontSize = 12.sp) } } - Icon( - Icons.Outlined.ChevronRight, - null, + LumaIconView( + icon = LumaIcon.ChevronRight, + contentDescription = null, tint = LumaColors.textTertiary, modifier = Modifier .size(20.dp) diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt index e791ab2..2765346 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt @@ -74,7 +74,7 @@ fun LiquidAppBottomBar( selected = selectedTab == index, contentDescription = stringResource(tab.labelRes), tint = if (isGhostLayer) Color.White else if (selectedTab == index) LumaColors.accent else inactive, - modifier = Modifier.size(30.dp), + modifier = Modifier.size(33.dp), ) Spacer(Modifier.height(3.dp)) Text( @@ -136,7 +136,7 @@ fun LiquidAppBottomBar( icon = LumaIcon.Search, contentDescription = null, tint = if (searchSelected) LumaColors.accent else inactive, - modifier = Modifier.size(34.dp), + modifier = Modifier.size(37.dp), ) Text( text = stringResource(Res.string.search), diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt index 95fa978..12dc0ef 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -63,7 +63,6 @@ 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.input.pointer.pointerInput import androidx.compose.ui.geometry.Rect import androidx.compose.ui.text.font.FontWeight @@ -84,13 +83,11 @@ import androidx.compose.runtime.LaunchedEffect import kotlinx.coroutines.delay import kotlin.math.roundToInt import androidx.compose.ui.zIndex -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.Icon import top.yukonga.miuix.kmp.basic.Text import com.example.myapplication.ui.design.LumaColors import com.example.myapplication.ui.design.LumaIcon @@ -101,21 +98,6 @@ import com.example.myapplication.ui.design.LumaShapes import com.example.myapplication.ui.design.LumaTypography import com.example.myapplication.ui.design.LumaGlassRuntime import com.example.myapplication.ui.design.lumaGlass -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.filled.Storage -import androidx.compose.material.icons.outlined.GridView -import androidx.compose.material.icons.outlined.FavoriteBorder -import androidx.compose.material.icons.outlined.Home -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.Storage internal data class NavTab( val icon: LumaIcon, @@ -509,30 +491,6 @@ fun LiquidGlassPopupOverlay( } } -@Composable -fun GlassPopupItem( - text: String, - icon: ImageVector? = null, - tint: Color = Color(0xFF374151), - onClick: () -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .clickable(indication = null, interactionSource = interactionSource, onClick = onClick) - .padding(horizontal = 16.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(18.dp)) - } - Text(text = text, color = tint, fontSize = 14.sp, fontWeight = FontWeight.Medium) - } -} - @Composable fun GlassPopupItem( text: String, diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt index 0aff493..8ce3bb8 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt @@ -231,10 +231,10 @@ fun Modifier.lumaFallbackGlass( if (light) { Brush.horizontalGradient( listOf( - LumaColors.accent.copy(alpha = 0.24f), - Color.White.copy(alpha = 0.36f), - Color.White.copy(alpha = 0.22f), - LumaColors.accent.copy(alpha = 0.12f), + LumaColors.accent.copy(alpha = 0.10f), + Color.White.copy(alpha = 0.40f), + Color.White.copy(alpha = 0.30f), + LumaColors.accent.copy(alpha = 0.08f), ), ) } else { @@ -251,14 +251,15 @@ fun Modifier.lumaFallbackGlass( Brush.verticalGradient(listOf(top, bottom)) } return this + .clip(shape) .background(fill, shape) .then( if (selected) { Modifier.drawWithCache { val leftGlow = Brush.radialGradient( colors = listOf( - LumaColors.accent.copy(alpha = if (light) 0.22f else 0.26f), - LumaColors.accent.copy(alpha = 0.06f), + LumaColors.accent.copy(alpha = if (light) 0.14f else 0.18f), + LumaColors.accent.copy(alpha = 0.04f), Color.Transparent, ), center = Offset(size.width * 0.14f, size.height * 0.18f), @@ -266,7 +267,7 @@ fun Modifier.lumaFallbackGlass( ) val rightGlow = Brush.radialGradient( colors = listOf( - Color.White.copy(alpha = if (light) 0.22f else 0.12f), + Color.White.copy(alpha = if (light) 0.28f else 0.16f), Color.Transparent, ), center = Offset(size.width * 0.86f, size.height * 0.22f), @@ -283,6 +284,7 @@ fun Modifier.lumaFallbackGlass( }, ) .border(0.6.dp, edge, shape) + .clip(shape) } enum class LumaGlassLevel { diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt index 8b8a053..12aebe0 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt @@ -89,6 +89,11 @@ enum class LumaIcon( Calendar(R.drawable.aurora_calendar, R.drawable.aurora_calendar_selected), Check(R.drawable.aurora_check, R.drawable.aurora_check_selected), Close(R.drawable.aurora_close, R.drawable.aurora_close_selected), + Back(R.drawable.aurora_back, R.drawable.aurora_back_selected), + ChevronRight(R.drawable.aurora_chevron_right, R.drawable.aurora_chevron_right_selected), + More(R.drawable.aurora_more, R.drawable.aurora_more_selected), + EyeOff(R.drawable.aurora_eye_off, R.drawable.aurora_eye_off_selected), + Star(R.drawable.aurora_star, R.drawable.aurora_star_selected), } @Composable diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt index 445573d..0b891a1 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt @@ -51,7 +51,6 @@ import com.kyant.backdrop.shadow.Shadow import com.kyant.shapes.Capsule import com.example.myapplication.ui.design.LumaColors import com.example.myapplication.ui.design.LumaGlassRuntime -import com.example.myapplication.ui.design.LumaShapes import com.example.myapplication.ui.design.LumaSpacing import com.example.myapplication.ui.design.lumaFallbackGlass import com.example.myapplication.ui.design.rememberLumaLayerBackdrop @@ -206,7 +205,8 @@ fun LiquidBottomTabs( .then(interactiveHighlight.modifier) .height(64f.dp) .fillMaxWidth() - .padding(LumaSpacing.xxs), + .padding(LumaSpacing.xxs) + .clip(Capsule()), verticalAlignment = Alignment.CenterVertically, content = { CompositionLocalProvider( @@ -287,10 +287,9 @@ fun LiquidBottomTabs( backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop!!), shape = { Capsule() }, effects = { - val progress = dampedDragAnimation.pressProgress lens( - 10f.dp.toPx() * progress, - 14f.dp.toPx() * progress, + 10f.dp.toPx(), + 14f.dp.toPx(), chromaticAberration = true, ) }, diff --git a/app/src/main/res/drawable/aurora_back.xml b/app/src/main/res/drawable/aurora_back.xml new file mode 100644 index 0000000..7fa71c0 --- /dev/null +++ b/app/src/main/res/drawable/aurora_back.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/aurora_back_selected.xml b/app/src/main/res/drawable/aurora_back_selected.xml new file mode 100644 index 0000000..066d9b6 --- /dev/null +++ b/app/src/main/res/drawable/aurora_back_selected.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/aurora_chevron_right.xml b/app/src/main/res/drawable/aurora_chevron_right.xml new file mode 100644 index 0000000..e4964db --- /dev/null +++ b/app/src/main/res/drawable/aurora_chevron_right.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/aurora_chevron_right_selected.xml b/app/src/main/res/drawable/aurora_chevron_right_selected.xml new file mode 100644 index 0000000..636c392 --- /dev/null +++ b/app/src/main/res/drawable/aurora_chevron_right_selected.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/aurora_eye_off.xml b/app/src/main/res/drawable/aurora_eye_off.xml new file mode 100644 index 0000000..c24e82d --- /dev/null +++ b/app/src/main/res/drawable/aurora_eye_off.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/app/src/main/res/drawable/aurora_eye_off_selected.xml b/app/src/main/res/drawable/aurora_eye_off_selected.xml new file mode 100644 index 0000000..3cb1b6c --- /dev/null +++ b/app/src/main/res/drawable/aurora_eye_off_selected.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/app/src/main/res/drawable/aurora_more.xml b/app/src/main/res/drawable/aurora_more.xml new file mode 100644 index 0000000..9236d95 --- /dev/null +++ b/app/src/main/res/drawable/aurora_more.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/aurora_more_selected.xml b/app/src/main/res/drawable/aurora_more_selected.xml new file mode 100644 index 0000000..aa7b45e --- /dev/null +++ b/app/src/main/res/drawable/aurora_more_selected.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/aurora_star.xml b/app/src/main/res/drawable/aurora_star.xml new file mode 100644 index 0000000..4e523b7 --- /dev/null +++ b/app/src/main/res/drawable/aurora_star.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/aurora_star_selected.xml b/app/src/main/res/drawable/aurora_star_selected.xml new file mode 100644 index 0000000..2bb3b3c --- /dev/null +++ b/app/src/main/res/drawable/aurora_star_selected.xml @@ -0,0 +1,10 @@ + + + + diff --git a/memory/2026-08-27.md b/memory/2026-08-27.md index 86ce9a1..4e167e4 100644 --- a/memory/2026-08-27.md +++ b/memory/2026-08-27.md @@ -112,3 +112,9 @@ 文件变更: data/EmbyApiModels.kt、data/EmbyRepository.kt、navigation/AppNavHost.kt、screens/VideoPlayerScreen.kt、screens/HomeScreen.kt、ui/design/LumaDesignSystem.kt、ui/AppBottomBar.kt、ui/liquidglass/LiquidBottomTabs.kt、ui/liquidglass/LiquidBottomTab.kt、memory/*。 教训: 模拟器不能安全采样 Kyant Backdrop,降级态需用连续胶囊渐变和局部高光保持玻璃层次;提交时排除本地参考仓库和媒体素材。 标签: #lumaplayer #emby #playback #cache #liquid-glass #renderthread + +[PROJECT:lumaplayer] 底栏图标与安全降级玻璃收尾 +结论: 底栏图标放大约 10%,降级玻璃固定最终 Capsule 裁剪并采用白色透光、弱蓝色选中层;原生 AndroidLiquidGlass-kmp 路径空闲态保留轻微 Lens,详情/播放器请求失败不再卡加载;Debug/Lint/Unit 均成功,API 35 模拟器启动和导航点击无崩溃。 +文件变更: ui/AppBottomBar.kt、ui/design/LumaDesignSystem.kt、ui/liquidglass/LiquidBottomTabs.kt、navigation/AppNavHost.kt、Aurora 图标资源、memory/*。 +教训: 模拟器仍不能安全启用 Kyant RenderEffect 采样,降级路径必须在最终尺寸上裁剪,原生路径只能在通过设备与帧率门控后启用。 +标签: #lumaplayer #bottom-bar #aurora #liquid-glass #renderthread #build diff --git a/memory/MEMORY.md b/memory/MEMORY.md index 17ec76b..2104d4d 100644 --- a/memory/MEMORY.md +++ b/memory/MEMORY.md @@ -2,9 +2,9 @@ - 项目:lumaplayer,Android Kotlin/Compose 媒体库客户端。 - 当前 Git 分支:`master`。 -- 最近同步:远端与本地基线均为 `5df288b`,当前工作区有 UI 设计改动。 +- 最近同步:`master` 已完成底栏图标与安全降级玻璃收尾,提交前工作区仅保留本地参考素材未跟踪。 - 当前实现:底栏与安全的 Glass 组件使用 Kyant0 Backdrop 2.0.1;安全且实测帧率不低于 48fps 时使用 Backdrop + blur/vibrancy/lens,模拟器、低帧率或低版本使用渐变毛玻璃降级;页面捕获层仅服务根 Tab/底栏,子页玻璃无源时安全降级;添加服务器为独立普通表单页面并支持 HTTP/HTTPS;页面导航使用 Navigation Compose。 - 最新稳定性策略:通过 `LumaGlassRuntime` 识别常见 Android Emulator GPU,模拟器跳过 Kyant RenderEffect/GraphicsLayer 采样,真机保留 Backdrop;播放器不捕获 AndroidView。 - 当前 UI:新增 `ui/design` 令牌、公共加载/空状态/按钮、Aurora 图标子集和授权说明;主要页面已接入统一颜色、间距、圆角和动效。 -- 构建验证:Backdrop 层级收敛、模拟器安全降级、播放器采样隔离和 GlassSheet 收尾后 `assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功;已在 `sdk_gphone64_arm64` 模拟器安装启动复核,未出现 `SIGSEGV/RenderThread`,尚未做真机录屏比对。 +- 构建验证:Backdrop 层级收敛、模拟器安全降级、播放器采样隔离和 GlassSheet 收尾后 `assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功;已在 `sdk_gphone64_arm64` API 35 模拟器安装启动并点击 Vision/Settings/语言菜单,未出现 `SIGSEGV/RenderThread`,尚未做真机录屏比对。 - 详细状态见 [projects.md](projects.md),踩坑见 [lessons.md](lessons.md)。 diff --git a/memory/projects.md b/memory/projects.md index b005a5b..12ee32d 100644 --- a/memory/projects.md +++ b/memory/projects.md @@ -32,3 +32,4 @@ - 2026-08-27:再次加强模拟器识别,增加 x86 ABI、board 和 emu 设备名拦截;详情返回按钮统一为 Luma glass,详情操作目标补足 48dp;`assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功。 - 2026-08-27:播放器按 Emby Playback Check-in 约定补齐 `PlaySessionId`、`MediaSourceId`、`PositionTicks`、`CanSeek`、`QueueableMediaTypes` 和事件字段;播放开始/暂停/继续/拖动/播放中进度/停止统一进入串行队列,普通进度按 10 秒或位置变化阈值上报。 - 2026-08-27:首页增加按服务器身份隔离的进程内内容缓存,普通页面返回不重复请求,显式重试才刷新;玻璃运行时增加 Choreographer 帧率门控,底栏高帧率安全设备走本地 AndroidLiquidGlass-kmp 的 Lens,模拟器/低帧率使用连续胶囊毛玻璃和选中高光;`assembleDebug`、`lintDebug`、模拟器安装启动复核成功。 +- 2026-08-27:底栏普通图标调整为 33dp、搜索图标 37dp;降级玻璃增加最终 Capsule 裁剪并减弱蓝色选中填充,原生路径空闲态保留轻微 Lens;详情/播放器/剧集加载和播放地址失败时回落到错误态;最终 APK 在 API 35 模拟器启动、Vision/Settings/语言菜单点击复核成功。 From 3b322012cfee46ed40f28205b9e283502a81748b Mon Sep 17 00:00:00 2001 From: BoatingCloud <1759888028@qq.com> Date: Thu, 27 Aug 2026 22:45:50 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E6=94=B6=E6=95=9B=E7=8E=BB=E7=92=83?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E5=B9=B6=E5=AE=8C=E5=96=84=E6=B7=B1=E8=89=B2?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/myapplication/MainApp.kt | 13 +++++ .../myapplication/screens/LibraryScreen.kt | 4 +- .../myapplication/screens/SettingsScreen.kt | 4 +- .../example/myapplication/ui/LiquidGlass.kt | 55 +++++++------------ .../myapplication/ui/design/LumaComponents.kt | 18 ++++-- .../ui/design/LumaDesignSystem.kt | 16 ++++-- memory/2026-08-27.md | 6 ++ memory/MEMORY.md | 1 + memory/projects.md | 1 + 9 files changed, 69 insertions(+), 49 deletions(-) diff --git a/app/src/main/kotlin/com/example/myapplication/MainApp.kt b/app/src/main/kotlin/com/example/myapplication/MainApp.kt index 161b283..9b0214d 100644 --- a/app/src/main/kotlin/com/example/myapplication/MainApp.kt +++ b/app/src/main/kotlin/com/example/myapplication/MainApp.kt @@ -1,5 +1,6 @@ package com.example.myapplication +import android.app.Activity import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.fadeIn @@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -26,10 +28,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.core.view.WindowCompat import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyRepository import com.example.myapplication.data.EmbyServer @@ -91,6 +95,15 @@ fun MainApp() { if (!showStartScreen) { MiuixTheme { + val view = LocalView.current + val isDark = AppTheme.isDark + SideEffect { + (view.context as? Activity)?.window?.let { window -> + val controller = WindowCompat.getInsetsController(window, view) + controller.isAppearanceLightStatusBars = !isDark + controller.isAppearanceLightNavigationBars = !isDark + } + } val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route var lastRootTab by rememberSaveable { mutableIntStateOf(0) } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt index d2cd865..3d1e233 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -166,7 +166,7 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC var clicked by remember { mutableStateOf(false) } val pressBg by animateColorAsState(targetValue = if (clicked) LumaColors.glassHighlight.copy(alpha = 0.24f) 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)) { + GlassSurface(modifier = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { clicked = true; onClick() }, 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()) { @@ -222,7 +222,7 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, var clicked by remember { mutableStateOf(false) } val pressBg by animateColorAsState(targetValue = if (clicked) LumaColors.glassHighlight.copy(alpha = 0.24f) 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)) { + GlassSurface(modifier = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { clicked = true; onClick() }, 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()) { diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt index 8cf6294..823ffed 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt @@ -75,6 +75,7 @@ import com.example.myapplication.ui.design.LumaShapes import com.example.myapplication.ui.design.LumaSpacing import com.example.myapplication.ui.design.LumaTypography import com.example.myapplication.ui.design.lumaGlass +import com.example.myapplication.ui.design.lumaFallbackGlass import com.kyant.backdrop.Backdrop import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.drawBackdrop @@ -467,7 +468,7 @@ private fun LanguageMenuOverlay( onDrawSurface = { drawRect(surface) }, ) } else { - Modifier.background(LumaColors.fallbackGlass) + Modifier.lumaFallbackGlass(shape, border = false) }, ) .border(0.7.dp, border, shape) @@ -505,6 +506,7 @@ private fun LanguageOption( Row( Modifier .fillMaxWidth() + .heightIn(min = 48.dp) .clip(RoundedCornerShape(18.dp)) .clickable(onClick = onClick) .padding(horizontal = 10.dp, vertical = 8.dp), diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt index 12dc0ef..c30a607 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -98,6 +98,7 @@ import com.example.myapplication.ui.design.LumaShapes import com.example.myapplication.ui.design.LumaTypography import com.example.myapplication.ui.design.LumaGlassRuntime import com.example.myapplication.ui.design.lumaGlass +import com.example.myapplication.ui.design.lumaFallbackGlass internal data class NavTab( val icon: LumaIcon, @@ -209,9 +210,10 @@ fun SegmentedControl( modifier: Modifier = Modifier, ) { val backdrop = LocalGlassBackdrop.current + val containerShape = RoundedCornerShape(20.dp) Row( modifier - .clip(RoundedCornerShape(20.dp)) + .clip(containerShape) .then( if (backdrop != null && LumaGlassRuntime.useNativeBackdrop) { Modifier.drawBackdrop( @@ -224,10 +226,10 @@ fun SegmentedControl( onDrawSurface = { drawRect(GlassTokens.glassFillSoft) }, ) } else { - Modifier.background(GlassTokens.glassFillSoft) + Modifier.lumaFallbackGlass(containerShape, border = false) } ) - .border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(20.dp)) + .border(0.5.dp, GlassTokens.glassBorder, containerShape) .padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -276,7 +278,7 @@ fun SegmentedControl( ) .then( if (sel && (backdrop == null || !LumaGlassRuntime.useNativeBackdrop)) { - Modifier.background(selectedFill) + Modifier.lumaFallbackGlass(RoundedCornerShape(16.dp), selected = true, border = false) } else { Modifier } @@ -472,7 +474,7 @@ fun LiquidGlassPopupOverlay( onDrawSurface = { drawRect(surface) }, ) } else { - Modifier.background(LumaColors.fallbackGlass) + Modifier.lumaFallbackGlass(shape, border = false) } ) .border(0.7.dp, border, shape) @@ -537,23 +539,13 @@ fun LiquidGlassButton( scaleY = scale alpha = if (enabled) 1f else 0.52f } - .clip(shape) - .then( - if (backdrop != null && LumaGlassRuntime.useNativeBackdrop) { - Modifier.drawBackdrop( - backdrop = backdrop, - shape = { RoundedRectangle(cornerRadius) }, - effects = { - vibrancy() - blur(8.dp.toPx()) - }, - onDrawSurface = { drawRect(fill) }, - ) - } else { - Modifier.background(fill) - } + .lumaGlass( + backdrop = backdrop, + radius = cornerRadius, + level = LumaGlassLevel.Interactive, + tint = fill, + borderColor = GlassTokens.glassBorder, ) - .border(0.5.dp, GlassTokens.glassBorder, shape) .clickable(enabled = enabled, indication = null, interactionSource = interactionSource, onClick = onClick) .padding(horizontal = 16.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically, @@ -716,20 +708,15 @@ fun GlassInputField( Text(label, fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) val shape = RoundedCornerShape(12.dp) Box( - Modifier.fillMaxWidth().clip(shape) - .then( - if (backdrop != null && LumaGlassRuntime.useNativeBackdrop) { - Modifier.drawBackdrop( - backdrop = backdrop, - shape = { RoundedRectangle(12.dp) }, - effects = { vibrancy(); blur(8.dp.toPx()) }, - onDrawSurface = { drawRect(GlassTokens.glassFillSoft) }, - ) - } else { - Modifier.background(GlassTokens.glassFillSoft) - } + Modifier.fillMaxWidth() + .lumaGlass( + backdrop = backdrop, + radius = 12.dp, + level = LumaGlassLevel.Clear, + tint = GlassTokens.glassFillSoft, + border = false, ) - .border(1.dp, if (isError) GlassTokens.danger else GlassTokens.glassBorder, shape) + .border(0.7.dp, if (isError) GlassTokens.danger else GlassTokens.glassBorder, shape) .padding(horizontal = 14.dp, vertical = 12.dp), ) { BasicTextField( diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt index 9d3f877..9b4eb43 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt @@ -106,12 +106,18 @@ fun LumaSectionTitle( ) { Text(title, color = LumaColors.text, fontSize = LumaTypography.title, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) if (action != null) { - Text( - action, - color = LumaColors.accent, - fontSize = LumaTypography.secondary, - modifier = Modifier.clickable(enabled = onAction != null, indication = null, interactionSource = remember { MutableInteractionSource() }) { onAction?.invoke() }, - ) + Box( + Modifier + .heightIn(min = 48.dp) + .clickable( + enabled = onAction != null, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { onAction?.invoke() }, + contentAlignment = Alignment.Center, + ) { + Text(action, color = LumaColors.accent, fontSize = LumaTypography.secondary) + } } } } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt index 8ce3bb8..e2274e4 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt @@ -201,15 +201,13 @@ object LumaColors { val glassShadow: Color get() = if (isDark) Color.Black.copy(alpha = 0.32f) else Color(0xFF344054).copy(alpha = 0.10f) - /** Stable tint used when native backdrop sampling is disabled on an emulator or old API. */ - val fallbackGlass: Color - get() = if (isDark) Color(0xFF1A2330).copy(alpha = 0.86f) else Color(0xFFE4E9F0).copy(alpha = 0.86f) } /** Stable frosted fallback for devices where Kyant Backdrop is disabled. */ fun Modifier.lumaFallbackGlass( shape: Shape, selected: Boolean = false, + border: Boolean = true, ): Modifier { val light = !LumaColors.isDark val top = if (selected) { @@ -283,7 +281,7 @@ fun Modifier.lumaFallbackGlass( Modifier }, ) - .border(0.6.dp, edge, shape) + .then(if (border) Modifier.border(0.6.dp, edge, shape) else Modifier) .clip(shape) } @@ -331,7 +329,6 @@ fun Modifier.lumaGlass( val surfaceTint = tint ?: spec.tint val usesDefaultGlassTint = tint == null || tint == LumaColors.clearTint || tint == LumaColors.regularTint || tint == LumaColors.strongTint || tint == LumaColors.floatingTint - val fallbackSurface = if (usesDefaultGlassTint) LumaColors.fallbackGlass else surfaceTint val edgeColor = borderColor ?: LumaColors.glassBorder val safeBackdrop = backdrop.takeIf { LumaGlassRuntime.useNativeBackdrop } return this @@ -355,7 +352,14 @@ fun Modifier.lumaGlass( onDrawSurface = { drawRect(surfaceTint) }, ) } else { - Modifier.background(fallbackSurface) + if (usesDefaultGlassTint) { + Modifier.lumaFallbackGlass(shape, border = false) + } else { + Modifier.background( + surfaceTint.copy(alpha = surfaceTint.alpha.coerceAtMost(0.34f)), + shape, + ) + } }, ) .then(if (border) Modifier.border(0.6.dp, edgeColor, shape) else Modifier) diff --git a/memory/2026-08-27.md b/memory/2026-08-27.md index 4e167e4..88a4975 100644 --- a/memory/2026-08-27.md +++ b/memory/2026-08-27.md @@ -118,3 +118,9 @@ 文件变更: ui/AppBottomBar.kt、ui/design/LumaDesignSystem.kt、ui/liquidglass/LiquidBottomTabs.kt、navigation/AppNavHost.kt、Aurora 图标资源、memory/*。 教训: 模拟器仍不能安全启用 Kyant RenderEffect 采样,降级路径必须在最终尺寸上裁剪,原生路径只能在通过设备与帧率门控后启用。 标签: #lumaplayer #bottom-bar #aurora #liquid-glass #renderthread #build + +[PROJECT:lumaplayer] 公共玻璃降级与深色系统栏收尾 +结论: 分段控件、按钮、输入框、资源库菜单、主题菜单和语言菜单统一复用渐变毛玻璃降级,深色主题系统栏图标同步;Debug/Lint/Unit 构建及 API35 模拟器回归通过。 +文件变更: MainApp.kt、screens/LibraryScreen.kt、screens/SettingsScreen.kt、ui/LiquidGlass.kt、ui/design/LumaComponents.kt、ui/design/LumaDesignSystem.kt、memory/*。 +教训: 模拟器继续禁用 Kyant RenderEffect 采样,截图中的玻璃为安全降级态;真实 Backdrop 仅在设备安全门和帧率门控同时通过时启用。 +标签: #lumaplayer #glass #fallback #dark-mode #verification diff --git a/memory/MEMORY.md b/memory/MEMORY.md index 2104d4d..60cf5a6 100644 --- a/memory/MEMORY.md +++ b/memory/MEMORY.md @@ -8,3 +8,4 @@ - 当前 UI:新增 `ui/design` 令牌、公共加载/空状态/按钮、Aurora 图标子集和授权说明;主要页面已接入统一颜色、间距、圆角和动效。 - 构建验证:Backdrop 层级收敛、模拟器安全降级、播放器采样隔离和 GlassSheet 收尾后 `assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功;已在 `sdk_gphone64_arm64` API 35 模拟器安装启动并点击 Vision/Settings/语言菜单,未出现 `SIGSEGV/RenderThread`,尚未做真机录屏比对。 - 详细状态见 [projects.md](projects.md),踩坑见 [lessons.md](lessons.md)。 +- 最新收尾:公共分段控件、按钮、输入框、主题/语言/资源库弹层统一走同一套降级毛玻璃;状态栏/导航栏图标随深色主题同步;Debug/Lint/Unit 与 API35 模拟器回归通过。 diff --git a/memory/projects.md b/memory/projects.md index 12ee32d..8ff0c57 100644 --- a/memory/projects.md +++ b/memory/projects.md @@ -33,3 +33,4 @@ - 2026-08-27:播放器按 Emby Playback Check-in 约定补齐 `PlaySessionId`、`MediaSourceId`、`PositionTicks`、`CanSeek`、`QueueableMediaTypes` 和事件字段;播放开始/暂停/继续/拖动/播放中进度/停止统一进入串行队列,普通进度按 10 秒或位置变化阈值上报。 - 2026-08-27:首页增加按服务器身份隔离的进程内内容缓存,普通页面返回不重复请求,显式重试才刷新;玻璃运行时增加 Choreographer 帧率门控,底栏高帧率安全设备走本地 AndroidLiquidGlass-kmp 的 Lens,模拟器/低帧率使用连续胶囊毛玻璃和选中高光;`assembleDebug`、`lintDebug`、模拟器安装启动复核成功。 - 2026-08-27:底栏普通图标调整为 33dp、搜索图标 37dp;降级玻璃增加最终 Capsule 裁剪并减弱蓝色选中填充,原生路径空闲态保留轻微 Lens;详情/播放器/剧集加载和播放地址失败时回落到错误态;最终 APK 在 API 35 模拟器启动、Vision/Settings/语言菜单点击复核成功。 +- 2026-08-27:收敛公共分段控件、弹出菜单、按钮、输入框及语言菜单的降级玻璃路径,移除纯色方框;补充深色主题系统栏图标同步和操作标题 48dp 点击区域。`assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功,API 35 模拟器验证设置/语言/主题/关于/服务器添加链路无崩溃;本地参考素材仍未跟踪。 From 27154a3a4e4e70cb66fcc9d29f9c79714b089c1f Mon Sep 17 00:00:00 2001 From: BoatingCloud <1759888028@qq.com> Date: Thu, 27 Aug 2026 22:59:12 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=99=A8=E8=BF=9B=E5=BA=A6=E6=9D=A1=E4=B8=8E=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 - .../myapplication/screens/HomeScreen.kt | 23 ++- .../myapplication/screens/RecordsScreen.kt | 14 +- .../myapplication/screens/SearchScreen.kt | 17 ++- .../screens/VideoPlayerScreen.kt | 9 +- .../myapplication/ui/design/LumaComponents.kt | 139 ++++++++++++++++++ memory/2026-08-27.md | 6 + memory/MEMORY.md | 1 + memory/projects.md | 1 + 9 files changed, 197 insertions(+), 14 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d8db8ad..69072a4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,7 +45,6 @@ dependencies { 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") diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index dcd6a81..8d877cf 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -71,7 +71,7 @@ import com.example.myapplication.ui.design.LumaIconView import com.example.myapplication.ui.design.LumaAsyncImage import com.example.myapplication.ui.design.LumaEmptyState import com.example.myapplication.ui.design.LumaErrorState -import com.example.myapplication.ui.design.LumaLoading +import com.example.myapplication.ui.design.LumaSkeleton import com.example.myapplication.ui.design.LumaMotion import com.example.myapplication.ui.design.LumaShapes import com.example.myapplication.ui.design.LumaSpacing @@ -658,8 +658,25 @@ fun HomeScreen( EmptyHomeState() } } else if (isLoading) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - LumaLoading(label = stringResource(Res.string.loading_home)) + Column( + Modifier + .fillMaxSize() + .padding(horizontal = LumaSpacing.md, vertical = LumaSpacing.xl), + verticalArrangement = Arrangement.spacedBy(LumaSpacing.md), + ) { + LumaSkeleton(Modifier.fillMaxWidth().height(290.dp), RoundedCornerShape(LumaShapes.large)) + LumaSkeleton(Modifier.fillMaxWidth(0.48f).height(22.dp), RoundedCornerShape(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + repeat(2) { + LumaSkeleton(Modifier.weight(1f).height(170.dp), RoundedCornerShape(LumaShapes.small)) + } + } + LumaSkeleton(Modifier.fillMaxWidth(0.34f).height(22.dp), RoundedCornerShape(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + repeat(3) { + LumaSkeleton(Modifier.weight(1f).height(150.dp), RoundedCornerShape(LumaShapes.small)) + } + } } } else if (loadError) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt index 13f0e7e..b41a9f7 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -61,7 +61,7 @@ import com.example.myapplication.ui.design.LumaEmptyState import com.example.myapplication.ui.design.LumaErrorState import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaAsyncImage -import com.example.myapplication.ui.design.LumaLoading +import com.example.myapplication.ui.design.LumaSkeleton import com.example.myapplication.ui.design.LumaShapes import top.yukonga.miuix.kmp.basic.Text @@ -130,8 +130,16 @@ fun RecordsScreen( subtitle = stringResource(Res.string.empty_calendar_sub), ) } else if (loading) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - LumaLoading(label = stringResource(Res.string.loading_favorites)) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 150.dp), + 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(6) { + LumaSkeleton(Modifier.fillMaxWidth().aspectRatio(0.68f), RoundedCornerShape(LumaShapes.small)) + } } } else if (loadError) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt index d9f27df..3725cc3 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt @@ -50,7 +50,7 @@ import com.example.myapplication.ui.design.LumaEmptyState import com.example.myapplication.ui.design.LumaErrorState import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaAsyncImage -import com.example.myapplication.ui.design.LumaLoading +import com.example.myapplication.ui.design.LumaSkeleton import kotlinx.coroutines.delay import top.yukonga.miuix.kmp.basic.Text @@ -124,8 +124,19 @@ fun SearchScreen( title = stringResource(Res.string.search_title), subtitle = stringResource(Res.string.search_empty_input), ) - loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - LumaLoading(label = stringResource(Res.string.loading_search)) + loading -> LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 150.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + top = 16.dp, + bottom = bottomBarContentPadding(86.dp).calculateBottomPadding(), + ), + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(6) { + LumaSkeleton(Modifier.fillMaxWidth().aspectRatio(0.68f), RoundedCornerShape(16.dp)) + } } loadError -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LumaErrorState( diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt index 2765a01..6d3585c 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt @@ -34,8 +34,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -77,6 +75,7 @@ import com.example.myapplication.R as Res import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaIconView import com.example.myapplication.ui.design.LumaLoading +import com.example.myapplication.ui.design.LumaSeekBar import com.example.myapplication.ui.design.LumaGlassLevel import com.example.myapplication.ui.design.lumaGlass import com.example.myapplication.ui.design.LumaShapes @@ -391,7 +390,7 @@ fun VideoPlayerScreen( Row(verticalAlignment = Alignment.CenterVertically) { Text(formatMs(currentPositionMs), color = Color.White, fontSize = 12.sp) - Slider( + LumaSeekBar( value = if (isSeeking) seekPosition else progress, onValueChange = { v -> isSeeking = true; seekPosition = v }, onValueChangeFinished = { @@ -400,7 +399,9 @@ fun VideoPlayerScreen( latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, "TimeUpdate") }, modifier = Modifier.weight(1f).padding(horizontal = 12.dp), - colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = Color.White) + activeColor = Color.White, + trackColor = Color.White.copy(alpha = 0.32f), + thumbColor = Color.White, ) Text(formatMs(totalDurationMs), color = Color.White, fontSize = 12.sp) } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt index 9b4eb43..8b9863c 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt @@ -1,7 +1,13 @@ package com.example.myapplication.ui.design import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.animation.core.LinearEasing +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState @@ -15,21 +21,42 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import top.yukonga.miuix.kmp.basic.Text +import kotlin.math.abs @Composable fun LumaAsyncImage( @@ -122,6 +149,118 @@ fun LumaSectionTitle( } } +@Composable +fun LumaSeekBar( + value: Float, + onValueChange: (Float) -> Unit, + onValueChangeFinished: () -> Unit, + modifier: Modifier = Modifier, + activeColor: Color = LumaColors.accent, + trackColor: Color = LumaColors.divider, + thumbColor: Color = activeColor, +) { + val density = LocalDensity.current + var trackWidthPx by remember { mutableFloatStateOf(0f) } + var dragging by remember { mutableStateOf(false) } + val currentValue by rememberUpdatedState(value.coerceIn(0f, 1f)) + val currentOnValueChange by rememberUpdatedState(onValueChange) + val currentOnValueChangeFinished by rememberUpdatedState(onValueChangeFinished) + val thumbRadius = with(density) { if (dragging) 7.dp.toPx() else 4.dp.toPx() } + + fun updateFromPosition(x: Float) { + if (trackWidthPx > 0f) currentOnValueChange((x / trackWidthPx).coerceIn(0f, 1f)) + } + + Canvas( + modifier + .heightIn(min = 48.dp) + .onSizeChanged { trackWidthPx = it.width.toFloat() } + .semantics { + progressBarRangeInfo = ProgressBarRangeInfo(currentValue, 0f..1f) + setProgress { requestedValue -> + currentOnValueChange(requestedValue.coerceIn(0f, 1f)) + currentOnValueChangeFinished() + true + } + } + .pointerInput(trackWidthPx) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + dragging = true + updateFromPosition(down.position.x) + var lastX = down.position.x + var moved = false + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull() ?: break + if (change.changedToUp()) { + updateFromPosition(change.position.x) + change.consume() + dragging = false + currentOnValueChangeFinished() + break + } + val delta = change.position.x - lastX + if (!moved && abs(change.position.x - down.position.x) > viewConfiguration.touchSlop) { + moved = true + } + if (moved || delta != 0f) { + updateFromPosition(change.position.x) + change.consume() + } + lastX = change.position.x + } + dragging = false + } + }, + ) { + val centerY = size.height / 2f + val trackHeight = 4.dp.toPx() + val progressWidth = size.width * currentValue + val radius = trackHeight / 2f + drawRoundRect( + color = trackColor, + topLeft = Offset(0f, centerY - radius), + size = Size(size.width, trackHeight), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius), + ) + drawRoundRect( + color = activeColor, + topLeft = Offset(0f, centerY - radius), + size = Size(progressWidth, trackHeight), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius), + ) + drawCircle( + color = thumbColor, + radius = thumbRadius, + center = Offset(progressWidth.coerceIn(0f, size.width), centerY), + ) + } +} + +@Composable +fun LumaSkeleton( + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(LumaShapes.small), +) { + val transition = rememberInfiniteTransition(label = "lumaSkeleton") + val alpha by transition.animateFloat( + initialValue = 0.52f, + targetValue = 0.86f, + animationSpec = infiniteRepeatable( + animation = tween(900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "lumaSkeletonAlpha", + ) + Box( + modifier + .clip(shape) + .background(LumaColors.backgroundElevated) + .graphicsLayer { this.alpha = alpha }, + ) +} + @Composable fun LumaLoading( modifier: Modifier = Modifier, diff --git a/memory/2026-08-27.md b/memory/2026-08-27.md index 88a4975..2fe98cd 100644 --- a/memory/2026-08-27.md +++ b/memory/2026-08-27.md @@ -124,3 +124,9 @@ 文件变更: MainApp.kt、screens/LibraryScreen.kt、screens/SettingsScreen.kt、ui/LiquidGlass.kt、ui/design/LumaComponents.kt、ui/design/LumaDesignSystem.kt、memory/*。 教训: 模拟器继续禁用 Kyant RenderEffect 采样,截图中的玻璃为安全降级态;真实 Backdrop 仅在设备安全门和帧率门控同时通过时启用。 标签: #lumaplayer #glass #fallback #dark-mode #verification + +[PROJECT:lumaplayer] 播放器进度与媒体加载态 +结论: 用自绘可访问进度条替代 Material Slider,并为 Home、Vision 收藏、Search 增加统一脉冲骨架;完整 Debug/Lint/Unit 构建通过,参考素材未跟踪。 +文件变更: app/build.gradle.kts、screens/HomeScreen.kt、screens/RecordsScreen.kt、screens/SearchScreen.kt、screens/VideoPlayerScreen.kt、ui/design/LumaComponents.kt、memory/*。 +教训: 启动截图需等待启动流程完成后再判断加载态;当前 API35 模拟器仍只验证安全降级玻璃,不能代替真机 Backdrop 逐帧比对。 +标签: #lumaplayer #loading #skeleton #player #accessibility #build diff --git a/memory/MEMORY.md b/memory/MEMORY.md index 60cf5a6..2fa3215 100644 --- a/memory/MEMORY.md +++ b/memory/MEMORY.md @@ -9,3 +9,4 @@ - 构建验证:Backdrop 层级收敛、模拟器安全降级、播放器采样隔离和 GlassSheet 收尾后 `assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功;已在 `sdk_gphone64_arm64` API 35 模拟器安装启动并点击 Vision/Settings/语言菜单,未出现 `SIGSEGV/RenderThread`,尚未做真机录屏比对。 - 详细状态见 [projects.md](projects.md),踩坑见 [lessons.md](lessons.md)。 - 最新收尾:公共分段控件、按钮、输入框、主题/语言/资源库弹层统一走同一套降级毛玻璃;状态栏/导航栏图标随深色主题同步;Debug/Lint/Unit 与 API35 模拟器回归通过。 +- 最新改动:播放器进度条改为自绘可访问 SeekBar,媒体首页、Vision 收藏和搜索加载态改为统一骨架占位;移除未使用的 Material 图标依赖,完整 Debug/Lint/Unit 通过。 diff --git a/memory/projects.md b/memory/projects.md index 8ff0c57..b2125f8 100644 --- a/memory/projects.md +++ b/memory/projects.md @@ -34,3 +34,4 @@ - 2026-08-27:首页增加按服务器身份隔离的进程内内容缓存,普通页面返回不重复请求,显式重试才刷新;玻璃运行时增加 Choreographer 帧率门控,底栏高帧率安全设备走本地 AndroidLiquidGlass-kmp 的 Lens,模拟器/低帧率使用连续胶囊毛玻璃和选中高光;`assembleDebug`、`lintDebug`、模拟器安装启动复核成功。 - 2026-08-27:底栏普通图标调整为 33dp、搜索图标 37dp;降级玻璃增加最终 Capsule 裁剪并减弱蓝色选中填充,原生路径空闲态保留轻微 Lens;详情/播放器/剧集加载和播放地址失败时回落到错误态;最终 APK 在 API 35 模拟器启动、Vision/Settings/语言菜单点击复核成功。 - 2026-08-27:收敛公共分段控件、弹出菜单、按钮、输入框及语言菜单的降级玻璃路径,移除纯色方框;补充深色主题系统栏图标同步和操作标题 48dp 点击区域。`assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功,API 35 模拟器验证设置/语言/主题/关于/服务器添加链路无崩溃;本地参考素材仍未跟踪。 +- 2026-08-27:播放器进度条替换为自绘拖拽控件并补充无障碍进度调节,Home/Vision/Search 的媒体加载态改为统一骨架占位;移除未使用的 Material 图标依赖。`assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功。 From 56d181bce3959072d51f7998e22eb3f68f10e510 Mon Sep 17 00:00:00 2001 From: BoatingCloud <1759888028@qq.com> Date: Thu, 27 Aug 2026 23:13:09 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=BA=95=E6=A0=8F?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E4=B8=8E=E9=99=8D=E7=BA=A7=E7=8E=BB=E7=92=83?= =?UTF-8?q?=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../myapplication/screens/LibraryScreen.kt | 15 ++++++---- .../example/myapplication/ui/AppBottomBar.kt | 4 +-- .../example/myapplication/ui/LiquidGlass.kt | 2 +- .../ui/design/LumaDesignSystem.kt | 30 +++++++++++-------- memory/2026-08-27.md | 6 ++++ memory/projects.md | 1 + 6 files changed, 37 insertions(+), 21 deletions(-) diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt index 3d1e233..727cfd0 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -173,7 +173,7 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC LumaAsyncImage(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) { - LumaIconView(LumaIcon.Play, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp)) + LumaIconView(LumaIcon.Server, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp)) } } Spacer(Modifier.width(12.dp)) @@ -237,7 +237,7 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, Modifier.size(48.dp).clip(RoundedCornerShape(14.dp)).background(Brush.linearGradient(listOf(tint, tint.copy(alpha = 0.7f)))), contentAlignment = Alignment.Center, ) { - LumaIconView(LumaIcon.Play, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp)) + LumaIconView(LumaIcon.Server, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp)) } } Spacer(Modifier.width(10.dp)) @@ -517,12 +517,17 @@ internal fun IconLibraryManagerScreenContent( ) { Box(Modifier.width(86.dp)) { Box( - Modifier.size(34.dp) - .lumaGlass(radius = 17.dp, level = com.example.myapplication.ui.design.LumaGlassLevel.Floating) + Modifier.size(48.dp) .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onBack() }, contentAlignment = Alignment.Center, ) { - LumaIconView(LumaIcon.Close, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(19.dp)) + Box( + Modifier.size(34.dp) + .lumaGlass(radius = 17.dp, level = com.example.myapplication.ui.design.LumaGlassLevel.Floating), + contentAlignment = Alignment.Center, + ) { + LumaIconView(LumaIcon.Close, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(19.dp)) + } } } Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt index 2765346..b78eaa3 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt @@ -74,7 +74,7 @@ fun LiquidAppBottomBar( selected = selectedTab == index, contentDescription = stringResource(tab.labelRes), tint = if (isGhostLayer) Color.White else if (selectedTab == index) LumaColors.accent else inactive, - modifier = Modifier.size(33.dp), + modifier = Modifier.size(36.dp), ) Spacer(Modifier.height(3.dp)) Text( @@ -136,7 +136,7 @@ fun LiquidAppBottomBar( icon = LumaIcon.Search, contentDescription = null, tint = if (searchSelected) LumaColors.accent else inactive, - modifier = Modifier.size(37.dp), + modifier = Modifier.size(41.dp), ) Text( text = stringResource(Res.string.search), diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt index c30a607..f64a30d 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -358,7 +358,7 @@ fun GlassSearchField( if (value.isNotEmpty()) { Box( Modifier - .size(40.dp) + .size(48.dp) .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt index e2274e4..99bdbd2 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt @@ -221,7 +221,11 @@ fun Modifier.lumaFallbackGlass( Color(0xFF121B27).copy(alpha = 0.46f) } val edge = if (selected) { - LumaColors.glassHighlight.copy(alpha = if (light) 0.34f else 0.26f) + if (light) { + Color(0xFF9BA8B8).copy(alpha = 0.28f) + } else { + Color.White.copy(alpha = 0.22f) + } } else { LumaColors.glassHighlight.copy(alpha = if (light) 0.18f else 0.14f) } @@ -229,19 +233,19 @@ fun Modifier.lumaFallbackGlass( if (light) { Brush.horizontalGradient( listOf( - LumaColors.accent.copy(alpha = 0.10f), - Color.White.copy(alpha = 0.40f), - Color.White.copy(alpha = 0.30f), - LumaColors.accent.copy(alpha = 0.08f), + Color.White.copy(alpha = 0.44f), + Color.White.copy(alpha = 0.66f), + Color(0xFFDCE3EB).copy(alpha = 0.48f), + Color.White.copy(alpha = 0.38f), ), ) } else { Brush.horizontalGradient( listOf( - LumaColors.accent.copy(alpha = 0.28f), Color.White.copy(alpha = 0.16f), - Color.White.copy(alpha = 0.08f), - LumaColors.accent.copy(alpha = 0.18f), + Color(0xFF46515F).copy(alpha = 0.30f), + Color.White.copy(alpha = 0.20f), + Color(0xFF202A36).copy(alpha = 0.28f), ), ) } @@ -256,23 +260,23 @@ fun Modifier.lumaFallbackGlass( Modifier.drawWithCache { val leftGlow = Brush.radialGradient( colors = listOf( - LumaColors.accent.copy(alpha = if (light) 0.14f else 0.18f), - LumaColors.accent.copy(alpha = 0.04f), + Color.White.copy(alpha = if (light) 0.26f else 0.18f), + Color.White.copy(alpha = if (light) 0.08f else 0.05f), Color.Transparent, ), - center = Offset(size.width * 0.14f, size.height * 0.18f), + center = Offset(size.width * 0.28f, size.height * 0.18f), radius = size.height * 1.25f, ) val rightGlow = Brush.radialGradient( colors = listOf( - Color.White.copy(alpha = if (light) 0.28f else 0.16f), + Color.White.copy(alpha = if (light) 0.34f else 0.18f), Color.Transparent, ), center = Offset(size.width * 0.86f, size.height * 0.22f), radius = size.height * 0.92f, ) onDrawWithContent { - drawCircle(leftGlow, center = Offset(size.width * 0.14f, size.height * 0.18f), radius = size.height * 1.25f) + drawCircle(leftGlow, center = Offset(size.width * 0.28f, size.height * 0.18f), radius = size.height * 1.25f) drawCircle(rightGlow, center = Offset(size.width * 0.86f, size.height * 0.22f), radius = size.height * 0.92f) drawContent() } diff --git a/memory/2026-08-27.md b/memory/2026-08-27.md index 2fe98cd..34363be 100644 --- a/memory/2026-08-27.md +++ b/memory/2026-08-27.md @@ -130,3 +130,9 @@ 文件变更: app/build.gradle.kts、screens/HomeScreen.kt、screens/RecordsScreen.kt、screens/SearchScreen.kt、screens/VideoPlayerScreen.kt、ui/design/LumaComponents.kt、memory/*。 教训: 启动截图需等待启动流程完成后再判断加载态;当前 API35 模拟器仍只验证安全降级玻璃,不能代替真机 Backdrop 逐帧比对。 标签: #lumaplayer #loading #skeleton #player #accessibility #build + +[PROJECT:lumaplayer] 底栏图标与资源库细节复核 +结论: 底栏普通图标调整为 36dp、搜索图标 41dp,资源库空图标改为服务器图标;搜索清除和图标库返回保留原视觉并补足 48dp 触控范围;API35 模拟器启动、设置页和语言悬浮菜单无异常。 +文件变更: screens/LibraryScreen.kt、ui/AppBottomBar.kt、ui/LiquidGlass.kt、ui/design/LumaDesignSystem.kt。 +教训: 降级玻璃必须保持独立胶囊几何,不能让菜单或选中态参与页面列表测量;本地参考素材继续排除提交。 +标签: #lumaplayer #bottom-bar #accessibility #fallback-glass #emulator #build diff --git a/memory/projects.md b/memory/projects.md index b2125f8..7a553c7 100644 --- a/memory/projects.md +++ b/memory/projects.md @@ -35,3 +35,4 @@ - 2026-08-27:底栏普通图标调整为 33dp、搜索图标 37dp;降级玻璃增加最终 Capsule 裁剪并减弱蓝色选中填充,原生路径空闲态保留轻微 Lens;详情/播放器/剧集加载和播放地址失败时回落到错误态;最终 APK 在 API 35 模拟器启动、Vision/Settings/语言菜单点击复核成功。 - 2026-08-27:收敛公共分段控件、弹出菜单、按钮、输入框及语言菜单的降级玻璃路径,移除纯色方框;补充深色主题系统栏图标同步和操作标题 48dp 点击区域。`assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功,API 35 模拟器验证设置/语言/主题/关于/服务器添加链路无崩溃;本地参考素材仍未跟踪。 - 2026-08-27:播放器进度条替换为自绘拖拽控件并补充无障碍进度调节,Home/Vision/Search 的媒体加载态改为统一骨架占位;移除未使用的 Material 图标依赖。`assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功。 +- 2026-08-27:底栏图标调整为 36dp、搜索图标 41dp,资源库无图标服务器使用正确的服务器图标;搜索清除与图标库返回补足 48dp 触控范围。完整 Debug/Lint/Unit 构建和 API35 模拟器回归通过。