Compare commits

...

6 Commits

Author SHA1 Message Date
Jonatan Nyberg
50905080b8
Merge b0327fd224 into 83115ea99a 2026-10-16 00:32:05 -07:00
Kingkor Roy Tirtho
83115ea99a feat(plugin): add login prompt for metadata plugin access and implement authentication view model 2026-10-16 12:06:13 +06:00
Kingkor Roy Tirtho
59f1b82368 feat(ui): update MillenniumModalBottomSheet with compact padding and spacing adjustments 2026-10-16 12:01:36 +06:00
Kingkor Roy Tirtho
c57e33fe0d feat(ui): replace IndeterminateProgress with new loading components and enhance dialog structures 2026-10-16 11:49:48 +06:00
Kingkor Roy Tirtho
daf54b74f4 feat(plugin): implement MetadataPluginGuard to manage plugin dependencies and user prompts 2026-10-16 09:29:08 +06:00
Jonatan Nyberg
b0327fd224 translation: add Swedish resource translations
Signed-off-by: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com>
2026-09-20 14:17:16 +03:00
50 changed files with 2247 additions and 1137 deletions

View File

@ -336,6 +336,14 @@ android {
}
// ditchoom's socket-quic-quiche ships libquiche_jni.so with 4 KB LOAD segment alignment
// (not 16 KB compatible), even in the latest release. It is only an optional QUIC transport
// pulled in transitively by mqtt-client, so drop it on Android until upstream fixes it.
configurations.matching { it.name.startsWith("debug") || it.name.startsWith("release") }.configureEach {
exclude(group = "com.ditchoom", module = "socket-quic-quiche")
exclude(group = "com.ditchoom", module = "socket-quic-quiche-android")
}
dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs)
debugImplementation(libs.compose.uiTooling)

View File

@ -0,0 +1,22 @@
<!--
~ 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 <https://www.gnu.org/licenses/>.
-->
<resources>
<string name="app_name">Spotube</string>
<string name="now_playing">Spelas nu</string>
<string name="car_connect_hint">Spela musik från telefonen för att börja lyssna i bilen</string>
</resources>

View File

@ -0,0 +1,169 @@
<!--
~ 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 <https://www.gnu.org/licenses/>.
-->
<resources>
<string name="settings_screen_title">Inställningar</string>
<string name="settings_section_plugins">Tillägg</string>
<string name="settings_section_language_region">Språk och region</string>
<string name="settings_section_appearance">Utseende</string>
<string name="settings_section_playback">Uppspelning</string>
<string name="settings_section_downloads">Nedladdningar</string>
<string name="settings_section_caching">Cachelagring</string>
<string name="settings_section_desktop">Dator</string>
<string name="settings_section_updates">Uppdateringar</string>
<string name="settings_action_close">Stäng</string>
<string name="settings_action_save">Spara</string>
<string name="settings_action_cancel">Avbryt</string>
<string name="settings_language_title">Språk</string>
<string name="settings_language_subtitle_current">Språk för appens gränssnitt. Nuvarande: %1$s</string>
<string name="settings_country_title">Land</string>
<string name="settings_country_subtitle_current">Land som styr innehåll och tillgänglighet. Nuvarande: %1$s</string>
<string name="settings_option_name_and_code">%1$s (%2$s)</string>
<string name="settings_theme_title">Tema</string>
<string name="settings_theme_subtitle_current">Välj hur Spotube anpassar sig till systemets utseende. Nuvarande: %1$s</string>
<string name="settings_theme_light">Ljust</string>
<string name="settings_theme_dark">Mörkt</string>
<string name="settings_theme_system">System</string>
<string name="settings_accent_title">Accentfärg</string>
<string name="settings_accent_subtitle_current">Nuvarande färgpalett: %1$s</string>
<string name="settings_accent_dialog_title">Accentfärg</string>
<string name="settings_accent_dialog_description">Välj en accentpalett och förhandsgranska den i både ljust och mörkt tema.</string>
<string name="settings_preview_light">Ljust</string>
<string name="settings_preview_dark">Mörkt</string>
<string name="settings_preview_label">Förhandsgranskning</string>
<string name="settings_preview_button">Knapp</string>
<string name="settings_accent_green_goblin">Gröna trollet</string>
<string name="settings_accent_electric_violet">Elektriskt violett</string>
<string name="settings_accent_oceanic_cyan">Havscyan</string>
<string name="settings_accent_sunset_orange">Solnedgångsorange</string>
<string name="settings_accent_rose_garden">Rosenträdgård</string>
<string name="settings_accent_midnight_blue">Midnattsblått</string>
<string name="settings_accent_metallic_slate">Metalliskt skiffergrått</string>
<string name="settings_download_folder_title">Egen nedladdningsmapp</string>
<string name="settings_download_folder_current">Nuvarande mapp: %1$s</string>
<string name="settings_download_folder_default">Nuvarande mapp: standardmappen för musik</string>
<string name="settings_download_folder_description">Lämna fältet tomt för att använda standardmappen för musik.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Lokala mediemappar</string>
<string name="settings_local_media_folders_subtitle_current">Egna mappar att skanna på datorn. Tillagda: %1$d</string>
<string name="settings_local_media_folders_subtitle_empty">Ingen egen mapp har lagts till ännu</string>
<string name="settings_local_media_folders_description">Lägg till mappar med mappväljaren. Den egna nedladdningsmappen tas med automatiskt.</string>
<string name="settings_local_media_folders_manage">Hantera</string>
<string name="settings_local_media_folders_add_action">Lägg till mapp</string>
<string name="settings_local_media_folders_remove_action">Ta bort mapp</string>
<string name="settings_local_media_folders_none_added">Inga egna mappar har lagts till.</string>
<string name="settings_download_format_title">Musikformat för nedladdning</string>
<string name="settings_download_format_subtitle_current">Önskad kodek och önskat behållarformat för nedladdningar. Nuvarande: %1$s</string>
<string name="settings_download_quality_title">Musikkvalitet för nedladdning</string>
<string name="settings_streaming_format_title">Musikformat för strömning</string>
<string name="settings_streaming_format_subtitle_current">Önskad kodek och önskat behållarformat för musikströmning. Nuvarande: %1$s</string>
<string name="settings_streaming_quality_title">Musikkvalitet för strömning</string>
<string name="settings_subtitle_current">Nuvarande: %1$s</string>
<string name="settings_enable_music_caching_title">Aktivera cachelagring av musik</string>
<string name="settings_enable_music_caching_subtitle">Lagra strömmat ljud lokalt så att det går snabbare att spela upp igen.</string>
<string name="settings_cache_folder_title">Cachemapp</string>
<string name="settings_cache_folder_current">Nuvarande: %1$s</string>
<string name="settings_cache_folder_default">Nuvarande: appens standardcache</string>
<string name="settings_cache_size_limit_title">Maximal cachestorlek</string>
<string name="settings_cache_size_limit_unlimited">Ingen gräns</string>
<string name="settings_cache_size_limit_current">Nuvarande gräns: %1$d MB</string>
<string name="settings_cache_size_limit_description">Ange den maximala cachestorleken i MB. Ange 0 eller lämna tomt för obegränsad storlek.</string>
<string name="settings_cache_size_limit_placeholder">500</string>
<string name="settings_error_cache_size_range">Storleken måste vara 0 eller ett positivt tal.</string>
<string name="settings_enable_endless_playback_title">Fortsätt spela automatiskt</string>
<string name="settings_enable_endless_playback_subtitle">Fortsätt spela rekommenderade låtar när kön tar slut.</string>
<string name="settings_enable_connect_title">Aktivera Connect</string>
<string name="settings_enable_connect_subtitle">Gör det möjligt att fjärrstyra uppspelningen via Spotube Connect.</string>
<string name="settings_playback_port_title">Port för uppspelningens proxyserver</string>
<string name="settings_playback_port_subtitle_current">Port som uppspelningens proxyserver använder. Nuvarande: %1$d</string>
<string name="settings_playback_port_description">Välj en port mellan 1 och 65535.</string>
<string name="settings_playback_port_placeholder">14769</string>
<string name="settings_error_whole_number">Ange ett giltigt heltal.</string>
<string name="settings_error_port_range">Porten måste vara mellan 1 och 65535.</string>
<string name="settings_blacklist_title">Spärrlista</string>
<string name="settings_blacklist_subtitle">Hantera spärrade låtar och artister</string>
<string name="settings_desktop_minimize_title">Minimera till meddelandefältet</string>
<string name="settings_desktop_minimize_subtitle">Låt Spotube fortsätta köras i meddelandefältet när fönstret minimeras.</string>
<string name="settings_desktop_discord_title">Discord Rich Presence</string>
<string name="settings_desktop_discord_subtitle">Visa din aktuella uppspelningsstatus i Discord på datorn.</string>
<string name="settings_updates_auto_check_title">Sök automatiskt efter uppdateringar</string>
<string name="settings_updates_auto_check_subtitle">Sök efter nya versioner av Spotube i bakgrunden.</string>
<string name="settings_plugins_manage_title">Hantera tillägg</string>
<string name="settings_plugins_manage_subtitle">Hantera dina tillägg</string>
<string name="settings_plugins_default_ability_title">Standardtillägg: %1$s</string>
<string name="settings_plugins_ability_metadata">Metadata</string>
<string name="settings_plugins_ability_audio">Ljud</string>
<string name="settings_plugins_ability_lyrics">Låttexter</string>
<string name="settings_plugins_ability_scrobble">Skrobbling</string>
<string name="settings_plugins_no_selection">Inget tillägg valt</string>
<string name="settings_plugins_action_change">Ändra</string>
<string name="settings_plugins_action_select">Välj</string>
<string name="settings_plugins_selected">Valt</string>
<string name="settings_plugins_clear">Rensa val</string>
<string name="settings_plugins_no_plugins">Inga tillägg är tillgängliga</string>
<string name="settings_plugins_plugin_content_description">Tillägget %1$s</string>
<string name="settings_plugins_clear_content_description">Rensa</string>
<string name="plugin_configure_title">Konfigurera</string>
<string name="plugin_screen_title">Tilläggshanterare</string>
<string name="plugin_error_enter_url">Ange en URL</string>
<string name="plugin_error_url_scheme">URL:en måste börja med http:// eller https://</string>
<string name="plugin_error_download_failed">Det gick inte att ladda ner tillägget</string>
<string name="plugin_empty_title">Inga tillägg har installerats ännu</string>
<string name="plugin_empty_subtitle">Klistra in en nedladdningsadress eller välj en fil ovan.</string>
<string name="plugin_installed_count">Installerade %2$s: %1$d</string>
<string name="plugin_installed_singular">tillägg</string>
<string name="plugin_installed_plural">tillägg</string>
<string name="plugin_install_section_title">Installera ett tillägg</string>
<string name="plugin_url_placeholder">https://example.com/plugin.smplug</string>
<string name="plugin_action_download">Ladda ner</string>
<string name="plugin_action_install_from_file">Installera från fil (.smplug)</string>
<string name="plugin_state_active">Aktivt</string>
<string name="plugin_version_label">v%1$s</string>
<string name="plugin_state_builtin">Inbyggt</string>
<string name="plugin_action_remove">Ta bort tillägg</string>
<string name="plugin_action_login">Logga in</string>
<string name="plugin_action_logout">Logga ut</string>
<string name="plugin_section_url_title">Ladda ner från URL</string>
<string name="plugin_section_file_title">Installera från fil</string>
<string name="plugin_section_install">Installera</string>
<string name="plugin_permissions_author_version">• %1$s</string>
<string name="plugin_permissions_requested_title">Begärda behörigheter</string>
<string name="plugin_permissions_none">Det här tillägget kräver inga särskilda behörigheter.</string>
<string name="plugin_permissions_compare_title">Installerat och tillhandahållet</string>
<string name="plugin_permissions_installed_label">Installerat</string>
<string name="plugin_permissions_supplied_label">Tillhandahållet</string>
<string name="plugin_permissions_api_diff">API %1$s → %2$s</string>
<string name="plugin_permissions_capability_storage_title">Beständig lagring</string>
<string name="plugin_permissions_capability_storage_desc">Läsa och skriva data på disken</string>
<string name="plugin_permissions_capability_network_title">Nätverksförfrågningar</string>
<string name="plugin_permissions_capability_network_desc">Skicka och ta emot data via internet</string>
<string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Visa webbinnehåll i appen</string>
</resources>

View File

