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(