From daf54b74f41c03c93ac1ba8845f843fd3d427cb1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 09:29:08 +0600 Subject: [PATCH] feat(plugin): implement MetadataPluginGuard to manage plugin dependencies and user prompts --- composeApp/build.gradle.kts | 8 + .../composeResources/values/strings.xml | 12 + .../dev/krtirtho/spotube/core/di/Modules.kt | 2 + .../core/navigation/NavigationModule.kt | 86 +++--- .../spotube/modules/library/LibraryScreen.kt | 21 +- .../local_tracks/LibraryLocalTracksScreen.kt | 67 +++-- .../plugin/guard/MetadataPluginGuard.kt | 244 ++++++++++++++++++ .../guard/MetadataPluginGuardViewModel.kt | 75 ++++++ .../components/LocalMediaFoldersEditor.kt | 27 ++ .../settings/sections/DownloadsSection.kt | 33 +-- 10 files changed, 486 insertions(+), 89 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 750ed2c6..1e1af578 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 62a749a7..296ddb3f 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -68,6 +68,8 @@ Allow audio access to browse music on your device. Grant access No local folders found + Add a folder with music to play it right here. No plugin needed. + Add folder No tracks in this folder No matching folders or tracks Failed to refresh local media @@ -263,6 +265,16 @@ Failed to download plugin No plugins installed yet Paste a download URL or pick a file above. + No metadata plugin selected + Spotube needs a metadata plugin to browse, search and show your music. Install one to get started. + You already have plugins installed, but none is set as your metadata source. Pick one to start browsing. + Discover a metadata plugin + Review its permissions and install it + Select it as your metadata source + Install a plugin + Choose a plugin + Play local music + Your local tracks and downloads work without a plugin. %1$d plugin installed %1$d plugins installed diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 769c08ff..e5bcc686 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -70,6 +70,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 @@ -138,6 +139,7 @@ val sharedModules = module { bind() } viewModelOf(::PluginViewModel) + viewModelOf(::MetadataPluginGuardViewModel) // Settings singleOf(::SettingsRepository) { bind() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index 4fc959dc..88bdeaef 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -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 { - HomeScreen(viewModel = koinViewModel()) + MetadataPluginGuard { + HomeScreen(viewModel = koinViewModel()) + } } navigation { - SearchScreen() + MetadataPluginGuard { + SearchScreen() + } } navigation { + // Not guarded as a whole: Local tracks and Downloads work without a plugin. LibraryScreen() } navigation { @@ -117,49 +123,57 @@ val navigationModule = module { } navigation {} navigation { - 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 { - 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 { - 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 { LyricsScreen(viewModel = koinViewModel()) } navigation { - 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 { BlacklistScreen() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt index 4b7b24af..ae863f1f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/LibraryScreen.kt @@ -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) { + LibraryPlaylistsScreen( + showCreatePlaylist = state.showCreatePlaylist, + onDismissCreatePlaylist = libraryState::dismissCreatePlaylist, + viewMode = viewMode + ) + } - LibraryTab.Albums -> LibraryAlbumsScreen(viewMode = viewMode) - LibraryTab.Artists -> LibraryArtistsScreen(viewMode = viewMode) + LibraryTab.Albums -> MetadataPluginGuard(showTopBar = false) { + LibraryAlbumsScreen(viewMode = viewMode) + } + LibraryTab.Artists -> MetadataPluginGuard(showTopBar = false) { + LibraryArtistsScreen(viewMode = viewMode) + } LibraryTab.LocalTracks -> LibraryLocalTracksScreen() LibraryTab.Downloads -> DownloadsScreen() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt index 1cd80768..c2f3b405 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/library/local_tracks/LibraryLocalTracksScreen.kt @@ -36,6 +36,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 @@ -58,7 +61,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 +70,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 +84,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, @@ -143,16 +150,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 +189,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( @@ -238,6 +265,16 @@ internal fun LocalTracksBreadcrumb( maxLines = 1 ) if (state.isRefreshing) MillenniumIndeterminateProgress(Modifier.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 diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt new file mode 100644 index 00000000..d5ffc44d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuard.kt @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.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.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 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, + viewModel: MetadataPluginGuardViewModel = koinViewModel(), + content: @Composable () -> Unit +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + when (val s = state) { + is MetadataPluginGuardState.Available -> 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) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt new file mode 100644 index 00000000..f9346ef2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/guard/MetadataPluginGuardViewModel.kt @@ -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 . + */ + +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 = 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) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt index ea4b2b5c..1e3ae674 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/components/LocalMediaFoldersEditor.kt @@ -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 + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt index a4656eea..6e2b5dd8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/DownloadsSection.kt @@ -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) }