@ -68,6 +68,8 @@
<string name="library_local_permission_description">Allow audio access to browse music on your device.</string>
<string name="library_local_grant_access">Grant access</string>
<string name="library_local_empty_folders">No local folders found</string>
<string name="library_local_empty_folders_description">Add a folder with music to play it right here. No plugin needed.</string>
<string name="library_local_add_folder">Add folder</string>
<string name="library_local_empty_tracks">No tracks in this folder</string>
<string name="library_local_no_results">No matching folders or tracks</string>
<string name="library_local_refresh_failed">Failed to refresh local media</string>
@ -263,10 +265,24 @@
<string name="plugin_error_download_failed">Failed to download plugin</string>
<string name="plugin_empty_title">No plugins installed yet</string>
<string name="plugin_empty_subtitle">Paste a download URL or pick a file above.</string>
<string name="no_metadata_plugin_title">No metadata plugin selected</string>
<string name="no_metadata_plugin_description_install">Spotube needs a metadata plugin to browse, search and show your music. Install one to get started.</string>
<string name="no_metadata_plugin_description_select">You already have plugins installed, but none is set as your metadata source. Pick one to start browsing.</string>
<string name="no_metadata_plugin_step_discover">Discover a metadata plugin</string>
<string name="no_metadata_plugin_step_install">Review its permissions and install it</string>
<string name="no_metadata_plugin_step_select">Select it as your metadata source</string>
<string name="no_metadata_plugin_action_install">Install a plugin</string>
<string name="no_metadata_plugin_action_select">Choose a plugin</string>
<string name="no_metadata_plugin_action_local">Play local music</string>
<string name="no_metadata_plugin_local_hint">Your local tracks and downloads work without a plugin.</string>
<plurals name="plugin_installed_plugins_count">
<item quantity="one">%1$d plugin installed</item>
<item quantity="other">%1$d plugins installed</item>
</plurals>
<string name="plugin_install_sheet_subtitle">Add a plugin from a link or a file on your device.</string>
<string name="plugin_install_file_hint">Pick a .smplug file from your device</string>
<string name="plugin_install_or">or</string>
<string name="plugin_info_dialog_title">Plugin details</string>
<string name="plugin_install_section_title">Install a Plugin</string>
<string name="plugin_url_placeholder">https://example.com/plugin.smplug</string>
<string name="plugin_action_download">Download</string>
@ -560,6 +576,7 @@
<string name="search_start_typing">Start typing to search</string>
<string name="search_start_typing_tracks">Start typing to search tracks</string>
<string name="search_no_results">No results found</string>
<string name="common_no_results">No results</string>
<string name="search_see_all">See all</string>
<string name="search_searching">Searching</string>
<string name="search_loading_tracks">Loading tracks...</string>
@ -613,6 +630,12 @@
<string name="media_user">User</string>
<string name="media_playlist">Playlist</string>
<!-- Login required -->
<string name="login_required_title">Sign in to continue</string>
<string name="login_required_description">This part of Spotube needs you to be signed in with your music provider. Sign in once and everything will show up here.</string>
<string name="login_required_description_plugin">%1$s needs you to sign in before it can show this. Sign in once and everything will show up here.</string>
<string name="login_required_action">Sign in</string>
<!-- Error display -->
<string name="error_title">We\'re Truly Sorry</string>
<string name="error_description">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.</string>

View File

@ -54,6 +54,7 @@ import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
import dev.krtirtho.spotube.modules.home.HomeScreenViewModel
import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsViewModel
@ -70,6 +71,7 @@ import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginProvider
import dev.krtirtho.spotube.modules.plugin.PluginViewModel
import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuardViewModel
import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
@ -115,6 +117,7 @@ val sharedModules = module {
// Home
singleOf(::HomeScreenRepository)
viewModelOf(::HomeScreenViewModel)
viewModelOf(::MetadataAuthViewModel)
// Search
singleOf(::SearchRepository)
@ -138,6 +141,7 @@ val sharedModules = module {
bind<ScrobblePluginSource>()
}
viewModelOf(::PluginViewModel)
viewModelOf(::MetadataPluginGuardViewModel)
// Settings
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }

View File

@ -29,6 +29,7 @@ import dev.krtirtho.spotube.modules.library.LibraryScreen
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
import dev.krtirtho.spotube.modules.playlist.PlaylistScreen
import dev.krtirtho.spotube.modules.plugin.PluginScreen
import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuard
import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
import dev.krtirtho.spotube.modules.search.SearchScreen
@ -101,12 +102,17 @@ sealed interface Routes : NavKey {
val navigationModule = module {
singleOf(::NavigationCommands)
navigation<Routes.Home> {
HomeScreen(viewModel = koinViewModel())
MetadataPluginGuard {
HomeScreen(viewModel = koinViewModel())
}
}
navigation<Routes.Search> {
SearchScreen()
MetadataPluginGuard {
SearchScreen()
}
}
navigation<Routes.Library> {
// Not guarded as a whole: Local tracks and Downloads work without a plugin.
LibraryScreen()
}
navigation<Routes.Settings> {
@ -117,49 +123,57 @@ val navigationModule = module {
}
navigation<Routes.WebView> {}
navigation<Routes.Playlist> {
PlaylistScreen(
playlistId = it.playlistId,
viewModel = koinViewModel(
key = it.playlistId,
parameters = { parametersOf(it.playlistId) }
),
audioPlayerQueue = get(),
audioPlayer = get(),
navigationCommands = get())
MetadataPluginGuard(backButton = true) {
PlaylistScreen(
playlistId = it.playlistId,
viewModel = koinViewModel(
key = it.playlistId,
parameters = { parametersOf(it.playlistId) }
),
audioPlayerQueue = get(),
audioPlayer = get(),
navigationCommands = get())
}
}
navigation<Routes.Artist> {
ArtistScreen(
viewModel = koinViewModel(
key = it.artistId,
parameters = { parametersOf(it.artistId) }
),
audioPlayerQueue = get(),
audioPlayer = get(),
navigationCommands = get())
MetadataPluginGuard(backButton = true) {
ArtistScreen(
viewModel = koinViewModel(
key = it.artistId,
parameters = { parametersOf(it.artistId) }
),
audioPlayerQueue = get(),
audioPlayer = get(),
navigationCommands = get())
}
}
navigation<Routes.Album> {
AlbumScreen(
albumId = it.albumId,
viewModel = koinViewModel(
key = it.albumId,
parameters = { parametersOf(it.albumId) }
),
audioPlayerQueue = get(),
audioPlayer = get(),
navigationCommands = get())
MetadataPluginGuard(backButton = true) {
AlbumScreen(
albumId = it.albumId,
viewModel = koinViewModel(
key = it.albumId,
parameters = { parametersOf(it.albumId) }
),
audioPlayerQueue = get(),
audioPlayer = get(),
navigationCommands = get())
}
}
navigation<Routes.Lyrics> {
LyricsScreen(viewModel = koinViewModel())
}
navigation<Routes.SavedTracks> {
SavedTracksScreen(
viewModel = koinViewModel(
key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) }
),
audioPlayerQueue = get(),
audioPlayer = get(),
navigationCommands = get())
MetadataPluginGuard(backButton = true) {
SavedTracksScreen(
viewModel = koinViewModel(
key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) }
),
audioPlayerQueue = get(),
audioPlayer = get(),
navigationCommands = get())
}
}
navigation<Routes.Blacklist> {
BlacklistScreen()

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.core.remote
import org.spotube.millennium.composables.MillenniumAlertDialog
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@ -34,7 +35,6 @@ import androidx.compose.ui.text.style.TextAlign
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.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res
@ -56,21 +56,25 @@ fun ConnectionRequestDialogHost() {
}
pendingRequest?.let { request ->
MillenniumDialog(
MillenniumAlertDialog(
visible = true,
onDismissRequest = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
pendingRequest = null
},
title = stringResource(Res.string.remote_request_title)) {
Text(
text = stringResource(Res.string.remote_request_message, request.deviceName),
style = AppTheme.typography.bodyMedium,
textAlign = TextAlign.Start)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically) {
title = { Text(stringResource(Res.string.remote_request_title)) },
description = {
Text(stringResource(Res.string.remote_request_message, request.deviceName))
},
) {
MillenniumButton(
onClick = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
pendingRequest = null
},
style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.remote_request_allow_always))
}
MillenniumButton(
onClick = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
@ -86,15 +90,6 @@ fun ConnectionRequestDialogHost() {
}) {
Text(stringResource(Res.string.remote_request_allow))
}
MillenniumButton(
onClick = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
pendingRequest = null
},
style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.remote_request_allow_always))
}
}
}
}
}

View File

@ -19,6 +19,15 @@ package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogFooter
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
@ -31,7 +40,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import com.composeunstyled.SheetDetent
import com.composeunstyled.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@ -50,6 +58,9 @@ import spotube.composeapp.generated.resources.*
fun AdaptiveDialogBottomSheet(
onDismiss: () -> Unit,
title: @Composable (() -> Unit)? = null,
// Plain-text title for the large-screen dialog's own titlebar; when set it replaces [title]
// there so the titlebar isn't left empty above a duplicated in-body title.
dialogTitle: String? = null,
modifier: Modifier = Modifier,
actions: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit) {
@ -59,14 +70,19 @@ fun AdaptiveDialogBottomSheet(
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
title = "",
modifier = modifier) {
title?.let { it() }
content()
actions?.let {
MillenniumSeparator()
it()
modifier = modifier,
paneTitle = dialogTitle ?: "") {
MillenniumDialogHeader {
if (dialogTitle != null) {
MillenniumDialogTitle(text = dialogTitle, textStyle = AppTheme.typography.titleMedium)
} else {
MillenniumDialogTitle { title?.invoke() }
}
MillenniumDialogClose(
contentDescription = stringResource(Res.string.common_close_dialog))
}
MillenniumDialogBody { content() }
actions?.let { MillenniumDialogFooter(content = { it() }) }
}
} else {
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
@ -77,10 +93,7 @@ fun AdaptiveDialogBottomSheet(
}
MillenniumModalBottomSheet(state = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 16.dp),
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start) {
title?.let {
Row(
@ -99,7 +112,13 @@ fun AdaptiveDialogBottomSheet(
}
Spacer(Modifier.height(12.dp))
}
content()
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 560.dp)
.verticalScroll(rememberScrollState())) {
content()
}
actions?.let {
Spacer(Modifier.height(8.dp))
MillenniumSeparator()
@ -107,7 +126,6 @@ fun AdaptiveDialogBottomSheet(
it()
}
}
Spacer(Modifier.height(24.dp))
}
}
}

View File

