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