mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(plugin): implement MetadataPluginGuard to manage plugin dependencies and user prompts
This commit is contained in:
parent
2bef59ee76
commit
daf54b74f4
@ -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)
|
||||
|
||||
@ -68,6 +68,8 @@
|
||||
<string name="library_local_permission_description">Allow audio access to browse music on your device.</string>
|
||||
<string name="library_local_grant_access">Grant access</string>
|
||||
<string name="library_local_empty_folders">No local folders found</string>
|
||||
<string name="library_local_empty_folders_description">Add a folder with music to play it right here. No plugin needed.</string>
|
||||
<string name="library_local_add_folder">Add folder</string>
|
||||
<string name="library_local_empty_tracks">No tracks in this folder</string>
|
||||
<string name="library_local_no_results">No matching folders or tracks</string>
|
||||
<string name="library_local_refresh_failed">Failed to refresh local media</string>
|
||||
@ -263,6 +265,16 @@
|
||||
<string name="plugin_error_download_failed">Failed to download plugin</string>
|
||||
<string name="plugin_empty_title">No plugins installed yet</string>
|
||||
<string name="plugin_empty_subtitle">Paste a download URL or pick a file above.</string>
|
||||
<string name="no_metadata_plugin_title">No metadata plugin selected</string>
|
||||
<string name="no_metadata_plugin_description_install">Spotube needs a metadata plugin to browse, search and show your music. Install one to get started.</string>
|
||||
<string name="no_metadata_plugin_description_select">You already have plugins installed, but none is set as your metadata source. Pick one to start browsing.</string>
|
||||
<string name="no_metadata_plugin_step_discover">Discover a metadata plugin</string>
|
||||
<string name="no_metadata_plugin_step_install">Review its permissions and install it</string>
|
||||
<string name="no_metadata_plugin_step_select">Select it as your metadata source</string>
|
||||
<string name="no_metadata_plugin_action_install">Install a plugin</string>
|
||||
<string name="no_metadata_plugin_action_select">Choose a plugin</string>
|
||||
<string name="no_metadata_plugin_action_local">Play local music</string>
|
||||
<string name="no_metadata_plugin_local_hint">Your local tracks and downloads work without a plugin.</string>
|
||||
<plurals name="plugin_installed_plugins_count">
|
||||
<item quantity="one">%1$d plugin installed</item>
|
||||
<item quantity="other">%1$d plugins installed</item>
|
||||
|
||||
@ -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<ScrobblePluginSource>()
|
||||
}
|
||||
viewModelOf(::PluginViewModel)
|
||||
viewModelOf(::MetadataPluginGuardViewModel)
|
||||
|
||||
// Settings
|
||||
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
||||
|
||||
@ -29,6 +29,7 @@ import dev.krtirtho.spotube.modules.library.LibraryScreen
|
||||
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
||||
import dev.krtirtho.spotube.modules.playlist.PlaylistScreen
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginScreen
|
||||
import dev.krtirtho.spotube.modules.plugin.guard.MetadataPluginGuard
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
|
||||
import dev.krtirtho.spotube.modules.search.SearchScreen
|
||||
@ -101,12 +102,17 @@ sealed interface Routes : NavKey {
|
||||
val navigationModule = module {
|
||||
singleOf(::NavigationCommands)
|
||||
navigation<Routes.Home> {
|
||||
HomeScreen(viewModel = koinViewModel())
|
||||
MetadataPluginGuard {
|
||||
HomeScreen(viewModel = koinViewModel())
|
||||
}
|
||||
}
|
||||
navigation<Routes.Search> {
|
||||
SearchScreen()
|
||||
MetadataPluginGuard {
|
||||
SearchScreen()
|
||||
}
|
||||
}
|
||||
navigation<Routes.Library> {
|
||||
// Not guarded as a whole: Local tracks and Downloads work without a plugin.
|
||||
LibraryScreen()
|
||||
}
|
||||
navigation<Routes.Settings> {
|
||||
@ -117,49 +123,57 @@ val navigationModule = module {
|
||||
}
|
||||
navigation<Routes.WebView> {}
|
||||
navigation<Routes.Playlist> {
|
||||
PlaylistScreen(
|
||||
playlistId = it.playlistId,
|
||||
viewModel = koinViewModel(
|
||||
key = it.playlistId,
|
||||
parameters = { parametersOf(it.playlistId) }
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get())
|
||||
MetadataPluginGuard(backButton = true) {
|
||||
PlaylistScreen(
|
||||
playlistId = it.playlistId,
|
||||
viewModel = koinViewModel(
|
||||
key = it.playlistId,
|
||||
parameters = { parametersOf(it.playlistId) }
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get())
|
||||
}
|
||||
}
|
||||
navigation<Routes.Artist> {
|
||||
ArtistScreen(
|
||||
viewModel = koinViewModel(
|
||||
key = it.artistId,
|
||||
parameters = { parametersOf(it.artistId) }
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get())
|
||||
MetadataPluginGuard(backButton = true) {
|
||||
ArtistScreen(
|
||||
viewModel = koinViewModel(
|
||||
key = it.artistId,
|
||||
parameters = { parametersOf(it.artistId) }
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get())
|
||||
}
|
||||
}
|
||||
navigation<Routes.Album> {
|
||||
AlbumScreen(
|
||||
albumId = it.albumId,
|
||||
viewModel = koinViewModel(
|
||||
key = it.albumId,
|
||||
parameters = { parametersOf(it.albumId) }
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get())
|
||||
MetadataPluginGuard(backButton = true) {
|
||||
AlbumScreen(
|
||||
albumId = it.albumId,
|
||||
viewModel = koinViewModel(
|
||||
key = it.albumId,
|
||||
parameters = { parametersOf(it.albumId) }
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get())
|
||||
}
|
||||
}
|
||||
navigation<Routes.Lyrics> {
|
||||
LyricsScreen(viewModel = koinViewModel())
|
||||
}
|
||||
navigation<Routes.SavedTracks> {
|
||||
SavedTracksScreen(
|
||||
viewModel = koinViewModel(
|
||||
key = SAVED_TRACKS_COLLECTION_ID,
|
||||
parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) }
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get())
|
||||
MetadataPluginGuard(backButton = true) {
|
||||
SavedTracksScreen(
|
||||
viewModel = koinViewModel(
|
||||
key = SAVED_TRACKS_COLLECTION_ID,
|
||||
parameters = { parametersOf(SAVED_TRACKS_COLLECTION_ID) }
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get())
|
||||
}
|
||||
}
|
||||
navigation<Routes.Blacklist> {
|
||||
BlacklistScreen()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user