@ -22,7 +22,18 @@ 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.Spacer
import androidx.compose.foundation.layout.heightIn
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.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.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -30,6 +41,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -41,6 +53,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.adaptive.WindowBreakpoints
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@ -57,6 +70,14 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumDropdownMenu
import org.spotube.millennium.composables.MillenniumDropdownMenuContent
import org.spotube.millennium.composables.MillenniumDropdownMenuEmpty
import org.spotube.millennium.composables.MillenniumDropdownMenuFilter
import org.spotube.millennium.composables.MillenniumDropdownMenuHeader
import org.spotube.millennium.composables.MillenniumDropdownMenuItem
import org.spotube.millennium.composables.MillenniumDropdownMenuItemIcon
import org.spotube.millennium.composables.MillenniumDropdownMenuSeparator
import com.composeunstyled.Text
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
@ -67,6 +88,7 @@ import org.spotube.millennium.composables.MillenniumText
import org.spotube.millennium.composables.MillenniumTextField
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.accessibility_selected
import spotube.composeapp.generated.resources.common_no_results
import spotube.composeapp.generated.resources.search_filter_placeholder
data class AdaptiveMenuItem(
@ -99,17 +121,13 @@ fun AdaptiveDropdownBottomSheet(
Box(modifier = modifier) {
if (isLargeScreen) {
MillenniumDropdownMenu(
DesktopDropdownMenu(
expanded = expanded,
onExpandedChange = { expanded = it },
items = items.map { it.label },
onItemClick = { label ->
items.firstOrNull { it.label == label && it.enabled }?.onClick?.invoke()
expanded = false
},
minimumWidth = menuMinWidth,
anchor = { trigger { expanded = true } }
)
items = items,
menuMinWidth = menuMinWidth,
filter = filter,
trigger = { trigger { expanded = true } })
} else {
trigger { expanded = true }
if (expanded) {
@ -134,6 +152,7 @@ private fun AdaptiveBottomSheetContent(
header: @Composable (() -> Unit)?,
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
val hasSelection = items.any { it.selected }
val hasLeading = items.any { it.icon != null || it.leadingContent != null }
val queryState = rememberTextFieldState()
val query = queryState.text.toString()
val filteredItems = if (filter != null && query.isNotBlank()) {
@ -147,7 +166,10 @@ private fun AdaptiveBottomSheetContent(
onDismiss()
}
}
MillenniumModalBottomSheet(state = sheetState) {
MillenniumModalBottomSheet(
state = sheetState,
contentPadding = PaddingValues(top = 10.dp, bottom = 8.dp),
contentSpacing = 4.dp) {
Column(
modifier = Modifier.fillMaxWidth()) {
header?.invoke()
@ -175,69 +197,134 @@ private fun AdaptiveBottomSheetContent(
if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
}
MillenniumListTile(
title = {
MillenniumText(
item.label,
style = AppTheme.typography.bodyLarge,
color = if (item.enabled) {
AppTheme.colors.onSurface
} else {
AppTheme.colors.onSurface.copy(alpha = 0.38f)
}
)
},
subtitle = item.subtitle?.let { subtitle ->
{
MillenniumText(
subtitle,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant
)
}
},
leading = when {
item.leadingContent != null -> item.leadingContent
item.icon != null -> {
{
MillenniumIcon(
imageVector = item.icon,
contentDescription = null,
modifier = Modifier.size(22.dp)
)
}
}
item.selected -> {
{
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = stringResource(Res.string.accessibility_selected),
modifier = Modifier.size(22.dp)
)
}
}
hasSelection -> {{ Spacer(modifier = Modifier.size(22.dp)) }}
else -> null
},
trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) {
{
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = stringResource(Res.string.accessibility_selected),
modifier = Modifier.size(22.dp)
)
}
} else {
null
},
selected = item.selected,
enabled = item.enabled,
AdaptiveMenuTile(
item = item,
hasSelection = hasSelection,
hasLeading = hasLeading,
textStyle = AppTheme.typography.bodyLarge,
minimumHeight = 56.dp,
onClick = {
item.onClick()
onDismiss()
},
minimumHeight = 56.dp
)
})
}
}
}
}
}
/** One menu row. The leading slot is always reserved when any sibling has an icon/leading content
* (or a selection), so labels and icons stay aligned in a column. */
@Composable
private fun AdaptiveMenuTile(
item: AdaptiveMenuItem,
hasSelection: Boolean,
hasLeading: Boolean,
textStyle: androidx.compose.ui.text.TextStyle,
minimumHeight: Dp,
onClick: () -> Unit) {
if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
}
val contentColor = if (item.enabled) AppTheme.colors.onSurface
else AppTheme.colors.onSurface.copy(alpha = 0.38f)
MillenniumListTile(
title = { MillenniumText(item.label, style = textStyle, color = contentColor) },
subtitle = item.subtitle?.let { subtitle ->
{
MillenniumText(
subtitle,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
}
},
leading = when {
item.leadingContent != null -> item.leadingContent
item.icon != null -> {
{
MillenniumIcon(
imageVector = item.icon,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = contentColor)
}
}
item.selected -> {
{
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = stringResource(Res.string.accessibility_selected),
modifier = Modifier.size(22.dp))
}
}
hasSelection || hasLeading -> {{ Spacer(modifier = Modifier.size(22.dp)) }}
else -> null
},
trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) {
{
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = stringResource(Res.string.accessibility_selected),
modifier = Modifier.size(22.dp))
}
} else {
null
},
selected = item.selected,
enabled = item.enabled,
onClick = onClick,
minimumHeight = minimumHeight)
}
/** Desktop popup built from Millennium's dropdown bricks: native icons, descriptions,
* selection indicator, separators and an optional search header. */
@Composable
private fun DesktopDropdownMenu(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
items: List<AdaptiveMenuItem>,
menuMinWidth: Dp,
filter: ((AdaptiveMenuItem, String) -> Boolean)?,
trigger: @Composable () -> Unit) {
val queryState = rememberTextFieldState()
LaunchedEffect(expanded) { if (!expanded) queryState.clearText() }
val query = queryState.text.toString()
val visibleItems = if (filter != null && query.isNotBlank()) {
items.filter { filter(it, query) }
} else {
items
}
val hasLeading = items.any { it.icon != null || it.leadingContent != null }
val iconSize = MillenniumTheme.dropdownMenuTheme.states.default.iconSize
MillenniumDropdownMenu(
expanded = expanded,
onExpandedChange = onExpandedChange,
minimumWidth = menuMinWidth,
width = menuMinWidth,
anchor = trigger) {
if (filter != null) {
MillenniumDropdownMenuHeader(showDivider = false) {
MillenniumDropdownMenuFilter(
state = queryState,
placeholder = stringResource(Res.string.search_filter_placeholder))
}
}
MillenniumDropdownMenuContent {
if (visibleItems.isEmpty()) {
MillenniumDropdownMenuEmpty(stringResource(Res.string.common_no_results))
}
visibleItems.forEach { item ->
if (item.dividerBefore) MillenniumDropdownMenuSeparator()
MillenniumDropdownMenuItem(
onClick = item.onClick,
enabled = item.enabled,
selected = item.selected,
leading = item.leadingContent
?: item.icon?.let { icon -> { MillenniumDropdownMenuItemIcon(icon) } }
?: if (hasLeading) ({ Spacer(Modifier.size(iconSize)) }) else null,
description = item.subtitle?.let { subtitle -> { Text(subtitle) } }) {
Text(item.label)
}
}
}

View File

@ -18,6 +18,11 @@
package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogFooter
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -45,6 +50,9 @@ import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel
import org.koin.compose.viewmodel.koinViewModel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -72,7 +80,16 @@ import spotube.composeapp.generated.resources.*
fun ErrorDisplay(
errorMessage: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier) {
modifier: Modifier = Modifier,
authViewModel: MetadataAuthViewModel = koinViewModel()) {
val authState by authViewModel.uiState.collectAsStateWithLifecycle()
if (authState.loginRequired) {
LoginRequiredDisplay(
pluginName = authState.pluginName,
onLogin = authViewModel::login,
modifier = modifier)
return
}
var showFullLog by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
@ -191,19 +208,11 @@ private fun ErrorLogDialog(
onDismiss: () -> Unit) {
val clipboardManager = LocalClipboardManager.current
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
title = "") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Text(
MillenniumDialog(visible = true, onDismissRequest = onDismiss) {
MillenniumDialogHeader {
MillenniumDialogTitle(
text = stringResource(Res.string.error_full_log_title),
style = AppTheme.typography.titleLarge,
fontWeight = FontWeight.Bold)
textStyle = AppTheme.typography.titleMedium)
MillenniumIconButton(
onClick = {
clipboardManager.setText(AnnotatedString(errorMessage))
@ -213,47 +222,38 @@ private fun ErrorLogDialog(
imageVector = FeatherIcons.Copy,
contentDescription = stringResource(Res.string.error_copy_to_clipboard))
}
MillenniumDialogClose()
}
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))
MillenniumDialogBody(scrollable = false) {
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))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End) {
MillenniumDialogFooter {
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Quiet) {
style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.common_close))
}
}
}
}
@Preview
@Composable
private fun ErrorDisplayPreview() {
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
ErrorDisplay(
errorMessage = "This is an error message",
onRetry = {})
}
}
@Preview
@Composable
private fun ErrorLogDialogPreview() {

View File

@ -0,0 +1,101 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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.Row
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.shape.CircleShape
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.composeunstyled.Text
import compose.icons.FeatherIcons
import compose.icons.feathericons.Lock
import compose.icons.feathericons.LogIn
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumIcon
import spotube.composeapp.generated.resources.*
/** Friendly "sign in first" state for screens whose metadata plugin needs an account. */
@Composable
fun LoginRequiredDisplay(
pluginName: String?,
onLogin: () -> Unit,
modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)) {
Box(
modifier = Modifier
.size(88.dp)
.clip(CircleShape)
.background(AppTheme.colors.primary.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center) {
MillenniumIcon(
imageVector = FeatherIcons.Lock,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = AppTheme.colors.primary)
}
Text(
text = stringResource(Res.string.login_required_title),
style = AppTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = AppTheme.colors.onSurface,
textAlign = TextAlign.Center)
Text(
text = if (pluginName.isNullOrBlank()) {
stringResource(Res.string.login_required_description)
} else {
stringResource(Res.string.login_required_description_plugin, pluginName)
},
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant,
textAlign = TextAlign.Center)
MillenniumButton(
onClick = onLogin,
modifier = Modifier.height(44.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
MillenniumIcon(
imageVector = FeatherIcons.LogIn,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp).size(18.dp))
Text(stringResource(Res.string.login_required_action))
}
}
}
}

View File

@ -28,7 +28,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -143,7 +145,10 @@ fun TrackOptionsBottomSheet(
onDismiss()
}
}
MillenniumModalBottomSheet(state = sheetState) {
MillenniumModalBottomSheet(
state = sheetState,
contentPadding = PaddingValues(top = 10.dp, bottom = 8.dp),
contentSpacing = 4.dp) {
Column(modifier = Modifier.fillMaxWidth()) {
TrackOptionsSheetHeader(track = track)
@ -184,7 +189,7 @@ fun TrackOptionsBottomSheet(
} else {
AppTheme.colors.onSurface.copy(alpha = 0.38f)
})
}
} ?: Spacer(modifier = Modifier.size(22.dp))
Text(
text = item.label,
style = AppTheme.typography.bodyLarge,

View File

@ -42,6 +42,8 @@ import androidx.compose.ui.unit.TextUnit
import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer
import com.valentinilk.shimmer.shimmer
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.millenniumBevelBrush
val LocalSkeletonLoading = compositionLocalOf { false }
@ -80,11 +82,16 @@ fun SkeletonTree(
}
fun Modifier.shimmerApply(
color: Color = Color.LightGray.copy(alpha = 0.5f) // Adjust alpha/color to match your theme
color: Color? = null // defaults to Millennium's recessed skeleton fill
): Modifier = composed {
val isTreeLoading = LocalSkeletonLoading.current
val skeletonBrush = millenniumBevelBrush(MillenniumTheme.colors, sunken = true)
if (isTreeLoading) this.background(color) else this
when {
!isTreeLoading -> this
color != null -> this.background(color)
else -> this.background(skeletonBrush)
}
}
@Composable

View File

@ -18,6 +18,7 @@
package dev.krtirtho.spotube.core.ui.theming
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@ -35,9 +36,11 @@ import androidx.compose.ui.unit.sp
import dev.krtirtho.spotube.modules.settings.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings
import org.spotube.millennium.composables.MillenniumModalBottomSheetTheme
import org.spotube.millennium.composables.MillenniumTooltipHost
import org.spotube.millennium.composables.MillenniumTopAppBarTheme
import org.spotube.millennium.composables.MillenniumTopAppBarThemeProperties
import org.spotube.millennium.composables.defaultMillenniumModalBottomSheetTheme
import org.spotube.millennium.composables.defaultMillenniumTopAppBarTheme
import org.spotube.millennium.theme.MillenniumColors
import org.spotube.millennium.theme.MillenniumDarkThemeComponents
@ -218,14 +221,16 @@ fun SpotubeTheme(
properties = darkTheme.topAppBarTheme.properties.copy(
separatorColor = Color.Transparent
)
)
),
modalBottomSheetTheme = compactModalBottomSheetTheme()
),
light = lightTheme.copy(
topAppBarTheme = MillenniumTopAppBarTheme(
properties = lightTheme.topAppBarTheme.properties.copy(
separatorColor = Color.Transparent
)
)
),
modalBottomSheetTheme = compactModalBottomSheetTheme()
)
)
) {
@ -241,6 +246,23 @@ fun SpotubeTheme(
}
}
/** Library default is 24dp padding + 16dp spacing, which is far too roomy on phones. */
private fun compactModalBottomSheetTheme(): MillenniumModalBottomSheetTheme {
val padding = PaddingValues(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp)
fun org.spotube.millennium.composables.MillenniumModalBottomSheetThemeProperties.compact() =
copy(contentPadding = padding, contentSpacing = 8.dp)
val states = defaultMillenniumModalBottomSheetTheme().states
return MillenniumModalBottomSheetTheme(
states = org.spotube.millennium.theme.MillenniumControlStateProperty(
default = states.default.compact(),
hovered = states.hovered.compact(),
focused = states.focused.compact(),
pressed = states.pressed.compact(),
disabled = states.disabled.compact(),
selected = states.selected.compact(),
error = states.error.compact()))
}
/** Whether the active Millennium appearance is light. */
@Composable
fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f

View File

