diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a98cb2e0..66531e38 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -630,6 +630,12 @@ User Playlist + + Sign in to continue + This part of Spotube needs you to be signed in with your music provider. Sign in once and everything will show up here. + %1$s needs you to sign in before it can show this. Sign in once and everything will show up here. + Sign in + We\'re Truly Sorry Something isn\'t working right now. Your experience matters to us, and we\'re doing our best to fix this. Please try again or check the error details below. 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 e5bcc686..b31bc300 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 @@ -54,6 +54,7 @@ import dev.krtirtho.spotube.modules.downloads.DownloadManager import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.home.HomeScreenRepository import dev.krtirtho.spotube.modules.home.HomeScreenViewModel +import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsViewModel @@ -116,6 +117,7 @@ val sharedModules = module { // Home singleOf(::HomeScreenRepository) viewModelOf(::HomeScreenViewModel) + viewModelOf(::MetadataAuthViewModel) // Search singleOf(::SearchRepository) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt index 9a7dc563..2b35c5b6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ErrorDisplay.kt @@ -50,6 +50,9 @@ import dev.krtirtho.spotube.core.ui.theming.AppTheme import com.composeunstyled.Text import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel +import org.koin.compose.viewmodel.koinViewModel import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -77,7 +80,16 @@ import spotube.composeapp.generated.resources.* fun ErrorDisplay( errorMessage: String, onRetry: () -> Unit, - modifier: Modifier = Modifier) { + modifier: Modifier = Modifier, + authViewModel: MetadataAuthViewModel = koinViewModel()) { + val authState by authViewModel.uiState.collectAsStateWithLifecycle() + if (authState.loginRequired) { + LoginRequiredDisplay( + pluginName = authState.pluginName, + onLogin = authViewModel::login, + modifier = modifier) + return + } var showFullLog by remember { mutableStateOf(false) } val clipboardManager = LocalClipboardManager.current @@ -242,16 +254,6 @@ private fun ErrorLogDialog( } } -@Preview -@Composable -private fun ErrorDisplayPreview() { - Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) { - ErrorDisplay( - errorMessage = "This is an error message", - onRetry = {}) - } -} - @Preview @Composable private fun ErrorLogDialogPreview() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LoginRequiredDisplay.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LoginRequiredDisplay.kt new file mode 100644 index 00000000..59ab53e0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/LoginRequiredDisplay.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.Lock +import compose.icons.feathericons.LogIn +import dev.krtirtho.spotube.core.ui.theming.AppTheme +import org.jetbrains.compose.resources.stringResource +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumIcon +import spotube.composeapp.generated.resources.* + +/** Friendly "sign in first" state for screens whose metadata plugin needs an account. */ +@Composable +fun LoginRequiredDisplay( + pluginName: String?, + onLogin: () -> Unit, + modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp)) { + Box( + modifier = Modifier + .size(88.dp) + .clip(CircleShape) + .background(AppTheme.colors.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center) { + MillenniumIcon( + imageVector = FeatherIcons.Lock, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = AppTheme.colors.primary) + } + + Text( + text = stringResource(Res.string.login_required_title), + style = AppTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = AppTheme.colors.onSurface, + textAlign = TextAlign.Center) + + Text( + text = if (pluginName.isNullOrBlank()) { + stringResource(Res.string.login_required_description) + } else { + stringResource(Res.string.login_required_description_plugin, pluginName) + }, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center) + + MillenniumButton( + onClick = onLogin, + modifier = Modifier.height(44.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + MillenniumIcon( + imageVector = FeatherIcons.LogIn, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp).size(18.dp)) + Text(stringResource(Res.string.login_required_action)) + } + } + } +} 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 ae863f1f..f3b75d96 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 @@ -117,7 +117,7 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { } Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { when (state.currentTab) { - LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false) { + LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { LibraryPlaylistsScreen( showCreatePlaylist = state.showCreatePlaylist, onDismissCreatePlaylist = libraryState::dismissCreatePlaylist, @@ -125,10 +125,10 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) { ) } - LibraryTab.Albums -> MetadataPluginGuard(showTopBar = false) { + LibraryTab.Albums -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { LibraryAlbumsScreen(viewMode = viewMode) } - LibraryTab.Artists -> MetadataPluginGuard(showTopBar = false) { + LibraryTab.Artists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { LibraryArtistsScreen(viewMode = viewMode) } LibraryTab.LocalTracks -> LibraryLocalTracksScreen() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/MetadataAuthViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/MetadataAuthViewModel.kt new file mode 100644 index 00000000..1d7db0c4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/MetadataAuthViewModel.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.plugin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.core.di.injectLogger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import org.koin.core.component.KoinComponent + +data class MetadataAuthUiState( + /** True when the selected metadata plugin needs an account and the user isn't signed in. */ + val loginRequired: Boolean = false, + val pluginName: String? = null) + +/** + * Tells screens whether the selected metadata plugin is waiting for a login, so they can show a + * friendly sign-in prompt instead of surfacing the plugin's raw authorization exception. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MetadataAuthViewModel( + private val pluginManager: PluginManager) : ViewModel(), KoinComponent { + private val logger by injectLogger() + + val uiState: StateFlow = combine( + pluginManager.selectedMetadataPlugin.flatMapLatest { service -> + if (service == null) flowOf(false) else loginRequiredFlow(service) + }, + pluginManager.state) { loginRequired, managerState -> + val name = (managerState as? PluginManagerStates.Data) + ?.selectedPlugins?.get(PluginAbility.METADATA)?.name + MetadataAuthUiState(loginRequired = loginRequired, pluginName = name) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState()) + + private fun loginRequiredFlow(service: dev.krtirtho.spotube.core.zipline.PluginService): Flow = flow { + val requiresAuth = try { + service.use { coreAPI.requiresAuthentication } + } catch (e: Exception) { + if (e is CancellationException) throw e + false + } + if (!requiresAuth) { + emit(false) + } else { + service.loggedInFlow.collect { emit(!it) } + } + } + + fun login() { + val service = pluginManager.selectedMetadataPlugin.value ?: return + pluginManager.launchTask { + runCatching { service.use { coreAPI.login() } } + .onFailure { logger.e(it) { "Metadata plugin login failed" } } + } + } +} 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 index d5ffc44d..3c501b4a 100644 --- 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 @@ -43,6 +43,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.composeunstyled.Text import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.core.ui.component.LoginRequiredDisplay +import dev.krtirtho.spotube.modules.plugin.MetadataAuthViewModel import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -76,19 +78,42 @@ import spotube.composeapp.generated.resources.no_metadata_plugin_title * the screen sit on an endless loading skeleton. * * @param backButton whether the placeholder's top bar shows a back button (for pushed screens). + * @param requireLogin also swap [content] for a friendly sign-in prompt while the plugin needs an + * account and the user isn't signed in (for screens that only make sense when logged in). * @param showTopBar false when the host screen already draws its own app bar (e.g. Library tabs). */ @Composable fun MetadataPluginGuard( backButton: Boolean = false, showTopBar: Boolean = true, + requireLogin: Boolean = false, viewModel: MetadataPluginGuardViewModel = koinViewModel(), + authViewModel: MetadataAuthViewModel = koinViewModel(), content: @Composable () -> Unit ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val authState by authViewModel.uiState.collectAsStateWithLifecycle() when (val s = state) { - is MetadataPluginGuardState.Available -> content() + is MetadataPluginGuardState.Available -> + if (requireLogin && authState.loginRequired) { + Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) { + if (showTopBar) ApplicationMainBar(backButton = backButton) + Box( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = LocalAppShellBottomInset.current), + contentAlignment = Alignment.Center) { + LoginRequiredDisplay( + pluginName = authState.pluginName, + onLogin = authViewModel::login, + modifier = Modifier.widthIn(max = 420.dp)) + } + } + } else { + content() + } is MetadataPluginGuardState.Loading -> Box( Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) is MetadataPluginGuardState.Missing -> NoMetadataPluginScreen(