feat(plugin): implement MetadataPluginGuard to manage plugin dependencies and user prompts

This commit is contained in:
Kingkor Roy Tirtho 2026-10-16 09:29:08 +06:00
parent 2bef59ee76
commit daf54b74f4
10 changed files with 486 additions and 89 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 { dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs) coreLibraryDesugaring(libs.desugar.jdk.libs)
debugImplementation(libs.compose.uiTooling) debugImplementation(libs.compose.uiTooling)

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_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_grant_access">Grant access</string>
<string name="library_local_empty_folders">No local folders found</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_empty_tracks">No tracks in this folder</string>
<string name="library_local_no_results">No matching folders or tracks</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> <string name="library_local_refresh_failed">Failed to refresh local media</string>
@ -263,6 +265,16 @@
<string name="plugin_error_download_failed">Failed to download plugin</string> <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_title">No plugins installed yet</string>
<string name="plugin_empty_subtitle">Paste a download URL or pick a file above.</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"> <plurals name="plugin_installed_plugins_count">
<item quantity="one">%1$d plugin installed</item> <item quantity="one">%1$d plugin installed</item>
<item quantity="other">%1$d plugins installed</item> <item quantity="other">%1$d plugins installed</item>

View File

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

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

View File

@ -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.component.cards.PlayableListTile
import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState 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.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen
@ -58,7 +61,8 @@ import spotube.composeapp.generated.resources.*
@Composable @Composable
fun LibraryLocalTracksScreen( fun LibraryLocalTracksScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LibraryLocalTracksViewModel = koinViewModel() viewModel: LibraryLocalTracksViewModel = koinViewModel(),
foldersViewModel: LocalMediaFoldersViewModel = koinViewModel()
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val permission = rememberLocalMediaPermissionState() val permission = rememberLocalMediaPermissionState()
@ -66,8 +70,10 @@ fun LibraryLocalTracksScreen(
LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) } LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) }
LocalTracksContent( LocalTracksContent(
state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp, state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp,
viewModel::playCurrentFolder, permission.requestPermission, modifier viewModel::playCurrentFolder, permission.requestPermission, modifier,
onManageFolders = foldersViewModel::open
) )
LocalMediaFoldersDialogHost(foldersViewModel)
} }
@Composable @Composable
@ -78,14 +84,15 @@ internal fun LocalTracksContent(
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
onPlayFolder: () -> Unit, onPlayFolder: () -> Unit,
onRequestPermission: () -> Unit, onRequestPermission: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
onManageFolders: () -> Unit = {}
) { ) {
val inset = LocalAppShellBottomInset.current val inset = LocalAppShellBottomInset.current
Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
Column( Column(
Modifier.widthIn(max = 960.dp).fillMaxSize() Modifier.widthIn(max = 960.dp).fillMaxSize()
) { ) {
LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder) LocalTracksBreadcrumb(state, onNavigateUp, onPlayFolder, onManageFolders = onManageFolders)
when { when {
!state.canAccessMedia -> MediaEmptyState( !state.canAccessMedia -> MediaEmptyState(
Iconsax.IconsaxFolderOpen, Iconsax.IconsaxFolderOpen,
@ -143,16 +150,35 @@ internal fun LocalTracksContent(
} }
} }
else -> MediaEmptyState( else -> {
Iconsax.IconsaxFolderOpen, val noFolders = state.currentFolderPath == null && state.query.isBlank()
state.error ?: stringResource( MediaEmptyState(
when { Iconsax.IconsaxFolderOpen,
state.query.isNotBlank() -> Res.string.library_local_no_results state.error ?: stringResource(
state.currentFolderPath == null -> Res.string.library_local_empty_folders when {
else -> Res.string.library_local_empty_tracks state.query.isNotBlank() -> Res.string.library_local_no_results
} state.currentFolderPath == null -> Res.string.library_local_empty_folders
), Modifier.padding(bottom = inset) 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, state: LibraryLocalTracksState,
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
onPlayFolder: () -> Unit, onPlayFolder: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
onManageFolders: (() -> Unit)? = null
) { ) {
val backDescription = stringResource(Res.string.library_local_back) val backDescription = stringResource(Res.string.library_local_back)
Row( Row(
@ -238,6 +265,16 @@ internal fun LocalTracksBreadcrumb(
maxLines = 1 maxLines = 1
) )
if (state.isRefreshing) MillenniumIndeterminateProgress(Modifier.size(14.dp)) 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( if (state.currentFolderPath != null) MillenniumIconButton(
onClick = onPlayFolder, enabled = state.canPlayFolder, onClick = onPlayFolder, enabled = state.canPlayFolder,
modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet

View File

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

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,6 +18,12 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor 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

@ -20,7 +20,6 @@ package dev.krtirtho.spotube.modules.settings.sections
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.lazy.LazyListScope 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.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersViewModel
import dev.krtirtho.spotube.modules.settings.LocalMediaFoldersUiState import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersDialogHost
import dev.krtirtho.spotube.modules.settings.components.LocalMediaFoldersEditorContent
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard import dev.krtirtho.spotube.modules.settings.components.SelectionSettingCard
@ -185,8 +183,6 @@ private fun LocalMediaFoldersSettingCard(
viewModel: LocalMediaFoldersViewModel = koinViewModel() viewModel: LocalMediaFoldersViewModel = koinViewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val pickerLauncher =
rememberDirectoryPickerLauncher { directory -> viewModel.addFolder(directory?.path) }
val subtitlePreview = state.singleConfiguredPath ?: if (state.configuredCount == 0) { val subtitlePreview = state.singleConfiguredPath ?: if (state.configuredCount == 0) {
stringResource(Res.string.settings_local_media_folders_subtitle_empty) stringResource(Res.string.settings_local_media_folders_subtitle_empty)
} else stringResource( } else stringResource(
@ -214,30 +210,5 @@ private fun LocalMediaFoldersSettingCard(
onClick = viewModel::open onClick = viewModel::open
) )
if (state.isOpen) { LocalMediaFoldersDialogHost(viewModel)
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)
}
} }