@ -84,7 +84,6 @@ import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@ -143,8 +142,7 @@ fun ArtistScreen(
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(bottom = LocalAppShellBottomInset.current + 24.dp),
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
item {

View File

@ -18,10 +18,10 @@
package dev.krtirtho.spotube.modules.devices
import org.spotube.millennium.composables.MillenniumSpokeSpinner
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
@ -103,10 +103,9 @@ fun DevicesScreen(
title = { Text(stringResource(Res.string.devices_title)) },
actions = {
if (isDiscovering && connectingToDevice == null) {
MillenniumIndeterminateProgress(
modifier = Modifier
.size(24.dp)
.padding(end = 8.dp))
MillenniumSpokeSpinner(
size = 24.dp,
modifier = Modifier.padding(end = 8.dp))
} else {
MillenniumIcon(
imageVector = Iconsax.IconsaxRefreshRight,
@ -177,8 +176,7 @@ fun DevicesScreen(
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically) {
MillenniumIndeterminateProgress(
modifier = Modifier.size(24.dp))
MillenniumSpokeSpinner(size = 24.dp)
Text(
text = stringResource(Res.string.devices_connecting),
style = AppTheme.typography.bodyMedium)
@ -250,8 +248,7 @@ private fun DeviceRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (isConnecting) {
MillenniumIndeterminateProgress(
modifier = Modifier.size(24.dp))
MillenniumSpokeSpinner(size = 24.dp)
} else {
MillenniumIcon(
imageVector = Iconsax.IconsaxMirroringScreen,

View File

@ -18,6 +18,11 @@
package dev.krtirtho.spotube.modules.devices
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogFooter
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumListTile
@ -82,10 +87,12 @@ fun PlayDestinationPickerHost() {
PlaybackDestinationAction.PlayNext -> stringResource(Res.string.devices_destination_action_play_next)
}
MillenniumDialog(
visible = true,
onDismissRequest = controller::dismissPicker,
title = stringResource(Res.string.devices_destination_title)) {
MillenniumDialog(visible = true, onDismissRequest = controller::dismissPicker) {
MillenniumDialogHeader {
MillenniumDialogTitle(stringResource(Res.string.devices_destination_title))
MillenniumDialogClose()
}
MillenniumDialogBody {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
@ -138,6 +145,9 @@ fun PlayDestinationPickerHost() {
})
}
}
}
MillenniumDialogFooter {
MillenniumButton(
onClick = controller::dismissPicker,
style = MillenniumButtonStyle.Quiet) {

View File

@ -18,6 +18,8 @@
package dev.krtirtho.spotube.modules.devices
import org.spotube.millennium.composables.MillenniumDropdownMenuItem
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
@ -604,11 +606,6 @@ private fun RemoteQueueItemRow(
MillenniumDropdownMenu(
expanded = showMenu,
onExpandedChange = { showMenu = it },
items = listOf(stringResource(Res.string.devices_remote_remove_from_queue)),
onItemClick = {
onRemoveClick()
showMenu = false
},
anchor = {
MillenniumIconButton(
onClick = { showMenu = true },
@ -620,7 +617,16 @@ private fun RemoteQueueItemRow(
modifier = Modifier.size(18.dp)
)
}
})
}) {
MillenniumDropdownMenuItem(
label = stringResource(Res.string.devices_remote_remove_from_queue),
icon = Iconsax.IconsaxMusicSquareRemove,
destructive = true,
onClick = {
onRemoveClick()
showMenu = false
})
}
}
)
}

View File

@ -3,6 +3,7 @@
*/
package dev.krtirtho.spotube.modules.downloads
import org.spotube.millennium.composables.MillenniumStripedProgress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -42,7 +43,6 @@ 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 org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.millenniumPanelBrush
import org.spotube.millennium.theme.millenniumSurface
@ -225,9 +225,10 @@ internal fun DownloadListRow(
},
maxLines = 1, overflow = TextOverflow.Ellipsis
)
if (item.status == DownloadStatus.Downloading) MillenniumProgress(
item.progress,
Modifier.fillMaxWidth().height(4.dp)
if (item.status == DownloadStatus.Downloading) MillenniumStripedProgress(
progress = item.progress,
modifier = Modifier.fillMaxWidth(),
height = 6.dp
)
}
MillenniumIconButton(

View File

@ -17,6 +17,9 @@
package dev.krtirtho.spotube.modules.jam
import org.spotube.millennium.composables.MillenniumDropdownMenuItem
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@ -421,25 +424,27 @@ internal fun SessionPanel(
)
}
if (participant.canModerate) {
val removeLabel = stringResource(Res.string.jam_participants_remove)
val banLabel = stringResource(Res.string.jam_participants_ban)
// Keep destructive moderation out of the participant's primary row.
org.spotube.millennium.composables.MillenniumDropdownMenu(
expanded = participant.menuExpanded,
onExpandedChange = { expanded -> onMenuChange(if (expanded) participant.id else null) },
items = listOf(removeLabel, banLabel),
onItemClick = { label ->
if (label == removeLabel) onKick(participant.id) else onBan(
participant.id
)
},
anchor = {
MillenniumButton(
onClick = { onMenuChange(participant.id) },
style = MillenniumButtonStyle.Secondary
) { Text(stringResource(Res.string.jam_participants_manage)) }
}
)
) {
MillenniumDropdownMenuItem(
label = stringResource(Res.string.jam_participants_remove),
icon = Iconsax.IconsaxUserRemove,
onClick = { onKick(participant.id) })
MillenniumDropdownMenuItem(
label = stringResource(Res.string.jam_participants_ban),
icon = Iconsax.IconsaxForbidden,
destructive = true,
onClick = { onBan(participant.id) })
}
}
}
}

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.modules.jam
import org.spotube.millennium.composables.MillenniumAlertDialog
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import org.spotube.millennium.composables.MillenniumButton
@ -47,27 +48,23 @@ fun PlayDestinationPicker(
onSuggestToJam: () -> Unit) {
if (!visible) return
MillenniumDialog(
MillenniumAlertDialog(
visible = true,
onDismissRequest = onDismiss,
title = stringResource(Res.string.jam_destination_title)) {
Text(
text = stringResource(Res.string.jam_destination_message),
style = AppTheme.typography.bodyMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.common_cancel))
}
MillenniumButton(
onClick = onPlayLocally,
style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.jam_destination_play_here))
}
MillenniumButton(onClick = onSuggestToJam) {
Text(stringResource(Res.string.jam_destination_suggest))
}
title = { Text(stringResource(Res.string.jam_destination_title)) },
description = { Text(stringResource(Res.string.jam_destination_message)) }) {
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.common_cancel))
}
MillenniumButton(
onClick = onPlayLocally,
style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.jam_destination_play_here))
}
MillenniumButton(onClick = onSuggestToJam) {
Text(stringResource(Res.string.jam_destination_suggest))
}
}
}

View File

@ -57,6 +57,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import org.jetbrains.compose.resources.StringResource
import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuard
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.spotube.millennium.adaptive.WindowBreakpoints
@ -116,14 +117,20 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) {
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
when (state.currentTab) {
LibraryTab.Playlists -> LibraryPlaylistsScreen(
showCreatePlaylist = state.showCreatePlaylist,
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist,
viewMode = viewMode
)
LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) {
LibraryPlaylistsScreen(
showCreatePlaylist = state.showCreatePlaylist,
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist,
viewMode = viewMode
)
}
LibraryTab.Albums -> LibraryAlbumsScreen(viewMode = viewMode)
LibraryTab.Artists -> LibraryArtistsScreen(viewMode = viewMode)
LibraryTab.Albums -> MetadataPluginGuard(showTopBar = false, requireLogin = true) {
LibraryAlbumsScreen(viewMode = viewMode)
}
LibraryTab.Artists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) {
LibraryArtistsScreen(viewMode = viewMode)
}
LibraryTab.LocalTracks -> LibraryLocalTracksScreen()
LibraryTab.Downloads -> DownloadsScreen()
}

View File

@ -3,6 +3,8 @@
*/
package dev.krtirtho.spotube.modules.library.local_tracks
import org.spotube.millennium.composables.MillenniumCircularProgress
import org.spotube.millennium.composables.MillenniumSpokeSpinner
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -36,6 +38,9 @@ import dev.krtirtho.spotube.core.ui.component.cards.MediaEmptyState
import dev.krtirtho.spotube.core.ui.component.cards.PlayableListTile
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState
import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel
import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersDialogHost
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderAdd
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen
@ -48,7 +53,6 @@ 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.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.millenniumPanelBrush
@ -58,7 +62,8 @@ import spotube.composeapp.generated.resources.*
@Composable
fun LibraryLocalTracksScreen(
modifier: Modifier = Modifier,
viewModel: LibraryLocalTracksViewModel = koinViewModel()
viewModel: LibraryLocalTracksViewModel = koinViewModel(),
foldersViewModel: LocalMediaFoldersViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val permission = rememberLocalMediaPermissionState()
@ -66,8 +71,10 @@ fun LibraryLocalTracksScreen(
LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) }
LocalTracksContent(
state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp,
viewModel::playCurrentFolder, permission.requestPermission, modifier
viewModel::playCurrentFolder, permission.requestPermission, modifier,
onManageFolders = foldersViewModel::open
)
LocalMediaFoldersDialogHost(foldersViewModel)
}
@Composable
@ -78,14 +85,15 @@ internal fun LocalTracksContent(
onNavigateUp: () -> Unit,
onPlayFolder: () -> Unit,
onRequestPermission: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onManageFolders: () -> Unit = {}
) {
val inset = LocalAppShellBottomInset.current
Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
Column(
Modifier.widthIn(max = 960.dp).fillMaxSize()
) {
LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder)
LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder, onManageFolders = onManageFolders)
when {
!state.canAccessMedia -> MediaEmptyState(
Iconsax.IconsaxFolderOpen,
@ -106,7 +114,7 @@ internal fun LocalTracksContent(
Modifier.fillMaxSize().padding(bottom = inset),
contentAlignment = Alignment.Center
) {
MillenniumIndeterminateProgress()
MillenniumCircularProgress(size = 40.dp)
}
state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn(
@ -143,16 +151,35 @@ internal fun LocalTracksContent(
}
}
else -> MediaEmptyState(
Iconsax.IconsaxFolderOpen,
state.error ?: stringResource(
when {
state.query.isNotBlank() -> Res.string.library_local_no_results
state.currentFolderPath == null -> Res.string.library_local_empty_folders
else -> Res.string.library_local_empty_tracks
}
), Modifier.padding(bottom = inset)
)
else -> {
val noFolders = state.currentFolderPath == null && state.query.isBlank()
MediaEmptyState(
Iconsax.IconsaxFolderOpen,
state.error ?: stringResource(
when {
state.query.isNotBlank() -> Res.string.library_local_no_results
state.currentFolderPath == null -> Res.string.library_local_empty_folders
else -> Res.string.library_local_empty_tracks
}
), Modifier.padding(bottom = inset),
description = if (noFolders && state.error == null)
stringResource(Res.string.library_local_empty_folders_description) else null,
action = if (noFolders) ({
MillenniumButton(onClick = onManageFolders) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
MillenniumIcon(
Iconsax.IconsaxFolderAdd, null,
modifier = Modifier.size(18.dp)
)
Text(stringResource(Res.string.library_local_add_folder))
}
}
}) else null
)
}
}
}
}
@ -163,7 +190,8 @@ internal fun LocalTracksBreadcrumb(
state: LibraryLocalTracksState,
onNavigateUp: () -> Unit,
onPlayFolder: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onManageFolders: (() -> Unit)? = null
) {
val backDescription = stringResource(Res.string.library_local_back)
Row(
@ -237,7 +265,17 @@ internal fun LocalTracksBreadcrumb(
style = AppTheme.typography.labelSmall, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1
)
if (state.isRefreshing) MillenniumIndeterminateProgress(Modifier.size(14.dp))
if (state.isRefreshing) MillenniumSpokeSpinner(size = 14.dp)
if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton(
onClick = onManageFolders,
modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
Iconsax.IconsaxFolderAdd,
stringResource(Res.string.library_local_add_folder),
modifier = Modifier.size(14.dp)
)
}
if (state.currentFolderPath != null) MillenniumIconButton(
onClick = onPlayFolder, enabled = state.canPlayFolder,
modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet

View File

@ -18,6 +18,7 @@
package dev.krtirtho.spotube.modules.library.playlist
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
@ -154,34 +155,25 @@ fun AddToPlaylistPicker(
}
}
if (isLargeScreen) {
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
title = stringResource(Res.string.library_add_to_playlist_title),
modifier = modifier) {
Box(modifier = Modifier.fillMaxWidth().height(400.dp)) {
content()
}
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.common_cancel))
}
}
} else {
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
MillenniumModalBottomSheet(state = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
text = stringResource(Res.string.library_add_to_playlist_title),
style = AppTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp))
content()
val pickerTitle = stringResource(Res.string.library_add_to_playlist_title)
AdaptiveDialogBottomSheet(
onDismiss = onDismiss,
modifier = modifier,
dialogTitle = pickerTitle,
title = { Text(pickerTitle, style = AppTheme.typography.headlineSmall) },
actions = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End) {
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.common_cancel))
}
}
}) {
Box(modifier = Modifier.fillMaxWidth().height(400.dp)) {
content()
}
}
}

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.modules.library.playlist
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import androidx.compose.foundation.clickable
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
@ -162,90 +163,42 @@ fun PlaylistFormSheet(
val handleImagePick = { imagePicker.launch() }
if (isLargeScreen) {
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
title = stringResource(if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title),
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)) {
val formTitle = stringResource(
if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title)
AdaptiveDialogBottomSheet(
onDismiss = onDismiss,
modifier = modifier,
dialogTitle = formTitle,
title = { Text(formTitle, style = AppTheme.typography.headlineSmall) },
actions = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) {
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.common_cancel))
}
MillenniumButton(
onClick = {
onSubmit(currentData)
onDismiss()
},
enabled = name.isNotBlank()
) {
enabled = name.isNotBlank()) {
Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create))
}
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.common_cancel))
}
}
}
} else {
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
MillenniumModalBottomSheet(state = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = stringResource(if (isEditing) Res.string.library_playlist_form_edit_title else Res.string.library_playlist_form_create_title),
style = AppTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp)
)
PlaylistFormFields(
nameState = nameState,
descriptionState = descriptionState,
isPublic = isPublic,
onIsPublicChange = { isPublic = it },
isCollaborating = isCollaborating,
onIsCollaboratingChange = { isCollaborating = it },
imageBase64 = imageBase64,
imagePreviewUrl = imagePreviewUrl,
onPickImage = handleImagePick
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.common_cancel))
}
MillenniumButton(
onClick = {
onSubmit(currentData)
onDismiss()
},
enabled = name.isNotBlank()
) {
Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create))
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}) {
PlaylistFormFields(
nameState = nameState,
descriptionState = descriptionState,
isPublic = isPublic,
onIsPublicChange = { isPublic = it },
isCollaborating = isCollaborating,
onIsCollaboratingChange = { isCollaborating = it },
imageBase64 = imageBase64,
imagePreviewUrl = imagePreviewUrl,
onPickImage = handleImagePick)
}
}

View File

@ -18,10 +18,10 @@
package dev.krtirtho.spotube.modules.lyrics
import org.spotube.millennium.composables.MillenniumBarsLoader
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
@ -138,7 +138,7 @@ fun LyricsScreen(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
MillenniumIndeterminateProgress(modifier = Modifier.fillMaxWidth(0.4f))
MillenniumBarsLoader(size = 48.dp)
}
}

View File

