From cfd472016ebb0478adc062d06138a2750678d8a2 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 11 Oct 2026 17:17:34 +0600 Subject: [PATCH] refactor: integrate millennium UI --- composeApp/build.gradle.kts | 13 +- .../core/newpipe/NewPipeService.androidJvm.kt | 11 +- .../spotube/core/di/Modules.android.kt | 3 +- .../LocalNetworkPermission.android.kt | 6 +- .../spotube/media/MediaBrowseHelper.kt | 12 +- .../AndroidLocalMediaDiscoveryService.kt | 36 +- .../media/LocalMediaPermission.android.kt | 9 +- .../composeResources/values/strings.xml | 10 + .../kotlin/dev/krtirtho/spotube/App.kt | 115 +-- .../spotube/core/audioplayer/AudioPlayer.kt | 1 - .../core/audioplayer/AudioPlayerModels.kt | 18 +- .../core/audioplayer/AudioPlayerQueue.kt | 3 +- .../audioplayer/DeviceAudioPlayerQueue.kt | 27 +- .../dev/krtirtho/spotube/core/db/Database.kt | 3 +- .../dev/krtirtho/spotube/core/di/Modules.kt | 21 +- .../spotube/core/discord/DiscordRpcService.kt | 21 +- .../core/discovery/DeviceDiscoveryService.kt | 21 +- .../krtirtho/spotube/core/jam/JamProtocol.kt | 36 +- .../spotube/core/jam/JamRoomClient.kt | 33 +- .../spotube/core/jam/JamRoomService.kt | 36 +- .../core/navigation/NavigationModule.kt | 12 +- .../core/navigation/NavigationState.kt | 6 +- .../spotube/core/newpipe/NewPipeModels.kt | 3 +- .../core/playback/CollectionPlaybackHelper.kt | 36 +- .../core/remote/ConnectionRequestDialog.kt | 62 +- .../core/remote/RemoteControlClient.kt | 3 +- .../core/remote/RemoteControlHandler.kt | 33 +- .../core/remote/RemoteControlProtocol.kt | 9 +- .../core/remote/RemoteControlService.kt | 9 +- .../core/remote/RemotePlaybackController.kt | 24 +- .../spotube/core/scrobble/ScrobbleManager.kt | 21 +- .../server/AlternativeTracksRepository.kt | 3 +- .../spotube/core/server/CacheManager.kt | 12 +- .../spotube/core/server/LocalServer.kt | 6 +- .../core/server/MatchTracksRepository.kt | 3 +- .../spotube/core/server/StreamProxy.kt | 6 +- .../core/server/StreamingUrlRepository.kt | 33 +- .../core/ui/base/AutocompleteTextField.kt | 569 ----------- .../spotube/core/ui/base/BaseUIColorScheme.kt | 381 ------- .../spotube/core/ui/base/BaseUIPreviews.kt | 94 -- .../krtirtho/spotube/core/ui/base/Buttons.kt | 908 ----------------- .../dev/krtirtho/spotube/core/ui/base/Card.kt | 174 ---- .../krtirtho/spotube/core/ui/base/CheckBox.kt | 281 ----- .../krtirtho/spotube/core/ui/base/ChipTabs.kt | 240 ----- .../krtirtho/spotube/core/ui/base/Dialog.kt | 237 ----- .../spotube/core/ui/base/DropdownMenu.kt | 277 ----- .../spotube/core/ui/base/ListRowTile.kt | 240 ----- .../krtirtho/spotube/core/ui/base/Radio.kt | 183 ---- .../krtirtho/spotube/core/ui/base/Scaffold.kt | 121 --- .../krtirtho/spotube/core/ui/base/Slider.kt | 369 ------- .../krtirtho/spotube/core/ui/base/Surface.kt | 699 ------------- .../spotube/core/ui/base/TextField.kt | 291 ------ .../krtirtho/spotube/core/ui/base/Theme.kt | 962 ------------------ .../krtirtho/spotube/core/ui/base/Toggle.kt | 298 ------ .../krtirtho/spotube/core/ui/base/Utils.kt | 88 -- .../ui/component/AdaptiveDialogBottomSheet.kt | 83 +- .../component/AdaptiveDropdownBottomSheet.kt | 333 +++--- .../spotube/core/ui/component/AlbumCard.kt | 12 +- .../core/ui/component/ApplicationMainBar.kt | 16 +- .../spotube/core/ui/component/ArtistCard.kt | 12 +- .../core/ui/component/CollectionDetails.kt | 183 ++-- .../core/ui/component/CollectionView.kt | 113 +- .../spotube/core/ui/component/ErrorDisplay.kt | 236 ++--- .../core/ui/component/FeaturedCarousel.kt | 95 +- .../core/ui/component/LikedTracksCard.kt | 50 +- .../spotube/core/ui/component/PlaylistCard.kt | 12 +- .../ui/component/SharedTransitionLocals.kt | 6 +- .../spotube/core/ui/component/Sidebar.kt | 107 +- .../spotube/core/ui/component/TrackCard.kt | 15 +- .../spotube/core/ui/component/TrackList.kt | 597 +++++------ .../spotube/core/ui/component/TrackOptions.kt | 160 ++- .../spotube/core/ui/component/UserCard.kt | 12 +- .../core/ui/component/VerticalScrollbar.kt | 3 +- .../core/ui/component/cards/AvatarCard.kt | 33 +- .../core/ui/component/cards/PlayableCard.kt | 73 +- .../spotube/core/ui/coverflow/Coverflow.kt | 18 +- .../core/ui/coverflow/CoverflowGeometry.kt | 3 +- .../core/ui/coverflow/CoverflowItem.kt | 22 +- .../core/ui/coverflow/CoverflowParams.kt | 3 +- .../core/ui/coverflow/CoverflowScope.kt | 27 +- .../core/ui/coverflow/CoverflowState.kt | 15 +- .../spotube/core/ui/coverflow/Mirror.kt | 39 +- .../krtirtho/spotube/core/ui/misc/Shimmer.kt | 19 +- .../spotube/core/ui/theming/AppTheme.kt | 233 +++-- .../core/webview/PlatformWebviewScreen.kt | 449 ++++---- .../core/zipline/BuiltInPluginService.kt | 4 +- .../core/zipline/ZiplinePluginService.kt | 689 +++++++------ .../core/zipline/host_apis/RealCryptoAPI.kt | 17 +- .../zipline/host_apis/RealHttpClientAPI.kt | 1 - .../core/zipline/host_apis/RealWebViewAPI.kt | 145 ++- .../plugin_apis/lrclib/RealLRCLibLyricsAPI.kt | 6 +- .../plugin_apis/newpipe_yt/ISRCModels.kt | 6 +- .../plugin_apis/newpipe_yt/ISRCProviders.kt | 2 - .../newpipe_yt/RealNewPipeAudioAPI.kt | 22 +- .../spotube/modules/album/AlbumRepository.kt | 3 +- .../spotube/modules/album/AlbumScreen.kt | 6 +- .../spotube/modules/album/AlbumViewModel.kt | 30 +- .../spotube/modules/artist/ArtistScreen.kt | 297 +++--- .../spotube/modules/artist/ArtistViewModel.kt | 36 +- .../modules/blacklist/BlacklistRepository.kt | 3 +- .../modules/blacklist/BlacklistScreen.kt | 174 ++-- .../modules/blacklist/BlacklistViewModel.kt | 3 +- .../spotube/modules/devices/DevicesScreen.kt | 173 ++-- .../modules/devices/DevicesViewModel.kt | 6 +- .../modules/devices/PlayDestinationPicker.kt | 129 ++- .../modules/devices/RemoteControlScreen.kt | 376 +++---- .../modules/devices/RemoteControlViewModel.kt | 12 +- .../modules/downloads/DownloadManager.kt | 33 +- .../modules/downloads/DownloadProgressIcon.kt | 49 +- .../modules/downloads/DownloadsScreen.kt | 154 ++- .../modules/downloads/DownloadsViewModel.kt | 6 +- .../spotube/modules/home/HomeScreen.kt | 183 ++-- .../modules/home/HomeScreenRepository.kt | 1 - .../modules/home/HomeScreenViewModel.kt | 18 +- .../krtirtho/spotube/modules/jam/JamScreen.kt | 206 ++-- .../spotube/modules/jam/JamViewModel.kt | 12 +- .../modules/jam/PlayDestinationPicker.kt | 49 +- .../modules/library/LibraryRepository.kt | 12 +- .../spotube/modules/library/LibraryScreen.kt | 102 +- .../library/album/LibraryAlbumsScreen.kt | 42 +- .../library/album/LibraryAlbumsViewModel.kt | 18 +- .../library/artist/LibraryArtistsScreen.kt | 54 +- .../library/artist/LibraryArtistsViewModel.kt | 24 +- .../local_tracks/LibraryLocalTracksScreen.kt | 157 +-- .../LibraryLocalTracksViewModel.kt | 18 +- .../media/LocalMediaFoldersConfig.kt | 3 +- .../media/LocalMediaLibraryCoordinator.kt | 6 +- .../local_tracks/media/LocalMediaModels.kt | 9 +- .../media/LocalMediaPermission.kt | 3 +- .../media/NoopLocalMediaDiscoveryService.kt | 3 +- .../library/playlist/AddToPlaylistPicker.kt | 140 ++- .../playlist/LibraryPlaylistsScreen.kt | 71 +- .../playlist/LibraryPlaylistsViewModel.kt | 24 +- .../library/playlist/PlaylistFormSheet.kt | 221 ++-- .../spotube/modules/lyrics/LyricsScreen.kt | 225 ++-- .../spotube/modules/lyrics/LyricsViewModel.kt | 10 +- .../playlist/AddTracksToPlaylistDialog.kt | 177 ++-- .../modules/playlist/PlaylistRepository.kt | 3 +- .../modules/playlist/PlaylistScreen.kt | 48 +- .../modules/playlist/PlaylistViewModel.kt | 36 +- .../spotube/modules/plugin/BuiltInPlugins.kt | 12 +- .../modules/plugin/GitHubPluginRepo.kt | 15 +- .../spotube/modules/plugin/PluginManager.kt | 34 +- .../spotube/modules/plugin/PluginModels.kt | 3 +- .../spotube/modules/plugin/PluginScreen.kt | 606 ++++++----- .../spotube/modules/plugin/PluginViewModel.kt | 85 +- .../components/DefaultAbilityPluginGrid.kt | 139 +-- .../modules/plugin/components/PluginCard.kt | 120 ++- .../plugin/components/PluginInstallDialog.kt | 318 +++--- .../modules/plugin/components/PluginLogo.kt | 9 +- .../components/PluginPermissionsDialog.kt | 297 +++--- .../modules/saved_tracks/SavedTracksScreen.kt | 6 +- .../saved_tracks/SavedTracksViewModel.kt | 37 +- .../spotube/modules/search/SearchScreen.kt | 523 ++++------ .../modules/search/SearchScreenViewModel.kt | 82 +- .../modules/settings/JamSettingsScreen.kt | 87 +- .../modules/settings/JamSettingsViewModel.kt | 6 +- .../modules/settings/SettingsModels.kt | 17 +- .../modules/settings/SettingsRepository.kt | 18 +- .../modules/settings/SettingsScreen.kt | 62 +- .../modules/settings/SettingsSidebarLayout.kt | 121 +-- .../modules/settings/SettingsViewModel.kt | 1 - .../modules/settings/SurfaceAccents.kt | 70 -- .../components/InteractiveSettingCards.kt | 223 ++-- .../settings/components/SettingsCardItem.kt | 19 +- .../settings/sections/AppearanceSection.kt | 407 ++------ .../modules/settings/sections/CacheSection.kt | 30 +- .../settings/sections/DesktopSection.kt | 6 +- .../settings/sections/DownloadsSection.kt | 138 ++- .../modules/settings/sections/JamSection.kt | 26 +- .../sections/LanguageRegionSection.kt | 12 +- .../settings/sections/PlaybackSection.kt | 26 +- .../settings/sections/PluginsSection.kt | 12 +- .../settings/sections/UpdatesSection.kt | 3 +- .../modules/settings/sections/utils.kt | 97 +- .../spotube/modules/shell/AppBottombar.kt | 106 +- .../modules/shell/AppExpandedPlayer.kt | 357 ++++--- .../modules/shell/AppFloatingPlayer.kt | 75 +- .../spotube/modules/shell/AppLargePlayer.kt | 252 +++-- .../spotube/modules/shell/AppPlayerState.kt | 9 +- .../spotube/modules/shell/AppShell.kt | 301 +++--- .../modules/shell/AppShellViewModel.kt | 9 +- .../spotube/modules/shell/AppSidebar.kt | 195 ++-- .../modules/shell/PlayerHeartButton.kt | 24 +- .../modules/shell/PlayerOptionDialogs.kt | 276 +++-- .../modules/shell/PlayerOptionsViewModel.kt | 25 +- .../AlternativeTrackContent.kt | 150 ++- .../AlternativeTrackContentViewModel.kt | 15 +- .../AlternativeTrackSheet.kt | 74 +- .../shell/player_queue/PlayerQueueContent.kt | 268 ++--- .../PlayerQueueContentViewModel.kt | 27 +- .../modules/shell/player_queue/QueueSheet.kt | 100 +- .../spotube/modules/update/AppUpdateDialog.kt | 48 +- .../modules/update/AppUpdateService.kt | 12 +- .../modules/update/PluginUpdateDialog.kt | 59 +- .../modules/update/PluginUpdateService.kt | 9 +- .../spotube/modules/welcome/WelcomeContent.kt | 13 +- .../spotube/modules/welcome/WelcomeModels.kt | 6 +- .../modules/welcome/WelcomeRepository.kt | 6 +- .../spotube/modules/welcome/WelcomeScreen.kt | 133 ++- .../modules/welcome/WelcomeViewModel.kt | 10 +- .../spotube/modules/welcome/WelcomeVisuals.kt | 53 +- .../modules/welcome/pages/BrandingPage.kt | 19 +- .../modules/welcome/pages/DonationPage.kt | 56 +- .../modules/welcome/pages/HowItWorksPage.kt | 53 +- .../audioplayer/DeviceAudioPlayerQueueTest.kt | 9 +- .../core/audioplayer/FakePluginProvider.kt | 3 +- .../core/scrobble/ScrobbleManagerTest.kt | 42 +- .../StreamingUrlRepositoryBehaviorTest.kt | 22 +- .../server/StreamingUrlRepositoryFakes.kt | 9 +- .../core/server/StreamingUrlRepositoryTest.kt | 84 +- .../krtirtho/spotube/MainViewController.kt | 1 - .../core/audioplayer/AudioPlayer.ios.kt | 1 - .../core/newpipe/NewPipeService.ios.kt | 3 +- .../krtirtho/spotube/core/paths/Paths.ios.kt | 9 +- .../media/IosLocalMediaDiscoveryService.kt | 3 +- .../media/LocalMediaPermission.ios.kt | 3 +- .../core/audioplayer/AudioPlayer.jvm.kt | 6 +- .../mediacontrol/SystemMediaControlService.kt | 12 +- .../spotube/core/systemtray/SystemTray.kt | 9 +- .../core/systemtray/SystemTrayService.kt | 15 +- .../ui/component/ApplicationMainBar.jvm.kt | 77 +- .../ui/component/VerticalScrollbar.jvm.kt | 11 +- .../kotlin/dev/krtirtho/spotube/main.kt | 7 +- .../media/JvmLocalMediaDiscoveryService.kt | 18 +- .../media/LocalMediaPermission.jvm.kt | 3 +- .../core/audioplayer/AudioPlayerTest.kt | 6 +- .../ui/component/ApplicationMainBar.mobile.kt | 19 +- gradle/libs.versions.toml | 14 +- 229 files changed, 6317 insertions(+), 14840 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 3d4027e3..2d8729d9 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -119,7 +119,6 @@ kotlin { // Jetpack Compose dependencies implementation(libs.compose.runtime) implementation(libs.compose.foundation) - implementation(libs.compose.material3) implementation(libs.compose.ui) implementation(libs.coil.compose) implementation(libs.coil.network.ktor3) @@ -132,6 +131,9 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.dns.sd.kt) + // Millennium UI + implementation(libs.millennium) + // Navigation implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) @@ -164,12 +166,6 @@ kotlin { // icons implementation(libs.feather.icons) - // Material3 adaptive - implementation(libs.jetbrains.material3.adaptive) - implementation(libs.jetbrains.material3.adaptive.layout) - implementation(libs.jetbrains.material3.adaptive.suite) - implementation(libs.jetbrains.material3.adaptiveNavigation3) - implementation(libs.jetbrains.material3.window.size) // androidx-datastore implementation(libs.androidx.datastore) implementation(libs.androidx.datastore.preferences) @@ -184,8 +180,6 @@ kotlin { implementation(libs.semver) - implementation(libs.material.kolor) - // crypto implementation(libs.cryptography.core) implementation(libs.cryptography.provider.optimal) @@ -206,7 +200,6 @@ kotlin { // Shimmer effect implementation(libs.compose.shimmer) - implementation(libs.compose.placeholder.material3) // DLNA implementation(libs.dns.sd.kt) diff --git a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt index cf8dd550..5231f3b4 100644 --- a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -26,7 +26,6 @@ import org.schabi.newpipe.extractor.ServiceList.YouTube import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory import org.schabi.newpipe.extractor.stream.StreamInfoItem - actual class NewPipeService { actual suspend fun searchVideos(query: String): List { return withContext(Dispatchers.IO) { @@ -47,8 +46,7 @@ actual class NewPipeService { url = item.url, uploader = item.uploaderName, durationMs = item.duration, - thumbnailUrl = item.thumbnails.first().url, - ) + thumbnailUrl = item.thumbnails.first().url) } else { null } @@ -67,8 +65,7 @@ actual class NewPipeService { url = stream.content, codec = stream.codec, container = stream.format!!.suffix, - bitrate = stream.bitrate, - ) + bitrate = stream.bitrate) } + buildList { if (extractor.dashMpdUrl.isNotBlank()) add( AudioStream.Lossy( @@ -90,7 +87,6 @@ actual class NewPipeService { ) } - val videoStreams = extractor.videoStreams .filter { stream -> stream.isUrl && stream.format != null } .map { stream -> @@ -98,8 +94,7 @@ actual class NewPipeService { url = stream.content, codec = stream.codec, bitrate = stream.bitrate, - container = stream.format!!.suffix, - ) + container = stream.format!!.suffix) } VideoInfo( id = id, diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt index be29dba7..d3f042d2 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt @@ -45,7 +45,6 @@ actual val platformModules = module { collectionPlaybackHelper = get(), audioPlayerQueue = get(), settingsRepository = get(), - blacklistRepository = get(), - ) + blacklistRepository = get()) } } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt index 961a6b7c..409b08d9 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt @@ -35,14 +35,12 @@ import androidx.core.content.ContextCompat actual fun rememberLocalNetworkPermissionRequester(): () -> Unit { val context = LocalContext.current val launcher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { /* result is picked up by discovery/advertising retry loops */ } + ActivityResultContracts.RequestPermission()) { /* result is picked up by discovery/advertising retry loops */ } return { val granted = ContextCompat.checkSelfPermission( context, - Manifest.permission.NEARBY_WIFI_DEVICES, - ) == PackageManager.PERMISSION_GRANTED + Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED if (!granted) { launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt index cd37edbc..9a0a19d8 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/MediaBrowseHelper.kt @@ -53,8 +53,7 @@ class MediaBrowseHelper( private val collectionPlaybackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, private val settingsRepository: SettingsRepository, - private val blacklistRepository: BlacklistRepository, -) { + private val blacklistRepository: BlacklistRepository) { companion object { const val MEDIA_ID_ROOT = "root" @@ -253,8 +252,7 @@ class MediaBrowseHelper( .setIsPlayable(false) .build() ) - .build(), - ) + .build()) } private suspend fun buildBrowseItems(): List { @@ -390,8 +388,7 @@ class MediaBrowseHelper( .setIsBrowsable(true) .build() ) - .build(), - ) + .build()) } private suspend fun buildSavedPlaylistsItems(): List { @@ -475,8 +472,7 @@ class MediaBrowseHelper( .setIsPlayable(false) .build() ) - .build(), - ) + .build()) } private suspend fun buildArtistTracksItems(artistId: String): List { diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt index 228b7daf..f258bb9b 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/AndroidLocalMediaDiscoveryService.kt @@ -32,8 +32,7 @@ import java.io.File import java.util.Locale class AndroidLocalMediaDiscoveryService( - private val context: Context, -) : LocalMediaDiscoveryService, KoinComponent { + private val context: Context) : LocalMediaDiscoveryService, KoinComponent { val logger by injectLogger() override suspend fun discoverFolders(roots: List): List { @@ -59,8 +58,7 @@ class AndroidLocalMediaDiscoveryService( MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.DURATION, - MediaStore.Audio.Media.MIME_TYPE, - ) + if (Build.VERSION.SDK_INT >= 29) { + MediaStore.Audio.Media.MIME_TYPE) + if (Build.VERSION.SDK_INT >= 29) { arrayOf(MediaStore.Audio.Media.RELATIVE_PATH) } else { emptyArray() @@ -131,8 +129,7 @@ class AndroidLocalMediaDiscoveryService( artists = listOfNotNull(artist), durationMs = durationMs, album = album, - coverBytes = null, - ) + coverBytes = null) tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) acceptedRows += 1 @@ -150,8 +147,7 @@ class AndroidLocalMediaDiscoveryService( LocalMediaFolder( path = folderPath, name = normalizeFolderName(folderPath), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, - ) + tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) } .sortedBy { it.name.lowercase(Locale.getDefault()) } @@ -172,8 +168,7 @@ class AndroidLocalMediaDiscoveryService( override fun observeChanges( roots: List, - onChanged: LocalMediaChangeCallback, - ): LocalMediaObservation? { + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { logger.i { "observeChanges: start roots=${roots.size}" } if (!hasReadPermission()) { logger.w { "observeChanges: missing media permission, observer not registered" } @@ -190,8 +185,7 @@ class AndroidLocalMediaDiscoveryService( context.contentResolver.registerContentObserver( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, - observer, - ) + observer) logger.i { "observeChanges: observer registered" } @@ -257,8 +251,7 @@ class AndroidLocalMediaDiscoveryService( artists = emptyList(), durationMs = 0L, album = null, - coverBytes = null, - ) + coverBytes = null) tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) } @@ -269,8 +262,7 @@ class AndroidLocalMediaDiscoveryService( LocalMediaFolder( path = folderPath, name = normalizeFolderName(folderPath), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, - ) + tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) } .sortedBy { it.name.lowercase(Locale.getDefault()) } @@ -292,8 +284,7 @@ class AndroidLocalMediaDiscoveryService( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), - context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), - ) + context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)) return (configured + defaults) .map { it.absoluteFile } @@ -320,8 +311,7 @@ class AndroidLocalMediaDiscoveryService( private fun mergeFolders( mediaStoreFolders: List, - fallbackFolders: List, - ): List { + fallbackFolders: List): List { if (fallbackFolders.isEmpty()) return mediaStoreFolders if (mediaStoreFolders.isEmpty()) return fallbackFolders @@ -342,8 +332,7 @@ class AndroidLocalMediaDiscoveryService( LocalMediaFolder( path = path, name = normalizeFolderName(path), - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, - ) + tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) } .sortedBy { it.name.lowercase(Locale.getDefault()) } } @@ -357,7 +346,6 @@ class AndroidLocalMediaDiscoveryService( "wav", "ogg", "opus", - "wma", - ) + "wma") } } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt index 65c44906..e3fbc988 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.android.kt @@ -42,19 +42,16 @@ actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState { var isGranted by remember { mutableStateOf( - context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED, - ) + context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) } val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { granted -> + contract = ActivityResultContracts.RequestPermission()) { granted -> isGranted = granted || context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED } return LocalMediaPermissionState( isGranted = isGranted, - requestPermission = { launcher.launch(permission) }, - ) + requestPermission = { launcher.launch(permission) }) } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b82b5562..a38b2ac1 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -76,6 +76,16 @@ Accent Secondary + Palette + Millennium accent palette. Current: %1$s + Palette + Choose a Millennium accent palette and preview it in light and dark. + Palette + Millennium palette and accent. Current: %1$s + Blue + Orange + Lime + Download folder override Current folder: %1$s Current folder: default music folder diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index 648be9af..f7005f50 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -22,13 +22,11 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.NavDisplay @@ -37,9 +35,6 @@ import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.toEntries -import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme import dev.krtirtho.spotube.modules.library.LibraryTab @@ -81,8 +76,7 @@ interface NavigationItem { private val tabs = listOf( NavigationItem.Tab("Home", Iconsax.IconsaxHome, Routes.Home), - NavigationItem.Tab("Search", Iconsax.IconsaxSearch, Routes.Search), -) + NavigationItem.Tab("Search", Iconsax.IconsaxSearch, Routes.Search)) val sidebarTabs = tabs + listOf( NavigationItem.Group( @@ -95,8 +89,7 @@ val sidebarTabs = tabs + listOf( LibraryTab.Playlists ), NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums), - NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists), - ) + NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists)) ), NavigationItem.Group( "On Device", @@ -112,17 +105,13 @@ val sidebarTabs = tabs + listOf( Iconsax.IconsaxDirectboxReceive, Routes.Library, LibraryTab.Downloads - ), - ) + )) ), NavigationItem.Group( "Connect", items = listOf( NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices), - NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam), - ), - ), -) + NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam)))) val bottombarTabs = tabs + listOf( NavigationItem.Tab("Library", Iconsax.IconsaxMusicLibrary, Routes.Library), NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings) @@ -159,69 +148,51 @@ fun App( } SpotubeTheme(settings = userSettings) { - val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f - val accent = if (isDark) { - userSettings.accentColor.toDarkColor() - } else { - userSettings.accentColor.toLightColor() - } - val baseUITheme = rememberBaseUITheme( - surfaceTheme = userSettings.surfaceTheme, - accentColor = accent, - ) - CompositionLocalProvider( - LocalBaseUITheme provides baseUITheme, - LocalBaseUIColors provides baseUITheme.colors, - ) { - Box(modifier = Modifier.fillMaxSize()) { - val currentRoute = - navigationState.backStacks[navigationState.topLevelRoute]?.last() + Box(modifier = Modifier.fillMaxSize()) { + val currentRoute = + navigationState.backStacks[navigationState.topLevelRoute]?.last() - Box(Modifier.fillMaxSize()) { - AppShell(navigator, navigationState) { - SharedTransitionLayout { - CompositionLocalProvider( - LocalSharedTransitionScope provides this@SharedTransitionLayout, - ) { - Column { - NavDisplay( - modifier = Modifier.fillMaxSize(), - onBack = navigator::pop, - entries = navigationState.toEntries(koinEntryProvider()) - ) - } + Box(Modifier.fillMaxSize()) { + AppShell(navigator, navigationState) { + SharedTransitionLayout { + CompositionLocalProvider( + LocalSharedTransitionScope provides this@SharedTransitionLayout) { + Column { + NavDisplay( + modifier = Modifier.fillMaxSize(), + onBack = navigator::pop, + entries = navigationState.toEntries(koinEntryProvider()) + ) } } } - when (currentRoute) { - Routes.WebView -> WebViewScreen(koinInject()) - else -> {} - } + } + when (currentRoute) { + Routes.WebView -> WebViewScreen(koinInject()) + else -> {} } } - content() - availableAppUpdate?.let { update -> - AppUpdateDialog( - update = update, - onDismiss = appUpdateService::dismissUpdate, - onIgnore = appUpdateService::ignoreUpdate, - onUpdate = { - runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } - appUpdateService.dismissUpdate() - }, - ) - } - availablePluginUpdate?.let { update -> - PluginUpdateDialog( - update = update, - onDismiss = pluginUpdateService::dismissUpdate, - onIgnore = pluginUpdateService::ignoreUpdate, - onUpdate = { - runCatching { openUrlInBrowser(update.info.directDownloadUrl) } - pluginUpdateService.dismissUpdate() - }, - ) - } + } + content() + availableAppUpdate?.let { update -> + AppUpdateDialog( + update = update, + onDismiss = appUpdateService::dismissUpdate, + onIgnore = appUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } + appUpdateService.dismissUpdate() + }) + } + availablePluginUpdate?.let { update -> + PluginUpdateDialog( + update = update, + onDismiss = pluginUpdateService::dismissUpdate, + onIgnore = pluginUpdateService::ignoreUpdate, + onUpdate = { + runCatching { openUrlInBrowser(update.info.directDownloadUrl) } + pluginUpdateService.dismissUpdate() + }) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt index 67cbe65f..ecdf309b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlin.time.Duration - interface AudioPlayerInterface { // // Playback suspend fun play() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt index da119020..e52cbbfb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt @@ -30,8 +30,7 @@ data class MediaItem( val duration: Duration, val coverURL: String, val url: String, - val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, -) + val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) enum class LoopState { NONE, ONE, ALL; @@ -60,8 +59,7 @@ sealed interface QueueEntry { val track: MetadataTrack, override val url: String, val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, - override val addedBy: String = "", - ) : QueueEntry + override val addedBy: String = "") : QueueEntry @Serializable @SerialName("local") @@ -72,8 +70,7 @@ sealed interface QueueEntry { val album: String?, val coverBytes: ByteArray?, override val url: String, - override val addedBy: String = "", - ) : QueueEntry { + override val addedBy: String = "") : QueueEntry { override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || this::class != other::class) return false @@ -111,14 +108,12 @@ sealed interface QueueCollectionEntry { @Serializable @SerialName("playlist") data class Playlist( - override val id: String, - ) : QueueCollectionEntry + override val id: String) : QueueCollectionEntry @Serializable @SerialName("album") data class Album( - override val id: String, - ) : QueueCollectionEntry + override val id: String) : QueueCollectionEntry @Serializable @SerialName("saved_tracks") @@ -132,5 +127,4 @@ data class PersistedQueueState( val entries: List, val currentIndex: Int, val currentCollectionEntry: QueueCollectionEntry? = null, - val collectionHistory: List = emptyList(), -) + val collectionHistory: List = emptyList()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt index 8a524add..a373952a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerQueue.kt @@ -29,8 +29,7 @@ interface AudioPlayerQueue { entries: List, autoPlay: Boolean = true, startPosition: Int = 0, - collectionEntry: QueueCollectionEntry? = null, - ) + collectionEntry: QueueCollectionEntry? = null) suspend fun addToQueue(entry: QueueEntry) suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry? = null) suspend fun addAllAfterCurrent(entries: List) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index 230b0c78..95de3699 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -45,8 +45,7 @@ class DeviceAudioPlayerQueue( private val settingsProvider: SettingsProvider, private val repository: QueueStateRepository, private val pluginProvider: PluginProvider, - private val blacklistRepository: BlacklistRepository, -) : AudioPlayerQueue, KoinComponent { + private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { private val logger by injectLogger() @@ -60,8 +59,7 @@ class DeviceAudioPlayerQueue( override val queueFlow: StateFlow> = combine( audioPlayer.playlistFlow, - entryByMediaUrl, - ) { playlist, knownEntries -> + entryByMediaUrl) { playlist, knownEntries -> playlist.map { mediaItem -> knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry() } @@ -69,8 +67,7 @@ class DeviceAudioPlayerQueue( override val currentQueueEntryFlow: StateFlow = combine( audioPlayer.currentMediaItemFlow, - entryByMediaUrl, - ) { mediaItem, knownEntries -> + entryByMediaUrl) { mediaItem, knownEntries -> mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() } }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) @@ -107,8 +104,7 @@ class DeviceAudioPlayerQueue( combine( audioPlayer.currentMediaItemFlow, audioPlayer.playlistFlow, - audioPlayer.loopStateFlow, - ) { currentMedia, playlist, loopState -> + audioPlayer.loopStateFlow) { currentMedia, playlist, loopState -> Triple(currentMedia, playlist, loopState) }.collect { (currentMedia, playlist, loopState) -> if (currentMedia == null || playlist.isEmpty()) return@collect @@ -129,8 +125,7 @@ class DeviceAudioPlayerQueue( entries: List, autoPlay: Boolean, startPosition: Int, - collectionEntry: QueueCollectionEntry?, - ) { + collectionEntry: QueueCollectionEntry?) { logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" } val mediaItems = entries.mapNotNull { it.toMediaItem() } if (mediaItems.isEmpty()) { @@ -436,8 +431,7 @@ class DeviceAudioPlayerQueue( entries = queueEntries, currentIndex = normalizedCurrentIndex, currentCollectionEntry = currentCollectionEntryState.value, - collectionHistory = collectionHistoryState.value, - ) + collectionHistory = collectionHistoryState.value) ) } @@ -471,8 +465,7 @@ class DeviceAudioPlayerQueue( duration = track.durationMs.milliseconds, coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(), url = resolvedUrl, - protocol = protocol, - ) + protocol = protocol) } is QueueEntry.LocalTrack -> { @@ -483,8 +476,7 @@ class DeviceAudioPlayerQueue( album = album.orEmpty(), duration = duration.milliseconds, coverURL = "", - url = url, - ) + url = url) } } } @@ -496,8 +488,7 @@ class DeviceAudioPlayerQueue( duration = duration.inWholeMilliseconds, album = album.ifBlank { null }, coverBytes = null, - url = url, - ) + url = url) } private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt index 573efaa1..7b08d112 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt @@ -86,8 +86,7 @@ class Database(val paths: Paths) { "spotube_audio_player_queue.preferences_pb", "spotube_local_media.preferences_pb", "spotube_blacklist.preferences_pb", - "spotube_cookies.preferences_pb", - ) + "spotube_cookies.preferences_pb") } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index bdf28574..a8251a64 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -168,8 +168,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), - remotePlaybackController = get(), - ) + remotePlaybackController = get()) } // Saved Tracks @@ -183,8 +182,7 @@ val sharedModules = module { libraryRepository = get(), shareService = get(), downloadManager = get(), - remotePlaybackController = get(), - ) + remotePlaybackController = get()) } // Artist @@ -199,8 +197,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), - remotePlaybackController = get(), - ) + remotePlaybackController = get()) } // Blacklist @@ -212,8 +209,7 @@ val sharedModules = module { JamViewModel( jamRoomService = get(), shareService = get(), - settingsProvider = get(), - ) + settingsProvider = get()) } // Album @@ -229,8 +225,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), - remotePlaybackController = get(), - ) + remotePlaybackController = get()) } // Lyrics @@ -244,8 +239,7 @@ val sharedModules = module { savedTracksRepository = get(), artistRepository = get(), audioPlayerQueue = get(), - blacklistRepository = get(), - ) + blacklistRepository = get()) } singleOf(::MatchedTracksRepository) { bind() } single { @@ -253,8 +247,7 @@ val sharedModules = module { pluginManager = get(), audioPlayerQueue = get(), matchedTracksRepository = get(), - settingsRepository = get(), - ) + settingsRepository = get()) } singleOf(::CacheManager) singleOf(::AlternativeTracksRepository) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt index b7454957..9240a6bf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discord/DiscordRpcService.kt @@ -34,8 +34,7 @@ import kotlin.time.Clock class DiscordRpcService( private val audioPlayer: AudioPlayerInterface, - private val settingsProvider: SettingsProvider, -) : KoinComponent, AutoCloseable { + private val settingsProvider: SettingsProvider) : KoinComponent, AutoCloseable { private val logger by injectLogger() @@ -61,8 +60,7 @@ class DiscordRpcService( audioPlayer.playerStateFlow, audioPlayer.currentMediaItemFlow, audioPlayer.positionFlow, - audioPlayer.durationFlow, - ) { settings, playerState, mediaItem, position, duration -> + audioPlayer.durationFlow) { settings, playerState, mediaItem, position, duration -> DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration) }.collect { state -> handleStateChange(state) @@ -86,8 +84,7 @@ class DiscordRpcService( album = mediaItem.album, coverUrl = mediaItem.coverURL, positionMs = state.position.inWholeMilliseconds, - durationMs = state.duration.inWholeMilliseconds, - ) + durationMs = state.duration.inWholeMilliseconds) } PlayerState.PAUSED -> { val mediaItem = state.mediaItem ?: return @@ -98,8 +95,7 @@ class DiscordRpcService( album = mediaItem.album, coverUrl = mediaItem.coverURL, positionMs = state.position.inWholeMilliseconds, - durationMs = state.duration.inWholeMilliseconds, - ) + durationMs = state.duration.inWholeMilliseconds) } PlayerState.IDLE, PlayerState.COMPLETED -> { clearPresence() @@ -158,8 +154,7 @@ class DiscordRpcService( album: String, coverUrl: String, positionMs: Long, - durationMs: Long, - ) { + durationMs: Long) { val client = rpcClient ?: return try { client.updatePresence( @@ -168,8 +163,7 @@ class DiscordRpcService( album = album, coverUrl = coverUrl, positionMs = positionMs, - durationMs = durationMs, - ) + durationMs = durationMs) } catch (e: Exception) { rpcClient = null connectionFailed = true @@ -210,6 +204,5 @@ class DiscordRpcService( val playerState: PlayerState, val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?, val position: kotlin.time.Duration, - val duration: kotlin.time.Duration, - ) + val duration: kotlin.time.Duration) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt index 25537c3f..b525682e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt @@ -30,8 +30,7 @@ data class DiscoveredDevice( val type: String, val host: String, val port: Int, - val deviceId: String, -) { + val deviceId: String) { val key: String get() = "$name$type".replace(".", "") } @@ -55,8 +54,7 @@ class DeviceDiscoveryService { type = event.service.type, host = event.service.host, port = event.service.port, - deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(), - ) + deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) DiscoveryState.Discovered(device = device, resolve = event.resolve) } @@ -66,8 +64,7 @@ class DeviceDiscoveryService { type = event.service.type, host = event.service.host, port = event.service.port, - deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(), - ) + deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) DiscoveryState.Resolved(device = device) } @@ -77,8 +74,7 @@ class DeviceDiscoveryService { type = event.service.type, host = event.service.host, port = event.service.port, - deviceId = "", - ) + deviceId = "") DiscoveryState.Removed(device = device) } } @@ -88,8 +84,7 @@ class DeviceDiscoveryService { name: String, port: Int, deviceId: String, - registerTimeoutMs: Long = 5_000, - ): NetService { + registerTimeoutMs: Long = 5_000): NetService { val service = createService(name, port, deviceId) service.register(timeoutInMs = registerTimeoutMs) return service @@ -98,11 +93,9 @@ class DeviceDiscoveryService { fun createService( name: String, port: Int, - deviceId: String, - ): NetService = createNetService( + deviceId: String): NetService = createNetService( type = SERVICE_TYPE, name = name, port = port, - txt = mapOf(TXT_DEVICE_ID to deviceId), - ) + txt = mapOf(TXT_DEVICE_ID to deviceId)) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt index a51b8cb5..f007f635 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt @@ -46,8 +46,7 @@ sealed class JamMessage { */ val follow: Boolean = false, /** Host's live play state — late-joining guests start with it. */ - val isPlaying: Boolean = false, - ) : JamMessage() + val isPlaying: Boolean = false) : JamMessage() @Serializable @SerialName("playbackCommand") @@ -57,22 +56,19 @@ sealed class JamMessage { @SerialName("suggestTrack") data class SuggestTrack( val mediaItem: JamMediaItem, - val addedBy: String = "", - ) : JamMessage() + val addedBy: String = "") : JamMessage() @Serializable @SerialName("suggestPlaylist") data class SuggestPlaylist( val tracks: List, - val addedBy: String = "", - ) : JamMessage() + val addedBy: String = "") : JamMessage() @Serializable @SerialName("kick") data class Kick( val participantId: String, - val reason: String = "kicked", - ) : JamMessage() + val reason: String = "kicked") : JamMessage() @Serializable @SerialName("leave") @@ -104,8 +100,7 @@ data class JamPresence( val clientId: String, val displayName: String, val isHost: Boolean, - val left: Boolean = false, -) + val left: Boolean = false) @Serializable data class JamMediaItem( @@ -117,8 +112,7 @@ data class JamMediaItem( val durationMs: Long, val coverUrl: String, val protocol: String, - val addedBy: String = "", -) { + val addedBy: String = "") { companion object { fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { is QueueEntry.StreamingTrack -> JamMediaItem( @@ -132,8 +126,7 @@ data class JamMediaItem( ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url .orEmpty(), protocol = entry.protocol.name, - addedBy = entry.addedBy, - ) + addedBy = entry.addedBy) is QueueEntry.LocalTrack -> JamMediaItem( url = entry.url, @@ -144,8 +137,7 @@ data class JamMediaItem( durationMs = entry.duration, coverUrl = "", protocol = "PROGRESSIVE", - addedBy = entry.addedBy, - ) + addedBy = entry.addedBy) } fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem( @@ -158,8 +150,7 @@ data class JamMediaItem( coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url .orEmpty(), - protocol = "PROGRESSIVE", - ) + protocol = "PROGRESSIVE") fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem( url = item.url, @@ -168,8 +159,7 @@ data class JamMediaItem( album = item.album, durationMs = item.duration.inWholeMilliseconds, coverUrl = item.coverURL, - protocol = item.protocol.name, - ) + protocol = item.protocol.name) fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem( title = item.title, @@ -179,8 +169,7 @@ data class JamMediaItem( coverURL = item.coverUrl, url = item.url, protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol - .valueOf(item.protocol.ifBlank { "PROGRESSIVE" }), - ) + .valueOf(item.protocol.ifBlank { "PROGRESSIVE" })) } } @@ -188,8 +177,7 @@ data class JamMediaItem( data class JamParticipant( val id: String, val displayName: String, - val isHost: Boolean, -) + val isHost: Boolean) @Serializable enum class JamRole { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt index 83531f19..c09b4e20 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt @@ -30,10 +30,8 @@ import com.ditchoom.mqtt.controlpacket.TopicName import com.ditchoom.mqtt.controlpacket.WillConfig import com.ditchoom.mqtt5.controlpacket.ConnectionRequest import dev.krtirtho.spotube.modules.settings.JamBroker -import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -47,6 +45,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json import org.koin.core.component.KoinComponent +import kotlin.time.Duration.Companion.seconds /** * Thin facade over the Ditchoom MQTT 5 client for one jam room. @@ -114,8 +113,7 @@ class JamRoomClient : KoinComponent { code: String, clientId: String, displayName: String, - isHost: Boolean, - ): Result { + isHost: Boolean): Result { disconnect() if (broker.host.isBlank()) { return Result.failure(IllegalArgumentException("No jam broker configured")) @@ -126,8 +124,7 @@ class JamRoomClient : KoinComponent { clientId = clientId, displayName = displayName, isHost = isHost, - left = false, - ) + left = false) val mqtt = startClient(broker, clientId) client = mqtt @@ -167,8 +164,7 @@ class JamRoomClient : KoinComponent { publishJson( presenceTopic(code, presence.clientId), json.encodeToString(JamPresence.serializer(), presence), - retain = true, - ) + retain = true) } /** Marks us as the host in presence (host takeover). */ @@ -186,8 +182,7 @@ class JamRoomClient : KoinComponent { publishJson( presenceTopic(code, presence.clientId), json.encodeToString(JamPresence.serializer(), presence.copy(left = true)), - retain = true, - ) + retain = true) } } @@ -208,26 +203,22 @@ class JamRoomClient : KoinComponent { host = broker.host, port = broker.port, tlsEnabled = broker.useTls, - connectionTimeout = broker.connectionTimeoutSeconds.seconds, - ) + connectionTimeout = broker.connectionTimeoutSeconds.seconds) val code = roomCode ?: "unset" val will = WillConfig.Enabled( topic = TopicName.fromOrThrow(presenceTopic(code, clientId)), payload = json.encodeToString( JamPresence.serializer(), - JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true), - ).toReadBuffer(Charset.UTF8), + JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true)).toReadBuffer(Charset.UTF8), qos = QualityOfService.AT_LEAST_ONCE, - retain = true, - ) + retain = true) val request = ConnectionRequest( clientId = clientId, keepAliveSeconds = broker.keepAliveSeconds, cleanStart = true, userName = broker.username, password = broker.password, - will = will, - ) + will = will) val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true) val brokerRef = persistence.addBroker(connection, request) return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence) @@ -265,8 +256,7 @@ class JamRoomClient : KoinComponent { val operation = mqtt.subscribe( roomFilter(code), OpaquePublishPayloadCodec, - QualityOfService.AT_LEAST_ONCE, - ) { publish, payload -> + QualityOfService.AT_LEAST_ONCE) { publish, payload -> route(publish.topic.toString(), payload) } runCatching { operation.subAck.await() } @@ -309,8 +299,7 @@ class JamRoomClient : KoinComponent { topicName = topic, qos = QualityOfService.AT_LEAST_ONCE, payload = payload.toReadBuffer(Charset.UTF8), - retain = retain, - ) + retain = retain) }.onFailure { e -> log.w(e) { "Failed to publish to $topic" } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt index f8c15a2b..b9918040 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -59,15 +59,13 @@ private data class PlaybackBroadcast( val queue: List, val current: QueueEntry?, val shuffle: Boolean, - val playerState: PlayerState, -) + val playerState: PlayerState) class JamRoomService( private val jamClient: JamRoomClient, private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, - private val settingsRepository: SettingsRepository, -) { + private val settingsRepository: SettingsRepository) { private val log = Logger.withTag("JamRoomService") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -171,8 +169,7 @@ class JamRoomService( code = code, clientId = localClientId, displayName = name, - isHost = true, - ).map { + isHost = true).map { _role.value = JamRole.Host _roomCode.value = code _participants.value = listOf(JamParticipant(localClientId, name, isHost = true)) @@ -206,8 +203,7 @@ class JamRoomService( code = normalized, clientId = localClientId, displayName = name, - isHost = false, - ).map { + isHost = false).map { _role.value = JamRole.Guest _roomCode.value = normalized _participants.value = emptyList() @@ -261,8 +257,7 @@ class JamRoomService( jamClient.publishCommand( JamMessage.SuggestTrack( mediaItem = JamMediaItem.fromTrack(track), - addedBy = localClientId, - ) + addedBy = localClientId) ) } @@ -271,8 +266,7 @@ class JamRoomService( jamClient.publishCommand( JamMessage.SuggestPlaylist( tracks = tracks.map(JamMediaItem::fromTrack), - addedBy = localClientId, - ) + addedBy = localClientId) ) } @@ -296,8 +290,7 @@ class JamRoomService( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, audioPlayer.shuffleModeFlow, - audioPlayer.playerStateFlow, - ) { queue, current, shuffle, playerState -> + audioPlayer.playerStateFlow) { queue, current, shuffle, playerState -> PlaybackBroadcast(queue, current, shuffle, playerState) } .onEach { broadcast -> @@ -314,8 +307,7 @@ class JamRoomService( currentIndex = index.coerceAtLeast(0), shuffleEnabled = broadcast.shuffle, follow = now() > autoAdvanceDeadlineMs, - isPlaying = broadcast.playerState == PlayerState.PLAYING, - ) + isPlaying = broadcast.playerState == PlayerState.PLAYING) ) } .launchIn(this) @@ -374,8 +366,7 @@ class JamRoomService( audioPlayerQueue.load( entries = items.map { it.toQueueEntry() }, autoPlay = wasPlaying, - startPosition = startIndex, - ) + startPosition = startIndex) }.onFailure { log.w(it) { "Failed to apply jam queue" } } return } @@ -524,13 +515,11 @@ class JamRoomService( explicit = null, popularity = null, isrcCode = null, - externalUri = null, - ), + externalUri = null), url = "", protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } .getOrDefault(StreamProtocol.PROGRESSIVE), - addedBy = addedBy, - ) + addedBy = addedBy) else -> QueueEntry.LocalTrack( name = title, @@ -539,8 +528,7 @@ class JamRoomService( album = album.ifBlank { null }, coverBytes = null, url = url, - addedBy = addedBy, - ) + addedBy = addedBy) } private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index f1adc2f0..4fc959dc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -125,8 +125,7 @@ val navigationModule = module { ), audioPlayerQueue = get(), audioPlayer = get(), - navigationCommands = get(), - ) + navigationCommands = get()) } navigation { ArtistScreen( @@ -136,8 +135,7 @@ val navigationModule = module { ), audioPlayerQueue = get(), audioPlayer = get(), - navigationCommands = get(), - ) + navigationCommands = get()) } navigation { AlbumScreen( @@ -148,8 +146,7 @@ val navigationModule = module { ), audioPlayerQueue = get(), audioPlayer = get(), - navigationCommands = get(), - ) + navigationCommands = get()) } navigation { LyricsScreen(viewModel = koinViewModel()) @@ -162,8 +159,7 @@ val navigationModule = module { ), audioPlayerQueue = get(), audioPlayer = get(), - navigationCommands = get(), - ) + navigationCommands = get()) } navigation { BlacklistScreen() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt index 8845db6b..e9e65bd9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt @@ -46,8 +46,7 @@ val TOP_LEVEL_ROUTES = setOf( Routes.Library, Routes.Settings, // Bootstrap destination: needs its own back stack when the app starts on it. - Routes.Welcome, -) + Routes.Welcome) val serializersConfig = SavedStateConfiguration { serializersModule = SerializersModule { @@ -123,8 +122,7 @@ fun NavigationState.toEntries( ): SnapshotStateList> { val decoratedEntries = backStacks.mapValues { (_, stack) -> val decorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - ) + rememberSaveableStateHolderNavEntryDecorator()) rememberDecoratedNavEntries( backStack = stack, entryDecorators = decorators, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt index 36009bd8..817e89fd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeModels.kt @@ -49,5 +49,4 @@ data class MediaStream( val container: String, val bitrate: Int, val isAudio: Boolean, - val isVideo: Boolean, -) \ No newline at end of file + val isVideo: Boolean) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt index 6d22e903..01b4ea8d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt @@ -34,8 +34,7 @@ class CollectionPlaybackHelper( private val savedTracksRepository: SavedTracksRepository, private val artistRepository: ArtistRepository, private val audioPlayerQueue: AudioPlayerQueue, - private val blacklistRepository: BlacklistRepository, -) { + private val blacklistRepository: BlacklistRepository) { suspend fun playAlbum(albumId: String, shuffle: Boolean = false) { if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it } @@ -44,8 +43,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = QueueCollectionEntry.Album(albumId), - ) + collectionEntry = QueueCollectionEntry.Album(albumId)) } } @@ -54,8 +52,7 @@ class CollectionPlaybackHelper( if (entries.isNotEmpty()) { audioPlayerQueue.addAllToQueue( entries, - collectionEntry = QueueCollectionEntry.Album(albumId), - ) + collectionEntry = QueueCollectionEntry.Album(albumId)) } } @@ -87,8 +84,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = startPosition, - collectionEntry = QueueCollectionEntry.Album(albumId), - ) + collectionEntry = QueueCollectionEntry.Album(albumId)) } suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) { @@ -99,8 +95,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = QueueCollectionEntry.Playlist(playlistId), - ) + collectionEntry = QueueCollectionEntry.Playlist(playlistId)) } } @@ -109,8 +104,7 @@ class CollectionPlaybackHelper( if (entries.isNotEmpty()) { audioPlayerQueue.addAllToQueue( entries, - collectionEntry = QueueCollectionEntry.Playlist(playlistId), - ) + collectionEntry = QueueCollectionEntry.Playlist(playlistId)) } } @@ -142,8 +136,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = startPosition, - collectionEntry = QueueCollectionEntry.Playlist(playlistId), - ) + collectionEntry = QueueCollectionEntry.Playlist(playlistId)) } suspend fun playSavedTracks(shuffle: Boolean = false) { @@ -154,8 +147,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = QueueCollectionEntry.SavedTracks, - ) + collectionEntry = QueueCollectionEntry.SavedTracks) } } @@ -166,8 +158,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } } @@ -190,8 +181,7 @@ class CollectionPlaybackHelper( if (entries.isNotEmpty()) { audioPlayerQueue.addAllToQueue( entries, - collectionEntry = QueueCollectionEntry.SavedTracks, - ) + collectionEntry = QueueCollectionEntry.SavedTracks) } } @@ -216,8 +206,7 @@ class CollectionPlaybackHelper( entries = entries, autoPlay = true, startPosition = startPosition, - collectionEntry = QueueCollectionEntry.SavedTracks, - ) + collectionEntry = QueueCollectionEntry.SavedTracks) } private suspend fun fetchAllAlbumTracks(albumId: String): List { @@ -313,8 +302,7 @@ class CollectionPlaybackHelper( private fun isTrackBlacklisted( track: MetadataTrack, blacklistedTrackIds: Set, - blacklistedArtistIds: Set, - ): Boolean { + blacklistedArtistIds: Set): Boolean { return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt index cdda53c0..995d66e5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt @@ -17,17 +17,24 @@ package dev.krtirtho.spotube.core.remote -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import androidx.compose.ui.unit.dp +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog import org.koin.compose.koinInject @Composable @@ -42,50 +49,45 @@ fun ConnectionRequestDialogHost() { } pendingRequest?.let { request -> - AdaptiveDialogBottomSheet( - onDismiss = { + MillenniumDialog( + visible = true, + onDismissRequest = { handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) pendingRequest = null }, - title = { - Text( - text = "Remote Control Request", - style = MaterialTheme.typography.titleLarge, - ) - }, - content = { - Text( - text = "\"${request.deviceName}\" wants to control playback on this device.", - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Start, - ) - }, - actions = { - Button( + title = "Remote Control Request") { + Text( + text = "\"${request.deviceName}\" wants to control playback on this device.", + style = AppTheme.typography.bodyMedium, + textAlign = TextAlign.Start) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically) { + MillenniumButton( onClick = { handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) pendingRequest = null }, - ) { + style = MillenniumButtonStyle.Secondary) { Text("Deny") } - Button( + MillenniumButton( onClick = { handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow) pendingRequest = null - }, - ) { + }) { Text("Allow") } - Button( + MillenniumButton( onClick = { handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways) pendingRequest = null }, - ) { + style = MillenniumButtonStyle.Quiet) { Text("Allow Always") } - }, - ) + } + } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt index 8f2adeea..5dd7c43b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt @@ -162,8 +162,7 @@ class RemoteControlClient { val envelope = CommandEnvelope( commandId = randomShortId(), - command = command, - ) + command = command) try { val text = json.encodeToString(CommandEnvelope.serializer(), envelope) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt index 9307ea65..f593f5ad 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -48,13 +48,11 @@ class RemoteControlHandler( private val settingsRepository: SettingsRepository, private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, - private val collectionPlaybackHelper: CollectionPlaybackHelper, -) : KoinComponent { + private val collectionPlaybackHelper: CollectionPlaybackHelper) : KoinComponent { val logger by injectLogger() val incomingConnectionRequests = MutableSharedFlow( - extraBufferCapacity = 16, - ) + extraBufferCapacity = 16) private val json = Json { ignoreUnknownKeys = true @@ -88,8 +86,7 @@ class RemoteControlHandler( deviceId = deviceId ?: "unknown", deviceName = deviceName, sessionId = session.call.request.headers["X-Request-Id"] - ?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}", - ) + ?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}") incomingConnectionRequests.emit(request) val response = waitForRequestResolution(request.sessionId) if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) { @@ -225,8 +222,7 @@ class RemoteControlHandler( entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")), autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } is RemoteControlCommand.AddTrackToQueue -> { audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = "")) @@ -300,8 +296,7 @@ class RemoteControlHandler( private suspend fun handleCollectionSource( source: String, action: RemoteCollectionAction, - shuffle: Boolean = false, - ) { + shuffle: Boolean = false) { logger.d { "Remote collection $action for source: $source" } when { source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { @@ -359,8 +354,7 @@ class RemoteControlHandler( currentTrackTitle = current?.titleOrNull(), currentTrackArtists = current?.artistsOrNull(), currentTrackAlbum = current?.albumOrNull(), - currentTrackCoverUrl = current?.coverUrlOrNull(), - ) + currentTrackCoverUrl = current?.coverUrlOrNull()) val text = json.encodeToString(RemoteControlEvent.serializer(), state) session.send(Frame.Text(text)) } @@ -375,8 +369,7 @@ class RemoteControlHandler( } val event = RemoteControlEvent.QueueUpdated( entries = queue.map { it.toRemoteQueueEntry() }, - currentIndex = currentIndex, - ) + currentIndex = currentIndex) val text = json.encodeToString(RemoteControlEvent.serializer(), event) session.send(Frame.Text(text)) } @@ -403,8 +396,7 @@ class RemoteControlHandler( artists = track.artists.joinToString(", ") { artist -> artist.name }, album = track.album?.title, coverUrl = coverUrlOrNull(), - durationMs = track.durationMs, - ) + durationMs = track.durationMs) is QueueEntry.LocalTrack -> RemoteQueueEntry( mediaUrl = url, @@ -413,8 +405,7 @@ class RemoteControlHandler( artists = artists.joinToString(", "), album = album, coverUrl = null, - durationMs = duration, - ) + durationMs = duration) } private fun QueueEntry.mediaKey(): String = when (this) { @@ -447,8 +438,7 @@ class RemoteControlHandler( @Serializable data class CommandEnvelope( val commandId: String, - val command: RemoteControlCommand, -) + val command: RemoteControlCommand) private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/" private const val COLLECTION_ALBUM_PREFIX = "spotube://album/" @@ -464,8 +454,7 @@ enum class RemoteCollectionAction { data class ConnectionRequest( val deviceId: String, val deviceName: String, - val sessionId: String, -) + val sessionId: String) enum class ConnectionRequestResponse { Allow, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt index e2ab1bd9..dde77ef0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -119,15 +119,13 @@ sealed class RemoteControlEvent { val currentTrackTitle: String?, val currentTrackArtists: String?, val currentTrackAlbum: String?, - val currentTrackCoverUrl: String?, - ) : RemoteControlEvent() + val currentTrackCoverUrl: String?) : RemoteControlEvent() @Serializable @SerialName("queueUpdated") data class QueueUpdated( val entries: List, - val currentIndex: Int, - ) : RemoteControlEvent() + val currentIndex: Int) : RemoteControlEvent() @Serializable @SerialName("ack") @@ -146,5 +144,4 @@ data class RemoteQueueEntry( val artists: String, val album: String?, val coverUrl: String?, - val durationMs: Long, -) + val durationMs: Long) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt index 86b920d5..3d49aa31 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt @@ -53,8 +53,7 @@ import kotlin.random.Random class RemoteControlService( private val settingsRepository: SettingsRepository, private val discoveryService: DeviceDiscoveryService, - private val localServer: LocalServer, -) { + private val localServer: LocalServer) { private val log = Logger.withTag("RemoteControlService") private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -78,8 +77,7 @@ class RemoteControlService( scope.launch { combine( settingsRepository.userSettings, - localServer.port, - ) { settings, port -> settings to port } + localServer.port) { settings, port -> settings to port } .distinctUntilChanged() .collect { (settings, port) -> if (settings.allowRemoteControl && port != null) { @@ -139,8 +137,7 @@ class RemoteControlService( val service = discoveryService.createService( name = serviceName, port = port, - deviceId = deviceId, - ) + deviceId = deviceId) pendingService = service try { service.register(timeoutInMs = REGISTER_TIMEOUT_MS) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 57c89e82..3627dff7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -64,20 +64,17 @@ sealed interface PlaybackDestinationRequest { val type: RemoteCollectionType, val id: String, val startTrack: MetadataTrack? = null, - val shuffle: Boolean = false, - ) : PlaybackDestinationRequest + val shuffle: Boolean = false) : PlaybackDestinationRequest data class Track( override val title: String, override val action: PlaybackDestinationAction, - val track: MetadataTrack, - ) : PlaybackDestinationRequest + val track: MetadataTrack) : PlaybackDestinationRequest data class Tracks( override val title: String, override val action: PlaybackDestinationAction, - val tracks: List, - ) : PlaybackDestinationRequest + val tracks: List) : PlaybackDestinationRequest } /** @@ -90,8 +87,7 @@ class RemotePlaybackController( private val collectionPlaybackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, private val blacklistRepository: BlacklistRepository, - private val jamRoomService: JamRoomService, -) : KoinComponent { + private val jamRoomService: JamRoomService) : KoinComponent { private val logger = Logger.withTag("RemotePlaybackController") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -113,8 +109,7 @@ class RemotePlaybackController( id: String, title: String, startTrack: MetadataTrack? = null, - shuffle: Boolean = false, - ) { + shuffle: Boolean = false) { request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle)) } @@ -205,8 +200,7 @@ class RemotePlaybackController( QueueEntry.StreamingTrack( track = track, url = "", - addedBy = jamRoomService.participantClientId, - ) + addedBy = jamRoomService.participantClientId) } ) @@ -291,8 +285,7 @@ class RemotePlaybackController( entries = listOf(entry), autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } PlaybackDestinationAction.AddToQueue -> { @@ -319,8 +312,7 @@ class RemotePlaybackController( entries = entries, autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } PlaybackDestinationAction.AddToQueue -> { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt index 7e622b32..b48408b2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManager.kt @@ -52,8 +52,7 @@ class ScrobbleManager( private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, private val scrobblePluginSource: ScrobblePluginSource, - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), -) : KoinComponent { + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) : KoinComponent { private val logger by injectLogger() @@ -69,14 +68,12 @@ class ScrobbleManager( audioPlayerQueue.currentQueueEntryFlow, audioPlayer.playerStateFlow, audioPlayer.positionFlow, - audioPlayer.durationFlow, - ) { entry, playerState, position, duration -> + audioPlayer.durationFlow) { entry, playerState, position, duration -> PlaybackSnapshot( entry = entry, playerState = playerState, positionMs = position.inWholeMilliseconds, - playerDurationMs = duration.inWholeMilliseconds, - ) + playerDurationMs = duration.inWholeMilliseconds) }.collect(::onPlaybackSnapshot) } } @@ -137,8 +134,7 @@ class ScrobbleManager( scrobbled = true val track = entry.toScrobbleTrack( timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(), - durationMs = durationMs, - ) + durationMs = durationMs) scope.launch { submitScrobble(track) } } @@ -197,8 +193,7 @@ class ScrobbleManager( artistName = track.artists.joinToString(", ") { it.name }, albumName = track.album?.title, streamingProvider = resolveStreamingProvider(track), - durationMs = durationMs, - ) + durationMs = durationMs) is QueueEntry.LocalTrack -> ScrobbleTrack( timestamp = timestamp, @@ -209,8 +204,7 @@ class ScrobbleManager( artistName = artists.joinToString(", "), albumName = album, streamingProvider = LOCAL_PROVIDER, - durationMs = durationMs, - ) + durationMs = durationMs) } } @@ -245,8 +239,7 @@ class ScrobbleManager( val entry: QueueEntry?, val playerState: PlayerState, val positionMs: Long, - val playerDurationMs: Long, - ) + val playerDurationMs: Long) companion object { /** Tracks shorter than this are never scrobbled (Last.fm rule). */ diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt index 77ad48f0..fa70b004 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/AlternativeTracksRepository.kt @@ -29,8 +29,7 @@ class AlternativeTracksRepository( private val matchedTracksRepository: TrackSourceRepository, private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, - private val cacheManager: CacheManager, -) : KoinComponent { + private val cacheManager: CacheManager) : KoinComponent { private val logger by injectLogger() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt index c5db9b1d..b2c73d19 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/CacheManager.kt @@ -51,13 +51,11 @@ data class CacheEntry( val filename: String, val sizeBytes: Long, val createdAtMs: Long, - val contentType: String = "application/octet-stream", -) + val contentType: String = "application/octet-stream") class CacheManager( private val paths: Paths, - private val settingsRepository: SettingsRepository, -) { + private val settingsRepository: SettingsRepository) { private val fileSystem: FileSystem = FileSystem.SYSTEM private val cacheMutex: Mutex = Mutex() private val json: Json = Json { ignoreUnknownKeys = true } @@ -160,15 +158,13 @@ class CacheManager( trackId: String, filename: String, allBytes: ByteArray, - contentType: String, - ) { + contentType: String) { val entry = CacheEntry( trackId = trackId, filename = filename, sizeBytes = allBytes.size.toLong(), createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(), - contentType = contentType, - ) + contentType = contentType) cacheMutex.withLock { val index = readCacheIndex() writeCacheIndex(CacheIndex( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt index e23f9b02..26a57426 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt @@ -60,8 +60,7 @@ class LocalServer( private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, private val cacheManager: CacheManager, - private val remoteControlHandler: RemoteControlHandler, -) : KoinComponent { + private val remoteControlHandler: RemoteControlHandler) : KoinComponent { val logger by injectLogger() private val httpClient = HttpClient { @@ -99,8 +98,7 @@ class LocalServer( audioPlayerQueue = audioPlayerQueue, isCachingEnabled = { cachedCacheEnabled.value }, activePort = { activePort.value }, - scope = scope, - ) + scope = scope) } companion object { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt index d0170c1b..8c006c5d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/MatchTracksRepository.kt @@ -37,8 +37,7 @@ import kotlinx.serialization.json.Json @Serializable data class PluginTaggedAudioSource( val pluginId: String, - val source: AudioSource.Basic, -) + val source: AudioSource.Basic) /** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */ interface TrackSourceRepository { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt index ede7b89c..02e3f6c5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamProxy.kt @@ -62,8 +62,7 @@ internal class StreamProxy( private val audioPlayerQueue: AudioPlayerQueue, private val isCachingEnabled: () -> Boolean, private val activePort: () -> Int?, - private val scope: CoroutineScope, -): KoinComponent { + private val scope: CoroutineScope): KoinComponent { val logger by injectLogger() companion object { @@ -414,8 +413,7 @@ internal class StreamProxy( trackId = trackIdForCache, filename = cacheFilename, allBytes = allBytes, - contentType = cacheContentType, - ) + contentType = cacheContentType) scope.launch { cacheManager.evictIfNeeded() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt index 7ba99621..7ff57906 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepository.kt @@ -48,28 +48,24 @@ data class CachedStreamUrl( // The id of the audio plugin that produced this entry. Guards against a stale entry // (e.g. a YouTube video URL) being served after the user switches to a different // audio plugin, which would otherwise play back completely unrelated content. - val pluginId: String, -) + val pluginId: String) private data class CachedAlternatives( val pluginId: String, - val sources: List, -) + val sources: List) data class StreamInfo( val url: String, val protocol: StreamProtocol, val codec: String, - val container: String, -) + val container: String) class StreamingUrlRepository( private val pluginManager: AudioPluginSource, private val audioPlayerQueue: AudioPlayerQueue, private val matchedTracksRepository: TrackSourceRepository, private val settingsRepository: UserSettingsSource, - private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds, -) : KoinComponent { + private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent { private val logger by injectLogger() private val streamUrlCacheMutex = Mutex() @@ -133,8 +129,7 @@ class StreamingUrlRepository( val resolvedStream = selectPreferredAudioStream( stream.streams, preferredFormat, - preferredQuality, - ) + preferredQuality) if (resolvedStream != null) { val info = resolvedStream.toStreamInfo() cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) @@ -193,8 +188,7 @@ class StreamingUrlRepository( val resolvedStream = selectPreferredAudioStream( stream.streams, preferredFormat, - preferredQuality, - ) + preferredQuality) if (resolvedStream != null) { val info = resolvedStream.toStreamInfo() cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) @@ -207,15 +201,13 @@ class StreamingUrlRepository( url = normalizeManifestUrl(url, protocol), protocol = protocol, codec = codec, - container = container, - ) + container = container) private suspend fun getCachedStreamUrl( trackId: String, preferredFormat: AudioFormat, preferredQuality: AudioQuality, - pluginId: String, - ): StreamInfo? { + pluginId: String): StreamInfo? { val now = Clock.System.now().toEpochMilliseconds() return streamUrlCacheMutex.withLock { val cached = streamUrlCache[trackId] ?: return@withLock null @@ -241,8 +233,7 @@ class StreamingUrlRepository( preferredQuality: AudioQuality, source: AudioSource.Streamed, selectedStream: AudioStream, - pluginId: String, - ) { + pluginId: String) { val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs streamUrlCacheMutex.withLock { streamUrlCache[trackId] = CachedStreamUrl( @@ -255,8 +246,7 @@ class StreamingUrlRepository( selectedStream = selectedStream, preferredFormat = preferredFormat, preferredQuality = preferredQuality, - pluginId = pluginId, - ) + pluginId = pluginId) } } @@ -326,8 +316,7 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String internal fun selectPreferredAudioStream( streams: List, preferredFormat: AudioFormat, - preferredQuality: AudioQuality, -): AudioStream? = streams.minWithOrNull( + preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull( compareBy { stream -> formatMismatchCount(stream, preferredFormat) }.thenBy { stream -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt deleted file mode 100644 index 670524a9..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/AutocompleteTextField.kt +++ /dev/null @@ -1,569 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -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.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -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.graphics.Shape -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash - -private val AutocompleteTextFieldMinHeight = 44.dp -private val AutocompleteMenuDefaultMaxHeight = 300.dp -private val AutocompleteMenuDefaultOffset = 4.dp - -private data class ResolvedAutoCompleteTextFieldState( - val background: Brush, - val highlight: Color, - val shape: Shape, - val border: BaseUITheme.Border, - val shadow: BaseUITheme.Shadow, - val foreground: Color, -) - -@Composable -private fun resolveTextFieldState( - theme: BaseUITheme.TextFieldTheme, - isFocused: Boolean, - isHovered: Boolean, -): ResolvedAutoCompleteTextFieldState { - return when { - isFocused -> ResolvedAutoCompleteTextFieldState( - theme.background.focused, - theme.highlight.focused, - theme.shape.focused, - theme.border.focused, - theme.shadow.focused, - theme.foreground.focused - ) - - isHovered -> ResolvedAutoCompleteTextFieldState( - theme.background.hovered, - theme.highlight.hovered, - theme.shape.hovered, - theme.border.hovered, - theme.shadow.hovered, - theme.foreground.hovered - ) - - else -> ResolvedAutoCompleteTextFieldState( - theme.background.normal, - theme.highlight.normal, - theme.shape.normal, - theme.border.normal, - theme.shadow.normal, - theme.foreground.normal - ) - } -} - -@Composable -fun AutocompleteTextField( - value: String, - onValueChange: (String) -> Unit, - items: List, - itemContent: @Composable (item: T, isSelected: Boolean) -> Unit, - modifier: Modifier = Modifier, - onItemSelected: (T) -> Unit = {}, - placeholder: @Composable (() -> Unit)? = null, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - label: @Composable (() -> Unit)? = null, - singleLine: Boolean = true, - maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, - enabled: Boolean = true, - readOnly: Boolean = false, - isError: Boolean = false, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - visualTransformation: VisualTransformation = VisualTransformation.None, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - textStyle: TextStyle = TextStyle.Default, - cursorBrush: Color? = null, - onKeyEvent: ((KeyEvent) -> Boolean)? = null, - menuMaxHeight: Dp = AutocompleteMenuDefaultMaxHeight, - menuOffset: Dp = AutocompleteMenuDefaultOffset, - textFieldTheme: BaseUITheme.TextFieldTheme? = null, - menuTheme: BaseUITheme.AutoCompleteMenuTheme? = null, -) { - val density = LocalDensity.current - val baseTextFieldTheme = LocalBaseUITheme.current.textField - val baseMenuTheme = LocalBaseUITheme.current.autoCompleteMenuTheme - val resolvedTextFieldTheme = textFieldTheme ?: baseTextFieldTheme - val resolvedMenuTheme = menuTheme ?: baseMenuTheme - val isFocused by interactionSource.collectIsFocusedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - - var isMenuOpen by remember { mutableStateOf(false) } - var selectedIndex by remember { mutableIntStateOf(-1) } - var textFieldSize by remember { mutableStateOf(IntSize.Zero) } - val menuListState = rememberLazyListState() - val textFieldFocusRequester = remember { FocusRequester() } - - val menuExpanded = isMenuOpen && items.isNotEmpty() - - LaunchedEffect(isFocused) { - if (isFocused) { - isMenuOpen = true - } - } - - LaunchedEffect(value) { - if (selectedIndex != -1) selectedIndex = -1 - } - - LaunchedEffect(selectedIndex) { - if (selectedIndex >= 0 && menuExpanded) { - menuListState.scrollToItem(selectedIndex) - } - } - - val wrappedKeyboardActions = remember(keyboardActions, menuExpanded, selectedIndex, items) { - KeyboardActions( - onSearch = { - if (menuExpanded && selectedIndex in items.indices) { - onItemSelected(items[selectedIndex]) - selectedIndex = -1 - isMenuOpen = false - } else { - keyboardActions.onSearch?.invoke(this) - } - }, - onDone = keyboardActions.onDone, - onGo = keyboardActions.onGo, - onNext = keyboardActions.onNext, - onPrevious = keyboardActions.onPrevious, - onSend = keyboardActions.onSend, - ) - } - - val fieldState = resolveTextFieldState(resolvedTextFieldTheme, isFocused, isHovered) - - val resolvedBorder = if (isError) { - BaseUITheme.Border(MaterialTheme.colorScheme.error, fieldState.border.width) - } else { - fieldState.border - } - - val contentColor = when { - !enabled -> fieldState.foreground.copy(alpha = 0.38f) - else -> fieldState.foreground - } - - val cursor = cursorBrush?.let { SolidColor(it) } ?: resolvedTextFieldTheme.cursor - - fun navigate(delta: Int) { - if (items.isEmpty()) return - selectedIndex = ((selectedIndex + delta) % items.size + items.size) % items.size - } - - Box(modifier = modifier) { - Column { - AnimatedVisibility( - visible = label != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - label?.invoke() - } - - Box( - modifier = Modifier - .fillMaxWidth() - .onSizeChanged { textFieldSize = it } - .defaultMinSize(minHeight = AutocompleteTextFieldMinHeight) - .then( - if (fieldState.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = fieldState.shadow.elevation, - shape = fieldState.shape, - ambientColor = fieldState.shadow.ambientColor, - spotColor = fieldState.shadow.spotColor, - ) - } else Modifier - ) - .clip(fieldState.shape) - .background(fieldState.background, fieldState.shape) - .border( - BorderStroke(resolvedBorder.width, resolvedBorder.color), - fieldState.shape - ) - .highlight(fieldState.highlight) - .padding(resolvedTextFieldTheme.padding), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (leadingIcon != null) { - leadingIcon() - } - Box(modifier = Modifier.weight(1f)) { - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier - .fillMaxWidth() - .hoverable( - interactionSource = interactionSource, - enabled = enabled, - ) - .focusRequester(textFieldFocusRequester) - .onFocusChanged { state -> - if (!state.isFocused) { - isMenuOpen = false - selectedIndex = -1 - } - } - .onPreviewKeyEvent { event -> - if (onKeyEvent?.invoke(event) == true) { - return@onPreviewKeyEvent true - } - if (!menuExpanded) return@onPreviewKeyEvent false - when (event.key) { - Key.DirectionDown -> { - navigate(1) - true - } - - Key.DirectionUp -> { - if (selectedIndex >= 0) { - navigate(-1) - true - } else false - } - - Key.Enter -> { - if (selectedIndex in items.indices) { - onItemSelected(items[selectedIndex]) - selectedIndex = -1 - isMenuOpen = false - return@onPreviewKeyEvent true - } - false - } - - Key.Escape -> { - selectedIndex = -1 - isMenuOpen = false - true - } - - else -> false - } - }, - enabled = enabled, - readOnly = readOnly, - textStyle = textStyle.copy(color = contentColor), - cursorBrush = cursor, - keyboardOptions = keyboardOptions, - keyboardActions = wrappedKeyboardActions, - singleLine = singleLine, - maxLines = maxLines, - visualTransformation = visualTransformation, - interactionSource = interactionSource, - decorationBox = { innerTextField -> - if (value.isEmpty() && placeholder != null && !isFocused) { - CompositionLocalProvider(LocalTextStyle provides textStyle) { - Box(contentAlignment = Alignment.CenterStart) { - placeholder() - } - } - } else { - innerTextField() - } - }, - ) - } - - if (trailingIcon != null) { - trailingIcon() - } - } - } - } - - if (menuExpanded && textFieldSize.width > 0) { - val textFieldHeightPx = textFieldSize.height - val menuOffsetPx = with(density) { menuOffset.roundToPx() } - val textFieldWidthDp = with(density) { textFieldSize.width.toDp() } - - Popup( - alignment = Alignment.TopStart, - offset = IntOffset(0, textFieldHeightPx + menuOffsetPx), - properties = PopupProperties(focusable = false), - onDismissRequest = { - selectedIndex = -1 - isMenuOpen = false - }, - ) { - AutocompleteMenuContainer( - theme = resolvedMenuTheme, - modifier = Modifier - .width(textFieldWidthDp) - .heightIn(max = menuMaxHeight), - ) { - LazyColumn( - state = menuListState, - modifier = Modifier - .heightIn(max = menuMaxHeight), - ) { - items(items.size) { index -> - Box( - modifier = Modifier - .fillMaxWidth() - .clickable { - onItemSelected(items[index]) - selectedIndex = -1 - isMenuOpen = false - textFieldFocusRequester.requestFocus() - }, - ) { - itemContent(items[index], selectedIndex == index) - } - } - } - } - } - } - } -} - -/** - * The visual container of the autocomplete suggestion menu, without the Popup window. - * - * Extracted so the menu can be rendered in previews, since popups are hosted in a - * separate window the preview canvas cannot show. - */ -@Composable -internal fun AutocompleteMenuContainer( - theme: BaseUITheme.AutoCompleteMenuTheme, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - Box( - modifier = modifier - .shadow(theme.shadowElevation, theme.shape) - .background(theme.background, theme.shape) - .clip(theme.shape), - ) { - content() - } -} - -private val AutocompletePreviewArtists = listOf( - "Twenty One Pilots", - "The Beatles", - "Adele", - "Drake", - "Taylor Swift", - "Arctic Monkeys", - "Billie Eilish", -) - -@BaseUIPhonePreview -@Composable -private fun AutocompleteTextFieldPreview() { - BaseUIPreview { - var value by remember { mutableStateOf("") } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - Text( - text = "Field states", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - AutocompleteTextField( - value = "", - onValueChange = {}, - items = emptyList(), - itemContent = { _, _ -> }, - placeholder = { Text("Search artists...") }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - ) - }, - modifier = Modifier.fillMaxWidth(), - ) - AutocompleteTextField( - value = value, - onValueChange = { value = it }, - items = AutocompletePreviewArtists.filter { - it.contains(value, ignoreCase = true) - }, - onItemSelected = { value = it }, - placeholder = { Text("Search artists...") }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - ) - }, - trailingIcon = { - Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = "Clear", - ) - }, - modifier = Modifier.fillMaxWidth(), - itemContent = { item, isSelected -> - AutocompletePreviewItem(item, isSelected) - }, - ) - } - } -} - -@BaseUIPhonePreview -@Composable -private fun AutocompleteMenuPreview() { - BaseUIPreview { - var selectedIndex by remember { mutableStateOf(1) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = "Suggestion menu", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - AutocompleteMenuContainer( - theme = LocalBaseUITheme.current.autoCompleteMenuTheme, - modifier = Modifier.width(320.dp), - ) { - Column { - AutocompletePreviewArtists.take(5).forEachIndexed { index, artist -> - Box( - modifier = Modifier.clickable { selectedIndex = index }, - ) { - AutocompletePreviewItem(artist, isSelected = selectedIndex == index) - } - } - } - } - Text( - text = "Selected: ${AutocompletePreviewArtists[selectedIndex]}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun AutocompletePreviewItem(text: String, isSelected: Boolean) { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = if (isSelected) { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) - } else { - Color.Transparent - }, - ) - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt deleted file mode 100644 index cc588b44..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIColorScheme.kt +++ /dev/null @@ -1,381 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.luminance - -/** - * The app's own colour system, independent of [MaterialTheme]. - * - * Each [SurfaceStyle] is a complete visual theme with its own palette (backgrounds, - * surfaces, text and outlines) rather than Material's role colours. The user's chosen - * accent is threaded through as `primary`, but everything else comes from the surface - * theme, so the app never has to rely on Material's colour scheme. - */ -@Immutable -data class BaseUIColorScheme( - val surfaceTheme: SurfaceStyle, - val isLight: Boolean, - /** Full-bleed page background for the current surface theme. */ - val background: Brush, - val surface: Color, - val surfaceVariant: Color, - val surfaceContainer: Color, - val surfaceContainerHigh: Color, - val surfaceContainerHighest: Color, - val onBackground: Color, - val onSurface: Color, - val onSurfaceVariant: Color, - val primary: Color, - val onPrimary: Color, - val primaryContainer: Color, - val onPrimaryContainer: Color, - val secondaryContainer: Color, - val onSecondaryContainer: Color, - val backgroundPrimary: Color, - val onBackgroundPrimary: Color, - val backgroundSecondary: Color, - val onBackgroundSecondary: Color, - val accentPrimary: Color, - val onAccentPrimary: Color, - val accentSecondary: Color, - val onAccentSecondary: Color, - val backgroundInverse: Color, - val onBackgroundInverse: Color, - val actionInverse: Color, - val onActionInverse: Color, - val outline: Color, - val outlineVariant: Color, - val error: Color, - val onError: Color, - val scrim: Color, -) - -val LocalBaseUIColors = staticCompositionLocalOf { - error("No BaseUIColorScheme provided. Wrap your UI in a BaseUITheme provider.") -} - -/** - * Builds the [BaseUIColorScheme] for the selected [surfaceTheme] and accent. - * - * Light/dark is read from the surrounding [MaterialTheme] (which the app already drives - * from the user's theme setting), and the accent comes from the user's chosen accent - * colour. Everything else is derived from the surface theme palette. - */ -@Composable -fun rememberBaseUIColorScheme( - surfaceTheme: SurfaceStyle = SurfaceStyle.Glass, - accentColor: Color? = null, -): BaseUIColorScheme { - val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f - val accent = accentColor ?: surfaceTheme.accentColor(isLight) - return remember(surfaceTheme, isLight, accent) { - baseUIColorScheme(surfaceTheme, isLight, accent) - } -} - -/** - * The default accent colour that characterises a surface theme. Every theme has its own, - * so a Watery app is aqua and a Chrome app is steel, unless an accent is passed explicitly. - */ -fun SurfaceStyle.accentColor(isLight: Boolean): Color = when (this) { - SurfaceStyle.Watery -> if (isLight) Color(0xFF0E8FD6) else Color(0xFF4FC3F7) - SurfaceStyle.Icy -> if (isLight) Color(0xFF3FA9E0) else Color(0xFF7FD8FF) - SurfaceStyle.Chrome -> if (isLight) Color(0xFF5B6B7C) else Color(0xFF9AA5B4) - SurfaceStyle.Acrylic -> if (isLight) Color(0xFF5C6BC0) else Color(0xFF8C9EFF) - SurfaceStyle.Glass -> if (isLight) Color(0xFF7C4DFF) else Color(0xFFB388FF) -} - -/** - * The secondary accent is derived from the primary accent as a complementary hue, so it always - * shifts with — and harmonises with — the chosen accent instead of being fixed per style. - */ -private fun Color.complementaryAccent(hueShift: Float = 180f): Color { - val (hue, saturation, value) = toHsv() - return Color.hsv( - (hue + hueShift + 360f) % 360f, - saturation.coerceIn(0.45f, 0.9f), - value.coerceIn(0.45f, 0.95f), - ) -} - -private fun Color.toHsv(): Triple { - val max = maxOf(red, green, blue) - val min = minOf(red, green, blue) - val delta = max - min - val rawHue = when { - delta == 0f -> 0f - max == red -> 60f * (((green - blue) / delta) % 6f) - max == green -> 60f * (((blue - red) / delta) + 2f) - else -> 60f * (((red - green) / delta) + 4f) - } - val hue = if (rawHue < 0f) rawHue + 360f else rawHue - val saturation = if (max == 0f) 0f else delta / max - return Triple(hue, saturation, max) -} - -fun baseUIColorScheme( - surfaceTheme: SurfaceStyle, - isLight: Boolean, - accent: Color, -): BaseUIColorScheme { - val palette = surfacePalette(surfaceTheme, isLight) - val onPrimary = if (accent.luminance() > 0.5f) Color(0xFF10161C) else Color.White - val primaryContainer = lerp(palette.surface, accent, if (isLight) 0.16f else 0.32f) - val onPrimaryContainer = if (isLight) { - lerp(accent, Color.Black, 0.55f) - } else { - lerp(accent, Color.White, 0.7f) - } - val secondaryContainer = lerp(palette.surface, accent, if (isLight) 0.22f else 0.34f) - val inversePalette = surfacePalette(surfaceTheme, !isLight) - val secondaryAccent = accent.complementaryAccent() - val onSecondaryAccent = if (secondaryAccent.luminance() > 0.5f) Color(0xFF10161C) else Color.White - val secondaryBackground = if (isLight) { - lerp(palette.surface, secondaryAccent, 0.14f) - } else { - lerp(palette.surface, secondaryAccent, 0.22f) - } - val onSecondaryBackground = if (secondaryBackground.luminance() > 0.5f) { - lerp(secondaryAccent, Color.Black, 0.6f) - } else { - lerp(secondaryAccent, Color.White, 0.82f) - } - val actionInverse = if (isLight) lerp(accent, Color.Black, 0.45f) else lerp(accent, Color.White, 0.5f) - val onActionInverse = if (actionInverse.luminance() > 0.5f) Color(0xFF10161C) else Color.White - - return BaseUIColorScheme( - surfaceTheme = surfaceTheme, - isLight = isLight, - background = Brush.verticalGradient( - listOf(palette.backgroundTop, palette.backgroundBottom), - ), - surface = palette.surface, - surfaceVariant = palette.surfaceVariant, - surfaceContainer = palette.surfaceContainer, - surfaceContainerHigh = palette.surfaceContainerHigh, - surfaceContainerHighest = palette.surfaceContainerHighest, - onBackground = palette.onBackground, - onSurface = palette.onSurface, - onSurfaceVariant = palette.onSurfaceVariant, - primary = accent, - onPrimary = onPrimary, - primaryContainer = primaryContainer, - onPrimaryContainer = onPrimaryContainer, - secondaryContainer = secondaryContainer, - onSecondaryContainer = palette.onSurface, - backgroundPrimary = palette.surface, - onBackgroundPrimary = palette.onSurface, - backgroundSecondary = secondaryBackground, - onBackgroundSecondary = onSecondaryBackground, - accentPrimary = accent, - onAccentPrimary = onPrimary, - accentSecondary = secondaryAccent, - onAccentSecondary = onSecondaryAccent, - backgroundInverse = inversePalette.surface, - onBackgroundInverse = inversePalette.onSurface, - actionInverse = actionInverse, - onActionInverse = onActionInverse, - outline = palette.outline, - outlineVariant = palette.outlineVariant, - error = if (isLight) Color(0xFFC0392B) else Color(0xFFE57373), - onError = Color.White, - scrim = Color.Black.copy(alpha = 0.42f), - ) -} - -internal class SurfacePalette( - val backgroundTop: Color, - val backgroundBottom: Color, - val surface: Color, - val surfaceVariant: Color, - val surfaceContainer: Color, - val surfaceContainerHigh: Color, - val surfaceContainerHighest: Color, - val onBackground: Color, - val onSurface: Color, - val onSurfaceVariant: Color, - val outline: Color, - val outlineVariant: Color, -) - -internal fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette { - return if (isLight) { - when (style) { - SurfaceStyle.Glass -> SurfacePalette( - backgroundTop = Color(0xFFEAF0F7), - backgroundBottom = Color(0xFFD8E3F1), - surface = Color(0xFFF8FBFF), - surfaceVariant = Color(0xFFE4ECF5), - surfaceContainer = Color(0xFFEEF3F9), - surfaceContainerHigh = Color(0xFFF3F7FC), - surfaceContainerHighest = Color(0xFFE1E9F3), - onBackground = Color(0xFF1B2733), - onSurface = Color(0xFF1B2733), - onSurfaceVariant = Color(0xFF56677A), - outline = Color(0xFFA9B8C8), - outlineVariant = Color(0xFFD3DDE8), - ) - - SurfaceStyle.Acrylic -> SurfacePalette( - backgroundTop = Color(0xFFE6EAF0), - backgroundBottom = Color(0xFFD6DCE5), - surface = Color(0xFFF4F7FA), - surfaceVariant = Color(0xFFE1E7EE), - surfaceContainer = Color(0xFFEAEEF4), - surfaceContainerHigh = Color(0xFFF1F4F8), - surfaceContainerHighest = Color(0xFFDCE2E9), - onBackground = Color(0xFF1C222B), - onSurface = Color(0xFF1C222B), - onSurfaceVariant = Color(0xFF545E6C), - outline = Color(0xFFA6AEBC), - outlineVariant = Color(0xFFD2D8E0), - ) - - SurfaceStyle.Watery -> SurfacePalette( - backgroundTop = Color(0xFFEAF7FE), - backgroundBottom = Color(0xFFD8EFFB), - surface = Color(0xFFF0FAFF), - surfaceVariant = Color(0xFFE2F2FB), - surfaceContainer = Color(0xFFEAF6FD), - surfaceContainerHigh = Color(0xFFF2FAFE), - surfaceContainerHighest = Color(0xFFD8ECF8), - onBackground = Color(0xFF08324A), - onSurface = Color(0xFF08324A), - onSurfaceVariant = Color(0xFF3A6884), - outline = Color(0xFF9EC4DA), - outlineVariant = Color(0xFFD0E7F4), - ) - - SurfaceStyle.Icy -> SurfacePalette( - backgroundTop = Color(0xFFE8F7FF), - backgroundBottom = Color(0xFFC9E8FB), - surface = Color(0xFFF6FCFF), - surfaceVariant = Color(0xFFE0F1FC), - surfaceContainer = Color(0xFFEBF7FE), - surfaceContainerHigh = Color(0xFFF3FBFF), - surfaceContainerHighest = Color(0xFFD4EBFA), - onBackground = Color(0xFF0B3A55), - onSurface = Color(0xFF0B3A55), - onSurfaceVariant = Color(0xFF3D6C88), - outline = Color(0xFF93C0D9), - outlineVariant = Color(0xFFC9E5F4), - ) - - SurfaceStyle.Chrome -> SurfacePalette( - backgroundTop = Color(0xFFE4E8ED), - backgroundBottom = Color(0xFFD0D6DE), - surface = Color(0xFFF4F7FA), - surfaceVariant = Color(0xFFE0E5EB), - surfaceContainer = Color(0xFFE9EDF2), - surfaceContainerHigh = Color(0xFFF1F4F7), - surfaceContainerHighest = Color(0xFFDBE0E7), - onBackground = Color(0xFF1B2733), - onSurface = Color(0xFF1B2733), - onSurfaceVariant = Color(0xFF525C6A), - outline = Color(0xFFA2AAB6), - outlineVariant = Color(0xFFD0D5DC), - ) - } - } else { - when (style) { - SurfaceStyle.Glass -> SurfacePalette( - backgroundTop = Color(0xFF0E1217), - backgroundBottom = Color(0xFF161C24), - surface = Color(0xFF1E242C), - surfaceVariant = Color(0xFF262E38), - surfaceContainer = Color(0xFF1A2027), - surfaceContainerHigh = Color(0xFF232A33), - surfaceContainerHighest = Color(0xFF2C343E), - onBackground = Color(0xFFE6EDF5), - onSurface = Color(0xFFE6EDF5), - onSurfaceVariant = Color(0xFF9AA7B6), - outline = Color(0xFF46525F), - outlineVariant = Color(0xFF2C343E), - ) - - SurfaceStyle.Acrylic -> SurfacePalette( - backgroundTop = Color(0xFF101317), - backgroundBottom = Color(0xFF181C22), - surface = Color(0xFF20262E), - surfaceVariant = Color(0xFF282F39), - surfaceContainer = Color(0xFF1C2229), - surfaceContainerHigh = Color(0xFF252C35), - surfaceContainerHighest = Color(0xFF2E3640), - onBackground = Color(0xFFE4EAF1), - onSurface = Color(0xFFE4EAF1), - onSurfaceVariant = Color(0xFF98A2B0), - outline = Color(0xFF48525E), - outlineVariant = Color(0xFF2E3640), - ) - - SurfaceStyle.Watery -> SurfacePalette( - backgroundTop = Color(0xFF0A1622), - backgroundBottom = Color(0xFF0E1E2D), - surface = Color(0xFF0E2233), - surfaceVariant = Color(0xFF153148), - surfaceContainer = Color(0xFF0B1D2C), - surfaceContainerHigh = Color(0xFF12293C), - surfaceContainerHighest = Color(0xFF183449), - onBackground = Color(0xFFD6EAF7), - onSurface = Color(0xFFD6EAF7), - onSurfaceVariant = Color(0xFF8FB0C6), - outline = Color(0xFF3C5A72), - outlineVariant = Color(0xFF183449), - ) - - SurfaceStyle.Icy -> SurfacePalette( - backgroundTop = Color(0xFF0C1620), - backgroundBottom = Color(0xFF132331), - surface = Color(0xFF16283A), - surfaceVariant = Color(0xFF1E3448), - surfaceContainer = Color(0xFF122130), - surfaceContainerHigh = Color(0xFF1A2E42), - surfaceContainerHighest = Color(0xFF213A50), - onBackground = Color(0xFFDCEBF7), - onSurface = Color(0xFFDCEBF7), - onSurfaceVariant = Color(0xFF92B3CA), - outline = Color(0xFF40607A), - outlineVariant = Color(0xFF213A50), - ) - - SurfaceStyle.Chrome -> SurfacePalette( - backgroundTop = Color(0xFF101317), - backgroundBottom = Color(0xFF181C22), - surface = Color(0xFF232830), - surfaceVariant = Color(0xFF2B313A), - surfaceContainer = Color(0xFF1E232A), - surfaceContainerHigh = Color(0xFF272D36), - surfaceContainerHighest = Color(0xFF303742), - onBackground = Color(0xFFE7ECF2), - onSurface = Color(0xFFE7ECF2), - onSurfaceVariant = Color(0xFF9BA5B2), - outline = Color(0xFF4B5561), - outlineVariant = Color(0xFF303742), - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt deleted file mode 100644 index 7ce9a6f2..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/BaseUIPreviews.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview - -/** - * Shared scaffold for every preview of a base UI component. - * - * It mirrors the runtime environment the components expect: - * - a [MaterialTheme] whose light/dark mode follows the preview's `uiMode`, so the - * `@PreviewLightDark`-style annotations actually change the rendered colors, - * - a [LocalBaseUITheme] built from that color scheme via [rememberBaseUITheme], - * - a [Surface] matching the real app background, so layered highlights and shadows - * render against the color they will actually sit on. - * - * Always render base components inside [BaseUIPreview]; several of them read - * [LocalBaseUITheme] directly and would otherwise throw at composition time. - */ -@Composable -internal fun BaseUIPreview( - modifier: Modifier = Modifier, - surfaceTheme: SurfaceStyle = SurfaceStyle.Glass, - content: @Composable BoxScope.() -> Unit, -) { - MaterialTheme( - colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme(), - ) { - val theme = rememberBaseUITheme(surfaceTheme = surfaceTheme) - CompositionLocalProvider( - LocalBaseUITheme provides theme, - LocalBaseUIColors provides theme.colors, - ) { - Surface( - modifier = modifier.fillMaxSize(), - color = theme.colors.surface, - ) { - Box(modifier = Modifier.fillMaxSize(), content = content) - } - } - } -} - -/** - * Renders a base UI component on a phone-sized canvas in both light and dark themes. - */ -@Preview(name = "Phone · Light", group = "Base UI devices", device = Devices.PHONE) -@Preview( - name = "Phone · Dark", - group = "Base UI devices", - device = Devices.PHONE, - uiMode = UI_MODE_NIGHT_YES, -) -internal annotation class BaseUIPhonePreview - -/** - * Renders a base UI component on a desktop-sized canvas in both light and dark themes. - */ -@Preview(name = "Desktop · Light", group = "Base UI devices", device = Devices.DESKTOP) -@Preview( - name = "Desktop · Dark", - group = "Base UI devices", - device = Devices.DESKTOP, - uiMode = UI_MODE_NIGHT_YES, -) -internal annotation class BaseUIDesktopPreview diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt deleted file mode 100644 index daccca41..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ /dev/null @@ -1,908 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -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.fillMaxSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -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.graphics.Shape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowSquareUp -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxShare -import dev.krtirtho.spotube.resources.iconsax.User - -private val BadgeShape = RoundedCornerShape(11.dp) -private const val DisabledContentAlpha = 0.38f - -private val ButtonMinHeight = 40.dp -private val SquareButtonSize = 40.dp - -private data class ResolvedButtonState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, - val padding: PaddingValues, -) - -@Composable -private fun resolveButtonState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedButtonState { - return when { - isPressed -> ResolvedButtonState( - style.colors.pressed, - style.shape.pressed, - style.shadow.pressed, - style.border.pressed, - style.padding.pressed - ) - - isHovered -> ResolvedButtonState( - style.colors.hovered, - style.shape.hovered, - style.shadow.hovered, - style.border.hovered, - style.padding.hovered - ) - - else -> ResolvedButtonState( - style.colors.normal, - style.shape.normal, - style.shadow.normal, - style.border.normal, - style.padding.normal - ) - } -} - -private fun Modifier.applyShadow( - shadow: BaseUITheme.Shadow, - shape: Shape, -): Modifier { - return if (shadow.elevation > 0.dp) { - this.shadow( - elevation = shadow.elevation, - shape = shape, - ambientColor = shadow.ambientColor, - spotColor = shadow.spotColor, - ) - } else { - this - } -} - -/** - * Overlays the ambient [SurfaceStyle] material for a colour's [BaseUITheme.ButtonColors.surfaceRole], - * so an actionable item looks exactly like the matching [BaseSurface] role. - */ -@Composable -internal fun Modifier.applySurfaceMaterial(colors: BaseUITheme.ButtonColors): Modifier = - applySurfaceMaterial(colors.surfaceRole) - -@Composable -internal fun Modifier.applySurfaceMaterial(role: SurfaceRole?): Modifier { - val scheme = LocalBaseUIColors.current - return role?.let { baseSurfaceTexture(scheme.surfaceTheme, it, scheme.isLight) } ?: this -} - -/** - * The generic button gloss. Textured items get their sheen from the surface material, so it - * is only applied to colours without a [BaseUITheme.ButtonColors.surfaceRole]. - */ -internal fun Modifier.applyHighlight(colors: BaseUITheme.ButtonColors): Modifier = - if (colors.surfaceRole == null) highlight(colors.highlight) else this - -@Composable -fun OutlineButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - hoverOnly: Boolean = false, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .graphicsLayer { translationY = lift.toPx() } - .then( - if (hoverOnly && !isHovered) Modifier else Modifier.applyShadow( - state.shadow, - state.shape - ) - ) - .clip(state.shape) - .then( - if (hoverOnly && !isHovered) Modifier else Modifier.background( - state.colors.background, - state.shape - ) - .applySurfaceMaterial(state.colors) - .border(BorderStroke(state.border.width, state.border.color), state.shape) - ) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .then(if (hoverOnly && !isHovered) Modifier else Modifier.applyHighlight(state.colors)) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - Row( - modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = { - CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { - content() - } - }, - ) - } -} - -@Composable -fun PrimaryButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.primary - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .graphicsLayer { translationY = lift.toPx() } - .applyShadow(state.shadow, state.shape) - .clip(state.shape) - .background(state.colors.background, state.shape) - .applySurfaceMaterial(state.colors) - .border(BorderStroke(state.border.width, state.border.color), state.shape) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .applyHighlight(state.colors) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { - Row( - modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = content, - ) - } - } -} - -@Composable -fun SecondaryButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.secondary - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .graphicsLayer { translationY = lift.toPx() } - .applyShadow(state.shadow, state.shape) - .clip(state.shape) - .background(state.colors.background, state.shape) - .applySurfaceMaterial(state.colors) - .border(BorderStroke(state.border.width, state.border.color), state.shape) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .applyHighlight(state.colors) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { - Row( - modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = content, - ) - } - } -} - -@Composable -fun IconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.iconButtons.outline - OutlineButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = style, - ) { - content() - } -} - -@Composable -fun GhostButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.ghost - OutlineButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - hoverOnly = true, - theme = style, - content = content, - ) -} - -@Composable -fun GhostIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.iconButtons.ghost - OutlineButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - hoverOnly = true, - theme = style, - ) { - content() - } -} - -@Composable -fun PrimaryIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.iconButtons.primary - PrimaryButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = style, - ) { - content() - } -} - -@Composable -fun SecondaryIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.iconButtons.secondary - SecondaryButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = style, - ) { - content() - } -} - -@Composable -fun ButtonBadge( - count: Int, - modifier: Modifier = Modifier, - theme: BaseUITheme.ButtonStyle? = null, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val state = resolveButtonState(style, isPressed = false, isHovered = false) - Box( - modifier = modifier - .heightIn(min = 22.dp) - .defaultMinSize(minWidth = 22.dp) - .background(state.colors.background, BadgeShape) - .border(1.dp, state.border.color, BadgeShape) - .padding(horizontal = 7.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = count.toString(), - fontSize = 12.sp, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - maxLines = 1, - softWrap = false, - color = state.colors.foreground.copy(alpha = 0.7f), - ) - } -} - -@Composable -fun GroupButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val overlay = when { - isPressed -> state.colors.background - isHovered -> state.colors.background - else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)) - } - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .graphicsLayer { translationY = lift.toPx() } - .background(overlay) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - content = content, - ) - } -} - -@Composable -fun GroupIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveButtonState(style, isPressed, isHovered) - val overlay = when { - isPressed -> state.colors.background - isHovered -> state.colors.background - else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent)) - } - val lift = if (isHovered && !isPressed) (-1).dp else 0.dp - - Box( - modifier = modifier - .size(SquareButtonSize) - .graphicsLayer { translationY = lift.toPx() } - .background(overlay) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .fillMaxSize() - .alpha(if (enabled) 1f else DisabledContentAlpha), - contentAlignment = Alignment.Center, - ) { - content() - } - } -} - -@Composable -fun ButtonGroup( - modifier: Modifier = Modifier, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val state = resolveButtonState(style, isPressed = false, isHovered = false) - val groupShape = state.shape - Surface( - modifier = modifier - .defaultMinSize(minHeight = ButtonMinHeight) - .applyShadow(state.shadow, groupShape), - shape = groupShape, - color = Color.Transparent, - border = BorderStroke(state.border.width, state.border.color), - ) { - Box( - modifier = Modifier.background(state.colors.background, groupShape), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - content() - } - } - } -} - -@Composable -fun ButtonGroupDivider( - theme: BaseUITheme.ButtonStyle? = null, -) { - val baseTheme = LocalBaseUITheme.current - val style = theme ?: baseTheme.buttons.outline - val state = resolveButtonState(style, isPressed = false, isHovered = false) - Box( - modifier = Modifier - .width(1.dp) - .heightIn(min = 20.dp) - .background(state.border.color), - ) -} - -@BaseUIPhonePreview -@BaseUIDesktopPreview -@Composable -private fun ButtonsPreview() { - BaseUIPreview { - var liked by remember { mutableStateOf(false) } - var likeCount by remember { mutableIntStateOf(128) } - var following by remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlineButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxShare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Share", maxLines = 1, softWrap = false) - } - SecondaryButton(onClick = {}) { - Text("Secondary", maxLines = 1, softWrap = false) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - PrimaryButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text( - "Sign up", - maxLines = 1, - softWrap = false, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - ) - } - GhostButton(onClick = {}) { - Text("Ghost", maxLines = 1, softWrap = false) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlineButton(onClick = {}, enabled = false) { - Text("Disabled", maxLines = 1, softWrap = false) - } - SecondaryButton(onClick = {}, enabled = false) { - Text("Disabled", maxLines = 1, softWrap = false) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - PrimaryButton(onClick = {}, enabled = false) { - Text("Disabled", maxLines = 1, softWrap = false) - } - OutlineButton(onClick = {}) { - Text("Forward", maxLines = 1, softWrap = false) - Icon( - imageVector = Iconsax.IconsaxArrowSquareUp, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlineButton( - onClick = { - liked = !liked - likeCount += if (liked) 1 else -1 - }, - ) { - Icon( - imageVector = Iconsax.IconsaxHeart, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text(if (liked) "Liked" else "Like", maxLines = 1, softWrap = false) - ButtonBadge(count = likeCount) - } - PrimaryButton(onClick = { following = !following }) { - Text( - if (following) "Following" else "Follow", - maxLines = 1, - softWrap = false, - fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, - ) - } - } - } - } -} - -@BaseUIPhonePreview -@Composable -private fun IconButtonsPreview() { - BaseUIPreview { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = "Icon buttons", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - IconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxMagic, - contentDescription = "Magic", - modifier = Modifier.size(18.dp), - ) - } - GhostIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxHeart, - contentDescription = "Favorite", - modifier = Modifier.size(18.dp), - ) - } - PrimaryIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add", - modifier = Modifier.size(18.dp), - ) - } - SecondaryIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxShare, - contentDescription = "Share", - modifier = Modifier.size(18.dp), - ) - } - } - - Text( - text = "Variable icon button", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - VariableIconButton( - variant = VariableIconButtonVariant.Outline, - onClick = {}, - ) { - Icon( - imageVector = Iconsax.IconsaxDocumentText, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - VariableIconButton( - variant = VariableIconButtonVariant.Ghost, - onClick = {}, - ) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - VariableIconButton( - variant = VariableIconButtonVariant.Primary, - onClick = {}, - ) { - Icon( - imageVector = Iconsax.IconsaxMagic, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - VariableIconButton( - variant = VariableIconButtonVariant.Secondary, - onClick = {}, - ) { - Icon( - imageVector = Iconsax.User, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - } - } -} - -@BaseUIDesktopPreview -@Composable -private fun ButtonGroupPreview() { - BaseUIPreview { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - Text( - text = "Button groups", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - ButtonGroup { - GroupButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxDocumentText, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Documents", maxLines = 1, softWrap = false) - } - ButtonGroupDivider() - GroupButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxShare, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Text("Export", maxLines = 1, softWrap = false) - } - ButtonGroupDivider() - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - ButtonGroup { - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.ArrowLeft3, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - ButtonGroupDivider() - GroupIconButton(onClick = {}) { - Icon( - imageVector = Iconsax.IconsaxNext, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - ButtonBadge(count = 8) - } - } - } -} - -enum class VariableIconButtonVariant { - Outline, - Ghost, - Primary, - Secondary, -} - -@Composable -fun VariableIconButton( - variant: VariableIconButtonVariant = VariableIconButtonVariant.Outline, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ButtonStyle? = null, - content: @Composable () -> Unit, -) { - when (variant) { - VariableIconButtonVariant.Outline -> IconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - content = content, - ) - - VariableIconButtonVariant.Ghost -> GhostIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - content = content, - ) - - VariableIconButtonVariant.Primary -> PrimaryIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - content = content, - ) - - VariableIconButtonVariant.Secondary -> SecondaryIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - content = content, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt deleted file mode 100644 index 91802df8..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Card.kt +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay - -@Composable -fun Card( - modifier: Modifier = Modifier, - theme: BaseUITheme.CardTheme? = null, - content: @Composable () -> Unit, -) { - val cardTheme = theme ?: LocalBaseUITheme.current.card - - Box( - modifier = modifier - .then( - if (cardTheme.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = cardTheme.shadow.elevation, - shape = cardTheme.shape, - ambientColor = cardTheme.shadow.ambientColor, - spotColor = cardTheme.shadow.spotColor, - ) - } else Modifier - ) - .clip(cardTheme.shape) - .background(cardTheme.background, cardTheme.shape) - .applySurfaceMaterial(cardTheme.surfaceRole) - .border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape) - .padding(cardTheme.padding), - ) { - content() - } -} - -@BaseUIPhonePreview -@Composable -private fun CardPreview() { - BaseUIPreview { - var connected by remember { mutableStateOf(false) } - var playing by remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Card { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = if (connected) "Spotify connected" else "Connect a music source", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Spacer(Modifier.height(6.dp)) - Text( - text = if (connected) { - "Streaming, downloads and scrobbling are ready." - } else { - "Sign in to stream, download and scrobble from your account." - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - OutlineButton(onClick = { connected = false }) { - Text("Disconnect") - } - PrimaryButton(onClick = { connected = !connected }) { - Text(if (connected) "Connected" else "Connect") - } - } - } - } - - Card { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(44.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Iconsax.IconsaxMusic, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - Spacer(Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Midnight City", - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - ) - Text( - text = "M83 · Hurry Up, We're Dreaming", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - PrimaryIconButton(onClick = { playing = !playing }) { - Icon( - imageVector = if (playing) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playing) "Pause" else "Play", - modifier = Modifier.size(18.dp), - ) - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt deleted file mode 100644 index b037e565..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/CheckBox.kt +++ /dev/null @@ -1,281 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -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.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp - -enum class CheckBoxState { - SELECTED, - UNSELECTED, - INDETERMINATE, - @Deprecated("Use INDETERMINATE", ReplaceWith("CheckBoxState.INDETERMINATE")) - CLEAR; -} - -private val CheckBoxSize = 22.dp - -private data class ResolvedCheckBoxState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, -) - -@Composable -private fun resolveCheckBoxState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedCheckBoxState { - return when { - isPressed -> ResolvedCheckBoxState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed) - isHovered -> ResolvedCheckBoxState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered) - else -> ResolvedCheckBoxState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal) - } -} - -@Composable -fun CheckBox( - state: CheckBoxState = CheckBoxState.UNSELECTED, - onClick: (() -> Unit)?, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource? = null, - theme: BaseUITheme.CheckBoxTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.checkBox - val checkBoxTheme = theme ?: baseTheme - val source = interactionSource ?: remember { MutableInteractionSource() } - val isPressed by source.collectIsPressedAsState() - val isHovered by source.collectIsHoveredAsState() - - val isSelected = state == CheckBoxState.SELECTED - val isIndeterminate = state == CheckBoxState.INDETERMINATE - val isFilled = isSelected || isIndeterminate - - val style = if (isFilled) checkBoxTheme.selected else checkBoxTheme.unselected - val resolved = resolveCheckBoxState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp - - val borderColor = when { - !enabled -> resolved.colors.foreground.copy(alpha = 0.38f) - else -> resolved.border.color - } - - val checkProgress by animateFloatAsState( - targetValue = if (isSelected) 1f else 0f, - animationSpec = tween(durationMillis = 180), - label = "checkProgress", - ) - val dashProgress by animateFloatAsState( - targetValue = if (isIndeterminate) 1f else 0f, - animationSpec = tween(durationMillis = 150), - label = "dashProgress", - ) - - Box( - modifier = modifier - .size(CheckBoxSize) - .graphicsLayer { translationY = lift.toPx() } - .then( - if (resolved.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = resolved.shadow.elevation, - shape = resolved.shape, - ambientColor = resolved.shadow.ambientColor, - spotColor = resolved.shadow.spotColor, - ) - } else Modifier - ) - .clip(resolved.shape) - .background(resolved.colors.background, resolved.shape) - .applySurfaceMaterial(resolved.colors) - .border(BorderStroke(0.5.dp, borderColor), resolved.shape) - .applyHighlight(resolved.colors) - .then( - if (onClick != null) { - Modifier - .hoverable(interactionSource = source, enabled = enabled) - .clickable( - interactionSource = source, - indication = ripple(), - enabled = enabled, - onClick = onClick, - ) - } else { - Modifier - } - ), - contentAlignment = Alignment.Center, - ) { - if (isSelected) { - Box( - modifier = Modifier - .size(14.dp) - .graphicsLayer { alpha = checkProgress }, - ) { - androidx.compose.foundation.Canvas(modifier = Modifier.size(14.dp)) { - val stroke = 2.dp.toPx() - val path = Path().apply { - val w = size.width - val h = size.height - moveTo(w * 0.2f, h * 0.52f) - lineTo(w * 0.42f, h * 0.74f) - lineTo(w * 0.82f, h * 0.28f) - } - drawPath( - path = path, - color = checkBoxTheme.checkmarkColor, - style = Stroke( - width = stroke, - cap = StrokeCap.Round, - join = StrokeJoin.Round, - ), - ) - } - } - } else if (isIndeterminate) { - Box( - modifier = Modifier - .size(width = 10.dp, height = 2.dp) - .graphicsLayer { alpha = dashProgress } - .clip(RoundedCornerShape(1.dp)) - .background( - brush = Brush.horizontalGradient( - colors = listOf( - checkBoxTheme.checkmarkColor, - checkBoxTheme.checkmarkColor.copy(alpha = 0.92f), - ) - ), - ), - ) - } - } -} - -@BaseUIPhonePreview -@Composable -private fun CheckBoxPreview() { - BaseUIPreview { - var wifi by remember { mutableStateOf(true) } - var bluetooth by remember { mutableStateOf(false) } - var downloads by remember { mutableStateOf(true) } - - val allEnabled = wifi && bluetooth && downloads - val anyEnabled = wifi || bluetooth || downloads - val allState = when { - allEnabled -> CheckBoxState.SELECTED - anyEnabled -> CheckBoxState.INDETERMINATE - else -> CheckBoxState.UNSELECTED - } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - CheckBoxPreviewRow( - state = allState, - label = "All playback sources", - onClick = { - val target = !allEnabled - wifi = target - bluetooth = target - downloads = target - }, - ) - Spacer(Modifier.height(4.dp)) - CheckBoxPreviewRow( - state = if (wifi) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, - label = "Wi-Fi", - onClick = { wifi = !wifi }, - ) - CheckBoxPreviewRow( - state = if (bluetooth) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, - label = "Bluetooth", - onClick = { bluetooth = !bluetooth }, - ) - CheckBoxPreviewRow( - state = if (downloads) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, - label = "Download over cellular", - onClick = { downloads = !downloads }, - ) - CheckBoxPreviewRow(CheckBoxState.SELECTED, "Disabled selected", enabled = false) - CheckBoxPreviewRow(CheckBoxState.UNSELECTED, "Disabled unselected", enabled = false) - } - } -} - -@Composable -private fun CheckBoxPreviewRow( - state: CheckBoxState, - label: String, - enabled: Boolean = true, - onClick: () -> Unit = {}, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CheckBox(state = state, onClick = onClick, enabled = enabled) - Text(label, style = MaterialTheme.typography.bodyMedium) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt deleted file mode 100644 index 98a34762..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ChipTabs.kt +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -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.RowScope -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary - -private val ChipTabMinHeight = 36.dp - -private data class ResolvedChipState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, - val padding: androidx.compose.foundation.layout.PaddingValues, -) - -@Composable -private fun resolveChipState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedChipState { - return when { - isPressed -> ResolvedChipState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed, style.padding.pressed) - isHovered -> ResolvedChipState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered, style.padding.hovered) - else -> ResolvedChipState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal, style.padding.normal) - } -} - -private fun Modifier.applyChipShadow( - shadow: BaseUITheme.Shadow, - shape: Shape, -): Modifier { - return if (shadow.elevation > 0.dp) { - this.shadow( - elevation = shadow.elevation, - shape = shape, - ambientColor = shadow.ambientColor, - spotColor = shadow.spotColor, - ) - } else { - this - } -} - -@Composable -fun ChipTab( - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.ChipTabTheme? = null, - content: @Composable RowScope.() -> Unit, -) { - val baseTheme = LocalBaseUITheme.current.chipTab - val chipTheme = theme ?: baseTheme - val style = if (selected) chipTheme.selected else chipTheme.unselected - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val state = resolveChipState(style, isPressed, isHovered) - - Box( - modifier = modifier - .defaultMinSize(minHeight = ChipTabMinHeight) - .hoverable(interactionSource = interactionSource, enabled = enabled) - .applyChipShadow(state.shadow, state.shape) - .clip(state.shape) - .background(state.colors.background, state.shape) - .applySurfaceMaterial(state.colors) - .border(BorderStroke(state.border.width, state.border.color), state.shape) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = ripple(), - onClick = onClick, - ) - .applyHighlight(state.colors) - .padding(state.padding), - contentAlignment = Alignment.Center, - ) { - CompositionLocalProvider( - LocalContentColor provides state.colors.foreground, - LocalTextStyle provides LocalTextStyle.current.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, - ) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - content = content, - ) - } - } -} - -@Composable -fun ChipTab( - text: String, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - leadingIcon: @Composable (() -> Unit)? = null, - theme: BaseUITheme.ChipTabTheme? = null, -) { - ChipTab( - selected = selected, - onClick = onClick, - modifier = modifier, - enabled = enabled, - theme = theme, - ) { - if (leadingIcon != null) { - leadingIcon() - } - Text( - text = text, - maxLines = 1, - softWrap = false, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - ) - } -} - -@BaseUIPhonePreview -@Composable -private fun ChipTabsPreview() { - BaseUIPreview { - val tabs = listOf("All", "Songs", "Albums", "Playlists") - var selected by remember { mutableStateOf(0) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - tabs.forEachIndexed { index, tab -> - ChipTab( - text = tab, - selected = selected == index, - onClick = { selected = index }, - ) - } - } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ChipTab( - text = "Library", - selected = true, - onClick = {}, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxMusicLibrary, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }, - ) - ChipTab( - text = "Downloads", - selected = false, - onClick = {}, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxDocumentDownload, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - }, - ) - ChipTab( - text = "Disabled", - selected = false, - onClick = {}, - enabled = false, - ) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt deleted file mode 100644 index b7cd5613..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Dialog.kt +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties - -private val DefaultDialogMaxWidth: Dp = 440.dp - -@Composable -fun ThemedDialog( - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - title: @Composable (() -> Unit)? = null, - actions: @Composable (() -> Unit)? = null, - theme: BaseUITheme.DialogTheme? = null, - properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), - content: @Composable () -> Unit, -) { - Dialog( - onDismissRequest = onDismissRequest, - properties = properties, - ) { - AnimatedVisibility( - visible = true, - enter = scaleIn( - initialScale = 0.92f, - animationSpec = tween(280), - ) + fadeIn(animationSpec = tween(200)), - exit = scaleOut( - targetScale = 0.92f, - animationSpec = tween(180), - ) + fadeOut(animationSpec = tween(150)), - ) { - Box( - modifier = modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - ThemedDialogSurface( - theme = theme, - title = title, - actions = actions, - content = content, - ) - } - } - } -} - -/** - * The visual chrome of [ThemedDialog] without the platform [Dialog] window. - * - * Extracted so the dialog can be rendered inside previews (and reused elsewhere), - * since the platform dialog lives in a separate window the preview canvas cannot show. - */ -@Composable -internal fun ThemedDialogSurface( - modifier: Modifier = Modifier, - theme: BaseUITheme.DialogTheme? = null, - title: @Composable (() -> Unit)? = null, - actions: @Composable (() -> Unit)? = null, - content: @Composable () -> Unit, -) { - val dialogTheme = theme ?: LocalBaseUITheme.current.dialog - - Box( - modifier = modifier - .widthIn(max = DefaultDialogMaxWidth) - .shadow( - elevation = dialogTheme.shadow.elevation, - shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) }, - ambientColor = dialogTheme.shadow.ambientColor, - spotColor = dialogTheme.shadow.spotColor, - ) - .clip(dialogTheme.shape) - .background(dialogTheme.background, dialogTheme.shape) - .border( - BorderStroke(dialogTheme.border.width, dialogTheme.border.color), - dialogTheme.shape - ) - .highlight(Color.White.copy(alpha = 0.08f)), - ) { - Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) { - if (title != null) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 24.dp, top = 24.dp), - ) { - title() - } - Spacer(Modifier.height(8.dp)) - } - - Column( - modifier = Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()) - .padding( - start = 24.dp, - end = 24.dp, - top = if (title == null) 24.dp else 0.dp, - ), - ) { - content() - } - - if (actions != null) { - Spacer(Modifier.height(16.dp)) - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { - actions() - } - } - } else { - Spacer(Modifier.height(24.dp)) - } - } - } -} - -@BaseUIPhonePreview -@Composable -private fun ThemedDialogPreview() { - BaseUIPreview { - var visible by remember { mutableStateOf(true) } - - Box(modifier = Modifier.fillMaxSize()) { - PrimaryButton( - onClick = { visible = true }, - modifier = Modifier - .align(Alignment.TopStart) - .padding(24.dp), - ) { - Text("Open dialog") - } - - if (visible) { - Box( - modifier = Modifier - .fillMaxSize() - .background(LocalBaseUITheme.current.dialog.scrim) - .clickable { visible = false }, - contentAlignment = Alignment.Center, - ) { - ThemedDialogSurface( - modifier = Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = {}, - ), - title = { - Text( - text = "Sign out of Spotube?", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - }, - actions = { - OutlineButton(onClick = { visible = false }) { Text("Cancel") } - PrimaryButton(onClick = { visible = false }) { Text("Sign out") } - }, - ) { - Text( - text = "You can sign back in at any time. Your downloaded tracks stay on this device.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt deleted file mode 100644 index 20f9af7f..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/DropdownMenu.kt +++ /dev/null @@ -1,277 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxShare -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash - -@Composable -fun DropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - offset: DpOffset = DpOffset(0.dp, 4.dp), - theme: BaseUITheme.DropdownMenuTheme? = null, - content: @Composable ColumnScope.() -> Unit, -) { - val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu - - androidx.compose.material3.DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - offset = offset, - modifier = modifier.dropdownMenuContainer(menuTheme), - ) { - content() - } -} - -/** - * The container styling of [DropdownMenu], split out so it can be reused by the - * material3 popup and by previews, which cannot render the popup window itself. - */ -internal fun Modifier.dropdownMenuContainer(theme: BaseUITheme.DropdownMenuTheme): Modifier = - this - .widthIn(min = 180.dp) - .shadow( - elevation = theme.shadow.elevation, - shape = theme.shape, - ambientColor = theme.shadow.ambientColor, - spotColor = theme.shadow.spotColor, - ) - .clip(theme.shape) - .background(theme.background, theme.shape) - .border( - BorderStroke(theme.border.width, theme.border.color), - theme.shape, - ) - -@Composable -fun DropdownMenuItem( - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - leadingIcon: ImageVector? = null, - leadingContent: (@Composable () -> Unit)? = null, - subtitle: String? = null, - selected: Boolean = false, - theme: BaseUITheme.DropdownMenuTheme? = null, -) { - val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu - val interactionSource = remember { MutableInteractionSource() } - val isHovered by interactionSource.collectIsHoveredAsState() - - Row( - modifier = modifier - .fillMaxWidth() - .clip(menuTheme.shape) - .hoverable(interactionSource) - .then( - if (isHovered && enabled) { - Modifier.background(menuTheme.itemHoverBackground) - } else { - Modifier - } - ) - .then( - if (isHovered && enabled) { - Modifier.highlight(menuTheme.itemHighlight) - } else { - Modifier - } - ) - .clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ) - .padding(menuTheme.itemPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - when { - leadingContent != null -> leadingContent() - leadingIcon != null -> Icon( - imageVector = leadingIcon, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (enabled) { - menuTheme.itemForeground - } else { - menuTheme.itemForeground.copy(alpha = 0.38f) - }, - ) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = if (enabled) { - menuTheme.itemForeground - } else { - menuTheme.itemForeground.copy(alpha = 0.38f) - }, - maxLines = 1, - softWrap = false, - ) - if (subtitle != null) { - Text( - text = subtitle, - style = MaterialTheme.typography.labelSmall, - color = menuTheme.itemForeground.copy(alpha = if (enabled) 0.6f else 0.38f), - maxLines = 1, - softWrap = false, - ) - } - } - } -} - -@Composable -fun DropdownMenuDivider( - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(0.5.dp) - .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) - } -} - -@BaseUIPhonePreview -@Composable -private fun DropdownMenuPreview() { - BaseUIPreview { - var expanded by remember { mutableStateOf(true) } - var lastAction by remember { mutableStateOf("None") } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - OutlineButton(onClick = { expanded = !expanded }) { - Text(if (expanded) "Hide menu" else "Show menu") - } - - if (expanded) { - Column( - modifier = Modifier.dropdownMenuContainer( - LocalBaseUITheme.current.dropdownMenu, - ), - ) { - DropdownMenuItem( - text = "Play next", - leadingIcon = Iconsax.IconsaxPlay, - onClick = { - lastAction = "Play next" - expanded = false - }, - ) - DropdownMenuItem( - text = "Add to queue", - leadingIcon = Iconsax.IconsaxAddSquare, - onClick = { - lastAction = "Add to queue" - expanded = false - }, - ) - DropdownMenuItem( - text = "Add to favorites", - leadingIcon = Iconsax.IconsaxHeart, - selected = lastAction == "Add to favorites", - onClick = { - lastAction = "Add to favorites" - expanded = false - }, - ) - DropdownMenuDivider() - DropdownMenuItem( - text = "Share", - leadingIcon = Iconsax.IconsaxShare, - onClick = { - lastAction = "Share" - expanded = false - }, - ) - DropdownMenuItem( - text = "Remove from playlist", - leadingIcon = Iconsax.IconsaxTrash, - enabled = false, - onClick = {}, - ) - } - } - - Text( - text = "Last action: $lastAction", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt deleted file mode 100644 index 6111f2a4..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/ListRowTile.kt +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -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.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic - -@Composable -fun ListRowTile( - selected: Boolean = false, - enabled: Boolean = true, - onClick: (() -> Unit)? = null, - modifier: Modifier = Modifier, - leading: @Composable (() -> Unit)? = null, - title: @Composable (() -> Unit)? = null, - subtitle: @Composable (() -> Unit)? = null, - trailing: @Composable (() -> Unit)? = null, -) { - val rowTheme = LocalBaseUITheme.current.listRowTile - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - val isFocused by interactionSource.collectIsFocusedAsState() - - val background = when { - isPressed -> rowTheme.background.pressed - isHovered -> rowTheme.background.hovered - isFocused -> rowTheme.background.focused - selected -> rowTheme.background.selected - else -> rowTheme.background.normal - } - val shape = when { - isPressed -> rowTheme.shape.pressed - isHovered -> rowTheme.shape.hovered - isFocused -> rowTheme.shape.focused - selected -> rowTheme.shape.selected - else -> rowTheme.shape.normal - } - val borderDef = when { - isPressed -> rowTheme.border.pressed - isHovered -> rowTheme.border.hovered - isFocused -> rowTheme.border.focused - selected -> rowTheme.border.selected - else -> rowTheme.border.normal - } - val shadowDef = when { - isPressed -> rowTheme.shadow.pressed - isHovered -> rowTheme.shadow.hovered - isFocused -> rowTheme.shadow.focused - selected -> rowTheme.shadow.selected - else -> rowTheme.shadow.normal - } - - val mod = modifier - .fillMaxWidth() - .shadow( - shadowDef.elevation, - shape, - shadowDef.clip, - shadowDef.ambientColor, - shadowDef.spotColor - ) - .clip(shape) - .border(borderDef.width, borderDef.color, shape) - .background(background, shape) - .then( - if (onClick != null) { - Modifier.clickable( - enabled = enabled, - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ) - } else Modifier - ) - .padding(rowTheme.padding) - - Row( - modifier = mod, - verticalAlignment = Alignment.CenterVertically, - ) { - if (leading != null) { - leading() - Spacer(Modifier.width(12.dp)) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, - ) { - if (title != null) { - title() - } - if (subtitle != null) { - Spacer(Modifier.width(2.dp)) - subtitle() - } - } - if (trailing != null) { - Spacer(Modifier.width(12.dp)) - trailing() - } - } -} - -private data class ListRowPreviewTrack( - val title: String, - val subtitle: String, - val duration: String, -) - -private val ListRowPreviewTracks = listOf( - ListRowPreviewTrack("Midnight City", "M83 · Hurry Up, We're Dreaming", "4:03"), - ListRowPreviewTrack("Odoriko", "Vaundy · strobo", "3:37"), - ListRowPreviewTrack("Instant Crush", "Daft Punk · Random Access Memories", "5:37"), -) - -@BaseUIPhonePreview -@Composable -private fun ListRowTilePreview() { - BaseUIPreview { - var selectedIndex by remember { mutableStateOf(0) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - ListRowPreviewTracks.forEachIndexed { index, track -> - ListRowTile( - selected = selectedIndex == index, - onClick = { selectedIndex = index }, - leading = { ListRowTilePreviewArtwork() }, - title = { - Text( - text = track.title, - style = MaterialTheme.typography.bodyMedium, - ) - }, - subtitle = { - Text( - text = track.subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - trailing = { - Text( - text = track.duration, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) - } - - ListRowTile( - enabled = false, - title = { - Text( - text = "Add to queue", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), - ) - }, - subtitle = { - Text( - text = "Unavailable offline", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), - ) - }, - ) - } - } -} - -@Composable -private fun ListRowTilePreviewArtwork() { - Box( - modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Iconsax.IconsaxMusic, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt deleted file mode 100644 index 0a159766..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Radio.kt +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -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.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp - -private val RadioSize = 22.dp -private val RadioDotSize = 10.dp - -private data class ResolvedRadioState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, -) - -@Composable -private fun resolveRadioState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedRadioState { - return when { - isPressed -> ResolvedRadioState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed) - isHovered -> ResolvedRadioState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered) - else -> ResolvedRadioState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal) - } -} - -@Composable -fun Radio( - selected: Boolean, - onClick: (() -> Unit)?, - modifier: Modifier = Modifier, - enabled: Boolean = true, - theme: BaseUITheme.CheckBoxTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.checkBox - val radioTheme = theme ?: baseTheme - val source = remember { MutableInteractionSource() } - val isPressed by source.collectIsPressedAsState() - val isHovered by source.collectIsHoveredAsState() - - val style = if (selected) radioTheme.selected else radioTheme.unselected - val resolved = resolveRadioState(style, isPressed, isHovered) - val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp - - val dotProgress by animateFloatAsState( - targetValue = if (selected) 1f else 0f, - animationSpec = tween(durationMillis = 180), - label = "radioDot", - ) - - Box( - modifier = modifier - .size(RadioSize) - .graphicsLayer { - translationY = lift.toPx() - shape = CircleShape - clip = true - } - .then( - if (resolved.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = resolved.shadow.elevation, - shape = CircleShape, - ambientColor = resolved.shadow.ambientColor, - spotColor = resolved.shadow.spotColor, - ) - } else Modifier - ) - .clip(CircleShape) - .background(resolved.colors.background, CircleShape) - .border(BorderStroke(0.5.dp, resolved.border.color), CircleShape) - .highlight(resolved.colors.highlight) - .then( - if (onClick != null) { - Modifier - .hoverable(interactionSource = source, enabled = enabled) - .clickable( - interactionSource = source, - indication = ripple(bounded = true, radius = RadioSize / 2), - enabled = enabled, - onClick = onClick, - ) - } else { - Modifier - } - ), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .size(RadioDotSize * dotProgress) - .clip(CircleShape) - .background(radioTheme.checkmarkColor, CircleShape), - ) - } -} - -@BaseUIPhonePreview -@Composable -private fun RadioPreview() { - BaseUIPreview { - val options = listOf("Automatic", "High", "Lossless", "Data saver") - var selected by remember { mutableStateOf(0) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - options.forEachIndexed { index, label -> - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Radio( - selected = selected == index, - onClick = { selected = index }, - ) - Text(label, style = MaterialTheme.typography.bodyMedium) - } - } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Radio(selected = true, onClick = {}, enabled = false) - Text("Disabled", style = MaterialTheme.typography.bodyMedium) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt deleted file mode 100644 index 70ffe90f..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Scaffold.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.ScaffoldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp - -/** - * A theme-aware replacement for [Scaffold]. - * - * A scaffold is always the current theme's **Primary Background** — there is no option to - * switch its surface. Material's [Scaffold] would paint Material's own surface colour and - * hide the selected [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass), so [BaseScaffold] - * paints the theme's primary background material instead. - * - * [containerColor] is a narrow escape hatch for the rare screen that paints its own - * backdrop (e.g. onboarding) and needs the scaffold to stay out of the way; it is not a - * surface-style switch. - * - * It keeps the same slot structure as [Scaffold] (top/bottom bars, snackbar host, - * floating action button) and hands the resolved [PaddingValues] to [content]. - */ -@Composable -fun BaseScaffold( - modifier: Modifier = Modifier, - topBar: @Composable () -> Unit = {}, - bottomBar: @Composable () -> Unit = {}, - snackbarHost: @Composable () -> Unit = {}, - floatingActionButton: @Composable () -> Unit = {}, - containerColor: Color? = null, - contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, - content: @Composable (PaddingValues) -> Unit, -) { - Scaffold( - modifier = modifier, - topBar = topBar, - bottomBar = bottomBar, - snackbarHost = snackbarHost, - floatingActionButton = floatingActionButton, - containerColor = Color.Transparent, - contentWindowInsets = contentWindowInsets, - ) { innerPadding -> - val colors = LocalBaseUIColors.current - val backgroundModifier = containerColor?.let { Modifier.background(it) } - ?: Modifier.baseSurfaceBackdrop(colors.surfaceTheme, colors.isLight) - Box(modifier = Modifier.fillMaxSize().then(backgroundModifier)) { - content(innerPadding) - } - } -} - -@BaseUIPhonePreview -@BaseUIDesktopPreview -@Composable -private fun BaseScaffoldPreview() { - BaseUIPreview { - BaseScaffold( - topBar = { - Text( - text = "Title", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) - }, - bottomBar = { - Text( - text = "Bottom bar", - style = MaterialTheme.typography.labelMedium, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("Content") - Text("Sits on the selected surface theme.", style = MaterialTheme.typography.bodySmall) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt deleted file mode 100644 index 2d059068..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Slider.kt +++ /dev/null @@ -1,369 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import kotlin.math.roundToInt - -private val SliderTrackHeight = 7.dp -private val SliderThumbSize = 24.dp -private val SliderMinHeight = 32.dp - -@Composable -fun Slider( - value: Float, - onValueChange: (Float) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - valueRange: ClosedFloatingPointRange = 0f..1f, - steps: Int = 0, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - onValueChangeFinished: () -> Unit = {}, - theme: BaseUITheme.SliderTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.slider - val sliderTheme = theme ?: baseTheme - val isHovered by interactionSource.collectIsHoveredAsState() - val isPressed by interactionSource.collectIsPressedAsState() - val density = LocalDensity.current - var sliderWidth by remember { mutableIntStateOf(0) } - - val range = valueRange.endInclusive - valueRange.start - val fraction = if (range > 0f) ((value - valueRange.start) / range).coerceIn(0f, 1f) else 0f - - val stateColor = when { - isPressed -> sliderTheme.trackActiveColor.pressed - isHovered -> sliderTheme.trackActiveColor.hovered - else -> sliderTheme.trackActiveColor.normal - } - val inactiveColor = when { - isPressed -> sliderTheme.trackInactiveColor.pressed - isHovered -> sliderTheme.trackInactiveColor.hovered - else -> sliderTheme.trackInactiveColor.normal - } - val thumbColor = when { - isPressed -> sliderTheme.thumbColor.pressed - isHovered -> sliderTheme.thumbColor.hovered - else -> sliderTheme.thumbColor.normal - } - val thumbShadowState = when { - isPressed -> sliderTheme.thumbShadow.pressed - isHovered -> sliderTheme.thumbShadow.hovered - else -> sliderTheme.thumbShadow.normal - } - - val trackActiveColor = if (enabled) stateColor else stateColor.copy(alpha = 0.38f) - val trackInactiveColor = if (enabled) inactiveColor else inactiveColor.copy(alpha = 0.38f) - val resolvedThumbColor = if (enabled) thumbColor else thumbColor.copy(alpha = 0.38f) - - val thumbScale by animateFloatAsState( - targetValue = when { - isPressed -> 1.15f - isHovered -> 1.08f - else -> 1f - }, - animationSpec = tween(durationMillis = 150), - label = "thumbScale", - ) - - Box( - modifier = modifier - .fillMaxWidth() - .heightIn(min = SliderMinHeight) - .onSizeChanged { sliderWidth = it.width } - .hoverable(interactionSource = interactionSource, enabled = enabled) - .pointerInput(enabled, valueRange, steps) { - if (!enabled) return@pointerInput - - val stepSize = if (steps > 0) range / (steps + 1) else 0f - val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f } - - fun valueFromX(x: Float): Float { - val width = size.width.toFloat() - val trackWidth = width - 2 * thumbHalfPx - if (trackWidth <= 0f) return valueRange.start - val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start - val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive) - return if (steps > 0) { - val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt() - (valueRange.start + stepIndex * stepSize).coerceIn( - valueRange.start, - valueRange.endInclusive, - ) - } else { - coerced - } - } - - detectTapGestures { offset -> - onValueChange(valueFromX(offset.x)) - onValueChangeFinished() - } - } - .pointerInput(enabled, valueRange, steps) { - if (!enabled) return@pointerInput - - val stepSize = if (steps > 0) range / (steps + 1) else 0f - val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f } - - fun valueFromX(x: Float): Float { - val width = size.width.toFloat() - val trackWidth = width - 2 * thumbHalfPx - if (trackWidth <= 0f) return valueRange.start - val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start - val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive) - return if (steps > 0) { - val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt() - (valueRange.start + stepIndex * stepSize).coerceIn( - valueRange.start, - valueRange.endInclusive, - ) - } else { - coerced - } - } - - detectHorizontalDragGestures( - onDragEnd = { onValueChangeFinished() }, - ) { change, _ -> - change.consume() - onValueChange(valueFromX(change.position.x)) - } - }, - contentAlignment = Alignment.CenterStart, - ) { - Canvas( - modifier = Modifier - .fillMaxWidth() - .height(SliderTrackHeight) - .align(Alignment.CenterStart), - ) { - val thumbHalfPx = SliderThumbSize.toPx() / 2f - val trackY = size.height / 2f - val trackStart = thumbHalfPx - val trackEnd = size.width - thumbHalfPx - val trackWidth = trackEnd - trackStart - - if (trackWidth > 0f) { - val trackRadius = CornerRadius(size.height / 2f) - - drawRoundRect( - brush = Brush.verticalGradient( - colors = listOf( - trackInactiveColor.copy(alpha = 0.45f), - trackInactiveColor, - trackInactiveColor.copy(alpha = 0.7f), - ), - ), - topLeft = Offset(trackStart, 0f), - size = Size(trackWidth, size.height), - cornerRadius = trackRadius, - ) - drawLine( - color = Color.White.copy(alpha = 0.15f), - start = Offset(trackStart + size.height / 2f, 1.dp.toPx()), - end = Offset(trackEnd - size.height / 2f, 1.dp.toPx()), - strokeWidth = 1.dp.toPx(), - cap = StrokeCap.Round, - ) - - val activeEnd = trackStart + trackWidth * fraction - drawRoundRect( - brush = Brush.verticalGradient( - colors = listOf( - trackActiveColor.copy(alpha = 0.75f), - trackActiveColor, - trackActiveColor.copy(alpha = 0.9f), - ), - ), - topLeft = Offset(trackStart, 0f), - size = Size((activeEnd - trackStart).coerceAtLeast(0f), size.height), - cornerRadius = trackRadius, - ) - drawLine( - color = Color.White.copy(alpha = 0.22f), - start = Offset(trackStart + size.height / 2f, 1.dp.toPx()), - end = Offset(activeEnd - size.height / 2f, 1.dp.toPx()), - strokeWidth = 1.dp.toPx(), - cap = StrokeCap.Round, - ) - - if (steps > 0 && enabled) { - val stepSpacing = trackWidth / (steps + 1) - for (i in 1 downTo steps) { - val x = trackStart + stepSpacing * i - drawCircle( - color = if (x <= activeEnd) { - sliderTheme.stepActiveColor - } else { - sliderTheme.stepInactiveColor - }, - radius = 2.dp.toPx(), - center = Offset(x, trackY), - ) - } - } - } - } - - Box( - modifier = Modifier - .size(SliderThumbSize) - .offset { - val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f } - val trackWidth = sliderWidth.toFloat() - 2 * thumbHalfPx - val x = thumbHalfPx + trackWidth * fraction - thumbHalfPx - IntOffset(x.roundToInt(), 0) - } - .graphicsLayer { - scaleX = thumbScale - scaleY = thumbScale - } - .shadow( - elevation = thumbShadowState.elevation, - shape = CircleShape, - ambientColor = thumbShadowState.ambientColor, - spotColor = thumbShadowState.spotColor, - ) - .background( - brush = Brush.radialGradient( - colors = listOf( - Color.White.copy(alpha = 0.6f), - resolvedThumbColor.copy(alpha = 0.95f), - resolvedThumbColor, - ), - center = Offset(0f, -0.55f), - radius = 1.1f, - ), - shape = CircleShape, - ) - .border( - width = 0.8.dp, - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.55f), - Color.White.copy(alpha = 0.08f), - ), - ), - shape = CircleShape, - ), - ) - } -} - -@BaseUIPhonePreview -@Composable -private fun SliderPreview() { - BaseUIPreview { - var volume by remember { mutableFloatStateOf(0.65f) } - var progress by remember { mutableFloatStateOf(0.3f) } - var quality by remember { mutableFloatStateOf(2f) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(28.dp), - ) { - SliderPreviewField(title = "Volume", value = "${(volume * 100).toInt()}%") { - Slider(value = volume, onValueChange = { volume = it }) - } - SliderPreviewField(title = "Progress", value = "${(progress * 100).toInt()}%") { - Slider(value = progress, onValueChange = { progress = it }) - } - SliderPreviewField(title = "Quality", value = "${quality.toInt()} / 4") { - Slider( - value = quality, - onValueChange = { quality = it }, - valueRange = 0f..4f, - steps = 3, - ) - } - SliderPreviewField(title = "Disabled", value = "40%") { - Slider(value = 0.4f, onValueChange = {}, enabled = false) - } - } - } -} - -@Composable -private fun SliderPreviewField( - title: String, - value: String, - content: @Composable () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(title, style = MaterialTheme.typography.bodyMedium) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - content() - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt deleted file mode 100644 index d7c7b366..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Surface.kt +++ /dev/null @@ -1,699 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlin.random.Random -import kotlinx.serialization.Serializable - -/** - * The Frutiger-Aero material families available to [BaseSurface]. - * - * - [Watery]: deep aqua water with caustic light and bubbles. - * - [Icy]: pale frosted ice with crystalline facets and frost. - * - [Chrome]: brushed metal with a hard specular reflection. - * - [Acrylic]: translucent tinted panel with fine grain. - * - [Glass]: clear, glossy glass with a strong sheen. - * - * Each style doubles as a full app theme (see [BaseUIColorScheme]). - */ -@Serializable -enum class SurfaceStyle { - Watery, - Icy, - Chrome, - Acrylic, - Glass, -} - -/** - * The role a surface plays within a theme. Each style produces a distinct material for - * every role, so a theme is composed from them: - * - * - [PrimaryBackground] — the subtle main background (pages, panels). - * - [SecondaryBackground] — a distinct, complementary background hue that pairs with the - * primary (grouped/alternate sections). - * - [PrimaryAccent] — the primary accent surface for interactive elements (buttons, - * toggles, active chips). - * - [SecondaryAccent] — a complementary accent surface (secondary actions, highlights). - * - [InverseBackground] — the inverted-brightness background (tooltips, snackbars). - * - [InverseAction] — the inverted-brightness action surface. - */ -@Serializable -enum class SurfaceRole { - PrimaryBackground, - SecondaryBackground, - PrimaryAccent, - SecondaryAccent, - InverseBackground, - InverseAction, -} - -/** - * A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials, - * in the given [SurfaceRole]. - * - * Distinct from [androidx.compose.material3.Surface] — this draws decorative material - * backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching - * [LocalContentColor] so text/content inside stays legible. - */ -@Composable -fun BaseSurface( - modifier: Modifier = Modifier, - style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme, - role: SurfaceRole = SurfaceRole.PrimaryBackground, - shape: Shape = RectangleShape, - elevation: Dp? = null, - contentColor: Color? = null, - contentPadding: PaddingValues = PaddingValues.Zero, - contentAlignment: Alignment = Alignment.TopStart, - content: @Composable BoxScope.() -> Unit, -) { - val ambientScheme = LocalBaseUIColors.current - val isLight = ambientScheme.isLight - // A surface is rendered in its own style's colour scheme, so previewing a style shows - // that style's real accent instead of the ambient theme's. When the requested style is - // the ambient theme, the ambient scheme is used so a user-chosen accent still applies. - val ownScheme = remember(style, isLight) { - baseUIColorScheme(style, isLight, style.accentColor(isLight)) - } - val scheme = if (ambientScheme.surfaceTheme == style) ambientScheme else ownScheme - val palette = surfacePalette(style, isLight) - - val baseElevation = when (style) { - SurfaceStyle.Watery -> 14.dp - SurfaceStyle.Icy -> 12.dp - SurfaceStyle.Chrome -> 10.dp - SurfaceStyle.Acrylic -> 16.dp - SurfaceStyle.Glass -> 12.dp - } - val resolvedElevation = elevation ?: when (role) { - SurfaceRole.PrimaryBackground -> baseElevation - SurfaceRole.SecondaryBackground -> baseElevation + 2.dp - SurfaceRole.PrimaryAccent, SurfaceRole.SecondaryAccent -> baseElevation + 6.dp - SurfaceRole.InverseBackground -> baseElevation - SurfaceRole.InverseAction -> baseElevation + 6.dp - } - val resolvedContentColor = contentColor ?: when (role) { - SurfaceRole.PrimaryBackground -> scheme.onBackgroundPrimary - SurfaceRole.SecondaryBackground -> scheme.onBackgroundSecondary - SurfaceRole.PrimaryAccent -> scheme.onAccentPrimary - SurfaceRole.SecondaryAccent -> scheme.onAccentSecondary - SurfaceRole.InverseBackground -> scheme.onBackgroundInverse - SurfaceRole.InverseAction -> scheme.onActionInverse - } - val ambient = when (role) { - SurfaceRole.PrimaryAccent -> scheme.accentPrimary.copy(alpha = 0.42f) - SurfaceRole.SecondaryAccent -> scheme.accentSecondary.copy(alpha = 0.42f) - SurfaceRole.InverseAction -> scheme.actionInverse.copy(alpha = 0.42f) - SurfaceRole.InverseBackground -> Color.Black.copy(alpha = 0.3f) - else -> palette.onSurface.copy(alpha = 0.2f) - } - val spot = ambient.copy(alpha = (ambient.alpha + 0.07f).coerceAtMost(1f)) - val borderBrush = roleBorderBrush(style, role, isLight, scheme) - - // Translucent materials (Glass, Acrylic) must not use a drop shadow: it is drawn - // behind the node and, because the panel is see-through, shows up as a dark inset - // shape. Accent and secondary/inverse roles are filled, so they can cast one. - val translucent = role == SurfaceRole.PrimaryBackground && - (style == SurfaceStyle.Glass || style == SurfaceStyle.Acrylic) - - CompositionLocalProvider(LocalContentColor provides resolvedContentColor) { - Box( - modifier = modifier - .then( - if (!translucent) { - Modifier.shadow( - elevation = resolvedElevation, - shape = shape, - ambientColor = ambient, - spotColor = spot, - ) - } else { - Modifier - } - ) - .clip(shape) - .drawWithCache { - onDrawBehind { drawSurfaceMaterial(style, role, isLight, scheme) } - } - .border(BorderStroke(1.dp, borderBrush), shape) - .padding(contentPadding), - contentAlignment = contentAlignment, - content = content, - ) - } -} - -/** - * Paints the full-bleed material of [style] as a page background: the primary material - * body plus its sheen and reflections, but no panel bevel (a backdrop has no edges). - */ -fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier = - drawWithCache { - onDrawBehind { - drawStyleBase(style, isLight) - drawStyleDecorations(style, isLight) - } - } - -/** - * Overlays a [SurfaceStyle]'s material character (gloss, bubbles, facets, specular band, - * grain) and its inner bevel on a node that already carries a base fill. Compact components - * — buttons, toggles, chips — use this to share the exact look of a [BaseSurface] in [role] - * without redrawing the fill (which comes from their own interaction-state brush). - */ -fun Modifier.baseSurfaceTexture( - style: SurfaceStyle, - role: SurfaceRole, - isLight: Boolean, -): Modifier = drawWithCache { - onDrawBehind { - val effectiveLight = when (role) { - SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight - else -> isLight - } - drawStyleDecorations(style, effectiveLight) - drawInnerBevel(effectiveLight) - } -} - -/** Ambient-scheme overload of [baseSurfaceTexture] for a single role. */ -@Composable -fun Modifier.baseSurfaceTexture(role: SurfaceRole): Modifier { - val scheme = LocalBaseUIColors.current - return baseSurfaceTexture(scheme.surfaceTheme, role, scheme.isLight) -} - -/** - * Paints the full [SurfaceStyle] material for [role] — base fill, material character and inner - * bevel — using the ambient [BaseUIColorScheme]. Use it to make a chrome panel (a sidebar, a - * player bar) a first-class surface rather than a flat Material surface. - */ -@Composable -fun Modifier.baseSurfaceMaterial( - role: SurfaceRole = SurfaceRole.PrimaryBackground, - style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme, -): Modifier { - val scheme = LocalBaseUIColors.current - return drawWithCache { - onDrawBehind { drawSurfaceMaterial(style, role, scheme.isLight, scheme) } - } -} - -private fun DrawScope.drawSurfaceMaterial( - style: SurfaceStyle, - role: SurfaceRole, - isLight: Boolean, - scheme: BaseUIColorScheme, -) { - // Role base fill. - when (role) { - SurfaceRole.PrimaryBackground -> drawStyleBase(style, isLight) - - // A distinct, complementary background hue (keeps the style's texture below). - SurfaceRole.SecondaryBackground -> drawRect(color = scheme.backgroundSecondary) - - SurfaceRole.PrimaryAccent -> drawAccentBase(scheme.accentPrimary) - SurfaceRole.SecondaryAccent -> drawAccentBase(scheme.accentSecondary) - - // Opaque inverted background so translucent styles still read as a solid surface. - SurfaceRole.InverseBackground -> drawRect(color = scheme.backgroundInverse) - - SurfaceRole.InverseAction -> drawAccentBase(scheme.actionInverse) - } - - // The material's own character (bubbles, facets, grain, specular band, sheen). - val effectiveLight = when (role) { - SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight - else -> isLight - } - drawStyleDecorations(style, effectiveLight) - drawInnerBevel(effectiveLight) -} - -// --- Material base gradients (one per style) --------------------------------- - -private fun DrawScope.drawStyleBase(style: SurfaceStyle, isLight: Boolean) { - when (style) { - SurfaceStyle.Glass -> { - val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6) - drawRect( - brush = Brush.verticalGradient( - listOf( - tint.copy(alpha = if (isLight) 0.4f else 0.2f), - tint.copy(alpha = if (isLight) 0.24f else 0.1f), - ), - ), - ) - } - - SurfaceStyle.Acrylic -> { - val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8) - drawRect( - brush = Brush.verticalGradient( - listOf( - tint.copy(alpha = if (isLight) 0.45f else 0.22f), - tint.copy(alpha = if (isLight) 0.28f else 0.12f), - ), - ), - ) - } - - SurfaceStyle.Chrome -> { - if (isLight) { - drawRect( - brush = Brush.verticalGradient( - 0f to Color(0xFFF8FBFF), - 0.16f to Color(0xFFD6DEE7), - 0.4f to Color(0xFF8E99A8), - 0.5f to Color(0xFFEDF2F7), - 0.62f to Color(0xFF9AA5B4), - 1f to Color(0xFF6C7787), - ), - ) - } else { - drawRect( - brush = Brush.verticalGradient( - 0f to Color(0xFF3B424C), - 0.16f to Color(0xFF2A303A), - 0.4f to Color(0xFF1B2028), - 0.5f to Color(0xFF343B45), - 0.62f to Color(0xFF232830), - 1f to Color(0xFF141820), - ), - ) - } - } - - SurfaceStyle.Icy -> { - val colors = if (isLight) { - listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0)) - } else { - listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26)) - } - drawRect(brush = Brush.verticalGradient(colors)) - } - - SurfaceStyle.Watery -> { - // A clear-but-soft aqua column: unmistakably water, without a heavy flat blue. - val colors = if (isLight) { - listOf(Color(0xFFD8F0FD), Color(0xFFB0DEF7), Color(0xFF83C8EE)) - } else { - listOf(Color(0xFF123249), Color(0xFF0B2135), Color(0xFF06141F)) - } - drawRect(brush = Brush.verticalGradient(colors)) - } - } -} - -/** The accent-filled base used by [SurfaceRole.Action]. */ -private fun DrawScope.drawAccentBase(accent: Color) { - drawRect( - brush = Brush.verticalGradient( - listOf( - lerp(accent, Color.White, 0.45f), - accent, - lerp(accent, Color.Black, 0.16f), - ), - ), - ) -} - -// --- Material decorations (one per style) ------------------------------------ - -private fun DrawScope.drawStyleDecorations(style: SurfaceStyle, isLight: Boolean) { - when (style) { - SurfaceStyle.Glass -> { - drawGloss(if (isLight) 0.55f else 0.16f) - drawBottomReflection(if (isLight) 0.4f else 0.1f) - drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f) - } - - SurfaceStyle.Acrylic -> { - // Fine grain so the panel reads as a translucent acrylic sheet. - val random = Random(23) - repeat(180) { - val light = random.nextBoolean() - drawCircle( - color = if (light) { - Color.White.copy(alpha = if (isLight) 0.16f else 0.06f) - } else { - Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f) - }, - radius = 0.7f, - center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), - ) - } - drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f) - } - - SurfaceStyle.Chrome -> { - // Hard specular reflection near the top. - drawRect( - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = if (isLight) 0.85f else 0.18f), - Color.Transparent, - ), - startY = size.height * 0.05f, - endY = size.height * 0.22f, - ), - ) - // Darkened lower edge for the metal bevel. - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)), - startY = size.height * 0.78f, - endY = size.height, - ), - ) - } - - SurfaceStyle.Icy -> { - val frost = if (isLight) Color.White else Color(0xFFB8CCE0) - - // Crystalline facets (kept inset so they are never clipped by the shape). - val random = Random(37) - repeat(4) { - val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f) - val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f) - val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f) - val path = Path().apply { - moveTo(x, y) - lineTo(x + s, y + s * 0.4f) - lineTo(x + s * 0.4f, y + s) - close() - } - drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f)) - } - - // Frost speckles. - repeat(90) { - drawCircle( - color = frost.copy(alpha = if (isLight) 0.5f else 0.28f), - radius = 0.8f, - center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height), - ) - } - drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f) - } - - SurfaceStyle.Watery -> { - val sheen = if (isLight) Color.White else Color(0xFFAFD3F2) - - // Caustic light pooling from the surface. - drawRect( - brush = Brush.verticalGradient( - colors = listOf( - sheen.copy(alpha = if (isLight) 0.6f else 0.2f), - Color.Transparent, - ), - startY = 0f, - endY = size.height * 0.55f, - ), - ) - - val random = Random(53) - // Refracted caustic blobs — the bright, rippling patterns of moving water. - repeat(9) { - val rx = size.minDimension * (0.14f + random.nextFloat() * 0.26f) - val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f) - val cy = rx + random.nextFloat() * (size.height - 2 * rx).coerceAtLeast(0f) - drawOval( - brush = Brush.radialGradient( - colors = listOf( - sheen.copy(alpha = if (isLight) 0.5f else 0.22f), - Color.Transparent, - ), - center = Offset(cx, cy), - radius = rx, - ), - topLeft = Offset(cx - rx, cy - rx * 0.7f), - size = Size(rx * 2f, rx * 1.4f), - ) - } - - // Ripple crests: thin horizontal highlights that read as surface waves. - repeat(3) { - val y = size.height * (0.18f + random.nextFloat() * 0.64f) - drawRect( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - sheen.copy(alpha = if (isLight) 0.4f else 0.16f), - Color.Transparent, - ), - startY = y - size.height * 0.035f, - endY = y + size.height * 0.035f, - ), - ) - } - - drawBubbles(seed = 71, count = 11, alpha = if (isLight) 0.6f else 0.3f, rim = true) - } - } -} - -private fun DrawScope.drawGloss(alpha: Float, heightFraction: Float = 0.5f) { - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.White.copy(alpha = alpha), Color.Transparent), - startY = 0f, - endY = size.height * heightFraction, - ), - ) -} - -private fun DrawScope.drawBottomReflection(alpha: Float) { - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.White.copy(alpha = alpha)), - startY = size.height * 0.7f, - endY = size.height, - ), - ) -} - -/** A bright inner top edge plus a shaded inner bottom edge, giving the pane a glass bevel. */ -private fun DrawScope.drawInnerBevel(isLight: Boolean) { - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.White.copy(alpha = if (isLight) 0.85f else 0.22f), Color.Transparent), - startY = 0f, - endY = size.height * 0.1f, - ), - ) - drawRect( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = if (isLight) 0.08f else 0.28f)), - startY = size.height * 0.9f, - endY = size.height, - ), - ) -} - -private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float, rim: Boolean = false) { - val random = Random(seed) - repeat(count) { - val radius = size.minDimension * (0.05f + random.nextFloat() * 0.14f) - // Keep bubbles inset so they are never clipped into hard arcs at the edges. - val cx = radius + random.nextFloat() * (size.width - 2 * radius).coerceAtLeast(0f) - val cy = radius + random.nextFloat() * (size.height - 2 * radius).coerceAtLeast(0f) - val center = Offset(cx, cy) - drawCircle( - brush = Brush.radialGradient( - colors = listOf(Color.White.copy(alpha = alpha), Color.White.copy(alpha = 0f)), - center = center, - radius = radius, - ), - radius = radius, - center = center, - ) - if (rim) { - drawCircle( - color = Color.White.copy(alpha = alpha * 0.7f), - radius = radius, - center = center, - style = Stroke(width = (radius * 0.14f).coerceAtLeast(0.8f)), - ) - } - } -} - -private fun styleBorderBrush(style: SurfaceStyle): Brush = when (style) { - SurfaceStyle.Watery -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)), - ) - - SurfaceStyle.Icy -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)), - ) - - SurfaceStyle.Chrome -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)), - ) - - SurfaceStyle.Acrylic -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)), - ) - - SurfaceStyle.Glass -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)), - ) -} - -private fun roleBorderBrush( - style: SurfaceStyle, - role: SurfaceRole, - isLight: Boolean, - scheme: BaseUIColorScheme, -): Brush = when (role) { - SurfaceRole.PrimaryBackground -> styleBorderBrush(style) - - SurfaceRole.SecondaryBackground -> Brush.verticalGradient( - listOf( - lerp(scheme.backgroundSecondary, Color.White, 0.5f) - .copy(alpha = if (isLight) 0.85f else 0.6f), - Color.White.copy(alpha = 0.12f), - ), - ) - - SurfaceRole.PrimaryAccent -> accentEdgeBrush(scheme.accentPrimary) - SurfaceRole.SecondaryAccent -> accentEdgeBrush(scheme.accentSecondary) - - SurfaceRole.InverseBackground -> Brush.verticalGradient( - listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.1f)), - ) - - SurfaceRole.InverseAction -> accentEdgeBrush(scheme.actionInverse) -} - -private fun accentEdgeBrush(accent: Color): Brush = Brush.verticalGradient( - listOf(lerp(accent, Color.White, 0.6f), lerp(accent, Color.Black, 0.2f)), -) - -/** - * One preview showing everything about surfaces: every [SurfaceStyle] crossed with every - * [SurfaceRole], plus the full-bleed backdrops — no need to scroll through many previews. - */ -@BaseUIDesktopPreview -@Composable -private fun SurfacePreview() { - BaseUIPreview { - val colors = LocalBaseUIColors.current - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - SurfaceStyle.entries.forEach { style -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - SurfaceRole.entries.forEach { role -> - BaseSurface( - style = style, - role = role, - modifier = Modifier - .weight(1f) - .height(84.dp), - contentPadding = PaddingValues(10.dp), - ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = style.name, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = role.name, - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - } - } - - // Full-bleed backdrops (the page background material, without the panel bevel). - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - SurfaceStyle.entries.forEach { style -> - Box( - modifier = Modifier - .weight(1f) - .height(60.dp) - .clip(RoundedCornerShape(12.dp)) - .baseSurfaceBackdrop(style, colors.isLight), - contentAlignment = Alignment.Center, - ) { - Text( - text = style.name, - style = MaterialTheme.typography.labelSmall, - color = colors.onSurface, - ) - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt deleted file mode 100644 index 0309f2a4..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/TextField.kt +++ /dev/null @@ -1,291 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -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.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash -import dev.krtirtho.spotube.resources.iconsax.User - -private val TextFieldMinHeight = 44.dp - -private data class ResolvedTextFieldState( - val background: Brush, - val highlight: Color, - val shape: Shape, - val border: BaseUITheme.Border, - val shadow: BaseUITheme.Shadow, - val foreground: Color, -) - -@Composable -private fun resolveTextFieldState( - theme: BaseUITheme.TextFieldTheme, - isFocused: Boolean, - isHovered: Boolean, -): ResolvedTextFieldState { - return when { - isFocused -> ResolvedTextFieldState(theme.background.focused, theme.highlight.focused, theme.shape.focused, theme.border.focused, theme.shadow.focused, theme.foreground.focused) - isHovered -> ResolvedTextFieldState(theme.background.hovered, theme.highlight.hovered, theme.shape.hovered, theme.border.hovered, theme.shadow.hovered, theme.foreground.hovered) - else -> ResolvedTextFieldState(theme.background.normal, theme.highlight.normal, theme.shape.normal, theme.border.normal, theme.shadow.normal, theme.foreground.normal) - } -} - -@Composable -fun TextField( - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - placeholder: @Composable (() -> Unit)? = null, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - label: @Composable (() -> Unit)? = null, - singleLine: Boolean = false, - maxLines: Int = Int.MAX_VALUE, - enabled: Boolean = true, - readOnly: Boolean = false, - isError: Boolean = false, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - visualTransformation: VisualTransformation = VisualTransformation.None, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - textStyle: TextStyle = TextStyle.Default, - cursorBrush: Color? = null, - theme: BaseUITheme.TextFieldTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.textField - val textFieldTheme = theme ?: baseTheme - val isFocused by interactionSource.collectIsFocusedAsState() - val isHovered by interactionSource.collectIsHoveredAsState() - - val state = resolveTextFieldState(textFieldTheme, isFocused, isHovered) - - val resolvedBorder = if (isError) { - BaseUITheme.Border(MaterialTheme.colorScheme.error, state.border.width) - } else { - state.border - } - - val contentColor = when { - !enabled -> state.foreground.copy(alpha = 0.38f) - else -> state.foreground - } - - val cursor = cursorBrush?.let { SolidColor(it) } ?: textFieldTheme.cursor - - Column(modifier = modifier) { - AnimatedVisibility( - visible = label != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - label?.invoke() - } - - Box( - modifier = Modifier - .defaultMinSize(minHeight = TextFieldMinHeight) - .then( - if (state.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = state.shadow.elevation, - shape = state.shape, - ambientColor = state.shadow.ambientColor, - spotColor = state.shadow.spotColor, - ) - } else Modifier - ) - .clip(state.shape) - .background(state.background, state.shape) - .border(BorderStroke(resolvedBorder.width, resolvedBorder.color), state.shape) - .highlight(state.highlight) - .padding(textFieldTheme.padding), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (leadingIcon != null) { - leadingIcon() - } - Box(modifier = Modifier.weight(1f)) { - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier - .fillMaxWidth() - .hoverable(interactionSource = interactionSource, enabled = enabled), - enabled = enabled, - readOnly = readOnly, - textStyle = textStyle.copy(color = contentColor), - cursorBrush = cursor, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - singleLine = singleLine, - maxLines = maxLines, - visualTransformation = visualTransformation, - interactionSource = interactionSource, - decorationBox = { innerTextField -> - if (value.isEmpty() && placeholder != null && !isFocused) { - CompositionLocalProvider(LocalTextStyle provides textStyle) { - Box(contentAlignment = Alignment.CenterStart) { - placeholder() - } - } - } else { - innerTextField() - } - }, - ) - } - - if (trailingIcon != null) { - trailingIcon() - } - } - } - } -} - -@BaseUIPhonePreview -@Composable -private fun TextFieldPreview() { - BaseUIPreview { - var email by remember { mutableStateOf("") } - var query by remember { mutableStateOf("Twenty One Pilots") } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - TextField( - value = email, - onValueChange = { email = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("you@example.com") }, - label = { TextFieldPreviewLabel("Email address") }, - leadingIcon = { - Icon( - imageVector = Iconsax.User, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - }, - singleLine = true, - ) - - TextField( - value = query, - onValueChange = { query = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search songs, artists, albums...") }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - modifier = Modifier.size(18.dp), - ) - }, - trailingIcon = { - Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = "Clear", - modifier = Modifier.size(18.dp), - ) - }, - singleLine = true, - ) - - TextField( - value = "not-an-email", - onValueChange = {}, - modifier = Modifier.fillMaxWidth(), - label = { TextFieldPreviewLabel("Error state") }, - isError = true, - singleLine = true, - ) - - TextField( - value = "Read only value", - onValueChange = {}, - modifier = Modifier.fillMaxWidth(), - label = { TextFieldPreviewLabel("Disabled") }, - enabled = false, - singleLine = true, - ) - } - } -} - -@Composable -private fun TextFieldPreviewLabel(text: String) { - Text( - text = text, - modifier = Modifier.padding(bottom = 6.dp), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt deleted file mode 100644 index 33880cab..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Theme.kt +++ /dev/null @@ -1,962 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.AutoCompleteMenuTheme -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.CheckBoxTheme -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.ChipTabTheme -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.SliderTheme -import dev.krtirtho.spotube.core.ui.base.BaseUITheme.TextFieldTheme - -@Stable -data class BaseUITheme( - val colors: BaseUIColorScheme, - val surfaceTheme: SurfaceStyle, - val buttons: ButtonVariants, - val iconButtons: ButtonVariants, - val textField: TextFieldTheme, - val autoCompleteMenuTheme: AutoCompleteMenuTheme, - val slider: SliderTheme, - val checkBox: CheckBoxTheme, - val chipTab: ChipTabTheme, - val toggle: ToggleTheme, - val card: CardTheme, - val listRowTile: ListRowTheme, - val dialog: DialogTheme, - val dropdownMenu: DropdownMenuTheme, -) { - - @Stable - data class InteractionState( - val normal: T, - val hovered: T, - val pressed: T, - val focused: T, - ) { - companion object { - fun fromSingleValue(value: T): InteractionState { - return InteractionState(value, value, value, value) - } - } - } - - @Stable - data class AdvancedInteractionState( - val normal: T, - val hovered: T, - val pressed: T, - val focused: T, - val selected: T, - val disabled: T, - ) { - companion object { - fun fromSingleValue(value: T): AdvancedInteractionState { - return AdvancedInteractionState(value, value, value, value, value, value) - } - } - } - - @Stable - data class Border( - val color: Color, - val width: Dp, - ) - - @Stable - data class Shadow( - val elevation: Dp, - val clip: Boolean = elevation > 0.dp, - val ambientColor: Color, - val spotColor: Color, - ) - - @Stable - data class ButtonVariants( - val primary: ButtonStyle, - val secondary: ButtonStyle, - val outline: ButtonStyle, - val ghost: ButtonStyle, - ) - - @Stable - data class ButtonStyle( - val colors: InteractionState, - val shape: InteractionState, - val shadow: InteractionState, - val border: InteractionState, - val padding: InteractionState, - ) - - @Stable - data class ButtonColors( - val background: Brush, - val foreground: Color, - val highlight: Color, - /** - * The [SurfaceRole] this colour represents. When set, components overlay the - * matching [SurfaceStyle] material (gloss, bubbles, facets, specular band, grain) - * on top of [background], so buttons and other actionable items share the exact - * look of a [BaseSurface]. `null` means no material (e.g. ghost buttons). - */ - val surfaceRole: SurfaceRole? = null, - ) - - data class CardTheme( - val background: Brush, - val shape: Shape, - val border: Border, - val padding: PaddingValues, - val shadow: Shadow, - /** The [SurfaceRole] the card renders, so it can overlay the matching material. */ - val surfaceRole: SurfaceRole? = null, - ) - - data class ListRowTheme( - val background: AdvancedInteractionState, - val shape: AdvancedInteractionState, - val border: AdvancedInteractionState, - val shadow: AdvancedInteractionState, - val padding: PaddingValues, - val textStyle: AdvancedInteractionState, - val foreground: AdvancedInteractionState, - ) - - data class TextFieldTheme( - val background: InteractionState, - val highlight: InteractionState, - val shape: InteractionState, - val border: InteractionState, - val shadow: InteractionState, - val padding: PaddingValues, - val textStyle: TextStyle, - val cursor: Brush, - val foreground: InteractionState, - ) - - data class AutoCompleteMenuTheme( - val background: Brush, - val shape: Shape, - val border: Border, - val padding: PaddingValues, - val shadowElevation: Dp, - ) - - data class DialogTheme( - val background: Brush, - val scrim: Color, - val shape: Shape, - val border: Border, - val shadow: Shadow, - val padding: PaddingValues, - ) - - data class DropdownMenuTheme( - val background: Brush, - val shape: Shape, - val border: Border, - val shadow: Shadow, - val itemHoverBackground: Brush, - val itemForeground: Color, - val itemHighlight: Color, - val padding: PaddingValues, - val itemPadding: PaddingValues, - ) - - data class SliderTheme( - val trackActiveColor: InteractionState, - val trackInactiveColor: InteractionState, - val thumbColor: InteractionState, - val thumbShadow: InteractionState, - val stepActiveColor: Color, - val stepInactiveColor: Color, - ) - - data class CheckBoxTheme( - val selected: ButtonStyle, - val unselected: ButtonStyle, - val checkmarkColor: Color, - ) - - data class ChipTabTheme( - val selected: ButtonStyle, - val unselected: ButtonStyle, - ) - - data class ToggleTheme( - val checked: ButtonStyle, - val unchecked: ButtonStyle, - val thumb: InteractionState, - val thumbShadow: InteractionState, - ) -} - -val LocalBaseUITheme = staticCompositionLocalOf { - throw IllegalStateException("No BaseUITheme provided") -} - -@Composable -fun rememberBaseUITheme( - surfaceTheme: SurfaceStyle = SurfaceStyle.Glass, - accentColor: Color? = null, -): BaseUITheme { - val colors = rememberBaseUIColorScheme(surfaceTheme, accentColor) - val isLight = colors.isLight - - // ------------------------------------------------------------------ - // Frutiger-Aero glass palette. - // - // Surfaces are glossy, top-lit gradients with a strong white specular - // highlight and translucent "glass" edges, lifted by soft, coloured - // shadows — the aqua/glass language of Frutiger-Aero. - // ------------------------------------------------------------------ - // Primary surface (subtle) with the style's palette, and the secondary surface - // (slightly accented) used by cards and grouped content. - val panelTop = lerp(colors.backgroundPrimary, Color.White, if (isLight) 0.45f else 0.06f) - val panelBottom = colors.surfaceVariant - val secondaryTop = lerp(colors.backgroundSecondary, Color.White, if (isLight) 0.35f else 0.05f) - val secondaryBottom = colors.backgroundSecondary - val glassPressed = if (isLight) { - lerp(colors.surfaceContainerHighest, Color.White, 0.06f) - } else { - lerp(colors.surfaceContainerHighest, Color.Black, 0.08f) - } - - val gloss = if (isLight) Color.White.copy(alpha = 0.75f) else Color.White.copy(alpha = 0.18f) - val glossSoft = if (isLight) Color.White.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.12f) - val glassEdge = if (isLight) Color.White.copy(alpha = 0.95f) else Color.White.copy(alpha = 0.22f) - val glassBorder = if (isLight) { - lerp(colors.outlineVariant, Color.White, 0.55f) - } else { - colors.outlineVariant.copy(alpha = 0.5f) - } - val shadowColor = colors.onSurface - - fun glassBrush(top: Color, bottom: Color): Brush = - Brush.verticalGradient(listOf(top, bottom)) - - fun aqua(accent: Color): Brush = Brush.verticalGradient( - listOf( - lerp(accent, Color.White, 0.45f), - accent, - lerp(accent, Color.Black, 0.14f), - ) - ) - - val defaultShape = RoundedCornerShape(16.dp) - val defaultShapeState = - BaseUITheme.InteractionState(defaultShape, defaultShape, defaultShape, defaultShape) - val noBorder = BaseUITheme.Border(Color.Transparent, 0.dp) - val noBorderState = BaseUITheme.InteractionState(noBorder, noBorder, noBorder, noBorder) - val noShadow = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent) - val noShadowState = BaseUITheme.InteractionState(noShadow, noShadow, noShadow, noShadow) - val defaultButtonPadding = BaseUITheme.InteractionState( - PaddingValues(horizontal = 22.dp, vertical = 10.dp), - PaddingValues(horizontal = 22.dp, vertical = 10.dp), - PaddingValues(horizontal = 22.dp, vertical = 10.dp), - PaddingValues(horizontal = 22.dp, vertical = 10.dp), - ) - val iconButtonPadding = BaseUITheme.InteractionState( - PaddingValues(9.dp), - PaddingValues(9.dp), - PaddingValues(9.dp), - PaddingValues(9.dp), - ) - - // --- Glass (outline) ------------------------------------------------- - val outlineColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = glassBrush(panelTop, panelBottom), - foreground = colors.onSurface, - highlight = gloss, - ), - hovered = BaseUITheme.ButtonColors( - background = glassBrush(panelTop, panelBottom), - foreground = colors.onSurface, - highlight = Color.White.copy(alpha = if (isLight) 0.9f else 0.26f), - ), - pressed = BaseUITheme.ButtonColors( - background = glassBrush(glassPressed, glassPressed), - foreground = colors.onSurface, - highlight = glossSoft, - ), - focused = BaseUITheme.ButtonColors( - background = glassBrush(panelTop, panelBottom), - foreground = colors.onSurface, - highlight = gloss, - ), - ).withRole(SurfaceRole.PrimaryBackground) - val glassShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 8.dp, - true, - shadowColor.copy(alpha = 0.16f), - shadowColor.copy(alpha = 0.2f), - ), - hovered = BaseUITheme.Shadow( - 14.dp, - true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.26f), - ), - pressed = BaseUITheme.Shadow( - 2.dp, - true, - shadowColor.copy(alpha = 0.1f), - shadowColor.copy(alpha = 0.12f), - ), - focused = BaseUITheme.Shadow( - 8.dp, - true, - shadowColor.copy(alpha = 0.16f), - shadowColor.copy(alpha = 0.2f), - ), - ) - val glassBorderState = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(glassBorder, 1.dp), - hovered = BaseUITheme.Border(glassEdge, 1.dp), - pressed = BaseUITheme.Border(glassBorder.copy(alpha = 0.7f), 1.dp), - focused = BaseUITheme.Border(glassEdge, 1.dp), - ) - - // --- Aqua accent (primary) ------------------------------------------- - val primaryEdge = lerp(colors.primary, Color.White, 0.5f) - val primaryColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss), - hovered = BaseUITheme.ButtonColors( - aqua(colors.accentPrimary), - colors.onAccentPrimary, - Color.White.copy(alpha = if (isLight) 0.9f else 0.3f), - ), - pressed = BaseUITheme.ButtonColors( - glassBrush( - lerp(colors.primary, Color.Black, 0.16f), - lerp(colors.primary, Color.Black, 0.24f), - ), - colors.onAccentPrimary, - glossSoft, - ), - focused = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss), - ).withRole(SurfaceRole.PrimaryAccent) - val primaryShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 10.dp, - true, - colors.primary.copy(alpha = 0.35f), - colors.primary.copy(alpha = 0.42f), - ), - hovered = BaseUITheme.Shadow( - 16.dp, - true, - colors.primary.copy(alpha = 0.45f), - colors.primary.copy(alpha = 0.5f), - ), - pressed = BaseUITheme.Shadow( - 3.dp, - true, - colors.primary.copy(alpha = 0.22f), - colors.primary.copy(alpha = 0.28f), - ), - focused = BaseUITheme.Shadow( - 10.dp, - true, - colors.primary.copy(alpha = 0.35f), - colors.primary.copy(alpha = 0.42f), - ), - ) - val primaryBorder = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(primaryEdge, 1.dp), - hovered = BaseUITheme.Border(primaryEdge, 1.dp), - pressed = BaseUITheme.Border(primaryEdge.copy(alpha = 0.8f), 1.dp), - focused = BaseUITheme.Border(primaryEdge, 1.dp), - ) - - // --- Secondary (soft aqua glass) ------------------------------------- - val secondaryBase = colors.backgroundSecondary - val secondaryColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase), - colors.onSecondaryContainer, - gloss, - ), - hovered = BaseUITheme.ButtonColors( - glassBrush(lerp(secondaryBase, Color.White, 0.55f), secondaryBase), - colors.onSecondaryContainer, - Color.White.copy(alpha = if (isLight) 0.85f else 0.24f), - ), - pressed = BaseUITheme.ButtonColors( - glassBrush( - lerp(secondaryBase, Color.Black, 0.08f), - lerp(secondaryBase, Color.Black, 0.14f), - ), - colors.onSecondaryContainer, - glossSoft, - ), - focused = BaseUITheme.ButtonColors( - glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase), - colors.onSecondaryContainer, - gloss, - ), - ).withRole(SurfaceRole.SecondaryBackground) - val secondaryShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 8.dp, - true, - secondaryBase.copy(alpha = 0.4f), - secondaryBase.copy(alpha = 0.5f), - ), - hovered = BaseUITheme.Shadow( - 14.dp, - true, - secondaryBase.copy(alpha = 0.5f), - secondaryBase.copy(alpha = 0.6f), - ), - pressed = BaseUITheme.Shadow( - 2.dp, - true, - secondaryBase.copy(alpha = 0.3f), - secondaryBase.copy(alpha = 0.35f), - ), - focused = BaseUITheme.Shadow( - 8.dp, - true, - secondaryBase.copy(alpha = 0.4f), - secondaryBase.copy(alpha = 0.5f), - ), - ) - val secondaryBorder = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.5f), 1.dp), - hovered = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.6f), 1.dp), - pressed = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.4f), 1.dp), - focused = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.5f), 1.dp), - ) - - // --- Ghost ----------------------------------------------------------- - val ghostColors = BaseUITheme.InteractionState( - normal = BaseUITheme.ButtonColors( - background = glassBrush(Color.Transparent, Color.Transparent), - foreground = colors.onSurface, - highlight = Color.Transparent, - ), - hovered = BaseUITheme.ButtonColors( - background = glassBrush( - lerp(colors.surfaceContainerHigh, Color.White, 0.1f), - colors.surfaceContainer, - ), - foreground = colors.onSurface, - highlight = glossSoft, - ), - pressed = BaseUITheme.ButtonColors( - background = glassBrush(glassPressed, glassPressed), - foreground = colors.onSurface, - highlight = Color.Transparent, - ), - focused = BaseUITheme.ButtonColors( - background = glassBrush(Color.Transparent, Color.Transparent), - foreground = colors.onSurface, - highlight = Color.Transparent, - ), - ) - - val outlineStyle = BaseUITheme.ButtonStyle( - outlineColors, - defaultShapeState, - glassShadow, - glassBorderState, - defaultButtonPadding - ) - val primaryStyle = BaseUITheme.ButtonStyle( - primaryColors, - defaultShapeState, - primaryShadow, - primaryBorder, - defaultButtonPadding - ) - val secondaryStyle = BaseUITheme.ButtonStyle( - secondaryColors, - defaultShapeState, - secondaryShadow, - secondaryBorder, - defaultButtonPadding - ) - val ghostStyle = BaseUITheme.ButtonStyle( - ghostColors, - defaultShapeState, - noShadowState, - noBorderState, - defaultButtonPadding - ) - - val outlineIconStyle = BaseUITheme.ButtonStyle( - outlineColors, - defaultShapeState, - glassShadow, - glassBorderState, - iconButtonPadding - ) - val primaryIconStyle = BaseUITheme.ButtonStyle( - primaryColors, - defaultShapeState, - primaryShadow, - primaryBorder, - iconButtonPadding - ) - val secondaryIconStyle = BaseUITheme.ButtonStyle( - secondaryColors, - defaultShapeState, - secondaryShadow, - secondaryBorder, - iconButtonPadding - ) - val ghostIconStyle = BaseUITheme.ButtonStyle( - ghostColors, - defaultShapeState, - noShadowState, - noBorderState, - iconButtonPadding - ) - - val textFieldForeground = BaseUITheme.InteractionState( - normal = colors.onSurface, - hovered = colors.onSurface, - pressed = colors.onSurface, - focused = colors.onSurface, - ) - - val checkBoxShape = RoundedCornerShape(7.dp) - val checkBoxShapeState = - BaseUITheme.InteractionState(checkBoxShape, checkBoxShape, checkBoxShape, checkBoxShape) - val noPadding = - BaseUITheme.InteractionState(PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp)) - - val chipTabShape = RoundedCornerShape(999.dp) - val chipTabShapeState = - BaseUITheme.InteractionState(chipTabShape, chipTabShape, chipTabShape, chipTabShape) - val chipTabPadding = BaseUITheme.InteractionState( - PaddingValues(horizontal = 16.dp, vertical = 6.dp), - PaddingValues(horizontal = 16.dp, vertical = 6.dp), - PaddingValues(horizontal = 16.dp, vertical = 6.dp), - PaddingValues(horizontal = 16.dp, vertical = 6.dp), - ) - - val toggleShape = RoundedCornerShape(50) - val toggleShapeState = - BaseUITheme.InteractionState(toggleShape, toggleShape, toggleShape, toggleShape) - val togglePadding = BaseUITheme.InteractionState( - PaddingValues(2.dp), - PaddingValues(2.dp), - PaddingValues(2.dp), - PaddingValues(2.dp), - ) - - return BaseUITheme( - colors = colors, - surfaceTheme = surfaceTheme, - buttons = BaseUITheme.ButtonVariants( - primary = primaryStyle, - secondary = secondaryStyle, - outline = outlineStyle, - ghost = ghostStyle, - ), - iconButtons = BaseUITheme.ButtonVariants( - primary = primaryIconStyle, - secondary = secondaryIconStyle, - outline = outlineIconStyle, - ghost = ghostIconStyle, - ), - textField = TextFieldTheme( - background = BaseUITheme.InteractionState( - normal = glassBrush(panelTop, panelBottom), - hovered = glassBrush(panelTop, panelBottom), - pressed = glassBrush(glassPressed, glassPressed), - focused = glassBrush(panelTop, panelBottom), - ), - highlight = BaseUITheme.InteractionState( - normal = gloss, - hovered = gloss, - pressed = glossSoft, - focused = Color.White.copy(alpha = if (isLight) 0.85f else 0.22f), - ), - shape = BaseUITheme.InteractionState( - normal = defaultShape, - hovered = defaultShape, - pressed = defaultShape, - focused = defaultShape, - ), - border = BaseUITheme.InteractionState( - normal = BaseUITheme.Border(glassBorder, 1.dp), - hovered = BaseUITheme.Border(glassEdge, 1.dp), - pressed = BaseUITheme.Border(glassBorder.copy(alpha = 0.7f), 1.dp), - focused = BaseUITheme.Border(primaryEdge, 1.5.dp), - ), - shadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 8.dp, - true, - shadowColor.copy(alpha = 0.14f), - shadowColor.copy(alpha = 0.18f) - ), - hovered = BaseUITheme.Shadow( - 10.dp, - true, - shadowColor.copy(alpha = 0.18f), - shadowColor.copy(alpha = 0.22f) - ), - pressed = BaseUITheme.Shadow( - 4.dp, - true, - shadowColor.copy(alpha = 0.1f), - shadowColor.copy(alpha = 0.12f) - ), - focused = BaseUITheme.Shadow( - 10.dp, - true, - colors.primary.copy(alpha = 0.22f), - colors.primary.copy(alpha = 0.28f) - ), - ), - padding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), - textStyle = TextStyle.Default, - cursor = SolidColor(colors.primary), - foreground = textFieldForeground, - ), - autoCompleteMenuTheme = AutoCompleteMenuTheme( - background = glassBrush(panelTop, panelBottom), - shape = RoundedCornerShape(16.dp), - border = BaseUITheme.Border(glassBorder, 1.dp), - padding = PaddingValues(0.dp), - shadowElevation = 16.dp, - ), - slider = SliderTheme( - trackActiveColor = BaseUITheme.InteractionState( - normal = colors.primary, - hovered = colors.primary, - pressed = colors.primary, - focused = colors.primary, - ), - trackInactiveColor = BaseUITheme.InteractionState( - normal = glassBorder, - hovered = glassBorder, - pressed = glassBorder, - focused = glassBorder, - ), - thumbColor = BaseUITheme.InteractionState( - normal = colors.primary, - hovered = colors.primary, - pressed = colors.primary, - focused = colors.primary, - ), - thumbShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 5.dp, - true, - colors.primary.copy(alpha = 0.4f), - colors.primary.copy(alpha = 0.45f) - ), - hovered = BaseUITheme.Shadow( - 8.dp, - true, - colors.primary.copy(alpha = 0.45f), - colors.primary.copy(alpha = 0.5f) - ), - pressed = BaseUITheme.Shadow( - 2.dp, - true, - colors.primary.copy(alpha = 0.25f), - colors.primary.copy(alpha = 0.3f) - ), - focused = BaseUITheme.Shadow( - 5.dp, - true, - colors.primary.copy(alpha = 0.4f), - colors.primary.copy(alpha = 0.45f) - ), - ), - stepActiveColor = Color.White.copy(alpha = 0.7f), - stepInactiveColor = colors.onSurface.copy(alpha = 0.25f), - ), - checkBox = CheckBoxTheme( - selected = BaseUITheme.ButtonStyle( - colors = primaryColors, - shape = checkBoxShapeState, - shadow = primaryShadow, - border = primaryBorder, - padding = noPadding, - ), - unselected = BaseUITheme.ButtonStyle( - colors = outlineColors, - shape = checkBoxShapeState, - shadow = glassShadow, - border = glassBorderState, - padding = noPadding, - ), - checkmarkColor = colors.onAccentPrimary, - ), - chipTab = ChipTabTheme( - selected = BaseUITheme.ButtonStyle( - colors = primaryColors, - shape = chipTabShapeState, - shadow = primaryShadow, - border = primaryBorder, - padding = chipTabPadding, - ), - unselected = BaseUITheme.ButtonStyle( - colors = outlineColors, - shape = chipTabShapeState, - shadow = glassShadow, - border = glassBorderState, - padding = chipTabPadding, - ), - ), - toggle = BaseUITheme.ToggleTheme( - checked = BaseUITheme.ButtonStyle( - colors = primaryColors, - shape = toggleShapeState, - shadow = primaryShadow, - border = primaryBorder, - padding = togglePadding, - ), - unchecked = BaseUITheme.ButtonStyle( - colors = outlineColors, - shape = toggleShapeState, - shadow = glassShadow, - border = glassBorderState, - padding = togglePadding, - ), - thumb = BaseUITheme.InteractionState( - normal = Color.White, - hovered = Color.White, - pressed = Color.White, - focused = Color.White, - ), - thumbShadow = BaseUITheme.InteractionState( - normal = BaseUITheme.Shadow( - 3.dp, - true, - shadowColor.copy(alpha = 0.2f), - shadowColor.copy(alpha = 0.25f) - ), - hovered = BaseUITheme.Shadow( - 4.dp, - true, - shadowColor.copy(alpha = 0.28f), - shadowColor.copy(alpha = 0.32f) - ), - pressed = BaseUITheme.Shadow( - 1.dp, - true, - shadowColor.copy(alpha = 0.12f), - shadowColor.copy(alpha = 0.15f) - ), - focused = BaseUITheme.Shadow( - 3.dp, - true, - shadowColor.copy(alpha = 0.2f), - shadowColor.copy(alpha = 0.25f) - ), - ), - ), - card = BaseUITheme.CardTheme( - background = glassBrush(secondaryTop, secondaryBottom), - shape = RoundedCornerShape(20.dp), - border = BaseUITheme.Border(glassBorder, 1.dp), - padding = PaddingValues(0.dp), - shadow = BaseUITheme.Shadow( - 12.dp, - true, - shadowColor.copy(alpha = 0.16f), - shadowColor.copy(alpha = 0.2f) - ), - surfaceRole = SurfaceRole.SecondaryBackground, - ), - listRowTile = BaseUITheme.ListRowTheme( - background = BaseUITheme.AdvancedInteractionState( - normal = glassBrush(Color.Transparent, Color.Transparent), - hovered = glassBrush(panelTop, panelBottom), - pressed = glassBrush(glassPressed, glassPressed), - focused = glassBrush(panelTop, panelBottom), - selected = glassBrush(secondaryTop, secondaryBottom), - disabled = glassBrush( - panelTop.copy(alpha = 0.5f), - panelBottom.copy(alpha = 0.5f), - ), - ), - shape = BaseUITheme.AdvancedInteractionState( - normal = RoundedCornerShape(10.dp), - hovered = RoundedCornerShape(10.dp), - pressed = RoundedCornerShape(10.dp), - focused = RoundedCornerShape(10.dp), - selected = RoundedCornerShape(10.dp), - disabled = RoundedCornerShape(10.dp), - ), - border = BaseUITheme.AdvancedInteractionState( - normal = BaseUITheme.Border(Color.Transparent, 0.dp), - hovered = BaseUITheme.Border(glassBorder, 1.dp), - pressed = BaseUITheme.Border(Color.Transparent, 0.dp), - focused = BaseUITheme.Border(glassBorder, 1.dp), - selected = BaseUITheme.Border(Color.Transparent, 0.dp), - disabled = BaseUITheme.Border(Color.Transparent, 0.dp), - ), - shadow = BaseUITheme.AdvancedInteractionState( - normal = noShadow, - hovered = BaseUITheme.Shadow( - 6.dp, - true, - shadowColor.copy(alpha = 0.12f), - shadowColor.copy(alpha = 0.16f) - ), - pressed = noShadow, - focused = noShadow, - selected = noShadow, - disabled = noShadow, - ), - padding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), - textStyle = BaseUITheme.AdvancedInteractionState( - normal = TextStyle.Default, - hovered = TextStyle.Default, - pressed = TextStyle.Default, - focused = TextStyle.Default, - selected = TextStyle.Default, - disabled = TextStyle.Default, - ), - foreground = BaseUITheme.AdvancedInteractionState( - normal = colors.onSurface, - hovered = colors.onSurface, - pressed = colors.onSurface, - focused = colors.onSurface, - selected = colors.onSurface, - disabled = colors.onSurface.copy(alpha = 0.38f), - ), - ), - dialog = BaseUITheme.DialogTheme( - background = glassBrush(panelTop, panelBottom), - scrim = Color.Black.copy(alpha = 0.42f), - shape = RoundedCornerShape(24.dp), - border = BaseUITheme.Border(glassEdge, 1.dp), - shadow = BaseUITheme.Shadow( - 28.dp, - true, - shadowColor.copy(alpha = 0.3f), - shadowColor.copy(alpha = 0.4f) - ), - padding = PaddingValues(24.dp), - ), - dropdownMenu = BaseUITheme.DropdownMenuTheme( - background = glassBrush(panelTop, panelBottom), - shape = RoundedCornerShape(16.dp), - border = BaseUITheme.Border(glassBorder, 1.dp), - shadow = BaseUITheme.Shadow( - 16.dp, - true, - shadowColor.copy(alpha = 0.22f), - shadowColor.copy(alpha = 0.28f) - ), - itemHoverBackground = glassBrush(panelTop, panelBottom), - itemForeground = colors.onSurface, - itemHighlight = gloss, - padding = PaddingValues(6.dp), - itemPadding = PaddingValues(horizontal = 14.dp, vertical = 11.dp), - ), - ) -} - -// Helper functions for easier theme overrides - -fun BaseUITheme.ButtonStyle.copyShape(shape: Shape): BaseUITheme.ButtonStyle = - copy(shape = BaseUITheme.InteractionState.fromSingleValue(shape)) - -fun BaseUITheme.ButtonStyle.copyShadow(shadow: BaseUITheme.Shadow): BaseUITheme.ButtonStyle = - copy(shadow = BaseUITheme.InteractionState.fromSingleValue(shadow)) - -fun BaseUITheme.ButtonStyle.copyBorder(border: BaseUITheme.Border): BaseUITheme.ButtonStyle = - copy(border = BaseUITheme.InteractionState.fromSingleValue(border)) - -fun BaseUITheme.ButtonStyle.copyPadding(padding: PaddingValues): BaseUITheme.ButtonStyle = - copy(padding = BaseUITheme.InteractionState.fromSingleValue(padding)) - -fun BaseUITheme.ButtonStyle.copyColors(colors: BaseUITheme.ButtonColors): BaseUITheme.ButtonStyle = - copy(colors = BaseUITheme.InteractionState.fromSingleValue(colors)) - -/** Tags every interaction state of a button colour set with the surface role it renders. */ -private fun BaseUITheme.InteractionState.withRole( - role: SurfaceRole, -): BaseUITheme.InteractionState = BaseUITheme.InteractionState( - normal = normal.copy(surfaceRole = role), - hovered = hovered.copy(surfaceRole = role), - pressed = pressed.copy(surfaceRole = role), - focused = focused.copy(surfaceRole = role), -) - -@Composable -fun invertedButtonStyle(): BaseUITheme.ButtonStyle { - val scheme = MaterialTheme.colorScheme - val isLight = scheme.surface.luminance() > 0.5f - val shadowColor = scheme.onSurface - - val invertedColors = BaseUITheme.ButtonColors( - background = Brush.verticalGradient(listOf(scheme.onSurface, scheme.onSurface)), - foreground = scheme.surface, - highlight = if (isLight) Color.White.copy(alpha = 0.15f) else Color.White.copy(alpha = 0.08f), - ) - val pressedColors = BaseUITheme.ButtonColors( - background = Brush.verticalGradient( - listOf( - scheme.onSurfaceVariant, - scheme.onSurfaceVariant - ) - ), - foreground = scheme.surface, - highlight = if (isLight) Color.White.copy(alpha = 0.1f) else Color.White.copy(alpha = 0.05f), - ) - val border = BaseUITheme.Border(scheme.onSurfaceVariant, 0.5.dp) - val shadow = BaseUITheme.Shadow( - elevation = 6.dp, - clip = true, - ambientColor = shadowColor.copy(alpha = 0.2f), - spotColor = shadowColor.copy(alpha = 0.25f), - ) - - return BaseUITheme.ButtonStyle( - colors = BaseUITheme.InteractionState( - normal = invertedColors, - hovered = invertedColors, - pressed = pressedColors, - focused = invertedColors, - ), - shape = BaseUITheme.InteractionState.fromSingleValue(RoundedCornerShape(14.dp)), - shadow = BaseUITheme.InteractionState.fromSingleValue(shadow), - border = BaseUITheme.InteractionState.fromSingleValue(border), - padding = BaseUITheme.InteractionState.fromSingleValue(PaddingValues(8.dp)), - ) -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt deleted file mode 100644 index 607ef0cb..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Toggle.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max -import kotlin.math.roundToInt - -private val ToggleWidth = 44.dp -private val ToggleHeight = 24.dp -private val ThumbSize = 20.dp - -private data class ResolvedToggleState( - val colors: BaseUITheme.ButtonColors, - val shape: Shape, - val shadow: BaseUITheme.Shadow, - val border: BaseUITheme.Border, -) - -@Composable -private fun resolveToggleState( - style: BaseUITheme.ButtonStyle, - isPressed: Boolean, - isHovered: Boolean, -): ResolvedToggleState { - return when { - isPressed -> ResolvedToggleState( - style.colors.pressed, - style.shape.pressed, - style.shadow.pressed, - style.border.pressed, - ) - - isHovered -> ResolvedToggleState( - style.colors.hovered, - style.shape.hovered, - style.shadow.hovered, - style.border.hovered, - ) - - else -> ResolvedToggleState( - style.colors.normal, - style.shape.normal, - style.shadow.normal, - style.border.normal, - ) - } -} - -@Composable -fun Toggle( - checked: Boolean, - onCheckedChange: ((Boolean) -> Unit)?, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource? = null, - theme: BaseUITheme.ToggleTheme? = null, -) { - val baseTheme = LocalBaseUITheme.current.toggle - val toggleTheme = theme ?: baseTheme - val source = interactionSource ?: remember { MutableInteractionSource() } - val isPressed by source.collectIsPressedAsState() - val isHovered by source.collectIsHoveredAsState() - - val style = if (checked) toggleTheme.checked else toggleTheme.unchecked - val resolved = resolveToggleState(style, isPressed, isHovered) - - val thumbColor = when { - isPressed -> toggleTheme.thumb.pressed - isHovered -> toggleTheme.thumb.hovered - else -> toggleTheme.thumb.normal - } - - val thumbShadowState = when { - isPressed -> toggleTheme.thumbShadow.pressed - isHovered -> toggleTheme.thumbShadow.hovered - else -> toggleTheme.thumbShadow.normal - } - - val borderColor = when { - !enabled -> resolved.colors.foreground.copy(alpha = 0.38f) - else -> resolved.border.color - } - - val thumbProgress by animateFloatAsState( - targetValue = if (checked) 1f else 0f, - animationSpec = tween(durationMillis = 200), - label = "thumbProgress", - ) - - val thumbScale by animateFloatAsState( - targetValue = when { - isPressed -> 0.92f - isHovered -> 1.05f - else -> 1f - }, - animationSpec = tween(durationMillis = 150), - label = "thumbScale", - ) - - val lift = if (isHovered && !isPressed && onCheckedChange != null) (-1).dp else 0.dp - val density = LocalDensity.current - val thumbOffsetXPx by animateDpAsState( - targetValue = with(density) { - val trackWidth = ToggleWidth - ThumbSize - max((thumbProgress * trackWidth.toPx()).toDp() - 4.dp, 2.dp) - }, - animationSpec = tween(durationMillis = 200), - label = "thumbOffsetX", - ) - - Box( - modifier = modifier - .width(ToggleWidth) - .height(ToggleHeight) - .graphicsLayer { - translationY = lift.toPx() - } - .then( - if (resolved.shadow.elevation > 0.dp) { - Modifier.shadow( - elevation = resolved.shadow.elevation, - shape = resolved.shape, - ambientColor = resolved.shadow.ambientColor, - spotColor = resolved.shadow.spotColor, - ) - } else Modifier - ) - .clip(resolved.shape) - .background(resolved.colors.background, resolved.shape) - .applySurfaceMaterial(resolved.colors) - .border(BorderStroke(resolved.border.width, borderColor), resolved.shape) - .applyHighlight(resolved.colors) - .then( - if (onCheckedChange != null) { - Modifier - .hoverable(interactionSource = source, enabled = enabled) - .clickable( - enabled = enabled, - interactionSource = source, - indication = null, - onClick = { onCheckedChange(!checked) }, - ) - } else { - Modifier - } - ), - contentAlignment = Alignment.CenterStart, - ) { - Box( - modifier = Modifier - .offset(x = thumbOffsetXPx) - .size(ThumbSize) - .graphicsLayer { - scaleX = thumbScale - scaleY = thumbScale - } - .shadow( - elevation = thumbShadowState.elevation, - shape = CircleShape, - ambientColor = thumbShadowState.ambientColor, - spotColor = thumbShadowState.spotColor, - ) - .background( - brush = Brush.radialGradient( - colors = listOf( - Color.White.copy(alpha = 0.6f), - thumbColor.copy(alpha = 0.95f), - thumbColor, - ), - center = Offset(0f, -0.55f), - radius = 1.1f, - ), - shape = CircleShape, - ) - .border( - width = 0.8.dp, - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.55f), - Color.White.copy(alpha = 0.08f), - ), - ), - shape = CircleShape, - ), - ) - } -} - -@BaseUIPhonePreview -@Composable -private fun TogglePreview() { - BaseUIPreview { - var scrobbling by remember { mutableStateOf(true) } - var offlineMode by remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - TogglePreviewRow( - label = "Scrobble listened tracks", - checked = scrobbling, - onCheckedChange = { scrobbling = it }, - ) - TogglePreviewRow( - label = "Offline mode", - checked = offlineMode, - onCheckedChange = { offlineMode = it }, - ) - TogglePreviewRow( - label = "Discord rich presence", - checked = true, - enabled = false, - onCheckedChange = {}, - ) - } - } -} - -@Composable -private fun TogglePreviewRow( - label: String, - checked: Boolean, - enabled: Boolean = true, - onCheckedChange: (Boolean) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, style = MaterialTheme.typography.bodyMedium) - Toggle( - checked = checked, - onCheckedChange = onCheckedChange, - enabled = enabled, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt deleted file mode 100644 index 96d44fd5..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Utils.kt +++ /dev/null @@ -1,88 +0,0 @@ -package dev.krtirtho.spotube.core.ui.base - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -fun Modifier.highlight(color: Color): Modifier = - drawWithCache { - // Frutiger-Aero glass: a bright specular sheen across the top half plus a - // faint reflection rising from the bottom edge. - val topGloss = Brush.verticalGradient( - colors = listOf(color, color.copy(alpha = color.alpha * 0.25f), Color.Transparent), - startY = 0f, - endY = size.height * 0.55f, - ) - val bottomReflection = Brush.verticalGradient( - colors = listOf(Color.Transparent, color.copy(alpha = color.alpha * 0.3f)), - startY = size.height * 0.72f, - endY = size.height, - ) - onDrawWithContent { - drawContent() - drawRect( - brush = topGloss, - topLeft = androidx.compose.ui.geometry.Offset.Zero, - size = size, - ) - drawRect( - brush = bottomReflection, - topLeft = androidx.compose.ui.geometry.Offset.Zero, - size = size, - ) - } - } - -@BaseUIPhonePreview -@Composable -private fun HighlightModifierPreview() { - BaseUIPreview { - var highlighted by remember { mutableStateOf(true) } - - Row( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Box( - modifier = Modifier - .size(96.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.primaryContainer) - .then( - if (highlighted) { - Modifier.highlight(Color.White.copy(alpha = 0.45f)) - } else { - Modifier - }, - ) - .clickable { highlighted = !highlighted }, - ) - Box( - modifier = Modifier - .size(96.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.primaryContainer), - ) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt index 07131282..24b21b1a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSeparator import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,22 +33,19 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.resources.iconsax.FluentDismiss import dev.krtirtho.spotube.resources.iconsax.Iconsax -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AdaptiveDialogBottomSheet( onDismiss: () -> Unit, @@ -49,51 +53,37 @@ fun AdaptiveDialogBottomSheet( modifier: Modifier = Modifier, breakpointDp: Float = 600f, actions: @Composable (() -> Unit)? = null, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { val adaptiveInfo = currentWindowAdaptiveInfo() val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp if (isLargeScreen) { - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = if (title != null) { - { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Box(Modifier.weight(1f)) { title() } - IconButton(onClick = onDismiss) { - Icon( - Iconsax.FluentDismiss, - contentDescription = "Close dialog", - ) - } - } - } - } else { - null - }, - actions = actions, - modifier = modifier, - ) { + title = "", + modifier = modifier) { + title?.let { it() } content() + actions?.let { + MillenniumSeparator() + it() + } } } else { - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - modifier = modifier, - ) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet(state = sheetState) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(top = 16.dp), - horizontalAlignment = Alignment.Start, - ) { + horizontalAlignment = Alignment.Start) { title?.let { Row( modifier = Modifier.fillMaxWidth(), @@ -101,11 +91,12 @@ fun AdaptiveDialogBottomSheet( verticalAlignment = Alignment.CenterVertically ) { Box(Modifier.weight(1f)) { it() } - IconButton(onClick = onDismiss) { - Icon( + MillenniumIconButton( + onClick = onDismiss, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.FluentDismiss, - contentDescription = "Close dialog", - ) + contentDescription = "Close dialog") } } Spacer(Modifier.height(12.dp)) @@ -113,7 +104,7 @@ fun AdaptiveDialogBottomSheet( content() actions?.let { Spacer(Modifier.height(8.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + MillenniumSeparator() Spacer(Modifier.height(8.dp)) it() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt index 352f0282..acb5b3a4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDropdownBottomSheet.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -24,6 +31,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,16 +46,22 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.DropdownMenuPanel +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.UnstyledDropdownMenu +import com.composeunstyled.UnstyledDropdownMenuItem +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.gelBrush +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.text.input.rememberTextFieldState 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 @@ -64,10 +78,6 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import compose.icons.FeatherIcons import compose.icons.feathericons.Check -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.DropdownMenu -import dev.krtirtho.spotube.core.ui.base.DropdownMenuItem -import dev.krtirtho.spotube.core.ui.base.DropdownMenuDivider import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch @@ -83,8 +93,7 @@ data class AdaptiveMenuItem( val selected: Boolean = false, val dividerBefore: Boolean = false, val subtitle: String? = null, - val leadingContent: (@Composable () -> Unit)? = null, -) + val leadingContent: (@Composable () -> Unit)? = null) enum class HeaderDisplayMode { Always, @@ -101,69 +110,127 @@ fun AdaptiveDropdownBottomSheet( menuMinWidth: Dp = 200.dp, header: @Composable (() -> Unit)? = null, headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always, - filter: ((AdaptiveMenuItem, String) -> Boolean)? = null, -) { + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { var expanded by remember { mutableStateOf(false) } val adaptiveInfo = currentWindowAdaptiveInfo() val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp Box(modifier = modifier) { - trigger { expanded = true } - if (isLargeScreen) { - DropdownMenu( + UnstyledDropdownMenu( expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) { - header() - } - - var query by remember { mutableStateOf("") } - if (filter != null) { - TextField( - value = query, - onValueChange = { - query = it - }, - placeholder = { Text("Search...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - ) - } - - items.forEach { item -> - if (query.isNotBlank() && filter != null && !filter(item, query)) { - return@forEach + onExpandedChange = { expanded = it }, + panel = { + DropdownMenuPanel( + Modifier + .width(menuMinWidth) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = AppTheme.shapes.medium, + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp) + .padding(4.dp)) { + if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) { + header() } - if (item.dividerBefore) { - DropdownMenuDivider() + + val dropdownQueryState = rememberTextFieldState() + val query = dropdownQueryState.text.toString() + if (filter != null) { + MillenniumTextField( + state = dropdownQueryState, + label = "", + placeholder = "Search...", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp)) + } + + items.forEach { item -> + if (query.isNotBlank() && filter != null && !filter(item, query)) { + return@forEach + } + if (item.dividerBefore) { + MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) + } + val itemInteraction = remember { MutableInteractionSource() } + val itemHovered by itemInteraction.collectIsHoveredAsState() + val itemPressed by itemInteraction.collectIsPressedAsState() + val itemEnabled = item.enabled + val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected) + val itemForeground = when { + !itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f) + itemHighlighted -> MillenniumTheme.colors.onAccent + else -> AppTheme.colors.onSurface + } + UnstyledDropdownMenuItem( + onClick = { item.onClick() }, + enabled = itemEnabled, + indication = null, + interactionSource = itemInteraction) { + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (itemHighlighted) { + Modifier.millenniumSurface( + background = gelBrush( + MillenniumTheme.colors.accentTop, + MillenniumTheme.colors.accentBottom), + shape = RoundedCornerShape(8.dp)) + } else { + Modifier + }) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val itemIcon = item.icon + when { + item.leadingContent != null -> item.leadingContent.invoke() + itemIcon != null -> MillenniumIcon( + imageVector = itemIcon, + contentDescription = null, + tint = itemForeground, + modifier = Modifier.size(20.dp)) + + item.selected -> MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = "Selected", + tint = itemForeground, + modifier = Modifier.size(20.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.label, + style = AppTheme.typography.bodyMedium, + color = itemForeground) + item.subtitle?.let { subtitle -> + Text( + text = subtitle, + style = AppTheme.typography.bodySmall, + color = if (itemHighlighted) { + itemForeground.copy(alpha = 0.85f) + } else { + AppTheme.colors.onSurfaceVariant + }) + } + } + if (item.selected && (item.icon != null || item.leadingContent != null)) { + MillenniumIcon( + imageVector = FeatherIcons.Check, + contentDescription = "Selected", + tint = itemForeground, + modifier = Modifier.size(20.dp)) + } + } + } } - DropdownMenuItem( - text = item.label, - onClick = { - item.onClick() - expanded = false - }, - enabled = item.enabled, - selected = item.selected, - leadingIcon = item.icon, - leadingContent = item.leadingContent, - subtitle = item.subtitle, - ) } - } + }, + anchor = { trigger { expanded = true } }, + ) } else { + trigger { expanded = true } if (expanded) { AdaptiveBottomSheetContent( onDismiss = { expanded = false }, @@ -173,119 +240,97 @@ fun AdaptiveDropdownBottomSheet( } else { null }, - filter = filter, - ) + filter = filter) } } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AdaptiveBottomSheetContent( onDismiss: () -> Unit, items: List, header: @Composable (() -> Unit)?, - filter: ((AdaptiveMenuItem, String) -> Boolean)? = null, -) { + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { val hasSelection = items.any { it.selected } - var query by remember { mutableStateOf("") } + val queryState = rememberTextFieldState() + val query = queryState.text.toString() val filteredItems = if (filter != null && query.isNotBlank()) { items.filter { filter(it, query) } } else { items } - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet(state = sheetState) { Column( - modifier = Modifier.fillMaxWidth(), - ) { + modifier = Modifier.fillMaxWidth()) { header?.invoke() if (filter != null) { Spacer(modifier = Modifier.height(8.dp)) - TextField( - value = query, - onValueChange = { - query = it - }, - placeholder = { Text("Search...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, + MillenniumTextField( + state = queryState, + label = "", + placeholder = "Search...", modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - ) + .padding(horizontal = 12.dp, vertical = 8.dp)) } LazyColumn( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { items( - count = filteredItems.size, - ) { index -> + count = filteredItems.size) { index -> val item = filteredItems[index] if (item.dividerBefore) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) + MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) } Row( modifier = Modifier .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) + .clip(AppTheme.shapes.medium) .clickable( enabled = item.enabled, onClick = { item.onClick() onDismiss() - }, - ) + }) .background( color = if (item.selected) { - MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f) + AppTheme.colors.secondary.copy(alpha = 0.12f) } else { Color.Transparent }, - shape = MaterialTheme.shapes.medium, - ) + shape = AppTheme.shapes.medium) .padding(horizontal = 12.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { when { item.leadingContent != null -> item.leadingContent.invoke() - item.icon != null -> Icon( + item.icon != null -> MillenniumIcon( imageVector = item.icon, contentDescription = null, modifier = Modifier.size(22.dp), tint = if (item.enabled) { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) + AppTheme.colors.onSurface.copy(alpha = 0.38f) + }) - item.selected -> Icon( + item.selected -> MillenniumIcon( imageVector = FeatherIcons.Check, contentDescription = "Selected", modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) hasSelection -> Spacer(modifier = Modifier.size(22.dp)) } @@ -293,29 +338,26 @@ private fun AdaptiveBottomSheetContent( Column(modifier = Modifier.weight(1f)) { Text( text = item.label, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, color = if (item.enabled) { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) + AppTheme.colors.onSurface.copy(alpha = 0.38f) + }) item.subtitle?.let { subtitle -> Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } } if (item.selected && (item.icon != null || item.leadingContent != null)) { - Icon( + MillenniumIcon( imageVector = FeatherIcons.Check, contentDescription = "Selected", modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } } @@ -327,44 +369,37 @@ private fun AdaptiveBottomSheetContent( @Preview @Composable private fun AdaptiveDropdownBottomSheetPreview() { - BaseScaffold { + Box(modifier = Modifier.background(AppTheme.colors.background)) { AdaptiveDropdownBottomSheet( items = listOf( AdaptiveMenuItem( icon = Iconsax.IconsaxHeart, label = "Favorite", - onClick = {}, - ), + onClick = {}), AdaptiveMenuItem( icon = Iconsax.IconsaxShare, label = "Share", - onClick = {}, - ), + onClick = {}), AdaptiveMenuItem( icon = Iconsax.IconsaxTrash, label = "Delete", onClick = {}, - enabled = false, - ), - ), + enabled = false)), trigger = { onClick -> - IconButton( + MillenniumIconButton( onClick = onClick, modifier = Modifier .size(32.dp) .background( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), - shape = CircleShape, - ) + color = AppTheme.colors.primary.copy(alpha = 0.1f), + shape = CircleShape) .clip(CircleShape), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.Iconsax3DotsMore, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) + tint = AppTheme.colors.primary) } - }, - ) + }) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt index 769b8c43..23599f4e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt @@ -40,8 +40,7 @@ fun AlbumCard( audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), navigationCommands: NavigationCommands = koinInject(), - remotePlaybackController: RemotePlaybackController = koinInject(), -) { + remotePlaybackController: RemotePlaybackController = koinInject()) { val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() PlayableCard( @@ -57,17 +56,14 @@ fun AlbumCard( remotePlaybackController.requestCollectionPlay( RemoteCollectionType.Album, album.id, - album.title, - ) + album.title) }, onAddToQueue = { remotePlaybackController.requestCollectionAddToQueue( RemoteCollectionType.Album, album.id, - album.title, - ) + album.title) }, modifier = modifier.width(160.dp), - sharedElementKey = "album_art_${album.id}", - ) + sharedElementKey = "album_art_${album.id}") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt index 30cfa7b7..2b40616e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt @@ -17,9 +17,12 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.RowScope -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton + import androidx.compose.runtime.Composable import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 @@ -30,12 +33,12 @@ import org.koin.compose.koinInject fun ApplicationBackButton() { val navigationCommands: NavigationCommands = koinInject() - IconButton( + MillenniumIconButton( onClick = { navigationCommands.pop() }, - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.ArrowLeft3, contentDescription = "Back" ) @@ -48,5 +51,4 @@ expect fun ApplicationMainBar( subtitle: @Composable () -> Unit = {}, actions: @Composable (RowScope.() -> Unit) = {}, backButton: Boolean = true, - transparent: Boolean = false, -) \ No newline at end of file + transparent: Boolean = false) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt index 4cb5922e..541facd1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ArtistCard.kt @@ -29,9 +29,9 @@ 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.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -52,8 +52,7 @@ import org.koin.compose.koinInject fun ArtistCard( artist: MetadataArtist.Basic, modifier: Modifier = Modifier, - isBlacklisted: Boolean = false, -) { + isBlacklisted: Boolean = false) { val navigationCommands = koinInject() AvatarCard( @@ -64,7 +63,6 @@ fun ArtistCard( navigationCommands.navigateTo(Routes.Artist(artist.id)) }, modifier = modifier, - isBlacklisted = isBlacklisted, - ) + isBlacklisted = isBlacklisted) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt index 4b9ec5a9..2315074e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt @@ -17,13 +17,23 @@ package dev.krtirtho.spotube.core.ui.component + import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.height +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -31,9 +41,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,12 +52,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.ui.base.ButtonGroup -import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.GroupIconButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -62,7 +66,6 @@ import dev.krtirtho.spotube.resources.iconsax.User import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource - @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun CollectionDetails( @@ -85,8 +88,7 @@ fun CollectionDetails( onFollowClick: () -> Unit = { }, showFollowButton: Boolean = true, onEdit: (() -> Unit)? = null, - sharedElementKey: String? = null, -) { + sharedElementKey: String? = null) { BoxWithConstraints(modifier = modifier.fillMaxWidth()) { val isCompact = maxWidth < 600.dp @@ -98,95 +100,86 @@ fun CollectionDetails( if (onAddToJam != null) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - PrimaryButton( + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + MillenniumButton( modifier = if (isCompact) Modifier.weight(1f) else Modifier, - onClick = onAddToJam!!, - ) { - Icon( + onClick = onAddToJam!!) { + MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add to Jam", - ) - TextWithShimmer( - text = "Add to Jam", - modifier = Modifier.padding(start = 6.dp), - ) + contentDescription = "Add to Jam") + TextWithShimmer(text = "Add to Jam") } } } } else { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(6.dp)) { val modifier = if (isCompact) { Modifier.weight(1f) } else { Modifier } - PrimaryButton( + MillenniumButton( modifier = modifier, - onClick = onPlay, - ) { - Icon( + onClick = onPlay) { + MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, - contentDescription = if (isPlaying) "Pause" else "Play", - ) - TextWithShimmer( - text = if (isPlaying) "Pause" else "Play", - modifier = Modifier.padding(start = 6.dp), - ) + contentDescription = if (isPlaying) "Pause" else "Play") + TextWithShimmer(text = if (isPlaying) "Pause" else "Play") } - OutlineButton( + MillenniumButton( modifier = modifier, - onClick = onShufflePlay + onClick = onShufflePlay, + style = MillenniumButtonStyle.Secondary ) { - Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") - TextWithShimmer( - text = "Shuffle", - modifier = Modifier.padding(start = 6.dp), - ) + MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") + TextWithShimmer(text = "Shuffle") } } } } val actions = @Composable { - ButtonGroup { - GroupIconButton(onClick = onAddToQueue) { - Icon( + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIconButton( + onClick = onAddToQueue, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, contentDescription = "Add to queue" ) } - ButtonGroupDivider() + MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) if (showFollowButton) { if (isFollowing) { - GroupIconButton(onClick = onFollowClick) { - Icon( + MillenniumIconButton( + onClick = onFollowClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxHeart2, - contentDescription = "Unfollow", - ) + contentDescription = "Unfollow") } } else { - GroupIconButton(onClick = onFollowClick) { - Icon( + MillenniumIconButton( + onClick = onFollowClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxHeart, contentDescription = "Follow", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } } if (onEdit != null) { - ButtonGroupDivider() - GroupIconButton(onClick = onEdit) { - Icon( + MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) + MillenniumIconButton( + onClick = onEdit, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxEdit, - contentDescription = "Edit playlist", - ) + contentDescription = "Edit playlist") } } } @@ -195,8 +188,7 @@ fun CollectionDetails( Row( modifier = Modifier.clickable(onClick = onOwnerClick), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(10.dp)) { if (ownerImageURL != null) { AsyncImage( model = ownerImageURL, @@ -205,27 +197,24 @@ fun CollectionDetails( .size(16.dp) .clip(CircleShape) .shimmerApply(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { - Icon( + MillenniumIcon( imageVector = Iconsax.User, contentDescription = ownerName, modifier = Modifier .size(16.dp) .background( - MaterialTheme.colorScheme.primaryContainer, + AppTheme.colors.primaryContainer, CircleShape ) .padding(6.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) + tint = AppTheme.colors.onPrimaryContainer) } TextWithShimmer( text = "By $ownerName", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } } @@ -237,79 +226,68 @@ fun CollectionDetails( .sharedElementOrNone( key = sharedElementKey, sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ) + animatedVisibilityScope = animatedVisibilityScope) .shimmerApply(), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { if (imageURL.isNotBlank()) { AsyncImage( model = imageURL, contentDescription = title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else if (imageResource != null) { androidx.compose.foundation.Image( painter = painterResource(imageResource), contentDescription = title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = title.take(1).ifBlank { "?" }.uppercase(), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.displaySmall, + color = AppTheme.colors.onSurfaceVariant) } } } - val metadata = @Composable { Column { TextWithShimmer( text = title, - style = if (isCompact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.headlineLarge, + style = if (isCompact) AppTheme.typography.bodyLarge else AppTheme.typography.headlineLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) if (description.isNotBlank()) { TextWithShimmer( text = description, - style = if (isCompact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = if (isCompact) AppTheme.typography.labelSmall else AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } - Card( + MillenniumCard( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), - ) { + contentPadding = PaddingValues(0.dp)) { if (isCompact) { Column( modifier = Modifier .fillMaxWidth() .padding(14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { artwork() Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { + verticalArrangement = Arrangement.spacedBy(6.dp)) { metadata() ownerInfo() actions() @@ -323,14 +301,12 @@ fun CollectionDetails( .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { artwork() Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { metadata() ownerInfo() actions() @@ -354,6 +330,5 @@ fun CollectionDetailsPreview() { onOwnerClick = {}, onPlay = {}, onShufflePlay = {}, - onAddToQueue = {}, - ) + onAddToQueue = {}) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index 614999e6..5434ce34 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -17,18 +17,22 @@ package dev.krtirtho.spotube.core.ui.component + import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -93,8 +97,7 @@ fun CollectionView( onAddToJam: (List) -> Unit = {}, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, footerContent: (@Composable () -> Unit)? = null, - trailingContent: @Composable () -> Unit = {}, -) { + trailingContent: @Composable () -> Unit = {}) { val listState = rememberLazyListState() val isCollapsed by remember { derivedStateOf { @@ -103,45 +106,42 @@ fun CollectionView( } } - BaseScaffold( - topBar = { - ApplicationMainBar( - backButton = true, - title = if (isCollapsed) { - { - CollapsedCollectionTitle( - title = title, - imageURL = imageURL, - imageResource = imageResource, - ) + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = true, + title = if (isCollapsed) { + { + CollapsedCollectionTitle( + title = title, + imageURL = imageURL, + imageResource = imageResource) + } + } else { + {} + }, + actions = if (isCollapsed && !isJamGuest) { + { + MillenniumIconButton( + onClick = onPlay, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, + contentDescription = if (isPlaying) "Pause" else "Play") } - } else { - {} - }, - actions = if (isCollapsed && !isJamGuest) { - { - IconButton(onClick = onPlay) { - Icon( - imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, - contentDescription = if (isPlaying) "Pause" else "Play", - ) - } - } - } else { - {} - }, - ) - } - ) { innerPadding -> + } + } else { + {} + }) + + Box(modifier = Modifier.weight(1f).fillMaxSize()) { if (error != null && tracks.isEmpty() && !isLoading) { ErrorDisplay( errorMessage = error, onRetry = onRetry ?: {}, - modifier = Modifier.padding(innerPadding), - ) + modifier = Modifier.fillMaxSize()) } else { TrackList( - modifier = Modifier.padding(innerPadding), + modifier = Modifier.fillMaxSize(), state = listState, headerContent = { if (isLoading && tracks.isEmpty()) { @@ -164,8 +164,7 @@ fun CollectionView( onEdit = onEdit, sharedElementKey = sharedElementKey, isJamGuest = isJamGuest, - onAddToJam = { onAddToJam(tracks) }, - ) + onAddToJam = { onAddToJam(tracks) }) } } else { CollectionDetails( @@ -186,8 +185,7 @@ fun CollectionView( onEdit = onEdit, sharedElementKey = sharedElementKey, isJamGuest = isJamGuest, - onAddToJam = { onAddToJam(tracks) }, - ) + onAddToJam = { onAddToJam(tracks) }) } }, footerContent = footerContent, @@ -211,11 +209,11 @@ fun CollectionView( onBulkAddToJam = onBulkAddToJam, isInJam = isInJam, isJamGuest = isJamGuest, - trackOptionsState = trackOptionsState, - ) + trackOptionsState = trackOptionsState) } trailingContent() + } } } @@ -223,46 +221,39 @@ fun CollectionView( private fun CollapsedCollectionTitle( title: String, imageURL: String, - imageResource: DrawableResource? = null, -) { + imageResource: DrawableResource? = null) { Row( - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier .size(36.dp) .clip(RoundedCornerShape(6.dp)) .shimmerApply(), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { if (imageURL.isNotBlank()) { AsyncImage( model = imageURL, contentDescription = title, modifier = Modifier.size(36.dp), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else if (imageResource != null) { androidx.compose.foundation.Image( painter = painterResource(imageResource), contentDescription = title, modifier = Modifier.size(36.dp), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = title.take(1).ifBlank { "?" }.uppercase(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) } } TextWithShimmer( text = title, - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 10.dp), - ) + modifier = Modifier.padding(start = 10.dp)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt index 7038d5b4..be7fed6b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt @@ -17,30 +17,33 @@ package dev.krtirtho.spotube.core.ui.component + import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -67,8 +70,7 @@ import compose.icons.feathericons.RefreshCw fun ErrorDisplay( errorMessage: String, onRetry: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { var showFullLog by remember { mutableStateOf(false) } val clipboardManager = LocalClipboardManager.current @@ -77,117 +79,99 @@ fun ErrorDisplay( .fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - Icon( + verticalArrangement = Arrangement.spacedBy(20.dp)) { + MillenniumIcon( imageVector = FeatherIcons.AlertCircle, contentDescription = null, modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), - ) + tint = AppTheme.colors.error.copy(alpha = 0.7f)) Text( text = "We're Truly Sorry", - style = MaterialTheme.typography.headlineSmall, + style = AppTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - ) + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Center) Text( text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center, - lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2, - ) + lineHeight = AppTheme.typography.bodyMedium.lineHeight * 1.2) - Card( + MillenniumCard( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f), - ), - shape = MaterialTheme.shapes.medium, - ) { + contentPadding = PaddingValues(0.dp)) { Column( - modifier = Modifier.fillMaxWidth(), - ) { + modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Text( text = "Error Details", - style = MaterialTheme.typography.labelMedium, + style = AppTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + color = AppTheme.colors.onSurfaceVariant) Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - IconButton( + horizontalArrangement = Arrangement.spacedBy(4.dp)) { + MillenniumIconButton( onClick = { clipboardManager.setText(AnnotatedString(errorMessage)) }, modifier = Modifier.size(32.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = FeatherIcons.Copy, contentDescription = "Copy to clipboard", - modifier = Modifier.size(16.dp), - ) + modifier = Modifier.size(16.dp)) } - IconButton( + MillenniumIconButton( onClick = { showFullLog = true }, modifier = Modifier.size(32.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = FeatherIcons.Maximize2, contentDescription = "View full log", - modifier = Modifier.size(16.dp), - ) + modifier = Modifier.size(16.dp)) } } } - Surface( + MillenniumCard( modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f), - ) { + contentPadding = PaddingValues(0.dp)) { SelectionContainer { Text( text = errorMessage, - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace), + color = AppTheme.colors.onSurfaceVariant, maxLines = 5, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(12.dp), - ) + modifier = Modifier.padding(12.dp)) } } } } Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Button( + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumButton( onClick = onRetry, - modifier = Modifier.height(40.dp), - ) { - Icon( - imageVector = FeatherIcons.RefreshCw, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp).size(18.dp), - ) - Text("Retry") + modifier = Modifier.height(40.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( + imageVector = FeatherIcons.RefreshCw, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp).size(18.dp)) + Text("Retry") + } } } } @@ -195,93 +179,85 @@ fun ErrorDisplay( if (showFullLog) { ErrorLogDialog( errorMessage = errorMessage, - onDismiss = { showFullLog = false }, - ) + onDismiss = { showFullLog = false }) } } @Composable private fun ErrorLogDialog( errorMessage: String, - onDismiss: () -> Unit, -) { + onDismiss: () -> Unit) { val clipboardManager = LocalClipboardManager.current - AlertDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Full Error Log", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) + title = "") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Full Error Log", + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.Bold) - IconButton( - onClick = { - clipboardManager.setText(AnnotatedString(errorMessage)) - }, - ) { - Icon( - imageVector = FeatherIcons.Copy, - contentDescription = "Copy to clipboard", - ) - } + MillenniumIconButton( + onClick = { + clipboardManager.setText(AnnotatedString(errorMessage)) + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = FeatherIcons.Copy, + contentDescription = "Copy to clipboard") } - }, - text = { - Box( - modifier = Modifier - .fillMaxWidth() - .height(400.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f)), - ) { - SelectionContainer { - Text( - text = errorMessage, - style = MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(12.dp), - ) - } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) { + SelectionContainer { + Text( + text = errorMessage, + style = AppTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace), + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(12.dp)) } - }, - confirmButton = { - TextButton(onClick = onDismiss) { + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Quiet) { Text("Close") } - }, - ) + } + } } @Preview @Composable private fun ErrorDisplayPreview() { - Surface { + Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { ErrorDisplay( errorMessage = "This is an error message", - onRetry = {}, - ) + onRetry = {}) } } @Preview @Composable private fun ErrorLogDialogPreview() { - BaseScaffold { + Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { ErrorLogDialog( errorMessage = "This is an error message", - onDismiss = {}, - ) + onDismiss = {}) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt index 810c69e4..75f09d3a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/FeaturedCarousel.kt @@ -17,6 +17,10 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -34,11 +38,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -83,8 +86,7 @@ import org.koin.compose.koinInject @Composable fun FeaturedCarousel( items: List, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val filteredItems = remember(items) { items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track } } @@ -92,15 +94,13 @@ fun FeaturedCarousel( if (filteredItems.isEmpty()) return FeaturedCarouselContent( - items = filteredItems, - ) + items = filteredItems) } @Composable private fun FeaturedCarouselContent( items: List, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val centerIndex = items.size / 2 var selectedIndex by remember { mutableIntStateOf(centerIndex) } val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index } @@ -113,8 +113,7 @@ private fun FeaturedCarouselContent( .height( if (isSmallScreen) 200.dp else 300.dp ), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Coverflow( state = state, params = CoverflowParams( @@ -123,21 +122,17 @@ private fun FeaturedCarouselContent( angle = 12f, shift = 0.255f, zoom = 0.7864f, - mirror = false, - ), - modifier = modifier, - ) { + mirror = false), + modifier = modifier) { items( items = items, - key = { index: Int -> items[index].hashCode() }, - ) { item: MetadataBrowseItem -> + key = { index: Int -> items[index].hashCode() }) { item: MetadataBrowseItem -> FeaturedCarouselCard( item = item, isCentered = selectedIndex == items.indexOf(item), onScrollToCenter = { state.scrollToItem(items.indexOf(item)) - }, - ) + }) } } } @@ -148,8 +143,7 @@ private fun FeaturedCarouselCard( item: MetadataBrowseItem, isCentered: Boolean, modifier: Modifier = Modifier, - onScrollToCenter: () -> Unit = { }, -) { + onScrollToCenter: () -> Unit = { }) { val navigationCommands = koinInject() val audioPlayerQueue = koinInject() @@ -204,25 +198,21 @@ private fun FeaturedCarouselCard( } } .aspectRatio(1f) - .hazeSource(hazeState), - ) { + .hazeSource(hazeState)) { AsyncImage( model = thumbnailUrl, contentDescription = title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = dimAlpha)), - ) + .background(Color.Black.copy(alpha = dimAlpha))) Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { + contentAlignment = Alignment.BottomCenter) { Box( modifier = Modifier .fillMaxWidth() @@ -231,43 +221,35 @@ private fun FeaturedCarouselCard( brush = Brush.verticalGradient( colors = listOf( Color.Transparent, - Color.Black.copy(alpha = 0.9f), - ), - ), - ), - ) + Color.Black.copy(alpha = 0.9f))))) } } Box( modifier = Modifier .fillMaxSize(), - contentAlignment = Alignment.BottomCenter, - ) { + contentAlignment = Alignment.BottomCenter) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() - .padding(16.dp), - ) { + .padding(16.dp)) { Column(modifier = Modifier.weight(1f)) { Text( text = title, - style = MaterialTheme.typography.titleMedium, + style = AppTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.height(4.dp)) Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, + style = AppTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.85f), maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } val animatedAlpha by animateFloatAsState( @@ -306,8 +288,7 @@ private fun FeaturedPlayButton( navigationCommands: NavigationCommands, playbackHelper: CollectionPlaybackHelper = koinInject(), enabled: Boolean, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() @@ -320,7 +301,8 @@ private fun FeaturedPlayButton( modifier = modifier.size(44.dp), contentAlignment = Alignment.BottomEnd ) { - IconButton( + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = { if (isPlaying) { navigationCommands.navigateTo(Routes.Album(item.data.id)) @@ -330,7 +312,7 @@ private fun FeaturedPlayButton( }, enabled = enabled ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = null, tint = Color.White @@ -347,7 +329,8 @@ private fun FeaturedPlayButton( modifier = modifier.size(44.dp), contentAlignment = Alignment.BottomEnd ) { - IconButton( + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = { if (isPlaying) { navigationCommands.navigateTo(Routes.Playlist(item.data.id)) @@ -357,7 +340,7 @@ private fun FeaturedPlayButton( }, enabled = enabled ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = null, tint = Color.White @@ -377,20 +360,20 @@ private fun FeaturedPlayButton( modifier = modifier.size(44.dp), contentAlignment = Alignment.BottomEnd ) { - IconButton( + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = { scope.launch { audioPlayerQueue.load( entries = listOf(queueEntry), autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } }, enabled = enabled ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = null, tint = Color.White diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt index eca9b2e2..ac3444e8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LikedTracksCard.kt @@ -17,6 +17,10 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.fadeIn @@ -35,11 +39,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -76,8 +78,7 @@ fun LikedTracksCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject(), -) { + navigationCommands: NavigationCommands = koinInject()) { val scope = rememberCoroutineScope() val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() @@ -92,8 +93,7 @@ fun LikedTracksCard( .clip(RoundedCornerShape(8.dp)) .clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }) .width(160.dp) - .hoverable(interactionSource = interactionSource), - ) { + .hoverable(interactionSource = interactionSource)) { Column(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) { androidx.compose.foundation.Image( @@ -106,10 +106,8 @@ fun LikedTracksCard( .sharedElementOrNone( key = "saved_tracks_art", sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ), - contentScale = ContentScale.Crop, - ) + animatedVisibilityScope = animatedVisibilityScope), + contentScale = ContentScale.Crop) Box( modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), @@ -120,22 +118,24 @@ fun LikedTracksCard( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(4.dp) ) { - FilledTonalIconButton( + MillenniumIconButton( onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, - modifier = Modifier.size(30.dp) + modifier = Modifier.size(30.dp), + style = MillenniumIconButtonStyle.Secondary ) { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, contentDescription = "Add to queue", modifier = Modifier.size(16.dp) ) } Spacer(modifier = Modifier.height(8.dp)) - FilledIconButton( + MillenniumIconButton( onClick = { scope.launch { playbackHelper.playSavedTracks() } }, - modifier = Modifier.size(30.dp) + modifier = Modifier.size(30.dp), + style = MillenniumIconButtonStyle.Primary ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) { Iconsax.IconsaxPauseCircle } else Iconsax.Play, @@ -152,18 +152,16 @@ fun LikedTracksCard( ) { Text( text = "Liked Tracks", - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.height(4.dp)) Text( text = "Your saved songs", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt index cf83ec60..ffacbb4d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt @@ -40,8 +40,7 @@ fun PlaylistCard( audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), navigationCommands: NavigationCommands = koinInject(), - remotePlaybackController: RemotePlaybackController = koinInject(), -) { + remotePlaybackController: RemotePlaybackController = koinInject()) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() @@ -58,17 +57,14 @@ fun PlaylistCard( remotePlaybackController.requestCollectionPlay( RemoteCollectionType.Playlist, playlist.id, - playlist.title, - ) + playlist.title) }, onAddToQueue = { remotePlaybackController.requestCollectionAddToQueue( RemoteCollectionType.Playlist, playlist.id, - playlist.title, - ) + playlist.title) }, modifier = modifier, - sharedElementKey = "playlist_art_${playlist.id}", - ) + sharedElementKey = "playlist_art_${playlist.id}") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt index 64c05db0..60de716c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/SharedTransitionLocals.kt @@ -35,13 +35,11 @@ val LocalAnimatedVisibilityScope = compositionLocalOf fun Modifier.sharedElementOrNone( key: String?, sharedTransitionScope: SharedTransitionScope?, - animatedVisibilityScope: AnimatedVisibilityScope?, -): Modifier { + animatedVisibilityScope: AnimatedVisibilityScope?): Modifier { if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this with(sharedTransitionScope) { return this@sharedElementOrNone.sharedElement( sharedContentState = rememberSharedContentState(key = key), - animatedVisibilityScope = animatedVisibilityScope, - ) + animatedVisibilityScope = animatedVisibilityScope) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt index aae13619..755dc1bd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/Sidebar.kt @@ -17,33 +17,33 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import com.composeunstyled.LocalTextStyle +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.copyPadding -import dev.krtirtho.spotube.core.ui.base.copyShape /** * The shared navigation item used by every sidebar in the app (the main app sidebar @@ -60,64 +60,57 @@ fun SidebarItem( onClick: () -> Unit, modifier: Modifier = Modifier, expanded: Boolean = true, - badge: (@Composable BoxScope.() -> Unit)? = null, -) { - val itemContent: @Composable RowScope.() -> Unit = { - Box(modifier = Modifier.size(16.dp)) { - Icon( - imageVector = icon, - contentDescription = label, - tint = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - badge?.invoke(this) - } - AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { - Text( - text = label, - maxLines = 1, - softWrap = false, - style = LocalTextStyle.current.copy( - fontSize = MaterialTheme.typography.labelMedium.fontSize, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, - color = if (selected) { - MaterialTheme.colorScheme.onSecondaryContainer + badge: (@Composable BoxScope.() -> Unit)? = null) { + val itemContent: @Composable () -> Unit = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(16.dp)) { + MillenniumIcon( + imageVector = icon, + contentDescription = label, + tint = if (selected) { + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant - } + AppTheme.colors.onSurfaceVariant + }) + badge?.invoke(this) + } + AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { + Text( + text = label, + singleLine = true, + style = LocalTextStyle.current.copy( + fontSize = AppTheme.typography.labelMedium.fontSize, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + color = if (selected) { + AppTheme.colors.onSecondaryContainer + } else { + AppTheme.colors.onSurfaceVariant + } + ) ) - ) + } + Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier) } - Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier) } val buttonModifier = modifier .fillMaxWidth() .padding(horizontal = 7.dp, vertical = 2.dp) - val contentPadding = PaddingValues(horizontal = 5.dp) if (selected) { - SecondaryButton( + MillenniumButton( onClick = onClick, modifier = buttonModifier, - theme = LocalBaseUITheme.current.buttons.secondary - .copyPadding(contentPadding) - .copyShape(RoundedCornerShape(5.dp)), - content = itemContent, - ) + style = MillenniumButtonStyle.Secondary, + content = itemContent) } else { - OutlineButton( + MillenniumButton( onClick = onClick, modifier = buttonModifier, - theme = LocalBaseUITheme.current.buttons.outline - .copyPadding(contentPadding) - .copyShape(RoundedCornerShape(5.dp)), - hoverOnly = true, - content = itemContent, - ) + style = MillenniumButtonStyle.Quiet, + content = itemContent) } } @@ -127,16 +120,14 @@ fun SidebarItem( @Composable fun SidebarSectionLabel( text: String, - expanded: Boolean = true, -) { + expanded: Boolean = true) { AnimatedVisibility(visible = expanded) { Text( text = text.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp), - ) + .padding(horizontal = 24.dp, vertical = 8.dp)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt index 4d81fd73..57cf1f4e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackCard.kt @@ -26,12 +26,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.Card -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -54,8 +51,7 @@ import org.koin.compose.koinInject fun TrackCard( track: MetadataTrack, modifier: Modifier = Modifier, - audioPlayerQueue: AudioPlayerQueue = koinInject(), -) { + audioPlayerQueue: AudioPlayerQueue = koinInject()) { val scope = rememberCoroutineScope() val queueEntry = QueueEntry.StreamingTrack(track = track, url = "") @@ -69,8 +65,7 @@ fun TrackCard( entries = listOf(queueEntry), autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } }, onAddToQueue = { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index c092a364..7fbd1f4b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.core.ui.component + import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -36,6 +37,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row 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.size @@ -44,12 +46,20 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.ui.state.ToggleableState +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumCheckbox +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.composables.MillenniumTriStateCheckbox + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -83,16 +93,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumTy import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.ButtonGroup -import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.CheckBox -import dev.krtirtho.spotube.core.ui.base.CheckBoxState -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.GroupIconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.highlight import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.shimmerApply @@ -122,7 +122,7 @@ enum class TrackSortOption(val label: String) { Duration("Duration"), } -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun TrackList( tracks: List = emptyList(), @@ -153,9 +153,9 @@ fun TrackList( contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current), simplified: Boolean = false, scrollable: Boolean = true, - state: LazyListState? = null, -) { - var filterQuery by rememberSaveable { mutableStateOf("") } + state: LazyListState? = null) { + val filterState = rememberTextFieldState() + val filterQuery = filterState.text.toString() var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } var selectedTrackForOptions by remember { mutableStateOf(null) } var isSelectionMode by rememberSaveable { mutableStateOf(false) } @@ -216,11 +216,11 @@ fun TrackList( val useDropdownForOptions = isDesktop && !isCompact val trackCardContent: @Composable () -> Unit = { - Card( + MillenniumCard( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), - ) { + contentPadding = PaddingValues(0.dp)) { Column( modifier = Modifier .fillMaxWidth() @@ -228,9 +228,7 @@ fun TrackList( ) { visibleTracks.forEachIndexed { displayedIndex, track -> if (displayedIndex > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) + MillenniumSeparator() } TrackListRow( index = displayedIndex + 1, @@ -284,38 +282,31 @@ fun TrackList( onShowOptionsClick = { selectedTrackForOptions = track }, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, - onArtistsOverflowClick = { onArtistsOverflowClick(track) }, - ) + onArtistsOverflowClick = { onArtistsOverflowClick(track) }) } if (isLoading && tracks.isEmpty()) { repeat(ShimmerRowCount) { shimmerIndex -> if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) } ShimmerTrackListRow( index = shimmerIndex + 1, showIndex = showIndex, showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - ) + useDropdownForOptions = useDropdownForOptions) } } if (error != null) { if (visibleTracks.isNotEmpty()) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) } TrackListFeedbackRow( message = error, - isError = true, - ) + isError = true) } if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { @@ -324,17 +315,14 @@ fun TrackList( if (isLoadingNextPage) { if (visibleTracks.isNotEmpty()) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) + MillenniumSeparator( + modifier = Modifier.padding(horizontal = 8.dp)) } ShimmerTrackListRow( index = visibleTracks.size + 1, showIndex = showIndex, showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - ) + useDropdownForOptions = useDropdownForOptions) } } } @@ -345,19 +333,18 @@ fun TrackList( modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) .heightIn(max = 60.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End), - ) { + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End)) { if (showIndex || isSelectionMode) { val allSelected = visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size val anySelected = selectedTrackIds.isNotEmpty() val headerState = when { - allSelected -> CheckBoxState.SELECTED - anySelected -> CheckBoxState.INDETERMINATE - else -> CheckBoxState.UNSELECTED + allSelected -> ToggleableState.On + anySelected -> ToggleableState.Indeterminate + else -> ToggleableState.Off } - CheckBox( - state = headerState, + MillenniumTriStateCheckbox( + value = headerState, onClick = { if (allSelected) { isSelectionMode = false @@ -367,58 +354,45 @@ fun TrackList( selectedTrackIds = visibleTracks.map { it.id }.toSet() } }, - ) + label = "") } Box( modifier = Modifier .weight(1f) ) { - TextField( - value = filterQuery, - onValueChange = { filterQuery = it }, + MillenniumTextField( + state = filterState, + label = "", + placeholder = "Filter", modifier = Modifier .widthIn(max = 400.dp) - .align(Alignment.CenterEnd), - placeholder = { TextWithShimmer("Filter") }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Filter", - ) - }, - singleLine = true, - ) + .align(Alignment.CenterEnd)) } - ButtonGroup { + Row(verticalAlignment = Alignment.CenterVertically) { Box( - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { AdaptiveDropdownBottomSheet( items = TrackSortOption.entries.map { option -> AdaptiveMenuItem( label = option.label, onClick = { sortBy = option }, - selected = sortBy == option, - ) + selected = sortBy == option) }, trigger = { onClick -> - GroupIconButton( + MillenniumIconButton( onClick = onClick, - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxSort, - contentDescription = "Sort", - ) + contentDescription = "Sort") } - }, - ) + }) } - ButtonGroupDivider() + MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true) Box( - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { val targetTracks = if (selectedTrackIds.isNotEmpty()) { visibleTracks.filter { selectedTrackIds.contains(it.id) } } else { @@ -433,54 +407,42 @@ fun TrackList( AdaptiveMenuItem( icon = Iconsax.IconsaxDirectboxReceive, label = if (isAll) "Download All" else "Download $trackCount", - onClick = { onBulkDownload(targetTracks) }, - ), - ) + onClick = { onBulkDownload(targetTracks) })) if (!isJamGuest) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", - onClick = { onBulkAddToQueue(targetTracks) }, - ), - ) + onClick = { onBulkAddToQueue(targetTracks) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxNext, label = if (isAll) "Play All Next" else "Play $trackCount Next", - onClick = { onBulkPlayNext(targetTracks) }, - ), - ) + onClick = { onBulkPlayNext(targetTracks) })) } add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicPlaylist, label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", - onClick = { onBulkAddToPlaylist(targetTracks) }, - ), - ) + onClick = { onBulkAddToPlaylist(targetTracks) })) } + if (isInJam) { listOf( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam", - onClick = { onBulkAddToJam(targetTracks) }, - ), - ) + onClick = { onBulkAddToJam(targetTracks) })) } else { emptyList() }, trigger = { onClick -> - GroupIconButton( + MillenniumIconButton( onClick = onClick, - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = "Bulk actions", - ) + contentDescription = "Bulk actions") } - }, - ) + }) } } } @@ -495,8 +457,7 @@ fun TrackList( .align(Alignment.TopCenter) .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { if (headerContent != null) { item { headerContent() @@ -525,8 +486,7 @@ fun TrackList( }, onAlbumClick = { track.album?.let { onAlbumClick(it) } }, isInJam = isInJam, - isJamGuest = isJamGuest, - ) + isJamGuest = isJamGuest) } } VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd)) @@ -536,8 +496,7 @@ fun TrackList( modifier = modifier .fillMaxWidth() .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { if (headerContent != null) { headerContent() } @@ -560,8 +519,7 @@ fun TrackList( onTrackOptionsAction(track, action) selectedTrackForOptions = null }, - onAlbumClick = { track.album?.let { onAlbumClick(it) } }, - ) + onAlbumClick = { track.album?.let { onAlbumClick(it) } }) } } } @@ -590,235 +548,198 @@ private fun TrackListRow( onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val rowTheme = LocalBaseUITheme.current.listRowTile - val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f + modifier: Modifier = Modifier) { val artworkInteractionSource = remember { MutableInteractionSource() } val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() val rowInteractionSource = remember { MutableInteractionSource() } val isRowHovered by rowInteractionSource.collectIsHoveredAsState() val rowBackgroundColor = when { - isSelected -> rowTheme.background.selected - isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered - isCurrentTrack -> rowTheme.background.focused - trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f)) - else -> rowTheme.background.normal + isSelected -> AppTheme.colors.secondary.copy(alpha = 0.12f) + isCurrentTrack && isCurrentTrackPlaying -> AppTheme.colors.primaryContainer + isCurrentTrack -> AppTheme.colors.surfaceVariant + trackOptionsState.isBlacklisted -> AppTheme.colors.error.copy(alpha = 0.1f) + else -> Color.Transparent } - val highlightColor = Color.White.copy( - alpha = if (isLight) 0.9f else 0.06f - ) - Row( - modifier = modifier - .fillMaxWidth() - .hoverable(rowInteractionSource) - .combinedClickable( - onClick = onTrackClick, - onLongClick = onLongClick, - ) - .background(rowBackgroundColor) - .then( - if (isRowHovered || isSelected || (isCurrentTrack && isCurrentTrackPlaying)) { - Modifier.highlight(highlightColor) - } else { - Modifier - } - ) - .clip(MaterialTheme.shapes.small) - .padding(horizontal = 8.dp, vertical = 6.dp) - .padding(end = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (isSelectionMode) { - CheckBox( - state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, - onClick = { onSelectionToggle(!isSelected) }, - modifier = Modifier.shimmerApply() - ) - } else if (showIndex) { - Box( - modifier = Modifier.width(30.dp), - contentAlignment = Alignment.Center, - ) { - AnimatedContent( - targetState = isRowHovered, - transitionSpec = { - fadeIn() togetherWith fadeOut() - }, - label = "index-checkbox", - ) { hovered -> - if (hovered) { - CheckBox( - state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, - onClick = { onSelectionToggle(!isSelected) }, - modifier = Modifier.shimmerApply() - ) - } else { - TextWithShimmer( - text = index.toString(), - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - - Box( - modifier = Modifier - .size(44.dp) - .clip(MaterialTheme.shapes.small) - .hoverable(interactionSource = artworkInteractionSource), - contentAlignment = Alignment.Center, - ) { - - val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url - val platformContext = LocalPlatformContext.current - val imageRequest = remember(imageUrl) { - ImageRequest.Builder(platformContext) - .data(imageUrl) - .size(128) // 44.dp * ~3x density = ~132px. 128 is a perfect power-of-2 size. - .crossfade(false) // Crucial for scroll performance - .build() - } - - AsyncImage( - model = imageRequest, - contentDescription = track.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().shimmerApply(), - ) - - if (isArtworkHovered || isCurrentTrack) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.10f)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play", - tint = Color.White, - modifier = Modifier.size(18.dp).shimmerApply(), - ) - } - } - } - - Column(modifier = Modifier.weight(1.2f)) { + MillenniumListTile( + modifier = modifier, + selected = isSelected || isCurrentTrack, + onClick = onTrackClick, + title = { TextWithShimmer( text = track.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) + }, + subtitle = { ArtistChips( artists = track.artists, onArtistClick = onArtistClick, - onArtistsOverflowClick = onArtistsOverflowClick, - ) - } - - if (showAlbum && track.album != null) { - val interactionSource = remember { MutableInteractionSource() } - val isHovered by interactionSource.collectIsHoveredAsState() - val isPressed by interactionSource.collectIsPressedAsState() - val colorScheme = MaterialTheme.colorScheme - + onArtistsOverflowClick = onArtistsOverflowClick) + }, + leading = { Row( - modifier = Modifier.weight(1f) - ) { - TextWithShimmer( - text = track.album!!.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .hoverable(interactionSource) - .clickable( - indication = null, - interactionSource = interactionSource - ) { onAlbumClick(track.album!!) } - // Hide ripple and show underline on hover/click - .then( - if (isHovered || isPressed) { - Modifier.drawWithCache { - val underlineHeight = 1.dp.toPx() - onDrawWithContent { - drawContent() - drawLine( - color = colorScheme.primary, - start = Offset(0f, size.height - underlineHeight), - end = Offset(size.width, size.height - underlineHeight), - strokeWidth = underlineHeight, - ) - } - } + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (isSelectionMode) { + MillenniumCheckbox( + checked = isSelected, + onCheckedChange = { onSelectionToggle(it) }, + label = "", + modifier = Modifier.shimmerApply()) + } else if (showIndex) { + Box( + modifier = Modifier.width(30.dp), + contentAlignment = Alignment.Center) { + AnimatedContent( + targetState = isRowHovered, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "index-checkbox") { hovered -> + if (hovered) { + MillenniumCheckbox( + checked = isSelected, + onCheckedChange = { onSelectionToggle(it) }, + label = "", + modifier = Modifier.shimmerApply()) } else { - Modifier + TextWithShimmer( + text = index.toString(), + style = AppTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = AppTheme.colors.onSurfaceVariant) } - ) - ) - } - } + } + } + } - TextWithShimmer( - text = track.durationMs.toDurationString(), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.End, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(56.dp), - ) + Box( + modifier = Modifier + .size(44.dp) + .clip(AppTheme.shapes.small) + .hoverable(interactionSource = artworkInteractionSource), + contentAlignment = Alignment.Center) { + val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url + val platformContext = LocalPlatformContext.current + val imageRequest = remember(imageUrl) { + ImageRequest.Builder(platformContext) + .data(imageUrl) + .size(128) + .crossfade(false) + .build() + } - if (useDropdownForOptions) { - TrackOptions( - track = track, - state = trackOptionsState, - onAction = onTrackOptionsAction, - onAlbumClick = { track.album?.let { onAlbumClick(it) } }, - isInJam = isInJam, - isJamGuest = isJamGuest, - ) - } else { - GhostIconButton(onClick = onShowOptionsClick) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = "Track options", - ) + AsyncImage( + model = imageRequest, + contentDescription = track.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().shimmerApply()) + + if (isArtworkHovered || isCurrentTrack) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.10f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play", + tint = Color.White, + modifier = Modifier.size(18.dp).shimmerApply()) + } + } + } } - } - } + }, + trailing = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (showAlbum && track.album != null) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + val isPressed by interactionSource.collectIsPressedAsState() + val colorScheme = AppTheme.colors + + TextWithShimmer( + text = track.album!!.title, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .widthIn(max = 220.dp) + .hoverable(interactionSource) + .clickable( + indication = null, + interactionSource = interactionSource) { onAlbumClick(track.album!!) } + .then( + if (isHovered || isPressed) { + Modifier.drawWithCache { + val underlineHeight = 1.dp.toPx() + onDrawWithContent { + drawContent() + drawLine( + color = colorScheme.primary, + start = Offset(0f, size.height - underlineHeight), + end = Offset(size.width, size.height - underlineHeight), + strokeWidth = underlineHeight) + } + } + } else { + Modifier + })) + } + + TextWithShimmer( + text = track.durationMs.toDurationString(), + style = AppTheme.typography.bodySmall, + textAlign = TextAlign.End, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.width(56.dp)) + + if (useDropdownForOptions) { + TrackOptions( + track = track, + state = trackOptionsState, + onAction = onTrackOptionsAction, + onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + isInJam = isInJam, + isJamGuest = isJamGuest) + } else { + MillenniumIconButton( + onClick = onShowOptionsClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.Iconsax3DotsMore, + contentDescription = "Track options") + } + } + } + }) } @Composable private fun ArtistChips( artists: List, onArtistClick: (MetadataArtist.Basic) -> Unit, - onArtistsOverflowClick: () -> Unit, -) { + onArtistsOverflowClick: () -> Unit) { val visibleArtists = artists.take(2) Row( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { + modifier = Modifier.fillMaxWidth()) { visibleArtists.forEachIndexed { index, artist -> Row( - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() val isPressed by interactionSource.collectIsPressedAsState() TextWithShimmer( text = artist.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null, @@ -827,13 +748,12 @@ private fun ArtistChips( indication = null, interactionSource = interactionSource ) { onArtistClick(artist) } - , - ) + ) if (index < visibleArtists.size - 1) Text( ",", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall + color = AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodySmall ) } } @@ -842,12 +762,11 @@ private fun ArtistChips( if (remaining > 0) { TextWithShimmer( text = "+$remaining more", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.clickable(onClick = onArtistsOverflowClick), - ) + modifier = Modifier.clickable(onClick = onArtistsOverflowClick)) } } } @@ -857,8 +776,7 @@ private fun ShimmerTrackListRow( index: Int, showIndex: Boolean, showAlbum: Boolean, - useDropdownForOptions: Boolean, -) { + useDropdownForOptions: Boolean) { val dummyTrack = remember(index) { dummyTrackForShimmer(index) } SkeletonTree(true) { TrackListRow( @@ -881,8 +799,7 @@ private fun ShimmerTrackListRow( onShowOptionsClick = {}, onArtistClick = {}, onAlbumClick = {}, - onArtistsOverflowClick = {}, - ) + onArtistsOverflowClick = {}) } } @@ -891,8 +808,7 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack { id = "shimmer_artist_$index", name = "Artist Name", thumbnails = emptyList(), - externalUri = null, - ) + externalUri = null) return MetadataTrack( id = "shimmer_track_$index", title = "Track Title Here", @@ -913,29 +829,25 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack { thumbnails = emptyList(), albumType = MetadataAlbumType.Album, artists = listOf(artist), - externalUri = null, - ), + externalUri = null), thumbnails = null, explicit = false, popularity = 0, isrcCode = null, - externalUri = null, - ) + externalUri = null) } @Composable private fun TrackListFeedbackRow( message: String, - isError: Boolean = false, -) { + isError: Boolean = false) { TextWithShimmer( text = message, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, + color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - ) + .padding(horizontal = 8.dp, vertical = 12.dp)) } private fun Long.toDurationString(): String { @@ -951,21 +863,17 @@ fun previewTracks(): List { id = "artist_1", name = "Aria Vale", thumbnails = emptyList(), - externalUri = null, - ), + externalUri = null), MetadataArtist.Basic( id = "artist_2", name = "Neon Harbor", thumbnails = emptyList(), - externalUri = null, - ), + externalUri = null), MetadataArtist.Basic( id = "artist_3", name = "Echo Drift", thumbnails = emptyList(), - externalUri = null, - ), - ) + externalUri = null)) val album = MetadataAlbum.Detailed( releaseDate = null, @@ -978,13 +886,11 @@ fun previewTracks(): List { Thumbnail( url = "https://picsum.photos/120", width = 120, - height = 120, - ) + height = 120) ), albumType = MetadataAlbumType.Album, artists = artists.take(2), - externalUri = null, - ) + externalUri = null) return listOf( MetadataTrack( @@ -999,8 +905,7 @@ fun previewTracks(): List { explicit = false, popularity = 92, isrcCode = null, - externalUri = null, - ), + externalUri = null), MetadataTrack( id = "track_2", title = "Static Horizon", @@ -1013,8 +918,7 @@ fun previewTracks(): List { popularity = 80, isrcCode = null, externalUri = null, - thumbnails = null, - ), + thumbnails = null), MetadataTrack( id = "track_3", title = "Last Train Echo", @@ -1027,19 +931,16 @@ fun previewTracks(): List { popularity = 77, isrcCode = null, externalUri = null, - thumbnails = null, - ), - ) + thumbnails = null)) } @Composable @Preview private fun TrackListPreview() { - BaseScaffold { + Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { TrackList( tracks = previewTracks(), hasMore = true, - isLoadingNextPage = true, - ) + isLoadingNextPage = true) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index bccc4fb2..0f4653b8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -24,12 +31,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -38,8 +44,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore @@ -72,8 +76,7 @@ data class TrackOptionsState( val isInQueue: Boolean = false, val isCurrentlyPlaying: Boolean = false, val isFavorite: Boolean = false, - val isBlacklisted: Boolean = false, -) + val isBlacklisted: Boolean = false) data class TrackOptionsContext( val currentTrackId: String? = null, @@ -81,15 +84,13 @@ data class TrackOptionsContext( val savedTrackIds: Set = emptySet(), val blacklistedTrackIds: Set = emptySet(), val blacklistedArtistIds: Set = emptySet(), - val forceFavorite: Boolean = false, -) { + val forceFavorite: Boolean = false) { fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState( isInQueue = track.id in queueTrackIds, isCurrentlyPlaying = track.id == currentTrackId, isFavorite = forceFavorite || track.id in savedTrackIds, isBlacklisted = track.id in blacklistedTrackIds || - track.artists.any { it.id in blacklistedArtistIds }, - ) + track.artists.any { it.id in blacklistedArtistIds }) } @Composable @@ -100,8 +101,7 @@ fun TrackOptions( onAlbumClick: () -> Unit, modifier: Modifier = Modifier, isInJam: Boolean = false, - isJamGuest: Boolean = false, -) { + isJamGuest: Boolean = false) { AdaptiveDropdownBottomSheet( items = buildTrackMenuItems( track = track, @@ -109,11 +109,12 @@ fun TrackOptions( onAction = onAction, onAlbumClick = onAlbumClick, isInJam = isInJam, - isJamGuest = isJamGuest, - ), + isJamGuest = isJamGuest), trigger = { onClick -> - GhostIconButton(onClick = onClick) { - Icon( + MillenniumIconButton( + onClick = onClick, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.Iconsax3DotsMore, contentDescription = "Track options", modifier = Modifier.shimmerApply() @@ -122,11 +123,9 @@ fun TrackOptions( }, headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, header = { TrackOptionsSheetHeader(track = track) }, - modifier = modifier, - ) + modifier = modifier) } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun TrackOptionsBottomSheet( track: MetadataTrack, @@ -135,9 +134,14 @@ fun TrackOptionsBottomSheet( onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, isInJam: Boolean = false, - isJamGuest: Boolean = false, -) { - ModalBottomSheet(onDismissRequest = onDismiss) { + isJamGuest: Boolean = false) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.currentDetent) { + if (sheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() + } + } + MillenniumModalBottomSheet(state = sheetState) { Column(modifier = Modifier.fillMaxWidth()) { TrackOptionsSheetHeader(track = track) @@ -145,8 +149,7 @@ fun TrackOptionsBottomSheet( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { buildTrackMenuItems( track = track, state = state, @@ -159,40 +162,35 @@ fun TrackOptionsBottomSheet( onDismiss() }, isInJam = isInJam, - isJamGuest = isJamGuest, - ).forEach { item -> + isJamGuest = isJamGuest).forEach { item -> Row( modifier = Modifier .fillMaxWidth() .clickable( enabled = item.enabled, - onClick = item.onClick, - ) + onClick = item.onClick) .padding(horizontal = 12.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { item.icon?.let { icon -> - Icon( + MillenniumIcon( imageVector = icon, contentDescription = null, modifier = Modifier.size(22.dp), tint = if (item.enabled) { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) + AppTheme.colors.onSurface.copy(alpha = 0.38f) + }) } Text( text = item.label, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, color = if (item.enabled) { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - }, - ) + AppTheme.colors.onSurface.copy(alpha = 0.38f) + }) } } } @@ -207,39 +205,34 @@ fun TrackOptionsSheetHeader(track: MetadataTrack) { .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { AsyncImage( model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(56.dp) - .clip(MaterialTheme.shapes.small), - ) + .clip(AppTheme.shapes.small)) Column(modifier = Modifier.weight(1f)) { Text( text = track.title, - style = MaterialTheme.typography.titleMedium, + style = AppTheme.typography.titleMedium, maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = track.artists.joinToString(", ") { it.name }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) track.album?.let { album -> Text( text = album.title, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } @@ -251,25 +244,20 @@ private fun buildTrackMenuItems( onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, isInJam: Boolean = false, - isJamGuest: Boolean = false, -): List = buildList { + isJamGuest: Boolean = false): List = buildList { if (isInJam) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, label = "Add to Jam", - onClick = { onAction(TrackOptionsAction.AddToJam) }, - ), - ) + onClick = { onAction(TrackOptionsAction.AddToJam) })) } add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicCircle, label = "Start radio", - onClick = { onAction(TrackOptionsAction.StartRadio) }, - ), - ) + onClick = { onAction(TrackOptionsAction.StartRadio) })) // A guest's queue is the shared jam queue — mutating it locally is not // allowed, so queue actions are replaced by "Add to Jam". @@ -279,17 +267,13 @@ private fun buildTrackMenuItems( AdaptiveMenuItem( icon = Iconsax.IconsaxNext, label = "Play next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) + onClick = { onAction(TrackOptionsAction.PlayNext) })) } else if (state.isInQueue && !state.isCurrentlyPlaying) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxNext, label = "Move to next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) + onClick = { onAction(TrackOptionsAction.PlayNext) })) } if (!state.isInQueue) { @@ -297,17 +281,13 @@ private fun buildTrackMenuItems( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, label = "Add to queue", - onClick = { onAction(TrackOptionsAction.AddToQueue) }, - ), - ) + onClick = { onAction(TrackOptionsAction.AddToQueue) })) } else { add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicSquareRemove, label = "Remove from queue", - onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, - ), - ) + onClick = { onAction(TrackOptionsAction.RemoveFromQueue) })) } } @@ -315,42 +295,32 @@ private fun buildTrackMenuItems( AdaptiveMenuItem( icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, label = if (state.isFavorite) "Remove from favorites" else "Save as favorite", - onClick = { onAction(TrackOptionsAction.ToggleFavorite) }, - ), - ) + onClick = { onAction(TrackOptionsAction.ToggleFavorite) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicPlaylist, label = "Add to playlist", - onClick = { onAction(TrackOptionsAction.AddToPlaylist) }, - ), - ) + onClick = { onAction(TrackOptionsAction.AddToPlaylist) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxDirectboxReceive, label = "Download", - onClick = { onAction(TrackOptionsAction.Download) }, - ), - ) + onClick = { onAction(TrackOptionsAction.Download) })) add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicSquareRemoveFilled, label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist", - onClick = { onAction(TrackOptionsAction.ToggleBlacklist) }, - ), - ) + onClick = { onAction(TrackOptionsAction.ToggleBlacklist) })) if (!track.externalUri.isNullOrBlank()) { add( AdaptiveMenuItem( icon = Iconsax.IconsaxShare, label = "Share", - onClick = { onAction(TrackOptionsAction.Share) }, - ), - ) + onClick = { onAction(TrackOptionsAction.Share) })) } if (track.album != null) { @@ -358,8 +328,6 @@ private fun buildTrackMenuItems( AdaptiveMenuItem( icon = Iconsax.IconsaxCd, label = "Go to album", - onClick = onAlbumClick, - ), - ) + onClick = onAlbumClick)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt index 1b4b9b13..868ec005 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/UserCard.kt @@ -26,9 +26,9 @@ 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.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,8 +44,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard @Composable fun UserCard( user: MetadataUser, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { AvatarCard( title = user.displayName ?: user.username, subtitle = "User", @@ -53,7 +52,6 @@ fun UserCard( onClick = { // TODO: Route to user details screen. }, - modifier = modifier, - ) + modifier = modifier) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt index 2522f276..d677bdd3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.kt @@ -28,8 +28,7 @@ import dev.krtirtho.spotube.isDesktop @Composable expect fun VerticalScrollbar( listState: LazyListState, - modifier: Modifier = Modifier, -) + modifier: Modifier = Modifier) fun Modifier.dragScrollable(rowState: LazyListState): Modifier = if (getPlatform().isDesktop()) pointerInput(rowState) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt index 940b6b3d..ff08d418 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/AvatarCard.kt @@ -31,9 +31,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -58,10 +58,9 @@ fun AvatarCard( imageURL: String? = null, onClick: () -> Unit = {}, modifier: Modifier = Modifier, - isBlacklisted: Boolean = false, -) { + isBlacklisted: Boolean = false) { val backgroundColor = if (isBlacklisted) { - MaterialTheme.colorScheme.error.copy(alpha = 0.1f) + AppTheme.colors.error.copy(alpha = 0.1f) } else { Color.Transparent } @@ -71,20 +70,17 @@ fun AvatarCard( .clip(RoundedCornerShape(8.dp)) .background(backgroundColor) .clickable(onClick = { onClick.invoke() }, enabled = true) - .width(160.dp), - ) { + .width(160.dp)) { Column( modifier = Modifier .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { Box( modifier = Modifier .fillMaxWidth() .clip(CircleShape) .padding(12.dp), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { AsyncImage( model = imageURL, contentDescription = title, @@ -93,27 +89,24 @@ fun AvatarCard( .aspectRatio(1f) .clip(CircleShape) .shimmerApply(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } Column( modifier = Modifier.fillMaxWidth().padding(12.dp) ) { TextWithShimmer( text = title, - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) + modifier = Modifier.fillMaxWidth()) subtitle?.let { TextWithShimmer( text = subtitle, - style = MaterialTheme.typography.bodyMedium, + style = AppTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) + modifier = Modifier.fillMaxWidth()) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt index 89b9458c..74ccc375 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/cards/PlayableCard.kt @@ -17,6 +17,10 @@ package dev.krtirtho.spotube.core.ui.component.cards + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.fadeIn @@ -36,9 +40,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold + +import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -50,10 +53,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.ui.base.BaseUITheme -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton -import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone @@ -75,8 +74,7 @@ fun PlayableCard( onPlay: (() -> Unit)? = null, onAddToQueue: (() -> Unit)? = null, modifier: Modifier = Modifier, - sharedElementKey: String? = null, -) { + sharedElementKey: String? = null) { val interactionSource = remember { MutableInteractionSource() } val sharedTransitionScope = LocalSharedTransitionScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current @@ -86,8 +84,7 @@ fun PlayableCard( .clip(RoundedCornerShape(8.dp)) .clickable(onClick = { onClick?.invoke() }, enabled = true) .width(160.dp) - .hoverable(interactionSource = interactionSource), - ) { + .hoverable(interactionSource = interactionSource)) { Column(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth().shimmerApply()) { AsyncImage( @@ -100,10 +97,8 @@ fun PlayableCard( .sharedElementOrNone( key = sharedElementKey, sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ), - contentScale = ContentScale.Crop, - ) + animatedVisibilityScope = animatedVisibilityScope), + contentScale = ContentScale.Crop) if (onPlay != null || onAddToQueue != null) Box( @@ -114,46 +109,35 @@ fun PlayableCard( this@Column.AnimatedVisibility( visible = isHovered, enter = fadeIn(), - exit = fadeOut(), - ) { + exit = fadeOut()) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(4.dp) ) { onAddToQueue?.let { - SecondaryIconButton( + MillenniumIconButton( onClick = onAddToQueue, - modifier = Modifier.size(30.dp), - theme = LocalBaseUITheme.current.iconButtons.secondary.copy( - shape = BaseUITheme.InteractionState.fromSingleValue( - CircleShape - ) - ) + modifier = Modifier.size(34.dp), + style = MillenniumIconButtonStyle.Secondary ) { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add to queue", - ) + contentDescription = "Add to queue") } } Spacer(modifier = Modifier.height(8.dp)) onPlay?.let { - PrimaryIconButton( + MillenniumIconButton( onClick = onPlay, - modifier = Modifier.size(30.dp), - theme = LocalBaseUITheme.current.iconButtons.secondary.copy( - shape = BaseUITheme.InteractionState.fromSingleValue( - CircleShape - ) - ) + modifier = Modifier.size(34.dp), + style = MillenniumIconButtonStyle.Primary ) { - Icon( + MillenniumIcon( imageVector = if (isPlaying) { Iconsax.IconsaxPause } else Iconsax.IconsaxPlay, - contentDescription = "Play", - ) + contentDescription = "Play") } } } @@ -165,19 +149,17 @@ fun PlayableCard( ) { TextWithShimmer( text = title, - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.height(4.dp)) subtitle?.let { TextWithShimmer( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } @@ -185,11 +167,10 @@ fun PlayableCard( } } - @PreviewLightDark @Composable private fun PlayableCardPreview() { - BaseScaffold { + Box { PlayableCard( title = "Sample Title", subtitle = "Sample Subtitle", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt index 84569b6e..1eafe443 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Coverflow.kt @@ -45,8 +45,7 @@ fun Coverflow( modifier: Modifier = Modifier, state: CoverflowState = rememberCoverflowState(), params: CoverflowParams = CoverflowParams(), - content: CoverflowScope.() -> Unit, -) { + content: CoverflowScope.() -> Unit) { var size by remember { mutableStateOf(IntSize.Zero) } var geometry by remember { mutableStateOf(null) } @@ -55,8 +54,7 @@ fun Coverflow( val g = geometry ?: return@LaunchedEffect state.lazyListState.scrollToItem( state.selectedIndex + 1, - -g.spacerWidth, - ) + -g.spacerWidth) state.initialScrollDone = true } @@ -69,8 +67,7 @@ fun Coverflow( }, verticalAlignment = Alignment.CenterVertically, state = state.lazyListState, - flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState), - ) { + flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)) { if (size != IntSize.Zero) { val g = Geometry(params, size) geometry = g @@ -79,8 +76,7 @@ fun Coverflow( val coverflowScope = CoverflowScopeImpl( geometry = g, lazyListScope = this, - coverflowState = state, - ) + coverflowState = state) item { Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() })) @@ -102,15 +98,13 @@ fun Coverflow( if (state.selectedIndex > 0) { state.scrollToItem(state.selectedIndex - 1) } - }, - ) {} + }) {} Box( modifier = Modifier .fillMaxHeight() .align(Alignment.CenterEnd) .clickable { state.scrollToItem(state.selectedIndex + 1) - }, - ) {} + }) {} } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt index 0ea24cc7..72151f39 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowGeometry.kt @@ -25,8 +25,7 @@ import kotlin.math.min @Stable internal class Geometry( internal val params: CoverflowParams = CoverflowParams(), - private val size: IntSize = IntSize.Zero, -) { + private val size: IntSize = IntSize.Zero) { @Stable private val shortEdge get() = min(size.width, size.height) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt index 9eabc429..4ca74a4b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowItem.kt @@ -41,8 +41,7 @@ internal fun CoverflowItem( geometry: Geometry, onClickHandler: () -> Unit = {}, onSelectedHandler: (Boolean) -> Unit, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { var horizontalPosition by remember { mutableStateOf(null) } val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f) @@ -53,8 +52,7 @@ internal fun CoverflowItem( .onGloballyPositioned { coordinates -> horizontalPosition = coordinates.positionInParent().x }, - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { if (horizontalPosition != null) { onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f)) @@ -65,20 +63,16 @@ internal fun CoverflowItem( rotationY = geometry.rotation(distanceToCenter), translationX = geometry.translationX(distanceToCenter), scaleY = geometry.scale(distanceToCenter), - scaleX = geometry.scale(distanceToCenter), - ), - propagateMinConstraints = true, - ) { + scaleX = geometry.scale(distanceToCenter)), + propagateMinConstraints = true) { val interactionSource = remember { MutableInteractionSource() } if (geometry.params.mirror) { Mirror( modifier = Modifier.clickable( interactionSource = interactionSource, indication = null, - onClick = onClickHandler, - ), - coverSize = geometry.coverSize, - ) { + onClick = onClickHandler), + coverSize = geometry.coverSize) { content() } } else { @@ -86,9 +80,7 @@ internal fun CoverflowItem( modifier = Modifier.clickable( interactionSource = interactionSource, indication = null, - onClick = onClickHandler, - ), - ) { + onClick = onClickHandler)) { content() } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt index 6298a886..c2a7bf5b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowParams.kt @@ -26,5 +26,4 @@ data class CoverflowParams( val angle: Float = 45f, val shift: Float = 0.4f, val zoom: Float = 0.8f, - val mirror: Boolean = false, -) + val mirror: Boolean = false) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt index ef4fb578..e534c464 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowScope.kt @@ -26,37 +26,32 @@ interface CoverflowScope { onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> }, key: ((index: Int) -> Any)? = null, contentType: (index: Int) -> Any? = { null }, - itemContent: @Composable ((item: T) -> Unit), - ) + itemContent: @Composable ((item: T) -> Unit)) fun items( count: Int, onSelectHandler: (index: Int) -> Unit = {}, key: ((index: Int) -> Any)? = null, contentType: (index: Int) -> Any? = { null }, - itemContent: @Composable ((index: Int) -> Unit), - ) + itemContent: @Composable ((index: Int) -> Unit)) } internal class CoverflowScopeImpl( private val geometry: Geometry, private val lazyListScope: LazyListScope, - private val coverflowState: CoverflowState, -) : CoverflowScope { + private val coverflowState: CoverflowState) : CoverflowScope { override fun items( items: List, onSelectHandler: (item: T, index: Int) -> Unit, key: ((index: Int) -> Any)?, contentType: (index: Int) -> Any?, - itemContent: @Composable (item: T) -> Unit, - ) = items( + itemContent: @Composable (item: T) -> Unit) = items( count = items.size, onSelectHandler = { onSelectHandler(items[it], it) }, key = key, - contentType = contentType, - ) { + contentType = contentType) { itemContent(items[it]) } @@ -65,12 +60,10 @@ internal class CoverflowScopeImpl( onSelectHandler: (index: Int) -> Unit, key: ((index: Int) -> Any)?, contentType: (index: Int) -> Any?, - itemContent: @Composable (index: Int) -> Unit, - ) = lazyListScope.items( + itemContent: @Composable (index: Int) -> Unit) = lazyListScope.items( count, key, - contentType, - ) { + contentType) { CoverflowItem( onClickHandler = { coverflowState.scrollToItem(it) }, onSelectedHandler = { isSelected: Boolean -> @@ -78,16 +71,14 @@ internal class CoverflowScopeImpl( onSelectHandler(it) } }, - geometry = geometry, - ) { + geometry = geometry) { itemContent(it) } } private fun selectHandler( index: Int, - isSelected: Boolean, - ): Boolean { + isSelected: Boolean): Boolean { if (!isSelected && index == coverflowState.selectedIndex) { coverflowState.selectedIndex = -1 } else if (isSelected && index != coverflowState.selectedIndex) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt index 99a16d77..5e716f59 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/CoverflowState.kt @@ -28,19 +28,16 @@ import kotlinx.coroutines.launch @Composable fun rememberCoverflowState( initialIndex: Int = 0, - onSelectHandler: (Int) -> Unit = {}, -): CoverflowState { + onSelectHandler: (Int) -> Unit = {}): CoverflowState { val lazyListState: LazyListState = rememberLazyListState( - initialFirstVisibleItemIndex = initialIndex + 1, - ) + initialFirstVisibleItemIndex = initialIndex + 1) val coroutineScope: CoroutineScope = rememberCoroutineScope() return remember { CoverflowState( lazyListState, coroutineScope, onSelectHandler, - initialIndex, - ) + initialIndex) } } @@ -48,8 +45,7 @@ class CoverflowState internal constructor( internal val lazyListState: LazyListState, private val coroutineScope: CoroutineScope, private val onSelectHandler: (Int) -> Unit = {}, - initialIndex: Int = 0, -) { + initialIndex: Int = 0) { internal var geometry: Geometry? = null internal var initialScrollDone: Boolean = false @@ -65,8 +61,7 @@ class CoverflowState internal constructor( coroutineScope.launch { lazyListState.animateScrollToItem( index + 1, - -geometryCopy.spacerWidth, - ) + -geometryCopy.spacerWidth) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt index 3ec6fa81..008f788b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/coverflow/Mirror.kt @@ -48,23 +48,20 @@ import androidx.compose.ui.unit.times internal fun Mirror( modifier: Modifier = Modifier, coverSize: Float, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() } Column( modifier = modifier .padding(top = coverSizeDp) .fillMaxWidth() - .requiredHeight(2 * coverSizeDp), - ) { + .requiredHeight(2 * coverSizeDp)) { Box( propagateMinConstraints = true, modifier = Modifier .weight(1f) .fillMaxWidth() - .height(coverSizeDp), - ) { + .height(coverSizeDp)) { content() } Box( @@ -75,34 +72,27 @@ internal fun Mirror( .height(coverSizeDp) .graphicsLayer( compositingStrategy = CompositingStrategy.Offscreen, - rotationX = 180f, - ) + rotationX = 180f) .clip(HalfSizeShape) .drawWithContent { val colors = listOf( Color.Transparent, Color.Transparent, - Color.Black, - ) + Color.Black) drawContent() drawRect( topLeft = Offset( 0f, - this.size.height / 2, - ), + this.size.height / 2), size = Size( this.size.width, - this.size.height / 2, - ), + this.size.height / 2), brush = Brush.verticalGradient(colors), - blendMode = BlendMode.DstIn, - ) + blendMode = BlendMode.DstIn) } .blur( radiusX = 1.dp, - radiusY = 3.dp, - ), - ) { + radiusY = 3.dp)) { content() } } @@ -112,17 +102,12 @@ private object HalfSizeShape : Shape { override fun createOutline( size: Size, layoutDirection: LayoutDirection, - density: Density, - ): Outline = Outline.Rectangle( + density: Density): Outline = Outline.Rectangle( Rect( Offset( 0f, - size.height / 2, - ), + size.height / 2), Size( size.width, - size.height, - ), - ), - ) + size.height))) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt index a5040409..9cbd742f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/misc/Shimmer.kt @@ -17,10 +17,11 @@ package dev.krtirtho.spotube.core.ui.misc +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.text.TextAutoSize -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text +import com.composeunstyled.LocalTextStyle +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf @@ -38,7 +39,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit -import com.eygraber.compose.placeholder.material3.placeholder import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.shimmer @@ -84,11 +84,7 @@ fun Modifier.shimmerApply( ): Modifier = composed { val isTreeLoading = LocalSkeletonLoading.current - this.placeholder( - visible = isTreeLoading, - color = color, - shape = null, - ) + if (isTreeLoading) this.background(color) else this } @Composable @@ -110,23 +106,20 @@ fun TextWithShimmer( maxLines: Int = Int.MAX_VALUE, minLines: Int = 1, onTextLayout: ((TextLayoutResult) -> Unit)? = null, - style: TextStyle = LocalTextStyle.current, -) { + style: TextStyle = LocalTextStyle.current) { Text( text = text, color = color, modifier = Modifier.shimmerApply().then(modifier), autoSize = autoSize, fontSize = fontSize, - fontStyle = fontStyle, fontWeight = fontWeight, fontFamily = fontFamily, letterSpacing = letterSpacing, textDecoration = textDecoration, - textAlign = textAlign, + textAlign = textAlign ?: TextAlign.Start, lineHeight = lineHeight, overflow = overflow, - softWrap = softWrap, maxLines = maxLines, minLines = minLines, onTextLayout = onTextLayout, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt index 633d1f96..b58b713f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/theming/AppTheme.kt @@ -18,93 +18,188 @@ package dev.krtirtho.spotube.core.ui.theming import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember -import com.materialkolor.rememberDynamicColorScheme -import dev.krtirtho.spotube.core.ui.base.BaseUIColorScheme -import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.UserSettings +import org.spotube.millennium.theme.MillenniumColors +import org.spotube.millennium.theme.MillenniumPalette +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.MillenniumThemeConfig + +/** + * Semantic color roles used across the app, resolved from the active Millennium palette. + * + * This is not a second palette: every role is derived from [MillenniumColors], so the + * whole app follows the selected Millennium accent family and light/dark appearance. + */ +@Immutable +data class AppColors( + val primary: Color, + val onPrimary: Color, + val primaryContainer: Color, + val onPrimaryContainer: Color, + val secondary: Color, + val onSecondary: Color, + val secondaryContainer: Color, + val onSecondaryContainer: Color, + val tertiaryContainer: Color, + val onTertiaryContainer: Color, + val background: Color, + val onBackground: Color, + val surface: Color, + val onSurface: Color, + val surfaceVariant: Color, + val onSurfaceVariant: Color, + val surfaceContainer: Color, + val surfaceContainerHigh: Color, + val surfaceContainerHighest: Color, + val inverseSurface: Color, + val inverseOnSurface: Color, + val outline: Color, + val outlineVariant: Color, + val error: Color, + val onError: Color, + val errorContainer: Color, + val onErrorContainer: Color, + val scrim: Color) + +/** Maps Millennium's semantic palette onto the app's role names. */ +fun appColorsFrom(m: MillenniumColors): AppColors = AppColors( + primary = m.accent, + onPrimary = m.onAccent, + primaryContainer = m.raised, + onPrimaryContainer = m.text, + secondary = m.accent, + onSecondary = m.onAccent, + secondaryContainer = m.raised, + onSecondaryContainer = m.text, + tertiaryContainer = m.raised, + onTertiaryContainer = m.text, + background = m.background, + onBackground = m.text, + surface = m.surface, + onSurface = m.text, + surfaceVariant = m.inset, + onSurfaceVariant = m.muted, + surfaceContainer = m.surface, + surfaceContainerHigh = m.raised, + surfaceContainerHighest = m.raised, + inverseSurface = m.text, + inverseOnSurface = m.background, + outline = m.border, + outlineVariant = m.border, + error = m.danger, + onError = m.background, + errorContainer = m.danger.copy(alpha = 0.16f), + onErrorContainer = m.danger, + scrim = m.scrim) + +/** Named text styles used by the app, independent of Material typography. */ +@Immutable +data class AppTypography( + val displaySmall: TextStyle, + val headlineLarge: TextStyle, + val headlineMedium: TextStyle, + val headlineSmall: TextStyle, + val titleLarge: TextStyle, + val titleMedium: TextStyle, + val titleSmall: TextStyle, + val bodyLarge: TextStyle, + val bodyMedium: TextStyle, + val bodySmall: TextStyle, + val labelLarge: TextStyle, + val labelMedium: TextStyle, + val labelSmall: TextStyle) + +private fun style( + size: Int, + lineHeight: Int, + weight: FontWeight = FontWeight.Normal): TextStyle = TextStyle( + fontFamily = FontFamily.SansSerif, + fontSize = size.sp, + lineHeight = lineHeight.sp, + fontWeight = weight) + +val AppDefaultTypography = AppTypography( + displaySmall = style(36, 44), + headlineLarge = style(32, 40), + headlineMedium = style(28, 36), + headlineSmall = style(24, 32), + titleLarge = style(22, 28, FontWeight.Medium), + titleMedium = style(16, 24, FontWeight.Medium), + titleSmall = style(14, 20, FontWeight.Medium), + bodyLarge = style(16, 24), + bodyMedium = style(14, 20), + bodySmall = style(12, 16), + labelLarge = style(14, 20, FontWeight.Medium), + labelMedium = style(12, 16, FontWeight.Medium), + labelSmall = style(11, 16, FontWeight.Medium)) + +/** Corner shapes used by the app. */ +@Immutable +data class AppShapes( + val small: Shape, + val medium: Shape, + val large: Shape) + +val AppDefaultShapes = AppShapes( + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp)) + +val LocalAppColors = staticCompositionLocalOf { appColorsFrom(org.spotube.millennium.theme.MillenniumLightColors) } +val LocalAppTypography = staticCompositionLocalOf { AppDefaultTypography } +val LocalAppShapes = staticCompositionLocalOf { AppDefaultShapes } + +/** Reads the active app theme tokens, all backed by Millennium. */ +object AppTheme { + val colors: AppColors @Composable get() = LocalAppColors.current + val typography: AppTypography @Composable get() = LocalAppTypography.current + val shapes: AppShapes @Composable get() = LocalAppShapes.current +} /** * The app's single source of truth for colour. * - * The selected [UserSettings.surfaceTheme] and [UserSettings.accentColor] produce a - * [BaseUIColorScheme], and every Material role is then overridden from it. That way the - * whole app — Material components included — renders in the chosen surface theme's colours - * and accent, instead of Material's dynamic palette drifting away from the base UI. + * Installs [MillenniumTheme] with the user's selected [UserSettings.palette] and light/dark + * [UserSettings.theme], then derives the app's semantic [AppColors], [AppTypography] and + * [AppShapes] tokens from the active Millennium theme. */ @Composable fun SpotubeTheme( settings: UserSettings, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { val isDarkTheme = when (settings.theme) { Theme.LIGHT -> false Theme.DARK -> true Theme.SYSTEM -> isSystemInDarkTheme() } - val isLight = !isDarkTheme + val palette: MillenniumPalette = settings.palette - val accent = if (isDarkTheme) { - settings.accentColor.toDarkColor() - } else { - settings.accentColor.toLightColor() - } - - val baseColors = remember(settings.surfaceTheme, isLight, accent) { - baseUIColorScheme(settings.surfaceTheme, isLight, accent) - } - - val dynamicScheme = rememberDynamicColorScheme( - seedColor = accent, - isDark = isDarkTheme, - ) - - MaterialTheme( - colorScheme = dynamicScheme.mergeWithBaseColors(baseColors), - ) { - Surface(content = content) + MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) { + val colors = appColorsFrom(MillenniumTheme.colors) + CompositionLocalProvider( + LocalAppColors provides colors, + LocalAppTypography provides AppDefaultTypography, + LocalAppShapes provides AppDefaultShapes, + content = content) } } -/** - * Overrides the accent/surface roles of a dynamically generated [ColorScheme] with the - * base UI palette, so Material components share the exact same colours as the base - * components and the surface theme's primary/secondary backgrounds. - */ -private fun ColorScheme.mergeWithBaseColors(base: BaseUIColorScheme): ColorScheme = copy( - primary = base.accentPrimary, - onPrimary = base.onAccentPrimary, - primaryContainer = base.primaryContainer, - onPrimaryContainer = base.onPrimaryContainer, - inversePrimary = base.actionInverse, - secondary = base.accentSecondary, - onSecondary = base.onAccentSecondary, - secondaryContainer = base.secondaryContainer, - onSecondaryContainer = base.onSecondaryContainer, - background = base.surface, - onBackground = base.onBackground, - surface = base.surface, - onSurface = base.onSurface, - surfaceVariant = base.surfaceVariant, - onSurfaceVariant = base.onSurfaceVariant, - surfaceTint = base.accentPrimary, - surfaceBright = base.surfaceContainerHigh, - surfaceDim = base.surfaceContainerHighest, - surfaceContainerLowest = base.surface, - surfaceContainerLow = base.surfaceContainer, - surfaceContainer = base.surfaceContainer, - surfaceContainerHigh = base.surfaceContainerHigh, - surfaceContainerHighest = base.surfaceContainerHighest, - inverseSurface = base.backgroundInverse, - inverseOnSurface = base.onBackgroundInverse, - outline = base.outline, - outlineVariant = base.outlineVariant, - error = base.error, - onError = base.onError, - scrim = base.scrim, -) +/** Whether the active Millennium appearance is light. */ +@Composable +fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt index 7d3373cd..d166f722 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt @@ -1,221 +1,230 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.webview - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import compose.icons.FeatherIcons -import compose.icons.feathericons.ChevronLeft -import compose.icons.feathericons.ChevronRight -import compose.icons.feathericons.X -import dev.krtirtho.spotube.core.tools.user_agents.UserAgents -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.nucleusframework.webview.jsbridge.IJsMessageHandler -import dev.nucleusframework.webview.jsbridge.JsMessage -import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge -import dev.nucleusframework.webview.web.WebView -import dev.nucleusframework.webview.web.WebViewNavigator -import dev.nucleusframework.webview.web.WebViewState -import dev.nucleusframework.webview.web.rememberWebViewNavigator - -class PostMessageHandler( - private val onMessageReceived: (String) -> Unit = {} -) : IJsMessageHandler { - override fun methodName(): String { - return "sendMessage" - } - - override fun handle( - message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit - ) { - onMessageReceived(message.params) - callback(message.params) - } -} - -@Composable -fun PlatformWebViewScreen(webViewController: WebViewController) { - if (webViewController.getContent() == null) { - // This should never happen, but just in case - Text("No URL to load") - return - } - - val state = remember { - WebViewState( - webViewController.getWebContent( - additionalHttpHeaders = mapOf( - "User-Agent" to UserAgents.random() - ) - ) - ) - }.apply { - this.content = webViewController.getWebContent() - platformWebviewConfig(this, webViewController.currentPluginId) - } - - val navigator = rememberWebViewNavigator() - val webViewBridge = rememberWebViewJsBridge(navigator) - - val bridgeBootstrapScript = remember { - """ - (function() { - if (typeof window.sendMessage !== "function") { - window.sendMessage = function(message) { - if (typeof message !== "string") { - throw new TypeError("[window.sendMessage] Message must be a string"); - } - window.kmpJsBridge.callNative("sendMessage", message); - }; - } - - if (!window.bridgeReady) { - const event = new CustomEvent("onBridgeReady"); - window.dispatchEvent(event); - window.bridgeReady = true; - } - })(); - """.trimIndent() - } - - LaunchedEffect(state) { - snapshotFlow { state.lastLoadedUrl }.collect { url -> - if (url != null) { - webViewController.emitUrlChange(url) - navigator.evaluateJavaScript(bridgeBootstrapScript) - webViewController.emitWebViewCreated() - } - } - } - - LaunchedEffect(state.cookieManager, navigator) { - webViewController.setCookieManager(cookieManager = state.cookieManager) - webViewController.webViewNavigator = navigator - } - - LaunchedEffect(webViewBridge) { - webViewBridge.register(PostMessageHandler { message -> - webViewController.emitPostMessage(message) - }) - } - - DisposableEffect(Unit) { - onDispose { - webViewController.dispose() - } - } - - BaseScaffold( - contentWindowInsets = WindowInsets.statusBars, - topBar = { - ApplicationMainBar( - title = { - Row( - modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - modifier = Modifier.height(56.dp) - ) { - IconButton( - onClick = { - navigator.navigateBack() - }, enabled = navigator.canGoBack - ) { - Icon( - FeatherIcons.ChevronLeft, - contentDescription = "Go back to browser history" - ) - } - IconButton( - onClick = { - navigator.navigateForward() - }, enabled = navigator.canGoForward - ) { - Icon( - FeatherIcons.ChevronRight, - contentDescription = "Go forward to browser history" - ) - } - } - Surface( - modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp), - shape = RoundedCornerShape(18.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)) - ) { - BasicTextField( - value = state.lastLoadedUrl ?: "", - onValueChange = {}, // Read-only - readOnly = true, - singleLine = true, - textStyle = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Start - ), - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp) - .wrapContentHeight(Alignment.CenterVertically) - ) - } - } - } - ) - }) - { innerPadding -> - WebView( - state = state, - modifier = Modifier.padding(innerPadding).fillMaxSize(), - navigator = navigator, - webViewJsBridge = webViewBridge, - onCreated = { webView -> - navigator.evaluateJavaScript(bridgeBootstrapScript) - }, - ) - } +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.webview + + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.ui.draw.clip + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronLeft +import compose.icons.feathericons.ChevronRight +import compose.icons.feathericons.X +import dev.krtirtho.spotube.core.tools.user_agents.UserAgents +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.nucleusframework.webview.jsbridge.IJsMessageHandler +import dev.nucleusframework.webview.jsbridge.JsMessage +import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge +import dev.nucleusframework.webview.web.WebView +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import dev.nucleusframework.webview.web.rememberWebViewNavigator + +class PostMessageHandler( + private val onMessageReceived: (String) -> Unit = {} +) : IJsMessageHandler { + override fun methodName(): String { + return "sendMessage" + } + + override fun handle( + message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit + ) { + onMessageReceived(message.params) + callback(message.params) + } +} + +@Composable +fun PlatformWebViewScreen(webViewController: WebViewController) { + if (webViewController.getContent() == null) { + // This should never happen, but just in case + Text("No URL to load") + return + } + + val state = remember { + WebViewState( + webViewController.getWebContent( + additionalHttpHeaders = mapOf( + "User-Agent" to UserAgents.random() + ) + ) + ) + }.apply { + this.content = webViewController.getWebContent() + platformWebviewConfig(this, webViewController.currentPluginId) + } + + val navigator = rememberWebViewNavigator() + val webViewBridge = rememberWebViewJsBridge(navigator) + + val bridgeBootstrapScript = remember { + """ + (function() { + if (typeof window.sendMessage !== "function") { + window.sendMessage = function(message) { + if (typeof message !== "string") { + throw new TypeError("[window.sendMessage] Message must be a string"); + } + window.kmpJsBridge.callNative("sendMessage", message); + }; + } + + if (!window.bridgeReady) { + const event = new CustomEvent("onBridgeReady"); + window.dispatchEvent(event); + window.bridgeReady = true; + } + })(); + """.trimIndent() + } + + LaunchedEffect(state) { + snapshotFlow { state.lastLoadedUrl }.collect { url -> + if (url != null) { + webViewController.emitUrlChange(url) + navigator.evaluateJavaScript(bridgeBootstrapScript) + webViewController.emitWebViewCreated() + } + } + } + + LaunchedEffect(state.cookieManager, navigator) { + webViewController.setCookieManager(cookieManager = state.cookieManager) + webViewController.webViewNavigator = navigator + } + + LaunchedEffect(webViewBridge) { + webViewBridge.register(PostMessageHandler { message -> + webViewController.emitPostMessage(message) + }) + } + + DisposableEffect(Unit) { + onDispose { + webViewController.dispose() + } + } + + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { + Row( + modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier.height(56.dp) + ) { + MillenniumIconButton( + onClick = { + navigator.navigateBack() + }, + enabled = navigator.canGoBack, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + FeatherIcons.ChevronLeft, + contentDescription = "Go back to browser history" + ) + } + MillenniumIconButton( + onClick = { + navigator.navigateForward() + }, + enabled = navigator.canGoForward, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + FeatherIcons.ChevronRight, + contentDescription = "Go forward to browser history" + ) + } + } + Box( + modifier = Modifier + .weight(1f) + .height(36.dp) + .padding(horizontal = 4.dp) + .clip(RoundedCornerShape(18.dp)) + .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f)) + .border( + BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)), + RoundedCornerShape(18.dp)), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = state.lastLoadedUrl ?: "", + onValueChange = {}, // Read-only + readOnly = true, + singleLine = true, + textStyle = AppTheme.typography.bodyMedium.copy( + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Start + ), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp) + .wrapContentHeight(Alignment.CenterVertically) + ) + } + } + } + ) + WebView( + state = state, + modifier = Modifier.weight(1f).fillMaxWidth(), + navigator = navigator, + webViewJsBridge = webViewBridge, + onCreated = { webView -> + navigator.evaluateJavaScript(bridgeBootstrapScript) + }) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt index 13829efa..80f5514b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/BuiltInPluginService.kt @@ -38,8 +38,7 @@ import org.koin.core.component.KoinComponent import kotlin.reflect.KClass class BuiltInPluginService( - private val pluginInfo: PluginEntry, -) : PluginService, KoinComponent { + private val pluginInfo: PluginEntry) : PluginService, KoinComponent { override val pluginId: String = pluginInfo.id private val loggedInStateFlow = MutableStateFlow(false) @@ -58,7 +57,6 @@ class BuiltInPluginService( } } - override suspend fun start() { when (pluginInfo) { NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt index ccd966a4..42acea9f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt @@ -1,347 +1,344 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.zipline - -import app.cash.zipline.Zipline -import app.cash.zipline.ZiplineService -import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh -import app.cash.zipline.loader.LoadResult -import app.cash.zipline.loader.ManifestVerifier -import app.cash.zipline.loader.ZiplineLoader -import dev.krtirtho.plugin_interfaces.core.Initializer -import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI -import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI -import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI -import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI -import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI -import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.core.webview.WebViewController -import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI -import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI -import dev.krtirtho.spotube.modules.plugin.PluginAbility -import dev.krtirtho.spotube.modules.plugin.PluginCapability -import dev.krtirtho.spotube.modules.plugin.PluginEntry -import io.ktor.http.URLBuilder -import io.ktor.http.decodeURLQueryComponent -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import okio.Path.Companion.toPath -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import kotlin.reflect.KClass - - -/** - * Manages the lifecycle of a Zipline plugin. - * It runs everything on its own dispatcher (different thread) as per Zipline's requirements. - * Anything it provides, must be called within that dispatcher context. - * The [use] function must be used. - * - * The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well, - * so if they are calling something on [Dispatchers.Main], those calls should be wrapped in - * `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows. - * - * The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called. - */ -open class ZiplinePluginService( - val applicationName: String, - private val manifestUrl: String, - private val pluginInfo: PluginEntry, -) : PluginService, KoinComponent { - - override val pluginId: String = pluginInfo.id - - // QuickJS compile() uses deep C-level recursion on the native thread stack. - // Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules - // (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing. - // We use a custom EventListener to increase maxStackSize right after the Zipline - // instance is created, before any modules are loaded. - private val ziplineDispatcher = createZiplineDispatcher() - - private fun trace(event: String) { - logger.d { "[$applicationName] $event" } - } - - private val logger by injectLogger() - private val webViewController: WebViewController by inject() - - private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher) - private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable -> - logger.e(throwable) { "Zipline Engine Error" } - } - private val lifecycleMutex = Mutex() - private var ziplineLoader: ZiplineLoader - private var ziplineInstance: Zipline? = null - private val serviceRegistry = mutableMapOf, ZiplineService>() - - init { - val manifestPath = URLBuilder(manifestUrl) - val baseDir = - manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent - ?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.") - ziplineLoader = ZiplineLoader( - dispatcher = ziplineDispatcher.dispatcher, - manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS, - httpClient = FileSystemHTTPClient(baseDir) - ) - } - - private val realHttpClientAPI = RealHttpClientAPI() - private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id) - - private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo) - private val cryptoAPI = RealCryptoAPI(scope.coroutineContext) - private val systemInformationAPI = RealSystemInformationAPI() - - private val loggedInStateFlow = MutableStateFlow(false) - override val loggedInFlow: StateFlow = loggedInStateFlow.asStateFlow() - - private fun bindHostServices(zipline: Zipline) { - trace("initializer(): binding host APIs") - logger.d { "[$applicationName] Binding host APIs in initializer" } - try { - // Basic APIs - zipline.bind(CryptoAPI_SERVICE_NAME, cryptoAPI) - zipline.bind( - SystemInformationAPI_SERVICE_NAME, - systemInformationAPI - ) - - // Conditional APIs based on plugin capabilities - if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) { - zipline.bind( - HttpClientAPI_SERVICE_NAME, - realHttpClientAPI - ) - } - if (PluginCapability.WEBVIEW in pluginInfo.capabilities) { - zipline.bind(WebViewAPI_SERVICE_NAME, realWebViewAPI) - } - if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) { - zipline.bind( - PersistedStorageAPI_SERVICE_NAME, - persistedStorageAPI - ) - } - } catch (e: Exception) { - logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" } - throw e - } - } - - private fun consumePluginServices(result: LoadResult.Success) { - trace("start(): loadOnce success") - val apiMap = - buildMap, ZiplineService> { - put(CoreAPI::class, result.zipline.take(CoreAPI_SERVICE_NAME)) - - if (PluginAbility.METADATA in pluginInfo.abilities) { - put( - MetadataUserAPI::class, - result.zipline.take( - MetadataUserAPI_SERVICE_NAME - ) - ) - put( - MetadataTrackAPI::class, - result.zipline.take( - MetadataTrackAPI_SERVICE_NAME - ) - ) - put( - MetadataAlbumAPI::class, - result.zipline.take( - MetadataAlbumAPI_SERVICE_NAME - ) - ) - put( - MetadataArtistAPI::class, - result.zipline.take( - MetadataArtistAPI_SERVICE_NAME - ) - ) - put( - MetadataPlaylistAPI::class, - result.zipline.take( - MetadataPlaylistAPI_SERVICE_NAME - ) - ) - put( - MetadataBrowseAPI::class, - result.zipline.take( - MetadataBrowseAPI_SERVICE_NAME - ) - ) - put( - MetadataSearchAPI::class, - result.zipline.take( - MetadataSearchAPI_SERVICE_NAME - ) - ) - } - if (PluginAbility.AUDIO in pluginInfo.abilities) { - put( - AudioAPI::class, - result.zipline.take(AudioAPI_SERVICE_NAME) - ) - } - if (PluginAbility.LYRICS in pluginInfo.abilities) { - put( - LyricsAPI::class, - result.zipline.take(LyricsAPI_SERVICE_NAME) - ) - } - if (PluginAbility.SCROBBLE in pluginInfo.abilities) { - put( - ScrobbleAPI::class, - result.zipline.take(ScrobbleAPI_SERVICE_NAME) - ) - } - } - - serviceRegistry.putAll(apiMap) - trace("start(): API ready") - } - - private fun runLogInFlowObservers() = scope.launch { - val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI - coreAPI.loggedInFlow.collect { isLoggedIn -> - loggedInStateFlow.value = isLoggedIn - } - } - - override suspend fun start() { - lifecycleMutex.withLock { - trace("start(): entered") - if (serviceRegistry.isNotEmpty()) { - trace("start(): already started, skipping") - return - } - - logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" } - withContext(ziplineDispatcher.dispatcher) { - trace("start(): inside zipline dispatcher before loadOnce") - val result = ziplineLoader.loadOnce( - applicationName = applicationName, - manifestUrl = manifestUrl, - freshnessChecker = DefaultFreshnessCheckerNotFresh, - ) - when (result) { - is LoadResult.Success -> { - logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" } - ziplineInstance = result.zipline - // Now we consume the initializer - val initializer = result.zipline.take(Initializer_SERVICE_NAME) - // Bind host services before initialization, so plugins can use them in their initializer - bindHostServices(result.zipline) - trace("start(): calling initializer.initialize()") - runCatching { initializer.initialize() } - .onSuccess { - consumePluginServices(result) - runLogInFlowObservers() - } - .onFailure { e -> - logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" } - throw e - } - } - - is LoadResult.Failure -> { - trace("start(): loadOnce failure: ${result.exception}") - logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" } - throw result.exception - } - } - } - - } - } - - override suspend fun stop() { - lifecycleMutex.withLock { - trace("stop(): entered") - withContext(ziplineDispatcher.dispatcher) { - ziplineInstance?.close() - ziplineInstance = null - for (service in serviceRegistry.values) { - try { - trace("stop(): closing service ${service::class.simpleName}") - service.close() - } catch (_: Exception) { - trace("stop(): error closing service ${service::class.simpleName}") - } - } - } - serviceRegistry.clear() - scope.cancel() - loggedInStateFlow.value = false - ziplineDispatcher.close() - trace("stop(): completed") - } - } - - override suspend fun use(block: suspend PluginServiceScope.() -> T): T { - return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) { - // Create the scope with the current registry - val scope = PluginServiceScope(serviceRegistry) - // Execute the block with 'scope' as 'this' - scope.block() - } - } +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.zipline + +import app.cash.zipline.Zipline +import app.cash.zipline.ZiplineService +import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh +import app.cash.zipline.loader.LoadResult +import app.cash.zipline.loader.ManifestVerifier +import app.cash.zipline.loader.ZiplineLoader +import dev.krtirtho.plugin_interfaces.core.Initializer +import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI +import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI +import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI +import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI +import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI +import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME +import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME +import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.webview.WebViewController +import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI +import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI +import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI +import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI +import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI +import dev.krtirtho.spotube.modules.plugin.PluginAbility +import dev.krtirtho.spotube.modules.plugin.PluginCapability +import dev.krtirtho.spotube.modules.plugin.PluginEntry +import io.ktor.http.URLBuilder +import io.ktor.http.decodeURLQueryComponent +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okio.Path.Companion.toPath +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import kotlin.reflect.KClass + +/** + * Manages the lifecycle of a Zipline plugin. + * It runs everything on its own dispatcher (different thread) as per Zipline's requirements. + * Anything it provides, must be called within that dispatcher context. + * The [use] function must be used. + * + * The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well, + * so if they are calling something on [Dispatchers.Main], those calls should be wrapped in + * `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows. + * + * The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called. + */ +open class ZiplinePluginService( + val applicationName: String, + private val manifestUrl: String, + private val pluginInfo: PluginEntry) : PluginService, KoinComponent { + + override val pluginId: String = pluginInfo.id + + // QuickJS compile() uses deep C-level recursion on the native thread stack. + // Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules + // (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing. + // We use a custom EventListener to increase maxStackSize right after the Zipline + // instance is created, before any modules are loaded. + private val ziplineDispatcher = createZiplineDispatcher() + + private fun trace(event: String) { + logger.d { "[$applicationName] $event" } + } + + private val logger by injectLogger() + private val webViewController: WebViewController by inject() + + private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher) + private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable -> + logger.e(throwable) { "Zipline Engine Error" } + } + private val lifecycleMutex = Mutex() + private var ziplineLoader: ZiplineLoader + private var ziplineInstance: Zipline? = null + private val serviceRegistry = mutableMapOf, ZiplineService>() + + init { + val manifestPath = URLBuilder(manifestUrl) + val baseDir = + manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent + ?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.") + ziplineLoader = ZiplineLoader( + dispatcher = ziplineDispatcher.dispatcher, + manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS, + httpClient = FileSystemHTTPClient(baseDir) + ) + } + + private val realHttpClientAPI = RealHttpClientAPI() + private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id) + + private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo) + private val cryptoAPI = RealCryptoAPI(scope.coroutineContext) + private val systemInformationAPI = RealSystemInformationAPI() + + private val loggedInStateFlow = MutableStateFlow(false) + override val loggedInFlow: StateFlow = loggedInStateFlow.asStateFlow() + + private fun bindHostServices(zipline: Zipline) { + trace("initializer(): binding host APIs") + logger.d { "[$applicationName] Binding host APIs in initializer" } + try { + // Basic APIs + zipline.bind(CryptoAPI_SERVICE_NAME, cryptoAPI) + zipline.bind( + SystemInformationAPI_SERVICE_NAME, + systemInformationAPI + ) + + // Conditional APIs based on plugin capabilities + if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) { + zipline.bind( + HttpClientAPI_SERVICE_NAME, + realHttpClientAPI + ) + } + if (PluginCapability.WEBVIEW in pluginInfo.capabilities) { + zipline.bind(WebViewAPI_SERVICE_NAME, realWebViewAPI) + } + if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) { + zipline.bind( + PersistedStorageAPI_SERVICE_NAME, + persistedStorageAPI + ) + } + } catch (e: Exception) { + logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" } + throw e + } + } + + private fun consumePluginServices(result: LoadResult.Success) { + trace("start(): loadOnce success") + val apiMap = + buildMap, ZiplineService> { + put(CoreAPI::class, result.zipline.take(CoreAPI_SERVICE_NAME)) + + if (PluginAbility.METADATA in pluginInfo.abilities) { + put( + MetadataUserAPI::class, + result.zipline.take( + MetadataUserAPI_SERVICE_NAME + ) + ) + put( + MetadataTrackAPI::class, + result.zipline.take( + MetadataTrackAPI_SERVICE_NAME + ) + ) + put( + MetadataAlbumAPI::class, + result.zipline.take( + MetadataAlbumAPI_SERVICE_NAME + ) + ) + put( + MetadataArtistAPI::class, + result.zipline.take( + MetadataArtistAPI_SERVICE_NAME + ) + ) + put( + MetadataPlaylistAPI::class, + result.zipline.take( + MetadataPlaylistAPI_SERVICE_NAME + ) + ) + put( + MetadataBrowseAPI::class, + result.zipline.take( + MetadataBrowseAPI_SERVICE_NAME + ) + ) + put( + MetadataSearchAPI::class, + result.zipline.take( + MetadataSearchAPI_SERVICE_NAME + ) + ) + } + if (PluginAbility.AUDIO in pluginInfo.abilities) { + put( + AudioAPI::class, + result.zipline.take(AudioAPI_SERVICE_NAME) + ) + } + if (PluginAbility.LYRICS in pluginInfo.abilities) { + put( + LyricsAPI::class, + result.zipline.take(LyricsAPI_SERVICE_NAME) + ) + } + if (PluginAbility.SCROBBLE in pluginInfo.abilities) { + put( + ScrobbleAPI::class, + result.zipline.take(ScrobbleAPI_SERVICE_NAME) + ) + } + } + + serviceRegistry.putAll(apiMap) + trace("start(): API ready") + } + + private fun runLogInFlowObservers() = scope.launch { + val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI + coreAPI.loggedInFlow.collect { isLoggedIn -> + loggedInStateFlow.value = isLoggedIn + } + } + + override suspend fun start() { + lifecycleMutex.withLock { + trace("start(): entered") + if (serviceRegistry.isNotEmpty()) { + trace("start(): already started, skipping") + return + } + + logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" } + withContext(ziplineDispatcher.dispatcher) { + trace("start(): inside zipline dispatcher before loadOnce") + val result = ziplineLoader.loadOnce( + applicationName = applicationName, + manifestUrl = manifestUrl, + freshnessChecker = DefaultFreshnessCheckerNotFresh) + when (result) { + is LoadResult.Success -> { + logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" } + ziplineInstance = result.zipline + // Now we consume the initializer + val initializer = result.zipline.take(Initializer_SERVICE_NAME) + // Bind host services before initialization, so plugins can use them in their initializer + bindHostServices(result.zipline) + trace("start(): calling initializer.initialize()") + runCatching { initializer.initialize() } + .onSuccess { + consumePluginServices(result) + runLogInFlowObservers() + } + .onFailure { e -> + logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" } + throw e + } + } + + is LoadResult.Failure -> { + trace("start(): loadOnce failure: ${result.exception}") + logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" } + throw result.exception + } + } + } + + } + } + + override suspend fun stop() { + lifecycleMutex.withLock { + trace("stop(): entered") + withContext(ziplineDispatcher.dispatcher) { + ziplineInstance?.close() + ziplineInstance = null + for (service in serviceRegistry.values) { + try { + trace("stop(): closing service ${service::class.simpleName}") + service.close() + } catch (_: Exception) { + trace("stop(): error closing service ${service::class.simpleName}") + } + } + } + serviceRegistry.clear() + scope.cancel() + loggedInStateFlow.value = false + ziplineDispatcher.close() + trace("stop(): completed") + } + } + + override suspend fun use(block: suspend PluginServiceScope.() -> T): T { + return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) { + // Create the scope with the current registry + val scope = PluginServiceScope(serviceRegistry) + // Execute the block with 'scope' as 'this' + scope.block() + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt index 2c0fc64b..1fd4ac7a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealCryptoAPI.kt @@ -185,7 +185,6 @@ fun AESEncodingFormats.format(): AES.Key.Format { } } - class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { private val provider = CryptographyProvider.Default @@ -193,7 +192,6 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { return CryptographyRandom.nextBytes(size) } - override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray { val hasher = provider.get(algorithm.algorithm()).hasher() return hasher.hash(data) @@ -301,8 +299,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { val keyPair = provider.get(RSA.PSS).keyPairGenerator( algorithm.keySizeBits.bits, algorithm.hashAlgorithm.algorithm(), - algorithm.publicExponent.toBigInt(), - ).generateKey() + algorithm.publicExponent.toBigInt()).generateKey() Pair( keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()), @@ -366,8 +363,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { is SignAlgorithms.ECDSA -> provider.get(ECDSA).privateKeyDecoder( - algorithm.curve.curve(), - ).decodeFromByteArray( + algorithm.curve.curve()).decodeFromByteArray( algorithm.format.privateKeyFormat(), privateKey ).signatureGenerator( @@ -377,8 +373,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { is SignAlgorithms.EdDSA -> provider.get(EdDSA).privateKeyDecoder( - algorithm.curve.curve(), - ).decodeFromByteArray( + algorithm.curve.curve()).decodeFromByteArray( algorithm.format.privateKeyFormat(), privateKey ).signatureGenerator().generateSignature(data) @@ -422,8 +417,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { is SignAlgorithms.ECDSA -> provider.get(ECDSA).publicKeyDecoder( - algorithm.curve.curve(), - ).decodeFromByteArray( + algorithm.curve.curve()).decodeFromByteArray( algorithm.format.publicKeyFormat(), publicKey ).signatureVerifier( @@ -436,8 +430,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { is SignAlgorithms.EdDSA -> provider.get(EdDSA).publicKeyDecoder( - algorithm.curve.curve(), - ).decodeFromByteArray( + algorithm.curve.curve()).decodeFromByteArray( algorithm.format.publicKeyFormat(), publicKey ).signatureVerifier().verifySignature( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt index db09738a..5cd23546 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealHttpClientAPI.kt @@ -28,7 +28,6 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.takeFrom import org.koin.core.component.KoinComponent - class RealHttpClientAPI : HttpClientAPI, KoinComponent { val logger by injectLogger() val httpClient = HttpClient() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt index 80f906cb..830cb4cc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt @@ -1,74 +1,73 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.zipline.host_apis - -import dev.krtirtho.plugin_interfaces.host_apis.Cookie -import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI -import dev.krtirtho.spotube.core.webview.WebViewController -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class RealWebViewAPI( - private val scope: CoroutineScope, - private val webViewController: WebViewController, - private val pluginId: String, -) : WebViewAPI { - override fun navigateTo(url: String) { - scope.launch(Dispatchers.Main) { - webViewController.navigateTo(url, pluginId) - } - } - - override fun navigateToHTML(html: String) { - scope.launch { - webViewController.navigateToHTML(html, pluginId) - } - } - - override suspend fun getCookies(url: String): List { - return withContext(Dispatchers.Main) { - webViewController.getCookies(url) - } - } - - override suspend fun evaluateJavaScript(script: String): String? { - return withContext(Dispatchers.Main) { - webViewController.evaluateJavascript(script) - } - } - - override fun urlChangeFlow(): Flow { - return webViewController.urlChangedFlow - } - override fun webviewCreatedFlow(): Flow { - return webViewController.webviewCreatedFlow - } - override fun postMessagesFlow(): Flow { - return webViewController.postMessagesFlow - } - - override fun exitWebView() { - scope.launch(Dispatchers.Main) { - webViewController.closeWebview() - } - } - +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.zipline.host_apis + +import dev.krtirtho.plugin_interfaces.host_apis.Cookie +import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI +import dev.krtirtho.spotube.core.webview.WebViewController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RealWebViewAPI( + private val scope: CoroutineScope, + private val webViewController: WebViewController, + private val pluginId: String) : WebViewAPI { + override fun navigateTo(url: String) { + scope.launch(Dispatchers.Main) { + webViewController.navigateTo(url, pluginId) + } + } + + override fun navigateToHTML(html: String) { + scope.launch { + webViewController.navigateToHTML(html, pluginId) + } + } + + override suspend fun getCookies(url: String): List { + return withContext(Dispatchers.Main) { + webViewController.getCookies(url) + } + } + + override suspend fun evaluateJavaScript(script: String): String? { + return withContext(Dispatchers.Main) { + webViewController.evaluateJavascript(script) + } + } + + override fun urlChangeFlow(): Flow { + return webViewController.urlChangedFlow + } + override fun webviewCreatedFlow(): Flow { + return webViewController.webviewCreatedFlow + } + override fun postMessagesFlow(): Flow { + return webViewController.postMessagesFlow + } + + override fun exitWebView() { + scope.launch(Dispatchers.Main) { + webViewController.closeWebview() + } + } + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt index d3c8e92e..33667956 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt @@ -45,8 +45,7 @@ data class LRCLibResponse( val artistName: String, val albumName: String, val plainLyrics: String?, - val syncedLyrics: String?, -) + val syncedLyrics: String?) class RealLRCLibLyricsAPI : LyricsAPI { private val httpClient = HttpClient { @@ -95,7 +94,6 @@ class RealLRCLibLyricsAPI : LyricsAPI { val text = line.substring(match.range.last + 1).trim() LyricsLine(time = timeMs, text = text) }, - plainLyrics = response.plainLyrics, - ) + plainLyrics = response.plainLyrics) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt index e1268ef1..3c63c55b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCModels.kt @@ -124,16 +124,14 @@ data class MusicGatewayResponse( val externalIds: ExternalIds, val id: String, val name: String, - val popularity: Int, - ) { + val popularity: Int) { @Serializable data class Album( @SerialName("album_type") val albumType: String, val artists: List, val id: String, - val name: String, - ) + val name: String) @Serializable data class Artist( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt index a36a7ad9..e36e3ad7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt @@ -57,7 +57,6 @@ class ISRCProviders { } } - private var ifpiSessionToken: String? = null private val ifpiDefaultHeaders = mapOf( "Content-Type" to "application/json", @@ -76,7 +75,6 @@ class ISRCProviders { "User-Agent" to UserAgents.random() ) - private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) { if (ifpiSessionToken != null) return@withContext ifpiSessionToken diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt index adc839cd..92b84513 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt @@ -65,12 +65,10 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { Thumbnail( url = video.thumbnailUrl, width = 300, - height = 300, - ) + height = 300) ), externalUri = "https://www.youtube.com/watch?v=${video.id}", - confidence = confidence, - ) + confidence = confidence) } override val supportedQualities: List = listOf( @@ -79,15 +77,13 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), - ) + AudioQuality.Lossy(bitrate = 256_000)) ), AudioFormat( codec = "aac", container = "mp4", qualities = listOf( AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), - ) + AudioQuality.Lossy(bitrate = 256_000)) ) ) @@ -98,7 +94,6 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { )) val isYouTubeID = track.id.matches(youtubeIDRegex) - logger.i { "Searching streams for track(${track.id}): ${track.title} by ${ track.artists.joinToString( @@ -119,13 +114,11 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { Thumbnail( url = videoInfo.thumbnailUrl, width = 300, - height = 300, - ) + height = 300) ), externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", confidence = 1.0f, - streams = videoInfo.audioStreams, - ) + streams = videoInfo.audioStreams) ) } } @@ -165,8 +158,7 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { Thumbnail( url = videoInfo.thumbnailUrl, width = 300, - height = 300, - ) + height = 300) ), externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", confidence = source.confidence, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt index a8d2e8ec..36ff187c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumRepository.kt @@ -36,8 +36,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class AlbumRepository( val pluginManager: PluginManager, - private val libraryRepository: LibraryRepository, -) { + private val libraryRepository: LibraryRepository) { val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val plugin diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index 6b2ac18d..ed4b9a11 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -110,8 +110,6 @@ fun AlbumScreen( visible = showAddToPlaylistPicker, currentUserId = currentUserId, onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist, - ) - }, - ) + onPlaylistSelected = viewModel::addTracksToPlaylist) + }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index d27d9421..2cd84136 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -65,15 +65,13 @@ sealed interface AlbumScreenState { override val isSaved: Boolean = false, override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val isSaving: Boolean = false, - ) : Data { + override val isSaving: Boolean = false) : Data { fun toLoadingMore(): LoadingMore = LoadingMore( album = album, isSaved = isSaved, tracks = tracks, nextPagination = nextPagination, - isSaving = isSaving, - ) + isSaving = isSaving) } data class LoadingMore( @@ -81,8 +79,7 @@ sealed interface AlbumScreenState { override val isSaved: Boolean = false, override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val isSaving: Boolean = false, - ) : Data + override val isSaving: Boolean = false) : Data } data class Error(val message: String) : AlbumScreenState @@ -99,8 +96,7 @@ class AlbumViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, - private val remotePlaybackController: RemotePlaybackController, -) : ViewModel(), KoinComponent { + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(AlbumScreenState.Loading) @@ -126,8 +122,7 @@ class AlbumViewModel( audioPlayerQueue.currentQueueEntryFlow, savedTracksRepository.savedTracksIdsFlow, _blacklistedTrackIds, - _blacklistedArtistIds, - ) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> + _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> TrackOptionsContext( currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, queueTrackIds = queue.mapNotNull { entry -> @@ -135,8 +130,7 @@ class AlbumViewModel( }.toSet(), savedTrackIds = savedIds, blacklistedTrackIds = blacklistedTracks, - blacklistedArtistIds = blacklistedArtists, - ) + blacklistedArtistIds = blacklistedArtists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) init { @@ -175,8 +169,7 @@ class AlbumViewModel( album = albumInfo?.first, isSaved = albumInfo?.second ?: false, tracks = tracksResult?.items ?: emptyList(), - nextPagination = tracksResult?.nextPagination, - ) + nextPagination = tracksResult?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load album" } _state.value = AlbumScreenState.Error(e.message ?: "Unknown error") @@ -193,8 +186,7 @@ class AlbumViewModel( isSaved = currentState.isSaved, tracks = currentState.tracks + (result?.items ?: emptyList()), nextPagination = result?.nextPagination, - isSaving = currentState.isSaving, - ) + isSaving = currentState.isSaving) } }.onFailure { e -> logger.e(e) { "Failed to load more tracks" } @@ -245,8 +237,7 @@ class AlbumViewModel( type = RemoteCollectionType.Album, id = albumId, title = title, - startTrack = track, - ) + startTrack = track) } fun refresh() { @@ -319,8 +310,7 @@ class AlbumViewModel( runCatching { libraryRepository.addTracksToPlaylist( playlistId, - _tracksToAddToPlaylist.value.map { it.id }, - ) + _tracksToAddToPlaylist.value.map { it.id }) }.onFailure { e -> logger.e(e) { "Failed to add tracks to playlist" } }.onSuccess { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 5d5afc72..f274c19e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -17,6 +17,15 @@ package dev.krtirtho.spotube.modules.artist + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumTooltip +import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,19 +44,9 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PlainTooltip -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TooltipAnchorPosition -import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults -import androidx.compose.material3.rememberTooltipState + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -74,10 +73,6 @@ import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import org.koin.compose.koinInject -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ArtistCard @@ -118,17 +113,19 @@ fun ArtistScreen( val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() - BaseScaffold( - topBar = { ApplicationMainBar() } - ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + ApplicationMainBar() + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { when (val currentState = state) { is ArtistScreenState.Loading -> { - ArtistLoadingContent(innerPadding) + ArtistLoadingContent() } is ArtistScreenState.Error -> { ArtistErrorContent( - innerPadding = innerPadding, message = currentState.message, onRetry = { viewModel.refresh() } ) @@ -141,25 +138,21 @@ fun ArtistScreen( state = listState, modifier = Modifier .fillMaxSize() - .padding(innerPadding) .padding(bottom = LocalAppShellBottomInset.current + 24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { + verticalArrangement = Arrangement.spacedBy(24.dp)) { item { ArtistHeaderCard( artist = currentState.artist, isSaved = savedArtistIds.contains(currentState.artist.id), onFollowClick = viewModel::toggleSavedArtist, isBlacklisted = currentState.artist.id in blacklistedArtistIds, - onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }, - ) + onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }) } item { TopTracksHeader( onPlay = viewModel::playTopTracks, - onAddToQueue = viewModel::addTopTracksToQueue, - ) + onAddToQueue = viewModel::addTopTracksToQueue) } item { @@ -197,8 +190,7 @@ fun ArtistScreen( onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToJam = viewModel::addTracksToJam, isInJam = jamActive, - isJamGuest = isJamGuest, - ) + isJamGuest = isJamGuest) } if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) { @@ -212,8 +204,7 @@ fun ArtistScreen( ) ) }, - onLoadMore = { viewModel.loadMoreAlbums() }, - ) + onLoadMore = { viewModel.loadMoreAlbums() }) } } @@ -228,8 +219,7 @@ fun ArtistScreen( ) ) }, - onLoadMore = { viewModel.loadMoreRelatedArtists() }, - ) + onLoadMore = { viewModel.loadMoreRelatedArtists() }) } } @@ -244,73 +234,62 @@ fun ArtistScreen( ) ) }, - onLoadMore = { viewModel.loadMoreFeaturedPlaylists() }, - ) + onLoadMore = { viewModel.loadMoreFeaturedPlaylists() }) } } } } } + } AddToPlaylistPicker( visible = showAddToPlaylistPicker, currentUserId = currentUserId, onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist, - ) + onPlaylistSelected = viewModel::addTracksToPlaylist) } } @Composable -private fun ArtistLoadingContent(innerPadding: PaddingValues) { +private fun ArtistLoadingContent() { Box( modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center, - ) { + .fillMaxSize(), + contentAlignment = Alignment.Center) { SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { + verticalArrangement = Arrangement.spacedBy(24.dp)) { Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { + .padding(horizontal = 16.dp)) { Box( modifier = Modifier .size(200.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), - ) + .background(AppTheme.colors.surfaceContainerHighest)) } Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { + .padding(horizontal = 16.dp)) { Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), - ) + .padding(vertical = 8.dp)) } LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { + contentPadding = PaddingValues(horizontal = 16.dp)) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -321,26 +300,20 @@ private fun ArtistLoadingContent(innerPadding: PaddingValues) { @Composable private fun ArtistErrorContent( - innerPadding: PaddingValues, message: String, - onRetry: () -> Unit, -) { + onRetry: () -> Unit) { Box( modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center, - ) { + .fillMaxSize(), + contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( text = message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyLarge, - ) - PrimaryButton(onClick = onRetry) { + color = AppTheme.colors.error, + style = AppTheme.typography.bodyLarge) + MillenniumButton(onClick = onRetry) { Text("Retry") } } @@ -353,44 +326,34 @@ private fun ArtistHeaderCard( isSaved: Boolean, onFollowClick: () -> Unit, isBlacklisted: Boolean, - onBlacklistClick: () -> Unit, -) { + onBlacklistClick: () -> Unit) { BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val isCompact = maxWidth < 600.dp - Card( + MillenniumCard( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - shape = RoundedCornerShape(20.dp), - ) { + .padding(horizontal = 12.dp, vertical = 8.dp)) { if (isCompact) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { ArtistAvatar( artist = artist, - size = 180.dp, - ) + size = 180.dp) ArtistMeta( artist = artist, - isCompact = true, - ) + isCompact = true) ArtistHeaderActions( isSaved = isSaved, onFollowClick = onFollowClick, isBlacklisted = isBlacklisted, - onBlacklistClick = onBlacklistClick, - ) + onBlacklistClick = onBlacklistClick) } } else { Row( @@ -398,28 +361,23 @@ private fun ArtistHeaderCard( .fillMaxWidth() .padding(20.dp), horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { ArtistAvatar( artist = artist, - size = 220.dp, - ) + size = 220.dp) Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { + verticalArrangement = Arrangement.spacedBy(14.dp)) { ArtistMeta( artist = artist, - isCompact = false, - ) + isCompact = false) ArtistHeaderActions( isSaved = isSaved, onFollowClick = onFollowClick, isBlacklisted = isBlacklisted, - onBlacklistClick = onBlacklistClick, - ) + onBlacklistClick = onBlacklistClick) } } } @@ -430,30 +388,26 @@ private fun ArtistHeaderCard( @Composable private fun ArtistAvatar( artist: MetadataArtist.Detailed, - size: androidx.compose.ui.unit.Dp, -) { + size: androidx.compose.ui.unit.Dp) { Box( modifier = Modifier .size(size) .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest), - contentAlignment = Alignment.Center, - ) { + .background(AppTheme.colors.surfaceContainerHighest), + contentAlignment = Alignment.Center) { val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty() if (imageUrl.isNotBlank()) { AsyncImage( model = imageUrl, contentDescription = artist.name, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { - Icon( + MillenniumIcon( imageVector = Iconsax.User, contentDescription = artist.name, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(size * 0.4f), - ) + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(size * 0.4f)) } } } @@ -461,21 +415,18 @@ private fun ArtistAvatar( @Composable private fun ArtistMeta( artist: MetadataArtist.Detailed, - isCompact: Boolean, -) { + isCompact: Boolean) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { + verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( text = artist.name, - style = if (isCompact) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineLarge, + style = if (isCompact) AppTheme.typography.headlineSmall else AppTheme.typography.headlineLarge, fontWeight = FontWeight.SemiBold, textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = buildString { @@ -485,74 +436,66 @@ private fun ArtistMeta( append(artist.genres.joinToString(", ")) } }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start) artist.biography?.takeIf { it.isNotBlank() }?.let { Text( text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, maxLines = if (isCompact) 4 else 6, overflow = TextOverflow.Ellipsis, - textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, - ) + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start) } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ArtistHeaderActions( isSaved: Boolean, onFollowClick: () -> Unit, isBlacklisted: Boolean, - onBlacklistClick: () -> Unit, -) { + onBlacklistClick: () -> Unit) { Row( horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { if (isSaved) { - SecondaryButton(onClick = onFollowClick) { + MillenniumButton( + onClick = onFollowClick, + style = MillenniumButtonStyle.Secondary) { Text( "Following", textAlign = TextAlign.Center, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } else { - PrimaryButton(onClick = onFollowClick) { + MillenniumButton(onClick = onFollowClick) { Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) } } - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), - tooltip = { - PlainTooltip { - Text(if (isBlacklisted) "Remove from blacklist" else "Add to blacklist") - } - }, - state = rememberTooltipState() + MillenniumTooltip( + text = if (isBlacklisted) "Remove from blacklist" else "Add to blacklist" ) { if (isBlacklisted) { - SecondaryIconButton(onClick = onBlacklistClick) { - Icon( + MillenniumIconButton( + onClick = onBlacklistClick, + style = MillenniumIconButtonStyle.Secondary) { + MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, contentDescription = "Remove from blacklist", - tint = MaterialTheme.colorScheme.error, - ) + tint = AppTheme.colors.error) } } else { - SecondaryIconButton(onClick = onBlacklistClick) { - Icon( + MillenniumIconButton( + onClick = onBlacklistClick, + style = MillenniumIconButtonStyle.Secondary) { + MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, - contentDescription = "Add to blacklist", - ) + contentDescription = "Add to blacklist") } } } @@ -562,33 +505,32 @@ private fun ArtistHeaderActions( @Composable private fun TopTracksHeader( onPlay: () -> Unit, - onAddToQueue: () -> Unit, -) { + onAddToQueue: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + horizontalArrangement = Arrangement.SpaceBetween) { Text( text = "Top Tracks", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - PrimaryIconButton(onClick = onPlay) { - Icon( + MillenniumIconButton( + onClick = onPlay, + style = MillenniumIconButtonStyle.Primary) { + MillenniumIcon( imageVector = Iconsax.IconsaxPlay, - contentDescription = "Play top tracks", - ) + contentDescription = "Play top tracks") } - SecondaryIconButton(onClick = onAddToQueue) { - Icon( + MillenniumIconButton( + onClick = onAddToQueue, + style = MillenniumIconButtonStyle.Secondary) { + MillenniumIcon( imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Add top tracks to queue", - ) + contentDescription = "Add top tracks to queue") } } } @@ -598,8 +540,7 @@ private fun TopTracksHeader( private fun AlbumsSection( albums: List, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, - onLoadMore: () -> Unit, -) { + onLoadMore: () -> Unit) { val rowState = rememberLazyListState() LaunchedEffect(rowState) { @@ -624,8 +565,7 @@ private fun AlbumsSection( modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { items(albums, key = { it.id }) { album -> AlbumCard(album = album) } @@ -636,8 +576,7 @@ private fun AlbumsSection( private fun RelatedArtistsSection( artists: List, onArtistClick: (MetadataArtist.Basic) -> Unit, - onLoadMore: () -> Unit, -) { + onLoadMore: () -> Unit) { val rowState = rememberLazyListState() LaunchedEffect(rowState) { @@ -662,8 +601,7 @@ private fun RelatedArtistsSection( modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { items(artists, key = { it.id }) { artist -> ArtistCard(artist = artist) } @@ -674,8 +612,7 @@ private fun RelatedArtistsSection( private fun FeaturedPlaylistsSection( playlists: List, onPlaylistClick: (MetadataPlaylist) -> Unit, - onLoadMore: () -> Unit, -) { + onLoadMore: () -> Unit) { val rowState = rememberLazyListState() LaunchedEffect(rowState) { @@ -700,8 +637,7 @@ private fun FeaturedPlaylistsSection( modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { items(playlists, key = { it.id }) { playlist -> PlaylistCard(playlist = playlist) } @@ -712,10 +648,9 @@ private fun FeaturedPlaylistsSection( private fun SectionHeader(title: String) { Text( text = title, - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp), - ) + modifier = Modifier.padding(horizontal = 16.dp)) } private fun formatFollowers(count: Int?): String { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index 8c89001a..9fa5ac86 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -59,8 +59,7 @@ sealed interface ArtistScreenState { val relatedArtists: List, val relatedArtistsNextPagination: PaginationStrategy?, val featuredPlaylists: List, - val featuredPlaylistsNextPagination: PaginationStrategy?, - ) : ArtistScreenState + val featuredPlaylistsNextPagination: PaginationStrategy?) : ArtistScreenState data class Error(val message: String) : ArtistScreenState } @@ -75,8 +74,7 @@ class ArtistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, - private val remotePlaybackController: RemotePlaybackController, -) : ViewModel(), KoinComponent { + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(ArtistScreenState.Loading) @@ -106,8 +104,7 @@ class ArtistViewModel( audioPlayerQueue.currentQueueEntryFlow, savedTracksRepository.savedTracksIdsFlow, _blacklistedTrackIds, - _blacklistedArtistIds, - ) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> + _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> TrackOptionsContext( currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, queueTrackIds = queue.mapNotNull { entry -> @@ -115,8 +112,7 @@ class ArtistViewModel( }.toSet(), savedTrackIds = savedIds, blacklistedTrackIds = blacklistedTracks, - blacklistedArtistIds = blacklistedArtists, - ) + blacklistedArtistIds = blacklistedArtists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) init { @@ -198,8 +194,7 @@ class ArtistViewModel( if (result != null) { _state.value = currentState.copy( albums = currentState.albums + result.items, - albumsNextPagination = result.nextPagination, - ) + albumsNextPagination = result.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more albums" } @@ -219,8 +214,7 @@ class ArtistViewModel( if (result != null) { _state.value = currentState.copy( relatedArtists = currentState.relatedArtists + result.items, - relatedArtistsNextPagination = result.nextPagination, - ) + relatedArtistsNextPagination = result.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more related artists" } @@ -240,8 +234,7 @@ class ArtistViewModel( if (result != null) { _state.value = currentState.copy( featuredPlaylists = currentState.featuredPlaylists + result.items, - featuredPlaylistsNextPagination = result.nextPagination, - ) + featuredPlaylistsNextPagination = result.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more featured playlists" } @@ -254,8 +247,7 @@ class ArtistViewModel( remotePlaybackController.requestCollectionAddToQueue( RemoteCollectionType.ArtistTopTracks, artistId, - artistName, - ) + artistName) } fun playTopTracks() { @@ -263,8 +255,7 @@ class ArtistViewModel( remotePlaybackController.requestCollectionPlay( RemoteCollectionType.ArtistTopTracks, artistId, - artistName, - ) + artistName) } fun playTopTracksFromTrack(track: MetadataTrack) { @@ -273,8 +264,7 @@ class ArtistViewModel( type = RemoteCollectionType.ArtistTopTracks, id = artistId, title = artistName, - startTrack = track, - ) + startTrack = track) } fun addTracksToJam(tracks: List) { remotePlaybackController.addTracksToJam(tracks) @@ -349,8 +339,7 @@ class ArtistViewModel( runCatching { libraryRepository.addTracksToPlaylist( playlistId, - _tracksToAddToPlaylist.value.map { it.id }, - ) + _tracksToAddToPlaylist.value.map { it.id }) }.onFailure { e -> logger.e(e) { "Failed to add tracks to playlist" } }.onSuccess { @@ -389,8 +378,7 @@ class ArtistViewModel( relatedArtists = overview.relatedArtists.items, relatedArtistsNextPagination = overview.relatedArtists.nextPagination, featuredPlaylists = overview.featuredPlaylists.items, - featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination, - ) + featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination) } else { _state.value = ArtistScreenState.Error("Failed to load artist overview") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt index ee9f4d56..1a87bd0d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistRepository.kt @@ -29,8 +29,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.encodeToString open class BlacklistRepository( - private val database: Database, -) { + private val database: Database) { companion object { private val BLACKLISTED_TRACKS_KEY = stringPreferencesKey("blacklisted_tracks") private val BLACKLISTED_ARTISTS_KEY = stringPreferencesKey("blacklisted_artists") diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt index 9d2d6261..6ec6972e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistScreen.kt @@ -17,6 +17,11 @@ package dev.krtirtho.spotube.modules.blacklist + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,15 +36,13 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.remember -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.draw.clip @@ -50,9 +53,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset @@ -67,36 +67,31 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun BlacklistScreen() { val viewModel = koinViewModel() - BaseScaffold( - topBar = { - ApplicationMainBar( - backButton = true, - title = { Text("Blacklist") }, - ) - }, - ) { innerPadding -> + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + backButton = true, + title = { Text("Blacklist") }) BlacklistContent( viewModel = viewModel, - modifier = Modifier.padding(innerPadding), - ) + modifier = Modifier.weight(1f)) } } /** - * The blacklist UI without its own [BaseScaffold] / app bar, so it can be embedded + * The blacklist UI without its own app bar, so it can be embedded * as a tab in the large-screen settings layout as well as hosted by [BlacklistScreen]. */ @Composable internal fun BlacklistContent( viewModel: BlacklistViewModel, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val blacklistedTracks by viewModel.blacklistedTracks.collectAsStateWithLifecycle() val blacklistedArtists by viewModel.blacklistedArtists.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current - var filterQuery by rememberSaveable { mutableStateOf("") } - val normalizedQuery = remember(filterQuery) { filterQuery.trim().lowercase() } + val filterState = rememberTextFieldState("") + val filterQuery = filterState.text.toString() + val normalizedQuery = filterQuery.trim().lowercase() val filteredTracks = remember(blacklistedTracks, normalizedQuery) { if (normalizedQuery.isBlank()) { @@ -130,61 +125,46 @@ internal fun BlacklistContent( modifier = modifier .fillMaxSize() .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - TextField( - value = filterQuery, - onValueChange = { filterQuery = it }, + verticalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumTextField( + state = filterState, + label = "", modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Filter blacklist...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - ) - }, - ) + placeholder = "Filter blacklist...") if (isEmpty) { Box( modifier = Modifier .fillMaxSize() .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = if (hasActiveFilter) { "No blacklisted items match '$filterQuery'" } else { "No blacklisted items" }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } } else { LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues( - bottom = 16.dp + shellBottomInset, - ), - ) { + bottom = 16.dp + shellBottomInset)) { if (filteredTracks.isNotEmpty()) { item { Text( text = "Tracks", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp), - ) + style = AppTheme.typography.titleSmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp)) } items(filteredTracks, key = { "track_${it.id}" }) { track -> BlacklistedTrackRow( track = track, - onRemove = { viewModel.toggleTrack(track) }, - ) + onRemove = { viewModel.toggleTrack(track) }) } } @@ -192,16 +172,14 @@ internal fun BlacklistContent( item { Text( text = "Artists", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp), - ) + style = AppTheme.typography.titleSmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp)) } items(filteredArtists, key = { "artist_${it.id}" }) { artist -> BlacklistedArtistRow( artist = artist, - onRemove = { viewModel.toggleArtist(artist) }, - ) + onRemove = { viewModel.toggleArtist(artist) }) } } } @@ -212,21 +190,18 @@ internal fun BlacklistContent( @Composable private fun BlacklistedTrackRow( track: MetadataTrack, - onRemove: () -> Unit, -) { + onRemove: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumIcon( imageVector = Iconsax.IconsaxMusicCircle, contentDescription = "Track", modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) AsyncImage( model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url, @@ -234,32 +209,30 @@ private fun BlacklistedTrackRow( contentScale = ContentScale.Crop, modifier = Modifier .size(48.dp) - .clip(MaterialTheme.shapes.small) - .shimmerApply(), - ) + .clip(AppTheme.shapes.small) + .shimmerApply()) Column(modifier = Modifier.weight(1f)) { Text( text = track.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = track.artists.joinToString(", ") { it.name }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } - GhostIconButton(onClick = onRemove) { - Icon( + MillenniumIconButton( + onClick = onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxMusicSquareRemoveFilled, contentDescription = "Remove from blacklist", - tint = MaterialTheme.colorScheme.error, - ) + tint = AppTheme.colors.error) } } } @@ -267,28 +240,24 @@ private fun BlacklistedTrackRow( @Composable private fun BlacklistedArtistRow( artist: MetadataArtist, - onRemove: () -> Unit, -) { + onRemove: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumIcon( imageVector = Iconsax.User, contentDescription = "Artist", modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) Box( modifier = Modifier .size(48.dp) .clip(CircleShape), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { val imageUrl = artist.thumbnails.firstOrNull()?.url if (!imageUrl.isNullOrBlank()) { AsyncImage( @@ -298,38 +267,35 @@ private fun BlacklistedArtistRow( modifier = Modifier .fillMaxSize() .clip(CircleShape) - .shimmerApply(), - ) + .shimmerApply()) } else { - Icon( + MillenniumIcon( imageVector = Iconsax.User, contentDescription = artist.name, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp), - ) + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(24.dp)) } } Column(modifier = Modifier.weight(1f)) { Text( text = artist.name, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = "Artist", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } - GhostIconButton(onClick = onRemove) { - Icon( + MillenniumIconButton( + onClick = onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, contentDescription = "Remove from blacklist", - tint = MaterialTheme.colorScheme.error, - ) + tint = AppTheme.colors.error) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt index d4315086..e7ff5210 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/blacklist/BlacklistViewModel.kt @@ -27,8 +27,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class BlacklistViewModel( - private val repository: BlacklistRepository, -) : ViewModel() { + private val repository: BlacklistRepository) : ViewModel() { val blacklistedTracks: StateFlow> = repository.blacklistedTracks .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt index a6c06f0d..01b115c8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.modules.devices + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -28,13 +35,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -56,8 +60,7 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun DevicesScreen( - navigationCommands: NavigationCommands, -) { + navigationCommands: NavigationCommands) { val viewModel = koinViewModel() val devices by viewModel.devices.collectAsStateWithLifecycle() val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle() @@ -77,58 +80,51 @@ fun DevicesScreen( } } - BaseScaffold( - topBar = { - ApplicationMainBar( - backButton = true, - title = { Text("Devices") }, - actions = { - if (isDiscovering && connectingToDevice == null) { - CircularProgressIndicator( - modifier = Modifier - .size(24.dp) - .padding(end = 8.dp), - strokeWidth = 2.dp, - ) - } else { - Icon( - imageVector = Iconsax.IconsaxRefreshRight, - contentDescription = "Refresh", - modifier = Modifier - .size(24.dp) - .clickable(enabled = connectingToDevice == null) { - viewModel.startDiscovery() - }, - ) - } - }, - ) - }, - ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + ApplicationMainBar( + backButton = true, + title = { Text("Devices") }, + actions = { + if (isDiscovering && connectingToDevice == null) { + MillenniumIndeterminateProgress( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp)) + } else { + MillenniumIcon( + imageVector = Iconsax.IconsaxRefreshRight, + contentDescription = "Refresh", + modifier = Modifier + .size(24.dp) + .clickable(enabled = connectingToDevice == null) { + viewModel.startDiscovery() + }) + } + }) val shellBottomInset = LocalAppShellBottomInset.current Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding), - ) { + .weight(1f)) { // Error banner error?.let { errorMessage -> Box( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - ) { + .padding(16.dp)) { Column { Text( text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - OutlinedButton( + color = AppTheme.colors.error, + style = AppTheme.typography.bodyMedium) + MillenniumButton( onClick = { viewModel.clearError() }, modifier = Modifier.padding(top = 8.dp), - ) { + style = MillenniumButtonStyle.Secondary) { Text("Dismiss") } } @@ -141,18 +137,16 @@ fun DevicesScreen( Box( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - ) { + .padding(16.dp)) { Column { Text( text = "Connected to ${state.host}:${state.port}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - OutlinedButton( + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) + MillenniumButton( onClick = { viewModel.disconnect() }, modifier = Modifier.padding(top = 8.dp), - ) { + style = MillenniumButtonStyle.Secondary) { Text("Disconnect") } } @@ -163,20 +157,15 @@ fun DevicesScreen( modifier = Modifier .fillMaxWidth() .padding(16.dp), - contentAlignment = Alignment.CenterStart, - ) { + contentAlignment = Alignment.CenterStart) { Row( horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) + verticalAlignment = Alignment.CenterVertically) { + MillenniumIndeterminateProgress( + modifier = Modifier.size(24.dp)) Text( text = "Connecting...", - style = MaterialTheme.typography.bodyMedium, - ) + style = AppTheme.typography.bodyMedium) } } } @@ -189,8 +178,7 @@ fun DevicesScreen( modifier = Modifier .fillMaxSize() .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = if (isDiscovering) { @@ -198,16 +186,14 @@ fun DevicesScreen( } else { "No devices found" }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) if (!isDiscovering) { Text( text = "Make sure the other device has \"Allow remote control\" enabled in settings.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp), - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp)) } } } @@ -219,15 +205,12 @@ fun DevicesScreen( verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues( horizontal = 16.dp, - vertical = 8.dp, - ), - ) { + vertical = 8.dp)) { items(devices.values.toList(), key = { it.key }) { device -> DeviceRow( device = device, isConnecting = connectingToDevice?.key == device.key, - onClick = { viewModel.connectToDevice(device) }, - ) + onClick = { viewModel.connectToDevice(device) }) } item { Box(modifier = Modifier.padding(bottom = shellBottomInset)) @@ -242,54 +225,46 @@ fun DevicesScreen( private fun DeviceRow( device: DiscoveredDevice, isConnecting: Boolean, - onClick: () -> Unit, -) { + onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick, enabled = !isConnecting) .padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (isConnecting) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) + MillenniumIndeterminateProgress( + modifier = Modifier.size(24.dp)) } else { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxMirroringScreen, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) + tint = AppTheme.colors.primary) } Column(modifier = Modifier.weight(1f)) { Text( text = device.name.ifBlank { "Unknown Device" }, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = if (device.host.isNotBlank() && device.port > 0) { "${device.host}:${device.port}" } else { "Resolving..." }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) if (device.deviceId.isNotBlank()) { Text( text = "ID: ${device.deviceId.take(8)}...", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f), maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt index 435feaaf..eb2e33b1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt @@ -39,8 +39,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject class DevicesViewModel( - private val navigationCommands: NavigationCommands, -) : ViewModel(), KoinComponent { + private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent { private val logger = Logger.withTag("DevicesViewModel") private val discoveryService: DeviceDiscoveryService by inject() private val remoteControlClient: RemoteControlClient by inject() @@ -187,8 +186,7 @@ class DevicesViewModel( host = device.host, port = device.port, deviceId = deviceId, - deviceName = deviceName, - ) + deviceName = deviceName) // Clear connecting state after connection attempt // The connectionState flow will show the actual connection status diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index a8b8213e..323cebc4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -17,25 +17,28 @@ package dev.krtirtho.spotube.modules.devices + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.core.ui.base.ListRowTile -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen @@ -66,83 +69,67 @@ fun PlayDestinationPickerHost() { PlaybackDestinationAction.PlayNext -> "Play next" } - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = controller::dismissPicker, - title = { + title = "Where to $actionLabel?") { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Where to $actionLabel?", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) - }, - content = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "$actionLabel \"${pendingRequest.title}\" on:", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + text = "$actionLabel \"${pendingRequest.title}\" on:", + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) - ListRowTile( - onClick = controller::playLocally, + MillenniumListTile( + onClick = controller::playLocally, + modifier = Modifier.fillMaxWidth(), + leading = { + MillenniumIcon( + imageVector = Iconsax.IconsaxCd, + contentDescription = null, + tint = AppTheme.colors.primary) + }, + title = { + Text( + text = "This Device", + style = AppTheme.typography.bodyLarge) + }, + subtitle = { + Text( + text = "$actionLabel here", + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + }) + + if (connectionState is ConnectionState.Connected) { + MillenniumListTile( + onClick = controller::playOnRemote, modifier = Modifier.fillMaxWidth(), leading = { - Icon( - imageVector = Iconsax.IconsaxCd, + MillenniumIcon( + imageVector = Iconsax.IconsaxMirroringScreen, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) + tint = AppTheme.colors.primary) }, title = { Text( - text = "This Device", - style = MaterialTheme.typography.bodyLarge, - ) + text = remoteDeviceName, + style = AppTheme.typography.bodyLarge) }, subtitle = { Text( - text = "$actionLabel here", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) - - if (connectionState is ConnectionState.Connected) { - ListRowTile( - onClick = controller::playOnRemote, - modifier = Modifier.fillMaxWidth(), - leading = { - Icon( - imageVector = Iconsax.IconsaxMirroringScreen, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - title = { - Text( - text = remoteDeviceName, - style = MaterialTheme.typography.bodyLarge, - ) - }, - subtitle = { - Text( - text = "$actionLabel on the connected device", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) - } + text = "$actionLabel on the connected device", + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + }) } - }, - actions = { - TextButton(onClick = controller::dismissPicker) { + + MillenniumButton( + onClick = controller::dismissPicker, + style = MillenniumButtonStyle.Quiet) { Text("Cancel") } - }, - ) + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index b0b42d5b..30cb314d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -17,6 +17,14 @@ package dev.krtirtho.spotube.modules.devices + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSlider +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -37,16 +45,11 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -64,13 +67,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.RemoteQueueEntry -import dev.krtirtho.spotube.core.ui.base.BaseUITheme -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.ListRowTile -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton -import dev.krtirtho.spotube.core.ui.base.Slider import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet @@ -78,7 +74,6 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay @@ -89,11 +84,9 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh import org.koin.compose.viewmodel.koinViewModel import kotlin.time.Duration.Companion.milliseconds -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RemoteControlScreen( - onDisconnect: () -> Unit, -) { + onDisconnect: () -> Unit) { val viewModel = koinViewModel() val playerState by viewModel.playerState.collectAsStateWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() @@ -105,44 +98,40 @@ fun RemoteControlScreen( // stretch awkwardly on large screens. Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter, - ) { - BaseScaffold( + contentAlignment = Alignment.TopCenter) { + Column( modifier = Modifier - .fillMaxHeight(), - topBar = { - ApplicationMainBar( - title = { Text("Remote Control") }, - backButton = true, - actions = { - GhostIconButton( - onClick = viewModel::toggleQueueVisibility, - ) { - Icon( - imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = "Queue", - tint = if (isQueueVisible) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - GhostIconButton( - onClick = { - viewModel.disconnect() - onDisconnect() - } - ) { - Icon( - imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Disconnect", - ) - } + .fillMaxHeight() + .background(MillenniumTheme.colors.background)) { + ApplicationMainBar( + title = { Text("Remote Control") }, + backButton = true, + actions = { + MillenniumIconButton( + onClick = viewModel::toggleQueueVisibility, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = "Queue", + tint = if (isQueueVisible) { + AppTheme.colors.primary + } else { + AppTheme.colors.onSurfaceVariant + }) } - ) - } - ) { padding -> + MillenniumIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = "Disconnect") + } + } + ) + Box(modifier = Modifier.weight(1f)) { when (connectionState) { is ConnectionState.Connected -> { RemoteControlContent( @@ -154,14 +143,13 @@ fun RemoteControlScreen( onSetVolume = viewModel::setVolume, onToggleShuffle = viewModel::toggleShuffle, onCycleLoopMode = viewModel::cycleLoopMode, - modifier = Modifier.padding(padding).padding(bottom = shellBottomInset) + modifier = Modifier.padding(bottom = shellBottomInset) ) } is ConnectionState.Connecting -> { Box( modifier = Modifier .fillMaxSize() - .padding(padding) .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { @@ -172,7 +160,6 @@ fun RemoteControlScreen( Box( modifier = Modifier .fillMaxSize() - .padding(padding) .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { @@ -183,7 +170,6 @@ fun RemoteControlScreen( Box( modifier = Modifier .fillMaxSize() - .padding(padding) .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { @@ -191,6 +177,7 @@ fun RemoteControlScreen( } } } + } } } @@ -203,8 +190,7 @@ fun RemoteControlScreen( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - onClick = { viewModel.toggleQueueVisibility() }, - ) + onClick = { viewModel.toggleQueueVisibility() }) ) } @@ -212,18 +198,15 @@ fun RemoteControlScreen( Box( modifier = Modifier .fillMaxSize() - .padding(bottom = shellBottomInset), - ) { + .padding(bottom = shellBottomInset)) { QueueSheet( isVisible = isQueueVisible, onDismiss = { viewModel.toggleQueueVisibility() }, - modifier = Modifier.fillMaxSize(), - ) { + modifier = Modifier.fillMaxSize()) { RemoteQueueSection( queueState = queueState, onPlayQueueItem = viewModel::playQueueItem, - onRemoveQueueItem = viewModel::removeQueueItem, - ) + onRemoveQueueItem = viewModel::removeQueueItem) } } } @@ -238,8 +221,7 @@ private fun RemoteControlContent( onSetVolume: (Float) -> Unit, onToggleShuffle: () -> Unit, onCycleLoopMode: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { Box (modifier = Modifier.fillMaxSize()) { Column( modifier = modifier @@ -247,8 +229,7 @@ private fun RemoteControlContent( .padding(horizontal = 24.dp) .widthIn(max = 480.dp) .align(Alignment.TopCenter), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { Spacer(modifier = Modifier.height(32.dp)) // Album art @@ -257,26 +238,22 @@ private fun RemoteControlContent( .fillMaxWidth() .aspectRatio(1f) .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { + .background(AppTheme.colors.surfaceVariant)) { if (playerState.currentTrackCoverUrl != null) { AsyncImage( model = playerState.currentTrackCoverUrl, contentDescription = "Album cover", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( + contentAlignment = Alignment.Center) { + MillenniumIcon( imageVector = Iconsax.IconsaxMusicFilter, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(48.dp), - ) + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(48.dp)) } } } @@ -286,38 +263,34 @@ private fun RemoteControlContent( // Track info Column( modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { Text( text = playerState.currentTrackTitle ?: "Unknown Track", - style = MaterialTheme.typography.headlineSmall, + style = AppTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.height(8.dp)) Text( text = playerState.currentTrackArtists ?: "Unknown Artist", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) if (playerState.currentTrackAlbum != null) { Spacer(modifier = Modifier.height(4.dp)) Text( text = playerState.currentTrackAlbum!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.7f), textAlign = TextAlign.Center, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } @@ -325,29 +298,25 @@ private fun RemoteControlContent( // Seek bar Column( - modifier = Modifier.fillMaxWidth(), - ) { - Slider( + modifier = Modifier.fillMaxWidth()) { + MillenniumSlider( value = playerState.positionMs.toFloat(), onValueChange = { onSeek(it.toLong()) }, + label = "Seek", valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), - modifier = Modifier.fillMaxWidth(), - ) + modifier = Modifier.fillMaxWidth()) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { + horizontalArrangement = Arrangement.SpaceBetween) { Text( text = formatDuration(playerState.positionMs), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) Text( text = formatDuration(playerState.durationMs), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } } @@ -357,85 +326,67 @@ private fun RemoteControlContent( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { // Shuffle - IconButton( + MillenniumIconButton( onClick = onToggleShuffle, - modifier = Modifier.size(48.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle", tint = if (playerState.shuffleEnabled) { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) + AppTheme.colors.onSurfaceVariant + }) } // Skip previous - IconButton( + MillenniumIconButton( onClick = onSkipPrevious, - modifier = Modifier.size(56.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxPrevious, contentDescription = "Previous", - modifier = Modifier.size(32.dp), - ) + modifier = Modifier.size(32.dp)) } // Play/Pause - val baseTheme = LocalBaseUITheme.current - val circlePrimaryIconTheme = remember(baseTheme) { - baseTheme.iconButtons.primary.copy( - shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) - ) - } - PrimaryIconButton( + MillenniumIconButton( onClick = onTogglePlayPause, - modifier = Modifier.size(72.dp), - theme = circlePrimaryIconTheme, - ) { - Icon( + style = MillenniumIconButtonStyle.Primary) { + MillenniumIcon( imageVector = if (playerState.isPlaying) { Iconsax.IconsaxPause } else { Iconsax.IconsaxPlay }, contentDescription = if (playerState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(40.dp), - ) + modifier = Modifier.size(40.dp)) } // Skip next - IconButton( + MillenniumIconButton( onClick = onSkipNext, - modifier = Modifier.size(56.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxNext, contentDescription = "Next", - modifier = Modifier.size(32.dp), - ) + modifier = Modifier.size(32.dp)) } // Loop mode - IconButton( + MillenniumIconButton( onClick = onCycleLoopMode, - modifier = Modifier.size(48.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxRepeateMusic, contentDescription = "Loop mode", tint = if (playerState.loopMode != "none") { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) + AppTheme.colors.onSurfaceVariant + }) } } @@ -445,21 +396,19 @@ private fun RemoteControlContent( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumIcon( imageVector = Iconsax.IconsaxVolumeHigh, contentDescription = "Volume", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp), - ) + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(24.dp)) - Slider( + MillenniumSlider( value = playerState.volume, onValueChange = onSetVolume, + label = "Volume", valueRange = 0f..1f, - modifier = Modifier.weight(1f), - ) + modifier = Modifier.weight(1f)) } Spacer(modifier = Modifier.height(32.dp)) @@ -471,42 +420,35 @@ private fun RemoteControlContent( private fun RemoteQueueSection( queueState: RemoteQueueState, onPlayQueueItem: (Int) -> Unit, - onRemoveQueueItem: (String) -> Unit, -) { + onRemoveQueueItem: (String) -> Unit) { Column( modifier = Modifier .fillMaxSize() .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { + verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( text = "Queue", - style = MaterialTheme.typography.titleLarge, - ) + style = AppTheme.typography.titleLarge) if (queueState.entries.isEmpty()) { Text( text = "No queue entries", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } else { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + verticalArrangement = Arrangement.spacedBy(4.dp)) { itemsIndexed( items = queueState.entries, - key = { index, entry -> "${entry.mediaUrl}@$index" }, - ) { index, entry -> + key = { index, entry -> "${entry.mediaUrl}@$index" }) { index, entry -> RemoteQueueItemRow( entry = entry, index = index, isCurrent = index == queueState.currentIndex, onPlayClick = { onPlayQueueItem(index) }, - onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) }, - ) + onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) }) } } } @@ -519,11 +461,10 @@ private fun RemoteQueueItemRow( index: Int, isCurrent: Boolean, onPlayClick: () -> Unit, - onRemoveClick: () -> Unit, -) { + onRemoveClick: () -> Unit) { var showMenu by remember { mutableStateOf(false) } - ListRowTile( + MillenniumListTile( onClick = onPlayClick, selected = isCurrent, modifier = Modifier, @@ -531,82 +472,67 @@ private fun RemoteQueueItemRow( Box( modifier = Modifier .size(48.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center) { if (entry.coverUrl != null) { AsyncImage( model = entry.coverUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = "${index + 1}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) } } }, title = { Text( text = entry.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (isCurrent) { - MaterialTheme.colorScheme.onSecondaryContainer + AppTheme.colors.onSecondaryContainer } else { - MaterialTheme.colorScheme.onSurface - }, - ) + AppTheme.colors.onSurface + }) }, subtitle = { Text( text = entry.artists, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) }, trailing = { Text( text = formatDuration(entry.durationMs), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) - Box { - GhostIconButton( - onClick = { showMenu = true }, - modifier = Modifier.size(36.dp), - ) { - Icon( - Iconsax.Iconsax3DotsMore, - contentDescription = "More options", - modifier = Modifier.size(18.dp), - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Remove from queue") }, - onClick = { - onRemoveClick() - showMenu = false - }, - leadingIcon = { - Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) - }, - ) - } - } + MillenniumDropdownMenu( + expanded = showMenu, + onExpandedChange = { showMenu = it }, + items = listOf("Remove from queue"), + onItemClick = { + onRemoveClick() + showMenu = false + }, + anchor = { + MillenniumIconButton( + onClick = { showMenu = true }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options", + modifier = Modifier.size(18.dp)) + } + }) } ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt index 9e329f26..7c6f1557 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt @@ -44,13 +44,11 @@ data class RemotePlayerState( val currentTrackTitle: String? = null, val currentTrackArtists: String? = null, val currentTrackAlbum: String? = null, - val currentTrackCoverUrl: String? = null, -) + val currentTrackCoverUrl: String? = null) data class RemoteQueueState( val entries: List = emptyList(), - val currentIndex: Int = -1, -) + val currentIndex: Int = -1) class RemoteControlViewModel : ViewModel(), KoinComponent { private val logger = Logger.withTag("RemoteControlViewModel") @@ -105,16 +103,14 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { currentTrackTitle = event.currentTrackTitle, currentTrackArtists = event.currentTrackArtists, currentTrackAlbum = event.currentTrackAlbum, - currentTrackCoverUrl = event.currentTrackCoverUrl, - ) + currentTrackCoverUrl = event.currentTrackCoverUrl) } } private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) { _queueState.value = RemoteQueueState( entries = event.entries, - currentIndex = event.currentIndex, - ) + currentIndex = event.currentIndex) } fun togglePlayPause() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt index cc1aea59..8330c0e8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadManager.kt @@ -68,8 +68,7 @@ data class DownloadItem( val totalBytes: Long = 0L, val downloadedBytes: Long = 0L, val errorMessage: String? = null, - val track: MetadataTrack? = null, -) + val track: MetadataTrack? = null) sealed interface DownloadStatus { data object Queued : DownloadStatus @@ -131,8 +130,7 @@ class DownloadManager( title = track.title, artists = artistNames, album = track.album?.title, - track = track, - ) + track = track) downloadsMap.update { it + (id to item) } scope.launch { processQueue() } @@ -158,8 +156,7 @@ class DownloadManager( status = DownloadStatus.Queued, progress = 0f, downloadedBytes = 0L, - errorMessage = null, - )) + errorMessage = null)) } scope.launch { processQueue() } } @@ -203,8 +200,7 @@ class DownloadManager( map[item.id]?.let { map + (item.id to it.copy( status = DownloadStatus.Failed("No track metadata"), - errorMessage = "No track metadata", - )) + errorMessage = "No track metadata")) } ?: map } return @@ -218,8 +214,7 @@ class DownloadManager( map[item.id]?.let { map + (item.id to it.copy( status = DownloadStatus.Failed("Could not resolve stream URL"), - errorMessage = "Could not resolve stream URL. Try playing the track first.", - )) + errorMessage = "Could not resolve stream URL. Try playing the track first.")) } ?: map } return @@ -263,8 +258,7 @@ class DownloadManager( map[item.id]?.let { map + (item.id to it.copy( status = DownloadStatus.Failed(e.message ?: "Unknown error"), - errorMessage = e.message, - )) + errorMessage = e.message)) } ?: map } } finally { @@ -279,8 +273,7 @@ class DownloadManager( item: DownloadItem, tempDir: Path, downloadDir: Path, - totalSize: Long, - ) { + totalSize: Long) { val segmentSize = totalSize / SEGMENT_COUNT val segments = (0 until SEGMENT_COUNT).map { i -> val start = i * segmentSize @@ -317,8 +310,7 @@ class DownloadManager( item: DownloadItem, segment: Segment, outputPath: Path, - totalSize: Long, - ) { + totalSize: Long) { val response = httpClient.get(url) { headers { append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}") @@ -341,8 +333,7 @@ class DownloadManager( filename: String, item: DownloadItem, tempDir: Path, - downloadDir: Path, - ) { + downloadDir: Path) { val tempFile = tempDir / "${item.id}.tmp" val response = httpClient.get(url) @@ -373,8 +364,7 @@ class DownloadManager( map + (id to current.copy( progress = progress, downloadedBytes = newDownloaded, - totalBytes = totalSize, - )) + totalBytes = totalSize)) } ?: map } } @@ -423,8 +413,7 @@ class DownloadManager( album = item.album, trackNumber = track.trackNumber, discNumber = track.discNumber, - coverBytes = coverBytes, - ) + coverBytes = coverBytes) } catch (e: Exception) { logger.w(e) { "Failed to write audio metadata for ${item.title}" } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt index fa26af13..9168226f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadProgressIcon.kt @@ -17,20 +17,25 @@ package dev.krtirtho.spotube.modules.downloads + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme + +import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -46,9 +51,8 @@ fun DownloadProgressIcon( modifier: Modifier = Modifier, size: Dp = 24.dp, strokeWidth: Dp = 2.dp, - tint: Color = MaterialTheme.colorScheme.onSurface, - progressTint: Color = MaterialTheme.colorScheme.primary, -) { + tint: Color = AppTheme.colors.onSurface, + progressTint: Color = AppTheme.colors.primary) { val downloadsViewModel = koinViewModel() val downloads by downloadsViewModel.downloads.collectAsStateWithLifecycle() @@ -68,33 +72,36 @@ fun DownloadProgressIcon( modifier = modifier.size(size), contentAlignment = Alignment.Center ) { - CircularProgressIndicator( - progress = { progress }, - modifier = Modifier.size(size), - strokeWidth = strokeWidth, - color = progressTint, - ) - Icon( + Canvas(Modifier.size(size)) { + val diameter = this.size.minDimension + val stroke = strokeWidth.toPx() + drawArc( + color = progressTint, + startAngle = -90f, + sweepAngle = 360f * progress, + useCenter = false, + style = Stroke(width = stroke), + topLeft = Offset(stroke / 2f, stroke / 2f), + size = Size(diameter - stroke, diameter - stroke)) + } + MillenniumIcon( imageVector = icon, contentDescription = contentDescription, tint = tint, - modifier = Modifier.size(size * 0.6f), - ) + modifier = Modifier.size(size * 0.6f)) } } else { - Icon( + MillenniumIcon( imageVector = icon, contentDescription = contentDescription, tint = tint, - modifier = modifier.size(size), - ) + modifier = modifier.size(size)) } } @Composable fun DownloadBadgeIndicator( - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val downloadsViewModel = koinViewModel() val downloads by downloadsViewModel.downloads.collectAsStateWithLifecycle() @@ -105,7 +112,7 @@ fun DownloadBadgeIndicator( modifier = modifier .size(8.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) + .background(AppTheme.colors.primary) ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt index 5d8b8dcc..b8cad5cf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsScreen.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.modules.downloads + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumProgress import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -31,12 +38,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -70,26 +75,26 @@ fun DownloadsScreen(modifier: Modifier = Modifier) { Box(modifier = Modifier.fillMaxSize()) { Column( - modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), - ) { + modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter)) { val hasCompleted = downloads.any { it.status == DownloadStatus.Completed } if (hasCompleted) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = viewModel::clearCompleted) { - Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Text( - text = " Clear completed", - style = MaterialTheme.typography.labelLarge, - ) + horizontalArrangement = Arrangement.End) { + MillenniumButton( + onClick = viewModel::clearCompleted, + style = MillenniumButtonStyle.Quiet) { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( + imageVector = Iconsax.IconsaxTrash, + contentDescription = null, + modifier = Modifier.size(16.dp)) + Text( + text = " Clear completed", + style = AppTheme.typography.labelLarge) + } } } } @@ -97,26 +102,22 @@ fun DownloadsScreen(modifier: Modifier = Modifier) { if (downloads.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = "No downloads yet", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } } else { LazyColumn( contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { items(downloads, key = { it.id }) { item -> DownloadListRow( item = item, onCancel = { viewModel.cancel(item.id) }, onRetry = { viewModel.retry(item.id) }, - onRemove = { viewModel.remove(item.id) }, - ) + onRemove = { viewModel.remove(item.id) }) } } } @@ -129,24 +130,22 @@ private fun DownloadListRow( item: DownloadItem, onCancel: () -> Unit, onRetry: () -> Unit, - onRemove: () -> Unit, -) { + onRemove: () -> Unit) { val rowBackgroundColor = when (item.status) { - is DownloadStatus.Failed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f) - is DownloadStatus.Downloading -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - else -> MaterialTheme.colorScheme.surface + is DownloadStatus.Failed -> AppTheme.colors.errorContainer.copy(alpha = 0.2f) + is DownloadStatus.Downloading -> AppTheme.colors.primaryContainer.copy(alpha = 0.15f) + else -> AppTheme.colors.surface } Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp) - .clip(MaterialTheme.shapes.small) + .clip(AppTheme.shapes.small) .background(rowBackgroundColor) .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(10.dp)) { val imageUrl = (item.track?.album?.thumbnails ?: item.track?.thumbnails) ?.firstOrNull()?.url @@ -162,117 +161,110 @@ private fun DownloadListRow( Box( modifier = Modifier .size(44.dp) - .clip(MaterialTheme.shapes.small), - contentAlignment = Alignment.Center, - ) { + .clip(AppTheme.shapes.small), + contentAlignment = Alignment.Center) { AsyncImage( model = imageRequest, contentDescription = item.title, contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) + modifier = Modifier.fillMaxSize()) if (item.status == DownloadStatus.Downloading) { - LinearProgressIndicator( - progress = { item.progress }, + MillenniumProgress( + progress = item.progress, modifier = Modifier .fillMaxWidth() .height(3.dp) - .align(Alignment.BottomCenter), - ) + .align(Alignment.BottomCenter)) } } Column(modifier = Modifier.weight(1f)) { Text( text = item.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = item.artists, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) when (item.status) { is DownloadStatus.Downloading -> { val percent = (item.progress * 100).toInt() Text( text = "Downloading... $percent%", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } is DownloadStatus.Queued -> { Text( text = "Queued", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } is DownloadStatus.Failed -> { Text( text = "Failed: ${item.errorMessage ?: "Unknown error"}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.error, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } is DownloadStatus.Cancelled -> { Text( text = "Cancelled", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } is DownloadStatus.Completed -> { Text( text = "Completed", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary) } } } when (item.status) { is DownloadStatus.Downloading, is DownloadStatus.Queued -> { - IconButton(onClick = onCancel) { - Icon( + MillenniumIconButton( + onClick = onCancel, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxCloseSquare, contentDescription = "Cancel", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } is DownloadStatus.Failed, is DownloadStatus.Cancelled -> { - IconButton(onClick = onRetry) { - Icon( + MillenniumIconButton( + onClick = onRetry, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxRefreshRight, contentDescription = "Retry", - tint = MaterialTheme.colorScheme.primary, - ) + tint = AppTheme.colors.primary) } } is DownloadStatus.Completed -> { - IconButton(onClick = onRemove) { - Icon( + MillenniumIconButton( + onClick = onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxTrash, contentDescription = "Remove", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt index 24d8ff86..b1aef155 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/downloads/DownloadsViewModel.kt @@ -25,13 +25,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn class DownloadsViewModel( - private val downloadManager: DownloadManager, -) : ViewModel() { + private val downloadManager: DownloadManager) : ViewModel() { val downloads: StateFlow> = downloadManager.downloadsFlow.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), - emptyList(), - ) + emptyList()) fun downloadTrack(track: MetadataTrack) { downloadManager.enqueue(track) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt index 4b6782fe..3ab5a299 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt @@ -17,6 +17,14 @@ package dev.krtirtho.spotube.modules.home + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.theme.MillenniumTheme +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,14 +38,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumPullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -55,7 +59,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowse import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.ui.base.ChipTab import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ArtistCard @@ -79,8 +82,6 @@ import org.koin.compose.koinInject import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map - -@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen(viewModel: HomeScreenViewModel) { val navigationCommands = koinInject() @@ -120,58 +121,56 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { } } - BaseScaffold( - topBar = { - ApplicationMainBar( - backButton = false, - title = { - Text("Browse") - }, - actions = { - if (!isDesktop) { - IconButton(onClick = { navigationCommands.navigateTo(Routes.Devices) }) { - Icon( - imageVector = Iconsax.IconsaxMirroringScreen, - contentDescription = "Devices", - ) - } - IconButton(onClick = { navigationCommands.navigateTo(Routes.Jam) }) { - Icon( - imageVector = Iconsax.User, - contentDescription = "Group Jam", - ) - } + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + ApplicationMainBar( + backButton = false, + title = { + Text("Browse") + }, + actions = { + if (!isDesktop) { + MillenniumIconButton( + onClick = { navigationCommands.navigateTo(Routes.Devices) }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = "Devices") } - }, - ) - }, - ) { innerPadding -> + MillenniumIconButton( + onClick = { navigationCommands.navigateTo(Routes.Jam) }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = Iconsax.User, + contentDescription = "Group Jam") + } + } + }) Box( modifier = Modifier .fillMaxSize() - .padding(innerPadding) + .weight(1f) ) { if (isDesktop) { HomeContent( listState = listState, state = state, onRetry = { viewModel.refresh() }, - onGenreSelected = { viewModel.selectGenre(it) }, - ) + onGenreSelected = { viewModel.selectGenre(it) }) } else { - PullToRefreshBox( + MillenniumPullToRefreshBox( isRefreshing = state is HomeScreenState.Loading, onRefresh = { viewModel.refresh() }, - modifier = Modifier.fillMaxSize(), - ) { + modifier = Modifier.fillMaxSize()) { HomeContent( listState = listState, state = state, onRetry = { viewModel.refresh() }, - onGenreSelected = { viewModel.selectGenre(it) }, - ) + onGenreSelected = { viewModel.selectGenre(it) }) } } @@ -189,8 +188,7 @@ private fun HomeContent( listState: androidx.compose.foundation.lazy.LazyListState, state: HomeScreenState, onRetry: () -> Unit, - onGenreSelected: (String) -> Unit, -) { + onGenreSelected: (String) -> Unit) { val shellBottomInset = LocalAppShellBottomInset.current val contentPadding = remember(shellBottomInset) { PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) @@ -200,37 +198,31 @@ private fun HomeContent( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { + verticalArrangement = Arrangement.spacedBy(24.dp)) { when (state) { is HomeScreenState.Loading -> { item { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + verticalArrangement = Arrangement.spacedBy(16.dp)) { repeat(3) { SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { Box( modifier = Modifier .fillMaxWidth() .height(180.dp) - .padding(horizontal = 16.dp), - ) + .padding(horizontal = 16.dp)) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { + contentPadding = PaddingValues(horizontal = 16.dp)) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -244,8 +236,7 @@ private fun HomeContent( item { ErrorDisplay( errorMessage = state.message, - onRetry = onRetry, - ) + onRetry = onRetry) } } @@ -261,8 +252,7 @@ private fun HomeContent( GenreTabs( genres = state.genres, selectedGenreId = state.selectedGenreId, - onGenreSelected = onGenreSelected, - ) + onGenreSelected = onGenreSelected) } } @@ -273,24 +263,20 @@ private fun HomeContent( SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { Box( modifier = Modifier .fillMaxWidth() .height(180.dp) - .padding(horizontal = 16.dp), - ) + .padding(horizontal = 16.dp)) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { + contentPadding = PaddingValues(horizontal = 16.dp)) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -301,8 +287,7 @@ private fun HomeContent( HomeSection( title = section.title, subtitle = section.description, - items = section.items, - ) + items = section.items) } } @@ -311,24 +296,20 @@ private fun HomeContent( SkeletonTree(true) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { Box( modifier = Modifier .fillMaxWidth() .height(180.dp) - .padding(horizontal = 16.dp), - ) + .padding(horizontal = 16.dp)) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { + contentPadding = PaddingValues(horizontal = 16.dp)) { items(4) { PlayableCard( title = "Item Title", subtitle = "Subtitle", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -346,36 +327,30 @@ private fun HomeSection( title: String, subtitle: String? = null, items: List, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val rowState = rememberLazyListState() Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { if (!subtitle.isNullOrEmpty()) Text( text = subtitle, - style = MaterialTheme.typography.labelMedium.copy( - color = MaterialTheme.colorScheme.secondary, - fontWeight = FontWeight.Medium, - ), - modifier = Modifier.padding(horizontal = 16.dp), - ) + style = AppTheme.typography.labelMedium.copy( + color = AppTheme.colors.secondary, + fontWeight = FontWeight.Medium), + modifier = Modifier.padding(horizontal = 16.dp)) Text( text = title, - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp), - ) + modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp)) LazyRow( state = rowState, modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { items(items) { browseItem -> when (browseItem) { is MetadataBrowseItem.Album -> AlbumCard(album = browseItem.data) @@ -393,22 +368,24 @@ private fun HomeSection( private fun GenreTabs( genres: List, selectedGenreId: String?, - onGenreSelected: (String) -> Unit, -) { + onGenreSelected: (String) -> Unit) { val rowState = rememberLazyListState() LazyRow( state = rowState, modifier = Modifier.dragScrollable(rowState), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(6.dp)) { items(genres) { genre -> - ChipTab( - text = genre.name, - selected = selectedGenreId == genre.id, + MillenniumButton( onClick = { onGenreSelected(genre.id) }, - ) + style = if (selectedGenreId == genre.id) { + MillenniumButtonStyle.Primary + } else { + MillenniumButtonStyle.Secondary + }) { + Text(genre.name) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt index ecffbd25..5255c8dc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt @@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch - @OptIn(ExperimentalCoroutinesApi::class) class HomeScreenRepository( private val pluginManager: PluginManager diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt index f77c8d86..667fe23f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt @@ -51,15 +51,13 @@ sealed interface HomeScreenState { override val genres: List, override val selectedGenreId: String?, override val browseSections: Map?>, - override val paginationStrategies: Map, - ) : Data { + override val paginationStrategies: Map) : Data { fun toLoadingMore(): LoadingMore = LoadingMore( featuredItems = featuredItems, genres = genres, selectedGenreId = selectedGenreId, browseSections = browseSections, - paginationStrategies = paginationStrategies, - ) + paginationStrategies = paginationStrategies) } data class LoadingMore( @@ -67,8 +65,7 @@ sealed interface HomeScreenState { override val genres: List, override val selectedGenreId: String?, override val browseSections: Map?>, - override val paginationStrategies: Map, - ) : Data + override val paginationStrategies: Map) : Data } data class Error(val message: String) : HomeScreenState @@ -111,8 +108,7 @@ class HomeScreenViewModel( genres = genres, selectedGenreId = firstGenreId, browseSections = browseSections, - paginationStrategies = paginationStrategies, - ) + paginationStrategies = paginationStrategies) }.onFailure { e -> logger.e(e) { "Failed to load home screen data" } state.value = HomeScreenState.Error(e.message ?: "Unknown error") @@ -135,8 +131,7 @@ class HomeScreenViewModel( genres = current.genres, selectedGenreId = genreId, browseSections = current.browseSections + (genreId to (result?.items ?: emptyList())), - paginationStrategies = current.paginationStrategies + (genreId to result?.nextPagination), - ) + paginationStrategies = current.paginationStrategies + (genreId to result?.nextPagination)) } } } @@ -156,8 +151,7 @@ class HomeScreenViewModel( genres = currentState.genres, selectedGenreId = genreId, browseSections = currentState.browseSections + (genreId to (existingSections + (result?.items ?: emptyList()))), - paginationStrategies = currentState.paginationStrategies + (genreId to result?.nextPagination), - ) + paginationStrategies = currentState.paginationStrategies + (genreId to result?.nextPagination)) } }.onFailure { e -> logger.e(e) { "Failed to load more home screen data" } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt index f05cbc50..8693e7d3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.modules.jam +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -25,22 +26,24 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSegmentedButton +import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults +import org.spotube.millennium.composables.MillenniumSegmentedButtonRow +import org.spotube.millennium.composables.MillenniumTextField + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -65,29 +68,26 @@ import org.koin.compose.viewmodel.koinViewModel */ @Composable fun JamScreen( - navigationCommands: NavigationCommands, -) { + navigationCommands: NavigationCommands) { val viewModel = koinViewModel() val state by viewModel.uiState.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current - BaseScaffold( - topBar = { - ApplicationMainBar( - backButton = true, - title = { Text("Group Jam") }, - ) - }, - ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + ApplicationMainBar( + backButton = true, + title = { Text("Group Jam") }) Column( modifier = Modifier - .fillMaxSize() - .padding(innerPadding) + .fillMaxWidth() + .weight(1f) .padding(16.dp) .padding(bottom = shellBottomInset) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + verticalArrangement = Arrangement.spacedBy(16.dp)) { state.error?.let { error -> ErrorBanner(text = error, onDismiss = viewModel::clearError) } @@ -96,16 +96,14 @@ fun JamScreen( !state.isActive -> CreateOrJoinView( state = state, onCreate = viewModel::createSession, - onJoin = viewModel::joinWithCode, - ) + onJoin = viewModel::joinWithCode) else -> SessionView( state = state, onShareCode = viewModel::shareRoomCode, onLeave = viewModel::leave, onKick = viewModel::kickParticipant, - onBan = viewModel::banParticipant, - ) + onBan = viewModel::banParticipant) } } } @@ -116,10 +114,11 @@ private fun ErrorBanner(text: String, onDismiss: () -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( text = text, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - OutlinedButton(onClick = onDismiss) { + color = AppTheme.colors.error, + style = AppTheme.typography.bodyMedium) + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text("Dismiss") } } @@ -129,42 +128,36 @@ private fun ErrorBanner(text: String, onDismiss: () -> Unit) { private fun CreateOrJoinView( state: JamUiState, onCreate: () -> Unit, - onJoin: (String) -> Unit, -) { + onJoin: (String) -> Unit) { var tab by rememberSaveable { mutableIntStateOf(0) } - var pasted by rememberSaveable { mutableStateOf("") } + val codeState = rememberTextFieldState() Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.", - style = MaterialTheme.typography.titleMedium, - ) + style = AppTheme.typography.titleMedium) if (!state.brokerConfigured) { Text( text = "No jam broker configured — set one up in Settings to host or join a session.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.error) } else { Text( text = "Broker: ${state.brokerHost}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - SegmentedButton( + MillenniumSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + MillenniumSegmentedButton( selected = tab == 0, onClick = { tab = 0 }, - shape = SegmentedButtonDefaults.itemShape(0, 2), - ) { Text("Create") } - SegmentedButton( + shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)) { Text("Create") } + MillenniumSegmentedButton( selected = tab == 1, onClick = { tab = 1 }, - shape = SegmentedButtonDefaults.itemShape(1, 2), - ) { Text("Join") } + shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)) { Text("Join") } } if (tab == 0) { @@ -172,13 +165,11 @@ private fun CreateOrJoinView( Text( text = "Start a session as the host. You'll get a 6-character room code to " + "share with friends; you control the queue.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + MillenniumButton( onClick = onCreate, - enabled = state.brokerConfigured, - ) { + enabled = state.brokerConfigured) { Text("Create Session") } } @@ -186,21 +177,17 @@ private fun CreateOrJoinView( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( text = "Enter the 6-character room code the host shared with you.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - OutlinedTextField( - value = pasted, - onValueChange = { pasted = JamRoomCode.normalize(it) }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Room code") }, - placeholder = { Text("ABC123") }, - singleLine = true, - ) - Button( - onClick = { onJoin(pasted) }, - enabled = state.brokerConfigured && JamRoomCode.isValid(pasted), - ) { + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) + MillenniumTextField( + state = codeState, + label = "Room code", + placeholder = "ABC123", + modifier = Modifier.fillMaxWidth()) + val normalizedCode = JamRoomCode.normalize(codeState.text.toString()) + MillenniumButton( + onClick = { onJoin(normalizedCode) }, + enabled = state.brokerConfigured && JamRoomCode.isValid(normalizedCode)) { Text("Join Session") } } @@ -214,44 +201,39 @@ private fun SessionView( onShareCode: () -> Unit, onLeave: () -> Unit, onKick: (String) -> Unit, - onBan: (String) -> Unit, -) { + onBan: (String) -> Unit) { val isHost = state.role == JamRole.Host Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { if (!state.isConnected) { Text( text = "Connecting to the session…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } ParticipantsSection( participants = state.participants, isHost = isHost, onKick = onKick, - onBan = onBan, - ) + onBan = onBan) if (isHost) { - HorizontalDivider() + MillenniumSeparator() Text( text = "Invite someone", - style = MaterialTheme.typography.titleSmall, - ) + style = AppTheme.typography.titleSmall) RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode) } - HorizontalDivider() + MillenniumSeparator() Text( text = "The queue and playback controls are in the player at the bottom of the app — " + "the jam queue is shared with every participant.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) LeaveButton(onLeave) } @@ -262,45 +244,40 @@ private fun ParticipantsSection( participants: List, isHost: Boolean, onKick: (String) -> Unit, - onBan: (String) -> Unit, -) { + onBan: (String) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = "Participants (${participants.size})", - style = MaterialTheme.typography.titleSmall, - ) + style = AppTheme.typography.titleSmall) participants.forEach { participant -> Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = participant.displayName, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) + modifier = Modifier.weight(1f)) if (participant.isHost) { Text( text = "Host", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary) } if (isHost && !participant.isHost) { - OutlinedButton( + MillenniumButton( onClick = { onKick(participant.id) }, modifier = Modifier.height(32.dp), - ) { - Text("Kick", style = MaterialTheme.typography.labelSmall) + style = MillenniumButtonStyle.Secondary) { + Text("Kick", style = AppTheme.typography.labelSmall) } - OutlinedButton( + MillenniumButton( onClick = { onBan(participant.id) }, modifier = Modifier.height(32.dp), - ) { - Text("Ban", style = MaterialTheme.typography.labelSmall) + style = MillenniumButtonStyle.Secondary) { + Text("Ban", style = AppTheme.typography.labelSmall) } } } @@ -311,8 +288,7 @@ private fun ParticipantsSection( @Composable private fun RoomCodeBox( code: String, - onShare: () -> Unit, -) { + onShare: () -> Unit) { val clipboard = LocalClipboardManager.current val viewModel: JamViewModel = koinViewModel() @@ -320,18 +296,20 @@ private fun RoomCodeBox( SelectionContainer { Text( text = code, - style = MaterialTheme.typography.displaySmall, + style = AppTheme.typography.displaySmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(vertical = 8.dp), - ) + color = AppTheme.colors.primary, + modifier = Modifier.padding(vertical = 8.dp)) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { clipboard.setText(AnnotatedString(code)) }) { + MillenniumButton( + onClick = { clipboard.setText(AnnotatedString(code)) }) { Text("Copy") } if (viewModel.supportsNativeShare) { - OutlinedButton(onClick = onShare) { + MillenniumButton( + onClick = onShare, + style = MillenniumButtonStyle.Secondary) { Text("Share") } } @@ -341,7 +319,9 @@ private fun RoomCodeBox( @Composable private fun LeaveButton(onLeave: () -> Unit) { - OutlinedButton(onClick = onLeave) { + MillenniumButton( + onClick = onLeave, + style = MillenniumButtonStyle.Secondary) { Text("Leave Session") } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt index 92dcc936..c361668d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -43,14 +43,12 @@ data class JamUiState( val roomCode: String? = null, val brokerHost: String = "", val brokerConfigured: Boolean = false, - val error: String? = null, -) + val error: String? = null) class JamViewModel( private val jamRoomService: JamRoomService, private val shareService: ShareService, - private val settingsProvider: SettingsProvider, -) : ViewModel() { + private val settingsProvider: SettingsProvider) : ViewModel() { private val _localError = MutableStateFlow(null) @@ -64,8 +62,7 @@ class JamViewModel( jamRoomService.roomCode, jamRoomService.connectionError, settingsProvider.settingsState, - _localError, - ) { values -> + _localError) { values -> @Suppress("UNCHECKED_CAST") val role = values[0] as JamRole? @Suppress("UNCHECKED_CAST") @@ -84,8 +81,7 @@ class JamViewModel( roomCode = roomCode, brokerHost = settings?.jamBroker?.host.orEmpty(), brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(), - error = localError ?: connectionError, - ) + error = localError ?: connectionError) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState()) // ---------- Session lifecycle ---------- diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt index 9c1f30c2..c9dc4bca 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt @@ -17,12 +17,16 @@ package dev.krtirtho.spotube.modules.jam -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row + import androidx.compose.runtime.Composable -import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import androidx.compose.ui.unit.dp /** * Dialog shown when the user tries to play a collection while a jam session is active. @@ -33,31 +37,30 @@ fun PlayDestinationPicker( visible: Boolean, onDismiss: () -> Unit, onPlayLocally: () -> Unit, - onSuggestToJam: () -> Unit, -) { + onSuggestToJam: () -> Unit) { if (!visible) return - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Text("Play Where?", style = MaterialTheme.typography.titleLarge) - }, - content = { - Text( - text = "You have an active jam session. Choose where to play this collection.", - style = MaterialTheme.typography.bodyMedium, - ) - }, - actions = { - TextButton(onClick = onDismiss) { + title = "Play Where?") { + Text( + text = "You have an active jam session. Choose where to play this collection.", + style = AppTheme.typography.bodyMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Quiet) { Text("Cancel") } - TextButton(onClick = onPlayLocally) { + MillenniumButton( + onClick = onPlayLocally, + style = MillenniumButtonStyle.Quiet) { Text("Play here") } - Button(onClick = onSuggestToJam) { + MillenniumButton(onClick = onSuggestToJam) { Text("Suggest to Jam") } - }, - ) + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt index bc1129cf..74a880ab 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryRepository.kt @@ -299,8 +299,7 @@ class LibraryRepository( isPublic: Boolean, isCollaborating: Boolean, imageBase64: String, - trackIds: List, - ): MetadataPlaylist? { + trackIds: List): MetadataPlaylist? { return authPlugin?.let { plugin -> pluginManager.withScope { plugin.use { @@ -310,8 +309,7 @@ class LibraryRepository( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - trackIds = trackIds, - ) + trackIds = trackIds) playlistCache.invalidateAll() savedPlaylistIds.value += result.id result @@ -327,8 +325,7 @@ class LibraryRepository( isPublic: Boolean?, isCollaborating: Boolean?, imageBase64: String?, - trackIds: List?, - ): MetadataPlaylist? { + trackIds: List?): MetadataPlaylist? { return authPlugin?.let { plugin -> pluginManager.withScope { plugin.use { @@ -339,8 +336,7 @@ class LibraryRepository( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - trackIds = trackIds, - ) + trackIds = trackIds) playlistCache.invalidateAll() result } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt index fdc33bfa..1dbef564 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt @@ -17,24 +17,26 @@ package dev.krtirtho.spotube.modules.library + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.ChipTab import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.downloads.DownloadsScreen import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen @@ -47,7 +49,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun LibraryScreen( libraryState: LibraryState = koinInject(), @@ -57,57 +58,52 @@ fun LibraryScreen( val searchMode by libraryState.searchMode.collectAsState() val isLargeScreen = appShellViewModel.useSidebar() - BaseScaffold( - topBar = { - Column { - ApplicationMainBar( - backButton = false, - title = { - if (!isLargeScreen) Text("Your Library") - }, - actions = { - if (!isLargeScreen) { - IconButton( - onClick = { - libraryState.setSearchMode(!searchMode) - }, - ) { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = "Search", - ) + Column(modifier = Modifier.fillMaxSize()) { + Column { + ApplicationMainBar( + backButton = false, + title = { + if (!isLargeScreen) Text("Your Library") + }, + actions = { + if (!isLargeScreen) { + MillenniumIconButton( + icon = Iconsax.IconsaxSearchBroken, + contentDescription = "Search", + onClick = { + libraryState.setSearchMode(!searchMode) + }, + style = MillenniumIconButtonStyle.Quiet) + } + } + ) + if (!isLargeScreen) + Column( + modifier = Modifier.padding(horizontal = 12.dp) + ) { + Spacer(modifier = Modifier.height(4.dp)) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(LibraryTab.entries.size) { index -> + val tab = LibraryTab.entries[index] + MillenniumButton( + onClick = { libraryState.onTabSelected(tab) }, + style = if (currentTab == tab) { + MillenniumButtonStyle.Primary + } else { + MillenniumButtonStyle.Secondary + }) { + Text(tab.title) } } } - ) - if (!isLargeScreen) - Column( - modifier = Modifier.padding(horizontal = 12.dp) - ) { - Spacer(modifier = Modifier.height(4.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(LibraryTab.entries.size) { index -> - val tab = LibraryTab.entries[index] - ChipTab( - selected = currentTab == tab, - onClick = { libraryState.onTabSelected(tab) }, - ) { - Text(tab.title) - } - } - } - Spacer(modifier = Modifier.height(12.dp)) - } - } - - + Spacer(modifier = Modifier.height(12.dp)) + } } - ) { innerPadding -> + Column( modifier = Modifier - .padding(innerPadding) + .weight(1f) .padding(horizontal = 12.dp) ) { when (currentTab) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt index 106e4e8e..c9d0832c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsScreen.kt @@ -17,6 +17,9 @@ package dev.krtirtho.spotube.modules.library.album + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -30,8 +33,8 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material3.Icon -import androidx.compose.material3.Text + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,16 +43,17 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel @@ -62,6 +66,13 @@ fun LibraryAlbumsScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() + val searchState = rememberTextFieldState((state as? LibraryAlbumsState.Data)?.query ?: "") + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .drop(1) + .collect(viewModel::onQueryChange) + } + LaunchedEffect(gridState) { snapshotFlow { gridState.layoutInfo } .map { layoutInfo -> @@ -83,19 +94,11 @@ fun LibraryAlbumsScreen( Column(modifier = modifier) { AnimatedVisibility(searchMode){ - TextField( - value = (state as? LibraryAlbumsState.Data)?.query ?: "", - onValueChange = viewModel::onQueryChange, + MillenniumTextField( + state = searchState, + label = "Search", modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search saved albums...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search" - ) - } - ) + placeholder = "Search saved albums...") } Spacer(modifier = Modifier.height(12.dp)) when (state) { @@ -116,8 +119,7 @@ fun LibraryAlbumsScreen( PlayableCard( title = "Album Title", subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -127,8 +129,7 @@ fun LibraryAlbumsScreen( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ErrorDisplay( errorMessage = (state as LibraryAlbumsState.Error).message, - onRetry = { viewModel.refresh() }, - ) + onRetry = { viewModel.refresh() }) } } @@ -167,8 +168,7 @@ fun LibraryAlbumsScreen( PlayableCard( title = "Album Title", subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt index 42534e03..4b48f0c8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/album/LibraryAlbumsViewModel.kt @@ -46,8 +46,7 @@ sealed interface LibraryAlbumsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, - ) : Data { + override val nextPagination: PaginationStrategy? = null) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { album -> @@ -63,15 +62,13 @@ sealed interface LibraryAlbumsState { fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination, - ) + nextPagination = nextPagination) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, - ) : Data { + override val nextPagination: PaginationStrategy? = null) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { album -> @@ -91,8 +88,7 @@ sealed interface LibraryAlbumsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryAlbumsViewModel( - private val repository: LibraryRepository, -) : ViewModel(), KoinComponent { + private val repository: LibraryRepository) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryAlbumsState.Loading) @@ -117,8 +113,7 @@ class LibraryAlbumsViewModel( val result = repository.savedAlbums() _state.value = LibraryAlbumsState.Data.Loaded( allItems = result?.items ?: emptyList(), - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load albums" } _state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error") @@ -132,8 +127,7 @@ class LibraryAlbumsViewModel( _state.value = LibraryAlbumsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + (result?.items ?: emptyList()), - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more albums" } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt index fc9b0714..9056f51d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsScreen.kt @@ -17,6 +17,9 @@ package dev.krtirtho.spotube.modules.library.artist + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -30,8 +33,8 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material3.Icon -import androidx.compose.material3.Text + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,16 +43,17 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.ui.component.ArtistCard import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel @@ -57,11 +61,17 @@ import org.koin.compose.viewmodel.koinViewModel fun LibraryArtistsScreen( modifier: Modifier = Modifier, searchMode: Boolean = false, - viewModel: LibraryArtistsViewModel = koinViewModel(), -) { + viewModel: LibraryArtistsViewModel = koinViewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() + val searchState = rememberTextFieldState((state as? LibraryArtistsState.Data)?.query ?: "") + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .drop(1) + .collect(viewModel::onQueryChange) + } + LaunchedEffect(gridState) { snapshotFlow { gridState.layoutInfo } .map { layoutInfo -> @@ -83,19 +93,11 @@ fun LibraryArtistsScreen( Column(modifier = modifier) { AnimatedVisibility(searchMode){ - TextField( - value = (state as? LibraryArtistsState.Data)?.query ?: "", - onValueChange = viewModel::onQueryChange, + MillenniumTextField( + state = searchState, + label = "Search", modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search saved artists...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - ) - }, - ) + placeholder = "Search saved artists...") } Spacer(modifier = Modifier.height(12.dp)) when (state) { @@ -109,15 +111,13 @@ fun LibraryArtistsScreen( modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + verticalArrangement = Arrangement.spacedBy(16.dp)) { items(8) { SkeletonTree(true) { AvatarCard( title = "Artist Name", subtitle = "Artist", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -127,8 +127,7 @@ fun LibraryArtistsScreen( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ErrorDisplay( errorMessage = (state as LibraryArtistsState.Error).message, - onRetry = { viewModel.refresh() }, - ) + onRetry = { viewModel.refresh() }) } } @@ -153,13 +152,11 @@ fun LibraryArtistsScreen( modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + verticalArrangement = Arrangement.spacedBy(16.dp)) { items(dataState.items, key = { it.id }) { artist -> ArtistCard( artist = artist, - modifier = Modifier.fillMaxWidth(), - ) + modifier = Modifier.fillMaxWidth()) } // if (state is LibraryArtistsState.Data.LoadingMore) { @@ -168,8 +165,7 @@ fun LibraryArtistsScreen( AvatarCard( title = "Artist Name", subtitle = "Artist", - imageURL = "https://placehold.co/400x400", - ) + imageURL = "https://placehold.co/400x400") } } // } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt index aeb1564c..8d8b2cbb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/artist/LibraryArtistsViewModel.kt @@ -46,8 +46,7 @@ sealed interface LibraryArtistsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, - ) : Data { + override val nextPagination: PaginationStrategy? = null) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { artist -> @@ -60,15 +59,13 @@ sealed interface LibraryArtistsState { fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination, - ) + nextPagination = nextPagination) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, - ) : Data { + override val nextPagination: PaginationStrategy? = null) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { artist -> @@ -85,8 +82,7 @@ sealed interface LibraryArtistsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryArtistsViewModel( - private val repository: LibraryRepository, -) : ViewModel(), KoinComponent { + private val repository: LibraryRepository) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryArtistsState.Loading) @@ -115,11 +111,9 @@ class LibraryArtistsViewModel( id = it.id, name = it.name, thumbnails = it.thumbnails, - externalUri = it.externalUri, - ) + externalUri = it.externalUri) } ?: emptyList(), - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load artists" } _state.value = LibraryArtistsState.Error(e.message ?: "Unknown error") @@ -135,14 +129,12 @@ class LibraryArtistsViewModel( id = it.id, name = it.name, thumbnails = it.thumbnails, - externalUri = it.externalUri, - ) + externalUri = it.externalUri) } ?: emptyList() _state.value = LibraryArtistsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + newItems, - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more artists" } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index d3a93160..288df6e3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -17,6 +17,15 @@ package dev.krtirtho.spotube.modules.library.local_tracks + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -29,22 +38,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.foundation.text.input.rememberTextFieldState + +import com.composeunstyled.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.drop import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset @@ -77,63 +85,62 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) } + val searchState = rememberTextFieldState(state.query) + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .drop(1) + .collect(viewModel::onQueryChange) + } + Box(modifier = modifier.fillMaxWidth()) { Column( modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { AnimatedVisibility(searchMode) { - TextField( - value = state.query, - onValueChange = viewModel::onQueryChange, - modifier = Modifier - .fillMaxWidth(), - placeholder = { - Text( - if (state.currentFolderPath == null) { - "Search folders or tracks" - } else { - "Search tracks in folder" - } - ) - }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search" - ) - }, - trailingIcon = { - Icon( - imageVector = Iconsax.IconsaxRefreshRight, - contentDescription = "Refresh", - modifier = Modifier.clickable { viewModel.refresh() } - ) - } - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = "Search") + MillenniumTextField( + state = searchState, + label = "Search", + modifier = Modifier.weight(1f), + placeholder = if (state.currentFolderPath == null) { + "Search folders or tracks" + } else { + "Search tracks in folder" + }) + MillenniumIconButton( + icon = Iconsax.IconsaxRefreshRight, + contentDescription = "Refresh", + onClick = { viewModel.refresh() }, + style = MillenniumIconButtonStyle.Quiet) + } } if (state.currentFolderPath != null) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + horizontalArrangement = Arrangement.SpaceBetween) { Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = viewModel::navigateUp) { - Icon(imageVector = Iconsax.ArrowLeft3, contentDescription = "Back") - } + MillenniumIconButton( + icon = Iconsax.ArrowLeft3, + contentDescription = "Back", + onClick = viewModel::navigateUp, + style = MillenniumIconButtonStyle.Quiet) Text(viewModel.currentFolder()?.name ?: "Folder") } - IconButton(onClick = { - viewModel.currentFolder()?.let { viewModel.playFolder(it.path) } - }) { - Icon( - imageVector = Iconsax.IconsaxPlayCircle2, - contentDescription = "Play folder" - ) - } + MillenniumIconButton( + icon = Iconsax.IconsaxPlayCircle2, + contentDescription = "Play folder", + onClick = { + viewModel.currentFolder()?.let { viewModel.playFolder(it.path) } + }, + style = MillenniumIconButtonStyle.Quiet) } } @@ -144,17 +151,18 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { + verticalArrangement = Arrangement.spacedBy(10.dp)) { Text("Allow audio permission to show local tracks") - TextButton(onClick = permissionState.requestPermission) { + MillenniumButton( + onClick = permissionState.requestPermission, + style = MillenniumButtonStyle.Quiet) { Text("Grant access") } } } } else if (state.isRefreshing && folders.isEmpty() && tracks.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() + MillenniumIndeterminateProgress() } } else if (state.currentFolderPath == null) { if (folders.isEmpty()) { @@ -164,17 +172,16 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean } else { LazyColumn(contentPadding = contentPadding) { items(folders, key = { it.path }) { folder -> - ListItem( - headlineContent = { Text(folder.name) }, - supportingContent = { Text("${folder.trackCount} track(s)") }, - leadingContent = { - Icon( + MillenniumListTile( + title = { Text(folder.name) }, + subtitle = { Text("${folder.trackCount} track(s)") }, + leading = { + MillenniumIcon( imageVector = Iconsax.IconsaxFolderOpen, contentDescription = null ) }, - modifier = Modifier.clickable { viewModel.openFolder(folder.path) }, - ) + onClick = { viewModel.openFolder(folder.path) }) } } } @@ -186,30 +193,28 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean } else { LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = contentPadding) { items(tracks, key = { it.path }) { track -> - ListItem( - headlineContent = { Text(track.name) }, - supportingContent = { + MillenniumListTile( + title = { Text(track.name) }, + subtitle = { val artists = track.artists.joinToString().ifBlank { "Unknown artist" } val album = track.album?.takeIf { it.isNotBlank() } Text(if (album == null) artists else "$artists - $album") }, - leadingContent = { - Icon( + leading = { + MillenniumIcon( imageVector = Iconsax.IconsaxMusicCircle, contentDescription = null ) }, - trailingContent = { - IconButton(onClick = { viewModel.playTrack(track.path) }) { - Icon( - imageVector = Iconsax.Play, - contentDescription = "Play" - ) - } + trailing = { + MillenniumIconButton( + icon = Iconsax.Play, + contentDescription = "Play", + onClick = { viewModel.playTrack(track.path) }, + style = MillenniumIconButtonStyle.Quiet) }, - modifier = Modifier.clickable { viewModel.playTrack(track.path) }, - ) + onClick = { viewModel.playTrack(track.path) }) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt index 0987ff7b..d1897a74 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksViewModel.kt @@ -35,13 +35,11 @@ data class LibraryLocalTracksState( val folders: List = emptyList(), val currentFolderPath: String? = null, val query: String = "", - val error: String? = null, -) + val error: String? = null) class LibraryLocalTracksViewModel( private val coordinator: LocalMediaLibraryCoordinator, - private val audioPlayerQueue: AudioPlayerQueue, -) : ViewModel() { + private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { private val _state = MutableStateFlow(LibraryLocalTracksState()) val state: StateFlow = _state.asStateFlow() @@ -52,8 +50,7 @@ class LibraryLocalTracksViewModel( _state.update { it.copy( folders = cache.folders, - error = null, - ) + error = null) } } } @@ -98,8 +95,7 @@ class LibraryLocalTracksViewModel( entries = tracks.map { it.toQueueEntry() }, autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } } @@ -114,8 +110,7 @@ class LibraryLocalTracksViewModel( entries = tracks.map { it.toQueueEntry() }, autoPlay = true, startPosition = selectedIndex, - collectionEntry = null, - ) + collectionEntry = null) } } @@ -173,7 +168,6 @@ class LibraryLocalTracksViewModel( duration = durationMs, album = album, coverBytes = coverBytes, - url = path, - ) + url = path) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt index 882c5cd5..1ca25812 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaFoldersConfig.kt @@ -24,8 +24,7 @@ import kotlinx.coroutines.flow.map class LocalMediaFoldersConfig( private val settingsRepository: SettingsRepository, - private val paths: Paths, -) { + private val paths: Paths) { val configuredRootsFlow: Flow> = settingsRepository.userSettings.map { settings -> val cacheRoot = if (settings.enableMusicCaching) { listOf(settings.cacheFolder ?: paths.getMusicCacheDirPath()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt index 60b03e58..19e393ec 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaLibraryCoordinator.kt @@ -35,8 +35,7 @@ import org.koin.core.component.KoinComponent class LocalMediaLibraryCoordinator( private val discoveryService: LocalMediaDiscoveryService, private val cacheRepository: LocalMediaCacheRepository, - private val foldersConfig: LocalMediaFoldersConfig, -) : KoinComponent { + private val foldersConfig: LocalMediaFoldersConfig) : KoinComponent { private val logger by injectLogger() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -100,8 +99,7 @@ class LocalMediaLibraryCoordinator( }.onSuccess { folders -> val cache = LocalMediaCache( folders = folders, - indexedAtEpochMs = Clock.System.now().toEpochMilliseconds(), - ) + indexedAtEpochMs = Clock.System.now().toEpochMilliseconds()) cacheRepository.save(cache) _cacheState.value = cache }.onFailure { throwable -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt index d8264f6a..95820144 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaModels.kt @@ -26,15 +26,13 @@ data class LocalMediaTrack( val artists: List, val durationMs: Long, val album: String? = null, - val coverBytes: ByteArray? = null, -) + val coverBytes: ByteArray? = null) @Serializable data class LocalMediaFolder( val path: String, val name: String, - val tracks: List, -) { + val tracks: List) { val trackCount: Int get() = tracks.size } @@ -42,5 +40,4 @@ data class LocalMediaFolder( @Serializable data class LocalMediaCache( val folders: List = emptyList(), - val indexedAtEpochMs: Long = 0, -) + val indexedAtEpochMs: Long = 0) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt index b9fef9c4..72065609 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.kt @@ -21,8 +21,7 @@ import androidx.compose.runtime.Composable data class LocalMediaPermissionState( val isGranted: Boolean, - val requestPermission: () -> Unit, -) + val requestPermission: () -> Unit) @Composable expect fun rememberLocalMediaPermissionState(): LocalMediaPermissionState diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt index 0ad5288a..7dff7fc1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/NoopLocalMediaDiscoveryService.kt @@ -24,8 +24,7 @@ class NoopLocalMediaDiscoveryService : LocalMediaDiscoveryService { override fun observeChanges( roots: List, - onChanged: LocalMediaChangeCallback, - ): LocalMediaObservation? { + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { return null } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt index c504d0b8..3fc8122c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/AddToPlaylistPicker.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.modules.library.playlist + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -30,13 +37,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,6 +51,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -52,15 +60,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.koin.compose.koinInject -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddToPlaylistPicker( visible: Boolean, @@ -69,8 +75,7 @@ fun AddToPlaylistPicker( onPlaylistSelected: (String) -> Unit, modifier: Modifier = Modifier, libraryRepository: LibraryRepository = koinInject(), - breakpointDp: Float = 600f, -) { + breakpointDp: Float = 600f) { if (!visible) return val adaptiveInfo = currentWindowAdaptiveInfo() @@ -80,6 +85,13 @@ fun AddToPlaylistPicker( var isLoading by remember { mutableStateOf(true) } var searchQuery by remember { mutableStateOf("") } val scope = rememberCoroutineScope() + val searchState = rememberTextFieldState(searchQuery) + + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .drop(1) + .collect { searchQuery = it } + } LaunchedEffect(visible, currentUserId) { if (visible) { @@ -107,21 +119,21 @@ fun AddToPlaylistPicker( val content: @Composable () -> Unit = { Column(modifier = Modifier.fillMaxSize()) { - TextField( - value = searchQuery, - onValueChange = { searchQuery = it }, + Row( modifier = Modifier .fillMaxWidth() .padding(bottom = 12.dp), - placeholder = { Text("Search your playlists...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - ) - }, - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = "Search") + MillenniumTextField( + state = searchState, + label = "Search", + modifier = Modifier.weight(1f), + placeholder = "Search your playlists...") + } PlaylistPickerContent( playlists = filteredPlaylists, @@ -129,44 +141,36 @@ fun AddToPlaylistPicker( onPlaylistSelected = { playlistId -> onPlaylistSelected(playlistId) onDismiss() - }, - ) + }) } } if (isLargeScreen) { - AlertDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - modifier = modifier, - title = { - Text("Add to Your Playlist") - }, - text = { - Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { - content() - } - }, - confirmButton = { - OutlineButton(onClick = onDismiss) { - Text("Cancel") - } - }, - ) + title = "Add to Your Playlist", + modifier = modifier) { + Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { + content() + } + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { + Text("Cancel") + } + } } else { - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + MillenniumModalBottomSheet(state = sheetState) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { + .padding(horizontal = 16.dp, vertical = 8.dp)) { Text( text = "Add to Your Playlist", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp), - ) + style = AppTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp)) content() } } @@ -177,8 +181,7 @@ fun AddToPlaylistPicker( private fun PlaylistPickerContent( playlists: List, isLoading: Boolean, - onPlaylistSelected: (String) -> Unit, -) { + onPlaylistSelected: (String) -> Unit) { if (isLoading) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Loading your playlists...") @@ -190,13 +193,11 @@ private fun PlaylistPickerContent( } else { LazyColumn( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + verticalArrangement = Arrangement.spacedBy(4.dp)) { items(playlists, key = { it.id }) { playlist -> PlaylistPickerRow( playlist = playlist, - onClick = { onPlaylistSelected(playlist.id) }, - ) + onClick = { onPlaylistSelected(playlist.id) }) } } } @@ -205,8 +206,7 @@ private fun PlaylistPickerContent( @Composable private fun PlaylistPickerRow( playlist: MetadataPlaylist, - onClick: () -> Unit, -) { + onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() @@ -214,8 +214,7 @@ private fun PlaylistPickerRow( .clickable(onClick = onClick) .padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { val imageUrl = playlist.thumbnails.firstOrNull()?.url if (imageUrl != null) { AsyncImage( @@ -224,34 +223,29 @@ private fun PlaylistPickerRow( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Box( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = playlist.title.take(1).uppercase(), - style = MaterialTheme.typography.titleMedium, - ) + style = AppTheme.typography.titleMedium) } } Column(modifier = Modifier.weight(1f)) { Text( text = playlist.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = "${playlist.trackCount} tracks", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index 1d18c751..779e1df8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -17,6 +17,11 @@ package dev.krtirtho.spotube.modules.library.playlist + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,8 +37,8 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material3.Icon -import androidx.compose.material3.Text + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,10 +49,9 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.component.ErrorDisplay -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.component.LikedTracksCard import dev.krtirtho.spotube.core.ui.component.PlaylistCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard @@ -57,6 +61,7 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel @@ -64,12 +69,17 @@ import org.koin.compose.viewmodel.koinViewModel fun LibraryPlaylistsScreen( modifier: Modifier = Modifier, searchMode: Boolean = false, - viewModel: LibraryPlaylistsViewModel = koinViewModel(), -) { + viewModel: LibraryPlaylistsViewModel = koinViewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() var showCreatePlaylist by remember { mutableStateOf(false) } + val searchState = rememberTextFieldState((state as? LibraryPlaylistsState.Data)?.query ?: "") + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() } + .drop(1) + .collect(viewModel::onQueryChange) + } LaunchedEffect(gridState) { snapshotFlow { gridState.layoutInfo } @@ -94,35 +104,21 @@ fun LibraryPlaylistsScreen( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { AnimatedVisibility( visible = searchMode, - modifier = Modifier.weight(1f), - ) { - TextField( - value = (state as? LibraryPlaylistsState.Data)?.query ?: "", - onValueChange = viewModel::onQueryChange, + modifier = Modifier.weight(1f)) { + MillenniumTextField( + state = searchState, + label = "Search", modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Search saved playlists...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search" - ) - } - ) + placeholder = "Search saved playlists...") } - IconButton( + MillenniumIconButton( + icon = Iconsax.IconsaxAddSquare, + contentDescription = "Create Playlist", onClick = { showCreatePlaylist = true }, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = "Create Playlist", - ) - } + modifier = Modifier.size(48.dp)) } Spacer(modifier = Modifier.height(12.dp)) when (state) { @@ -143,8 +139,7 @@ fun LibraryPlaylistsScreen( PlayableCard( title = "Playlist Name", subtitle = "By Artist", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -154,8 +149,7 @@ fun LibraryPlaylistsScreen( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ErrorDisplay( errorMessage = (state as LibraryPlaylistsState.Error).message, - onRetry = { viewModel.refresh() }, - ) + onRetry = { viewModel.refresh() }) } } @@ -201,8 +195,7 @@ fun LibraryPlaylistsScreen( PlayableCard( title = "Playlist Name", subtitle = "By Artist", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -221,9 +214,7 @@ fun LibraryPlaylistsScreen( isPublic = data.isPublic, isCollaborating = data.isCollaborating, imageBase64 = data.imageBase64, - trackIds = emptyList(), - ) - }, - ) + trackIds = emptyList()) + }) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt index 97f580bc..efceaded 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/LibraryPlaylistsViewModel.kt @@ -46,8 +46,7 @@ sealed interface LibraryPlaylistsState { data class Loaded( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, - ) : Data { + override val nextPagination: PaginationStrategy? = null) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { @@ -61,15 +60,13 @@ sealed interface LibraryPlaylistsState { fun toLoadingMore(): LoadingMore = LoadingMore( query = query, allItems = allItems, - nextPagination = nextPagination, - ) + nextPagination = nextPagination) } data class LoadingMore( override val query: String = "", override val allItems: List = emptyList(), - override val nextPagination: PaginationStrategy? = null, - ) : Data { + override val nextPagination: PaginationStrategy? = null) : Data { override val items: List get() = if (query.isNotBlank()) { allItems.filter { @@ -87,8 +84,7 @@ sealed interface LibraryPlaylistsState { @OptIn(ExperimentalCoroutinesApi::class) class LibraryPlaylistsViewModel( - private val repository: LibraryRepository, -) : ViewModel(), KoinComponent { + private val repository: LibraryRepository) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(LibraryPlaylistsState.Loading) @@ -113,8 +109,7 @@ class LibraryPlaylistsViewModel( val result = repository.savedPlaylists() _state.value = LibraryPlaylistsState.Data.Loaded( allItems = result?.items ?: emptyList(), - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load playlists" } _state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error") @@ -128,8 +123,7 @@ class LibraryPlaylistsViewModel( _state.value = LibraryPlaylistsState.Data.Loaded( query = currentState.query, allItems = currentState.allItems + (result?.items ?: emptyList()), - nextPagination = result?.nextPagination, - ) + nextPagination = result?.nextPagination) } }.onFailure { e -> logger.e(e) { "Failed to load more playlists" } @@ -159,8 +153,7 @@ class LibraryPlaylistsViewModel( isPublic: Boolean, isCollaborating: Boolean, imageBase64: String, - trackIds: List, - ) { + trackIds: List) { viewModelScope.launch { runCatching { repository.createPlaylist( @@ -169,8 +162,7 @@ class LibraryPlaylistsViewModel( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - trackIds = trackIds, - ) + trackIds = trackIds) }.onFailure { e -> logger.e(e) { "Failed to create playlist" } }.onSuccess { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt index b08c095c..ba7f9e85 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/playlist/PlaylistFormSheet.kt @@ -18,6 +18,12 @@ package dev.krtirtho.spotube.modules.library.playlist import androidx.compose.foundation.clickable +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumSwitch +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,32 +36,33 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.Toggle import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.readBytes import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okio.ByteString.Companion.toByteString @@ -66,10 +73,8 @@ data class PlaylistFormData( val isPublic: Boolean = true, val isCollaborating: Boolean = false, val imageBase64: String = "", - val imagePreviewUrl: String? = null, -) + val imagePreviewUrl: String? = null) -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaylistFormSheet( visible: Boolean, @@ -78,8 +83,7 @@ fun PlaylistFormSheet( modifier: Modifier = Modifier, initialData: PlaylistFormData? = null, isEditing: Boolean = false, - breakpointDp: Float = 600f, -) { + breakpointDp: Float = 600f) { if (!visible) return val adaptiveInfo = currentWindowAdaptiveInfo() @@ -92,6 +96,20 @@ fun PlaylistFormSheet( var imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") } var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) } + val nameState = rememberTextFieldState(initialData?.name ?: "") + val descriptionState = rememberTextFieldState(initialData?.description ?: "") + + LaunchedEffect(nameState) { + snapshotFlow { nameState.text.toString() } + .drop(1) + .collect { name = it } + } + LaunchedEffect(descriptionState) { + snapshotFlow { descriptionState.text.toString() } + .drop(1) + .collect { description = it } + } + val scope = rememberCoroutineScope() val imagePicker = rememberFilePickerLauncher( type = FileKitType.Image @@ -114,93 +132,78 @@ fun PlaylistFormSheet( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - imagePreviewUrl = imagePreviewUrl, - ) + imagePreviewUrl = imagePreviewUrl) val handleImagePick = { imagePicker.launch() } if (isLargeScreen) { - AlertDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - modifier = modifier, - title = { - Text(if (isEditing) "Edit Playlist" else "Create Playlist") - }, - text = { - PlaylistFormFields( - name = name, - onNameChange = { name = it }, - description = description, - onDescriptionChange = { description = it }, - isPublic = isPublic, - onIsPublicChange = { isPublic = it }, - isCollaborating = isCollaborating, - onIsCollaboratingChange = { isCollaborating = it }, - imageBase64 = imageBase64, - imagePreviewUrl = imagePreviewUrl, - onPickImage = handleImagePick, - ) - }, - confirmButton = { - PrimaryButton( + title = if (isEditing) "Edit Playlist" else "Create Playlist", + modifier = modifier) { + PlaylistFormFields( + nameState = nameState, + descriptionState = descriptionState, + isPublic = isPublic, + onIsPublicChange = { isPublic = it }, + isCollaborating = isCollaborating, + onIsCollaboratingChange = { isCollaborating = it }, + imageBase64 = imageBase64, + imagePreviewUrl = imagePreviewUrl, + onPickImage = handleImagePick) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( onClick = { onSubmit(currentData) onDismiss() }, - enabled = name.isNotBlank(), - ) { + enabled = name.isNotBlank()) { Text(if (isEditing) "Save" else "Create") } - }, - dismissButton = { - OutlineButton(onClick = onDismiss) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text("Cancel") } - }, - ) + } + } } else { - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + MillenniumModalBottomSheet(state = sheetState) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { + .padding(horizontal = 16.dp, vertical = 8.dp)) { Text( text = if (isEditing) "Edit Playlist" else "Create Playlist", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp), - ) + style = AppTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp)) PlaylistFormFields( - name = name, - onNameChange = { name = it }, - description = description, - onDescriptionChange = { description = it }, + nameState = nameState, + descriptionState = descriptionState, isPublic = isPublic, onIsPublicChange = { isPublic = it }, isCollaborating = isCollaborating, onIsCollaboratingChange = { isCollaborating = it }, imageBase64 = imageBase64, imagePreviewUrl = imagePreviewUrl, - onPickImage = handleImagePick, - ) + onPickImage = handleImagePick) Spacer(modifier = Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { - OutlineButton(onClick = onDismiss) { + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text("Cancel") } - PrimaryButton( + MillenniumButton( onClick = { onSubmit(currentData) onDismiss() }, - enabled = name.isNotBlank(), - ) { + enabled = name.isNotBlank()) { Text(if (isEditing) "Save" else "Create") } } @@ -212,41 +215,31 @@ fun PlaylistFormSheet( @Composable private fun PlaylistFormFields( - name: String, - onNameChange: (String) -> Unit, - description: String, - onDescriptionChange: (String) -> Unit, + nameState: TextFieldState, + descriptionState: TextFieldState, isPublic: Boolean, onIsPublicChange: (Boolean) -> Unit, isCollaborating: Boolean, onIsCollaboratingChange: (Boolean) -> Unit, imageBase64: String, imagePreviewUrl: String?, - onPickImage: () -> Unit, -) { + onPickImage: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - TextField( - value = name, - onValueChange = onNameChange, + verticalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumTextField( + state = nameState, + label = "Name", modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Playlist name") }, - singleLine = true, - label = { Text("Name") }, - ) + placeholder = "Playlist name") - TextField( - value = description, - onValueChange = onDescriptionChange, + MillenniumTextField( + state = descriptionState, + label = "Description", modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Description (optional)") }, - maxLines = 3, - label = { Text("Description") }, - ) + placeholder = "Description (optional)") Row( modifier = Modifier @@ -255,17 +248,15 @@ private fun PlaylistFormFields( .clickable { onIsPublicChange(!isPublic) } .padding(horizontal = 4.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { - Text("Public", style = MaterialTheme.typography.bodyLarge) + Text("Public", style = AppTheme.typography.bodyLarge) Text( "Anyone can see this playlist", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } - Toggle(checked = isPublic, onCheckedChange = onIsPublicChange) + MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange, label = "Public") } Row( @@ -275,58 +266,50 @@ private fun PlaylistFormFields( .clickable { onIsCollaboratingChange(!isCollaborating) } .padding(horizontal = 4.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { - Text("Collaborative", style = MaterialTheme.typography.bodyLarge) + Text("Collaborative", style = AppTheme.typography.bodyLarge) Text( "Others can add tracks to this playlist", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } - Toggle(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange) + MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange, label = "Collaborative") } Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("Cover Image", style = MaterialTheme.typography.bodyLarge) + verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Cover Image", style = AppTheme.typography.bodyLarge) Row( horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier .size(64.dp) .clip(RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { when { imagePreviewUrl != null -> AsyncImage( model = imagePreviewUrl, contentDescription = "Playlist cover", modifier = Modifier.size(64.dp), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) imageBase64.isNotBlank() -> { Text( "✓", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.titleMedium, + color = AppTheme.colors.primary) } else -> { Text( "No image", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } } } - PrimaryButton(onClick = onPickImage) { + MillenniumButton(onClick = onPickImage) { Text(if (imageBase64.isNotBlank() || imagePreviewUrl != null) "Change Image" else "Pick Image") } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt index 6797df65..5d98d25b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsScreen.kt @@ -17,7 +17,13 @@ package dev.krtirtho.spotube.modules.lyrics + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,17 +34,15 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -47,6 +51,7 @@ import androidx.compose.runtime.rememberCoroutineScope 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.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -54,7 +59,7 @@ import androidx.compose.ui.unit.sp import compose.icons.FeatherIcons import compose.icons.feathericons.X import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.core.ui.component.ApplicationBackButton import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 @@ -65,60 +70,56 @@ import org.koin.compose.viewmodel.koinViewModel fun LyricsScreen( viewModel: LyricsViewModel = koinViewModel(), modifier: Modifier = Modifier, - onClose: (() -> Unit)? = null, -) { + onClose: (() -> Unit)? = null) { val uiState by viewModel.uiState.collectAsState() val positionMillis by viewModel.positionMillisFlow.collectAsState() val scope = rememberCoroutineScope() - BaseScaffold( - topBar = { - ApplicationMainBar( - backButton = onClose == null, - title = { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - ) { - if (onClose != null) { - IconButton( - onClick = onClose, - modifier = Modifier.align(Alignment.CenterStart) - ) { - Icon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = "Close" - ) - } - } - Row( - modifier = Modifier - .align(Alignment.Center) - .clip(RoundedCornerShape(50.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - .padding(10.dp, 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - LyricsModeTab( - label = "Synced", - isSelected = uiState.mode == LyricType.SYNCED, - onClick = { viewModel.setMode(LyricType.SYNCED) } - ) - LyricsModeTab( - label = "Plain", - isSelected = uiState.mode == LyricType.STATIC, - onClick = { viewModel.setMode(LyricType.STATIC) } - ) - } - } + Column(modifier = modifier.fillMaxSize().background(AppTheme.colors.background)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically) { + if (onClose != null) { + MillenniumIconButton( + onClick = onClose, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = "Close" + ) } - ) - }, - modifier = modifier, - ) { innerPadding -> + } else { + ApplicationBackButton() + } + Spacer(Modifier.weight(1f)) + Row( + modifier = Modifier + .clip(RoundedCornerShape(50.dp)) + .background(AppTheme.colors.surfaceVariant) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + LyricsModeTab( + label = "Synced", + isSelected = uiState.mode == LyricType.SYNCED, + onClick = { viewModel.setMode(LyricType.SYNCED) } + ) + LyricsModeTab( + label = "Plain", + isSelected = uiState.mode == LyricType.STATIC, + onClick = { viewModel.setMode(LyricType.STATIC) } + ) + } + Spacer(Modifier.weight(1f)) + Spacer(Modifier.width(44.dp)) + } Column( modifier = Modifier - .fillMaxSize() - .padding(innerPadding) + .weight(1f) + .fillMaxWidth() .padding(horizontal = 16.dp) .padding(bottom = LocalAppShellBottomInset.current + 24.dp) ) { @@ -129,7 +130,7 @@ fun LyricsScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + MillenniumIndeterminateProgress(modifier = Modifier.fillMaxWidth(0.4f)) } } @@ -140,8 +141,8 @@ fun LyricsScreen( ) { Text( text = uiState.error ?: "Unknown error", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.error, textAlign = TextAlign.Center ) } @@ -171,27 +172,29 @@ fun LyricsScreen( private fun LyricsModeTab( label: String, isSelected: Boolean, - onClick: () -> Unit, -) { - Surface( - onClick = onClick, - shape = RoundedCornerShape(20.dp), - color = if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.surface - }, - contentColor = if (isSelected) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurface - } + onClick: () -> Unit) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(50.dp)) + .background( + if (isSelected) { + AppTheme.colors.primary + } else { + Color.Transparent + } + ) + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 7.dp) ) { Text( text = label, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold + style = AppTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = if (isSelected) { + AppTheme.colors.onPrimary + } else { + AppTheme.colors.onSurfaceVariant + } ) } } @@ -200,8 +203,7 @@ private fun LyricsModeTab( private fun SyncedLyricsContent( syncedLyrics: List, currentIndex: Int, - onScrollToIndex: (Int) -> Unit, -) { + onScrollToIndex: (Int) -> Unit) { if (syncedLyrics.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), @@ -209,8 +211,8 @@ private fun SyncedLyricsContent( ) { Text( text = "No synced lyrics available", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center ) } @@ -236,29 +238,58 @@ private fun SyncedLyricsContent( ) { itemsIndexed(syncedLyrics) { index, line -> val isCurrentLine = index == currentIndex - Text( - text = line.text.ifBlank { "..." }, + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = if (isCurrentLine) FontWeight.Bold else FontWeight.Normal, + .clip(RoundedCornerShape(10.dp)) + .then( + if (isCurrentLine) { + Modifier.background(AppTheme.colors.primaryContainer) + } else { + Modifier + } + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = formatLyricTime(line.time), + style = AppTheme.typography.labelSmall, color = if (isCurrentLine) { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ), - textAlign = TextAlign.Center - ) + AppTheme.colors.onSurfaceVariant + }, + modifier = Modifier.width(44.dp) + ) + Text( + text = line.text.ifBlank { "..." }, + modifier = Modifier.weight(1f), + style = AppTheme.typography.bodyLarge.copy( + fontWeight = if (isCurrentLine) FontWeight.Bold else FontWeight.Normal, + color = if (isCurrentLine) { + AppTheme.colors.primary + } else { + AppTheme.colors.onSurface + } + ) + ) + } } } } +private fun formatLyricTime(millis: Long): String { + val totalSeconds = (millis / 1000).coerceAtLeast(0) + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "$minutes:${seconds.toString().padStart(2, '0')}" +} + @Composable private fun PlainLyricsContent( - plainLyrics: String, -) { + plainLyrics: String) { if (plainLyrics.isBlank()) { Box( modifier = Modifier.fillMaxSize(), @@ -266,8 +297,8 @@ private fun PlainLyricsContent( ) { Text( text = "No plain lyrics available", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, textAlign = TextAlign.Center ) } @@ -281,8 +312,8 @@ private fun PlainLyricsContent( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), - style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 30.sp), - color = MaterialTheme.colorScheme.onSurface, + style = AppTheme.typography.bodyLarge.copy(lineHeight = 30.sp), + color = AppTheme.colors.onSurface, textAlign = TextAlign.Center ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt index c1615daa..a38c3043 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/lyrics/LyricsViewModel.kt @@ -39,20 +39,17 @@ enum class LyricType { SYNCED } - data class LyricsUiState( val mode: LyricType = LyricType.SYNCED, val syncedLyrics: List? = null, val plainLyrics: String? = null, val isLoading: Boolean = false, - val error: String? = null, -) + val error: String? = null) class LyricsViewModel( private val pluginManager: PluginManager, audioPlayer: AudioPlayerInterface, - private val audioPlayerQueue: AudioPlayerQueue, -) : ViewModel() { + private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { private val _uiState = MutableStateFlow(LyricsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -90,8 +87,7 @@ class LyricsViewModel( it.copy( syncedLyrics = lyrics?.syncedLyrics, plainLyrics = lyrics?.plainLyrics, - isLoading = false, - ) + isLoading = false) } } } ?: run { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt index 1696ad32..b50e255f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/AddTracksToPlaylistDialog.kt @@ -17,6 +17,16 @@ package dev.krtirtho.spotube.modules.playlist + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,14 +39,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.SheetDetent +import com.composeunstyled.Text +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,6 +53,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -52,20 +62,16 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.search.SearchRepository import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import org.koin.compose.koinInject -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddTracksToPlaylistDialog( visible: Boolean, @@ -74,8 +80,7 @@ fun AddTracksToPlaylistDialog( modifier: Modifier = Modifier, searchRepository: SearchRepository = koinInject(), libraryRepository: LibraryRepository = koinInject(), - breakpointDp: Float = 600f, -) { + breakpointDp: Float = 600f) { if (!visible) return val adaptiveInfo = currentWindowAdaptiveInfo() @@ -88,6 +93,13 @@ fun AddTracksToPlaylistDialog( var addingTrackIds by remember { mutableStateOf>(emptySet()) } var addedTrackIds by remember { mutableStateOf>(emptySet()) } val scope = rememberCoroutineScope() + val queryState = rememberTextFieldState(query) + + LaunchedEffect(queryState) { + snapshotFlow { queryState.text.toString() } + .drop(1) + .collect { query = it } + } LaunchedEffect(visible) { if (!visible) { @@ -125,32 +137,21 @@ fun AddTracksToPlaylistDialog( val content: @Composable () -> Unit = { Column( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TextField( - value = query, - onValueChange = { query = it }, + verticalAlignment = Alignment.CenterVertically) { + MillenniumTextField( + state = queryState, + label = "Search", modifier = Modifier.weight(1f), - placeholder = { Text("Search tracks...") }, - singleLine = true, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - ) - }, - ) - IconButton(onClick = handleSearch) { - Icon( - imageVector = Iconsax.IconsaxSearch, - contentDescription = "Search", - ) - } + placeholder = "Search tracks...") + MillenniumIconButton( + icon = Iconsax.IconsaxSearch, + contentDescription = "Search", + onClick = handleSearch, + style = MillenniumIconButtonStyle.Secondary) } when { @@ -159,9 +160,8 @@ fun AddTracksToPlaylistDialog( modifier = Modifier .fillMaxWidth() .weight(1f), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() + contentAlignment = Alignment.Center) { + MillenniumIndeterminateProgress() } } hasSearched && searchResults.isEmpty() -> { @@ -169,8 +169,7 @@ fun AddTracksToPlaylistDialog( modifier = Modifier .fillMaxWidth() .weight(1f), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text("No tracks found") } } @@ -179,15 +178,13 @@ fun AddTracksToPlaylistDialog( modifier = Modifier .fillMaxWidth() .weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + verticalArrangement = Arrangement.spacedBy(4.dp)) { items(searchResults, key = { it.id }) { track -> TrackSearchResultRow( track = track, isAdding = addingTrackIds.contains(track.id), isAdded = addedTrackIds.contains(track.id), - onAdd = { handleAddTrack(track) }, - ) + onAdd = { handleAddTrack(track) }) } } } @@ -196,8 +193,7 @@ fun AddTracksToPlaylistDialog( modifier = Modifier .fillMaxWidth() .weight(1f), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text("Search for tracks to add") } } @@ -206,39 +202,30 @@ fun AddTracksToPlaylistDialog( } if (isLargeScreen) { - AlertDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - modifier = modifier, - title = { - Text("Add Tracks to Playlist") - }, - text = { - Box(modifier = Modifier.fillMaxWidth().height(500.dp)) { - content() - } - }, - confirmButton = { - PrimaryButton(onClick = onDismiss) { - Text("Done") - } - }, - ) + title = "Add Tracks to Playlist", + modifier = modifier) { + Box(modifier = Modifier.fillMaxWidth().height(500.dp)) { + content() + } + MillenniumButton(onClick = onDismiss) { + Text("Done") + } + } } else { - ModalBottomSheet( - onDismissRequest = onDismiss, - dragHandle = null, - ) { + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + MillenniumModalBottomSheet(state = sheetState) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) - .height(600.dp), - ) { + .height(600.dp)) { Text( text = "Add Tracks to Playlist", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 16.dp), - ) + style = AppTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp)) content() } } @@ -250,16 +237,14 @@ private fun TrackSearchResultRow( track: MetadataTrack, isAdding: Boolean, isAdded: Boolean, - onAdd: () -> Unit, -) { + onAdd: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { val imageUrl = track.album?.thumbnails?.firstOrNull()?.url if (imageUrl != null) { AsyncImage( @@ -268,55 +253,49 @@ private fun TrackSearchResultRow( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(6.dp)), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Box( modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = track.title.take(1).uppercase(), - style = MaterialTheme.typography.titleMedium, - ) + style = AppTheme.typography.titleMedium) } } Column(modifier = Modifier.weight(1f)) { Text( text = track.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = track.artists.joinToString(", ") { it.name }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } when { isAdded -> { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxCheckCircle, contentDescription = "Added", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), - ) + tint = AppTheme.colors.primary, + modifier = Modifier.size(24.dp)) } isAdding -> { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) + MillenniumIndeterminateProgress( + modifier = Modifier.size(24.dp)) } else -> { - OutlineButton(onClick = onAdd) { + MillenniumButton( + onClick = onAdd, + style = MillenniumButtonStyle.Secondary) { Text("Add") } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt index c7d444f6..c58da096 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistRepository.kt @@ -37,8 +37,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class PlaylistRepository( val pluginManager: PluginManager, - private val libraryRepository: LibraryRepository, -) { + private val libraryRepository: LibraryRepository) { val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val plugin diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index a472ac93..09ae39bb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -17,12 +17,17 @@ package dev.krtirtho.spotube.modules.playlist + +import androidx.compose.ui.Alignment +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.Text + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -40,7 +45,6 @@ import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData @@ -90,14 +94,18 @@ fun PlaylistScreen( modifier = Modifier .fillMaxWidth() .padding(vertical = 16.dp), - horizontalArrangement = Arrangement.Center, - ) { - OutlineButton(onClick = { showAddTracksDialog = true }) { - Icon( - imageVector = Iconsax.IconsaxAddSquare, - contentDescription = null, - ) - Text("Add Tracks", modifier = Modifier.padding(start = 8.dp)) + horizontalArrangement = Arrangement.Center) { + MillenniumButton( + onClick = { showAddTracksDialog = true }, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = null) + Text("Add Tracks") + } } } } @@ -153,8 +161,7 @@ fun PlaylistScreen( description = it.description.orEmpty(), isPublic = true, isCollaborating = false, - imagePreviewUrl = it.thumbnails.firstOrNull()?.url, - ) + imagePreviewUrl = it.thumbnails.firstOrNull()?.url) }, onSubmit = { data -> viewModel.updatePlaylist( @@ -162,17 +169,14 @@ fun PlaylistScreen( description = data.description.ifBlank { null }, isPublic = data.isPublic, isCollaborating = data.isCollaborating, - imageBase64 = data.imageBase64.ifBlank { null }, - ) - }, - ) + imageBase64 = data.imageBase64.ifBlank { null }) + }) AddToPlaylistPicker( visible = showAddToPlaylistPicker, currentUserId = currentUserId, onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist, - ) + onPlaylistSelected = viewModel::addTracksToPlaylist) AddTracksToPlaylistDialog( visible = showAddTracksDialog, @@ -180,8 +184,6 @@ fun PlaylistScreen( onDismiss = { showAddTracksDialog = false viewModel.refresh() - }, - ) - }, - ) + }) + }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index 5a4c1076..ec441718 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -66,15 +66,13 @@ sealed interface PlaylistScreenState { override val isSaved: Boolean = false, override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val isSaving: Boolean = false, - ) : Data { + override val isSaving: Boolean = false) : Data { fun toLoadingMore(): LoadingMore = LoadingMore( playlist = playlist, isSaved = isSaved, tracks = tracks, nextPagination = nextPagination, - isSaving = isSaving, - ) + isSaving = isSaving) } data class LoadingMore( @@ -82,8 +80,7 @@ sealed interface PlaylistScreenState { override val isSaved: Boolean = false, override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val isSaving: Boolean = false, - ) : Data + override val isSaving: Boolean = false) : Data } data class Error(val message: String) : PlaylistScreenState @@ -100,8 +97,7 @@ class PlaylistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, - private val remotePlaybackController: RemotePlaybackController, -) : ViewModel(), KoinComponent { + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(PlaylistScreenState.Loading) @@ -127,8 +123,7 @@ class PlaylistViewModel( audioPlayerQueue.currentQueueEntryFlow, savedTracksRepository.savedTracksIdsFlow, _blacklistedTrackIds, - _blacklistedArtistIds, - ) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> + _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists -> TrackOptionsContext( currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, queueTrackIds = queue.mapNotNull { entry -> @@ -136,8 +131,7 @@ class PlaylistViewModel( }.toSet(), savedTrackIds = savedIds, blacklistedTrackIds = blacklistedTracks, - blacklistedArtistIds = blacklistedArtists, - ) + blacklistedArtistIds = blacklistedArtists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) init { @@ -177,8 +171,7 @@ class PlaylistViewModel( playlist = playlistInfo?.first, isSaved = playlistInfo?.second ?: false, tracks = tracksResult?.items ?: emptyList(), - nextPagination = tracksResult?.nextPagination, - ) + nextPagination = tracksResult?.nextPagination) }.onFailure { e -> logger.e(e) { "Failed to load playlist" } _state.value = PlaylistScreenState.Error(e.message ?: "Unknown error") @@ -196,8 +189,7 @@ class PlaylistViewModel( isSaved = currentState.isSaved, tracks = currentState.tracks + (result?.items ?: emptyList()), nextPagination = result?.nextPagination, - isSaving = currentState.isSaving, - ) + isSaving = currentState.isSaving) } }.onFailure { e -> logger.e(e) { "Failed to load more tracks" } @@ -251,8 +243,7 @@ class PlaylistViewModel( type = RemoteCollectionType.Playlist, id = playlistId, title = title, - startTrack = track, - ) + startTrack = track) } fun refresh() { @@ -267,8 +258,7 @@ class PlaylistViewModel( description: String?, isPublic: Boolean?, isCollaborating: Boolean?, - imageBase64: String?, - ) { + imageBase64: String?) { viewModelScope.launch { runCatching { libraryRepository.updatePlaylist( @@ -278,8 +268,7 @@ class PlaylistViewModel( isPublic = isPublic, isCollaborating = isCollaborating, imageBase64 = imageBase64, - trackIds = null, - ) + trackIds = null) }.onFailure { e -> logger.e(e) { "Failed to update playlist" } }.onSuccess { @@ -352,8 +341,7 @@ class PlaylistViewModel( runCatching { libraryRepository.addTracksToPlaylist( playlistId, - _tracksToAddToPlaylist.value.map { it.id }, - ) + _tracksToAddToPlaylist.value.map { it.id }) }.onFailure { e -> logger.e(e) { "Failed to add tracks to playlist" } }.onSuccess { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt index b43b5c52..532bc9ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/BuiltInPlugins.kt @@ -36,8 +36,7 @@ val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry( contact = "", repository = "", bugs = "", - license = "", -) + license = "") val LRCLIB_BUILT_IN_PLUGIN = PluginEntry( name = "LRCLib Lyrics", version = "0.1.0", @@ -45,18 +44,15 @@ val LRCLIB_BUILT_IN_PLUGIN = PluginEntry( description = "LRCLib Lyrics plugin for fetching lyrics.", author = "Spotube Team", capabilities = listOf( - PluginCapability.NETWORK_REQUESTS, - ), + PluginCapability.NETWORK_REQUESTS), abilities = listOf(PluginAbility.LYRICS), contact = "", repository = "", bugs = "", - license = "", -) + license = "") val BUILT_IN_PLUGINS = listOf( NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, - LRCLIB_BUILT_IN_PLUGIN, -) + LRCLIB_BUILT_IN_PLUGIN) /** * The bundled placeholder logo for a built-in plugin, or `null` if it has none. diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt index 91ffc9b3..86cfe2a1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/GitHubPluginRepo.kt @@ -33,8 +33,7 @@ import kotlinx.serialization.json.Json data class GitHubRepoSearchResponse( @SerialName("total_count") val totalCount: Int, @SerialName("incomplete_results") val incompleteResults: Boolean, - val items: List, -) + val items: List) @Serializable data class GitHubRepo( @@ -43,14 +42,12 @@ data class GitHubRepo( val description: String? = null, @SerialName("stargazers_count") val stargazersCount: Int = 0, @SerialName("html_url") val htmlUrl: String, - val owner: GitHubOwner, -) + val owner: GitHubOwner) @Serializable data class GitHubOwner( val login: String, - @SerialName("avatar_url") val avatarUrl: String, -) + @SerialName("avatar_url") val avatarUrl: String) @Serializable data class GitHubRelease( @@ -60,14 +57,12 @@ data class GitHubRelease( val assets: List, @SerialName("html_url") val htmlUrl: String, val prerelease: Boolean = false, - val draft: Boolean = false, -) + val draft: Boolean = false) @Serializable data class GitHubAsset( val name: String, - @SerialName("browser_download_url") val browserDownloadUrl: String, -) + @SerialName("browser_download_url") val browserDownloadUrl: String) class GitHubPluginRepository { private val httpClient = HttpClient { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt index dfc636e7..5d13734d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt @@ -83,11 +83,9 @@ interface ScrobblePluginSource { val selectedScrobblePlugin: StateFlow } - class PluginManager( val database: Database, - val paths: Paths, -) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { + val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { private val logger by injectLogger() private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } @@ -97,7 +95,6 @@ class PluginManager( val pluginsDirPath: Path get() = pluginsDir private val httpClient = HttpClient() - enum class InstallPromptKind { INSTALL, UPDATE, @@ -113,8 +110,7 @@ class PluginManager( val message: String, val bytes: ByteArray? = null, val existingEntry: PluginEntry? = null, - val confirmLabel: String? = null, - ) + val confirmLabel: String? = null) val pendingPlugin = MutableStateFlow(null) @@ -122,8 +118,7 @@ class PluginManager( .map { p -> val json = p[DatabaseKeys.PLUGINS_STATE_KEY] val defaultSelectedPlugins = mapOf( - PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN, - ) + PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN) if (json == null) { PluginManagerStates.Data( plugins = BUILT_IN_PLUGINS, @@ -156,8 +151,7 @@ class PluginManager( res.copy( plugins = resolvedPlugins, - selectedPlugins = selectedPlugins, - ) + selectedPlugins = selectedPlugins) } catch (_: Exception) { PluginManagerStates.Data( BUILT_IN_PLUGINS, @@ -202,8 +196,7 @@ class PluginManager( val service = ZiplinePluginService( applicationName = plugin.name, manifestUrl = "http://localhost?path=${(pluginsDir / plugin.id.toPath() / "manifest.zipline.json")}", - pluginInfo = plugin, - ) + pluginInfo = plugin) ziplineServices[ability] = service ziplineServicesByPluginID[plugin.id] = service service.start() @@ -275,7 +268,6 @@ class PluginManager( override val selectedScrobblePlugin = filterSelectedPluginByType(PluginAbility.SCROBBLE) - fun launchTask(block: suspend CoroutineScope.() -> Unit): Job { return scope.launch(block = block) } @@ -288,7 +280,6 @@ class PluginManager( block() } - private suspend fun updatePluginsState(newState: PluginManagerStates.Data) { database.settingsDataStore.updateData { preferences -> preferences.toMutablePreferences().apply { @@ -397,8 +388,7 @@ class PluginManager( suspend fun addPluginFromByteArray( bytes: ByteArray, - allowReplacingInstalled: Boolean = false, - ) { + allowReplacingInstalled: Boolean = false) { withContext(Dispatchers.IO) { if (!FileSystem.SYSTEM.exists(pluginsDir)) { FileSystem.SYSTEM.createDirectories(pluginsDir) @@ -606,8 +596,7 @@ class PluginManager( entry = pluginEntry, kind = InstallPromptKind.INFO, title = "Plugin API not compatible", - message = apiError, - ) + message = apiError) } val installedPlugin = findInstalledPlugin(pluginEntry.id) ?: return PendingPlugin( @@ -616,8 +605,7 @@ class PluginManager( title = "Install plugin?", message = "${pluginEntry.name} will be added to your installed plugins.", bytes = bytes, - confirmLabel = "Install", - ) + confirmLabel = "Install") return when (compareVersions(installedPlugin, pluginEntry)) { VersionRelation.Update -> PendingPlugin( @@ -627,8 +615,7 @@ class PluginManager( message = "An older version is installed (${installedPlugin.version}). Update to ${pluginEntry.version}?", bytes = bytes, existingEntry = installedPlugin, - confirmLabel = "Update", - ) + confirmLabel = "Update") VersionRelation.Replace -> PendingPlugin( entry = pluginEntry, @@ -637,8 +624,7 @@ class PluginManager( message = buildReplaceMessage(installedPlugin, pluginEntry), bytes = bytes, existingEntry = installedPlugin, - confirmLabel = "Replace", - ) + confirmLabel = "Replace") } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt index 1d7189e4..e8ed2f06 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt @@ -55,8 +55,7 @@ data class PluginEntry( val contact: String, val repository: String, val bugs: String, - val license: String, -) { + val license: String) { @Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD") val id: String = MurmurHash3().hash32x86("$name:$author".encodeToByteArray()) .toUInt() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index d8cc2ca5..a578caf8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -17,6 +17,20 @@ package dev.krtirtho.spotube.modules.plugin + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -34,18 +48,17 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import androidx.compose.foundation.text.input.rememberTextFieldState + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -61,14 +74,6 @@ import coil3.request.ImageRequest import coil3.request.crossfade import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.core.extras.kebabToTitleCase -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem @@ -100,6 +105,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxLink import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import okio.Path +import kotlinx.coroutines.flow.collect import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res @@ -139,37 +145,30 @@ private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube") private val VERIFIED_PLUGIN_OWNERS = setOf() -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PluginScreen( viewModel: PluginViewModel = koinViewModel(), - onboarding: Boolean = false, -) { - BaseScaffold( - topBar = { - if (!onboarding) { - ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) - } - }, - ) { innerPadding -> + onboarding: Boolean = false) { + Column(modifier = Modifier.fillMaxSize()) { + if (!onboarding) { + ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) + } PluginScreenContent( viewModel = viewModel, onboarding = onboarding, - modifier = Modifier.padding(innerPadding), - ) + modifier = Modifier.weight(1f)) } } /** - * The plugin management UI without its own [BaseScaffold] / app bar, so it can be embedded + * The plugin management UI without its own scaffold / app bar, so it can be embedded * as a tab in the large-screen settings layout as well as hosted by [PluginScreen]. */ @Composable internal fun PluginScreenContent( viewModel: PluginViewModel, onboarding: Boolean = false, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current @@ -250,7 +249,7 @@ internal fun PluginScreenContent( Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center - ) { CircularProgressIndicator() } + ) { MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp)) } } is PluginUiState.Data -> { @@ -274,18 +273,15 @@ internal fun PluginScreenContent( horizontal = 4.dp, vertical = 16.dp ), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( stringResource(Res.string.welcome_plugins_title), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold) Text( stringResource(Res.string.welcome_plugins_setup_guide), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } } item { @@ -293,17 +289,21 @@ internal fun PluginScreenContent( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 12.dp) ) { - OutlineButton(onClick = { viewModel.showOnboardingDiscovery(true) }) { + MillenniumButton( + onClick = { viewModel.showOnboardingDiscovery(true) }, + style = MillenniumButtonStyle.Secondary) { Text( stringResource(Res.string.welcome_plugins_discover), fontWeight = if (data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal ) } - OutlineButton(onClick = { - viewModel.showOnboardingDiscovery( - false - ) - }) { + MillenniumButton( + onClick = { + viewModel.showOnboardingDiscovery( + false + ) + }, + style = MillenniumButtonStyle.Secondary) { Text( stringResource(Res.string.welcome_plugins_sources), fontWeight = if (!data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal @@ -322,8 +322,7 @@ internal fun PluginScreenContent( selections = data.abilitySelections, onSelected = { ability, plugin -> viewModel.selectPlugin(ability, plugin) - }, - ) + }) } item { @@ -336,15 +335,19 @@ internal fun PluginScreenContent( ) { Text( stringResource(Res.string.plugin_configure_title), - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold ) - PrimaryButton(onClick = { viewModel.showInstallSheet() }) { - Icon( - Iconsax.IconsaxAdd, - contentDescription = stringResource(Res.string.plugin_install_section_title), - ) - Text(stringResource(Res.string.plugin_install_section_title)) + MillenniumButton(onClick = { viewModel.showInstallSheet() }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxAdd, + contentDescription = stringResource(Res.string.plugin_install_section_title)) + Text(stringResource(Res.string.plugin_install_section_title)) + } } } } @@ -360,29 +363,29 @@ internal fun PluginScreenContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Surface( + Box( modifier = Modifier.size(72.dp) - .clip(RoundedCornerShape(18.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + .clip(RoundedCornerShape(18.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.1f)) ) { Box(contentAlignment = Alignment.Center) { - Icon( + MillenniumIcon( Iconsax.IconsaxBox, contentDescription = null, modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary + tint = AppTheme.colors.primary ) } } Text( stringResource(Res.string.plugin_empty_title), - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold ) Text( stringResource(Res.string.plugin_empty_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant ) } } @@ -400,8 +403,8 @@ internal fun PluginScreenContent( data.plugins.size, noun ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.padding( horizontal = 4.dp, vertical = 4.dp @@ -409,21 +412,22 @@ internal fun PluginScreenContent( ) } item { - Card( + Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), - ) { + .padding(vertical = 4.dp) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(12.dp), + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp, + shadowElevation = 6.dp)) { Column( modifier = Modifier.fillMaxWidth() ) { data.plugins.forEachIndexed { index, item -> if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f - ), - ) + MillenniumSeparator() } PluginCard( plugin = item.plugin, @@ -440,8 +444,7 @@ internal fun PluginScreenContent( } else null, onLogout = if (item.isSelected && item.authState.requiresAuth) { { viewModel.logout(item.plugin) } - } else null, - ) + } else null) } } } @@ -452,7 +455,9 @@ internal fun PluginScreenContent( if ((!onboarding || data.onboardingDiscover) && (onboarding || data.discover.isLoading || data.discover.repos.isNotEmpty() || data.discover.error != null)) { if (onboarding) { item { - OutlineButton(onClick = viewModel::showInstallSheet) { + MillenniumButton( + onClick = viewModel::showInstallSheet, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.plugin_install_section_title)) } } @@ -465,15 +470,15 @@ internal fun PluginScreenContent( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxGlobe, contentDescription = null, modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary + tint = AppTheme.colors.primary ) Text( stringResource(Res.string.welcome_plugins_discover), - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold ) } @@ -484,7 +489,7 @@ internal fun PluginScreenContent( Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(Modifier.size(24.dp)) + MillenniumIndeterminateProgress(Modifier.widthIn(max = 120.dp)) } } } @@ -492,7 +497,7 @@ internal fun PluginScreenContent( item { Text( stringResource(Res.string.welcome_plugins_discovery_empty), - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.padding(12.dp) ) } @@ -516,7 +521,7 @@ internal fun PluginScreenContent( .padding(vertical = 16.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) + MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 120.dp)) } } } @@ -526,14 +531,16 @@ internal fun PluginScreenContent( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( stringResource(Res.string.welcome_plugins_discovery_error), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.error, modifier = Modifier.padding( horizontal = 4.dp, vertical = 8.dp ) ) - OutlineButton(onClick = viewModel::retryDiscovery) { + MillenniumButton( + onClick = viewModel::retryDiscovery, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.welcome_plugins_retry)) } } @@ -541,7 +548,9 @@ internal fun PluginScreenContent( } if (onboarding && data.discover.hasMore && !data.discover.isLoading && !data.discover.isLoadingMore && data.discover.error == null) { item { - OutlineButton(onClick = viewModel::loadNextDiscoverPage) { + MillenniumButton( + onClick = viewModel::loadNextDiscoverPage, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.welcome_plugins_more)) } } @@ -590,8 +599,7 @@ private fun InstallSheet( onUrlChange: (String) -> Unit, onSubmitUrl: () -> Unit, onPickFile: () -> Unit, - onDismiss: () -> Unit, -) { + onDismiss: () -> Unit) { AdaptiveDialogBottomSheet( onDismiss = onDismiss, title = { @@ -599,95 +607,78 @@ private fun InstallSheet( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxImportArrow2Bulk, contentDescription = null, modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary + tint = AppTheme.colors.primary ) Text( stringResource(Res.string.plugin_install_section_title), - style = MaterialTheme.typography.titleSmall, + style = AppTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold ) } - }, - ) { + }) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - stringResource(Res.string.plugin_section_url_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + val urlState = rememberTextFieldState(urlInput) + LaunchedEffect(urlInput) { + if (urlState.text.toString() != urlInput) { + urlState.edit { replace(0, length, urlInput) } + } + } + LaunchedEffect(urlState) { + snapshotFlow { urlState.text.toString() }.collect { value -> + if (value != urlInput) onUrlChange(value) + } + } + Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - TextField( - value = urlInput, - onValueChange = onUrlChange, + MillenniumTextField( + state = urlState, + label = stringResource(Res.string.plugin_section_url_title), modifier = Modifier.weight(1f), - placeholder = { - Text( - stringResource(Res.string.plugin_url_placeholder), - style = MaterialTheme.typography.bodySmall - ) - }, - leadingIcon = { - Icon( - Iconsax.IconsaxLink, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - }, - isError = urlError != null, - singleLine = true, - ) - SecondaryIconButton( + placeholder = stringResource(Res.string.plugin_url_placeholder), + error = urlError?.text()) + MillenniumIconButton( onClick = onSubmitUrl, enabled = !isLoadingUrl, - ) { + style = MillenniumIconButtonStyle.Secondary) { if (isLoadingUrl) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) + MillenniumIndeterminateProgress(modifier = Modifier.size(16.dp)) } else { - Icon( + MillenniumIcon( Iconsax.IconsaxImportArrow2Bulk, - contentDescription = stringResource(Res.string.plugin_action_download), - ) + contentDescription = stringResource(Res.string.plugin_action_download)) } } } - urlError?.let { error -> - Text( - error.text(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + MillenniumSeparator() Text( stringResource(Res.string.plugin_section_file_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurfaceVariant ) - OutlineButton( + MillenniumButton( onClick = onPickFile, modifier = Modifier.fillMaxWidth(), - ) { - Icon( - Iconsax.IconsaxExportArrowBulk, - contentDescription = stringResource(Res.string.plugin_action_install_from_file) - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.plugin_action_install_from_file)) + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MillenniumIcon( + Iconsax.IconsaxExportArrowBulk, + contentDescription = stringResource(Res.string.plugin_action_install_from_file) + ) + Text(stringResource(Res.string.plugin_action_install_from_file)) + } } } } @@ -697,51 +688,33 @@ private fun InstallSheet( private fun PluginInfoDialog( plugin: PluginEntry, logoPath: Path?, - onDismiss: () -> Unit, -) { - ThemedDialog( + onDismiss: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Surface( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - ) { - PluginLogo( - plugin = plugin, - logoPath = logoPath, - contentDescription = plugin.name, - modifier = Modifier.fillMaxSize(), - fallback = { - Box(contentAlignment = Alignment.Center) { - Icon( - Iconsax.IconsaxBox, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - }, - ) - } - Text( - plugin.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - } - }, - actions = { - PrimaryButton(onClick = onDismiss) { - Text("Close") - } + title = plugin.name) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)) + ) { + PluginLogo( + plugin = plugin, + logoPath = logoPath, + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize(), + fallback = { + Box(contentAlignment = Alignment.Center) { + MillenniumIcon( + Iconsax.IconsaxBox, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = AppTheme.colors.primary + ) + } + }) } - ) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth() @@ -749,8 +722,8 @@ private fun PluginInfoDialog( if (plugin.description.isNotBlank()) { Text( plugin.description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface ) } @@ -792,6 +765,11 @@ private fun PluginInfoDialog( } } } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton(onClick = onDismiss) { + Text("Close") + } + } } } @@ -800,37 +778,30 @@ private fun SupportDialog( plugin: PluginEntry, supportText: String?, isLoading: Boolean, - onDismiss: () -> Unit, -) { - ThemedDialog( + onDismiss: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Text( - "Support ${plugin.name}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - }, - actions = { - PrimaryButton(onClick = onDismiss) { - Text("Close") - } - } - ) { + title = "Support ${plugin.name}") { if (isLoading) { Box( modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp)) } } else { Text( supportText ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface ) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton(onClick = onDismiss) { + Text("Close") + } + } } } @@ -838,15 +809,20 @@ private fun SupportDialog( private fun DiscoverRepoCard( repo: GitHubRepo, isInstalling: Boolean, - onInstall: () -> Unit, -) { + onInstall: () -> Unit) { val isOfficial = repo.owner.login in OFFICIAL_PLUGIN_OWNERS val isVerified = repo.owner.login in VERIFIED_PLUGIN_OWNERS - Card( + Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(12.dp), + borderColor = AppTheme.colors.outlineVariant, + borderWidth = 1.dp, + shadowElevation = 6.dp) ) { Row( modifier = Modifier @@ -879,35 +855,37 @@ private fun DiscoverRepoCard( .last() .replace("spotube-plugin-", "") .kebabToTitleCase(), - style = MaterialTheme.typography.bodyMedium, + style = AppTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false) ) if (isOfficial) { - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.15f)) ) { Text( "Official", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary, modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp) ) } } else if (isVerified) { - Surface( - shape = RoundedCornerShape(4.dp), - color = Color(0xFF4CAF50).copy(alpha = 0.15f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF4CAF50).copy(alpha = 0.15f)) ) { Row( modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxCheckCircle, contentDescription = null, modifier = Modifier.size(10.dp), @@ -915,7 +893,7 @@ private fun DiscoverRepoCard( ) Text( "Verified", - style = MaterialTheme.typography.labelSmall, + style = AppTheme.typography.labelSmall, color = Color(0xFF4CAF50) ) } @@ -925,8 +903,8 @@ private fun DiscoverRepoCard( if (!repo.description.isNullOrBlank()) { Text( repo.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis ) @@ -937,68 +915,71 @@ private fun DiscoverRepoCard( ) { Text( repo.owner.login, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxHeart, contentDescription = "Github Stars", modifier = Modifier.size(11.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) Text( repo.stargazersCount.toString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.clickable { - openUrlInBrowser(repo.htmlUrl) - } + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.surfaceVariant) + .clickable { + openUrlInBrowser(repo.htmlUrl) + } ) { Row( modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.CarbonGithubLogo, contentDescription = "Github Repository URL", modifier = Modifier.size(10.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) Text( "github.com", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } } } } - SecondaryButton( + MillenniumButton( onClick = onInstall, - enabled = !isInstalling + enabled = !isInstalling, + style = MillenniumButtonStyle.Secondary ) { - if (isInstalling) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Icon( - Iconsax.IconsaxAdd, - contentDescription = null, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (isInstalling) { + MillenniumIndeterminateProgress(modifier = Modifier.size(16.dp)) + } else { + MillenniumIcon( + Iconsax.IconsaxAdd, + contentDescription = null) + } + Text(stringResource(Res.string.plugin_section_install)) } - Text(stringResource(Res.string.plugin_section_install)) } } } @@ -1012,14 +993,14 @@ private fun DetailRow(label: String, value: String) { ) { Text( "$label:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.widthIn(min = 90.dp) ) Text( value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurface ) } } @@ -1032,14 +1013,14 @@ private fun ClickableDetailRow(label: String, value: String, onClick: () -> Unit ) { Text( "$label:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.widthIn(min = 90.dp) ) Text( value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -1054,20 +1035,21 @@ private fun DetailChipsRow(label: String, chips: List) { ) { Text( "$label:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.widthIn(min = 90.dp) ) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { chips.forEach { chip -> - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.secondaryContainer.copy(alpha = 0.6f)) ) { Text( chip, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSecondaryContainer, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) ) } @@ -1079,8 +1061,7 @@ private fun DetailChipsRow(label: String, chips: List) { @Composable fun DefaultAbilityPluginSelector( selection: AbilitySelection, - onSelected: (PluginEntry?) -> Unit = { }, -) { + onSelected: (PluginEntry?) -> Unit = { }) { val plugins = selection.plugins val selectedPlugin = selection.selectedPlugin val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins) @@ -1093,8 +1074,7 @@ fun DefaultAbilityPluginSelector( AdaptiveMenuItem( label = plugin.name, onClick = { onSelected(plugin) }, - selected = isSelected, - ) + selected = isSelected) ) } } else { @@ -1102,8 +1082,7 @@ fun DefaultAbilityPluginSelector( AdaptiveMenuItem( label = noPluginsText, onClick = { }, - enabled = false, - ) + enabled = false) ) } } @@ -1120,11 +1099,12 @@ fun DefaultAbilityPluginSelector( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = selection.ability.accentColor().copy(alpha = 0.1f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(selection.ability.accentColor().copy(alpha = 0.1f)) ) { - Icon( + MillenniumIcon( imageVector = selection.ability.abilityIcon(), contentDescription = stringResource( Res.string.settings_plugins_plugin_content_description, @@ -1141,21 +1121,21 @@ fun DefaultAbilityPluginSelector( Res.string.settings_plugins_default_ability_title, selection.ability.displayLabel() ), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurface ) if (selectedPlugin != null) { Text( selectedPlugin.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.primary, modifier = Modifier.padding(top = 4.dp) ) } else { Text( stringResource(Res.string.settings_plugins_no_selection), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp) ) } @@ -1171,13 +1151,13 @@ fun DefaultAbilityPluginSelector( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = selection.ability.accentColor().copy(alpha = 0.1f) + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(selection.ability.accentColor().copy(alpha = 0.1f)) ) { - Icon( + MillenniumIcon( imageVector = selection.ability.abilityIcon(), contentDescription = null, modifier = Modifier.padding(8.dp), @@ -1190,35 +1170,37 @@ fun DefaultAbilityPluginSelector( Res.string.settings_plugins_default_ability_title, selection.ability.displayLabel() ), - style = MaterialTheme.typography.titleMedium, - ) + style = AppTheme.typography.titleMedium) selectedPlugin?.let { Text( it.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) } } } }, trigger = { onClick -> - OutlineButton(onClick = onClick) { - Text( - if (selectedPlugin != null) { - stringResource(Res.string.settings_plugins_action_change) - } else { - stringResource(Res.string.settings_plugins_action_select) + " " - }, - ) - Icon( - imageVector = if (selectedPlugin != null) Iconsax.IconsaxEdit else Iconsax.IconsaxArrowDown4, - contentDescription = null, - modifier = Modifier.size(14.dp), - ) + MillenniumButton( + onClick = onClick, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + if (selectedPlugin != null) { + stringResource(Res.string.settings_plugins_action_change) + } else { + stringResource(Res.string.settings_plugins_action_select) + }) + MillenniumIcon( + imageVector = if (selectedPlugin != null) Iconsax.IconsaxEdit else Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(14.dp)) + } } - }, - ) + }) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt index 4bc032b3..3e04d224 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt @@ -49,22 +49,19 @@ import org.koin.core.component.KoinComponent data class PluginAuthState( val requiresAuth: Boolean = false, - val isLoggedIn: Boolean = false, -) + val isLoggedIn: Boolean = false) data class PluginListItem( val plugin: PluginEntry, val isSelected: Boolean, val authState: PluginAuthState, - val logoPath: Path?, -) + val logoPath: Path?) data class AbilitySelection( val ability: PluginAbility, val plugins: List, val selectedPlugin: PluginEntry?, - val pluginLogoPaths: Map = emptyMap(), -) + val pluginLogoPaths: Map = emptyMap()) data class PluginDiscoverState( val repos: List = emptyList(), @@ -74,8 +71,7 @@ data class PluginDiscoverState( val isLoadingMore: Boolean = false, val error: String? = null, val installingRepoId: Long? = null, - val isInitialLoaded: Boolean = false, -) + val isInitialLoaded: Boolean = false) sealed interface UrlError { data object Empty : UrlError @@ -105,8 +101,7 @@ sealed interface PluginUiState { val installDialogRepo: GitHubRepo? = null, val releases: List = emptyList(), val isLoadingReleases: Boolean = false, - val discover: PluginDiscoverState = PluginDiscoverState(), - ) : PluginUiState + val discover: PluginDiscoverState = PluginDiscoverState()) : PluginUiState } private data class PluginManagerSnapshot( @@ -116,8 +111,7 @@ private data class PluginManagerSnapshot( val audioPlugins: List, val lyricsPlugins: List, val scrobblePlugins: List, - val activeServices: Map?, -) + val activeServices: Map?) private data class PluginScreenState( val onboardingDiscover: Boolean = true, @@ -133,14 +127,12 @@ private data class PluginScreenState( val installDialogRepo: GitHubRepo? = null, val releases: List = emptyList(), val isLoadingReleases: Boolean = false, - val discover: PluginDiscoverState = PluginDiscoverState(), -) + val discover: PluginDiscoverState = PluginDiscoverState()) @OptIn(ExperimentalCoroutinesApi::class) class PluginViewModel( private val pluginManager: PluginManager, - private val webViewController: WebViewController, -) : ViewModel(), KoinComponent { + private val webViewController: WebViewController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val gitHubRepo = GitHubPluginRepository() @@ -172,8 +164,7 @@ class PluginViewModel( private class PaginationInfo( var currentPage: Int = 1, var hasMore: Boolean = true, - var totalCount: Int = 0, - ) + var totalCount: Int = 0) @Suppress("UNCHECKED_CAST") private val pluginManagerSnapshot: StateFlow = combine( @@ -183,8 +174,7 @@ class PluginViewModel( pluginManager.audioPlugins, pluginManager.lyricsPlugins, pluginManager.scrobblePlugins, - pluginManager.ziplineServices, - ) { values -> + pluginManager.ziplineServices) { values -> PluginManagerSnapshot( state = values[0] as PluginManagerStates, pendingPlugin = values[1] as PluginManager.PendingPlugin?, @@ -192,8 +182,7 @@ class PluginViewModel( audioPlugins = values[3] as List, lyricsPlugins = values[4] as List, scrobblePlugins = values[5] as List, - activeServices = values[6] as Map?, - ) + activeServices = values[6] as Map?) }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), @@ -204,8 +193,7 @@ class PluginViewModel( audioPlugins = emptyList(), lyricsPlugins = emptyList(), scrobblePlugins = emptyList(), - activeServices = null, - ) + activeServices = null) ) private val pluginLogoPaths: StateFlow> = pluginManager.state @@ -237,8 +225,7 @@ class PluginViewModel( pluginManagerSnapshot, _screenState, pluginAuthStates, - pluginLogoPaths, - ) { snapshot, screen, authStates, logoPaths -> + pluginLogoPaths) { snapshot, screen, authStates, logoPaths -> when (val state = snapshot.state) { is PluginManagerStates.Loading -> PluginUiState.Loading is PluginManagerStates.Data -> PluginUiState.Data( @@ -248,35 +235,29 @@ class PluginViewModel( plugin = plugin, isSelected = state.selectedPlugins.containsValue(plugin), authState = authStates[plugin.id] ?: PluginAuthState(), - logoPath = logoPaths[plugin.id], - ) + logoPath = logoPaths[plugin.id]) }, abilitySelections = listOf( AbilitySelection( PluginAbility.METADATA, snapshot.metadataPlugins, state.selectedPlugins[PluginAbility.METADATA], - logoPaths, - ), + logoPaths), AbilitySelection( PluginAbility.AUDIO, snapshot.audioPlugins, state.selectedPlugins[PluginAbility.AUDIO], - logoPaths, - ), + logoPaths), AbilitySelection( PluginAbility.LYRICS, snapshot.lyricsPlugins, state.selectedPlugins[PluginAbility.LYRICS], - logoPaths, - ), + logoPaths), AbilitySelection( PluginAbility.SCROBBLE, snapshot.scrobblePlugins, state.selectedPlugins[PluginAbility.SCROBBLE], - logoPaths, - ), - ), + logoPaths)), pendingPlugin = snapshot.pendingPlugin, pendingPluginLogoPath = snapshot.pendingPlugin?.entry?.id?.let { getLogoPath(it) }, urlInput = screen.urlInput, @@ -291,8 +272,7 @@ class PluginViewModel( installDialogRepo = screen.installDialogRepo, releases = screen.releases, isLoadingReleases = screen.isLoadingReleases, - discover = screen.discover, - ) + discover = screen.discover) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PluginUiState.Loading) @@ -373,8 +353,7 @@ class PluginViewModel( it.copy( urlError = e.message?.let { message -> UrlError.Message(message) } ?: UrlError.DownloadFailed, - isLoadingUrl = false, - ) + isLoadingUrl = false) } } } @@ -403,8 +382,7 @@ class PluginViewModel( _screenState.update { it.copy( showPluginInfo = plugin, - pluginInfoLogoPath = getLogoPath(plugin.id), - ) + pluginInfoLogoPath = getLogoPath(plugin.id)) } } @@ -417,8 +395,7 @@ class PluginViewModel( it.copy( showPluginSupport = plugin, isLoadingSupport = true, - supportText = null, - ) + supportText = null) } viewModelScope.launch { val service = findServiceForPlugin(plugin.id) @@ -442,8 +419,7 @@ class PluginViewModel( it.copy( showPluginSupport = null, supportText = null, - isLoadingSupport = false, - ) + isLoadingSupport = false) } } @@ -452,8 +428,7 @@ class PluginViewModel( it.copy( installDialogRepo = repo, isLoadingReleases = true, - releases = emptyList(), - ) + releases = emptyList()) } viewModelScope.launch { runCatching { @@ -549,8 +524,7 @@ class PluginViewModel( currentPage = 1, hasMore = _paginationInfo.hasMore, isLoading = false, - isInitialLoaded = true, - ) + isInitialLoaded = true) ) } }.onFailure { e -> @@ -560,8 +534,7 @@ class PluginViewModel( discover = it.discover.copy( isLoading = false, error = e.message, - isInitialLoaded = true, - ) + isInitialLoaded = true) ) } } @@ -588,8 +561,7 @@ class PluginViewModel( repos = filterInstalledRepos(_allRepos), currentPage = nextPage, hasMore = _paginationInfo.hasMore, - isLoadingMore = false, - ) + isLoadingMore = false) ) } }.onFailure { e -> @@ -598,8 +570,7 @@ class PluginViewModel( it.copy( discover = it.discover.copy( isLoadingMore = false, - error = e.message, - ) + error = e.message) ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt index 9f835868..36ff939a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/DefaultAbilityPluginGrid.kt @@ -17,6 +17,8 @@ package dev.krtirtho.spotube.modules.plugin.components + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -32,10 +34,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -123,23 +125,19 @@ private fun PluginEntry.metadataLabel(): String = buildString { internal fun DefaultAbilityPluginGrid( selections: List, onSelected: (PluginAbility, PluginEntry?) -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { selections.chunked(2).forEach { rowSelections -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { rowSelections.forEach { selection -> DefaultAbilityPluginCard( selection = selection, onSelected = { onSelected(selection.ability, it) }, - modifier = Modifier.weight(1f), - ) + modifier = Modifier.weight(1f)) } repeat(2 - rowSelections.size) { Spacer(modifier = Modifier.weight(1f)) @@ -153,8 +151,7 @@ internal fun DefaultAbilityPluginGrid( private fun DefaultAbilityPluginCard( selection: AbilitySelection, onSelected: (PluginEntry?) -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val ability = selection.ability val selected = selection.selectedPlugin val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins) @@ -172,27 +169,23 @@ private fun DefaultAbilityPluginCard( modifier = Modifier .size(30.dp) .clip(RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { PluginLogo( plugin = plugin, logoPath = selection.pluginLogoPaths[plugin.id], contentDescription = plugin.name, modifier = Modifier.fillMaxSize(), fallback = { - Icon( + MillenniumIcon( imageVector = ability.abilityIcon(), contentDescription = null, modifier = Modifier.size(18.dp), - tint = ability.accentColor(), - ) - }, - ) + tint = ability.accentColor()) + }) } }, onClick = { onSelected(plugin) }, - selected = selected?.name == plugin.name, - ) + selected = selected?.name == plugin.name) ) } } else { @@ -200,8 +193,7 @@ private fun DefaultAbilityPluginCard( AdaptiveMenuItem( label = noPluginsText, onClick = { }, - enabled = false, - ) + enabled = false) ) } } @@ -216,23 +208,19 @@ private fun DefaultAbilityPluginCard( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(12.dp)) { AbilityBadge(ability) Column(modifier = Modifier.weight(1f)) { Text( stringResource( Res.string.settings_plugins_default_ability_title, - ability.displayLabel(), - ), - style = MaterialTheme.typography.titleMedium, - ) + ability.displayLabel()), + style = AppTheme.typography.titleMedium) selected?.let { Text( it.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) } } } @@ -244,10 +232,8 @@ private fun DefaultAbilityPluginCard( logoPath = selection.pluginLogoPaths[selected?.id], pluginName = selected?.name ?: noSelectionText, version = selected?.version, - onClick = onClick, - ) - }, - ) + onClick = onClick) + }) } @Composable @@ -258,8 +244,7 @@ private fun AbilityPluginTile( pluginName: String, version: String?, onClick: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val accent = ability.accentColor() val gradient = ability.aeroGradient() val shape = RoundedCornerShape(20.dp) @@ -275,8 +260,7 @@ private fun AbilityPluginTile( val background = Brush.linearGradient( colors = gradient, start = Offset.Zero, - end = Offset(size.width, size.height), - ) + end = Offset(size.width, size.height)) onDrawBehind { drawRect(background) drawAeroDecorations(seed) @@ -285,71 +269,59 @@ private fun AbilityPluginTile( brush = Brush.verticalGradient( colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.32f)), startY = size.height * 0.3f, - endY = size.height, - ), - ) + endY = size.height)) // Glossy top highlight. drawRect( brush = Brush.verticalGradient( colors = listOf(Color.White.copy(alpha = 0.35f), Color.Transparent), startY = 0f, - endY = size.height * 0.55f, - ), - ) + endY = size.height * 0.55f)) } - }, - ) { + }) { Column( modifier = Modifier .fillMaxWidth() .padding(18.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + verticalArrangement = Arrangement.spacedBy(16.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, - ) { + verticalAlignment = Alignment.Top) { Box( modifier = Modifier .size(52.dp) .clip(CircleShape) .background(Color.White.copy(alpha = 0.35f)) .border(1.dp, Color.White.copy(alpha = 0.6f), CircleShape), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { PluginLogo( plugin = selectedPlugin, logoPath = logoPath, contentDescription = pluginName, modifier = Modifier.fillMaxSize(), fallback = { - Icon( + MillenniumIcon( imageVector = ability.abilityIcon(), contentDescription = null, modifier = Modifier.size(26.dp), - tint = accent, - ) - }, - ) + tint = accent) + }) } - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxArrowDown4, contentDescription = null, modifier = Modifier.size(16.dp), - tint = Color.White.copy(alpha = 0.85f), - ) + tint = Color.White.copy(alpha = 0.85f)) } Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = pluginName, - style = MaterialTheme.typography.titleMedium, + style = AppTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color.White, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) Text( text = buildString { append(ability.displayLabel()) @@ -357,11 +329,10 @@ private fun AbilityPluginTile( append(" · v").append(version) } }, - style = MaterialTheme.typography.labelMedium, + style = AppTheme.typography.labelMedium, color = Color.White.copy(alpha = 0.85f), maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } } } @@ -370,16 +341,15 @@ private fun AbilityPluginTile( @Composable private fun AbilityBadge(ability: PluginAbility) { val accent = ability.accentColor() - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = accent.copy(alpha = 0.12f), - ) { - Icon( + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(accent.copy(alpha = 0.12f))) { + MillenniumIcon( imageVector = ability.abilityIcon(), contentDescription = null, modifier = Modifier.padding(8.dp), - tint = accent, - ) + tint = accent) } } @@ -392,29 +362,24 @@ private fun DrawScope.drawAeroDecorations(seed: Int) { repeat(6) { val center = Offset( x = random.nextFloat() * size.width, - y = random.nextFloat() * size.height, - ) + y = random.nextFloat() * size.height) val radius = size.minDimension * (0.12f + random.nextFloat() * 0.4f) drawCircle( brush = Brush.radialGradient( colors = listOf(Color.White.copy(alpha = 0.3f), Color.Transparent), center = center, - radius = radius, - ), + radius = radius), radius = radius, - center = center, - ) + center = center) } repeat(3) { val center = Offset( x = random.nextFloat() * size.width, - y = random.nextFloat() * size.height, - ) + y = random.nextFloat() * size.height) val radius = size.minDimension * (0.03f + random.nextFloat() * 0.05f) drawCircle( color = Color.White.copy(alpha = 0.28f), radius = radius, - center = center, - ) + center = center) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt index b2a34f72..9fce3eb4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginCard.kt @@ -17,7 +17,14 @@ package dev.krtirtho.spotube.modules.plugin.components + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -28,10 +35,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,8 +47,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import okio.Path -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.modules.plugin.BUILT_IN_PLUGINS import dev.krtirtho.spotube.modules.plugin.PluginAbility import dev.krtirtho.spotube.modules.plugin.PluginEntry @@ -72,8 +77,7 @@ internal fun PluginCard( onLogout: (() -> Unit)? = null, onInfo: (() -> Unit)? = null, onSupport: (() -> Unit)? = null, - logoPath: Path? = null, -) { + logoPath: Path? = null) { Row( modifier = Modifier .fillMaxWidth() @@ -82,11 +86,11 @@ internal fun PluginCard( horizontalArrangement = Arrangement.spacedBy(14.dp) ) { // Plugin icon - Surface( + Box( modifier = Modifier .size(44.dp) - .clip(RoundedCornerShape(10.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + .clip(RoundedCornerShape(10.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.1f)) ) { PluginLogo( plugin = plugin, @@ -95,15 +99,14 @@ internal fun PluginCard( modifier = Modifier.fillMaxSize(), fallback = { Box(contentAlignment = Alignment.Center) { - Icon( + MillenniumIcon( Iconsax.IconsaxBox, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + tint = AppTheme.colors.primary, modifier = Modifier.size(22.dp) ) } - }, - ) + }) } // Text content @@ -117,7 +120,7 @@ internal fun PluginCard( ) { Text( plugin.name, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -125,25 +128,26 @@ internal fun PluginCard( ) // "Active" chip if (isSelected) { - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.15f)) ) { Row( modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxCheckSquare, contentDescription = null, modifier = Modifier.size(10.dp), - tint = MaterialTheme.colorScheme.primary + tint = AppTheme.colors.primary ) Text( stringResource(Res.string.plugin_state_active), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary ) } } @@ -153,8 +157,8 @@ internal fun PluginCard( if (plugin.description.isNotBlank()) { Text( plugin.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis ) @@ -169,16 +173,16 @@ internal fun PluginCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.User, contentDescription = null, modifier = Modifier.size(11.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) Text( plugin.author, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } if (plugin !in BUILT_IN_PLUGINS) @@ -186,16 +190,16 @@ internal fun PluginCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp) ) { - Icon( + MillenniumIcon( Iconsax.IconsaxTag, contentDescription = null, modifier = Modifier.size(11.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) Text( stringResource(Res.string.plugin_version_label, plugin.version), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } } @@ -204,14 +208,15 @@ internal fun PluginCard( if (plugin.abilities.isNotEmpty()) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { plugin.abilities.forEach { ability -> - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(AppTheme.colors.secondaryContainer.copy(alpha = 0.6f)) ) { Text( ability.displayLabel(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSecondaryContainer, modifier = Modifier.padding( horizontal = 6.dp, vertical = 2.dp @@ -233,42 +238,45 @@ internal fun PluginCard( verticalAlignment = Alignment.CenterVertically ) { if (onInfo != null) { - GhostIconButton(onClick = onInfo) { - Icon( + MillenniumIconButton( + onClick = onInfo, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxInformation, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } if (onSupport != null) { - GhostIconButton(onClick = onSupport) { - Icon( + MillenniumIconButton( + onClick = onSupport, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxHeart, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } if (plugin in BUILT_IN_PLUGINS) { Text( stringResource(Res.string.plugin_state_builtin), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) .border( - BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + BorderStroke(1.dp, AppTheme.colors.outlineVariant), shape = RoundedCornerShape(6.dp) ) .padding(horizontal = 6.dp, vertical = 2.dp) ) } else { - GhostIconButton(onClick = onRemove) { - Icon( + MillenniumIconButton( + onClick = onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxTrash, contentDescription = stringResource(Res.string.plugin_action_remove), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } } @@ -279,10 +287,12 @@ internal fun PluginCard( else -> null } authAction?.let { (action, label) -> - OutlineButton(onClick = action) { + MillenniumButton( + onClick = action, + style = MillenniumButtonStyle.Secondary) { Text( text = stringResource(label), - style = MaterialTheme.typography.labelLarge + style = AppTheme.typography.labelLarge ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt index 81049853..84b675ca 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginInstallDialog.kt @@ -17,30 +17,25 @@ package dev.krtirtho.spotube.modules.plugin.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -57,24 +52,25 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.crossfade -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.modules.plugin.GitHubRepo import dev.krtirtho.spotube.modules.plugin.GitHubRelease import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxBox -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIndeterminateProgress -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PluginInstallDialog( repo: GitHubRepo, releases: List, isLoadingReleases: Boolean, onDismiss: () -> Unit, - onInstall: (GitHubRelease) -> Unit, -) { + onInstall: (GitHubRelease) -> Unit) { var selectedRelease by remember { mutableStateOf(null) } var expanded by remember { mutableStateOf(false) } @@ -84,173 +80,153 @@ fun PluginInstallDialog( } } - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + title = repo.fullName) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)) ) { - Surface( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - ) { - Box(contentAlignment = Alignment.Center) { - val platformContext = LocalPlatformContext.current - AsyncImage( - model = ImageRequest.Builder(platformContext) - .data(repo.owner.avatarUrl) - .crossfade(true) - .build(), - contentDescription = repo.owner.login, - modifier = Modifier.fillMaxSize() - ) - } + Box(contentAlignment = Alignment.Center) { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .data(repo.owner.avatarUrl) + .crossfade(true) + .build(), + contentDescription = repo.owner.login, + modifier = Modifier.fillMaxSize() + ) } - Column(modifier = Modifier.weight(1f)) { + } + Column(modifier = Modifier.weight(1f)) { + Text( + repo.fullName, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!repo.description.isNullOrBlank()) { Text( - repo.fullName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, + repo.description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 2, overflow = TextOverflow.Ellipsis ) - if (!repo.description.isNullOrBlank()) { - Text( - repo.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + } + } + } + + if (isLoadingReleases) { + Box( + modifier = Modifier.fillMaxWidth().height(120.dp), + contentAlignment = Alignment.Center + ) { + MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp)) + } + } else if (releases.isEmpty()) { + Text( + "No releases found", + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant + ) + } else { + Text( + "Select Release", + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface + ) + + MillenniumDropdownMenu( + expanded = expanded, + onExpandedChange = { expanded = it }, + items = releases.map { it.tagName }, + onItemClick = { tagName -> + selectedRelease = releases.firstOrNull { it.tagName == tagName } + expanded = false + }, + modifier = Modifier.fillMaxWidth(), + anchor = { + MillenniumButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + style = MillenniumButtonStyle.Secondary + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + selectedRelease?.tagName ?: "Select a release", + style = AppTheme.typography.bodyMedium + ) + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } + } + ) + + selectedRelease?.let { release -> + if (release.prerelease) { + Text( + "Pre-release", + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.error + ) + } + if (!release.body.isNullOrBlank()) { + Text( + "Release Notes", + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface + ) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .verticalScroll(rememberScrollState()) + .padding(12.dp) + ) { + Text( + release.body, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } } } } - }, - actions = { - PrimaryButton( + } + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( onClick = { selectedRelease?.let { onInstall(it) } }, enabled = selectedRelease != null && !isLoadingReleases ) { Text("Install") } } - ) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - if (isLoadingReleases) { - Box( - modifier = Modifier.fillMaxWidth().height(120.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(32.dp)) - } - } else if (releases.isEmpty()) { - Text( - "No releases found", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - Text( - "Select Release", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it } - ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .menuAnchor(MenuAnchorType.PrimaryNotEditable), - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - selectedRelease?.tagName ?: "Select a release", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - } - } - - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - releases.forEach { release -> - DropdownMenuItem( - text = { - Column { - Text( - release.tagName, - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (release == selectedRelease) FontWeight.SemiBold else FontWeight.Normal - ) - if (release.prerelease) { - Text( - "Pre-release", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error - ) - } - } - }, - onClick = { - selectedRelease = release - expanded = false - } - ) - } - } - } - - selectedRelease?.let { release -> - if (!release.body.isNullOrBlank()) { - Text( - "Release Notes", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 200.dp) - .verticalScroll(rememberScrollState()) - .padding(12.dp) - ) { - Text( - release.body, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt index 6db4d779..daffad26 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginLogo.kt @@ -44,8 +44,7 @@ fun PluginLogo( modifier: Modifier = Modifier, contentDescription: String? = null, contentScale: ContentScale = ContentScale.Crop, - fallback: @Composable () -> Unit, -) { + fallback: @Composable () -> Unit) { val builtInLogo = plugin?.let { builtInPluginLogo(it) } when { @@ -58,8 +57,7 @@ fun PluginLogo( .build(), contentDescription = contentDescription, contentScale = contentScale, - modifier = modifier, - ) + modifier = modifier) } builtInLogo != null -> { @@ -67,8 +65,7 @@ fun PluginLogo( painter = painterResource(builtInLogo), contentDescription = contentDescription, contentScale = contentScale, - modifier = modifier, - ) + modifier = modifier) } else -> fallback() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt index e4f065d0..40be3978 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/PluginPermissionsDialog.kt @@ -17,10 +17,17 @@ package dev.krtirtho.spotube.modules.plugin.components + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -28,10 +35,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,10 +46,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import okio.Path -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.modules.plugin.PluginCapability import dev.krtirtho.spotube.modules.plugin.PluginEntry import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -80,81 +82,119 @@ fun PluginPermissionDialog( existingPlugin: PluginEntry? = null, logoPath: Path? = null, onConfirm: (() -> Unit)? = null, - onDismiss: () -> Unit, -) { - ThemedDialog( + onDismiss: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp) + title = title) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), - contentAlignment = Alignment.Center - ) { - PluginLogo( - plugin = pluginInfo, - logoPath = logoPath, - contentDescription = pluginInfo.name, - modifier = Modifier.fillMaxSize(), - fallback = { - Icon( - imageVector = Iconsax.IconsaxBoxAdd, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - }, - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - pluginInfo.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + PluginLogo( + plugin = pluginInfo, + logoPath = logoPath, + contentDescription = pluginInfo.name, + modifier = Modifier.fillMaxSize(), + fallback = { + MillenniumIcon( + imageVector = Iconsax.IconsaxBoxAdd, + contentDescription = null, + tint = AppTheme.colors.primary, + modifier = Modifier.size(24.dp) + ) + }) } + Column(modifier = Modifier.weight(1f)) { + Text( + pluginInfo.name, + style = AppTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + } + } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(top = 8.dp) - ) { - Icon( - Iconsax.User, - contentDescription = null, - modifier = Modifier.size(13.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - pluginInfo.author, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - stringResource( - Res.string.plugin_permissions_author_version, - stringResource(Res.string.plugin_version_label, pluginInfo.version) - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + MillenniumIcon( + Iconsax.User, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = AppTheme.colors.onSurfaceVariant + ) + Text( + pluginInfo.author, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + Text( + stringResource( + Res.string.plugin_permissions_author_version, + stringResource(Res.string.plugin_version_label, pluginInfo.version) + ), + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + + if (pluginInfo.description.isNotBlank()) { + Text( + pluginInfo.description, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + + MillenniumCard(contentPadding = PaddingValues(14.dp)) { + Text( + message, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurface + ) + } + + existingPlugin?.let { + InstalledComparisonCard( + installedPlugin = it, + incomingPlugin = pluginInfo + ) + } + + Text( + stringResource(Res.string.plugin_permissions_requested_title), + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.colors.onSurface + ) + + if (pluginInfo.capabilities.isEmpty()) { + Text( + stringResource(Res.string.plugin_permissions_none), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant + ) + } else { + pluginInfo.capabilities.forEach { capability -> + CapabilityRow(capability) } - }, - actions = { - OutlineButton(onClick = onDismiss) { + } + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text( if (confirmLabel == null) { stringResource(Res.string.settings_action_close) @@ -164,59 +204,10 @@ fun PluginPermissionDialog( ) } if (confirmLabel != null && onConfirm != null) { - PrimaryButton(onClick = onConfirm) { + MillenniumButton(onClick = onConfirm) { Text(confirmLabel) } } - }, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (pluginInfo.description.isNotBlank()) { - Text( - pluginInfo.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - - Card { - Text( - message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(14.dp) - ) - } - - existingPlugin?.let { - InstalledComparisonCard( - installedPlugin = it, - incomingPlugin = pluginInfo - ) - } - - Text( - stringResource(Res.string.plugin_permissions_requested_title), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - - if (pluginInfo.capabilities.isEmpty()) { - Text( - stringResource(Res.string.plugin_permissions_none), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - pluginInfo.capabilities.forEach { capability -> - CapabilityRow(capability) - } - } } } } @@ -224,32 +215,28 @@ fun PluginPermissionDialog( @Composable private fun InstalledComparisonCard( installedPlugin: PluginEntry, - incomingPlugin: PluginEntry, -) { - Card( + incomingPlugin: PluginEntry) { + MillenniumCard( modifier = Modifier.fillMaxWidth(), - ) { + contentPadding = PaddingValues(14.dp)) { Column( - modifier = Modifier.padding(14.dp), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( stringResource(Res.string.plugin_permissions_compare_title), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { VersionStat( label = stringResource(Res.string.plugin_permissions_installed_label), - value = installedPlugin.version, - ) + value = installedPlugin.version) VersionStat( label = stringResource(Res.string.plugin_permissions_supplied_label), - value = incomingPlugin.version, - ) + value = incomingPlugin.version) } Text( stringResource( @@ -257,8 +244,8 @@ private fun InstalledComparisonCard( installedPlugin.apiVersion, incomingPlugin.apiVersion ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } } @@ -269,12 +256,12 @@ private fun VersionStat(label: String, value: String) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) Text( stringResource(Res.string.plugin_version_label, value), - style = MaterialTheme.typography.bodyMedium, + style = AppTheme.typography.bodyMedium, fontWeight = FontWeight.Medium ) } @@ -300,13 +287,11 @@ private fun CapabilityRow(capability: PluginCapability) { ) } - Card( + MillenniumCard( modifier = Modifier.fillMaxWidth(), - ) { + contentPadding = PaddingValues(12.dp)) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp) ) { @@ -314,26 +299,26 @@ private fun CapabilityRow(capability: PluginCapability) { modifier = Modifier .size(34.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f)), + .background(AppTheme.colors.errorContainer.copy(alpha = 0.6f)), contentAlignment = Alignment.Center ) { - Icon( + MillenniumIcon( imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.error + tint = AppTheme.colors.error ) } Column { Text( label, - style = MaterialTheme.typography.bodyMedium, + style = AppTheme.typography.bodyMedium, fontWeight = FontWeight.Medium ) Text( description, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index a7640ebd..324c464f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -103,8 +103,6 @@ fun SavedTracksScreen( visible = showAddToPlaylistPicker, currentUserId = currentUserId, onDismiss = viewModel::dismissAddToPlaylistPicker, - onPlaylistSelected = viewModel::addTracksToPlaylist, - ) - }, - ) + onPlaylistSelected = viewModel::addTracksToPlaylist) + }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index bf8fafa4..44ecc52e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -71,20 +71,17 @@ sealed interface SavedTracksScreenState { data class Loaded( override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val totalCount: Int = 0, - ) : Data { + override val totalCount: Int = 0) : Data { fun toLoadingMore(): LoadingMore = LoadingMore( tracks = tracks, nextPagination = nextPagination, - totalCount = totalCount, - ) + totalCount = totalCount) } data class LoadingMore( override val tracks: List = emptyList(), override val nextPagination: PaginationStrategy? = null, - override val totalCount: Int = 0, - ) : Data + override val totalCount: Int = 0) : Data } data class Error(val message: String) : SavedTracksScreenState @@ -99,8 +96,7 @@ class SavedTracksViewModel( private val libraryRepository: LibraryRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, - private val remotePlaybackController: RemotePlaybackController, -) : ViewModel(), KoinComponent { + private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent { private val logger by injectLogger() private val _state = MutableStateFlow(SavedTracksScreenState.Loading) @@ -125,8 +121,7 @@ class SavedTracksViewModel( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, _blacklistedTrackIds, - _blacklistedArtistIds, - ) { queue, currentEntry, blacklistedTracks, blacklistedArtists -> + _blacklistedArtistIds) { queue, currentEntry, blacklistedTracks, blacklistedArtists -> TrackOptionsContext( currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, queueTrackIds = queue.mapNotNull { entry -> @@ -134,8 +129,7 @@ class SavedTracksViewModel( }.toSet(), blacklistedTrackIds = blacklistedTracks, blacklistedArtistIds = blacklistedArtists, - forceFavorite = true, - ) + forceFavorite = true) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext(forceFavorite = true)) init { @@ -174,8 +168,7 @@ class SavedTracksViewModel( _state.value = SavedTracksScreenState.Data.Loaded( tracks = tracksResult?.items ?: emptyList(), nextPagination = tracksResult?.nextPagination, - totalCount = totalCount, - ) + totalCount = totalCount) }.onFailure { e -> logger.e(e) { "Failed to load saved tracks" } _state.value = SavedTracksScreenState.Error(e.message ?: "Unknown error") @@ -192,8 +185,7 @@ class SavedTracksViewModel( _state.value = SavedTracksScreenState.Data.Loaded( tracks = currentState.tracks + (result?.items ?: emptyList()), nextPagination = result?.nextPagination, - totalCount = currentState.totalCount, - ) + totalCount = currentState.totalCount) }.onFailure { e -> logger.e(e) { "Failed to load more tracks" } _state.value = SavedTracksScreenState.Error(e.message ?: "Unknown error") @@ -211,8 +203,7 @@ class SavedTracksViewModel( RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks", - shuffle = true, - ) + shuffle = true) } fun addSavedTracksToQueue() { @@ -224,8 +215,7 @@ class SavedTracksViewModel( type = RemoteCollectionType.SavedTracks, id = SAVED_TRACKS_COLLECTION_ID, title = "Saved Tracks", - startTrack = track, - ) + startTrack = track) } fun refresh() { @@ -290,8 +280,7 @@ class SavedTracksViewModel( runCatching { libraryRepository.addTracksToPlaylist( playlistId, - _tracksToAddToPlaylist.value.map { it.id }, - ) + _tracksToAddToPlaylist.value.map { it.id }) }.onFailure { e -> logger.e(e) { "Failed to add tracks to playlist" } }.onSuccess { @@ -358,7 +347,6 @@ class SavedTracksViewModel( } } - sealed interface SavedState { data class Success(val data: T) : SavedState data object Loading : SavedState @@ -368,8 +356,7 @@ sealed interface SavedState { @Composable fun rememberIsSavedTracks( trackIds: List, - repository: SavedTracksRepository = koinInject(), -): SavedState> { + repository: SavedTracksRepository = koinInject()): SavedState> { val scope = rememberCoroutineScope() var state by remember { mutableStateOf>>(SavedState.Loading) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index ba3a837e..a3900f93 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -17,6 +17,17 @@ package dev.krtirtho.spotube.modules.search + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumAutocomplete +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -46,15 +57,10 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text +import androidx.compose.foundation.text.input.rememberTextFieldState + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -65,12 +71,9 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow 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.graphics.graphicsLayer import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -91,8 +94,6 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService -import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField -import dev.krtirtho.spotube.core.ui.base.ChipTab import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ArtistCard @@ -171,8 +172,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { entries = listOf(QueueEntry.StreamingTrack(track = track, url = "")), autoPlay = true, startPosition = 0, - collectionEntry = null, - ) + collectionEntry = null) } } @@ -236,8 +236,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = isTrackBlacklisted(track), - ) + isBlacklisted = isTrackBlacklisted(track)) } val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle(emptyList()) @@ -252,8 +251,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { isInQueue = queueTrackIds.contains(track.id), isCurrentlyPlaying = track.id == currentTrackId, isFavorite = savedTrackIds.contains(track.id), - isBlacklisted = isTrackBlacklisted(track), - ) + isBlacklisted = isTrackBlacklisted(track)) } fun bulkAddToQueue(tracks: List) { @@ -268,13 +266,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { remotePlaybackController.requestTracksPlayNext(tracks, "Search results") } - BaseScaffold( - topBar = { ApplicationMainBar(backButton = false) } - ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background)) { + ApplicationMainBar(backButton = false) Column( modifier = Modifier - .padding(innerPadding) - .fillMaxSize() + .fillMaxWidth() + .weight(1f) ) { Column( modifier = Modifier.fillMaxWidth() @@ -282,10 +282,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { SearchBar( query = state.query, onQueryChange = viewModel::onQueryChange, - onClear = viewModel::clearQuery, - onSearch = { - focusManager.clearFocus() - }, recentSearches = state.recentSearches, onRecentSearchClick = { search -> viewModel.applyRecentSearch(search) @@ -295,15 +291,13 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { onClearAllRecentSearches = { viewModel.clearAllRecentSearches() focusManager.clearFocus() - }, - ) + }) if (state.supportedSearchTypes.isNotEmpty()) { SearchTabs( types = state.supportedSearchTypes, selectedType = selectedType, - onTabSelected = viewModel::onTabSelected, - ) + onTabSelected = viewModel::onTabSelected) } } @@ -311,16 +305,14 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { state.isLoadingSearchTypes -> { SearchLoadingIndicator( modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == null -> { SearchMessage( message = "No search types available", modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == MetadataSupportedSearchType.ALL -> { @@ -356,8 +348,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { // No-op for now, could show a dialog with all artists }, modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == MetadataSupportedSearchType.TRACK -> { @@ -388,8 +379,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { // No-op for now, could show a dialog with all artists }, modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == MetadataSupportedSearchType.PLAYLIST -> { @@ -398,8 +388,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { playlists = state.playlists, onLoadNextPage = viewModel::loadNextPlaylists, modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == MetadataSupportedSearchType.ALBUM -> { @@ -408,8 +397,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { albums = state.albums, onLoadNextPage = viewModel::loadNextAlbums, modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == MetadataSupportedSearchType.ARTIST -> { @@ -418,8 +406,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { artists = state.artists, onLoadNextPage = viewModel::loadNextArtists, modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } selectedType == MetadataSupportedSearchType.USER -> { @@ -428,8 +415,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { users = state.users, onLoadNextPage = viewModel::loadNextUsers, modifier = Modifier - .fillMaxSize(), - ) + .fillMaxSize()) } } } @@ -445,35 +431,31 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist.map { it.id } ) } - }, - ) + }) } } -private sealed interface SearchDropdownEntry { - val onClick: () -> Unit - - data class ClearAll(override val onClick: () -> Unit) : SearchDropdownEntry - data class RecentSearch( - val query: String, - override val onClick: () -> Unit, - val onRemove: () -> Unit, - ) : SearchDropdownEntry -} - @Composable private fun SearchBar( query: String, onQueryChange: (String) -> Unit, - onClear: () -> Unit, - onSearch: () -> Unit, recentSearches: List, onRecentSearchClick: (String) -> Unit, onRecentSearchRemove: (String) -> Unit, - onClearAllRecentSearches: () -> Unit, -) { + onClearAllRecentSearches: () -> Unit) { val showRecentSearches = query.isBlank() && recentSearches.isNotEmpty() - val dropdownItems = remember(showRecentSearches, recentSearches) { + val searchState = rememberTextFieldState(query) + + LaunchedEffect(searchState) { + snapshotFlow { searchState.text.toString() }.collect { onQueryChange(it) } + } + LaunchedEffect(query) { + if (searchState.text.toString() != query) { + searchState.edit { replace(0, length, query) } + } + } + + val suggestions = remember(showRecentSearches, recentSearches) { buildList { if (showRecentSearches) { add(SearchDropdownEntry.ClearAll(onClick = onClearAllRecentSearches)) @@ -482,8 +464,7 @@ private fun SearchBar( SearchDropdownEntry.RecentSearch( query = search, onClick = { onRecentSearchClick(search) }, - onRemove = { onRecentSearchRemove(search) }, - ) + onRemove = { onRecentSearchRemove(search) }) ) } } @@ -494,128 +475,78 @@ private fun SearchBar( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .padding(bottom = 8.dp) - ) { - AutocompleteTextField( - value = query, - onValueChange = onQueryChange, - items = dropdownItems, - onItemSelected = { entry -> entry.onClick() }, - placeholder = { - Text( - "Search songs, artists, albums...", - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxSearchBroken, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp) - ) - }, - trailingIcon = { - if (query.isNotBlank()) { - IconButton( - onClick = onClear, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = FeatherIcons.X, - contentDescription = "Clear", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { onSearch() }), - itemContent = { entry, isSelected -> + .padding(bottom = 8.dp)) { + MillenniumAutocomplete( + state = searchState, + label = "Search", + suggestions = suggestions, + onSuggestionSelected = { entry -> entry.onClick() }, + suggestionLabel = { entry -> when (entry) { - is SearchDropdownEntry.ClearAll -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 2.dp) - .clip(RoundedCornerShape(4.dp)) - .background( - if (isSelected) MaterialTheme.colorScheme.surfaceVariant - else Color.Transparent - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxTrash, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(16.dp) - ) - Text( - "Clear all history", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - is SearchDropdownEntry.RecentSearch -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 2.dp) - .clip(RoundedCornerShape(4.dp)) - .background( - if (isSelected) MaterialTheme.colorScheme.surfaceVariant - else Color.Transparent - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - imageVector = Iconsax.InconsaxClock, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - entry.query, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f) - ) - IconButton( - onClick = entry.onRemove, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = FeatherIcons.X, - contentDescription = "Remove", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - } - } - } - } + is SearchDropdownEntry.ClearAll -> "Clear all history" + is SearchDropdownEntry.RecentSearch -> entry.query } }, - ) + placeholder = "Search songs, artists, albums...", + modifier = Modifier.fillMaxWidth(), + suggestionContent = { entry, _ -> SearchSuggestionRow(entry) }) + } +} + +private sealed interface SearchDropdownEntry { + val onClick: () -> Unit + + data class ClearAll(override val onClick: () -> Unit) : SearchDropdownEntry + data class RecentSearch( + val query: String, + override val onClick: () -> Unit, + val onRemove: () -> Unit) : SearchDropdownEntry +} + +@Composable +private fun SearchSuggestionRow(entry: SearchDropdownEntry) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp)) { + when (entry) { + is SearchDropdownEntry.ClearAll -> { + MillenniumIcon( + imageVector = Iconsax.IconsaxTrash, + contentDescription = null, + tint = AppTheme.colors.error, + modifier = Modifier.size(16.dp)) + Text( + "Clear all history", + color = AppTheme.colors.error, + style = AppTheme.typography.bodyMedium) + } + + is SearchDropdownEntry.RecentSearch -> { + MillenniumIcon( + imageVector = Iconsax.InconsaxClock, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = AppTheme.colors.onSurfaceVariant) + Text( + entry.query, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = AppTheme.typography.bodyMedium, + modifier = Modifier.weight(1f)) + MillenniumIconButton( + onClick = entry.onRemove, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + imageVector = FeatherIcons.X, + contentDescription = "Remove", + modifier = Modifier.size(14.dp), + tint = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f)) + } + } + } } } @@ -623,8 +554,7 @@ private fun SearchBar( private fun SearchTabs( types: List, selectedType: MetadataSupportedSearchType?, - onTabSelected: (MetadataSupportedSearchType) -> Unit, -) { + onTabSelected: (MetadataSupportedSearchType) -> Unit) { val listState = rememberLazyListState() LazyRow( state = listState, @@ -634,10 +564,13 @@ private fun SearchTabs( ) { items(types) { type -> val isSelected = selectedType == type - ChipTab( - selected = isSelected, - onClick = { onTabSelected(type) } - ) { + MillenniumButton( + onClick = { onTabSelected(type) }, + style = if (isSelected) { + MillenniumButtonStyle.Primary + } else { + MillenniumButtonStyle.Secondary + }) { Text(type.tabTitle()) } } @@ -667,8 +600,7 @@ private fun SearchAllTab( onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { if (query.isBlank()) { SearchMessage(message = "Start typing to search", modifier = modifier) return @@ -692,8 +624,7 @@ private fun SearchAllTab( ErrorDisplay( errorMessage = hasAnyError, onRetry = { }, - modifier = modifier, - ) + modifier = modifier) return } @@ -732,10 +663,9 @@ private fun SearchAllTab( if (showTracks) { Text( text = "Tracks", - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp), - ) + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp)) } if (MetadataSupportedSearchType.TRACK in supportedSearchTypes) { if (tracks.isLoading && tracks.items.isEmpty()) { @@ -754,8 +684,7 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.PLAYLIST in supportedSearchTypes && playlists.items.isNotEmpty()) { SearchHorizontalSection( title = "Playlists", - onSeeAll = { onSeeAll(MetadataSupportedSearchType.PLAYLIST) }, - ) { + onSeeAll = { onSeeAll(MetadataSupportedSearchType.PLAYLIST) }) { playlists.items.forEach { playlist -> item(key = playlist.id) { PlaylistCard(playlist = playlist) } } @@ -765,8 +694,7 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.ALBUM in supportedSearchTypes && albums.items.isNotEmpty()) { SearchHorizontalSection( title = "Albums", - onSeeAll = { onSeeAll(MetadataSupportedSearchType.ALBUM) }, - ) { + onSeeAll = { onSeeAll(MetadataSupportedSearchType.ALBUM) }) { albums.items.forEach { album -> item(key = album.id) { AlbumCard(album = album) } } @@ -776,8 +704,7 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.ARTIST in supportedSearchTypes && artists.items.isNotEmpty()) { SearchHorizontalSection( title = "Artists", - onSeeAll = { onSeeAll(MetadataSupportedSearchType.ARTIST) }, - ) { + onSeeAll = { onSeeAll(MetadataSupportedSearchType.ARTIST) }) { artists.items.forEach { artist -> item(key = artist.id) { ArtistCard(artist = artist) } } @@ -787,8 +714,7 @@ private fun SearchAllTab( if (MetadataSupportedSearchType.USER in supportedSearchTypes && users.items.isNotEmpty()) { SearchHorizontalSection( title = "Users", - onSeeAll = { onSeeAll(MetadataSupportedSearchType.USER) }, - ) { + onSeeAll = { onSeeAll(MetadataSupportedSearchType.USER) }) { users.items.forEach { user -> item(key = user.id) { UserCard(user = user) } } @@ -817,8 +743,7 @@ private fun SearchTracksTab( onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { if (query.isBlank()) { SearchMessage(message = "Start typing to search tracks", modifier = modifier) return @@ -850,8 +775,7 @@ private fun SearchTracksTab( onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, simplified = true, - modifier = modifier, - ) + modifier = modifier) } @Composable @@ -859,8 +783,7 @@ private fun SearchAlbumsTab( query: String, albums: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { SearchGridTab( query = query, items = albums.items, @@ -870,8 +793,7 @@ private fun SearchAlbumsTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No albums found", loadingMessage = "Loading albums...", - modifier = modifier, - ) { album -> + modifier = modifier) { album -> AlbumCard(album = album, modifier = Modifier.fillMaxWidth()) } } @@ -881,8 +803,7 @@ private fun SearchArtistsTab( query: String, artists: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { SearchGridTab( query = query, items = artists.items, @@ -892,8 +813,7 @@ private fun SearchArtistsTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No artists found", loadingMessage = "Loading artists...", - modifier = modifier, - ) { artist -> + modifier = modifier) { artist -> ArtistCard(artist = artist, modifier = Modifier.fillMaxWidth()) } } @@ -903,8 +823,7 @@ private fun SearchPlaylistsTab( query: String, playlists: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { SearchGridTab( query = query, items = playlists.items, @@ -914,8 +833,7 @@ private fun SearchPlaylistsTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No playlists found", loadingMessage = "Loading playlists...", - modifier = modifier, - ) { playlist -> + modifier = modifier) { playlist -> PlaylistCard(playlist = playlist, modifier = Modifier.fillMaxWidth()) } } @@ -925,8 +843,7 @@ private fun SearchUsersTab( query: String, users: SearchPagedState, onLoadNextPage: () -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { SearchGridTab( query = query, items = users.items, @@ -936,8 +853,7 @@ private fun SearchUsersTab( onLoadNextPage = onLoadNextPage, emptyMessage = "No users found", loadingMessage = "Loading users...", - modifier = modifier, - ) { user -> + modifier = modifier) { user -> UserCard(user = user, modifier = Modifier.fillMaxWidth()) } } @@ -954,8 +870,7 @@ private fun SearchGridTab( emptyMessage: String, loadingMessage: String, modifier: Modifier = Modifier, - itemContent: @Composable (T) -> Unit, -) { + itemContent: @Composable (T) -> Unit) { if (query.isBlank()) { SearchMessage(message = "Start typing to search", modifier = modifier) return @@ -982,16 +897,14 @@ private fun SearchGridTab( isLoading && items.isEmpty() -> { SearchLoadingIndicator( message = loadingMessage, - modifier = modifier, - ) + modifier = modifier) } error != null && items.isEmpty() -> { ErrorDisplay( errorMessage = error, onRetry = onLoadNextPage, - modifier = modifier, - ) + modifier = modifier) } !isLoading && items.isEmpty() -> { @@ -1005,8 +918,7 @@ private fun SearchGridTab( modifier = modifier.padding(horizontal = 12.dp), contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { items(items) { item -> itemContent(item) } @@ -1017,8 +929,7 @@ private fun SearchGridTab( PlayableCard( title = "Sample Title", subtitle = "Sample Subtitle", - imageURL = "https://placehold.co/600x400", - ) + imageURL = "https://placehold.co/600x400") } } } @@ -1031,38 +942,32 @@ private fun SearchGridTab( private fun SearchHorizontalSection( title: String, onSeeAll: () -> Unit, - content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit, -) { + content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + horizontalArrangement = Arrangement.SpaceBetween) { Text( text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold) Text( text = "See all", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.clickable(onClick = onSeeAll), - ) + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.primary, + modifier = Modifier.clickable(onClick = onSeeAll)) } LazyRow( contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - content = content, - ) + content = content) } } @@ -1070,46 +975,39 @@ private fun SearchHorizontalSection( private fun SearchTrackRow( index: Int, track: MetadataTrack, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { Row( modifier = modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(10.dp)) { Text( text = (index + 1).toString(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.End, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.End) Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( text = track.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - ) + style = AppTheme.typography.bodyLarge, + maxLines = 1) Text( text = track.artists.joinToString { it.name }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + maxLines = 1) } if (track.album != null) Text( text = track.album!!.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - modifier = Modifier.weight(1f), - ) + modifier = Modifier.weight(1f)) } } @@ -1120,33 +1018,27 @@ private val WarmSearchMessages = listOf( "Dusting off the vinyl...", "Consulting the music gods...", "Tuning the antennas...", - "Flipping through the records...", -) + "Flipping through the records...") @Composable private fun SearchLoadingIndicator( message: String? = null, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val infiniteTransition = rememberInfiniteTransition(label = "search-loading") val rotation by infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1500, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "icon-rotation", - ) + repeatMode = RepeatMode.Restart), + label = "icon-rotation") val bounce by infiniteTransition.animateFloat( initialValue = -8f, targetValue = 8f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 800, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "icon-bounce", - ) + repeatMode = RepeatMode.Reverse), + label = "icon-bounce") val messageIndex by infiniteTransition.animateFloat( initialValue = 0f, targetValue = WarmSearchMessages.size.toFloat(), @@ -1155,52 +1047,44 @@ private fun SearchLoadingIndicator( durationMillis = WarmSearchMessages.size * 2000, easing = LinearEasing ), - repeatMode = RepeatMode.Restart, - ), - label = "message-cycle", - ) + repeatMode = RepeatMode.Restart), + label = "message-cycle") val displayMessage = message ?: WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size] Column( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { + verticalArrangement = Arrangement.Center) { Box( modifier = Modifier.size(64.dp), - contentAlignment = Alignment.Center, - ) { - Icon( + contentAlignment = Alignment.Center) { + MillenniumIcon( imageVector = Iconsax.IconsaxSearchBroken, contentDescription = "Searching", - tint = MaterialTheme.colorScheme.primary, + tint = AppTheme.colors.primary, modifier = Modifier .size(48.dp) .graphicsLayer { rotationZ = rotation translationY = bounce - }, - ) + }) } - LinearProgressIndicator( + MillenniumIndeterminateProgress( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 16.dp), - ) + .padding(horizontal = 32.dp, vertical = 16.dp)) AnimatedContent( targetState = displayMessage, transitionSpec = { fadeIn(tween(300)) togetherWith fadeOut(tween(300)) }, - label = "message-transition", - ) { msg -> + label = "message-transition") { msg -> Text( text = msg, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - ) + color = AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, + textAlign = TextAlign.Center) } } } @@ -1209,35 +1093,30 @@ private fun SearchLoadingIndicator( private fun SearchMessage( message: String, modifier: Modifier = Modifier, - isError: Boolean = false, -) { + isError: Boolean = false) { Box( modifier = modifier, - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = message, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, + color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, textAlign = TextAlign.Center, - modifier = Modifier.padding(16.dp), - ) + modifier = Modifier.padding(16.dp)) } } @Composable private fun SearchMessageInline( message: String, - isError: Boolean = false, -) { + isError: Boolean = false) { Text( text = message, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, + color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, + style = AppTheme.typography.bodyMedium, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), - ) + .padding(horizontal = 16.dp)) } @Composable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt index 5f233b9a..5134106a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreenViewModel.kt @@ -47,8 +47,7 @@ data class SearchPagedState( val nextPagination: PaginationStrategy? = null, val hasNextPage: Boolean = true, val isLoading: Boolean = false, - val error: String? = null, -) + val error: String? = null) data class SearchScreenState( val query: String = "", @@ -60,15 +59,13 @@ data class SearchScreenState( val albums: SearchPagedState = SearchPagedState(), val artists: SearchPagedState = SearchPagedState(), val playlists: SearchPagedState = SearchPagedState(), - val users: SearchPagedState = SearchPagedState(), -) + val users: SearchPagedState = SearchPagedState()) @OptIn(ExperimentalCoroutinesApi::class) class SearchScreenViewModel( private val repository: SearchRepository, private val savedTracksRepository: SavedTracksRepository, - private val database: Database, -) : ViewModel() { + private val database: Database) : ViewModel() { companion object { private val RECENT_SEARCHES_KEY = stringPreferencesKey("recent_searches") private val SEARCH_TYPES_ORDER = listOf( @@ -77,8 +74,7 @@ class SearchScreenViewModel( MetadataSupportedSearchType.PLAYLIST, MetadataSupportedSearchType.ALBUM, MetadataSupportedSearchType.ARTIST, - MetadataSupportedSearchType.USER, - ) + MetadataSupportedSearchType.USER) private const val QUERY_DEBOUNCE_MS = 350L private const val RECENT_SEARCHES_LIMIT = 12 private const val TRACK_PAGE_SIZE = 20 @@ -130,8 +126,7 @@ class SearchScreenViewModel( albums = SearchPagedState(), artists = SearchPagedState(), playlists = SearchPagedState(), - users = SearchPagedState(), - ) + users = SearchPagedState()) } fun clearAllRecentSearches() { @@ -157,8 +152,7 @@ class SearchScreenViewModel( albums = SearchPagedState(), artists = SearchPagedState(), playlists = SearchPagedState(), - users = SearchPagedState(), - ) + users = SearchPagedState()) if (_state.value.query.isNotBlank()) { viewModelScope.launch { refreshCurrentSelection(reset = true) @@ -232,8 +226,7 @@ class SearchScreenViewModel( _state.value = _state.value.copy( supportedSearchTypes = ordered, selectedSearchType = selectedType, - isLoadingSearchTypes = false, - ) + isLoadingSearchTypes = false) if (_state.value.query.isNotBlank()) { refreshCurrentSelection(reset = true) @@ -243,8 +236,7 @@ class SearchScreenViewModel( isLoadingSearchTypes = false, supportedSearchTypes = emptyList(), selectedSearchType = null, - tracks = SearchPagedState(error = it.message ?: "Failed to load search capabilities"), - ) + tracks = SearchPagedState(error = it.message ?: "Failed to load search capabilities")) } } } @@ -257,8 +249,7 @@ class SearchScreenViewModel( albums = SearchPagedState(), artists = SearchPagedState(), playlists = SearchPagedState(), - users = SearchPagedState(), - ) + users = SearchPagedState()) return } @@ -284,8 +275,7 @@ class SearchScreenViewModel( albums = SearchPagedState(isLoading = true), artists = SearchPagedState(isLoading = true), playlists = SearchPagedState(isLoading = true), - users = SearchPagedState(isLoading = true), - ) + users = SearchPagedState(isLoading = true)) if (MetadataSupportedSearchType.ALL in supported) { runCatching { @@ -305,41 +295,34 @@ class SearchScreenViewModel( items = tracks.take(TRACK_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), + isLoading = false), playlists = SearchPagedState( items = playlists.take(ALL_ROW_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), + isLoading = false), albums = SearchPagedState( items = albums.take(ALL_ROW_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), + isLoading = false), artists = SearchPagedState( items = artists.take(ALL_ROW_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), + isLoading = false), users = SearchPagedState( items = users.take(ALL_ROW_PAGE_SIZE), nextPagination = null, hasNextPage = false, - isLoading = false, - ), - ) + isLoading = false)) }.onFailure { _state.value = _state.value.copy( tracks = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), playlists = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), albums = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), artists = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - users = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search"), - ) + users = SearchPagedState(isLoading = false, error = it.message ?: "Failed to search")) } return@coroutineScope } @@ -356,8 +339,7 @@ class SearchScreenViewModel( playlists = _state.value.playlists.copy(items = _state.value.playlists.items.take(ALL_ROW_PAGE_SIZE)), albums = _state.value.albums.copy(items = _state.value.albums.items.take(ALL_ROW_PAGE_SIZE)), artists = _state.value.artists.copy(items = _state.value.artists.items.take(ALL_ROW_PAGE_SIZE)), - users = _state.value.users.copy(items = _state.value.users.items.take(ALL_ROW_PAGE_SIZE)), - ) + users = _state.value.users.copy(items = _state.value.users.items.take(ALL_ROW_PAGE_SIZE))) } private suspend fun loadTracks(reset: Boolean) { @@ -390,16 +372,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( tracks = _state.value.tracks.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search tracks", - ) + error = throwable.message ?: "Failed to search tracks") ) } } @@ -432,16 +412,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( albums = _state.value.albums.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search albums", - ) + error = throwable.message ?: "Failed to search albums") ) } } @@ -474,16 +452,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( artists = _state.value.artists.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search artists", - ) + error = throwable.message ?: "Failed to search artists") ) } } @@ -516,16 +492,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( playlists = _state.value.playlists.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search playlists", - ) + error = throwable.message ?: "Failed to search playlists") ) } } @@ -558,16 +532,14 @@ class SearchScreenViewModel( nextPagination = page.nextPagination, hasNextPage = page.nextPagination != null, isLoading = false, - error = null, - ) + error = null) ) }.onFailure { throwable -> _state.value = _state.value.copy( users = _state.value.users.copy( isLoading = false, hasNextPage = false, - error = throwable.message ?: "Failed to search users", - ) + error = throwable.message ?: "Failed to search users") ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt index da022080..411935f7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsScreen.kt @@ -1,13 +1,15 @@ package dev.krtirtho.spotube.modules.settings import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Text +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumButton import androidx.compose.runtime.getValue import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -15,8 +17,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard @@ -57,31 +57,24 @@ import spotube.composeapp.generated.resources.settings_section_jam @Composable fun JamSettingsScreen( - viewModel: JamSettingsViewModel = koinViewModel(), -) { - BaseScaffold( - topBar = { - ApplicationMainBar( - title = { Text(stringResource(Res.string.settings_section_jam)) }, - ) - }, - ) { innerPadding -> + viewModel: JamSettingsViewModel = koinViewModel()) { + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { Text(stringResource(Res.string.settings_section_jam)) }) JamSettingsContent( viewModel = viewModel, - modifier = Modifier.padding(innerPadding), - ) + modifier = Modifier.weight(1f)) } } /** - * The Group Jam settings UI without its own [BaseScaffold] / app bar, so it can be embedded + * The Group Jam settings UI without its own app bar, so it can be embedded * as a tab in the large-screen settings layout as well as hosted by [JamSettingsScreen]. */ @Composable internal fun JamSettingsContent( viewModel: JamSettingsViewModel = koinViewModel(), - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val broker = uiState.settings.jamBroker val shellBottomInset = LocalAppShellBottomInset.current @@ -91,14 +84,12 @@ internal fun JamSettingsContent( Box( modifier = modifier - .fillMaxSize(), - ) { + .fillMaxSize()) { LazyColumn( modifier = Modifier .widthIn(max = 1280.dp) .align(Alignment.TopCenter), - contentPadding = contentPadding, - ) { + contentPadding = contentPadding) { settingsSectionHeader(Res.string.settings_jam_section_connection) settingsSectionCard( items = listOf( @@ -116,8 +107,7 @@ internal fun JamSettingsContent( ) ) } - }, - ) + }) }, { val errorWholeNumber = @@ -146,8 +136,7 @@ internal fun JamSettingsContent( Iconsax.CustomServer, stringResource(Res.string.settings_jam_broker_port) ) - }, - ) + }) }, { SwitchSettingCard( @@ -162,11 +151,8 @@ internal fun JamSettingsContent( Iconsax.IconsaxWifiSquare, stringResource(Res.string.settings_jam_broker_tls) ) - }, - ) - }, - ), - ) + }) + })) settingsSectionHeader(Res.string.settings_jam_section_credentials) settingsSectionCard( items = listOf( @@ -185,8 +171,7 @@ internal fun JamSettingsContent( Iconsax.User, stringResource(Res.string.settings_jam_broker_username) ) - }, - ) + }) }, { TextInputSettingCard( @@ -203,11 +188,8 @@ internal fun JamSettingsContent( Iconsax.IconsaxEye, stringResource(Res.string.settings_jam_broker_password) ) - }, - ) - }, - ), - ) + }) + })) settingsSectionHeader(Res.string.settings_jam_section_identity) settingsSectionCard( items = listOf( @@ -227,11 +209,8 @@ internal fun JamSettingsContent( Iconsax.IconsaxTag, stringResource(Res.string.settings_jam_broker_client_id) ) - }, - ) - }, - ), - ) + }) + })) settingsSectionHeader(Res.string.settings_jam_section_diagnostics) settingsSectionCard( items = listOf( @@ -239,8 +218,7 @@ internal fun JamSettingsContent( val canTest = broker.host.isNotBlank() && !uiState.isTesting val resultMessage = uiState.testResult?.fold( onSuccess = { it }, - onFailure = { "Failed: ${it.message ?: "Connection failed"}" }, - ) + onFailure = { "Failed: ${it.message ?: "Connection failed"}" }) SettingCardItem( enabled = canTest, @@ -253,35 +231,28 @@ internal fun JamSettingsContent( icon = { SettingsItemIcon( Iconsax.IconsaxInformation, - stringResource(Res.string.settings_jam_broker_test), - ) + stringResource(Res.string.settings_jam_broker_test)) }, trailingContent = { - PrimaryButton( + MillenniumButton( onClick = viewModel::testConnection, - enabled = canTest, - ) { + enabled = canTest) { Text( if (uiState.isTesting) { stringResource(Res.string.settings_jam_broker_testing) } else { stringResource(Res.string.settings_jam_broker_test) - }, - ) + }) } }, - onClick = viewModel::testConnection, - ) - }, - ), - ) + onClick = viewModel::testConnection) + })) item { Text( text = stringResource(Res.string.settings_jam_broker_placeholder_note), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - ) + .padding(horizontal = 16.dp, vertical = 4.dp)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt index 010fc6e2..ad66d86a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/JamSettingsViewModel.kt @@ -12,13 +12,11 @@ import kotlinx.coroutines.launch data class JamSettingsUiState( val settings: UserSettings = UserSettings(), val isTesting: Boolean = false, - val testResult: Result? = null, -) + val testResult: Result? = null) class JamSettingsViewModel( private val settingsRepository: SettingsRepository, - private val jamRoomClient: JamRoomClient, -) : ViewModel() { + private val jamRoomClient: JamRoomClient) : ViewModel() { private val _uiState = MutableStateFlow(JamSettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index d51fb20e..b3dc1c60 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -19,8 +19,8 @@ package dev.krtirtho.spotube.modules.settings import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality -import dev.krtirtho.spotube.core.ui.base.SurfaceStyle import kotlinx.serialization.Serializable +import org.spotube.millennium.theme.MillenniumPalette @Serializable enum class Theme { @@ -35,8 +35,7 @@ data class UserSettings( // Appearance val theme: Theme = Theme.SYSTEM, - val accentColor: AccentColors = AccentColors.GREEN_GOBLIN, - val surfaceTheme: SurfaceStyle = SurfaceStyle.Glass, + val palette: MillenniumPalette = MillenniumPalette.Blue, // Playback val streamingMusicFormat: AudioFormat = AudioFormat( @@ -46,8 +45,7 @@ data class UserSettings( AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), - ) + AudioQuality.Lossy(bitrate = 256_000)) ), val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), val enableMusicCaching: Boolean = true, @@ -78,8 +76,7 @@ data class UserSettings( AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), - ) + AudioQuality.Lossy(bitrate = 256_000)) ), val downloadMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), @@ -90,8 +87,7 @@ data class UserSettings( // Updates val autoCheckForUpdates: Boolean = true, val ignoredAppUpdateVersion: String? = null, - val ignoredPluginUpdateVersions: Map = emptyMap(), -) + val ignoredPluginUpdateVersions: Map = emptyMap()) /** * Configuration for the MQTT broker used by Group Jam. The host is a placeholder @@ -107,5 +103,4 @@ data class JamBroker( val password: String? = null, val clientIdPrefix: String = "spotube", val keepAliveSeconds: Int = 30, - val connectionTimeoutSeconds: Int = 10, -) + val connectionTimeoutSeconds: Int = 10) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt index 1d6b0d6f..8313d990 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsRepository.kt @@ -37,6 +37,16 @@ interface UserSettingsSource { class SettingsRepository(private val database: Database) : UserSettingsSource { companion object { private val SETTINGS_KEY = stringPreferencesKey("user_settings") + + /** + * Lenient so settings written by older builds (which stored `accentColor` / + * `surfaceTheme` instead of the Millennium `palette`) still decode, and so a + * missing field falls back to its default. + */ + private val settingsJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } } val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -44,7 +54,7 @@ class SettingsRepository(private val database: Database) : UserSettingsSource { override val userSettings: StateFlow = database.settingsDataStore.data.map { prefs -> val json = prefs[SETTINGS_KEY] if (json != null) { - Json.decodeFromString(json as String) + settingsJson.decodeFromString(json as String) } else { UserSettings() // Default value } @@ -56,13 +66,13 @@ class SettingsRepository(private val database: Database) : UserSettingsSource { suspend fun updateSettings(newSettings: UserSettings) { database.settingsDataStore.edit { prefs -> - prefs[SETTINGS_KEY] = Json.encodeToString(newSettings) + prefs[SETTINGS_KEY] = settingsJson.encodeToString(newSettings) } } suspend fun getCurrentSettings(): UserSettings { val preferences = database.settingsDataStore.data.first() - val settingsJson = preferences[SETTINGS_KEY] ?: return UserSettings() - return Json.decodeFromString(settingsJson) + val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings() + return settingsJson.decodeFromString(storedJson) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index 694c9d18..add9858d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -19,13 +19,13 @@ package dev.krtirtho.spotube.modules.settings import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Text +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -38,7 +38,6 @@ import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.BaseScaffold import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import spotube.composeapp.generated.resources.* import dev.krtirtho.spotube.modules.settings.sections.appearanceSection @@ -72,15 +71,13 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { if (maxWidth >= SettingsDialogMinWidth) { SettingsDialog( settingsViewModel = settingsViewModel, - onDismiss = { navigatorCommands.pop(Routes.Settings) }, - ) + onDismiss = { navigatorCommands.pop(Routes.Settings) }) } else { CompactSettingsScreen(settingsViewModel = settingsViewModel) } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun CompactSettingsScreen(settingsViewModel: SettingsViewModel) { val navigatorCommands = koinInject() @@ -91,24 +88,20 @@ private fun CompactSettingsScreen(settingsViewModel: SettingsViewModel) { PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) } - BaseScaffold( - topBar = { - ApplicationMainBar( - title = { - Text(stringResource(Res.string.settings_screen_title)) - }, - backButton = false - ) - } - ) { innerPadding -> + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { + Text(stringResource(Res.string.settings_screen_title)) + }, + backButton = false + ) CompactSettingsContent( settings = settingsState, settingsViewModel = settingsViewModel, navigatorCommands = navigatorCommands, requestLocalNetworkPermission = requestLocalNetworkPermission, contentPadding = contentPadding, - modifier = Modifier.padding(innerPadding), - ) + modifier = Modifier.weight(1f)) } } @@ -119,8 +112,7 @@ private fun CompactSettingsContent( navigatorCommands: NavigationCommands, requestLocalNetworkPermission: () -> Unit, contentPadding: PaddingValues, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val platformType = remember { getPlatform().type } val isDesktopPlatform = platformType == PlatformType.Windows || platformType == PlatformType.Linux || @@ -131,48 +123,38 @@ private fun CompactSettingsContent( modifier = Modifier .widthIn(max = 1280.dp) .align(Alignment.TopCenter), - contentPadding = contentPadding, - ) { + contentPadding = contentPadding) { pluginsSection( - navigatorCommands = navigatorCommands, - ) + navigatorCommands = navigatorCommands) if (settings != null) { languageRegionSection( settings = settings, - settingsViewModel = settingsViewModel, - ) + settingsViewModel = settingsViewModel) appearanceSection( settings = settings, - settingsViewModel = settingsViewModel, - ) + settingsViewModel = settingsViewModel) playbackSection( settings = settings, settingsViewModel = settingsViewModel, navigatorCommands = navigatorCommands, - requestLocalNetworkPermission = requestLocalNetworkPermission, - ) + requestLocalNetworkPermission = requestLocalNetworkPermission) jamSection( settings = settings, - navigationCommands = navigatorCommands, - ) + navigationCommands = navigatorCommands) cacheSection( settings = settings, - settingsViewModel = settingsViewModel, - ) + settingsViewModel = settingsViewModel) downloadsSection( settings = settings, - settingsViewModel = settingsViewModel, - ) + settingsViewModel = settingsViewModel) if (isDesktopPlatform) { desktopSection( settings = settings, - settingsViewModel = settingsViewModel, - ) + settingsViewModel = settingsViewModel) } updatesSection( settings = settings, - settingsViewModel = settingsViewModel, - ) + settingsViewModel = settingsViewModel) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt index 01e560cd..0104944a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsSidebarLayout.kt @@ -17,9 +17,15 @@ package dev.krtirtho.spotube.modules.settings -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,12 +40,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -49,8 +53,6 @@ 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.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -60,8 +62,6 @@ import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.component.SidebarItem import dev.krtirtho.spotube.modules.blacklist.BlacklistContent import dev.krtirtho.spotube.modules.plugin.PluginScreenContent @@ -113,8 +113,7 @@ private val SettingsSidebarWidth = 236.dp */ internal enum class SettingsTab( val titleRes: StringResource, - val icon: ImageVector, -) { + val icon: ImageVector) { Plugins(Res.string.settings_section_plugins, Iconsax.IconsaxBoxAdd), LanguageRegion(Res.string.settings_section_language_region, Iconsax.IconsaxLanguageSquare), Appearance(Res.string.settings_section_appearance, Iconsax.IconsaxColorSwatch), @@ -134,19 +133,16 @@ internal enum class SettingsTab( @Composable internal fun SettingsDialog( settingsViewModel: SettingsViewModel, - onDismiss: () -> Unit, -) { + onDismiss: () -> Unit) { Dialog( onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { + properties = DialogProperties(usePlatformDefaultWidth = false)) { // The dialog is its own surface; the player/bottom bars behind it should not // reserve any inset inside the dialog content. CompositionLocalProvider(LocalAppShellBottomInset provides 0.dp) { SettingsPane( settingsViewModel = settingsViewModel, - onClose = onDismiss, - ) + onClose = onDismiss) } } } @@ -154,13 +150,11 @@ internal fun SettingsDialog( @Composable private fun SettingsPane( settingsViewModel: SettingsViewModel, - onClose: () -> Unit, -) { + onClose: () -> Unit) { val navigatorCommands: NavigationCommands = koinInject() val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle() val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val contentPadding = remember { PaddingValues(top = 16.dp, bottom = 24.dp) } - val dialogTheme = LocalBaseUITheme.current.dialog val platformType = remember { getPlatform().type } val isDesktopPlatform = platformType == PlatformType.Windows || @@ -177,73 +171,57 @@ private fun SettingsPane( modifier = Modifier .fillMaxSize() .padding(32.dp), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Row( modifier = Modifier .widthIn(max = 1200.dp) .heightIn(max = 860.dp) .fillMaxWidth() .fillMaxHeight() - .shadow( - elevation = dialogTheme.shadow.elevation, - shape = dialogTheme.shape, - ambientColor = dialogTheme.shadow.ambientColor, - spotColor = dialogTheme.shadow.spotColor, - ) - .clip(dialogTheme.shape) - .background(dialogTheme.background, dialogTheme.shape) - .border( - BorderStroke(dialogTheme.border.width, dialogTheme.border.color), - dialogTheme.shape, - ), - ) { + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = AppTheme.shapes.large, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + shadowElevation = 16.dp)) { SettingsSidebar( tabs = tabs, selectedTab = selectedTab, - onSelect = { selectedIndex = tabs.indexOf(it) }, - ) - VerticalDivider( + onSelect = { selectedIndex = tabs.indexOf(it) }) + MillenniumSeparator( modifier = Modifier.fillMaxHeight(), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - thickness = 1.dp, - ) + vertical = true) Column( modifier = Modifier .weight(1f) - .fillMaxHeight(), - ) { + .fillMaxHeight()) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Box(modifier = Modifier.weight(1f)) { SettingsPageTitle(selectedTab.titleRes) } - GhostIconButton( + MillenniumIconButton( onClick = onClose, modifier = Modifier.padding(end = 8.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Close", - ) + contentDescription = "Close") } } - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + MillenniumSeparator() Box( modifier = Modifier .weight(1f) - .fillMaxWidth(), - ) { + .fillMaxWidth()) { SettingsTabContent( tab = selectedTab, settings = settingsState, settingsViewModel = settingsViewModel, navigatorCommands = navigatorCommands, requestLocalNetworkPermission = requestLocalNetworkPermission, - contentPadding = contentPadding, - ) + contentPadding = contentPadding) } } } @@ -254,19 +232,17 @@ private fun SettingsPane( private fun SettingsSidebar( tabs: List, selectedTab: SettingsTab, - onSelect: (SettingsTab) -> Unit, -) { + onSelect: (SettingsTab) -> Unit) { LazyColumn( modifier = Modifier .width(SettingsSidebarWidth) .fillMaxHeight() .padding(horizontal = 8.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { item { Text( stringResource(Res.string.settings_screen_title), - style = MaterialTheme.typography.titleLarge, + style = AppTheme.typography.titleLarge, modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 16.dp) ) } @@ -275,8 +251,7 @@ private fun SettingsSidebar( label = stringResource(tab.titleRes), icon = tab.icon, selected = tab == selectedTab, - onClick = { onSelect(tab) }, - ) + onClick = { onSelect(tab) }) } } } @@ -288,8 +263,7 @@ private fun SettingsTabContent( settingsViewModel: SettingsViewModel, navigatorCommands: NavigationCommands, requestLocalNetworkPermission: () -> Unit, - contentPadding: PaddingValues, -) { + contentPadding: PaddingValues) { when (tab) { SettingsTab.Plugins -> { PluginScreenContent(viewModel = koinViewModel()) @@ -307,9 +281,8 @@ private fun SettingsTabContent( if (settings == null) { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() + contentAlignment = Alignment.Center) { + MillenniumIndeterminateProgress() } } else { Box(modifier = Modifier.fillMaxSize()) { @@ -317,8 +290,7 @@ private fun SettingsTabContent( modifier = Modifier .widthIn(max = 1280.dp) .align(Alignment.TopCenter), - contentPadding = contentPadding, - ) { + contentPadding = contentPadding) { when (tab) { SettingsTab.LanguageRegion -> languageRegionSection(settings, settingsViewModel, includeHeader = false) @@ -333,8 +305,7 @@ private fun SettingsTabContent( navigatorCommands = navigatorCommands, requestLocalNetworkPermission = requestLocalNetworkPermission, includeBlacklist = false, - includeHeader = false, - ) + includeHeader = false) SettingsTab.Caching -> cacheSection(settings, settingsViewModel, includeHeader = false) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt index 88b4878d..42af007c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsViewModel.kt @@ -28,7 +28,6 @@ interface SettingsProvider { val settingsState: StateFlow } - class SettingsViewModel( private val repository: SettingsRepository ) : ViewModel(), SettingsProvider { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt deleted file mode 100644 index 489a8b9f..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SurfaceAccents.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.modules.settings - -import androidx.compose.ui.graphics.Color -import dev.krtirtho.spotube.core.ui.base.SurfaceStyle - -/** - * Accent palettes that are known to pair well with each surface material. - * - * The appearance picker only offers these combinations, so a user can never end up with a - * clashing surface + accent pair — instead of the old behaviour where any accent could be - * applied to any surface. - */ -val SurfaceStyle.recommendedAccents: List - get() = when (this) { - SurfaceStyle.Watery -> listOf( - AccentColors.OCEANIC_CYAN, - AccentColors.MIDNIGHT_BLUE, - AccentColors.GREEN_GOBLIN, - AccentColors.SUNSET_ORANGE, - ) - - SurfaceStyle.Icy -> listOf( - AccentColors.MIDNIGHT_BLUE, - AccentColors.OCEANIC_CYAN, - AccentColors.ELECTRIC_VIOLET, - AccentColors.ROSE_GARDEN, - ) - - SurfaceStyle.Chrome -> listOf( - AccentColors.METALLIC_SLATE, - AccentColors.MIDNIGHT_BLUE, - AccentColors.SUNSET_ORANGE, - AccentColors.GREEN_GOBLIN, - ) - - SurfaceStyle.Acrylic -> listOf( - AccentColors.METALLIC_SLATE, - AccentColors.ELECTRIC_VIOLET, - AccentColors.OCEANIC_CYAN, - AccentColors.ROSE_GARDEN, - ) - - SurfaceStyle.Glass -> listOf( - AccentColors.GREEN_GOBLIN, - AccentColors.ELECTRIC_VIOLET, - AccentColors.OCEANIC_CYAN, - AccentColors.ROSE_GARDEN, - ) - } - -/** The concrete accent colour for the current light/dark mode. */ -fun AccentColors.colorFor(isLight: Boolean): Color = - if (isLight) toLightColor() else toDarkColor() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt index 35736f3c..98cfc5bc 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/InteractiveSettingCards.kt @@ -17,40 +17,41 @@ package dev.krtirtho.spotube.modules.settings.components -import androidx.compose.foundation.clickable + +import androidx.compose.ui.Alignment +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumRadioGroup +import org.spotube.millennium.composables.MillenniumSwitch +import org.spotube.millennium.composables.MillenniumTextField import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text +import androidx.compose.foundation.text.input.rememberTextFieldState + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.Radio -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.ThemedDialog -import dev.krtirtho.spotube.core.ui.base.Toggle import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 +import kotlinx.coroutines.flow.drop import spotube.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -60,17 +61,16 @@ internal fun SwitchSettingCard( subtitle: String? = null, checked: Boolean, onCheckedChange: (Boolean) -> Unit, - icon: (@Composable () -> Unit)? = null, -) { + icon: (@Composable () -> Unit)? = null) { SettingCardItem( title = title, subtitle = subtitle, icon = icon, trailingContent = { - Toggle( + MillenniumSwitch( checked = checked, onCheckedChange = onCheckedChange, - ) + label = title) }, onClick = { onCheckedChange(!checked) @@ -89,8 +89,7 @@ internal fun SelectionSettingCard( dialogTitle: String = title, icon: (@Composable () -> Unit)? = null, inlineSelectorMinWidth: Dp = 700.dp, - filter: ((AdaptiveMenuItem, String) -> Boolean)? = null, -) { + filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { var isDialogOpen by remember { mutableStateOf(false) } BoxWithConstraints { @@ -106,21 +105,24 @@ internal fun SelectionSettingCard( AdaptiveMenuItem( label = optionLabel(option), onClick = { onOptionSelected(option) }, - selected = option == selectedOption, - ) + selected = option == selectedOption) }, trigger = { onClick -> - OutlineButton(onClick = onClick) { - Text(optionLabel(selectedOption)) - Icon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = null, - modifier = Modifier.size(14.dp), - ) + MillenniumButton( + onClick = onClick, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(optionLabel(selectedOption)) + MillenniumIcon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(14.dp)) + } } }, - filter = filter, - ) + filter = filter) }, onClick = { if (isWideLayout) { @@ -130,45 +132,24 @@ internal fun SelectionSettingCard( ) if (!isWideLayout && isDialogOpen) { - ThemedDialog( + val labels = options.map { optionLabel(it) } + MillenniumDialog( + visible = true, onDismissRequest = { isDialogOpen = false }, - title = { - Text(dialogTitle, style = MaterialTheme.typography.titleLarge) - }, - actions = { - OutlineButton(onClick = { isDialogOpen = false }) { - Text(stringResource(Res.string.settings_action_close)) - } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - options.forEach { option -> - val isSelected = option == selectedOption - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .clickable { - onOptionSelected(option) - isDialogOpen = false - } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Radio( - selected = isSelected, - onClick = { - onOptionSelected(option) - isDialogOpen = false - } - ) - Text( - text = optionLabel(option), - style = MaterialTheme.typography.bodyMedium - ) - } - } + title = dialogTitle) { + MillenniumRadioGroup( + value = selectedOption, + onValueChange = { option -> + onOptionSelected(option) + isDialogOpen = false + }, + options = options, + label = dialogTitle, + optionLabel = { option -> labels[options.indexOf(option)] }) + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Quiet) { + Text(stringResource(Res.string.settings_action_close)) } } } @@ -189,13 +170,23 @@ internal fun TextInputSettingCard( icon: (@Composable () -> Unit)? = null, inlineTextFieldMinWidth: Dp = 700.dp, inlineTextFieldWidth: Dp = 220.dp, - enabled: Boolean = true, -) { + enabled: Boolean = true) { var isDialogOpen by remember { mutableStateOf(false) } BoxWithConstraints { val isWideLayout = maxWidth >= inlineTextFieldMinWidth - var inlineDraft by remember(value, isWideLayout) { mutableStateOf(value) } + val inlineState = rememberTextFieldState(value) + + LaunchedEffect(inlineState, isWideLayout) { + snapshotFlow { inlineState.text.toString() } + .drop(1) + .collect { draft -> + val normalizedValue = normalize(draft) + if (validate(normalizedValue) == null) { + onValueSaved(normalizedValue) + } + } + } SettingCardItem( enabled = enabled, @@ -204,24 +195,12 @@ internal fun TextInputSettingCard( icon = icon, trailingContent = if (isWideLayout) { { - TextField( - value = inlineDraft, - onValueChange = { draft -> - inlineDraft = draft - val normalizedValue = normalize(draft) - if (validate(normalizedValue) == null) { - onValueSaved(normalizedValue) - } - }, + MillenniumTextField( + state = inlineState, + label = title, modifier = Modifier.width(inlineTextFieldWidth), - placeholder = if (placeholder.isNotEmpty()) { - { Text(placeholder) } - } else { - null - }, - singleLine = true, - enabled = enabled, - ) + placeholder = placeholder, + enabled = enabled) } } else { null @@ -234,54 +213,50 @@ internal fun TextInputSettingCard( ) if (!isWideLayout && isDialogOpen) { + val dialogState = rememberTextFieldState(value) var draft by remember(value, isDialogOpen) { mutableStateOf(value) } + + LaunchedEffect(dialogState) { + snapshotFlow { dialogState.text.toString() } + .drop(1) + .collect { draft = it } + } + val normalizedValue = normalize(draft) val errorMessage = validate(normalizedValue) - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = { isDialogOpen = false }, - title = { - Text(dialogTitle, style = MaterialTheme.typography.titleLarge) - }, - actions = { - PrimaryButton( + title = dialogTitle) { + if (dialogDescription != null) { + Text( + text = dialogDescription, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) + } + MillenniumTextField( + state = dialogState, + label = title, + modifier = Modifier.fillMaxWidth(), + placeholder = placeholder, + error = errorMessage) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( onClick = { if (errorMessage == null) { onValueSaved(normalizedValue) isDialogOpen = false } - } - ) { + }, + enabled = errorMessage == null) { Text(stringResource(Res.string.settings_action_save)) } - OutlineButton(onClick = { isDialogOpen = false }) { + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.settings_action_cancel)) } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - dialogDescription?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = if (placeholder.isNotEmpty()) { - { Text(placeholder) } - } else { - null - }, - isError = errorMessage != null, - supportingText = errorMessage?.let { message -> - { Text(message) } - }, - singleLine = true, - ) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt index 7e34554f..17c6d8f9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/SettingsCardItem.kt @@ -17,11 +17,11 @@ package dev.krtirtho.spotube.modules.settings.components -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumListTile import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import dev.krtirtho.spotube.core.ui.base.ListRowTile /** * Reusable setting card item component for consistent styling @@ -37,7 +37,7 @@ fun SettingCardItem( onClick: () -> Unit = {}, modifier: Modifier = Modifier ) { - ListRowTile( + MillenniumListTile( selected = false, enabled = enabled, onClick = onClick, @@ -46,19 +46,18 @@ fun SettingCardItem( title = { Text( title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurface ) }, subtitle = subtitle?.let { { Text( it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant ) } }, - trailing = trailingContent, - ) + trailing = trailingContent) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt index 260cd7d3..19db935a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/AppearanceSection.kt @@ -23,20 +23,14 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -44,36 +38,37 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.BaseSurface -import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.SurfaceRole -import dev.krtirtho.spotube.core.ui.base.SurfaceStyle -import dev.krtirtho.spotube.core.ui.base.ThemedDialog -import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme -import dev.krtirtho.spotube.modules.settings.AccentColors +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.UserSettings -import dev.krtirtho.spotube.modules.settings.colorFor import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard import dev.krtirtho.spotube.modules.settings.components.SettingCardItem -import dev.krtirtho.spotube.modules.settings.recommendedAccents import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumText +import org.spotube.millennium.theme.MillenniumColorSchemes +import org.spotube.millennium.theme.MillenniumPalette +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.MillenniumThemeConfig +import org.spotube.millennium.theme.resolve import spotube.composeapp.generated.resources.* internal fun LazyListScope.appearanceSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { if (includeHeader) { settingsSectionHeader(Res.string.settings_section_appearance) } @@ -100,26 +95,20 @@ internal fun LazyListScope.appearanceSection( ) }, { - // Surface material and accent are one decision: each surface only offers the - // accents that are known to pair with it, so the result is always a curated, - // good-looking combination instead of an arbitrary one. AppearanceSettingCard( - selectedSurface = settings.surfaceTheme, - selectedAccent = settings.accentColor, + selectedPalette = settings.palette, icon = { SettingsItemIcon( Iconsax.IconsaxMagic, stringResource(Res.string.settings_appearance_title) ) }, - onSaved = { surface, accent -> + onSaved = { palette -> settingsViewModel.updateSettings { - copy(surfaceTheme = surface, accentColor = accent) + copy(palette = palette) } - }, - ) - }, - ) + }) + }) ) } @@ -133,197 +122,72 @@ private fun Theme.displayLabel(): String { } @Composable -private fun SurfaceStyle.displayLabel(): String { - return when (this) { - SurfaceStyle.Watery -> stringResource(Res.string.settings_surface_watery) - SurfaceStyle.Icy -> stringResource(Res.string.settings_surface_icy) - SurfaceStyle.Chrome -> stringResource(Res.string.settings_surface_chrome) - SurfaceStyle.Acrylic -> stringResource(Res.string.settings_surface_acrylic) - SurfaceStyle.Glass -> stringResource(Res.string.settings_surface_glass) - } -} - -@Composable -private fun AccentColors.displayLabel(): String { - return when (this) { - AccentColors.GREEN_GOBLIN -> stringResource(Res.string.settings_accent_green_goblin) - AccentColors.ELECTRIC_VIOLET -> stringResource(Res.string.settings_accent_electric_violet) - AccentColors.OCEANIC_CYAN -> stringResource(Res.string.settings_accent_oceanic_cyan) - AccentColors.SUNSET_ORANGE -> stringResource(Res.string.settings_accent_sunset_orange) - AccentColors.ROSE_GARDEN -> stringResource(Res.string.settings_accent_rose_garden) - AccentColors.MIDNIGHT_BLUE -> stringResource(Res.string.settings_accent_midnight_blue) - AccentColors.METALLIC_SLATE -> stringResource(Res.string.settings_accent_metallic_slate) - } +private fun MillenniumPalette.displayLabel(): String = when (this) { + MillenniumPalette.Blue -> stringResource(Res.string.settings_palette_blue) + MillenniumPalette.Orange -> stringResource(Res.string.settings_palette_orange) + MillenniumPalette.Lime -> stringResource(Res.string.settings_palette_lime) } @Composable private fun AppearanceSettingCard( - selectedSurface: SurfaceStyle, - selectedAccent: AccentColors, + selectedPalette: MillenniumPalette, icon: (@Composable () -> Unit)? = null, - onSaved: (SurfaceStyle, AccentColors) -> Unit, -) { + onSaved: (MillenniumPalette) -> Unit) { var isDialogOpen by remember { mutableStateOf(false) } - val isLight = LocalBaseUIColors.current.isLight SettingCardItem( title = stringResource(Res.string.settings_appearance_title), subtitle = stringResource( - Res.string.settings_appearance_subtitle_current, - selectedSurface.displayLabel(), - selectedAccent.displayLabel(), - ), + Res.string.settings_appearance_subtitle_palette, + selectedPalette.displayLabel()), icon = icon, trailingContent = { - AppearanceTrailingSwatch( - surface = selectedSurface, - accent = selectedAccent, - isLight = isLight, - ) + PaletteTrailingSwatch(selectedPalette) }, - onClick = { isDialogOpen = true }, - ) + onClick = { isDialogOpen = true }) if (isDialogOpen) { - var draftSurface by remember(selectedSurface, isDialogOpen) { - mutableStateOf(selectedSurface) - } - var draftAccent by remember(selectedSurface, selectedAccent, isDialogOpen) { - mutableStateOf(selectedAccent) + var draftPalette by remember(selectedPalette, isDialogOpen) { + mutableStateOf(selectedPalette) } - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = { isDialogOpen = false }, - title = { - Text( - stringResource(Res.string.settings_appearance_dialog_title), - style = MaterialTheme.typography.titleLarge, - ) - }, - actions = { - PrimaryButton( + title = stringResource(Res.string.settings_palette_dialog_title)) { + MillenniumText( + stringResource(Res.string.settings_palette_dialog_description), + color = AppTheme.colors.onSurfaceVariant) + MillenniumText( + stringResource(Res.string.settings_palette_label), + color = AppTheme.colors.onSurface) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MillenniumPalette.entries.forEach { palette -> + PaletteSwatch( + palette = palette, + label = palette.displayLabel(), + selected = palette == draftPalette, + onClick = { draftPalette = palette }) + } + } + + PalettePreview(draftPalette) + + MillenniumSeparator() + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( onClick = { - onSaved(draftSurface, draftAccent) + onSaved(draftPalette) isDialogOpen = false - } - ) { - Text(stringResource(Res.string.settings_action_save)) + }) { + MillenniumText(stringResource(Res.string.settings_action_save)) } - OutlineButton(onClick = { isDialogOpen = false }) { - Text(stringResource(Res.string.settings_action_cancel)) - } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { - Text( - text = stringResource(Res.string.settings_appearance_dialog_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Text( - text = stringResource(Res.string.settings_appearance_surface_label), - style = MaterialTheme.typography.labelLarge, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - SurfaceStyle.entries.forEach { style -> - SurfaceStyleTile( - style = style, - accent = draftAccent, - isLight = isLight, - selected = style == draftSurface, - modifier = Modifier.weight(1f), - onClick = { - draftSurface = style - if (draftAccent !in style.recommendedAccents) { - draftAccent = style.recommendedAccents.first() - } - }, - ) - } - } - - Text( - text = stringResource(Res.string.settings_appearance_accent_label), - style = MaterialTheme.typography.labelLarge, - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - draftSurface.recommendedAccents.forEach { accent -> - AccentSwatch( - accent = accent, - label = accent.displayLabel(), - isLight = isLight, - selected = accent == draftAccent, - onClick = { draftAccent = accent }, - ) - } - } - - AppearancePreview( - surface = draftSurface, - accent = draftAccent, - isLight = isLight, - ) - } - } - } -} - -@Composable -private fun SurfaceStyleTile( - style: SurfaceStyle, - accent: AccentColors, - isLight: Boolean, - selected: Boolean, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - val scheme = remember(style, accent, isLight) { - baseUIColorScheme(style, isLight, accent.colorFor(isLight)) - } - Box( - modifier = modifier - .height(70.dp) - .then( - if (selected) { - Modifier - .border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(14.dp)) - .padding(2.dp) - } else { - Modifier.padding(2.dp) - } - ), - ) { - CompositionLocalProvider(LocalBaseUIColors provides scheme) { - BaseSurface( - style = style, - role = SurfaceRole.PrimaryBackground, - shape = RoundedCornerShape(12.dp), - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onClick), - contentPadding = PaddingValues(8.dp), - ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = style.displayLabel(), - style = MaterialTheme.typography.labelMedium, - ) - Box( - modifier = Modifier - .size(14.dp) - .background(accent.colorFor(isLight), CircleShape), - ) + MillenniumButton( + onClick = { isDialogOpen = false }, + style = MillenniumButtonStyle.Secondary) { + MillenniumText(stringResource(Res.string.settings_action_cancel)) } } } @@ -331,126 +195,65 @@ private fun SurfaceStyleTile( } @Composable -private fun AccentSwatch( - accent: AccentColors, +private fun PaletteSwatch( + palette: MillenniumPalette, label: String, - isLight: Boolean, selected: Boolean, - onClick: () -> Unit, -) { + onClick: () -> Unit) { + val scheme = remember(palette) { MillenniumColorSchemes().resolve(palette, dark = false) } Box( modifier = Modifier - .size(34.dp) + .size(40.dp) .clip(CircleShape) - .background(accent.colorFor(isLight)) + .background(scheme.accent) .border( width = if (selected) 3.dp else 1.dp, - color = if (selected) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.outlineVariant - }, - shape = CircleShape, - ) + color = if (selected) AppTheme.colors.onSurface else AppTheme.colors.outlineVariant, + shape = CircleShape) .clickable(onClick = onClick) - .semantics { contentDescription = label }, - ) + .semantics { contentDescription = label }) } @Composable -private fun AppearanceTrailingSwatch( - surface: SurfaceStyle, - accent: AccentColors, - isLight: Boolean, -) { - val scheme = remember(surface, accent, isLight) { - baseUIColorScheme(surface, isLight, accent.colorFor(isLight)) - } - CompositionLocalProvider(LocalBaseUIColors provides scheme) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - BaseSurface( - style = surface, - role = SurfaceRole.PrimaryBackground, - shape = RoundedCornerShape(8.dp), - modifier = Modifier.size(width = 46.dp, height = 28.dp), - ) {} - BaseSurface( - style = surface, - role = SurfaceRole.PrimaryAccent, - shape = CircleShape, - modifier = Modifier.size(18.dp), - ) {} - } +private fun PaletteTrailingSwatch(palette: MillenniumPalette) { + val scheme = remember(palette) { MillenniumColorSchemes().resolve(palette, dark = false) } + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(width = 46.dp, height = 28.dp) + .clip(RoundedCornerShape(8.dp)) + .background(scheme.accent.copy(alpha = 0.35f))) + Box( + Modifier + .size(18.dp) + .clip(CircleShape) + .background(scheme.accent)) } } @Composable -private fun AppearancePreview( - surface: SurfaceStyle, - accent: AccentColors, - isLight: Boolean, -) { - val scheme = remember(surface, accent, isLight) { - baseUIColorScheme(surface, isLight, accent.colorFor(isLight)) - } - CompositionLocalProvider(LocalBaseUIColors provides scheme) { - BaseSurface( - style = surface, - role = SurfaceRole.PrimaryBackground, - shape = RoundedCornerShape(16.dp), +private fun PalettePreview(palette: MillenniumPalette) { + val dark = AppTheme.colors.background.luminance() < 0.5f + MillenniumTheme(dark = dark, config = MillenniumThemeConfig(palette = palette)) { + Column( modifier = Modifier .fillMaxWidth() - .height(150.dp), - contentPadding = PaddingValues(14.dp), - ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text( - text = stringResource(Res.string.settings_preview_label), - style = MaterialTheme.typography.labelLarge, - ) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - BaseSurface( - style = surface, - role = SurfaceRole.PrimaryAccent, - shape = RoundedCornerShape(999.dp), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - stringResource(Res.string.settings_preview_button), - style = MaterialTheme.typography.labelMedium, - ) - } - BaseSurface( - style = surface, - role = SurfaceRole.SecondaryAccent, - shape = RoundedCornerShape(999.dp), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - stringResource(Res.string.settings_preview_secondary), - style = MaterialTheme.typography.labelMedium, - ) - } + .clip(RoundedCornerShape(14.dp)) + .background(MillenniumTheme.colors.surface) + .border(1.dp, MillenniumTheme.colors.border, RoundedCornerShape(14.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumText( + stringResource(Res.string.settings_preview_label), + color = MillenniumTheme.colors.muted) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton(onClick = {}) { + MillenniumText(stringResource(Res.string.settings_preview_button)) } - BaseSurface( - style = surface, - role = SurfaceRole.SecondaryBackground, - shape = RoundedCornerShape(10.dp), - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentPadding = PaddingValues(10.dp), - ) { - Text( - text = stringResource(Res.string.settings_preview_label), - style = MaterialTheme.typography.bodySmall, - ) + MillenniumButton(onClick = {}, style = MillenniumButtonStyle.Secondary) { + MillenniumText(stringResource(Res.string.settings_preview_secondary)) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt index c43fc60a..581d4cdd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/CacheSection.kt @@ -17,14 +17,17 @@ package dev.krtirtho.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable import compose.icons.FeatherIcons import compose.icons.feathericons.Folder import compose.icons.feathericons.HardDrive import spotube.composeapp.generated.resources.* -import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.components.SettingCardItem @@ -42,8 +45,7 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.cacheSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { if (includeHeader) { settingsSectionHeader(Res.string.settings_section_caching) } @@ -122,8 +124,7 @@ internal fun LazyListScope.cacheSection( } } ) - }, - ) + }) ) } @@ -131,8 +132,7 @@ internal fun LazyListScope.cacheSection( private fun CacheFolderSettingCard( enabled: Boolean, folder: String?, - onFolderSelected: (String?) -> Unit, -) { + onFolderSelected: (String?) -> Unit) { val pickerLauncher = rememberDirectoryPickerLauncher { directory -> if (directory != null) { val normalized = normalizePath(directory.path) @@ -154,17 +154,13 @@ private fun CacheFolderSettingCard( ) }, trailingContent = { - GhostIconButton( + MillenniumIconButton( + icon = Iconsax.IconsaxFolderAdd, + contentDescription = stringResource(Res.string.settings_cache_folder_title), onClick = { pickerLauncher.launch() }, enabled = enabled, - ) { - Icon( - Iconsax.IconsaxFolderAdd, - contentDescription = stringResource(Res.string.settings_cache_folder_title) - ) - } + style = MillenniumIconButtonStyle.Quiet) }, onClick = { pickerLauncher.launch() }, - enabled = enabled, - ) + enabled = enabled) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt index 3095f488..0874de79 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DesktopSection.kt @@ -30,8 +30,7 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.desktopSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { if (includeHeader) { settingsSectionHeader(Res.string.settings_section_desktop) } @@ -72,8 +71,7 @@ internal fun LazyListScope.desktopSection( } } ) - }, - ) + }) ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt index 4acd6832..62350c83 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,9 +31,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -37,10 +44,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.modules.settings.SettingsViewModel import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard @@ -79,13 +82,11 @@ import spotube.composeapp.generated.resources.settings_subtitle_current internal fun LazyListScope.downloadsSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets) val downloadQualities = availableAudioQualities( format = settings.downloadMusicFormat, - current = settings.downloadMusicQuality, - ) + current = settings.downloadMusicQuality) if (includeHeader) { settingsSectionHeader(Res.string.settings_section_downloads) @@ -129,8 +130,7 @@ internal fun LazyListScope.downloadsSection( settingsViewModel.updateSettings { copy( downloadMusicFormat = format, - downloadMusicQuality = format.resolveQuality(downloadMusicQuality), - ) + downloadMusicQuality = format.resolveQuality(downloadMusicQuality)) } } ) @@ -154,16 +154,14 @@ internal fun LazyListScope.downloadsSection( } } ) - }, - ) + }) ) } @Composable private fun DownloadFolderSettingCard( folder: String?, - onFolderSelected: (String?) -> Unit, -) { + onFolderSelected: (String?) -> Unit) { val pickerLauncher = rememberDirectoryPickerLauncher { directory -> if (directory != null) { val normalized = normalizePath(directory.path) @@ -182,12 +180,11 @@ private fun DownloadFolderSettingCard( SettingsItemIcon(Iconsax.IconsaxDirectboxReceive, stringResource(Res.string.settings_download_folder_title)) }, trailingContent = { - GhostIconButton(onClick = { pickerLauncher.launch() }) { - Icon( - imageVector = Iconsax.IconsaxFolderAdd, - contentDescription = stringResource(Res.string.settings_download_folder_title) - ) - } + MillenniumIconButton( + icon = Iconsax.IconsaxFolderAdd, + contentDescription = stringResource(Res.string.settings_download_folder_title), + onClick = { pickerLauncher.launch() }, + style = MillenniumIconButtonStyle.Quiet) }, onClick = { pickerLauncher.launch() @@ -198,8 +195,7 @@ private fun DownloadFolderSettingCard( @Composable private fun LocalMediaFoldersSettingCard( folders: List, - onFoldersSaved: (List) -> Unit, -) { + onFoldersSaved: (List) -> Unit) { var isDialogOpen by remember { mutableStateOf(false) } val subtitlePreview = when { @@ -207,8 +203,7 @@ private fun LocalMediaFoldersSettingCard( folders.size == 1 -> folders.first() else -> stringResource( Res.string.settings_local_media_folders_subtitle_current, - folders.size, - ) + folders.size) } SettingCardItem( @@ -217,11 +212,12 @@ private fun LocalMediaFoldersSettingCard( icon = { SettingsItemIcon( Iconsax.IconsaxFolder, - stringResource(Res.string.settings_local_media_folders_title), - ) + stringResource(Res.string.settings_local_media_folders_title)) }, trailingContent = { - OutlineButton(onClick = { isDialogOpen = true }) { + MillenniumButton( + onClick = { isDialogOpen = true }, + style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.settings_local_media_folders_manage)) } }, @@ -234,8 +230,7 @@ private fun LocalMediaFoldersSettingCard( LocalMediaFoldersDialog( folders = folders, onDismiss = { isDialogOpen = false }, - onFoldersSaved = onFoldersSaved, - ) + onFoldersSaved = onFoldersSaved) } } @@ -243,8 +238,7 @@ private fun LocalMediaFoldersSettingCard( private fun LocalMediaFoldersDialog( folders: List, onDismiss: () -> Unit, - onFoldersSaved: (List) -> Unit, -) { + onFoldersSaved: (List) -> Unit) { val draftFolders = remember(folders) { mutableStateListOf().apply { addAll(folders) } } @@ -257,41 +251,23 @@ private fun LocalMediaFoldersDialog( } } - ThemedDialog( + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(Res.string.settings_local_media_folders_title), - style = MaterialTheme.typography.titleLarge, - ) - }, - actions = { - PrimaryButton( - onClick = { - onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct()) - onDismiss() - } - ) { - Text(stringResource(Res.string.settings_action_save)) - } - OutlineButton(onClick = onDismiss) { - Text(stringResource(Res.string.settings_action_cancel)) - } - }, - ) { + title = stringResource(Res.string.settings_local_media_folders_title)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( text = stringResource(Res.string.settings_local_media_folders_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) - OutlineButton(onClick = { pickerLauncher.launch() }) { + MillenniumButton( + onClick = { pickerLauncher.launch() }, + style = MillenniumButtonStyle.Secondary) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(Iconsax.IconsaxAddSquare, contentDescription = null) + verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon(Iconsax.IconsaxAddSquare, contentDescription = null) Text(stringResource(Res.string.settings_local_media_folders_add_action)) } } @@ -300,42 +276,52 @@ private fun LocalMediaFoldersDialog( Box(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { Text( text = stringResource(Res.string.settings_local_media_folders_none_added), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } } else { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { draftFolders.forEachIndexed { index, folder -> Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Text( text = folder, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - ) - GhostIconButton( + style = AppTheme.typography.bodySmall) + MillenniumIconButton( onClick = { if (index in draftFolders.indices) { draftFolders.removeAt(index) } - } - ) { - Icon( + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = Iconsax.IconsaxTrash, contentDescription = stringResource( - Res.string.settings_local_media_folders_remove_action, - ), - ) + Res.string.settings_local_media_folders_remove_action)) } } } } } + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( + onClick = { + onFoldersSaved(draftFolders.map(::normalizePath).filter { it.isNotBlank() }.distinct()) + onDismiss() + }) { + Text(stringResource(Res.string.settings_action_save)) + } + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.settings_action_cancel)) + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt index 9feaedf3..1c8886de 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt @@ -9,9 +9,11 @@ package dev.krtirtho.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme + +import dev.krtirtho.spotube.core.ui.theming.AppTheme import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight import dev.krtirtho.spotube.core.navigation.NavigationCommands @@ -28,8 +30,7 @@ import spotube.composeapp.generated.resources.settings_section_jam internal fun LazyListScope.jamSection( settings: UserSettings, - navigationCommands: NavigationCommands, -) { + navigationCommands: NavigationCommands) { settingsSectionHeader(Res.string.settings_section_jam) settingsSectionCard( items = listOf( @@ -39,24 +40,19 @@ internal fun LazyListScope.jamSection( subtitle = stringResource( Res.string.settings_jam_broker_host_subtitle, settings.jamBroker.host.ifBlank { "—" }, - settings.jamBroker.port, - ), + settings.jamBroker.port), icon = { SettingsItemIcon( Iconsax.CustomServer, - stringResource(Res.string.settings_jam_broker_title), - ) + stringResource(Res.string.settings_jam_broker_title)) }, trailingContent = { - Icon( + MillenniumIcon( imageVector = FeatherIcons.ChevronRight, contentDescription = stringResource(Res.string.settings_jam_broker_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) }, - onClick = { navigationCommands.navigateTo(Routes.JamSettings) }, - ) - }, - ) + onClick = { navigationCommands.navigateTo(Routes.JamSettings) }) + }) ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt index 046a28df..a4795ff1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/LanguageRegionSection.kt @@ -35,8 +35,7 @@ import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.languageRegionSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { if (includeHeader) { settingsSectionHeader(Res.string.settings_section_language_region) } @@ -65,8 +64,7 @@ internal fun LazyListScope.languageRegionSection( copy(language = value) } }, - filter = { item, query -> item.label.contains(query, ignoreCase = true) }, - ) + filter = { item, query -> item.label.contains(query, ignoreCase = true) }) }, { SelectionSettingCard( @@ -91,10 +89,8 @@ internal fun LazyListScope.languageRegionSection( copy(country = value) } }, - filter = { item, query -> item.label.contains(query, ignoreCase = true) }, - ) - }, - ) + filter = { item, query -> item.label.contains(query, ignoreCase = true) }) + }) ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt index 0b4f515b..71b77ad6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt @@ -17,9 +17,11 @@ package dev.krtirtho.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme + +import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import compose.icons.FeatherIcons import compose.icons.feathericons.ChevronRight @@ -53,13 +55,11 @@ internal fun LazyListScope.playbackSection( navigatorCommands: NavigationCommands, requestLocalNetworkPermission: () -> Unit, includeBlacklist: Boolean = true, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) val streamingQualities = availableAudioQualities( format = settings.streamingMusicFormat, - current = settings.streamingMusicQuality, - ) + current = settings.streamingMusicQuality) val cards: List<@Composable () -> Unit> = buildList { add { @@ -79,8 +79,7 @@ internal fun LazyListScope.playbackSection( settingsViewModel.updateSettings { copy( streamingMusicFormat = format, - streamingMusicQuality = format.resolveQuality(streamingMusicQuality), - ) + streamingMusicQuality = format.resolveQuality(streamingMusicQuality)) } } ) @@ -223,20 +222,17 @@ internal fun LazyListScope.playbackSection( icon = { SettingsItemIcon( Iconsax.IconsaxForbidden, - stringResource(Res.string.settings_blacklist_title), - ) + stringResource(Res.string.settings_blacklist_title)) }, trailingContent = { - Icon( + MillenniumIcon( imageVector = FeatherIcons.ChevronRight, contentDescription = stringResource(Res.string.settings_blacklist_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) }, onClick = { navigatorCommands.navigateTo(Routes.Blacklist) - }, - ) + }) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt index 2258f117..e4a44a6c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PluginsSection.kt @@ -17,10 +17,12 @@ package dev.krtirtho.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import compose.icons.FeatherIcons @@ -51,10 +53,10 @@ internal fun LazyListScope.pluginsSection( ) }, trailingContent = { - Icon( + MillenniumIcon( imageVector = FeatherIcons.ChevronRight, contentDescription = stringResource(Res.string.settings_plugins_manage_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = AppTheme.colors.onSurfaceVariant ) }, onClick = { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt index f81cf926..04b853dd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/UpdatesSection.kt @@ -32,8 +32,7 @@ import spotube.composeapp.generated.resources.settings_updates_auto_check_title internal fun LazyListScope.updatesSection( settings: UserSettings, settingsViewModel: SettingsViewModel, - includeHeader: Boolean = true, -) { + includeHeader: Boolean = true) { if (includeHeader) { settingsSectionHeader(Res.string.settings_section_updates) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt index b2e99aa7..affcb156 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/utils.kt @@ -17,19 +17,26 @@ package dev.krtirtho.spotube.modules.settings.sections + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight @@ -38,64 +45,52 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource -import dev.krtirtho.spotube.core.ui.base.Card private val standardLossyQualities = listOf( AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000), -) + AudioQuality.Lossy(bitrate = 256_000)) internal val streamingFormatPresets = listOf( AudioFormat( codec = "opus", container = "webm", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "aac", container = "mp4", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "vorbis", container = "ogg", - qualities = standardLossyQualities, - ), -) + qualities = standardLossyQualities)) internal val downloadFormatPresets = listOf( AudioFormat( codec = "aac", container = "mp4", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "mp3", container = "mp3", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "opus", container = "webm", - qualities = standardLossyQualities, - ), + qualities = standardLossyQualities), AudioFormat( codec = "flac", container = "flac", qualities = listOf( AudioQuality.Lossless(sampleRate = 44_100, channels = 2), - AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ), - ), -) + AudioQuality.Lossless(sampleRate = 48_000, channels = 2)))) internal fun LazyListScope.settingsSectionHeader(title: StringResource) { item { Text( text = stringResource(title), - style = MaterialTheme.typography.labelMedium, + style = AppTheme.typography.labelMedium, color = Color.Gray, modifier = Modifier .fillMaxWidth() @@ -112,9 +107,9 @@ internal fun LazyListScope.settingsSectionHeader(title: StringResource) { internal fun SettingsPageTitle(title: StringResource) { Text( text = stringResource(title), - style = MaterialTheme.typography.headlineSmall, + style = AppTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + color = AppTheme.colors.onSurface, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp) @@ -125,18 +120,16 @@ internal fun SettingsPageTitle(title: StringResource) { internal fun SettingsItemIcon( imageVector: ImageVector, contentDescription: String, - tint: Color = MaterialTheme.colorScheme.primary, -) { - Surface( - shape = RoundedCornerShape(8.dp), - color = tint.copy(alpha = 0.12f), - ) { + tint: Color = AppTheme.colors.primary) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(tint.copy(alpha = 0.12f))) { Box(modifier = Modifier.padding(8.dp)) { - Icon( + MillenniumIcon( imageVector = imageVector, contentDescription = contentDescription, - tint = tint, - ) + tint = tint) } } } @@ -194,27 +187,25 @@ internal fun normalizePath(path: String): String { } internal fun LazyListScope.settingsSectionCard( - items: List<@Composable () -> Unit>, -) { + items: List<@Composable () -> Unit>) { item { - Card( + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(horizontal = 8.dp, vertical = 4.dp) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = AppTheme.shapes.medium, + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + shadowElevation = 6.dp) + .padding(top = 4.dp, bottom = 4.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp, bottom = 4.dp) - ) { - items.forEachIndexed { index, content -> - if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - content() + items.forEachIndexed { index, content -> + if (index > 0) { + MillenniumSeparator() } + content() } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt index 561704f3..c7e4cd30 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt @@ -17,99 +17,45 @@ package dev.krtirtho.spotube.modules.shell -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text +import com.composeunstyled.LocalTextStyle +import com.composeunstyled.Text +import org.spotube.millennium.composables.MillenniumBottomNavigationBar +import org.spotube.millennium.composables.MillenniumBottomNavigationItem +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp import dev.krtirtho.spotube.NavigationItem import dev.krtirtho.spotube.bottombarTabs import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.BaseSurface -import dev.krtirtho.spotube.core.ui.base.SurfaceRole import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator @Composable fun AppBottombar( navigator: Navigator, navigationState: NavigationState, - modifier: Modifier = Modifier, -) { - BaseSurface( - role = SurfaceRole.PrimaryBackground, - shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), - modifier = modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(80.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - bottombarTabs.filterIsInstance>() - .forEach { tab -> - val selected = navigationState.topLevelRoute == tab.route - val labelColor = if (selected) { - MaterialTheme.colorScheme.primary + modifier: Modifier = Modifier) { + MillenniumBottomNavigationBar(modifier = modifier) { + bottombarTabs.filterIsInstance>() + .forEach { tab -> + val selected = navigationState.topLevelRoute == tab.route + MillenniumBottomNavigationItem( + selected = selected, + onClick = { navigator.navigate(tab.route) }, + contentDescription = tab.title, + icon = { + MillenniumIcon( + imageVector = tab.icon, + contentDescription = null, + tint = LocalTextStyle.current.color) + }, + label = { Text(tab.title) }, + badge = if (tab.route == Routes.Library) { + { DownloadBadgeIndicator() } } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - - Column( - modifier = Modifier - .clip(CircleShape) - .clickable { navigator.navigate(tab.route) } - .padding(8.dp) - .size(56.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Box { - Icon( - imageVector = tab.icon, - contentDescription = tab.title, - tint = if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - if (tab.route == Routes.Library) { - DownloadBadgeIndicator( - modifier = Modifier.align(Alignment.TopEnd) - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = tab.title, - style = MaterialTheme.typography.labelSmall, - fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, - color = labelColor, - ) - } - } - } + null + }) + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index c43837fd..df23acf1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -17,6 +17,8 @@ package dev.krtirtho.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope @@ -28,12 +30,15 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -43,16 +48,23 @@ 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.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.composables.MillenniumSnackbarHost +import org.spotube.millennium.composables.MillenniumSnackbarHostState +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumSlider +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.millennium.theme.millenniumPanelBrush +import org.spotube.millennium.theme.millenniumSurface +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState +import androidx.compose.ui.graphics.SolidColor + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -82,12 +94,6 @@ import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.Slider -import dev.krtirtho.spotube.core.ui.base.copyShape -import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon import dev.krtirtho.spotube.modules.downloads.DownloadStatus import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel @@ -121,7 +127,6 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import kotlin.time.Duration.Companion.milliseconds - // The expanded player on small screens // on the top left it has a angle/chevron down that can be used to collapse the player back to the // floating player @@ -135,7 +140,6 @@ import kotlin.time.Duration.Companion.milliseconds // below that we have the playback controls and the progress bar, which should be similar to the // large player but with a different layout to fit the smaller screen // And at the very end we will have the volume slider -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppExpandedPlayer( modifier: Modifier = Modifier, @@ -154,8 +158,7 @@ fun AppExpandedPlayer( savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } - ), -) { + )) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) val jamRoomService: JamRoomService = koinInject() val isJamGuest by jamRoomService.role @@ -175,17 +178,17 @@ fun AppExpandedPlayer( downloads.lastOrNull { it.track?.id == t.id } } } - val snackbarHostState = remember { SnackbarHostState() } + val snackbarHostState = remember { MillenniumSnackbarHostState() } val albumArtModifier = rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope) val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() } var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) } - val moreOptionsSheetState = rememberModalBottomSheetState() + val moreOptionsSheetState = rememberModalBottomSheetState( + initialDetent = SheetDetent.FullyExpanded) PlayerOptionDialogs(viewModel = playerOptionsViewModel) - LaunchedEffect(playerUiState.progress, isSeeking) { if (!isSeeking) { seekProgress = playerUiState.progress @@ -235,16 +238,23 @@ fun AppExpandedPlayer( } } - BaseScaffold( - modifier = modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - ) { innerPadding -> + LaunchedEffect(moreOptionsSheetState.bottomSheetState.currentDetent) { + if (moreOptionsSheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) { + playerOptionsViewModel.dismissMoreOptionsSheet() + } + } + + LaunchedEffect(playerOptionsUiState.isMoreOptionsSheetOpen) { if (playerOptionsUiState.isMoreOptionsSheetOpen) { - ModalBottomSheet( - onDismissRequest = { - playerOptionsViewModel.dismissMoreOptionsSheet() - }, - sheetState = moreOptionsSheetState + moreOptionsSheetState.animateTo(SheetDetent.FullyExpanded) + } + } + + Box(modifier = modifier.fillMaxSize().background(AppTheme.colors.background)) { + if (playerOptionsUiState.isMoreOptionsSheetOpen) { + MillenniumModalBottomSheet( + state = moreOptionsSheetState, + contentPadding = PaddingValues(horizontal = 0.dp, vertical = 8.dp) ) { // Grid of Options LazyVerticalGrid( @@ -252,8 +262,7 @@ fun AppExpandedPlayer( contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth(), - ) { + modifier = Modifier.fillMaxWidth()) { item { OptionTile( icon = Iconsax.SwapHorizontal2, @@ -261,8 +270,7 @@ fun AppExpandedPlayer( onClick = { playerOptionsViewModel.dismissMoreOptionsSheet() onAlternativeSource() - }, - ) + }) } item { val download = currentDownload @@ -276,14 +284,20 @@ fun AppExpandedPlayer( null -> "Download" } val statusColor = when (status) { - is DownloadStatus.Completed -> MaterialTheme.colorScheme.primary - is DownloadStatus.Failed -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurfaceVariant + is DownloadStatus.Completed -> AppTheme.colors.primary + is DownloadStatus.Failed -> AppTheme.colors.error + else -> AppTheme.colors.onSurfaceVariant } - Surface( + Box( modifier = Modifier .aspectRatio(1f) + .millenniumSurface( + background = millenniumPanelBrush(MillenniumTheme.colors), + shape = RoundedCornerShape(14.dp), + borderColor = MillenniumTheme.colors.border, + borderWidth = 1.dp, + shadowElevation = 2.dp) .clickable { playerOptionsViewModel.dismissMoreOptionsSheet() when (status) { @@ -317,26 +331,20 @@ fun AppExpandedPlayer( } } onDownloadTrack() - }, - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 1.dp, - ) { + }) { Column( modifier = Modifier .fillMaxSize() .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { + verticalArrangement = Arrangement.Center) { if (status is DownloadStatus.Downloading) { DownloadProgressIcon( track = currentTrack, icon = Iconsax.IconsaxDirectboxReceive, contentDescription = "Download", modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) + tint = AppTheme.colors.onSurface) } else { val icon = when (status) { is DownloadStatus.Completed -> Iconsax.IconsaxCheckCircle @@ -345,26 +353,24 @@ fun AppExpandedPlayer( is DownloadStatus.Queued -> Iconsax.InconsaxClock null -> Iconsax.IconsaxDirectboxReceive } - Icon( + MillenniumIcon( imageVector = icon, contentDescription = statusLabel, modifier = Modifier.size(28.dp), tint = when (status) { - is DownloadStatus.Completed -> MaterialTheme.colorScheme.primary - is DownloadStatus.Failed -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurface - }, - ) + is DownloadStatus.Completed -> AppTheme.colors.primary + is DownloadStatus.Failed -> AppTheme.colors.error + else -> AppTheme.colors.onSurface + }) } Spacer(modifier = Modifier.height(8.dp)) Text( text = statusLabel, - style = MaterialTheme.typography.labelSmall, + style = AppTheme.typography.labelSmall, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, - color = statusColor, - ) + color = statusColor) } } } @@ -376,8 +382,7 @@ fun AppExpandedPlayer( playerOptionsViewModel.dismissMoreOptionsSheet() playerOptionsViewModel.showSleepTimerDialog() onSleepTimer() - }, - ) + }) } item { OptionTile( @@ -390,8 +395,7 @@ fun AppExpandedPlayer( navigationCommands.navigateTo(Routes.Album(albumId)) } onGoToAlbum() - }, - ) + }) } item { OptionTile( @@ -400,8 +404,7 @@ fun AppExpandedPlayer( onClick = { playerOptionsViewModel.dismissMoreOptionsSheet() playerOptionsViewModel.showTrackDetails() - }, - ) + }) } } } @@ -410,29 +413,31 @@ fun AppExpandedPlayer( Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding) + .windowInsetsPadding(WindowInsets.safeDrawing) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { Spacer(modifier = Modifier.height(4.dp)) Row( modifier = Modifier .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - GhostIconButton(onClick = onCollapse) { - Icon(Iconsax.IconsaxArrowDown4, contentDescription = "Collapse player") + MillenniumIconButton( + onClick = onCollapse, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.IconsaxArrowDown4, contentDescription = "Collapse player") } Text( text = "Now Playing", - style = MaterialTheme.typography.titleMedium, + style = AppTheme.typography.titleMedium, modifier = Modifier.weight(1f), textAlign = TextAlign.Center, - maxLines = 1, - ) - GhostIconButton(onClick = playerOptionsViewModel::showMoreOptionsSheet) { - Icon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options") + maxLines = 1) + MillenniumIconButton( + onClick = playerOptionsViewModel::showMoreOptionsSheet, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options") } } @@ -440,22 +445,20 @@ fun AppExpandedPlayer( modifier = Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 24.dp), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Box( modifier = Modifier .fillMaxWidth(0.84f) .aspectRatio(1f) .then(albumArtModifier) .clip(RoundedCornerShape(22.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .background(AppTheme.colors.surfaceContainerHighest) ) { AsyncImage( model = coverModel, contentDescription = playerUiState.title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } } @@ -468,38 +471,34 @@ fun AppExpandedPlayer( ) { PlayerHeartButton( audioPlayerQueue = audioPlayerQueue, - savedTracksViewModel = savedTracksViewModel, - ) + savedTracksViewModel = savedTracksViewModel) Column( modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { Text( text = playerUiState.title, - style = MaterialTheme.typography.headlineSmall, + style = AppTheme.typography.headlineSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) + textAlign = TextAlign.Center) Text( text = playerUiState.artists, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) + textAlign = TextAlign.Center) } - IconButton( + MillenniumIconButton( onClick = onQueue, - theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(CircleShape) + style = MillenniumIconButtonStyle.Secondary ) { - Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") + MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") } } Column(modifier = Modifier.padding(top = 20.dp)) { - Slider( + MillenniumSlider( value = seekProgress, onValueChange = { seekProgress = it @@ -510,6 +509,7 @@ fun AppExpandedPlayer( onSeekFinished() }, enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, + label = "Seek", modifier = Modifier.fillMaxWidth() ) Row( @@ -517,57 +517,61 @@ fun AppExpandedPlayer( .fillMaxWidth() .padding(top = 2.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Text( text = formatPlayerTime(playerUiState.position), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) Text( text = formatPlayerTime(playerUiState.displayDuration), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } Row( modifier = Modifier .fillMaxWidth() .padding(top = 16.dp, bottom = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - GhostIconButton(onClick = ::onShuffleToggle, enabled = !isJamGuest) { - Icon( + verticalAlignment = Alignment.CenterVertically) { + MillenniumIconButton( + onClick = ::onShuffleToggle, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxShuffle, contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", tint = if (playerUiState.isShuffling) { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant + AppTheme.colors.onSurfaceVariant } ) } - GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) { - Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") + MillenniumIconButton( + onClick = ::onSkipPrevious, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = "Previous") } - IconButton( + MillenniumIconButton( onClick = ::onPlayPause, - theme = invertedButtonStyle().copyShape(CircleShape), - modifier = Modifier - .size(72.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Primary) { + MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(30.dp), - ) + modifier = Modifier.size(30.dp)) } - GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) { - Icon(Iconsax.IconsaxNext, contentDescription = "Next") + MillenniumIconButton( + onClick = ::onSkipNext, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.IconsaxNext, contentDescription = "Next") } - GhostIconButton(onClick = ::onLoopToggle, enabled = !isJamGuest) { - Icon( + MillenniumIconButton( + onClick = ::onLoopToggle, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( imageVector = when (playerUiState.loopState) { LoopState.NONE -> Iconsax.IconsaxRepeateMusic LoopState.ONE -> Iconsax.IconsaxRepeateOne @@ -575,9 +579,9 @@ fun AppExpandedPlayer( }, contentDescription = "Loop mode ${playerUiState.loopState.name}", tint = if (playerUiState.loopState == LoopState.NONE) { - MaterialTheme.colorScheme.onSurfaceVariant + AppTheme.colors.onSurfaceVariant } else { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } ) } @@ -589,9 +593,12 @@ fun AppExpandedPlayer( .fillMaxWidth() .padding(bottom = 16.dp), audioPlayer = audioPlayer, - onExpand = onExpandLyrics, - ) + onExpand = onExpandLyrics) } + + MillenniumSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter)) } } @@ -599,15 +606,13 @@ fun AppExpandedPlayer( @Composable private fun rememberSharedAlbumArtModifier( sharedTransitionScope: SharedTransitionScope?, - animatedVisibilityScope: AnimatedVisibilityScope?, -): Modifier { + animatedVisibilityScope: AnimatedVisibilityScope?): Modifier { if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier with(sharedTransitionScope) { return Modifier.sharedElement( sharedContentState = rememberSharedContentState(key = "player_album_art"), - animatedVisibilityScope = animatedVisibilityScope, - ) + animatedVisibilityScope = animatedVisibilityScope) } } @@ -649,33 +654,29 @@ private fun LyricsPreviewCard( if (syncedLyrics.isEmpty() || isLoading) return - Surface( + Box( modifier = modifier - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onExpand), - color = MaterialTheme.colorScheme.surfaceContainerHighest, - tonalElevation = 2.dp, - ) { + .millenniumSurface( + background = SolidColor(AppTheme.colors.surfaceContainerHighest), + shape = RoundedCornerShape(16.dp), + shadowElevation = 2.dp) + .clickable(onClick = onExpand)) { Column(modifier = Modifier.padding(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { Text( text = "Lyrics", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - GhostIconButton( + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) + MillenniumIconButton( onClick = onExpand, - modifier = Modifier.size(28.dp), - ) { - Icon( + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( Iconsax.IconsaxArrowSquareUp, contentDescription = "Expand lyrics", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = AppTheme.colors.onSurfaceVariant) } } @@ -685,8 +686,7 @@ private fun LyricsPreviewCard( .fillMaxWidth() .height(120.dp), contentPadding = PaddingValues(vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { + verticalArrangement = Arrangement.spacedBy(2.dp)) { val displayLines = syncedLyrics.take(6) itemsIndexed(displayLines) { index, line -> val isCurrent = @@ -696,18 +696,17 @@ private fun LyricsPreviewCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 2.dp), - style = MaterialTheme.typography.bodySmall.copy( + style = AppTheme.typography.bodySmall.copy( fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, color = if (isCurrent) { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + AppTheme.colors.onSurfaceVariant.copy(alpha = 0.7f) } ), maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) + textAlign = TextAlign.Center) } } } @@ -719,38 +718,30 @@ private fun OptionTile( icon: ImageVector, label: String, onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Surface( + modifier: Modifier = Modifier) { + MillenniumCard( modifier = modifier .aspectRatio(1f) .clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 1.dp, - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = icon, - contentDescription = label, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + contentPadding = PaddingValues(12.dp)) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + MillenniumIcon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(28.dp), + tint = AppTheme.colors.onSurface) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = label, + style = AppTheme.typography.labelSmall, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = AppTheme.colors.onSurfaceVariant) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt index b628831c..3c7bcab8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumProgress +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.ui.graphics.SolidColor import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope @@ -33,10 +40,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -68,11 +74,6 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.ui.base.BaseUITheme -import dev.krtirtho.spotube.core.ui.base.BaseSurface -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.SurfaceRole // The floating player on small screens that appears above the floating AppBottombar // @@ -93,8 +94,7 @@ fun AppFloatingPlayer( savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } - ), -) { + )) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) val scope = rememberCoroutineScope() val albumArtModifier = @@ -116,21 +116,18 @@ fun AppFloatingPlayer( } } - BaseSurface( - role = SurfaceRole.InverseBackground, - shape = RoundedCornerShape(24.dp, 24.dp), + Box( modifier = modifier .fillMaxWidth() - .height(76.dp), - ) { + .height(76.dp) + .millenniumSurface( + background = SolidColor(AppTheme.colors.inverseSurface), + shape = RoundedCornerShape(24.dp, 24.dp))) { Box( modifier = Modifier - .fillMaxSize(), - ) { - LinearProgressIndicator( - progress = { seekProgress.coerceIn(0f, 1f) }, - color = MaterialTheme.colorScheme.inverseOnSurface, - trackColor = MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.22f), + .fillMaxSize()) { + MillenniumProgress( + progress = seekProgress.coerceIn(0f, 1f), modifier = Modifier .fillMaxWidth() .height(2.dp) @@ -154,28 +151,27 @@ fun AppFloatingPlayer( .size(48.dp) .then(albumArtModifier) .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)) + .background(AppTheme.colors.onSurface.copy(alpha = 0.12f)) ) { AsyncImage( model = coverModel, contentDescription = playerUiState.title, modifier = Modifier .fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } Column(modifier = Modifier.padding(start = 10.dp)) { Text( text = playerUiState.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.inverseOnSurface, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.inverseOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = playerUiState.artists, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.72f), + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.inverseOnSurface.copy(alpha = 0.72f), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -185,20 +181,15 @@ fun AppFloatingPlayer( Row(verticalAlignment = Alignment.CenterVertically) { PlayerHeartButton( audioPlayerQueue = audioPlayerQueue, - savedTracksViewModel = savedTracksViewModel, - ) + savedTracksViewModel = savedTracksViewModel) Spacer(modifier = Modifier.size(8.dp)) - IconButton( + MillenniumIconButton( onClick = ::onPlayPause, enabled = playerUiState.queue.isNotEmpty(), - theme = LocalBaseUITheme.current.iconButtons.outline.copy( - shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) - ) - ) { - Icon( + style = MillenniumIconButtonStyle.Secondary) { + MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play", - ) + contentDescription = if (playerUiState.isPlaying) "Pause" else "Play") } } } @@ -210,14 +201,12 @@ fun AppFloatingPlayer( @Composable private fun rememberSharedAlbumArtModifier( sharedTransitionScope: SharedTransitionScope?, - animatedVisibilityScope: AnimatedVisibilityScope?, -): Modifier { + animatedVisibilityScope: AnimatedVisibilityScope?): Modifier { if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier with(sharedTransitionScope) { return Modifier.sharedElement( sharedContentState = rememberSharedContentState(key = "player_album_art"), - animatedVisibilityScope = animatedVisibilityScope, - ) + animatedVisibilityScope = animatedVisibilityScope) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index 1adb2dbb..fd274a6e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -17,6 +17,13 @@ package dev.krtirtho.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumSlider import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -33,13 +40,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -57,6 +61,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.composeunstyled.AnchorSide import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.blurEffect @@ -69,15 +74,6 @@ import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.Slider -import dev.krtirtho.spotube.core.ui.base.SurfaceRole -import dev.krtirtho.spotube.core.ui.base.VariableIconButton -import dev.krtirtho.spotube.core.ui.base.VariableIconButtonVariant -import dev.krtirtho.spotube.core.ui.base.baseSurfaceTexture -import dev.krtirtho.spotube.core.ui.base.copyShape -import dev.krtirtho.spotube.core.ui.base.invertedButtonStyle import dev.krtirtho.spotube.modules.downloads.DownloadProgressIcon import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID @@ -114,7 +110,6 @@ import spotube.composeapp.generated.resources.player_sleep_timer import spotube.composeapp.generated.resources.player_track_details import kotlin.time.Duration.Companion.milliseconds - // The main player on large screens shown at the bottom of the screen permanently. It should be // below the sidebar. And should be similarly give a floating vibe with a translucent background // just like the AppBottombar. @@ -138,7 +133,7 @@ fun AppLargePlayer( savedTracksViewModel: SavedTracksViewModel = koinViewModel( key = SAVED_TRACKS_COLLECTION_ID, parameters = { parametersOf() } - ), + ) ) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) val jamRoomService: JamRoomService = koinInject() @@ -214,19 +209,13 @@ fun AppLargePlayer( // A translucent tint (not near-opaque) so the blurred art behind still reads through, and // the material's watery caustics sit on top of it. - val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f) + val surfaceTint = AppTheme.colors.surface.copy(alpha = 0.6f) - Surface( + Column( modifier = modifier - .windowInsetsPadding(WindowInsets.navigationBars), - color = Color.Transparent, - shadowElevation = 0.dp, + .windowInsetsPadding(WindowInsets.navigationBars) ) { - HorizontalDivider( - modifier = Modifier.fillMaxWidth(), - color = Color.Gray, - thickness = 1.dp, - ) + MillenniumSeparator(modifier = Modifier.fillMaxWidth()) Box( modifier = Modifier .fillMaxWidth() @@ -238,7 +227,6 @@ fun AppLargePlayer( ) } } - .baseSurfaceTexture(SurfaceRole.PrimaryBackground) ) { Row( modifier = Modifier @@ -255,26 +243,26 @@ fun AppLargePlayer( modifier = Modifier .size(52.dp) .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .background(AppTheme.colors.surfaceContainerHighest) ) { AsyncImage( model = coverModel, contentDescription = playerUiState.title, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, + contentScale = ContentScale.Crop ) } Column(modifier = Modifier.padding(start = 12.dp)) { Text( text = playerUiState.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = playerUiState.artists, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -282,7 +270,7 @@ fun AppLargePlayer( Spacer(modifier = Modifier.width(10.dp)) PlayerHeartButton( audioPlayerQueue = audioPlayerQueue, - savedTracksViewModel = savedTracksViewModel, + savedTracksViewModel = savedTracksViewModel ) } @@ -290,7 +278,7 @@ fun AppLargePlayer( modifier = Modifier.weight(1.1f), horizontalAlignment = Alignment.CenterHorizontally ) { - Slider( + MillenniumSlider( value = seekProgress, onValueChange = { seekProgress = it @@ -301,7 +289,8 @@ fun AppLargePlayer( onSeekFinished() }, enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L, - modifier = Modifier.fillMaxWidth(), + label = "Seek", + modifier = Modifier.fillMaxWidth() ) Row( modifier = Modifier.fillMaxWidth(), @@ -310,13 +299,13 @@ fun AppLargePlayer( ) { Text( text = formatPlayerTime(playerUiState.position), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) Text( text = formatPlayerTime(playerUiState.displayDuration), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant ) } Row( @@ -324,44 +313,50 @@ fun AppLargePlayer( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { - VariableIconButton( + MillenniumIconButton( onClick = ::onShuffleToggle, enabled = !isJamGuest, - variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost + style = if (playerUiState.isShuffling) MillenniumIconButtonStyle.Secondary else MillenniumIconButtonStyle.Quiet ) { - Icon( + MillenniumIcon( Iconsax.IconsaxShuffle, contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", tint = if (playerUiState.isShuffling) { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant + AppTheme.colors.onSurfaceVariant } ) } - GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) { - Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") - } - IconButton( - onClick = ::onPlayPause, - theme = invertedButtonStyle().copyShape(CircleShape), - modifier = Modifier - .size(50.dp), + MillenniumIconButton( + onClick = ::onSkipPrevious, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet ) { - Icon( + MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = "Previous") + } + MillenniumIconButton( + onClick = ::onPlayPause, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, - contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", + contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause" ) } - GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) { - Icon(Iconsax.IconsaxNext, contentDescription = "Next") + MillenniumIconButton( + onClick = ::onSkipNext, + enabled = !isJamGuest, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon(Iconsax.IconsaxNext, contentDescription = "Next") } - VariableIconButton( + MillenniumIconButton( onClick = ::onLoopToggle, enabled = !isJamGuest, - variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline + style = if (playerUiState.loopState == LoopState.NONE) MillenniumIconButtonStyle.Quiet else MillenniumIconButtonStyle.Secondary ) { - Icon( + MillenniumIcon( imageVector = when (playerUiState.loopState) { LoopState.NONE -> Iconsax.IconsaxRepeateMusic LoopState.ONE -> Iconsax.IconsaxRepeateOne @@ -369,9 +364,9 @@ fun AppLargePlayer( }, contentDescription = "Loop mode ${playerUiState.loopState.name}", tint = if (playerUiState.loopState == LoopState.NONE) { - MaterialTheme.colorScheme.onSurfaceVariant + AppTheme.colors.onSurfaceVariant } else { - MaterialTheme.colorScheme.primary + AppTheme.colors.primary } ) } @@ -383,88 +378,86 @@ fun AppLargePlayer( horizontalAlignment = Alignment.End ) { Row(verticalAlignment = Alignment.CenterVertically) { - GhostIconButton(onClick = onQueue) { - Icon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onQueue + ) { + MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = "Queue") } - GhostIconButton(onClick = { - val track = (currentEntry as? QueueEntry.StreamingTrack)?.track - if (track != null) { - downloadsViewModel.downloadTrack(track) - } - }) { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + val track = (currentEntry as? QueueEntry.StreamingTrack)?.track + if (track != null) { + downloadsViewModel.downloadTrack(track) + } + }) { DownloadProgressIcon( track = (currentEntry as? QueueEntry.StreamingTrack)?.track, icon = Iconsax.IconsaxDirectboxReceive, - contentDescription = "Download", + contentDescription = "Download" ) } - GhostIconButton(onClick = onAlternativeSource) { - Icon(Iconsax.SwapHorizontal2, contentDescription = "Alternative source") + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onAlternativeSource + ) { + MillenniumIcon( + Iconsax.SwapHorizontal2, + contentDescription = "Alternative source" + ) } - GhostIconButton(onClick = onLyrics) { - Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = onLyrics + ) { + MillenniumIcon(Iconsax.IconsaxMusic, contentDescription = "Lyrics") } - Box { - GhostIconButton( - onClick = { - playerOptionsViewModel.showMoreOptionsMenu() - onMoreOptions() - } - ) { - Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options") - } - DropdownMenu( - expanded = playerOptionsUiState.isMoreOptionsMenuOpen, - onDismissRequest = playerOptionsViewModel::dismissMoreOptionsMenu, - ) { - DropdownMenuItem( - text = { Text(stringResource(Res.string.player_go_to_album)) }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxCd, - contentDescription = null, - ) - }, - enabled = currentTrack?.album?.id != null, - onClick = { + val goToAlbumLabel = stringResource(Res.string.player_go_to_album) + val sleepTimerLabel = stringResource(Res.string.player_sleep_timer) + val trackDetailsLabel = stringResource(Res.string.player_track_details) + MillenniumDropdownMenu( + expanded = playerOptionsUiState.isMoreOptionsMenuOpen, + onExpandedChange = { expanded -> + if (!expanded) playerOptionsViewModel.dismissMoreOptionsMenu() + }, + items = listOf(goToAlbumLabel, sleepTimerLabel, trackDetailsLabel), + onItemClick = { label -> + when (label) { + goToAlbumLabel -> { playerOptionsViewModel.dismissMoreOptionsMenu() currentTrack?.album?.id?.let { albumId -> navigationCommands.navigateTo(Routes.Album(albumId)) } - }, - ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.player_sleep_timer)) }, - leadingIcon = { - Icon( - imageVector = Iconsax.InconsaxClock, - contentDescription = null, - ) - }, - onClick = { + } + + sleepTimerLabel -> { playerOptionsViewModel.dismissMoreOptionsMenu() playerOptionsViewModel.showSleepTimerDialog() - }, - ) - DropdownMenuItem( - text = { Text(stringResource(Res.string.player_track_details)) }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxInformation, - contentDescription = null, - ) - }, - enabled = currentTrack != null, - onClick = { + } + + trackDetailsLabel -> { playerOptionsViewModel.dismissMoreOptionsMenu() playerOptionsViewModel.showTrackDetails() - }, - ) - } - } + } + } + }, + side = AnchorSide.Top, + anchor = { + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, + onClick = { + playerOptionsViewModel.showMoreOptionsMenu() + onMoreOptions() + } + ) { + MillenniumIcon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options" + ) + } + }) } Row(verticalAlignment = Alignment.CenterVertically) { - GhostIconButton( + MillenniumIconButton( + style = MillenniumIconButtonStyle.Quiet, onClick = { scope.launch { if (playerUiState.volume <= 0f) { @@ -481,12 +474,12 @@ fun AppLargePlayer( playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow else -> Iconsax.IconsaxVolumeHigh } - Icon( + MillenniumIcon( imageVector = volumeIcon, contentDescription = if (playerUiState.volume <= 0f) "Unmute" else "Mute" ) } - Slider( + MillenniumSlider( value = playerUiState.volume, onValueChange = { lastNonZeroVolume = it @@ -494,6 +487,7 @@ fun AppLargePlayer( audioPlayer.setVolume(it) } }, + label = "Volume", modifier = Modifier.fillMaxWidth(0.62f) ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt index 33b03057..c05c604c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppPlayerState.kt @@ -43,8 +43,7 @@ internal data class PlayerUiState( val playerState: PlayerState = PlayerState.IDLE, val loopState: LoopState = LoopState.NONE, val isShuffling: Boolean = false, - val volume: Float = 0f, -) { + val volume: Float = 0f) { val isPlaying: Boolean get() = playerState == PlayerState.PLAYING val displayDuration: Duration get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration @@ -74,8 +73,7 @@ internal data class PlayerUiState( @Composable internal fun rememberPlayerUiState( audioPlayer: AudioPlayerInterface, - audioPlayerQueue: AudioPlayerQueue, -): PlayerUiState { + audioPlayerQueue: AudioPlayerQueue): PlayerUiState { val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList()) val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsState(initial = null) val playerState by audioPlayer.playerStateFlow.collectAsState() @@ -99,8 +97,7 @@ internal fun rememberPlayerUiState( playerState = playerState, loopState = loopState, isShuffling = isShuffling, - volume = volume, - ) + volume = volume) // val logger = rememberLogger() // logger.d { state.toDebugString() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index 6e79cfa3..9beb0e79 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -37,17 +37,18 @@ import androidx.compose.foundation.layout.offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.material3.BottomSheetScaffold -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetValue -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.spotube.millennium.composables.MillenniumSnackbarHost +import org.spotube.millennium.composables.MillenniumSnackbarHostState +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.theme.MillenniumTheme +import com.composeunstyled.Sheet +import com.composeunstyled.SheetDetent +import com.composeunstyled.UnstyledBottomSheet +import com.composeunstyled.rememberBottomSheetState +import com.composeunstyled.Text + import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -74,8 +75,6 @@ import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost import dev.krtirtho.spotube.core.remote.RemotePlaybackController -import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors -import dev.krtirtho.spotube.core.ui.base.baseSurfaceBackdrop import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent @@ -98,12 +97,10 @@ fun AppShell( viewModel: AppShellViewModel = koinViewModel(), queueViewModel: PlayerQueueContentViewModel = koinViewModel(), alternativeViewModel: AlternativeTrackContentViewModel = koinViewModel(), - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { val navigatorCommands: NavigationCommands = koinInject() val remotePlaybackController: RemotePlaybackController = koinInject() - val baseUIColors = LocalBaseUIColors.current - val snackbarHostState = remember { SnackbarHostState() } + val snackbarHostState = remember { MillenniumSnackbarHostState() } LaunchedEffect(remotePlaybackController) { remotePlaybackController.events.collect { message -> snackbarHostState.showSnackbar(message) @@ -155,88 +152,73 @@ fun AppShell( Box( modifier = Modifier .fillMaxSize() - ) { - Box( - modifier = Modifier - .fillMaxSize() - .baseSurfaceBackdrop(baseUIColors.surfaceTheme, baseUIColors.isLight) - .hazeSource(state = bgHazeState) // <-- Put it here! - ) - Row(modifier = Modifier.fillMaxSize()) { + .background(MillenniumTheme.colors.background) + .hazeSource(state = bgHazeState) // <-- Put it here! + ) + Column(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.weight(1f)) { AppSidebar( hazeState = bgHazeState, navigator = navigator, navigationState = navigationState ) - VerticalDivider( + MillenniumSeparator( modifier = Modifier.fillMaxHeight(), - color = Color.Gray.copy(alpha = 0.2f), - thickness = 1.dp, - ) - Box(modifier = Modifier.weight(1f).hazeSource(hazeState)) { + vertical = true) + Box(modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)) { content() } } - - if (isQueueVisible) { - Box( - modifier = Modifier.fillMaxSize().clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { queueViewModel.setQueueVisibility(false) }, - ) - ) - } - - if (isAlternativeVisible) { - Box( - modifier = Modifier.fillMaxSize().clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { alternativeViewModel.setAlternativeVisibility(false) }, - ) - ) - } - - QueueSheet( - isVisible = isQueueVisible, - onDismiss = { queueViewModel.setQueueVisibility(false) }, - modifier = Modifier.fillMaxSize(), - ) { - PlayerQueueContent( - viewModel = queueViewModel, - modifier = Modifier.fillMaxSize(), - ) - } - - AlternativeTrackSheet( - isVisible = isAlternativeVisible, - onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, - modifier = Modifier.fillMaxSize(), - ) { - AlternativeTrackContent( - viewModel = alternativeViewModel, - modifier = Modifier.fillMaxSize(), - ) - } + AppLargePlayer( + hazeState = hazeState, + modifier = Modifier.fillMaxWidth(), + onQueue = queueViewModel::toggleQueueVisibility, + onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, + onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) }) + } + + if (isQueueVisible) { + Box( + modifier = Modifier.fillMaxSize().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { queueViewModel.setQueueVisibility(false) }) + ) + } + + if (isAlternativeVisible) { + Box( + modifier = Modifier.fillMaxSize().clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { alternativeViewModel.setAlternativeVisibility(false) }) + ) + } + + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { queueViewModel.setQueueVisibility(false) }, + modifier = Modifier.fillMaxSize()) { + PlayerQueueContent( + viewModel = queueViewModel, + modifier = Modifier.fillMaxSize()) + } + + AlternativeTrackSheet( + isVisible = isAlternativeVisible, + onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, + modifier = Modifier.fillMaxSize()) { + AlternativeTrackContent( + viewModel = alternativeViewModel, + modifier = Modifier.fillMaxSize()) } - AppLargePlayer( - hazeState = hazeState, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onQueue = queueViewModel::toggleQueueVisibility, - onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, - onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) }, - ) } } else { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .fillMaxSize() - .baseSurfaceBackdrop(baseUIColors.surfaceTheme, baseUIColors.isLight), - ) + .background(MillenniumTheme.colors.background)) CompactPlayerOverlay( navigator = navigator, navigationState = navigationState, @@ -244,8 +226,7 @@ fun AppShell( onQueue = queueViewModel::toggleQueueVisibility, onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, onExpandLyrics = viewModel::showLyricsOverlay, - content = content, - ) + content = content) QueueSheet( isVisible = isQueueVisible, @@ -254,19 +235,16 @@ fun AppShell( ) { PlayerQueueContent( viewModel = queueViewModel, - modifier = Modifier.fillMaxSize(), - ) + modifier = Modifier.fillMaxSize()) } AlternativeTrackSheet( isVisible = isAlternativeVisible, onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, - modifier = Modifier.fillMaxSize(), - ) { + modifier = Modifier.fillMaxSize()) { AlternativeTrackContent( viewModel = alternativeViewModel, - modifier = Modifier.fillMaxSize(), - ) + modifier = Modifier.fillMaxSize()) } if (isLyricsOverlayVisible) { @@ -275,14 +253,11 @@ fun AppShell( properties = DialogProperties( usePlatformDefaultWidth = false, dismissOnBackPress = true, - dismissOnClickOutside = true, - ), - ) { - Box(modifier = Modifier.fillMaxSize()) { + dismissOnClickOutside = true)) { + Box(modifier = Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) { LyricsScreen( modifier = Modifier.fillMaxSize(), - onClose = { viewModel.hideLyricsOverlay() }, - ) + onClose = { viewModel.hideLyricsOverlay() }) } } } @@ -292,19 +267,16 @@ fun AppShell( // Drawn last so it floats above the players/sheets, just above the // bottom overlay (large player or compact player + bottombar). - SnackbarHost( + MillenniumSnackbarHost( hostState = snackbarHostState, modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = bottomOverlayInset + 12.dp), - ) + .padding(bottom = bottomOverlayInset + 12.dp)) } } @OptIn( - ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class, - ExperimentalMaterial3Api::class -) + ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) @Composable private fun BoxWithConstraintsScope.CompactPlayerOverlay( navigator: Navigator, @@ -313,8 +285,7 @@ private fun BoxWithConstraintsScope.CompactPlayerOverlay( onQueue: () -> Unit, onAlternativeSource: () -> Unit, onExpandLyrics: () -> Unit, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { val density = LocalDensity.current val scope = rememberCoroutineScope() @@ -324,88 +295,60 @@ private fun BoxWithConstraintsScope.CompactPlayerOverlay( val peekHeightPx = floatingPlayerHeightPx + bottomBarHeightPx + navBarInsetPx val peekHeight = with(density) { peekHeightPx.toDp() } - val sheetHeightPx = with(density) { maxHeight.toPx() } - - val sheetState = rememberStandardBottomSheetState( - initialValue = SheetValue.PartiallyExpanded, - skipHiddenState = true, - ) - val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = sheetState, - ) - - val progressState = remember { mutableFloatStateOf(0f) } - LaunchedEffect(sheetState, sheetHeightPx, peekHeightPx) { - snapshotFlow { - try { - sheetState.requireOffset() - } catch (_: IllegalStateException) { - peekHeightPx - } - }.collect { offset -> - val expandedOffset = 0f - val collapsedOffset = sheetHeightPx - peekHeightPx - val range = (collapsedOffset - expandedOffset).coerceAtLeast(1f) - val rawProgress = (collapsedOffset - offset) / range - progressState.floatValue = rawProgress.coerceIn(0f, 1f) - } + val partialDetent = remember(peekHeight) { + SheetDetent("player-peek") { _, _ -> peekHeight } } + val sheetState = rememberBottomSheetState( + initialDetent = partialDetent, + detents = listOf(partialDetent, SheetDetent.FullyExpanded)) - val uiState by remember { - derivedStateOf { - viewModel.compactSheetUiState(progressState.floatValue) - } - } + val progress = runCatching { + sheetState.progress(partialDetent, SheetDetent.FullyExpanded) + }.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f + val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f)) - BottomSheetScaffold( - scaffoldState = scaffoldState, - sheetPeekHeight = peekHeight, - sheetDragHandle = null, - sheetContainerColor = Color.Transparent, - sheetShape = RectangleShape, - sheetTonalElevation = 0.dp, - sheetShadowElevation = 0.dp, - sheetContent = { - Box(modifier = Modifier.fillMaxSize()) { - if (uiState.showExpandedPlayer) { - AppExpandedPlayer( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = uiState.expandedPlayerAlpha }, - onCollapse = { - scope.launch { sheetState.partialExpand() } - }, - onQueue = onQueue, - onAlternativeSource = onAlternativeSource, - onExpandLyrics = onExpandLyrics, - ) - } + Box(modifier = Modifier.fillMaxSize()) { + content() - if (uiState.floatingPlayerAlpha > 0.01f) { - Column(modifier = Modifier.fillMaxWidth()) { - AppFloatingPlayer( + UnstyledBottomSheet( + state = sheetState, + modifier = Modifier.fillMaxSize()) { + Sheet(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { + if (uiState.showExpandedPlayer) { + AppExpandedPlayer( modifier = Modifier - .offset(y = 12.dp) - .graphicsLayer { - alpha = uiState.floatingPlayerAlpha - }, - ) + .fillMaxSize() + .graphicsLayer { alpha = uiState.expandedPlayerAlpha }, + onCollapse = { + scope.launch { sheetState.animateTo(partialDetent) } + }, + onQueue = onQueue, + onAlternativeSource = onAlternativeSource, + onExpandLyrics = onExpandLyrics) + } + + if (uiState.floatingPlayerAlpha > 0.01f) { + Column(modifier = Modifier.fillMaxWidth()) { + AppFloatingPlayer( + modifier = Modifier + .offset(y = 12.dp) + .graphicsLayer { + alpha = uiState.floatingPlayerAlpha + }) + } } } } - }, - ) { _ -> - Box(modifier = Modifier.fillMaxSize()) { - content() + } + + if (uiState.floatingPlayerAlpha > 0.01f) { + AppBottombar( + navigator = navigator, + navigationState = navigationState, + modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).graphicsLayer { + alpha = uiState.floatingPlayerAlpha + }) } } - if (uiState.floatingPlayerAlpha > 0.01f) { - AppBottombar( - navigator = navigator, - navigationState = navigationState, - modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).graphicsLayer { - alpha = uiState.floatingPlayerAlpha - }, - ) - } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt index ed3bffcc..4139fe18 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt @@ -34,12 +34,10 @@ data class CompactSheetUiState( val showExpandedPlayer: Boolean, val expandedPlayerAlpha: Float, val floatingPlayerAlpha: Float, - val bottomBarAlpha: Float, -) + val bottomBarAlpha: Float) data class LyricsOverlayState( - val isVisible: Boolean = false, -) + val isVisible: Boolean = false) class AppShellViewModel : ViewModel() { val sidebarMinWidth = 840.dp @@ -91,8 +89,7 @@ class AppShellViewModel : ViewModel() { showExpandedPlayer = progress > expandedPlayerVisibilityThreshold, expandedPlayerAlpha = progress, floatingPlayerAlpha = floatingPlayerAlpha(progress), - bottomBarAlpha = 1f - progress, - ) + bottomBarAlpha = 1f - progress) } fun showLyricsOverlay() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index e5dfbbf8..e63f2507 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -18,30 +18,19 @@ package dev.krtirtho.spotube.modules.shell import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -49,27 +38,30 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.composeunstyled.Text import dev.chrisbanes.haze.HazeState import dev.krtirtho.spotube.NavigationItem import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.SurfaceRole -import dev.krtirtho.spotube.core.ui.base.baseSurfaceMaterial -import dev.krtirtho.spotube.core.ui.component.SidebarItem -import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel -import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar -import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator +import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 -import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken -import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken import dev.krtirtho.spotube.sidebarTabs import org.jetbrains.compose.resources.Font import org.koin.compose.koinInject +import org.spotube.millennium.composables.MillenniumSidebar +import org.spotube.millennium.composables.MillenniumSidebarContent +import org.spotube.millennium.composables.MillenniumSidebarFooter +import org.spotube.millennium.composables.MillenniumSidebarGroup +import org.spotube.millennium.composables.MillenniumSidebarGroupContent +import org.spotube.millennium.composables.MillenniumSidebarGroupLabel +import org.spotube.millennium.composables.MillenniumSidebarHeader +import org.spotube.millennium.composables.MillenniumSidebarItem +import org.spotube.millennium.composables.MillenniumSidebarMenu +import org.spotube.millennium.composables.MillenniumSidebarTrigger import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.cookie_regular @@ -82,121 +74,94 @@ fun AppSidebar( libraryState: LibraryState = koinInject() ) { var expanded by rememberSaveable { mutableStateOf(true) } - val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp) + val groupExpansion = remember { mutableStateMapOf() } val currentLibraryTab by libraryState.currentTab.collectAsState() - val listState = rememberLazyListState() - - Column( - modifier = modifier - .fillMaxHeight() - .width(width) - .baseSurfaceMaterial(role = SurfaceRole.PrimaryBackground), - horizontalAlignment = Alignment.CenterHorizontally + MillenniumSidebar( + modifier = modifier, + collapsed = !expanded, + onCollapsedChange = { expanded = !it }, ) { - Column(modifier = Modifier.weight(1f)) { + MillenniumSidebarHeader { Row( - modifier = Modifier - .fillMaxWidth() - .padding(14.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { Text( text = "Spotube", - style = MaterialTheme.typography.headlineLarge.copy( + style = AppTheme.typography.headlineLarge.copy( fontFamily = FontFamily( - listOf( - Font(Res.font.cookie_regular, weight = FontWeight.Normal) - ) + listOf(Font(Res.font.cookie_regular, weight = FontWeight.Normal)) ) ), - color = MaterialTheme.colorScheme.onSurface - ) - } - GhostIconButton( - onClick = { expanded = !expanded } - ) { - Icon( - imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken, - contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + color = AppTheme.colors.onSurface ) } + MillenniumSidebarTrigger() } - - Spacer(modifier = Modifier.height(8.dp)) - - Box(modifier = Modifier.fillMaxSize()) { - LazyColumn(state = listState) { - items(sidebarTabs.size) { index -> - when (val tab = sidebarTabs[index]) { - is NavigationItem.Group<*> -> { - SidebarSectionLabel(text = tab.title, expanded = expanded) - tab.items.forEach { - val selected = navigationState.topLevelRoute == it.route - SidebarItem( - label = it.title, - icon = it.icon, - onClick = { - if (it.route == Routes.Library) { - libraryState.currentTab.value = - it.data as LibraryTab - } - navigator.navigate(it.route) - }, - selected = if (it.route == Routes.Library) selected && currentLibraryTab == it.data else selected, - expanded = expanded, - badge = if (it.route == Routes.Library - && it.data == LibraryTab.Downloads - ) { - { - DownloadBadgeIndicator( - modifier = Modifier.align(Alignment.TopEnd) - ) - } - } else { - null - }, - ) + } + MillenniumSidebarContent { + sidebarTabs.forEach { entry -> + when (entry) { + is NavigationItem.Group<*> -> { + val isGroupExpanded = groupExpansion[entry.title] ?: true + MillenniumSidebarGroup( + expanded = isGroupExpanded, + collapsible = true, + onExpandedChange = { groupExpansion[entry.title] = it }) { + MillenniumSidebarGroupLabel(entry.title) + MillenniumSidebarGroupContent { + MillenniumSidebarMenu { + entry.items.forEach { item -> + val selected = navigationState.topLevelRoute == item.route && + (item.route != Routes.Library || currentLibraryTab == item.data) + SidebarNavItem( + item = item, + selected = selected, + onClick = { + if (item.route == Routes.Library) { + (item.data as? LibraryTab)?.let { + libraryState.currentTab.value = it + } + } + navigator.navigate(item.route) + }) + } } - - } - - is NavigationItem.Tab<*> -> { - val selected = navigationState.topLevelRoute == tab.route - SidebarItem( - label = tab.title, - icon = tab.icon, - onClick = { - navigator.navigate(tab.route) - }, - selected = selected, - expanded = expanded, - ) } } } + + is NavigationItem.Tab<*> -> { + SidebarNavItem( + item = entry, + selected = navigationState.topLevelRoute == entry.route, + onClick = { navigator.navigate(entry.route) }) + } } - VerticalScrollbar( - listState = listState, - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight() - ) } } - HorizontalDivider() - SidebarItem( - label = "Settings", - icon = Iconsax.IconsaxSetting2, - onClick = { - navigator.navigate(Routes.Settings) - }, - selected = navigationState.topLevelRoute == Routes.Settings, - expanded = expanded, - ) - Spacer(modifier = Modifier.height(120.dp)) + MillenniumSidebarFooter { + MillenniumSidebarItem( + label = "Settings", + onClick = { navigator.navigate(Routes.Settings) }, + selected = navigationState.topLevelRoute == Routes.Settings, + icon = Iconsax.IconsaxSetting2) + } } } + +@Composable +private fun SidebarNavItem( + item: NavigationItem.Tab<*>, + selected: Boolean, + onClick: () -> Unit) { + MillenniumSidebarItem( + label = item.title, + onClick = onClick, + selected = selected, + contentDescription = item.title, + icon = item.icon) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt index 1356c83a..ea66bb29 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerHeartButton.kt @@ -17,18 +17,19 @@ package dev.krtirtho.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme + +import dev.krtirtho.spotube.core.ui.theming.AppTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.modules.saved_tracks.SavedState import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel import dev.krtirtho.spotube.modules.saved_tracks.rememberIsSavedTracks @@ -40,8 +41,7 @@ import kotlinx.coroutines.launch @Composable fun PlayerHeartButton( audioPlayerQueue: AudioPlayerQueue, - savedTracksViewModel: SavedTracksViewModel, -) { + savedTracksViewModel: SavedTracksViewModel) { val scope = rememberCoroutineScope() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id @@ -68,15 +68,13 @@ fun PlayerHeartButton( } } } - IconButton( + MillenniumIconButton( onClick = ::onLike, enabled = isSavedTrackState is SavedState.Success, - theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(CircleShape), - ) { - Icon( + style = MillenniumIconButtonStyle.Secondary) { + MillenniumIcon( imageVector = if (isLiked) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, contentDescription = if (isLiked) "Unlike" else "Like", - tint = if (isLiked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) + tint = if (isLiked) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt index 706fb061..99fd900f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionDialogs.kt @@ -17,6 +17,17 @@ package dev.krtirtho.spotube.modules.shell + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumTextField +import org.spotube.millennium.theme.millenniumSurface +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -24,13 +35,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,9 +50,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive @@ -59,15 +66,13 @@ fun PlayerOptionDialogs(viewModel: PlayerOptionsViewModel) { onMinutesChange = viewModel::updateSleepTimerMinutes, onStart = viewModel::startSleepTimer, onCancelTimer = viewModel::cancelSleepTimer, - onDismiss = viewModel::dismissSleepTimerDialog, - ) + onDismiss = viewModel::dismissSleepTimerDialog) } uiState.trackDetails?.let { details -> TrackDetailsDialog( details = details, - onDismiss = viewModel::dismissTrackDetails, - ) + onDismiss = viewModel::dismissTrackDetails) } } @@ -78,48 +83,47 @@ private fun SleepTimerDialog( onMinutesChange: (String) -> Unit, onStart: () -> Unit, onCancelTimer: () -> Unit, - onDismiss: () -> Unit, -) { - ThemedDialog( + onDismiss: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Text("Sleep timer", style = MaterialTheme.typography.titleLarge) - }, - actions = { - if (remainingMs != null) { - SecondaryButton(onClick = onCancelTimer) { Text("Cancel timer") } - } - SecondaryButton(onClick = onDismiss) { Text("Close") } - PrimaryButton( - onClick = onStart, - enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true, - ) { - Text("Start") - } - }, - ) { + title = "Sleep timer") { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { if (remainingMs != null) { Text( text = "Playback will pause in ${formatRemainingTime(remainingMs)}.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.primary) } else { Text( text = "Pause playback after the selected number of minutes.", - style = MaterialTheme.typography.bodyMedium, - ) + style = AppTheme.typography.bodyMedium) + } + val minutesState = rememberTextFieldState(minutes) + LaunchedEffect(minutesState.text) { + onMinutesChange(minutesState.text.toString()) + } + MillenniumTextField( + state = minutesState, + label = "Minutes", + placeholder = "Choose between 1 and 600 minutes", + modifier = Modifier.fillMaxWidth()) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (remainingMs != null) { + MillenniumButton( + onClick = onCancelTimer, + style = MillenniumButtonStyle.Secondary) { Text("Cancel timer") } + } + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text("Close") } + MillenniumButton( + onClick = onStart, + enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) { + Text("Start") + } } - OutlinedTextField( - value = minutes, - onValueChange = onMinutesChange, - modifier = Modifier.fillMaxWidth(), - label = { Text("Minutes") }, - supportingText = { Text("Choose between 1 and 600 minutes") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - ) } } } @@ -127,31 +131,22 @@ private fun SleepTimerDialog( @Composable private fun TrackDetailsDialog( details: PlayerTrackDetails, - onDismiss: () -> Unit, -) { - ThemedDialog( + onDismiss: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Track details", style = MaterialTheme.typography.titleLarge) - Text( - details.track.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - actions = { - SecondaryButton(onClick = onDismiss) { Text("Close") } - }, - ) { + title = "Track details") { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text( + details.track.title, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) if (details.isLoading) { Row( modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), - horizontalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator() + horizontalArrangement = Arrangement.Center) { + MillenniumIndeterminateProgress( + modifier = Modifier.fillMaxWidth()) } } @@ -159,17 +154,17 @@ private fun TrackDetailsDialog( LocalCacheBanner(details.localCacheEntry) } if (details.localCacheEntry == null && details.activeStreamMatchesPreference == false) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.tertiaryContainer, - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .millenniumSurface( + background = SolidColor(AppTheme.colors.tertiaryContainer), + shape = RoundedCornerShape(12.dp))) { Text( "The playback URL was resolved with different streaming settings. The highlighted stream matches your current preference; the active URL refreshes on the next stream request.", modifier = Modifier.padding(12.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onTertiaryContainer) } } @@ -177,12 +172,11 @@ private fun TrackDetailsDialog( Text( "Matched audio source", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) val source = details.matchedSource if (details.isLoading) { - Text("Loading matched source…", style = MaterialTheme.typography.bodySmall) + Text("Loading matched source…", style = AppTheme.typography.bodySmall) } else if (source == null) { Text( details.error ?: if (details.localCacheEntry != null) { @@ -190,9 +184,8 @@ private fun TrackDetailsDialog( } else { "No matched source is available." }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) } else { DetailRow("Title", source.title) DetailRow("ID", source.id) @@ -204,78 +197,70 @@ private fun TrackDetailsDialog( Text( "Available streams", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) when { details.localCacheEntry != null && details.streams.isEmpty() -> Text( "This track is being served from the local cache. Stream variants are not used for this playback.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) - details.isLoading -> Text("Loading streams…", style = MaterialTheme.typography.bodySmall) + details.isLoading -> Text("Loading streams…", style = AppTheme.typography.bodySmall) details.streams.isEmpty() -> Text( details.error ?: "No streams are available for this source.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) else -> details.streams.forEach { stream -> val isSelected = stream == details.preferredStream - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - }, - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .millenniumSurface( + background = SolidColor( + if (isSelected) { + AppTheme.colors.primaryContainer + } else { + AppTheme.colors.surfaceContainerHigh + }), + shape = RoundedCornerShape(12.dp))) { Column( modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + verticalArrangement = Arrangement.spacedBy(4.dp)) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + horizontalArrangement = Arrangement.SpaceBetween) { Text( text = streamFormatAndQuality(stream), - style = MaterialTheme.typography.bodyMedium, + style = AppTheme.typography.bodyMedium, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, color = if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer + AppTheme.colors.onPrimaryContainer } else { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface }, - modifier = Modifier.weight(1f), - ) + modifier = Modifier.weight(1f)) if (isSelected) { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxCheckCircle, contentDescription = "Currently selected stream", - tint = MaterialTheme.colorScheme.primary, - ) + tint = AppTheme.colors.primary) } } Text( "${stream.protocol} • ${stream.codec} • ${stream.container}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) Text( stream.url, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) if (isSelected) { Text( "Matches your streaming format & quality preference", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary) } } } @@ -285,39 +270,40 @@ private fun TrackDetailsDialog( details.activeStreamUrl?.let { activeUrl -> DetailRow("Active playback URL", activeUrl) } + + MillenniumButton( + onClick = onDismiss, + style = MillenniumButtonStyle.Secondary) { Text("Close") } } } } @Composable private fun LocalCacheBanner(entry: CacheEntry) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .millenniumSurface( + background = SolidColor(AppTheme.colors.secondaryContainer), + shape = RoundedCornerShape(12.dp))) { Row( modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( + verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( imageVector = Iconsax.IconsaxDirectboxReceive, contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - ) + tint = AppTheme.colors.onSecondaryContainer) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( "Served from local cache", - style = MaterialTheme.typography.labelLarge, + style = AppTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) + color = AppTheme.colors.onSecondaryContainer) Text( "${entry.filename} • ${formatFileSize(entry.sizeBytes)}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSecondaryContainer) } } } @@ -327,9 +313,8 @@ private fun LocalCacheBanner(entry: CacheEntry) { private fun TrackMetadataSection(track: MetadataTrack) { Text( "Metadata track", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) + style = AppTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold) DetailRow("ID", track.id) DetailRow("Title", track.title) DetailRow("Artists", track.artists.joinToString { it.name }) @@ -354,19 +339,16 @@ private fun DetailRow(label: String, value: String?) { if (value.isNullOrBlank()) return Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { + horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text( label, modifier = Modifier.weight(0.38f), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) Text( value, modifier = Modifier.weight(0.62f), - style = MaterialTheme.typography.bodySmall, - ) + style = AppTheme.typography.bodySmall) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt index 4b28e100..c811248b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/PlayerOptionsViewModel.kt @@ -58,8 +58,7 @@ data class PlayerTrackDetails( val activeStreamMatchesPreference: Boolean? = null, val localCacheEntry: CacheEntry? = null, val isLoading: Boolean = false, - val error: String? = null, -) + val error: String? = null) data class PlayerOptionsUiState( val isMoreOptionsMenuOpen: Boolean = false, @@ -67,8 +66,7 @@ data class PlayerOptionsUiState( val isSleepTimerDialogOpen: Boolean = false, val sleepTimerMinutes: String = "30", val sleepTimerRemainingMs: Long? = null, - val trackDetails: PlayerTrackDetails? = null, -) + val trackDetails: PlayerTrackDetails? = null) class PlayerOptionsViewModel( private val audioPlayer: AudioPlayerInterface, @@ -77,8 +75,7 @@ class PlayerOptionsViewModel( private val streamingUrlRepository: StreamingUrlRepository, private val cacheManager: CacheManager, private val settingsRepository: SettingsRepository, - private val pluginManager: AudioPluginSource, -) : ViewModel() { + private val pluginManager: AudioPluginSource) : ViewModel() { private val _uiState = MutableStateFlow(PlayerOptionsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -150,8 +147,7 @@ class PlayerOptionsViewModel( trackDetailsJob?.cancel() _uiState.update { it.copy( - trackDetails = PlayerTrackDetails(track = track, isLoading = true), - ) + trackDetails = PlayerTrackDetails(track = track, isLoading = true)) } trackDetailsJob = viewModelScope.launch { runCatching { @@ -174,15 +170,13 @@ class PlayerOptionsViewModel( val preferredStream = selectPreferredAudioStream( streams = streams, preferredFormat = settings.streamingMusicFormat, - preferredQuality = settings.streamingMusicQuality, - ) + preferredQuality = settings.streamingMusicQuality) val activeStreamUrl = if (cachedStreamSource != null) cachedStream.url else null val activeStreamMatchesPreference = when { activeStreamUrl == null || preferredStream == null -> null else -> activeStreamUrl == normalizeManifestUrl( url = preferredStream.url, - protocol = preferredStream.protocol, - ) + protocol = preferredStream.protocol) } PlayerTrackDetails( @@ -199,8 +193,7 @@ class PlayerOptionsViewModel( streams.isEmpty() && localCacheEntry == null -> "Stream details are not cached yet. Play the track first to load them." else -> null - }, - ) + }) } }.onSuccess { details -> _uiState.update { current -> @@ -216,9 +209,7 @@ class PlayerOptionsViewModel( trackDetails = PlayerTrackDetails( track = track, isLoading = false, - error = error.message ?: "Failed to load track details.", - ), - ) + error = error.message ?: "Failed to load track details.")) } else current } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt index 97e0aa28..662ea8ed 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContent.kt @@ -17,6 +17,10 @@ package dev.krtirtho.spotube.modules.shell.alternative_track + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIndeterminateProgress +import org.spotube.millennium.composables.MillenniumListTile import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -32,11 +36,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -56,33 +59,29 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun AlternativeTrackContent( viewModel: AlternativeTrackContentViewModel = koinViewModel(), - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val uiState by viewModel.alternativeTrackUiState.collectAsState() LaunchedEffect(uiState.currentTrack?.id) { viewModel.loadAlternatives() } - Surface(modifier = modifier) { + Box(modifier = modifier) { Column( modifier = Modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( text = "Alternative Sources", - style = MaterialTheme.typography.titleLarge, - ) + style = AppTheme.typography.titleLarge) val currentTrack = uiState.currentTrack if (currentTrack == null) { Text( text = "No track currently playing", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) return@Column } @@ -90,18 +89,14 @@ fun AlternativeTrackContent( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) + verticalAlignment = Alignment.CenterVertically) { + MillenniumIndeterminateProgress( + modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.width(12.dp)) Text( text = "Searching for alternative sources...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } return@Column } @@ -109,25 +104,21 @@ fun AlternativeTrackContent( if (uiState.alternatives.isEmpty()) { Text( text = "No alternative sources found", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) return@Column } LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { + verticalArrangement = Arrangement.spacedBy(6.dp)) { items( items = uiState.alternatives, - key = { source -> source.id }, - ) { source -> + key = { source -> source.id }) { source -> AlternativeSourceRow( source = source, isActive = source.id == uiState.activeSourceId, - onClick = { viewModel.selectAlternative(source) }, - ) + onClick = { viewModel.selectAlternative(source) }) } } } @@ -138,67 +129,47 @@ fun AlternativeTrackContent( private fun AlternativeSourceRow( source: AudioSource, isActive: Boolean, - onClick: () -> Unit, -) { + onClick: () -> Unit) { val thumbnailUrl = source.thumbnails.maxByOrNull { it.width * it.height }?.url val rowColor = if (isActive) { - MaterialTheme.colorScheme.secondaryContainer + AppTheme.colors.secondaryContainer } else { - MaterialTheme.colorScheme.surfaceContainerHigh + AppTheme.colors.surfaceContainerHigh } - Surface( - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - .clickable(onClick = onClick), - tonalElevation = if (isActive) 2.dp else 0.dp, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(64.dp) - .background(rowColor) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + MillenniumListTile( + onClick = onClick, + selected = isActive, + leading = { Box( modifier = Modifier .size(44.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center) { if (thumbnailUrl != null) { AsyncImage( model = thumbnailUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = source.title.firstOrNull()?.uppercase() ?: "?", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) } } - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = source.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isActive) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) + }, + title = { + Text( + text = source.title, + style = AppTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + }, + subtitle = { + Column { val subtitle = buildString { source.artist?.let { append(it) } if (source.album != null) { @@ -209,27 +180,24 @@ private fun AlternativeSourceRow( if (subtitle.isNotEmpty()) { Text( text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) } Text( text = "Confidence: ${(source.confidence * 100).toInt()}%", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } - + }, + trailing = { if (isActive) { - Icon( + MillenniumIcon( Iconsax.IconsaxCheckCircle, contentDescription = "Active source", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), - ) + tint = AppTheme.colors.primary, + modifier = Modifier.size(22.dp)) } - } - } + }) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt index 0e9bd5b6..5ccd3101 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackContentViewModel.kt @@ -37,13 +37,11 @@ data class AlternativeTrackUiState( val currentTrack: MetadataTrack? = null, val alternatives: List = emptyList(), val activeSourceId: String? = null, - val isLoading: Boolean = false, -) + val isLoading: Boolean = false) class AlternativeTrackContentViewModel( private val audioPlayerQueue: AudioPlayerQueue, - private val alternativeTracksRepository: AlternativeTracksRepository, -) : ViewModel() { + private val alternativeTracksRepository: AlternativeTracksRepository) : ViewModel() { private val alternativeVisibilityFlow = MutableStateFlow(false) private val alternativesFlow = MutableStateFlow>(emptyList()) private val activeSourceIdFlow = MutableStateFlow(null) @@ -55,20 +53,17 @@ class AlternativeTrackContentViewModel( audioPlayerQueue.currentQueueEntryFlow, alternativesFlow, activeSourceIdFlow, - isLoadingFlow, - ) { currentEntry, alternatives, activeSourceId, isLoading -> + isLoadingFlow) { currentEntry, alternatives, activeSourceId, isLoading -> val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track AlternativeTrackUiState( currentTrack = currentTrack, alternatives = alternatives, activeSourceId = activeSourceId, - isLoading = isLoading, - ) + isLoading = isLoading) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = AlternativeTrackUiState(), - ) + initialValue = AlternativeTrackUiState()) fun toggleAlternativeVisibility() { alternativeVisibilityFlow.update { !it } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt index 220f0869..d949602b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/alternative_track/AlternativeTrackSheet.kt @@ -17,27 +17,12 @@ package dev.krtirtho.spotube.modules.shell.alternative_track -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.core.animateDp -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.modules.shell.player_queue.BottomSheetContent +import dev.krtirtho.spotube.modules.shell.player_queue.EndDrawerSheet private val SlidingSheetBreakpoint = 840.dp @@ -46,61 +31,12 @@ fun AlternativeTrackSheet( isVisible: Boolean, onDismiss: () -> Unit, modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { BoxWithConstraints(modifier = modifier) { if (maxWidth >= SlidingSheetBreakpoint) { - SlidingAlternativeSheet(isVisible, onDismiss, content) + EndDrawerSheet(isVisible, onDismiss, content) } else { - BottomAlternativeSheet(isVisible, onDismiss, content) - } - } -} - -@Composable -private fun SlidingAlternativeSheet( - isVisible: Boolean, - onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - Box(modifier = Modifier.fillMaxSize()) { - AnimatedVisibility( - visible = isVisible, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 12.dp, end = 12.dp, bottom = 12.dp), - enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(), - exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(), - ) { - val animatedShadowElevation = transition.animateDp(label = "alternativeShadow") { state -> - if (state == EnterExitState.Visible) 20.dp else 0.dp - } - Surface( - modifier = Modifier - .fillMaxHeight() - .width(460.dp), - shape = MaterialTheme.shapes.large, - tonalElevation = 2.dp, - shadowElevation = animatedShadowElevation.value, - ) { - content() - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BottomAlternativeSheet( - isVisible: Boolean, - onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - if (isVisible) { - ModalBottomSheet( - onDismissRequest = onDismiss, - ) { - content() + BottomSheetContent(isVisible, onDismiss, content) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 0042df8d..33d9a4d2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -17,6 +17,17 @@ package dev.krtirtho.spotube.modules.shell.player_queue + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumDropdownMenu +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumSeparator +import org.spotube.millennium.composables.MillenniumTextField +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -37,13 +48,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme + +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -58,13 +66,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.jam.JamParticipant -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.GhostIconButton -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.ListRowTile -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore @@ -81,8 +82,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState @Composable fun PlayerQueueContent( viewModel: PlayerQueueContentViewModel = koinViewModel(), - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { val state by viewModel.queueContentUiState.collectAsState() val displayItems = state.displayItems val filterQuery = state.filterQuery @@ -96,45 +96,35 @@ fun PlayerQueueContent( onMove = { from, to -> if (isFiltered || isReadOnly) return@rememberReorderableLazyListState viewModel.onMove(from.index, to.index) - }, - ) + }) - Surface(modifier = modifier) { + Box(modifier = modifier) { Column( modifier = Modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( text = "Queue", - style = MaterialTheme.typography.titleLarge, - ) + style = AppTheme.typography.titleLarge) Row( horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TextField( - value = filterQuery, - onValueChange = viewModel::setQueueFilter, - placeholder = { Text("Filter queue...") }, - leadingIcon = { - Icon( - Iconsax.IconsaxFilterSearch, - contentDescription = "Search", - modifier = Modifier.size(18.dp), - ) - }, - singleLine = true, - modifier = Modifier.weight(1f), - ) + verticalAlignment = Alignment.CenterVertically) { + val filterState = rememberTextFieldState(filterQuery) + LaunchedEffect(filterState.text) { + viewModel.setQueueFilter(filterState.text.toString()) + } + MillenniumTextField( + state = filterState, + label = "", + placeholder = "Filter queue...", + modifier = Modifier.weight(1f)) if (!isReadOnly) { - IconButton( + MillenniumIconButton( onClick = viewModel::clearQueue, - theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small), - ) { - Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") } } } @@ -142,16 +132,14 @@ fun PlayerQueueContent( if (displayItems.isEmpty()) { Text( text = "No queue entries", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant) } else { - Card { + MillenniumCard { LazyColumn( modifier = Modifier.fillMaxSize(), state = lazyListState, - contentPadding = PaddingValues(bottom = 8.dp), - ) { + contentPadding = PaddingValues(bottom = 8.dp)) { items(displayItems, key = { it.id }) { item -> ReorderableItem(reorderableLazyListState, key = item.id) { isDragging -> val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) @@ -164,8 +152,7 @@ fun PlayerQueueContent( onDragStopped = { viewModel.onDragStop() }, showOptions = !isReadOnly, enabled = !isReadOnly, - onParticipantClick = { selectedParticipant = it }, - ) + onParticipantClick = { selectedParticipant = it }) } } } @@ -179,8 +166,7 @@ fun PlayerQueueContent( onDismiss = { selectedParticipant = null }, onKick = { viewModel.kickParticipant(participant.id) }, onBan = { viewModel.banParticipant(participant.id) }, - onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) }, - ) + onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) }) } } } @@ -193,83 +179,70 @@ private fun ParticipantDialog( onDismiss: () -> Unit, onKick: () -> Unit, onBan: () -> Unit, - onRemoveSuggestions: () -> Unit, -) { + onRemoveSuggestions: () -> Unit) { AdaptiveDialogBottomSheet( onDismiss = onDismiss, - title = { Text(participant.displayName, style = MaterialTheme.typography.titleLarge) }, - ) { + title = { Text(participant.displayName, style = AppTheme.typography.titleLarge) }) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + verticalArrangement = Arrangement.spacedBy(4.dp)) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.padding(vertical = 8.dp), - ) { + modifier = Modifier.padding(vertical = 8.dp)) { ParticipantAvatar(participant, size = 40) Text( text = participant.displayName, - style = MaterialTheme.typography.bodyLarge, - ) + style = AppTheme.typography.bodyLarge) if (participant.isHost) { Text( text = "Host", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.primary) } } if (isJamHost && !participant.isHost) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - ListRowTile( + MillenniumSeparator( + modifier = Modifier.fillMaxWidth()) + MillenniumListTile( onClick = { onKick() onDismiss() }, leading = { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxCloseSquare, contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) + tint = AppTheme.colors.error) }, title = { Text("Kick") }, - subtitle = { Text("Remove them from the session") }, - ) - ListRowTile( + subtitle = { Text("Remove them from the session") }) + MillenniumListTile( onClick = { onBan() onDismiss() }, leading = { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxUserRemove, contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) + tint = AppTheme.colors.error) }, title = { Text("Ban") }, - subtitle = { Text("Kick and prevent them from rejoining") }, - ) - ListRowTile( + subtitle = { Text("Kick and prevent them from rejoining") }) + MillenniumListTile( onClick = { onRemoveSuggestions() onDismiss() }, leading = { - Icon( + MillenniumIcon( imageVector = Iconsax.IconsaxMusicSquareRemove, - contentDescription = null, - ) + contentDescription = null) }, title = { Text("Remove suggestions") }, - subtitle = { Text("Remove every track they added to the queue") }, - ) + subtitle = { Text("Remove every track they added to the queue") }) } } } @@ -281,14 +254,12 @@ private fun ParticipantAvatar(participant: JamParticipant, size: Int) { modifier = Modifier .size(size.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { + .background(AppTheme.colors.primaryContainer), + contentAlignment = Alignment.Center) { Text( text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onPrimaryContainer) } } @@ -302,11 +273,10 @@ private fun QueueItemRow( onDragStopped: () -> Unit, showOptions: Boolean = true, enabled: Boolean = true, - onParticipantClick: (JamParticipant) -> Unit = {}, -) { + onParticipantClick: (JamParticipant) -> Unit = {}) { var showMenu by remember { mutableStateOf(false) } - ListRowTile( + MillenniumListTile( onClick = onPlayClick, enabled = enabled, selected = item.isCurrent, @@ -315,9 +285,8 @@ private fun QueueItemRow( Row( modifier = Modifier .height(72.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( + verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( Iconsax.IconsaxDragHandle, contentDescription = if (reorderScope != null) "Reorder" else null, modifier = Modifier @@ -327,36 +296,30 @@ private fun QueueItemRow( with(reorderScope) { Modifier.draggableHandle( onDragStarted = { onDragStarted() }, - onDragStopped = onDragStopped, - ) + onDragStopped = onDragStopped) } } else { Modifier - }, - ), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + }), + tint = AppTheme.colors.onSurfaceVariant) Box( modifier = Modifier .size(48.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { + .clip(AppTheme.shapes.small) + .background(AppTheme.colors.surfaceVariant), + contentAlignment = Alignment.Center) { if (item.imageUrl != null) { AsyncImage( model = item.imageUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + contentScale = ContentScale.Crop) } else { Text( text = "${item.originalIndex + 1}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelMedium, + color = AppTheme.colors.onSurfaceVariant) } } } @@ -364,31 +327,28 @@ private fun QueueItemRow( title = { Text( text = item.title, - style = MaterialTheme.typography.bodyLarge, + style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = if (item.isCurrent) { - MaterialTheme.colorScheme.onSecondaryContainer + AppTheme.colors.onSecondaryContainer } else { - MaterialTheme.colorScheme.onSurface - }, - ) + AppTheme.colors.onSurface + }) }, subtitle = { Text( text = item.subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + overflow = TextOverflow.Ellipsis) }, trailing = { Text( text = item.durationLabel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant) Spacer(modifier = Modifier.width(4.dp)) @@ -397,47 +357,35 @@ private fun QueueItemRow( modifier = Modifier .size(28.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) + .background(AppTheme.colors.surfaceVariant) .clickable { onParticipantClick(participant) }, - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { Text( text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant) } Spacer(modifier = Modifier.width(4.dp)) } if (showOptions) { - Box { - GhostIconButton( - onClick = { showMenu = true }, - modifier = Modifier.size(36.dp), - ) { - Icon( - Iconsax.Iconsax3DotsMore, - contentDescription = "More options", - modifier = Modifier.size(18.dp), - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Remove from queue") }, - onClick = { - onRemoveClick() - showMenu = false - }, - leadingIcon = { - Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) - }, - ) - } - } + MillenniumDropdownMenu( + expanded = showMenu, + onExpandedChange = { showMenu = it }, + items = listOf("Remove from queue"), + onItemClick = { + onRemoveClick() + showMenu = false + }, + anchor = { + MillenniumIconButton( + onClick = { showMenu = true }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options") + } + }) } } ) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index a426a839..253c8d83 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -42,8 +42,7 @@ data class QueueItemUi( val imageUrl: String?, val originalIndex: Int, /** Participant who added this item to the jam queue, if any. */ - val addedByParticipant: JamParticipant? = null, -) + val addedByParticipant: JamParticipant? = null) data class QueueContentUiState( val filterQuery: String = "", @@ -52,13 +51,11 @@ data class QueueContentUiState( /** Guests cannot reorder/remove/clear the shared jam queue. */ val isReadOnly: Boolean = false, val isJamHost: Boolean = false, - val participants: List = emptyList(), -) + val participants: List = emptyList()) class PlayerQueueContentViewModel( private val audioPlayerQueue: AudioPlayerQueue, - private val jamRoomService: JamRoomService, -) : ViewModel() { + private val jamRoomService: JamRoomService) : ViewModel() { private val queueVisibilityFlow = MutableStateFlow(false) private val queueFilterFlow = MutableStateFlow("") private val reorderBuffer = MutableStateFlow?>(null) @@ -70,8 +67,7 @@ class PlayerQueueContentViewModel( private val computedItems: StateFlow> = combine( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, - jamRoomService.participants, - ) { queue, currentEntry, participants -> + jamRoomService.participants) { queue, currentEntry, participants -> val currentIndex = if (currentEntry != null) { queue.indexOfFirst { it.matchesCurrent(currentEntry) } } else { @@ -113,22 +109,19 @@ class PlayerQueueContentViewModel( isCurrent = index == currentIndex, imageUrl = imageUrl, originalIndex = index, - addedByParticipant = addedByParticipant, - ) + addedByParticipant = addedByParticipant) } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = emptyList(), - ) + initialValue = emptyList()) val queueContentUiState: StateFlow = combine( computedItems, reorderBuffer, queueFilterFlow, jamRoomService.role, - jamRoomService.participants, - ) { items, buffer, filterQuery, role, participants -> + jamRoomService.participants) { items, buffer, filterQuery, role, participants -> val normalizedFilter = filterQuery.trim().lowercase() val isFiltered = normalizedFilter.isNotBlank() val filtered = if (isFiltered) { @@ -145,13 +138,11 @@ class PlayerQueueContentViewModel( isFiltered = isFiltered, isReadOnly = role == JamRole.Guest, isJamHost = role == JamRole.Host, - participants = participants, - ) + participants = participants) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = QueueContentUiState(), - ) + initialValue = QueueContentUiState()) fun toggleQueueVisibility() { queueVisibilityFlow.update { !it } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt index 47206bab..7bd75267 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt @@ -17,28 +17,19 @@ package dev.krtirtho.spotube.modules.shell.player_queue -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterExitState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.core.animateDp -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Surface +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import com.composeunstyled.DrawerPlacement +import com.composeunstyled.SheetDetent +import com.composeunstyled.rememberModalBottomSheetState +import org.spotube.millennium.composables.MillenniumDrawer +import org.spotube.millennium.composables.MillenniumDrawerValue +import org.spotube.millennium.composables.MillenniumModalBottomSheet +import org.spotube.millennium.composables.rememberMillenniumDrawerState private val SlidingSheetBreakpoint = 840.dp @@ -47,65 +38,54 @@ fun QueueSheet( isVisible: Boolean, onDismiss: () -> Unit, modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { + content: @Composable () -> Unit) { BoxWithConstraints(modifier = modifier) { if (maxWidth >= SlidingSheetBreakpoint) { - SlidingQueueSheet(isVisible, onDismiss, content) + EndDrawerSheet(isVisible, onDismiss, content) } else { - BottomQueueSheet(isVisible, onDismiss, content) + BottomSheetContent(isVisible, onDismiss, content) } } } @Composable -private fun SlidingQueueSheet( +internal fun EndDrawerSheet( isVisible: Boolean, onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - Box(modifier = Modifier.fillMaxSize()) { - AnimatedVisibility( - visible = isVisible, - modifier = Modifier - .align(Alignment.TopEnd) - .padding( - top = 12.dp, - end = 12.dp, - bottom = 12.dp + LocalAppShellBottomInset.current, - ), - enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(), - exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(), - ) { - val animatedShadowElevation = transition.animateDp(label = "queueShadow") { state -> - if (state == EnterExitState.Visible) 20.dp else 0.dp - } - Surface( - modifier = Modifier - .fillMaxHeight() - .width(460.dp), - shape = MaterialTheme.shapes.large, - tonalElevation = 2.dp, - shadowElevation = animatedShadowElevation.value, - ) { - content() - } + content: @Composable () -> Unit) { + val drawerState = rememberMillenniumDrawerState() + LaunchedEffect(isVisible) { + drawerState.targetValue = + if (isVisible) MillenniumDrawerValue.Open else MillenniumDrawerValue.Closed + } + LaunchedEffect(drawerState.currentValue) { + if (drawerState.currentValue == MillenniumDrawerValue.Closed && isVisible) { + onDismiss() } } + MillenniumDrawer( + state = drawerState, + placement = DrawerPlacement.End, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)) { + content() + } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun BottomQueueSheet( +internal fun BottomSheetContent( isVisible: Boolean, onDismiss: () -> Unit, - content: @Composable () -> Unit, -) { - if (isVisible) { - ModalBottomSheet( - onDismissRequest = onDismiss, - ) { - content() + content: @Composable () -> Unit) { + if (!isVisible) return + val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) + LaunchedEffect(sheetState.bottomSheetState.currentDetent) { + if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) { + onDismiss() } } + MillenniumModalBottomSheet( + state = sheetState, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { + content() + } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt index b007002d..6cd60adf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateDialog.kt @@ -17,19 +17,19 @@ package dev.krtirtho.spotube.modules.update +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.mikepenz.markdown.m3.Markdown -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.app_update_dialog_no_notes import spotube.composeapp.generated.resources.app_update_dialog_title @@ -46,35 +46,29 @@ fun AppUpdateDialog( update: AvailableAppUpdate, onDismiss: () -> Unit, onIgnore: () -> Unit, - onUpdate: () -> Unit, -) { - ThemedDialog( + onUpdate: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(Res.string.app_update_dialog_title, update.tag), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - }, - actions = { - OutlineButton(onClick = onIgnore) { - Text(stringResource(Res.string.update_action_ignore)) - } - PrimaryButton(onClick = onUpdate) { - Text(stringResource(Res.string.update_action_update)) - } - }, - ) { + title = stringResource(Res.string.app_update_dialog_title, update.tag)) { if (update.releaseNotesMarkdown.isNotBlank()) { SelectionContainer { Markdown( content = update.releaseNotesMarkdown, - modifier = Modifier.padding(bottom = 8.dp), - ) + modifier = Modifier.padding(bottom = 8.dp)) } } else { Text(stringResource(Res.string.app_update_dialog_no_notes)) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( + onClick = onIgnore, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.update_action_ignore)) + } + MillenniumButton(onClick = onUpdate) { + Text(stringResource(Res.string.update_action_update)) + } + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt index 19c5a8dd..8fff895e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/AppUpdateService.kt @@ -43,8 +43,7 @@ import org.koin.core.component.KoinComponent data class AvailableAppUpdate( val tag: String, - val releaseNotesMarkdown: String, -) + val releaseNotesMarkdown: String) /** * Checks GitHub for a newer app release once on startup and exposes it for the UI to @@ -53,8 +52,7 @@ data class AvailableAppUpdate( * process, independent of any screen's lifecycle. */ class AppUpdateService( - private val settingsRepository: SettingsRepository, -) : KoinComponent, AutoCloseable { + private val settingsRepository: SettingsRepository) : KoinComponent, AutoCloseable { private val logger = Logger.withTag("AppUpdateService") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val httpClient = HttpClient(CIO) { @@ -92,8 +90,7 @@ class AppUpdateService( if (latestVersion > currentVersion && release.tagName != settings.ignoredAppUpdateVersion) { _availableUpdate.value = AvailableAppUpdate( tag = release.tagName, - releaseNotesMarkdown = release.body.orEmpty(), - ) + releaseNotesMarkdown = release.body.orEmpty()) } } catch (e: Exception) { logger.w(e) { "Failed to check for app updates" } @@ -129,5 +126,4 @@ class AppUpdateService( @Serializable private data class GitHubRelease( @SerialName("tag_name") val tagName: String, - val body: String? = null, -) + val body: String? = null) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt index 97641c6b..0e1e9f50 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateDialog.kt @@ -17,19 +17,20 @@ package dev.krtirtho.spotube.modules.update +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.mikepenz.markdown.m3.Markdown -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.ThemedDialog import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumDialog import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.plugin_update_dialog_current_version import spotube.composeapp.generated.resources.plugin_update_dialog_no_notes @@ -48,36 +49,18 @@ fun PluginUpdateDialog( update: AvailablePluginUpdate, onDismiss: () -> Unit, onIgnore: () -> Unit, - onUpdate: () -> Unit, -) { - ThemedDialog( + onUpdate: () -> Unit) { + MillenniumDialog( + visible = true, onDismissRequest = onDismiss, - title = { - Text( - text = stringResource( - Res.string.plugin_update_dialog_title, - update.pluginName, - update.info.latestVersion, - ), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - }, - actions = { - OutlineButton(onClick = onIgnore) { - Text(stringResource(Res.string.update_action_ignore)) - } - PrimaryButton(onClick = onUpdate) { - Text(stringResource(Res.string.update_action_update)) - } - }, - ) { + title = stringResource( + Res.string.plugin_update_dialog_title, + update.pluginName, + update.info.latestVersion)) { Text( text = stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), - ) + color = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp)) if (update.info.releaseNotes.isNotBlank()) { SelectionContainer { Markdown(content = update.info.releaseNotes) @@ -85,5 +68,15 @@ fun PluginUpdateDialog( } else { Text(stringResource(Res.string.plugin_update_dialog_no_notes)) } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MillenniumButton( + onClick = onIgnore, + style = MillenniumButtonStyle.Secondary) { + Text(stringResource(Res.string.update_action_ignore)) + } + MillenniumButton(onClick = onUpdate) { + Text(stringResource(Res.string.update_action_update)) + } + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt index 03f4f53b..187dd94d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/update/PluginUpdateService.kt @@ -39,8 +39,7 @@ data class AvailablePluginUpdate( val pluginId: String, val pluginName: String, val currentVersion: String, - val info: PluginUpdateInfo, -) + val info: PluginUpdateInfo) /** * Checks the currently active plugins for updates through their own [PluginService.use] @@ -52,8 +51,7 @@ data class AvailablePluginUpdate( */ class PluginUpdateService( private val pluginManager: PluginManager, - private val settingsRepository: SettingsRepository, -) : KoinComponent { + private val settingsRepository: SettingsRepository) : KoinComponent { private val logger = Logger.withTag("PluginUpdateService") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -94,8 +92,7 @@ class PluginUpdateService( pluginId = pluginEntry.id, pluginName = pluginEntry.name, currentVersion = pluginEntry.version, - info = updateInfo, - ) + info = updateInfo) return } } catch (e: Exception) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt index b5ea2ede..76e07713 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt @@ -38,8 +38,7 @@ internal val welcomePrimaryLabels: List = listOf( Res.string.welcome_lets_go, Res.string.welcome_next, Res.string.welcome_next, - Res.string.welcome_get_started, -) + Res.string.welcome_get_started) internal val welcomeDonations: List = listOf( DonationOption( @@ -47,20 +46,16 @@ internal val welcomeDonations: List = listOf( title = Res.string.welcome_donate_patreon_title, subtitle = Res.string.welcome_donate_patreon_subtitle, url = "https://patreon.com/krtirtho", - icon = Iconsax.BrandPatreon, - ), + icon = Iconsax.BrandPatreon), DonationOption( id = "buymeacoffee", title = Res.string.welcome_donate_bmac_title, subtitle = Res.string.welcome_donate_bmac_subtitle, url = "https://www.buymeacoffee.com/krtirtho", - icon = Iconsax.BrandBuyMeACoffee, - ), + icon = Iconsax.BrandBuyMeACoffee), DonationOption( id = "opencollective", title = Res.string.welcome_donate_opencollective_title, subtitle = Res.string.welcome_donate_opencollective_subtitle, url = "https://opencollective.com/spotube", - icon = Iconsax.BrandOpencollective, - ), -) + icon = Iconsax.BrandOpencollective)) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt index b8199ef9..e5a293b6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt @@ -26,8 +26,7 @@ data class DonationOption( val title: StringResource, val subtitle: StringResource, val url: String, - val icon: ImageVector, -) + val icon: ImageVector) data class WelcomeUiState( val currentPageIndex: Int, @@ -35,5 +34,4 @@ data class WelcomeUiState( val isLastPage: Boolean, val primaryLabel: StringResource, val donations: List, - val demoPlaying: Boolean = true, -) + val demoPlaying: Boolean = true) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt index 6240e22d..3e19bf53 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt @@ -69,8 +69,7 @@ class WelcomeRepository(private val database: Database) { */ val shouldShowOnboarding: StateFlow = combine( onboardingCompleted, - sessionDismissed, - ) { completed, dismissed -> + sessionDismissed) { completed, dismissed -> when { dismissed -> false DebugConfig.FORCE_SHOW_ONBOARDING -> true @@ -79,8 +78,7 @@ class WelcomeRepository(private val database: Database) { }.stateIn( scope = scope, started = SharingStarted.Eagerly, - initialValue = if (DebugConfig.FORCE_SHOW_ONBOARDING) true else !database.isExistingInstall, - ) + initialValue = if (DebugConfig.FORCE_SHOW_ONBOARDING) true else !database.isExistingInstall) init { // One-time migration. When the flag was never written this is either a brand-new install diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt index 61311d76..8eaa84c0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt @@ -17,6 +17,10 @@ package dev.krtirtho.spotube.modules.welcome + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.fadeIn @@ -38,22 +42,17 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import dev.krtirtho.spotube.core.ui.base.BaseScaffold -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.ui.base.GhostButton -import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.welcome.pages.BrandingPage import dev.krtirtho.spotube.modules.welcome.pages.DonationPage @@ -72,54 +71,45 @@ import spotube.composeapp.generated.resources.welcome_skip * rendered inside the app shell. Uses the application's base controls and keeps plugin setup * in-place, with a neutral palette independent of the user's chosen accent. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun WelcomeScreen(viewModel: WelcomeViewModel) { val state by viewModel.uiState.collectAsStateWithLifecycle() WelcomeTheme { Box(Modifier.fillMaxSize()) { WelcomeBackground() - BaseScaffold( - containerColor = Color.Transparent, - topBar = { - ApplicationMainBar( - title = { - WelcomeWordmark( - fontSize = MaterialTheme.typography.titleLarge.fontSize, - ) - }, - backButton = false, - transparent = true, - actions = { - if (!state.isLastPage) { - GhostButton(onClick = viewModel::completeOnboarding) { - Text( - text = stringResource(Res.string.welcome_skip), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Column(modifier = Modifier.fillMaxSize()) { + ApplicationMainBar( + title = { + WelcomeWordmark( + fontSize = AppTheme.typography.titleLarge.fontSize) + }, + backButton = false, + transparent = true, + actions = { + if (!state.isLastPage) { + MillenniumButton( + onClick = viewModel::completeOnboarding, + style = MillenniumButtonStyle.Quiet) { + Text( + text = stringResource(Res.string.welcome_skip), + color = AppTheme.colors.onSurfaceVariant) } - }, - ) - }, - ) { innerPadding -> - Box(modifier = Modifier.fillMaxSize()) { + } + }) + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding) .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { + horizontalAlignment = Alignment.CenterHorizontally) { // Central stage (animated page content) Box( modifier = Modifier .weight(1f) .widthIn(max = 1280.dp) .fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { + contentAlignment = Alignment.Center) { AnimatedContent( modifier = Modifier.fillMaxSize(), targetState = state.currentPageIndex, @@ -132,15 +122,13 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { (slideOutHorizontally { it / 4 } + fadeOut()) } }, - label = "welcome_page", - ) { index -> + label = "welcome_page") { index -> when (index) { 0 -> BrandingPage() 1 -> DonationPage( donations = state.donations, - onDonate = viewModel::openDonation, - ) + onDonate = viewModel::openDonation) 2 -> PluginsPage() @@ -160,17 +148,15 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { .widthIn(max = 680.dp) .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { // Left: Back button if (state.currentPageIndex > 0) { - OutlineButton( + MillenniumButton( onClick = viewModel::previousPage, - ) { + style = MillenniumButtonStyle.Secondary) { Text( text = stringResource(Res.string.welcome_back), - color = MaterialTheme.colorScheme.onSurface, - ) + color = AppTheme.colors.onSurface) } } else { Spacer(Modifier.width(76.dp)) @@ -180,27 +166,28 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { PageIndicator( pageCount = state.pageCount, - currentPageIndex = state.currentPageIndex, - ) + currentPageIndex = state.currentPageIndex) } // Right: Primary Next / Let's Go action - OutlineButton( + MillenniumButton( onClick = viewModel::onPrimary, - ) { - Text( - text = stringResource(state.primaryLabel), - fontWeight = FontWeight.SemiBold, - ) - Icon( - imageVector = if (state.isLastPage) { - Iconsax.IconsaxCheckCircle - } else { - Iconsax.IconsaxNext - }, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(state.primaryLabel), + fontWeight = FontWeight.SemiBold) + MillenniumIcon( + imageVector = if (state.isLastPage) { + Iconsax.IconsaxCheckCircle + } else { + Iconsax.IconsaxNext + }, + contentDescription = null, + modifier = Modifier.size(16.dp)) + } } } } @@ -214,32 +201,28 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) { private fun PageIndicator( pageCount: Int, currentPageIndex: Int, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { Row( modifier = modifier .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(7.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { repeat(pageCount) { index -> val selected = index == currentPageIndex val width by animateDpAsState( targetValue = if (selected) 20.dp else 6.dp, - label = "welcome_page_indicator", - ) + label = "welcome_page_indicator") Box( modifier = Modifier .size(width = width, height = 6.dp) .clip(CircleShape) .background( if (selected) { - MaterialTheme.colorScheme.onSurface + AppTheme.colors.onSurface } else { - MaterialTheme.colorScheme.outlineVariant + AppTheme.colors.outlineVariant } - ), - ) + )) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt index b2ee940a..296c2d11 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt @@ -28,8 +28,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class WelcomeViewModel( - private val repository: WelcomeRepository, -) : ViewModel() { + private val repository: WelcomeRepository) : ViewModel() { private val currentPageIndex = MutableStateFlow(0) private val demoPlaying = MutableStateFlow(true) @@ -42,8 +41,7 @@ class WelcomeViewModel( isLastPage = index == pageCount - 1, primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)], donations = welcomeDonations, - demoPlaying = playing, - ) + demoPlaying = playing) } .stateIn( scope = viewModelScope, @@ -53,9 +51,7 @@ class WelcomeViewModel( pageCount = welcomePrimaryLabels.size, isLastPage = welcomePrimaryLabels.size == 1, primaryLabel = welcomePrimaryLabels.first(), - donations = welcomeDonations, - ), - ) + donations = welcomeDonations)) fun nextPage() { currentPageIndex.update { (it + 1).coerceAtMost(welcomePrimaryLabels.lastIndex) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt index c06a5b98..cf815c99 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt @@ -20,77 +20,46 @@ package dev.krtirtho.spotube.modules.welcome import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.TextUnit -import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme +import com.composeunstyled.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.theme.MillenniumTheme import spotube.composeapp.generated.resources.* -/** App-native controls and surfaces, in neutral charcoal/ivory instead of the user's accent. */ +/** Installs the Millennium theme for the onboarding pages. */ @Composable fun WelcomeTheme(content: @Composable () -> Unit) { - val dark = MaterialTheme.colorScheme.surface.luminance() < 0.5f - val scheme = if (dark) darkColorScheme( - primary = Color(0xFF8BA8DD), onPrimary = Color(0xFF10213E), - primaryContainer = Color(0xFF263754), onPrimaryContainer = Color(0xFFD9E4F7), - secondary = Color(0xFFBDBDBD), secondaryContainer = Color(0xFF343434), - onSecondaryContainer = Color(0xFFF0F0F0), - surface = Color(0xFF181818), background = Color(0xFF181818), - surfaceContainer = Color(0xFF222222), surfaceContainerHigh = Color(0xFF292929), - surfaceContainerHighest = Color(0xFF323232), - onSurface = Color(0xFFEDEBE7), onSurfaceVariant = Color(0xFFABA9A5), - outlineVariant = Color(0xFF414141), - ) else lightColorScheme( - primary = Color(0xFF244879), onPrimary = Color.White, - primaryContainer = Color(0xFFDDE5F1), onPrimaryContainer = Color(0xFF173252), - secondaryContainer = Color(0xFFE5E3DF), onSecondaryContainer = Color(0xFF292929), - surface = Color(0xFFF4F3F0), background = Color(0xFFF4F3F0), - surfaceContainer = Color(0xFFEDEBE7), surfaceContainerHigh = Color(0xFFFDFCFA), - surfaceContainerHighest = Color(0xFFE1DFDB), - onSurface = Color(0xFF252525), onSurfaceVariant = Color(0xFF696762), - outlineVariant = Color(0xFFD2D0CC), - ) - MaterialTheme(colorScheme = scheme) { - val baseTheme = rememberBaseUITheme() - CompositionLocalProvider( - LocalBaseUITheme provides baseTheme, - LocalBaseUIColors provides baseTheme.colors, - ) { - content() - } + val dark = AppTheme.colors.surface.luminance() < 0.5f + MillenniumTheme(dark = dark) { + content() } } @Composable fun WelcomeBackground(modifier: Modifier = Modifier) { - val scheme = MaterialTheme.colorScheme + val scheme = AppTheme.colors Box( modifier.fillMaxSize().background( - Brush.verticalGradient(listOf(scheme.surfaceContainer, scheme.surface)), - ) + Brush.verticalGradient(listOf(scheme.surfaceContainer, scheme.surface))) ) } @Composable fun WelcomeWordmark( modifier: Modifier = Modifier, - fontSize: TextUnit = MaterialTheme.typography.displaySmall.fontSize + fontSize: TextUnit = AppTheme.typography.displaySmall.fontSize ) { Text( stringResource(Res.string.welcome_app_name), modifier = modifier, fontFamily = FontFamily(Font(Res.font.cookie_regular)), fontSize = fontSize, - color = MaterialTheme.colorScheme.onSurface, - ) + color = AppTheme.colors.onSurface) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt index c0f432f6..42696f1a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt @@ -4,8 +4,8 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,24 +23,21 @@ fun BrandingPage(modifier: Modifier = Modifier) { Column( modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { + verticalArrangement = Arrangement.Center) { Image(painterResource(Res.drawable.spotube_logo), null, Modifier.size(88.dp)) Spacer(Modifier.height(24.dp)) WelcomeWordmark(fontSize = 48.sp) Spacer(Modifier.height(24.dp)) Text( stringResource(Res.string.welcome_brand_freedom), - style = MaterialTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), - textAlign = TextAlign.Center, - ) + style = AppTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), + textAlign = TextAlign.Center) Spacer(Modifier.height(16.dp)) Text( stringResource(Res.string.welcome_brand_quiet_description), modifier = Modifier.widthIn(max = 440.dp), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt index 39dcbaf5..8663aa17 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt @@ -1,11 +1,15 @@ package dev.krtirtho.spotube.modules.welcome.pages + +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -13,7 +17,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.modules.welcome.DonationOption import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* @@ -23,38 +26,38 @@ import spotube.composeapp.generated.resources.* fun DonationPage( donations: List, onDonate: (String) -> Unit, - modifier: Modifier = Modifier, -) { + modifier: Modifier = Modifier) { Column( modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { + verticalArrangement = Arrangement.Center) { Text( stringResource(Res.string.welcome_donate_quiet_title), - style = MaterialTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), - textAlign = TextAlign.Center, - ) + style = AppTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif), + textAlign = TextAlign.Center) Spacer(Modifier.height(20.dp)) Text( stringResource(Res.string.welcome_donate_letter), modifier = Modifier.widthIn(max = 540.dp), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) Spacer(Modifier.height(32.dp)) FlowRow( horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + verticalArrangement = Arrangement.spacedBy(12.dp)) { donations.forEach { option -> - OutlineButton(onClick = { onDonate(option.url) }) { - Icon( - option.icon, null, Modifier.size(24.dp), - tint = if (option.id == "patreon") MaterialTheme.colorScheme.onSurface else Color.Unspecified, - ) - Text(stringResource(option.title)) + MillenniumButton( + onClick = { onDonate(option.url) }, + style = MillenniumButtonStyle.Secondary) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MillenniumIcon( + option.icon, null, Modifier.size(24.dp), + tint = if (option.id == "patreon") AppTheme.colors.onSurface else Color.Unspecified) + Text(stringResource(option.title)) + } } } } @@ -62,9 +65,8 @@ fun DonationPage( Text( stringResource(Res.string.welcome_donate_optional), modifier = Modifier.widthIn(max = 500.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + style = AppTheme.typography.bodySmall, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt index bfd8a2b1..80b6b4eb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt @@ -1,5 +1,10 @@ package dev.krtirtho.spotube.modules.welcome.pages + +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.animation.core.* import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -7,9 +12,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text + +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -21,8 +26,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.resources.iconsax.* import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.* @@ -41,16 +44,14 @@ fun HowItWorksPage( Row( Modifier.fillMaxSize().padding(40.dp), horizontalArrangement = Arrangement.spacedBy(48.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + verticalAlignment = Alignment.CenterVertically) { PlayerDemonstration(isPlaying, onTogglePlayback, Modifier.weight(1f)) ListeningIntroduction(Modifier.weight(1f)) } } else { Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), - verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically), - ) { + verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically)) { ListeningIntroduction() PlayerDemonstration(isPlaying, onTogglePlayback) } @@ -63,13 +64,11 @@ private fun ListeningIntroduction(modifier: Modifier = Modifier) { Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) { Text( stringResource(Res.string.welcome_how_quiet_title), - style = MaterialTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif), - ) + style = AppTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif)) Text( stringResource(Res.string.welcome_how_quiet_description), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant) } } @@ -89,15 +88,14 @@ private fun PlayerDemonstration( 0f, (2 * PI).toFloat(), infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "audio_meter" ) - val scheme = MaterialTheme.colorScheme + val scheme = AppTheme.colors - Card(modifier.fillMaxWidth()) { + MillenniumCard(modifier = modifier.fillMaxWidth()) { Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) { Text( stringResource(if (isPlaying) Res.string.welcome_demo_streaming else Res.string.welcome_demo_paused), - style = MaterialTheme.typography.labelSmall, - color = scheme.onSurfaceVariant, - ) + style = AppTheme.typography.labelSmall, + color = scheme.onSurfaceVariant) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(20.dp) @@ -124,11 +122,11 @@ private fun PlayerDemonstration( Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( stringResource(Res.string.welcome_how_mockup_title), - style = MaterialTheme.typography.titleLarge + style = AppTheme.typography.titleLarge ) Text( stringResource(Res.string.welcome_how_mockup_artist), - style = MaterialTheme.typography.bodySmall, color = scheme.onSurfaceVariant + style = AppTheme.typography.bodySmall, color = scheme.onSurfaceVariant ) Canvas(Modifier.fillMaxWidth().height(32.dp)) { val count = 18 @@ -147,29 +145,32 @@ private fun PlayerDemonstration( } Text( stringResource(Res.string.welcome_how_mockup_lyric), - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Serif), + style = AppTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Serif), color = scheme.onSurfaceVariant ) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - Icon( + MillenniumIcon( Iconsax.IconsaxPrevious, null, Modifier.size(18.dp), tint = scheme.onSurfaceVariant ) Spacer(Modifier.width(24.dp)) - GhostIconButton(onClick = onTogglePlayback, modifier = Modifier.size(48.dp)) { - Icon( + MillenniumIconButton( + onClick = onTogglePlayback, + modifier = Modifier.size(48.dp), + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon( if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, stringResource(if (isPlaying) Res.string.welcome_demo_pause_action else Res.string.welcome_demo_play_action), Modifier.size(24.dp) ) } Spacer(Modifier.width(24.dp)) - Icon( + MillenniumIcon( Iconsax.IconsaxNext, null, Modifier.size(18.dp), diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt index d0a2aa10..3bc272c8 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/DeviceAudioPlayerQueueTest.kt @@ -282,8 +282,7 @@ class DeviceAudioPlayerQueueTest { val queue = createQueue() fakePlayer.setPlaylist(listOf( MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE), - MediaItem("T2", "A2", "Al2", 200.milliseconds, "", "url2", StreamProtocol.PROGRESSIVE), - )) + MediaItem("T2", "A2", "Al2", 200.milliseconds, "", "url2", StreamProtocol.PROGRESSIVE))) queue.jumpTo(1, autoPlay = true) @@ -296,8 +295,7 @@ class DeviceAudioPlayerQueueTest { fun `jumpTo without autoPlay does not call play`() = runTest { val queue = createQueue() fakePlayer.setPlaylist(listOf( - MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE), - )) + MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE))) queue.jumpTo(0, autoPlay = false) @@ -309,8 +307,7 @@ class DeviceAudioPlayerQueueTest { fun `jumpTo clamps out of range index`() = runTest { val queue = createQueue() fakePlayer.setPlaylist(listOf( - MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE), - )) + MediaItem("T1", "A1", "Al1", 100.milliseconds, "", "url1", StreamProtocol.PROGRESSIVE))) queue.jumpTo(999) diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt index 0a204cfd..1b59eaaa 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/audioplayer/FakePluginProvider.kt @@ -34,8 +34,7 @@ class FakeMetadataTrackAPI( class FakePluginService( private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(), - override val pluginId: String = "fake-plugin", -) : PluginService { + override val pluginId: String = "fake-plugin") : PluginService { override val loggedInFlow: StateFlow = MutableStateFlow(true) override suspend fun start() = Unit diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt index 8b0043e9..c7857fe4 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/scrobble/ScrobbleManagerTest.kt @@ -68,8 +68,7 @@ class ScrobbleManagerTest { audioPlayer = player, audioPlayerQueue = queue, scrobblePluginSource = pluginSource, - scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)), - ) + scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler))) } private fun playSeconds(seconds: Int) { @@ -86,8 +85,7 @@ class ScrobbleManagerTest { streamingEntry( id = "t1", durationMs = 200_000, - externalUri = "https://open.spotify.com/track/t1", - ) + externalUri = "https://open.spotify.com/track/t1") ) player.setPlayerState(PlayerState.PLAYING) player.setDuration(200.seconds) @@ -241,8 +239,7 @@ class ScrobbleManagerTest { localEntry( name = "Local Song", url = "file:///music/local.mp3", - duration = 100_000, - ) + duration = 100_000) ) player.setPlayerState(PlayerState.PLAYING) @@ -273,14 +270,12 @@ class ScrobbleManagerTest { private fun streamingTrack( id: String, durationMs: Long = 200_000, - externalUri: String? = null, - ): MetadataTrack { + externalUri: String? = null): MetadataTrack { val artist = MetadataArtist.Basic( id = "artist-$id", name = "Artist $id", thumbnails = emptyList(), - externalUri = null, - ) + externalUri = null) val album = MetadataAlbum.Detailed( releaseDate = "2024", genres = emptyList(), @@ -291,8 +286,7 @@ class ScrobbleManagerTest { thumbnails = emptyList(), albumType = MetadataAlbumType.Album, artists = listOf(artist), - externalUri = null, - ) + externalUri = null) return MetadataTrack( id = id, title = "Track $id", @@ -305,34 +299,29 @@ class ScrobbleManagerTest { explicit = false, popularity = null, isrcCode = null, - externalUri = externalUri, - ) + externalUri = externalUri) } private fun streamingEntry( id: String, durationMs: Long = 200_000, - externalUri: String? = null, - ): QueueEntry.StreamingTrack { + externalUri: String? = null): QueueEntry.StreamingTrack { return QueueEntry.StreamingTrack( track = streamingTrack(id, durationMs, externalUri), - url = "http://127.0.0.1:14769/stream/$id", - ) + url = "http://127.0.0.1:14769/stream/$id") } private fun localEntry( name: String = "Local Track", url: String = "file:///music/local.mp3", - duration: Long = 180_000, - ): QueueEntry.LocalTrack { + duration: Long = 180_000): QueueEntry.LocalTrack { return QueueEntry.LocalTrack( name = name, artists = listOf("Local Artist"), duration = duration, album = "Local Album", coverBytes = null, - url = url, - ) + url = url) } private class FakeQueue : AudioPlayerQueue { @@ -354,14 +343,12 @@ class ScrobbleManagerTest { entries: List, autoPlay: Boolean, startPosition: Int, - collectionEntry: QueueCollectionEntry?, - ) = error("not used") + collectionEntry: QueueCollectionEntry?) = error("not used") override suspend fun addToQueue(entry: QueueEntry) = error("not used") override suspend fun addAllToQueue( entries: List, - collectionEntry: QueueCollectionEntry?, - ) = error("not used") + collectionEntry: QueueCollectionEntry?) = error("not used") override suspend fun addAllAfterCurrent(entries: List) = error("not used") override suspend fun removeFromQueue(entry: QueueEntry) = error("not used") @@ -377,8 +364,7 @@ class ScrobbleManagerTest { } private class FakeScrobblePluginSource( - initialPlugin: PluginService? = null, - ) : ScrobblePluginSource { + initialPlugin: PluginService? = null) : ScrobblePluginSource { private val selected = MutableStateFlow(initialPlugin) override val selectedScrobblePlugin: StateFlow = selected diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index 8b0fbf82..3c70a4f0 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -56,8 +56,7 @@ class StreamingUrlRepositoryBehaviorTest { explicit = false, popularity = null, isrcCode = null, - externalUri = null, - ) + externalUri = null) private fun basicSource(id: String, confidence: Float = 1f) = AudioSource.Basic( id = id, @@ -66,14 +65,12 @@ class StreamingUrlRepositoryBehaviorTest { album = null, thumbnails = emptyList(), externalUri = null, - confidence = confidence, - ) + confidence = confidence) private fun streamedSource( sourceId: String, streams: List, - confidence: Float = 1f, - ) = AudioSource.Streamed( + confidence: Float = 1f) = AudioSource.Streamed( id = sourceId, title = "source-$sourceId", artist = null, @@ -81,16 +78,14 @@ class StreamingUrlRepositoryBehaviorTest { thumbnails = emptyList(), externalUri = null, confidence = confidence, - streams = streams, - ) + streams = streams) private fun stream(url: String, bitrate: Int = 256_000) = AudioStream.Lossy( url = url, codec = "opus", container = "webm", protocol = StreamProtocol.PROGRESSIVE, - bitrate = bitrate, - ) + bitrate = bitrate) private class Harness(cacheTtlMs: Long = 30_000) { val queue = FakeStreamingAudioPlayerQueue() @@ -102,8 +97,7 @@ class StreamingUrlRepositoryBehaviorTest { audioPlayerQueue = queue, matchedTracksRepository = matchedTracksRepository, settingsRepository = settings, - streamUrlCacheTtlMs = cacheTtlMs, - ) + streamUrlCacheTtlMs = cacheTtlMs) fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService { val service = FakeAudioPluginService(pluginId, audioAPI) @@ -219,9 +213,7 @@ class StreamingUrlRepositoryBehaviorTest { url = "https://example.com/aac.mp4", codec = "aac", container = "mp4", - bitrate = 256_000, - ), - ) + bitrate = 256_000)) ) ) } diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt index 27e5550f..a52298dc 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryFakes.kt @@ -19,8 +19,7 @@ import kotlinx.coroutines.flow.StateFlow class FakeAudioAPI( override val supportedQualities: List = emptyList(), var onGetStreamsByTrack: suspend (MetadataTrack) -> List = { emptyList() }, - var onGetStreamsOfAudioSource: suspend (AudioSource.Basic) -> List = { emptyList() }, -) : AudioAPI { + var onGetStreamsOfAudioSource: suspend (AudioSource.Basic) -> List = { emptyList() }) : AudioAPI { var getStreamsByTrackCallCount = 0 var getStreamsOfAudioSourceCallCount = 0 val getStreamsOfAudioSourceCalls = mutableListOf() @@ -40,8 +39,7 @@ class FakeAudioAPI( /** Minimal [PluginService] that only ever exposes an [AudioAPI], identified by [pluginId]. */ class FakeAudioPluginService( override val pluginId: String, - val audioAPI: FakeAudioAPI = FakeAudioAPI(), -) : PluginService { + val audioAPI: FakeAudioAPI = FakeAudioAPI()) : PluginService { override val loggedInFlow: StateFlow = MutableStateFlow(true) override suspend fun start() = Unit @@ -116,8 +114,7 @@ class FakeStreamingAudioPlayerQueue : AudioPlayerQueue { entries: List, autoPlay: Boolean, startPosition: Int, - collectionEntry: QueueCollectionEntry?, - ) = error("not used") + collectionEntry: QueueCollectionEntry?) = error("not used") override suspend fun addToQueue(entry: QueueEntry) = error("not used") override suspend fun addAllToQueue(entries: List, collectionEntry: QueueCollectionEntry?) = diff --git a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt index 97f72cc4..d11fe33e 100644 --- a/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/dev/krtirtho/spotube/core/server/StreamingUrlRepositoryTest.kt @@ -32,34 +32,29 @@ class StreamingUrlRepositoryTest { bitrate: Int, codec: String = "opus", container: String = "webm", - protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, - ) = AudioStream.Lossy( + protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) = AudioStream.Lossy( url = url, codec = codec, container = container, protocol = protocol, - bitrate = bitrate, - ) + bitrate = bitrate) private fun lossless( url: String, sampleRate: Int, channels: Int, codec: String = "flac", - container: String = "flac", - ) = AudioStream.Lossless( + container: String = "flac") = AudioStream.Lossless( url = url, codec = codec, container = container, sampleRate = sampleRate, - channels = channels, - ) + channels = channels) private fun select( streams: List, format: AudioFormat = opusWebm(), - quality: AudioQuality = AudioQuality.Lossy(256_000), - ) = selectPreferredAudioStream(streams, format, quality) + quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality) // ---------- empty / single ---------- @@ -78,8 +73,7 @@ class StreamingUrlRepositoryTest { fun `does not mutate the input list`() { val streams = mutableListOf( lossy("opus-44", 44_000), - lossy("opus-256", 256_000), - ) + lossy("opus-256", 256_000)) val snapshot = streams.toList() select(streams) @@ -96,8 +90,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(otherFormat, preferredFormat), - quality = AudioQuality.Lossy(256_000), - ) + quality = AudioQuality.Lossy(256_000)) assertSame(preferredFormat, result) } @@ -109,8 +102,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(mismatch, exact), - format = AudioFormat(codec = "OPUS", container = "WEBM", qualities = emptyList()), - ) + format = AudioFormat(codec = "OPUS", container = "WEBM", qualities = emptyList())) assertSame(exact, result) } @@ -122,8 +114,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(fullMismatch, partial), - quality = AudioQuality.Lossy(44_000), - ) + quality = AudioQuality.Lossy(44_000)) assertSame(partial, result) } @@ -145,8 +136,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(codecOnly, containerOnly), - quality = AudioQuality.Lossy(44_000), - ) + quality = AudioQuality.Lossy(44_000)) assertSame(containerOnly, result) } @@ -159,8 +149,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(low, target, high), - quality = AudioQuality.Lossy(256_000), - ) + quality = AudioQuality.Lossy(256_000)) assertSame(target, result) } @@ -173,8 +162,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(wrongFormatRightFamily, sameFormatWrongFamily), - quality = AudioQuality.Lossy(44_000), - ) + quality = AudioQuality.Lossy(44_000)) assertSame(sameFormatWrongFamily, result) } @@ -189,8 +177,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(low, exact, high), - quality = AudioQuality.Lossy(128_000), - ) + quality = AudioQuality.Lossy(128_000)) assertSame(exact, result) } @@ -202,8 +189,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(higher, lower), - quality = AudioQuality.Lossy(200_000), - ) + quality = AudioQuality.Lossy(200_000)) assertSame(lower, result) } @@ -215,8 +201,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(lower, higher), - quality = AudioQuality.Lossy(100_000), - ) + quality = AudioQuality.Lossy(100_000)) assertSame(higher, result) } @@ -237,8 +222,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(losslessStream, lossyStream), - quality = AudioQuality.Lossy(128_000), - ) + quality = AudioQuality.Lossy(128_000)) assertSame(lossyStream, result) } @@ -250,8 +234,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(min, max), - quality = AudioQuality.Lossy(Int.MAX_VALUE), - ) + quality = AudioQuality.Lossy(Int.MAX_VALUE)) assertSame(max, result) } @@ -267,8 +250,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(otherRate, mono, exact), format = flac(), - quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ) + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) assertSame(exact, result) } @@ -281,8 +263,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(far, near), format = flac(), - quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ) + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) assertSame(near, result) } @@ -295,8 +276,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(mono, stereo), format = flac(), - quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ) + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) assertSame(stereo, result) } @@ -309,8 +289,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(lossyStream, losslessStream), format = flac(), - quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ) + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) assertSame(losslessStream, result) } @@ -325,8 +304,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(first, second), format = flac(), - quality = AudioQuality.Lossy(256_000), - ) + quality = AudioQuality.Lossy(256_000)) assertSame(first, result) } @@ -338,8 +316,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(first, second), - quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2), - ) + quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2)) assertSame(first, result) } @@ -354,8 +331,7 @@ class StreamingUrlRepositoryTest { val result = select( streams = listOf(low, target), format = AudioFormat(codec = "", container = "", qualities = emptyList()), - quality = AudioQuality.Lossy(256_000), - ) + quality = AudioQuality.Lossy(256_000)) assertSame(target, result) } @@ -376,8 +352,7 @@ class StreamingUrlRepositoryTest { val streams = listOf( lossy("opus-44", 44_000), lossy("opus-128", 128_000), - lossy("opus-256", 256_000), - ) + lossy("opus-256", 256_000)) val first = select(streams, quality = AudioQuality.Lossy(100_000)) val second = select(streams, quality = AudioQuality.Lossy(100_000)) @@ -405,24 +380,21 @@ class StreamingUrlRepositoryTest { fun `relative hls manifest paths are resolved against the youtube origin`() { assertEquals( "https://www.youtube.com/watch?v=abc", - normalizeManifestUrl("/watch?v=abc", StreamProtocol.HLS), - ) + normalizeManifestUrl("/watch?v=abc", StreamProtocol.HLS)) } @Test fun `relative dash manifest paths are resolved against the youtube origin`() { assertEquals( "https://www.youtube.com/watch?v=abc", - normalizeManifestUrl("/watch?v=abc", StreamProtocol.DASH), - ) + normalizeManifestUrl("/watch?v=abc", StreamProtocol.DASH)) } @Test fun `relative progressive urls are returned unchanged`() { assertEquals( "/watch?v=abc", - normalizeManifestUrl("/watch?v=abc", StreamProtocol.PROGRESSIVE), - ) + normalizeManifestUrl("/watch?v=abc", StreamProtocol.PROGRESSIVE)) } @Test diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt index a17253b2..d7dca15e 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/MainViewController.kt @@ -33,7 +33,6 @@ import kotlinx.coroutines.SupervisorJob import org.koin.core.component.KoinComponent import org.koin.core.component.inject - fun MainViewController() = ComposeUIViewController(configure = { initKoin() }) { InitNewPipe { App() } } diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt index 04efce38..016993eb 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.ios.kt @@ -343,7 +343,6 @@ private fun CMTimeMake(value: Long, timescale: Int): CValue { return platform.CoreMedia.CMTimeMake(value, timescale) } - private fun CMTimeMakeWithSeconds(seconds: Double, preferredTimescale: Int): CValue { return platform.CoreMedia.CMTimeMakeWithSeconds(seconds, preferredTimescale) } diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt index 9c07b0ed..16820c4f 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt @@ -72,8 +72,7 @@ actual class NewPipeService { url = stream.content, codec = stream.codec ?: "unknown", bitrate = stream.bitrate, - container = stream.format?.suffix ?: "unknown", - ) + container = stream.format?.suffix ?: "unknown") }, videoStreams = listOf(), // Not yet supported id = id diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt index 0cde016d..516a4ccc 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.ios.kt @@ -35,8 +35,7 @@ actual class Paths { inDomain = NSUserDomainMask, appropriateForURL = null, create = false, - error = null, - ) + error = null) return requireNotNull(cacheDirectory?.path) } @@ -48,8 +47,7 @@ actual class Paths { inDomain = NSUserDomainMask, appropriateForURL = null, create = false, - error = null, - ) + error = null) return requireNotNull(documentDirectory?.path) } @@ -60,8 +58,7 @@ actual class Paths { inDomain = NSUserDomainMask, appropriateForURL = null, create = false, - error = null, - ) + error = null) return (downloadsDirectory?.path ?: getApplicationDataDirPath()) + "/Spotube" } diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt index f47d02dd..dbc48426 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/IosLocalMediaDiscoveryService.kt @@ -24,8 +24,7 @@ class IosLocalMediaDiscoveryService : LocalMediaDiscoveryService { override fun observeChanges( roots: List, - onChanged: LocalMediaChangeCallback, - ): LocalMediaObservation? { + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { return null } } diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt index 24a2efe0..9de9e195 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.ios.kt @@ -23,6 +23,5 @@ import androidx.compose.runtime.Composable actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState { return LocalMediaPermissionState( isGranted = true, - requestPermission = {}, - ) + requestPermission = {}) } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt index 56a44ffb..9ddcd48b 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.jvm.kt @@ -122,8 +122,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface ) { val durationMs = mediaPlayer.status().length() _bufferingPosition.tryEmit( - if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO, - ) + if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO) _playerState.tryEmit(if (newCache < 100f) PlayerState.BUFFERING else PlayerState.READY) } @@ -185,8 +184,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface override fun nextItem( mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer, - item: uk.co.caprica.vlcj.media.MediaRef, - ) { + item: uk.co.caprica.vlcj.media.MediaRef) { lock.withLock { val targetIndex = pendingNextIndex ?: run { val currentMrl = runCatching { diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt index 05281c43..a227be0a 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/mediacontrol/SystemMediaControlService.kt @@ -44,8 +44,7 @@ import kotlin.time.Duration.Companion.milliseconds * dispatched onto [scope] so they reach the player's suspend API. */ class SystemMediaControlService( - private val audioPlayer: AudioPlayerInterface, -) : KoinComponent, AutoCloseable { + private val audioPlayer: AudioPlayerInterface) : KoinComponent, AutoCloseable { private val logger by injectLogger() @@ -105,12 +104,10 @@ class SystemMediaControlService( scope.launch { combine( audioPlayer.playerStateFlow, - audioPlayer.positionFlow, - ) { state, position -> + audioPlayer.positionFlow) { state, position -> MediaPlaybackState( status = state.toMediaPlaybackStatus(), - positionMs = position.inWholeMilliseconds, - ) + positionMs = position.inWholeMilliseconds) }.collect { MediaControlService.setPlaybackState(it) } } @@ -127,8 +124,7 @@ class SystemMediaControlService( artist = artist, album = album, coverUrl = coverURL.ifBlank { null }, - duration = duration.inWholeMilliseconds, - ) + duration = duration.inWholeMilliseconds) } private fun PlayerState.toMediaPlaybackStatus(): MediaPlaybackStatus { diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt index bb3e4d29..2dd89abe 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTray.kt @@ -38,16 +38,14 @@ import org.koin.compose.koinInject fun NucleusApplicationScope.SystemTray( isWindowVisible: Boolean, onToggleWindowVisibility: () -> Unit, - onExit: () -> Unit, -) { + onExit: () -> Unit) { val trayService = koinInject() val state by trayService.state.collectAsState() Tray( icon = Iconsax.IconsaxMusic, tooltip = "Spotube", - primaryAction = onToggleWindowVisibility, - ) { + primaryAction = onToggleWindowVisibility) { Item(label = if (isWindowVisible) "Hide Window" else "Show Window") { onToggleWindowVisibility() } @@ -63,8 +61,7 @@ fun NucleusApplicationScope.SystemTray( CheckableItem( label = "Shuffle", checked = state.shuffleEnabled, - onCheckedChange = trayService::setShuffle, - ) + onCheckedChange = trayService::setShuffle) SubMenu(label = "Loop") { Item(label = "Loop: OFF") { trayService.setLoop(LoopState.NONE) } Item(label = "Loop: ONE") { trayService.setLoop(LoopState.ONE) } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt index b9dfe02c..c4baf2a7 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/systemtray/SystemTrayService.kt @@ -44,8 +44,7 @@ import kotlinx.coroutines.launch class SystemTrayService( private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, - private val savedTracksRepository: SavedTracksRepository, -) : AutoCloseable { + private val savedTracksRepository: SavedTracksRepository) : AutoCloseable { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -61,8 +60,7 @@ class SystemTrayService( audioPlayer.shuffleModeFlow, audioPlayer.volumeFlow, audioPlayerQueue.currentQueueEntryFlow, - savedTracksRepository.savedTracksIdsFlow, - ) { values -> + savedTracksRepository.savedTracksIdsFlow) { values -> TrayState( playerState = values[0] as PlayerState, mediaItem = values[1] as MediaItem?, @@ -70,8 +68,7 @@ class SystemTrayService( shuffleEnabled = values[3] as Boolean, volume = values[4] as Float, currentEntry = values[5] as QueueEntry?, - savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set), - ) + savedTrackIds = @Suppress("UNCHECKED_CAST") (values[6] as Set)) }.stateIn(scope, SharingStarted.Eagerly, TrayState.Initial) fun setCallbacks(onToggleWindowVisibility: () -> Unit) { @@ -137,8 +134,7 @@ class SystemTrayService( val shuffleEnabled: Boolean, val volume: Float, val currentEntry: QueueEntry?, - val savedTrackIds: Set, - ) { + val savedTrackIds: Set) { val isPlaying: Boolean get() = playerState == PlayerState.PLAYING val currentTrackId: String? get() = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id @@ -155,8 +151,7 @@ class SystemTrayService( shuffleEnabled = false, volume = 1f, currentEntry = null, - savedTrackIds = emptySet(), - ) + savedTrackIds = emptySet()) } } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt index a55f1c85..e5012afe 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt @@ -17,6 +17,10 @@ package dev.krtirtho.spotube.core.ui.component + +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumIconButton +import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -26,12 +30,8 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.window.WindowDraggableArea -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults + +import org.spotube.millennium.composables.MillenniumTopAppBar import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -41,7 +41,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPlacement import dev.krtirtho.spotube.core.systemtray.SystemTrayService -import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.resources.iconsax.FluentDismiss import dev.krtirtho.spotube.resources.iconsax.FluentMaximize @@ -54,22 +53,18 @@ import dev.nucleusframework.window.WindowDoubleClickAction import dev.nucleusframework.window.windowDragArea import org.koin.compose.koinInject - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable actual fun ApplicationMainBar( title: @Composable (() -> Unit), subtitle: @Composable () -> Unit, actions: @Composable (RowScope.() -> Unit), backButton: Boolean, - transparent: Boolean, -) { - val colors = LocalBaseUIColors.current + transparent: Boolean) { Box( modifier = Modifier.fillMaxWidth() .windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize) ) { - TopAppBar( + MillenniumTopAppBar( title = title, actions = { actions() @@ -80,24 +75,6 @@ actual fun ApplicationMainBar( } }, subtitle = subtitle, - colors = if (transparent) { - TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - scrolledContainerColor = Color.Transparent, - titleContentColor = colors.onSurface, - actionIconContentColor = colors.onSurfaceVariant, - navigationIconContentColor = colors.onSurfaceVariant, - ) - } else { - // The app bar follows the selected surface theme rather than Material's. - TopAppBarDefaults.topAppBarColors( - containerColor = colors.surface, - scrolledContainerColor = colors.surfaceContainer, - titleContentColor = colors.onSurface, - actionIconContentColor = colors.onSurfaceVariant, - navigationIconContentColor = colors.onSurfaceVariant, - ) - }, modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons ) LocalWindowScope.current.WindowControls( @@ -119,27 +96,33 @@ private fun WindowButtons(modifier: Modifier = Modifier) { modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween ) { - IconButton(onClick = { windowState.isMinimized = true }) { - Icon(Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)) + MillenniumIconButton( + onClick = { windowState.isMinimized = true }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.FluentMinus, "Minimize", modifier = Modifier.size(14.dp)) } - IconButton(onClick = { - windowState.placement = if (windowState.placement == WindowPlacement.Maximized) - WindowPlacement.Floating else WindowPlacement.Maximized - }) { + MillenniumIconButton( + onClick = { + windowState.placement = if (windowState.placement == WindowPlacement.Maximized) + WindowPlacement.Floating else WindowPlacement.Maximized + }, + style = MillenniumIconButtonStyle.Quiet) { if (windowState.placement == WindowPlacement.Floating) { - Icon(Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)) + MillenniumIcon(Iconsax.FluentMaximize, "Maximize", modifier = Modifier.size(14.dp)) } else { - Icon(Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)) + MillenniumIcon(Iconsax.FluentSquareMultiple, "Restore", modifier = Modifier.size(14.dp)) } } - IconButton(onClick = { - if (settings?.minimizeToTray == true) { - trayService.hideWindow() - } else { - applicationScope.exitApplication() - } - }) { - Icon(Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)) + MillenniumIconButton( + onClick = { + if (settings?.minimizeToTray == true) { + trayService.hideWindow() + } else { + applicationScope.exitApplication() + } + }, + style = MillenniumIconButtonStyle.Quiet) { + MillenniumIcon(Iconsax.FluentDismiss, "Close", modifier = Modifier.size(14.dp)) } } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt index bc56ea24..25f984c1 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/VerticalScrollbar.jvm.kt @@ -30,14 +30,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors +import dev.krtirtho.spotube.core.ui.theming.AppTheme @Composable actual fun VerticalScrollbar( listState: LazyListState, modifier: Modifier ) { - val colors = LocalBaseUIColors.current + val colors = AppTheme.colors CompositionLocalProvider( LocalScrollbarStyle provides ScrollbarStyle( minimalHeight = 16.dp, @@ -45,15 +45,12 @@ actual fun VerticalScrollbar( shape = RoundedCornerShape(4.dp), hoverDurationMillis = 300, unhoverColor = colors.onSurface.copy(alpha = 0.18f), - hoverColor = colors.onSurface.copy(alpha = 0.4f), - ), - ) { + hoverColor = colors.onSurface.copy(alpha = 0.4f))) { VerticalScrollbar( adapter = rememberScrollbarAdapter(listState), modifier = modifier.fillMaxHeight() .padding(end = 2.dp) .padding(vertical = 16.dp) - .width(12.dp), - ) + .width(12.dp)) } } \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt index 2a203feb..9cc41de2 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt @@ -58,7 +58,6 @@ private object KoinServicesProvider : KoinComponent { val settingsProvider: SettingsProvider get() = get() } - @OptIn(ExperimentalComposeUiApi::class) fun main() { FileKit.init(appId = "dev.krtirtho.spotube") @@ -93,8 +92,7 @@ fun main() { SystemTray( isWindowVisible = isWindowVisible, onToggleWindowVisibility = onToggleWindowVisibility, - onExit = onExit, - ) + onExit = onExit) } DecoratedWindow( @@ -109,8 +107,7 @@ fun main() { } }, title = "Spotube", - visible = isWindowVisible, - ) { + visible = isWindowVisible) { CompositionLocalProvider( LocalApplicationScope provides this@nucleusApplication, LocalWindowScope provides this@DecoratedWindow, diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt index fa2b1cf5..405ef268 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/JvmLocalMediaDiscoveryService.kt @@ -70,8 +70,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(), durationMs = metadata?.durationMs ?: 0L, album = metadata?.album, - coverBytes = metadata?.coverBytes, - ) + coverBytes = metadata?.coverBytes) folders.getOrPut(parent) { mutableListOf() }.add(track) } } @@ -85,16 +84,14 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { LocalMediaFolder( path = path, name = Path.of(path).fileName?.toString().orEmpty().ifBlank { path }, - tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, - ) + tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }) } .sortedBy { it.name.lowercase(Locale.getDefault()) } } override fun observeChanges( roots: List, - onChanged: LocalMediaChangeCallback, - ): LocalMediaObservation? { + onChanged: LocalMediaChangeCallback): LocalMediaObservation? { val resolvedRoots = resolveRoots(roots) if (resolvedRoots.isEmpty()) return null @@ -109,8 +106,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, - StandardWatchEventKinds.ENTRY_MODIFY, - ) + StandardWatchEventKinds.ENTRY_MODIFY) } } } @@ -169,8 +165,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { } else { listOf( Path.of(home, "Music"), - Path.of(home, "Downloads"), - ) + Path.of(home, "Downloads")) } return (defaults + customRoots) @@ -189,7 +184,6 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService { "wav", "ogg", "opus", - "wma", - ) + "wma") } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt index 24a2efe0..9de9e195 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/media/LocalMediaPermission.jvm.kt @@ -23,6 +23,5 @@ import androidx.compose.runtime.Composable actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState { return LocalMediaPermissionState( isGranted = true, - requestPermission = {}, - ) + requestPermission = {}) } diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt index 537fcfbb..28a9e548 100644 --- a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt +++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerTest.kt @@ -60,8 +60,7 @@ class AudioPlayerTest { val candidates = listOf( File("src/jvmTest/resources/test-audio"), File("../composeApp/src/jvmTest/resources/test-audio"), - File(System.getProperty("user.dir"), "src/jvmTest/resources/test-audio"), - ) + File(System.getProperty("user.dir"), "src/jvmTest/resources/test-audio")) for (dir in candidates) { if (dir.isDirectory) return dir } @@ -76,8 +75,7 @@ class AudioPlayerTest { duration = 10.seconds, coverURL = "", url = url, - protocol = StreamProtocol.PROGRESSIVE, - ) + protocol = StreamProtocol.PROGRESSIVE) } private fun testItems(count: Int = 3): List { diff --git a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt b/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt index 37f6b279..fd455041 100644 --- a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt +++ b/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt @@ -18,34 +18,21 @@ package dev.krtirtho.spotube.core.ui.component import androidx.compose.foundation.layout.RowScope -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults +import org.spotube.millennium.composables.MillenniumTopAppBar import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable actual fun ApplicationMainBar( title: @Composable (() -> Unit), subtitle: @Composable () -> Unit, actions: @Composable (RowScope.() -> Unit), backButton: Boolean, - transparent: Boolean, -) { - return TopAppBar( + transparent: Boolean) { + return MillenniumTopAppBar( title = title, subtitle = subtitle, actions = actions, - colors = if (transparent) { - TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - scrolledContainerColor = Color.Transparent, - ) - } else { - TopAppBarDefaults.topAppBarColors() - }, navigationIcon = { if (backButton) { ApplicationBackButton() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8f1e5295..1033de8a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,6 @@ carApp = "1.7.0" composeHotReload = "1.2.0" composeMultiplatform = "1.11.1" coil = "3.5.0" -composePlaceholderMaterial3 = "1.0.12" composeShimmer = "1.5.0" desugar_jdk_libs = "2.1.5" haze = "2.0.0-alpha03" @@ -41,9 +40,8 @@ kmpZip = "0.13.0" kotlin = "2.3.21" kotlinx-coroutines = "1.11.0" kotlinx-io = "0.9.1" -material3 = "1.10.0-alpha05" kotlinx-serialization-json = "1.11.0" -materialKolor = "4.1.1" +millennium = "1.0.0" mqttClient = "2.1.1" mqttBuffer = "6.30.8" multiplatformMarkdownRenderer = "0.45.0" @@ -68,7 +66,6 @@ multiplatform-nav3-ui = "1.1.1" compose-multiplatform-adaptive = "1.3.0-alpha05" compose-multiplatform-lifecycle = "2.10.0" feather-icons = "1.1.1" -material3-window-size = "1.9.0" kotlinx-datetime = "0.8.0" media3 = "1.10.1" androidx-datastore = "1.2.1" @@ -86,7 +83,6 @@ mokkery = "3.3.0" appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" } androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } -compose-placeholder-material3 = { module = "com.eygraber:compose-placeholder-material3", version.ref = "composePlaceholderMaterial3" } compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" } @@ -112,7 +108,6 @@ androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecyc androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } -compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } @@ -124,7 +119,7 @@ ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } -material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } +millennium = { module = "org.spotube.millennium:millennium", version.ref = "millennium" } mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClient" } mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" } mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" } @@ -166,12 +161,7 @@ koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3", version.ref = "koin" } jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" } -jetbrains-material3-adaptiveNavigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "compose-multiplatform-adaptive" } jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" } -jetbrains-material3-window-size = { module = "org.jetbrains.compose.material3:material3-window-size-class", version.ref = "material3-window-size" } -jetbrains-material3-adaptive-suite = { module = "org.jetbrains.compose.material3:material3-adaptive-navigation-suite", version.ref = "material3-window-size" } -jetbrains-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "compose-multiplatform-adaptive" } -jetbrains-material3-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "compose-multiplatform-adaptive" } feather-icons = { module = "br.com.devsrsouza.compose.icons:feather", version.ref = "feather-icons" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" }