@ -18,13 +18,15 @@
package dev.krtirtho.spotube.modules.playlist
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import org.spotube.millennium.composables.MillenniumCircularProgress
import org.spotube.millennium.composables.MillenniumSpokeSpinner
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
@ -169,7 +171,7 @@ fun AddTracksToPlaylistDialog(
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center) {
MillenniumIndeterminateProgress()
MillenniumCircularProgress(size = 40.dp)
}
}
hasSearched && searchResults.isEmpty() -> {
@ -209,33 +211,23 @@ fun AddTracksToPlaylistDialog(
}
}
if (isLargeScreen) {
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
title = stringResource(Res.string.playlist_add_tracks_title),
modifier = modifier) {
Box(modifier = Modifier.fillMaxWidth().height(500.dp)) {
content()
}
MillenniumButton(onClick = onDismiss) {
Text(stringResource(Res.string.playlist_add_tracks_done))
}
}
} else {
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
MillenniumModalBottomSheet(state = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(600.dp)) {
Text(
text = stringResource(Res.string.playlist_add_tracks_title),
style = AppTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp))
content()
val dialogTitle = stringResource(Res.string.playlist_add_tracks_title)
AdaptiveDialogBottomSheet(
onDismiss = onDismiss,
modifier = modifier,
dialogTitle = dialogTitle,
title = { Text(dialogTitle, style = AppTheme.typography.headlineSmall) },
actions = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End) {
MillenniumButton(onClick = onDismiss) {
Text(stringResource(Res.string.playlist_add_tracks_done))
}
}
}) {
Box(modifier = Modifier.fillMaxWidth().height(500.dp)) {
content()
}
}
}
@ -297,8 +289,7 @@ private fun TrackSearchResultRow(
modifier = Modifier.size(24.dp))
}
isAdding -> {
MillenniumIndeterminateProgress(
modifier = Modifier.size(24.dp))
MillenniumSpokeSpinner(size = 24.dp)
}
else -> {
MillenniumButton(

View File

@ -0,0 +1,80 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.plugin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.di.injectLogger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import org.koin.core.component.KoinComponent
data class MetadataAuthUiState(
/** True when the selected metadata plugin needs an account and the user isn't signed in. */
val loginRequired: Boolean = false,
val pluginName: String? = null)
/**
* Tells screens whether the selected metadata plugin is waiting for a login, so they can show a
* friendly sign-in prompt instead of surfacing the plugin's raw authorization exception.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MetadataAuthViewModel(
private val pluginManager: PluginManager) : ViewModel(), KoinComponent {
private val logger by injectLogger<MetadataAuthViewModel>()
val uiState: StateFlow<MetadataAuthUiState> = combine(
pluginManager.selectedMetadataPlugin.flatMapLatest { service ->
if (service == null) flowOf(false) else loginRequiredFlow(service)
},
pluginManager.state) { loginRequired, managerState ->
val name = (managerState as? PluginManagerStates.Data)
?.selectedPlugins?.get(PluginAbility.METADATA)?.name
MetadataAuthUiState(loginRequired = loginRequired, pluginName = name)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState())
private fun loginRequiredFlow(service: dev.krtirtho.spotube.core.zipline.PluginService): Flow<Boolean> = flow {
val requiresAuth = try {
service.use { coreAPI.requiresAuthentication }
} catch (e: Exception) {
if (e is CancellationException) throw e
false
}
if (!requiresAuth) {
emit(false)
} else {
service.loggedInFlow.collect { emit(!it) }
}
}
fun login() {
val service = pluginManager.selectedMetadataPlugin.value ?: return
pluginManager.launchTask {
runCatching { service.use { coreAPI.login() } }
.onFailure { logger.e(it) { "Metadata plugin login failed" } }
}
}
}

View File

@ -18,13 +18,15 @@
package dev.krtirtho.spotube.modules.plugin
import org.spotube.millennium.composables.MillenniumCircularProgress
import org.spotube.millennium.composables.MillenniumDotsLoader
import org.spotube.millennium.composables.MillenniumSpokeSpinner
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.MillenniumSegmentedButton
import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults
@ -85,6 +87,22 @@ import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.plugin.components.DefaultAbilityPluginGrid
import dev.krtirtho.spotube.modules.plugin.components.PluginCard
import dev.krtirtho.spotube.modules.plugin.components.PluginDialogActions
import dev.krtirtho.spotube.modules.plugin.components.PluginDialogBody
import dev.krtirtho.spotube.modules.plugin.components.PluginDialogTitle
import dev.krtirtho.spotube.modules.plugin.components.PluginIconTile
import dev.krtirtho.spotube.modules.plugin.components.PluginLogoTile
import dev.krtirtho.spotube.modules.plugin.components.PluginPanel
import dev.krtirtho.spotube.modules.plugin.components.PluginPill
import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd
import dev.krtirtho.spotube.resources.iconsax.User
import compose.icons.FeatherIcons
import compose.icons.feathericons.ChevronRight
import spotube.composeapp.generated.resources.plugin_install_sheet_subtitle
import spotube.composeapp.generated.resources.plugin_install_file_hint
import spotube.composeapp.generated.resources.plugin_install_or
import spotube.composeapp.generated.resources.plugin_info_dialog_title
import spotube.composeapp.generated.resources.plugin_version_label
import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog
import dev.krtirtho.spotube.modules.plugin.components.PluginLogo
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
@ -267,7 +285,7 @@ internal fun PluginScreenContent(
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp)) }
) { MillenniumCircularProgress(size = 40.dp) }
}
is PluginUiState.Data -> {
@ -492,7 +510,7 @@ internal fun PluginScreenContent(
Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
MillenniumIndeterminateProgress(Modifier.widthIn(max = 120.dp))
MillenniumDotsLoader()
}
}
}
@ -524,7 +542,7 @@ internal fun PluginScreenContent(
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 120.dp))
MillenniumDotsLoader()
}
}
}
@ -606,24 +624,17 @@ private fun InstallSheet(
AdaptiveDialogBottomSheet(
onDismiss = onDismiss,
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
MillenniumIcon(
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = AppTheme.colors.primary
)
Text(
stringResource(Res.string.plugin_install_section_title),
style = AppTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
}
}) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
PluginDialogTitle(
title = stringResource(Res.string.plugin_install_section_title),
icon = Iconsax.IconsaxBoxAdd)
},
dialogTitle = stringResource(Res.string.plugin_install_section_title)) {
PluginDialogBody {
Text(
stringResource(Res.string.plugin_install_sheet_subtitle),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
val urlState = rememberTextFieldState(urlInput)
LaunchedEffect(urlInput) {
if (urlState.text.toString() != urlInput) {
@ -636,51 +647,82 @@ private fun InstallSheet(
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
MillenniumTextField(
state = urlState,
label = stringResource(Res.string.plugin_section_url_title),
modifier = Modifier.weight(1f),
placeholder = stringResource(Res.string.plugin_url_placeholder),
error = urlError?.text())
MillenniumIconButton(
onClick = onSubmitUrl,
enabled = !isLoadingUrl,
style = MillenniumIconButtonStyle.Secondary) {
if (isLoadingUrl) {
MillenniumIndeterminateProgress(modifier = Modifier.size(16.dp))
} else {
MillenniumIcon(
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = stringResource(Res.string.plugin_action_download))
PluginPanel {
Column(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PluginIconTile(Iconsax.IconsaxLink)
Text(
stringResource(Res.string.plugin_section_url_title),
style = AppTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold)
}
MillenniumTextField(
state = urlState,
label = "",
modifier = Modifier.fillMaxWidth(),
placeholder = stringResource(Res.string.plugin_url_placeholder),
error = urlError?.text())
MillenniumButton(
onClick = onSubmitUrl,
enabled = !isLoadingUrl,
modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Primary) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (isLoadingUrl) {
MillenniumSpokeSpinner(size = 16.dp, color = MillenniumTheme.colors.onAccent)
} else {
MillenniumIcon(
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = null,
modifier = Modifier.size(18.dp))
}
Text(stringResource(Res.string.plugin_action_download))
}
}
}
}
MillenniumSeparator()
Text(
stringResource(Res.string.plugin_section_file_title),
style = AppTheme.typography.labelLarge,
color = AppTheme.colors.onSurfaceVariant
)
MillenniumButton(
onClick = onPickFile,
Row(
modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Secondary) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumSeparator(modifier = Modifier.weight(1f))
Text(
stringResource(Res.string.plugin_install_or),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant)
MillenniumSeparator(modifier = Modifier.weight(1f))
}
PluginPanel(modifier = Modifier.clickable(onClick = onPickFile)) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PluginIconTile(Iconsax.IconsaxExportArrowBulk)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
stringResource(Res.string.plugin_action_install_from_file),
style = AppTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold)
Text(
stringResource(Res.string.plugin_install_file_hint),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant)
}
MillenniumIcon(
Iconsax.IconsaxExportArrowBulk,
contentDescription = stringResource(Res.string.plugin_action_install_from_file)
)
Text(stringResource(Res.string.plugin_action_install_from_file))
FeatherIcons.ChevronRight,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = AppTheme.colors.onSurfaceVariant)
}
}
}
@ -692,84 +734,82 @@ private fun PluginInfoDialog(
plugin: PluginEntry,
logoPath: Path?,
onDismiss: () -> Unit) {
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
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
)
AdaptiveDialogBottomSheet(
onDismiss = onDismiss,
title = { PluginDialogTitle(stringResource(Res.string.plugin_info_dialog_title)) },
dialogTitle = stringResource(Res.string.plugin_info_dialog_title),
actions = {
PluginDialogActions(
secondaryLabel = stringResource(Res.string.common_close),
onSecondary = onDismiss)
}) {
PluginDialogBody {
PluginPanel {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)) {
PluginLogoTile(plugin = plugin, logoPath = logoPath)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
plugin.name,
style = AppTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
PluginPill(plugin.author, icon = Iconsax.User)
PluginPill(
stringResource(Res.string.plugin_version_label, plugin.version),
tint = AppTheme.colors.primary)
}
}
})
}
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
if (plugin.description.isNotBlank()) {
Text(
plugin.description,
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurface
)
}
DetailRow(stringResource(Res.string.plugin_info_version), plugin.version)
DetailRow(stringResource(Res.string.plugin_info_api_version), plugin.apiVersion)
DetailRow(stringResource(Res.string.plugin_info_author), plugin.author)
if (plugin.license.isNotBlank()) {
DetailRow(stringResource(Res.string.plugin_info_license), plugin.license)
}
if (plugin.capabilities.isNotEmpty()) {
DetailChipsRow(
stringResource(Res.string.plugin_info_capabilities),
plugin.capabilities.map { it.name })
}
if (plugin.abilities.isNotEmpty()) {
DetailChipsRow(
stringResource(Res.string.plugin_info_abilities),
plugin.abilities.map { ability -> ability.displayLabel() })
}
if (plugin.repository.isNotBlank()) {
ClickableDetailRow(label = stringResource(Res.string.plugin_info_repository), value = plugin.repository) {
openUrlInBrowser(plugin.repository)
}
if (plugin.description.isNotBlank()) {
Text(
plugin.description,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
}
}
if (plugin.contact.isNotBlank()) {
DetailRow(stringResource(Res.string.plugin_info_contact), plugin.contact)
}
if (plugin.bugs.isNotBlank()) {
ClickableDetailRow(label = stringResource(Res.string.plugin_info_report_bugs), value = plugin.bugs) {
openUrlInBrowser(plugin.bugs)
PluginPanel {
Column(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)) {
DetailRow(stringResource(Res.string.plugin_info_api_version), plugin.apiVersion)
if (plugin.license.isNotBlank()) {
DetailRow(stringResource(Res.string.plugin_info_license), plugin.license)
}
if (plugin.capabilities.isNotEmpty()) {
DetailChipsRow(
stringResource(Res.string.plugin_info_capabilities),
plugin.capabilities.map { it.name })
}
if (plugin.abilities.isNotEmpty()) {
DetailChipsRow(
stringResource(Res.string.plugin_info_abilities),
plugin.abilities.map { ability -> ability.displayLabel() })
}
if (plugin.repository.isNotBlank()) {
ClickableDetailRow(label = stringResource(Res.string.plugin_info_repository), value = plugin.repository) {
openUrlInBrowser(plugin.repository)
}
}
if (plugin.contact.isNotBlank()) {
DetailRow(stringResource(Res.string.plugin_info_contact), plugin.contact)
}
if (plugin.bugs.isNotBlank()) {
ClickableDetailRow(label = stringResource(Res.string.plugin_info_report_bugs), value = plugin.bugs) {
openUrlInBrowser(plugin.bugs)
}
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton(onClick = onDismiss) {
Text(stringResource(Res.string.common_close))
}
}
}
}
@ -779,27 +819,30 @@ private fun SupportDialog(
supportText: String?,
isLoading: Boolean,
onDismiss: () -> Unit) {
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
title = stringResource(Res.string.plugin_support_dialog_title, plugin.name)) {
if (isLoading) {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
contentAlignment = Alignment.Center
) {
MillenniumIndeterminateProgress(modifier = Modifier.widthIn(max = 160.dp))
}
} else {
Text(
supportText ?: "",
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurface
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton(onClick = onDismiss) {
Text(stringResource(Res.string.common_close))
AdaptiveDialogBottomSheet(
onDismiss = onDismiss,
title = { PluginDialogTitle(stringResource(Res.string.plugin_support_dialog_title, plugin.name)) },
dialogTitle = stringResource(Res.string.plugin_support_dialog_title, plugin.name),
actions = {
PluginDialogActions(
secondaryLabel = stringResource(Res.string.common_close),
onSecondary = onDismiss)
}) {
PluginDialogBody {
if (isLoading) {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
contentAlignment = Alignment.Center) {
MillenniumCircularProgress(size = 36.dp)
}
} else {
PluginPanel {
Text(
supportText ?: "",
modifier = Modifier.padding(14.dp),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurface)
}
}
}
}
@ -972,7 +1015,7 @@ private fun DiscoverRepoCard(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (isInstalling) {
MillenniumIndeterminateProgress(modifier = Modifier.size(16.dp))
MillenniumSpokeSpinner(size = 16.dp)
} else {
MillenniumIcon(
Iconsax.IconsaxAdd,

View File

@ -0,0 +1,249 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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.ColumnScope
import androidx.compose.foundation.layout.Row
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.plugin.PluginEntry
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
import okio.Path
import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.millenniumPanelBrush
import org.spotube.millennium.theme.millenniumSurface
// Shared building blocks for the plugin dialogs/sheets so they all read as one family and
// match the panel/tile language used by the plugin cards and the rest of the app.
/** Body column for plugin dialogs; the adaptive dialog/sheet around it handles scrolling. */
@Composable
internal fun PluginDialogBody(content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
content = content)
}
/** Title for the phone bottom sheet; the desktop dialog shows the same text in its titlebar. */
@Composable
internal fun PluginDialogTitle(
title: String,
icon: ImageVector? = null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (icon != null) {
PluginIconTile(icon, size = 40.dp)
}
Text(
title,
style = AppTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = AppTheme.colors.onSurface)
}
}
/** Rounded, softly bordered panel — the same surface the plugin cards use. */
@Composable
internal fun PluginPanel(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = modifier
.fillMaxWidth()
.millenniumSurface(
background = millenniumPanelBrush(MillenniumTheme.colors),
shape = RoundedCornerShape(14.dp),
borderColor = AppTheme.colors.outlineVariant,
borderWidth = 1.dp),
content = content)
}
@Composable
internal fun PluginIconTile(
icon: ImageVector,
modifier: Modifier = Modifier,
size: Dp = 38.dp,
tint: Color = AppTheme.colors.primary) {
Box(
modifier = modifier
.size(size)
.clip(RoundedCornerShape(size / 3.4f))
.background(tint.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center) {
MillenniumIcon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(size * 0.5f),
tint = tint)
}
}
@Composable
internal fun PluginLogoTile(
plugin: PluginEntry,
logoPath: Path?,
size: Dp = 56.dp) {
Box(
modifier = Modifier
.size(size)
.clip(RoundedCornerShape(size / 3.4f))
.background(AppTheme.colors.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center) {
PluginLogo(
plugin = plugin,
logoPath = logoPath,
contentDescription = plugin.name,
modifier = Modifier.fillMaxSize(),
fallback = {
MillenniumIcon(
imageVector = Iconsax.IconsaxBoxAdd,
contentDescription = null,
tint = AppTheme.colors.primary,
modifier = Modifier.size(size * 0.5f))
})
}
}
@Composable
internal fun PluginPill(
text: String,
icon: ImageVector? = null,
tint: Color = AppTheme.colors.onSurfaceVariant) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(tint.copy(alpha = 0.12f))
.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (icon != null) {
MillenniumIcon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = tint)
}
Text(
text,
style = AppTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
color = tint,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
}
}
/** Tinted info strip; [isWarning] switches it to the error tone. */
@Composable
internal fun PluginCallout(text: String, isWarning: Boolean = false) {
val tint = if (isWarning) AppTheme.colors.error else AppTheme.colors.primary
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(tint.copy(alpha = 0.10f))
.padding(12.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumIcon(
imageVector = Iconsax.IconsaxInformation,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = tint)
Text(
text,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurface)
}
}
@Composable
internal fun PluginSectionLabel(text: String, badge: String? = null) {
Row(
modifier = Modifier.padding(horizontal = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text,
style = AppTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = AppTheme.colors.onSurface)
if (badge != null) {
PluginPill(badge, tint = AppTheme.colors.primary)
}
}
}
/** Equal-width secondary + primary action pair. */
@Composable
internal fun PluginDialogActions(
secondaryLabel: String,
onSecondary: () -> Unit,
primaryLabel: String? = null,
onPrimary: (() -> Unit)? = null,
primaryEnabled: Boolean = true) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton(
onClick = onSecondary,
modifier = Modifier.weight(1f),
style = if (primaryLabel != null) MillenniumButtonStyle.Secondary else MillenniumButtonStyle.Primary) {
Text(secondaryLabel)
}
if (primaryLabel != null && onPrimary != null) {
MillenniumButton(
onClick = onPrimary,
enabled = primaryEnabled,
modifier = Modifier.weight(1f),
style = MillenniumButtonStyle.Primary) {
Text(primaryLabel)
}
}
}
}

View File

@ -17,7 +17,7 @@
package dev.krtirtho.spotube.modules.plugin.components
import androidx.compose.foundation.background
import org.spotube.millennium.composables.MillenniumCircularProgress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -32,10 +32,6 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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
@ -52,19 +48,22 @@ import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import coil3.request.crossfade
import dev.krtirtho.spotube.modules.plugin.GitHubRepo
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.plugin.GitHubRelease
import dev.krtirtho.spotube.modules.plugin.GitHubRepo
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
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.MillenniumDropdownMenu
import org.spotube.millennium.composables.MillenniumDropdownMenuContent
import org.spotube.millennium.composables.MillenniumDropdownMenuItem
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_cancel
import spotube.composeapp.generated.resources.plugin_action_install
import spotube.composeapp.generated.resources.plugin_install_no_releases
import spotube.composeapp.generated.resources.plugin_install_prerelease
@ -88,21 +87,24 @@ fun PluginInstallDialog(
}
}
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
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))
) {
Box(contentAlignment = Alignment.Center) {
AdaptiveDialogBottomSheet(
onDismiss = onDismiss,
title = { PluginDialogTitle(stringResource(Res.string.plugin_action_install)) },
dialogTitle = stringResource(Res.string.plugin_action_install),
actions = {
PluginDialogActions(
secondaryLabel = stringResource(Res.string.common_cancel),
onSecondary = onDismiss,
primaryLabel = stringResource(Res.string.plugin_action_install),
onPrimary = { selectedRelease?.let(onInstall) },
primaryEnabled = selectedRelease != null && !isLoadingReleases)
}) {
PluginDialogBody {
PluginPanel {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)) {
val platformContext = LocalPlatformContext.current
AsyncImage(
model = ImageRequest.Builder(platformContext)
@ -110,131 +112,99 @@ fun PluginInstallDialog(
.crossfade(true)
.build(),
contentDescription = repo.owner.login,
modifier = Modifier.fillMaxSize()
)
}
}
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.description,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.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(
stringResource(Res.string.plugin_install_no_releases),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant
)
} else {
Text(
stringResource(Res.string.plugin_install_select_release_label),
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
) {
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(16.dp)))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
repo.fullName,
style = AppTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
if (!repo.description.isNullOrBlank()) {
Text(
selectedRelease?.tagName
?: stringResource(Res.string.plugin_install_select_release_placeholder),
style = AppTheme.typography.bodyMedium
)
MillenniumIcon(
imageVector = Iconsax.IconsaxArrowDown4,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
}
}
)
selectedRelease?.let { release ->
if (release.prerelease) {
Text(
stringResource(Res.string.plugin_install_prerelease),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.error
)
}
if (!release.body.isNullOrBlank()) {
Text(
stringResource(Res.string.plugin_install_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,
repo.description,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant
)
color = AppTheme.colors.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis)
}
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton(
onClick = { selectedRelease?.let { onInstall(it) } },
enabled = selectedRelease != null && !isLoadingReleases
) {
Text(stringResource(Res.string.plugin_action_install))
if (isLoadingReleases) {
Box(
modifier = Modifier.fillMaxWidth().height(96.dp),
contentAlignment = Alignment.Center) {
MillenniumCircularProgress(size = 36.dp)
}
} else if (releases.isEmpty()) {
PluginCallout(stringResource(Res.string.plugin_install_no_releases), isWarning = true)
} else {
PluginSectionLabel(stringResource(Res.string.plugin_install_select_release_label))
MillenniumDropdownMenu(
expanded = expanded,
onExpandedChange = { expanded = it },
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
?: stringResource(Res.string.plugin_install_select_release_placeholder),
style = AppTheme.typography.bodyMedium)
MillenniumIcon(
imageVector = Iconsax.IconsaxArrowDown4,
contentDescription = null,
modifier = Modifier.size(16.dp))
}
}
}) {
MillenniumDropdownMenuContent {
releases.forEach { release ->
MillenniumDropdownMenuItem(
label = release.tagName,
selected = release.tagName == selectedRelease?.tagName,
description = if (release.prerelease) {
stringResource(Res.string.plugin_install_prerelease)
} else null,
onClick = {
selectedRelease = release
expanded = false
})
}
}
}
selectedRelease?.let { release ->
if (release.prerelease) {
PluginCallout(stringResource(Res.string.plugin_install_prerelease), isWarning = true)
}
if (!release.body.isNullOrBlank()) {
PluginSectionLabel(stringResource(Res.string.plugin_install_release_notes))
PluginPanel {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
.verticalScroll(rememberScrollState())
.padding(12.dp)) {
Text(
release.body,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
}
}
}
}
}
}
}

View File

@ -17,47 +17,39 @@
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
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 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.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import okio.Path
import com.composeunstyled.Text
import compose.icons.FeatherIcons
import compose.icons.feathericons.ArrowRight
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.plugin.PluginCapability
import dev.krtirtho.spotube.modules.plugin.PluginEntry
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxEye
import dev.krtirtho.spotube.resources.iconsax.IconsaxWifiSquare
import dev.krtirtho.spotube.resources.iconsax.PhosphorDatabaseDuotone
import dev.krtirtho.spotube.resources.iconsax.User
import okio.Path
import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumSeparator
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_cancel
import spotube.composeapp.generated.resources.common_close
import spotube.composeapp.generated.resources.plugin_permissions_api_diff
import spotube.composeapp.generated.resources.plugin_permissions_author_version
import spotube.composeapp.generated.resources.plugin_permissions_capability_network_desc
import spotube.composeapp.generated.resources.plugin_permissions_capability_network_title
import spotube.composeapp.generated.resources.plugin_permissions_capability_storage_desc
@ -70,8 +62,6 @@ import spotube.composeapp.generated.resources.plugin_permissions_none
import spotube.composeapp.generated.resources.plugin_permissions_requested_title
import spotube.composeapp.generated.resources.plugin_permissions_supplied_label
import spotube.composeapp.generated.resources.plugin_version_label
import spotube.composeapp.generated.resources.common_cancel
import spotube.composeapp.generated.resources.common_close
@Composable
fun PluginPermissionDialog(
@ -83,129 +73,80 @@ fun PluginPermissionDialog(
logoPath: Path? = null,
onConfirm: (() -> Unit)? = null,
onDismiss: () -> Unit) {
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
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
) {
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)
) {
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)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) {
Text(
if (confirmLabel == null) {
stringResource(Res.string.common_close)
} else {
stringResource(Res.string.common_cancel)
AdaptiveDialogBottomSheet(
onDismiss = onDismiss,
title = { PluginDialogTitle(title) },
dialogTitle = title,
actions = {
PluginDialogActions(
secondaryLabel = stringResource(
if (confirmLabel == null) Res.string.common_close else Res.string.common_cancel),
onSecondary = onDismiss,
primaryLabel = confirmLabel,
onPrimary = onConfirm)
}) {
PluginDialogBody {
PluginPanel {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)) {
PluginLogoTile(plugin = pluginInfo, logoPath = logoPath)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
pluginInfo.name,
style = AppTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
PluginPill(pluginInfo.author, icon = Iconsax.User)
PluginPill(
stringResource(Res.string.plugin_version_label, pluginInfo.version),
tint = AppTheme.colors.primary)
}
}
)
}
if (pluginInfo.description.isNotBlank()) {
Text(
pluginInfo.description,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis)
}
}
if (confirmLabel != null && onConfirm != null) {
MillenniumButton(onClick = onConfirm) {
Text(confirmLabel)
PluginCallout(text = message, isWarning = confirmLabel == null)
existingPlugin?.let {
InstalledComparisonPanel(installedPlugin = it, incomingPlugin = pluginInfo)
}
PluginSectionLabel(
stringResource(Res.string.plugin_permissions_requested_title),
badge = pluginInfo.capabilities.size.takeIf { it > 0 }?.toString())
PluginPanel {
if (pluginInfo.capabilities.isEmpty()) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PluginIconTile(Iconsax.IconsaxCheckCircle)
Text(
stringResource(Res.string.plugin_permissions_none),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
}
} else {
pluginInfo.capabilities.forEachIndexed { index, capability ->
if (index > 0) MillenniumSeparator()
CapabilityRow(capability)
}
}
}
}
@ -213,57 +154,58 @@ fun PluginPermissionDialog(
}
@Composable
private fun InstalledComparisonCard(
private fun InstalledComparisonPanel(
installedPlugin: PluginEntry,
incomingPlugin: PluginEntry) {
MillenniumCard(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(14.dp)) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(Res.string.plugin_permissions_compare_title),
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)
VersionStat(
label = stringResource(Res.string.plugin_permissions_supplied_label),
value = incomingPlugin.version)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
PluginSectionLabel(stringResource(Res.string.plugin_permissions_compare_title))
PluginPanel {
Column(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly) {
VersionStat(
label = stringResource(Res.string.plugin_permissions_installed_label),
value = installedPlugin.version)
MillenniumIcon(
imageVector = FeatherIcons.ArrowRight,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = AppTheme.colors.onSurfaceVariant)
VersionStat(
label = stringResource(Res.string.plugin_permissions_supplied_label),
value = incomingPlugin.version,
highlighted = true)
}
Text(
stringResource(
Res.string.plugin_permissions_api_diff,
installedPlugin.apiVersion,
incomingPlugin.apiVersion),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant)
}
Text(
stringResource(
Res.string.plugin_permissions_api_diff,
installedPlugin.apiVersion,
incomingPlugin.apiVersion
),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
}
}
}
@Composable
private fun VersionStat(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
private fun VersionStat(label: String, value: String, highlighted: Boolean = false) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
label,
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
color = AppTheme.colors.onSurfaceVariant)
Text(
stringResource(Res.string.plugin_version_label, value),
style = AppTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
style = AppTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = if (highlighted) AppTheme.colors.primary else AppTheme.colors.onSurface)
}
}
@ -273,54 +215,31 @@ private fun CapabilityRow(capability: PluginCapability) {
PluginCapability.PERSISTENT_STORAGE -> Triple(
Iconsax.PhosphorDatabaseDuotone,
stringResource(Res.string.plugin_permissions_capability_storage_title),
stringResource(Res.string.plugin_permissions_capability_storage_desc)
)
stringResource(Res.string.plugin_permissions_capability_storage_desc))
PluginCapability.NETWORK_REQUESTS -> Triple(
Iconsax.IconsaxWifiSquare,
stringResource(Res.string.plugin_permissions_capability_network_title),
stringResource(Res.string.plugin_permissions_capability_network_desc)
)
stringResource(Res.string.plugin_permissions_capability_network_desc))
PluginCapability.WEBVIEW -> Triple(
Iconsax.IconsaxEye,
stringResource(Res.string.plugin_permissions_capability_webview_title),
stringResource(Res.string.plugin_permissions_capability_webview_desc)
)
stringResource(Res.string.plugin_permissions_capability_webview_desc))
}
MillenniumCard(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
.background(AppTheme.colors.errorContainer.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
MillenniumIcon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = AppTheme.colors.error
)
}
Column {
Text(
label,
style = AppTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
description,
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PluginIconTile(icon)
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(
label,
style = AppTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium)
Text(
description,
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant)
}
}
}

View File

@ -0,0 +1,269 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.plugin.guard
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.LoginRequiredDisplay
import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
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.theme.MillenniumTheme
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.no_metadata_plugin_action_install
import spotube.composeapp.generated.resources.no_metadata_plugin_action_local
import spotube.composeapp.generated.resources.no_metadata_plugin_local_hint
import spotube.composeapp.generated.resources.no_metadata_plugin_action_select
import spotube.composeapp.generated.resources.no_metadata_plugin_description_install
import spotube.composeapp.generated.resources.no_metadata_plugin_description_select
import spotube.composeapp.generated.resources.no_metadata_plugin_step_discover
import spotube.composeapp.generated.resources.no_metadata_plugin_step_install
import spotube.composeapp.generated.resources.no_metadata_plugin_step_select
import spotube.composeapp.generated.resources.no_metadata_plugin_title
/**
* Renders [content] only while a metadata plugin is selected. Otherwise shows a guided
* "no plugin" state with a call to action leading to the plugin manager, instead of letting
* the screen sit on an endless loading skeleton.
*
* @param backButton whether the placeholder's top bar shows a back button (for pushed screens).
* @param requireLogin also swap [content] for a friendly sign-in prompt while the plugin needs an
* account and the user isn't signed in (for screens that only make sense when logged in).
* @param showTopBar false when the host screen already draws its own app bar (e.g. Library tabs).
*/
@Composable
fun MetadataPluginGuard(
backButton: Boolean = false,
showTopBar: Boolean = true,
requireLogin: Boolean = false,
viewModel: MetadataPluginGuardViewModel = koinViewModel(),
authViewModel: MetadataAuthViewModel = koinViewModel(),
content: @Composable () -> Unit
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val authState by authViewModel.uiState.collectAsStateWithLifecycle()
when (val s = state) {
is MetadataPluginGuardState.Available ->
if (requireLogin && authState.loginRequired) {
Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) {
if (showTopBar) ApplicationMainBar(backButton = backButton)
Box(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = LocalAppShellBottomInset.current),
contentAlignment = Alignment.Center) {
LoginRequiredDisplay(
pluginName = authState.pluginName,
onLogin = authViewModel::login,
modifier = Modifier.widthIn(max = 420.dp))
}
}
} else {
content()
}
is MetadataPluginGuardState.Loading -> Box(
Modifier.fillMaxSize().background(MillenniumTheme.colors.background))
is MetadataPluginGuardState.Missing -> NoMetadataPluginScreen(
hasInstalledPlugins = s.hasInstalledPlugins,
backButton = backButton,
showTopBar = showTopBar,
onOpenPlugins = viewModel::openPlugins,
onOpenLocalTracks = viewModel::openLocalTracks)
}
}
@Composable
private fun NoMetadataPluginScreen(
hasInstalledPlugins: Boolean,
backButton: Boolean,
showTopBar: Boolean,
onOpenPlugins: () -> Unit,
onOpenLocalTracks: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MillenniumTheme.colors.background)) {
if (showTopBar) ApplicationMainBar(backButton = backButton)
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(bottom = LocalAppShellBottomInset.current),
contentAlignment = Alignment.Center) {
Column(
modifier = Modifier
.widthIn(max = 420.dp)
.padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)) {
HeroBadge()
Text(
text = stringResource(Res.string.no_metadata_plugin_title),
style = AppTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = AppTheme.colors.onSurface,
textAlign = TextAlign.Center)
Text(
text = stringResource(
if (hasInstalledPlugins) Res.string.no_metadata_plugin_description_select
else Res.string.no_metadata_plugin_description_install),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant,
textAlign = TextAlign.Center)
if (!hasInstalledPlugins) {
MillenniumCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Step(1, Res.string.no_metadata_plugin_step_discover)
Step(2, Res.string.no_metadata_plugin_step_install)
Step(3, Res.string.no_metadata_plugin_step_select)
}
}
}
MillenniumButton(
onClick = onOpenPlugins,
style = MillenniumButtonStyle.Primary) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumIcon(
if (hasInstalledPlugins) Iconsax.IconsaxCheckCircle else Iconsax.IconsaxAdd,
contentDescription = null,
modifier = Modifier.size(18.dp))
Text(
stringResource(
if (hasInstalledPlugins) Res.string.no_metadata_plugin_action_select
else Res.string.no_metadata_plugin_action_install))
}
}
MillenniumButton(
onClick = onOpenLocalTracks,
style = MillenniumButtonStyle.Secondary) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumIcon(
Iconsax.IconsaxMusicLibrary,
contentDescription = null,
modifier = Modifier.size(18.dp))
Text(stringResource(Res.string.no_metadata_plugin_action_local))
}
}
Text(
text = stringResource(Res.string.no_metadata_plugin_local_hint),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant,
textAlign = TextAlign.Center)
}
}
}
}
@Composable
private fun HeroBadge() {
val primary = AppTheme.colors.primary
Box(
modifier = Modifier
.size(104.dp)
.clip(CircleShape)
.background(Brush.radialGradient(listOf(primary.copy(alpha = 0.22f), primary.copy(alpha = 0f)))),
contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(20.dp))
.background(primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center) {
MillenniumIcon(
Iconsax.IconsaxBoxAdd,
contentDescription = null,
modifier = Modifier.size(36.dp),
tint = primary)
}
}
}
@Composable
private fun Step(index: Int, label: StringResource) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
modifier = Modifier
.size(26.dp)
.clip(CircleShape)
.background(AppTheme.colors.primary.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center) {
Text(
text = index.toString(),
style = AppTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = AppTheme.colors.primary)
}
Text(
text = stringResource(label),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurface)
}
}

View File

@ -0,0 +1,75 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.plugin.guard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.plugin.PluginAbility
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginManagerStates
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
sealed interface MetadataPluginGuardState {
data object Loading : MetadataPluginGuardState
data object Available : MetadataPluginGuardState
/** No metadata plugin is selected. [hasInstalledPlugins] tells whether the user only needs
* to pick one (true) or has to install one first (false). */
data class Missing(val hasInstalledPlugins: Boolean) : MetadataPluginGuardState
}
class MetadataPluginGuardViewModel(
pluginManager: PluginManager,
private val navigationCommands: NavigationCommands,
private val libraryState: LibraryState
) : ViewModel() {
val uiState: StateFlow<MetadataPluginGuardState> = pluginManager.state
.map { state ->
when (state) {
is PluginManagerStates.Loading -> MetadataPluginGuardState.Loading
is PluginManagerStates.Data ->
if (PluginAbility.METADATA in state.selectedPlugins) {
MetadataPluginGuardState.Available
} else {
MetadataPluginGuardState.Missing(
hasInstalledPlugins = state.plugins.any { PluginAbility.METADATA in it.abilities })
}
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MetadataPluginGuardState.Loading)
fun openPlugins() {
navigationCommands.navigateTo(Routes.Plugins)
}
/** Local tracks are a plugin-free player, so offer them as the way forward. */
fun openLocalTracks() {
libraryState.onTabSelected(LibraryTab.LocalTracks)
navigationCommands.navigateTo(Routes.Library)
}
}

View File

@ -18,10 +18,10 @@
package dev.krtirtho.spotube.modules.search
import org.spotube.millennium.composables.MillenniumMarqueeProgress
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.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumAutocomplete
import org.spotube.millennium.composables.MillenniumText
@ -1155,7 +1155,7 @@ private fun SearchLoadingIndicator(
translationY = bounce
})
}
MillenniumIndeterminateProgress(
MillenniumMarqueeProgress(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 16.dp)

View File

@ -18,10 +18,10 @@
package dev.krtirtho.spotube.modules.settings
import org.spotube.millennium.composables.MillenniumCircularProgress
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.MillenniumSidebarItem
import org.spotube.millennium.theme.MillenniumTheme
@ -288,7 +288,7 @@ private fun SettingsTabContent(
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center) {
MillenniumIndeterminateProgress()
MillenniumCircularProgress(size = 40.dp)
}
} else {
Box(modifier = Modifier.fillMaxSize()) {

View File

@ -18,6 +18,12 @@
package dev.krtirtho.spotube.modules.settings.components
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogFooter
import org.spotube.millennium.composables.MillenniumDialogDescription
import androidx.compose.foundation.layout.Box
import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints
@ -238,26 +244,30 @@ internal fun TextInputSettingCard(
val normalizedValue = normalize(draft)
val errorMessage = validate(normalizedValue)
MillenniumDialog(
visible = true,
onDismissRequest = { isDialogOpen = false },
title = dialogTitle
) {
if (dialogDescription != null) {
Text(
text = dialogDescription,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant
MillenniumDialog(visible = true, onDismissRequest = { isDialogOpen = false }) {
MillenniumDialogHeader {
MillenniumDialogTitle(dialogTitle)
MillenniumDialogClose()
}
MillenniumDialogBody {
if (dialogDescription != null) {
MillenniumDialogDescription(dialogDescription)
}
MillenniumTextField(
state = dialogState,
label = title,
modifier = Modifier.fillMaxWidth(),
placeholder = placeholder,
error = errorMessage
)
}
MillenniumTextField(
state = dialogState,
label = title,
modifier = Modifier.fillMaxWidth(),
placeholder = placeholder,
error = errorMessage
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumDialogFooter {
MillenniumButton(
onClick = { isDialogOpen = false },
style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.common_cancel))
}
MillenniumButton(
onClick = {
if (errorMessage == null) {
@ -269,12 +279,6 @@ internal fun TextInputSettingCard(
) {
Text(stringResource(Res.string.common_save))
}
MillenniumButton(
onClick = { isDialogOpen = false },
style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.common_cancel))
}
}
}
}

View File

@ -18,6 +18,12 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel
import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher
import io.github.vinceglb.filekit.path
import org.spotube.millennium.composables.MillenniumDialog
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
@ -255,3 +261,24 @@ private fun LocalMediaFolderEditorRow(
}
}
}
/** Renders the "manage local folders" dialog (folder picker included) while [viewModel] has it open.
* Shared by Settings and the Local Tracks screen; callers trigger it via [LocalMediaFoldersViewModel.open]. */
@Composable
fun LocalMediaFoldersDialogHost(viewModel: LocalMediaFoldersViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val pickerLauncher =
rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) }
if (state.isOpen) {
MillenniumDialog(
visible = true,
onDismissRequest = viewModel::dismiss,
title = stringResource(Res.string.settings_local_media_folders_title)
) {
LocalMediaFoldersEditorContent(
state, { pickerLauncher.launch() }, viewModel::removeFolder,
viewModel::save, viewModel::dismiss
)
}
}
}

View File

@ -17,6 +17,11 @@
package dev.krtirtho.spotube.modules.settings.sections
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogFooter
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -151,10 +156,12 @@ private fun AppearanceSettingCard(
mutableStateOf(selectedPalette)
}
MillenniumDialog(
visible = true,
onDismissRequest = { isDialogOpen = false },
title = stringResource(Res.string.settings_palette_dialog_title)) {
MillenniumDialog(visible = true, onDismissRequest = { isDialogOpen = false }) {
MillenniumDialogHeader {
MillenniumDialogTitle(stringResource(Res.string.settings_palette_dialog_title))
MillenniumDialogClose()
}
MillenniumDialogBody {
MillenniumText(
stringResource(Res.string.settings_palette_dialog_description),
color = AppTheme.colors.onSurfaceVariant)
@ -174,9 +181,13 @@ private fun AppearanceSettingCard(
}
PalettePreview(draftPalette)
MillenniumSeparator()
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
}
MillenniumDialogFooter {
MillenniumButton(
onClick = { isDialogOpen = false },
style = MillenniumButtonStyle.Secondary) {
MillenniumText(stringResource(Res.string.common_cancel))
}
MillenniumButton(
onClick = {
onSaved(draftPalette)
@ -184,11 +195,6 @@ private fun AppearanceSettingCard(
}) {
MillenniumText(stringResource(Res.string.common_save))
}
MillenniumButton(
onClick = { isDialogOpen = false },
style = MillenniumButtonStyle.Secondary) {
MillenniumText(stringResource(Res.string.common_cancel))
}
}
}
}

View File

@ -20,7 +20,6 @@ package dev.krtirtho.spotube.modules.settings.sections
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.lazy.LazyListScope
@ -31,8 +30,7 @@ import androidx.compose.runtime.getValue
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel
import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersUiState
import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersEditorContent
import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersDialogHost
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
@ -185,8 +183,6 @@ private fun LocalMediaFoldersSettingCard(
viewModel: LocalMediaFoldersViewModel = koinViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val pickerLauncher =
rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) }
val subtitlePreview = state.singleConfiguredPath ?: if (state.configuredCount == 0) {
stringResource(Res.string.settings_local_media_folders_subtitle_empty)
} else stringResource(
@ -214,30 +210,5 @@ private fun LocalMediaFoldersSettingCard(
onClick = viewModel::open
)
if (state.isOpen) {
LocalMediaFoldersDialog(
state = state,
onAddFolder = { pickerLauncher.launch() },
onRemoveFolder = viewModel::removeFolder,
onSave = viewModel::save,
onDismiss = viewModel::dismiss
)
}
}
@Composable
private fun LocalMediaFoldersDialog(
state: LocalMediaFoldersUiState,
onAddFolder: () -> Unit,
onRemoveFolder: (String) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit
) {
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
title = stringResource(Res.string.settings_local_media_folders_title)
) {
LocalMediaFoldersEditorContent(state, onAddFolder, onRemoveFolder, onSave, onDismiss)
}
LocalMediaFoldersDialogHost(viewModel)
}

View File

@ -289,7 +289,8 @@ fun AppExpandedPlayer(
if (playerOptionsUiState.isMoreOptionsSheetOpen) {
MillenniumModalBottomSheet(
state = moreOptionsSheetState,
contentPadding = PaddingValues(horizontal = 0.dp, vertical = 8.dp)
contentPadding = PaddingValues(horizontal = 0.dp, vertical = 8.dp),
contentSpacing = 4.dp
) {
// Grid of Options
LazyVerticalGrid(

View File

@ -18,6 +18,10 @@
package dev.krtirtho.spotube.modules.shell
import org.spotube.millennium.composables.MillenniumDropdownMenuItem
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxMoon
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -436,27 +440,6 @@ fun AppLargePlayer(
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))
}
}
sleepTimerLabel -> {
playerOptionsViewModel.dismissMoreOptionsMenu()
playerOptionsViewModel.showSleepTimerDialog()
}
trackDetailsLabel -> {
playerOptionsViewModel.dismissMoreOptionsMenu()
playerOptionsViewModel.showTrackDetails()
}
}
},
side = AnchorSide.Top,
anchor = {
MillenniumIconButton(
@ -471,7 +454,31 @@ fun AppLargePlayer(
contentDescription = stringResource(Res.string.player_more_options_content_description)
)
}
})
}) {
MillenniumDropdownMenuItem(
label = goToAlbumLabel,
icon = Iconsax.IconsaxCd,
onClick = {
playerOptionsViewModel.dismissMoreOptionsMenu()
currentTrack?.album?.id?.let { albumId ->
navigationCommands.navigateTo(Routes.Album(albumId))
}
})
MillenniumDropdownMenuItem(
label = sleepTimerLabel,
icon = Iconsax.IconsaxMoon,
onClick = {
playerOptionsViewModel.dismissMoreOptionsMenu()
playerOptionsViewModel.showSleepTimerDialog()
})
MillenniumDropdownMenuItem(
label = trackDetailsLabel,
icon = Iconsax.IconsaxInformation,
onClick = {
playerOptionsViewModel.dismissMoreOptionsMenu()
playerOptionsViewModel.showTrackDetails()
})
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
MillenniumIconButton(

View File

@ -21,6 +21,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -166,7 +167,11 @@ fun AppSidebar(
}
}
}
MillenniumSidebarFooter {
// Compact the footer chrome only (padding + gap between items); items keep their size.
MillenniumSidebarFooter(
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 4.dp),
contentSpacing = 2.dp
) {
val supportLabel = stringResource(Res.string.support_button_label)
MillenniumSidebarItem(
label = supportLabel,

View File

@ -18,11 +18,16 @@
package dev.krtirtho.spotube.modules.shell
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogFooter
import org.spotube.millennium.composables.MillenniumMarqueeProgress
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
@ -128,11 +133,12 @@ private fun SleepTimerDialog(
onStart: () -> Unit,
onCancelTimer: () -> Unit,
onDismiss: () -> Unit) {
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
title = stringResource(Res.string.player_sleep_timer)) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
MillenniumDialog(visible = true, onDismissRequest = onDismiss) {
MillenniumDialogHeader {
MillenniumDialogTitle(stringResource(Res.string.player_sleep_timer))
MillenniumDialogClose()
}
MillenniumDialogBody {
if (remainingMs != null) {
Text(
text = stringResource(Res.string.player_sleep_timer_remaining, formatRemainingTime(remainingMs)),
@ -152,21 +158,20 @@ private fun SleepTimerDialog(
label = stringResource(Res.string.player_sleep_timer_minutes_label),
placeholder = stringResource(Res.string.player_sleep_timer_minutes_placeholder),
modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (remainingMs != null) {
MillenniumButton(
onClick = onCancelTimer,
style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.player_sleep_timer_cancel)) }
}
}
MillenniumDialogFooter {
if (remainingMs != null) {
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) }
MillenniumButton(
onClick = onStart,
enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) {
Text(stringResource(Res.string.player_sleep_timer_start))
}
onClick = onCancelTimer,
style = MillenniumButtonStyle.Quiet) { Text(stringResource(Res.string.player_sleep_timer_cancel)) }
}
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) }
MillenniumButton(
onClick = onStart,
enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true) {
Text(stringResource(Res.string.player_sleep_timer_start))
}
}
}
@ -176,11 +181,12 @@ private fun SleepTimerDialog(
private fun TrackDetailsDialog(
details: PlayerTrackDetails,
onDismiss: () -> Unit) {
MillenniumDialog(
visible = true,
onDismissRequest = onDismiss,
title = stringResource(Res.string.player_track_details)) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
MillenniumDialog(visible = true, onDismissRequest = onDismiss) {
MillenniumDialogHeader {
MillenniumDialogTitle(stringResource(Res.string.player_track_details))
MillenniumDialogClose()
}
MillenniumDialogBody(contentSpacing = 14.dp) {
Text(
details.track.title,
style = AppTheme.typography.bodyMedium,
@ -189,8 +195,7 @@ private fun TrackDetailsDialog(
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp),
horizontalArrangement = Arrangement.Center) {
MillenniumIndeterminateProgress(
modifier = Modifier.fillMaxWidth())
MillenniumMarqueeProgress(modifier = Modifier.fillMaxWidth())
}
}
@ -315,6 +320,8 @@ private fun TrackDetailsDialog(
DetailRow(stringResource(Res.string.player_track_details_active_url), activeUrl)
}
}
MillenniumDialogFooter {
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) { Text(stringResource(Res.string.common_close)) }

View File

@ -18,8 +18,8 @@
package dev.krtirtho.spotube.modules.shell.alternative_track
import org.spotube.millennium.composables.MillenniumSpokeSpinner
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
@ -98,8 +98,7 @@ fun AlternativeTrackContent(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically) {
MillenniumIndeterminateProgress(
modifier = Modifier.size(24.dp))
MillenniumSpokeSpinner(size = 24.dp)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(Res.string.alternative_track_searching),

View File

@ -18,6 +18,7 @@
package dev.krtirtho.spotube.modules.shell.player_queue
import org.spotube.millennium.composables.MillenniumDropdownMenuItem
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -419,11 +420,6 @@ private fun QueueItemRow(
MillenniumDropdownMenu(
expanded = showMenu,
onExpandedChange = { showMenu = it },
items = listOf(stringResource(Res.string.queue_remove_from_queue)),
onItemClick = {
onRemoveClick()
showMenu = false
},
anchor = {
MillenniumIconButton(
onClick = { showMenu = true },
@ -434,7 +430,16 @@ private fun QueueItemRow(
contentDescription = stringResource(Res.string.player_more_options_content_description)
)
}
})
}) {
MillenniumDropdownMenuItem(
label = stringResource(Res.string.queue_remove_from_queue),
icon = Iconsax.IconsaxMusicSquareRemove,
destructive = true,
onClick = {
onRemoveClick()
showMenu = false
})
}
}
}
)

View File

@ -99,7 +99,8 @@ internal fun BottomSheetContent(
}
MillenniumModalBottomSheet(
state = sheetState,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
contentSpacing = 8.dp
) {
content()
}

View File

@ -17,6 +17,12 @@
package dev.krtirtho.spotube.modules.support
import org.spotube.millennium.composables.MillenniumDialogHeader
import org.spotube.millennium.composables.MillenniumDialogTitle
import org.spotube.millennium.composables.MillenniumDialogClose
import org.spotube.millennium.composables.MillenniumDialogBody
import org.spotube.millennium.composables.MillenniumDialogDescription
import org.spotube.millennium.composables.MillenniumDialogFooter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@ -103,49 +109,50 @@ fun SupportDialog(
onDismissRequest: () -> Unit,
isReminder: Boolean = false) {
if (!visible) return
MillenniumDialog(
visible = true,
onDismissRequest = onDismissRequest,
title = stringResource(Res.string.support_dialog_title)) {
Text(
stringResource(Res.string.support_dialog_message),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant)
if (isReminder) {
Text(
stringResource(Res.string.support_dialog_reminder_hint),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
MillenniumDialog(visible = true, onDismissRequest = onDismissRequest) {
MillenniumDialogHeader {
MillenniumDialogTitle(stringResource(Res.string.support_dialog_title))
MillenniumDialogClose()
}
donationOptions.forEach { option ->
MillenniumButton(
onClick = {
openDonation(option)
onDismissRequest()
},
modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Secondary) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumIcon(
imageVector = option.icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (option.id == "patreon") {
AppTheme.colors.onSurface
} else {
Color.Unspecified
})
Text(stringResource(option.title))
MillenniumDialogBody {
MillenniumDialogDescription(stringResource(Res.string.support_dialog_message))
if (isReminder) {
Text(
stringResource(Res.string.support_dialog_reminder_hint),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
}
donationOptions.forEach { option ->
MillenniumButton(
onClick = {
openDonation(option)
onDismissRequest()
},
modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Secondary) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumIcon(
imageVector = option.icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (option.id == "patreon") {
AppTheme.colors.onSurface
} else {
Color.Unspecified
})
Text(stringResource(option.title))
}
}
}
}
MillenniumButton(
onClick = onDismissRequest,
modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.support_dialog_dismiss))
MillenniumDialogFooter {
MillenniumButton(
onClick = onDismissRequest,
style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.support_dialog_dismiss))
}
}
}
}

View File

@ -17,19 +17,14 @@
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 com.composeunstyled.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.composeunstyled.Text
import com.mikepenz.markdown.m3.Markdown
import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumAlertDialog
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
@ -47,28 +42,26 @@ fun AppUpdateDialog(
onDismiss: () -> Unit,
onIgnore: () -> Unit,
onUpdate: () -> Unit) {
MillenniumDialog(
MillenniumAlertDialog(
visible = true,
onDismissRequest = onDismiss,
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))
title = { Text(stringResource(Res.string.app_update_dialog_title, update.tag)) },
content = {
if (update.releaseNotesMarkdown.isNotBlank()) {
SelectionContainer {
Markdown(content = update.releaseNotesMarkdown)
}
} else {
Text(stringResource(Res.string.app_update_dialog_no_notes))
}
} else {
Text(stringResource(Res.string.app_update_dialog_no_notes))
}) {
MillenniumButton(
onClick = onIgnore,
style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.update_action_ignore))
}
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))
}
MillenniumButton(onClick = onUpdate) {
Text(stringResource(Res.string.update_action_update))
}
}
}

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.modules.update
import org.spotube.millennium.composables.MillenniumAlertDialog
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@ -30,7 +31,6 @@ import com.mikepenz.markdown.m3.Markdown
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
@ -50,33 +50,35 @@ fun PluginUpdateDialog(
onDismiss: () -> Unit,
onIgnore: () -> Unit,
onUpdate: () -> Unit) {
MillenniumDialog(
MillenniumAlertDialog(
visible = true,
onDismissRequest = onDismiss,
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),
color = AppTheme.colors.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp))
if (update.info.releaseNotes.isNotBlank()) {
SelectionContainer {
Markdown(content = update.info.releaseNotes)
title = {
Text(
stringResource(
Res.string.plugin_update_dialog_title,
update.pluginName,
update.info.latestVersion))
},
description = {
Text(stringResource(Res.string.plugin_update_dialog_current_version, update.currentVersion))
},
content = {
if (update.info.releaseNotes.isNotBlank()) {
SelectionContainer {
Markdown(content = update.info.releaseNotes)
}
} else {
Text(stringResource(Res.string.plugin_update_dialog_no_notes))
}
} else {
Text(stringResource(Res.string.plugin_update_dialog_no_notes))
}) {
MillenniumButton(
onClick = onIgnore,
style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.update_action_ignore))
}
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))
}
MillenniumButton(onClick = onUpdate) {
Text(stringResource(Res.string.update_action_update))
}
}
}