diff --git a/composeApp/src/commonMain/composeResources/drawable/spotube_logo.png b/composeApp/src/commonMain/composeResources/drawable/spotube_logo.png
new file mode 100644
index 00000000..d46d9ce5
Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/spotube_logo.png differ
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index dcddd6af..b636db4f 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -202,8 +202,98 @@
Ignore
Update
Spotube %1$s is available
- See what\'s new in this release on the Spotube downloads page.
+ See what’s new in this release on the Spotube downloads page.
%1$s %2$s is available
Currently installed: %1$s
- See what\'s new in this update from the plugin\'s page.
+ See what’s new in this update from the plugin’s page.
+
+ Skip
+ Next
+ Back
+ Get Started
+ Spotube
+ Music, without the strings.
+ An open-source player for a world of music.\nYours to explore. Yours to make your own.
+ Keep music free.
+ Spotube is built by people, not a subscription business. If it becomes part of your everyday life, a little support helps us give it the care it deserves.\n\nThank you for making room for independent software.
+ Start with a catalog plugin to find songs and playlists. Choose an audio plugin to play them. Lyrics and listening history are optional. Discover a plugin below, review its permissions, install it, then select it here.
+ Discover plugins
+ Your sources
+ Try again
+ More plugins
+ No new plugins found. You can also install one from a link or a .smplug file above.
+ Plugin discovery is unavailable right now. Try again, or install from a link or file.
+ A familiar player. A different kind of freedom.
+ Browse through your chosen catalog. Press play and your audio plugin finds the stream.\n\nYour device handles playback and caching. Add synced lyrics, save tracks for later, or listen together with Group Jam.
+ DEMONSTRATION
+ PAUSED
+ Pause demonstration
+ Play demonstration
+
+
+ Your music. Your sources. Your rules.
+ A free, open-source music player built by a community that simply loves music.
+ Open source
+ No ads
+ No telemetry
+ Plugin powered
+ Let’s Go
+
+
+ Supporters keep us going
+ Built with passion. Kept alive by you.
+ Spotube is 100% free, libre, and open source — no ads, no subscriptions, no tracking. Every feature exists because people like you chose to help keep the code flowing.
+ Patreon
+ Monthly Sponsor
+ Support recurring development and infrastructure
+ Buy Me a Coffee
+ One-Time Coffee
+ Fuel a late-night coding session with a quick tip
+ Open Collective
+ Community Fund
+ Transparent, community-governed financial backing
+ Thank you from the bottom of our hearts for believing in open music.
+ Supporting is completely optional — Spotube stays free either way.
+ Sponsor
+ Open %1$s
+
+
+ Modular Architecture
+ Choose where your music comes from.
+ Spotube separates catalog discovery, audio streaming, and synced lyrics. Configure your engines below or switch them anytime.
+ Metadata Engine
+ Powers music discovery, search, track listings, and playlists.
+ Audio Engine
+ Fetches and decodes the audio streams that play.
+ Lyrics Engine
+ Streams time-synced karaoke lyrics in real time.
+ Scrobble Engine
+ Records listening history to Last.fm or ListenBrainz.
+ Active
+ Built-in
+ Ready
+ Optional
+ Select an engine
+ Installed Engines
+ Set as default
+ NewPipe YouTube and LRCLib Lyrics are built-in and ready to stream out of the box.
+ Manage or add new community plugins anytime in Settings → Plugins.
+
+
+ Retro-Modern Architecture
+ Pure Hi-Fi, zero middlemen
+ Here’s how Spotube orchestrates decentralized playback directly on your machine.
+ DIRECT PEER-TO-PEER • 256 KBPS OPUS
+ Blue hour
+ Spotube · player demonstration
+ A little music for the moments in between.
+ 1. Decentralized Discovery
+ Queries go straight to your chosen catalog plugin. Spotube never profiles your taste.
+ 2. Direct Stream Resolution
+ Audio streams directly from content servers to your machine. No proxy hops or remote data harvesting.
+ 3. Local DSP & Playback
+ Decoded and cached locally with equalizer controls and optional offline download.
+ 4. Synced Lyrics & Jam
+ Karaoke lyrics track the playback timestamp, with LAN remote control and Group Jam.
+ No middleman servers. Playback happens right on your device — private by design.
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
index 6d9c9b22..6eb9bb8c 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt
@@ -48,6 +48,7 @@ import dev.krtirtho.spotube.modules.update.AppUpdateService
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
import dev.krtirtho.spotube.modules.update.PluginUpdateService
import dev.krtirtho.spotube.modules.webview.WebViewScreen
+import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
@@ -140,10 +141,17 @@ fun App(
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle()
+ val welcomeRepository: WelcomeRepository = koinInject()
+ val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle()
+
+ // The app always renders the shell + NavDisplay; onboarding is just the start destination.
+ // When it is completed the destination flips to Home and the navigation state is rebuilt.
+ val startRoute = if (showOnboarding) Routes.Welcome else Routes.Home
+
val navigationState = rememberNavigationState(
- startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
+ startRoute = startRoute, topLevelRoutes = TOP_LEVEL_ROUTES
)
- val navigator = remember {
+ val navigator = remember(navigationState) {
Navigator(navigationState)
}
@@ -151,7 +159,8 @@ fun App(
val baseUITheme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
Box(modifier = Modifier.fillMaxSize()) {
- val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
+ val currentRoute =
+ navigationState.backStacks[navigationState.topLevelRoute]?.last()
Box(Modifier.fillMaxSize()) {
AppShell(navigator, navigationState) {
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt
new file mode 100644
index 00000000..249315bf
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/DebugConfig.kt
@@ -0,0 +1,33 @@
+/*
+ * 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
+
+/**
+ * Development-only switches. These are intentionally kept out of [dev.krtirtho.spotube.modules.settings.UserSettings]
+ * so they can never leak into user-facing configuration.
+ */
+object DebugConfig {
+ /**
+ * Flip to `true` while developing to always open the Welcome/Onboarding screen, even after
+ * it has been completed (so you don't have to wipe app data to see it again). It still
+ * dismisses for the session when you finish or skip it.
+ *
+ * Keep it `false` when committing.
+ */
+ const val FORCE_SHOW_ONBOARDING: Boolean = true
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt
index f268d57f..573efaa1 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/db/Database.kt
@@ -23,10 +23,25 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import dev.krtirtho.spotube.core.paths.Paths
+import okio.FileSystem
import okio.Path.Companion.toPath
+import okio.SYSTEM
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
class Database(val paths: Paths) {
+ /**
+ * True when Spotube had already written its own data before this process started. Captured
+ * synchronously at construction time — the earliest point, before any eager service can
+ * create a fresh store — so it reliably tells a brand-new install apart from an upgrade of
+ * an existing one (used to skip the first-run onboarding on upgrades).
+ */
+ val isExistingInstall: Boolean = runCatching {
+ val dataDirPath = paths.getApplicationDataDirPath()
+ EXISTING_INSTALL_PROBE_FILES.any { fileName ->
+ FileSystem.SYSTEM.exists("$dataDirPath/$fileName".toPath())
+ }
+ }.getOrDefault(false)
+
val settingsDataStore: DataStore by lazy {
PreferenceDataStoreFactory.createWithPath(
produceFile = { "${paths.getApplicationDataDirPath()}/spotube.preferences_pb".toPath() }
@@ -61,6 +76,19 @@ class Database(val paths: Paths) {
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() }
)
}
+
+ private companion object {
+ /** Preference files written by previous launches; their presence means an upgrade. */
+ val EXISTING_INSTALL_PROBE_FILES = listOf(
+ "spotube.preferences_pb",
+ "spotube_plugins.preferences_pb",
+ "spotube_matched_tracks.preferences_pb",
+ "spotube_audio_player_queue.preferences_pb",
+ "spotube_local_media.preferences_pb",
+ "spotube_blacklist.preferences_pb",
+ "spotube_cookies.preferences_pb",
+ )
+ }
}
object DatabaseKeys {
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 d9fd4290..bdf28574 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
@@ -86,6 +86,8 @@ import dev.krtirtho.spotube.modules.shell.AppShellViewModel
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel
+import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
+import dev.krtirtho.spotube.modules.welcome.WelcomeViewModel
import org.koin.core.module.Module
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.createdAtStart
@@ -141,6 +143,10 @@ val sharedModules = module {
viewModelOf(::SettingsViewModel) { bind() }
viewModelOf(::JamSettingsViewModel)
+ // Welcome / Onboarding
+ singleOf(::WelcomeRepository)
+ viewModelOf(::WelcomeViewModel)
+
// Updates
single { AppUpdateService(get()) } withOptions { createdAtStart() }
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt
index b3f7f48f..f1adc2f0 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt
@@ -34,6 +34,7 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
import dev.krtirtho.spotube.modules.search.SearchScreen
import dev.krtirtho.spotube.modules.settings.SettingsScreen
import dev.krtirtho.spotube.modules.settings.JamSettingsScreen
+import dev.krtirtho.spotube.modules.welcome.WelcomeScreen
import kotlinx.serialization.Serializable
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI
@@ -91,6 +92,9 @@ sealed interface Routes : NavKey {
@Serializable
data object JamSettings : Routes
+
+ @Serializable
+ data object Welcome : Routes
}
@OptIn(KoinExperimentalAPI::class)
@@ -178,4 +182,7 @@ val navigationModule = module {
navigation {
JamSettingsScreen(viewModel = koinViewModel())
}
+ navigation {
+ WelcomeScreen(viewModel = koinViewModel())
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt
index 3f0ced21..8845db6b 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt
@@ -26,7 +26,6 @@ import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
-import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
@@ -45,7 +44,9 @@ val TOP_LEVEL_ROUTES = setOf(
Routes.Home,
Routes.Search,
Routes.Library,
- Routes.Settings
+ Routes.Settings,
+ // Bootstrap destination: needs its own back stack when the app starts on it.
+ Routes.Welcome,
)
val serializersConfig = SavedStateConfiguration {
@@ -63,6 +64,7 @@ val serializersConfig = SavedStateConfiguration {
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
subclass(Routes.Devices::class, Routes.Devices.serializer())
subclass(Routes.Jam::class, Routes.Jam.serializer())
+ subclass(Routes.Welcome::class, Routes.Welcome.serializer())
}
}
}
@@ -122,7 +124,6 @@ fun NavigationState.toEntries(
val decoratedEntries = backStacks.mapValues { (_, stack) ->
val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
- rememberViewModelStoreNavEntryDecorator()
)
rememberDecoratedNavEntries(
backStack = stack,
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt
index 359ee469..30cfa7b7 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.kt
@@ -47,5 +47,6 @@ expect fun ApplicationMainBar(
title: @Composable () -> Unit = {},
subtitle: @Composable () -> Unit = {},
actions: @Composable (RowScope.() -> Unit) = {},
- backButton: Boolean = true
+ backButton: Boolean = true,
+ transparent: Boolean = false,
)
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt
index 031397ed..ede1fb60 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt
@@ -102,6 +102,14 @@ import okio.Path
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.welcome_plugins_title
+import spotube.composeapp.generated.resources.welcome_plugins_setup_guide
+import spotube.composeapp.generated.resources.welcome_plugins_discover
+import spotube.composeapp.generated.resources.welcome_plugins_retry
+import spotube.composeapp.generated.resources.welcome_plugins_more
+import spotube.composeapp.generated.resources.welcome_plugins_discovery_empty
+import spotube.composeapp.generated.resources.welcome_plugins_discovery_error
+import spotube.composeapp.generated.resources.welcome_plugins_sources
import spotube.composeapp.generated.resources.plugin_action_download
import spotube.composeapp.generated.resources.plugin_action_install_from_file
import spotube.composeapp.generated.resources.plugin_configure_title
@@ -138,6 +146,7 @@ private val VERIFIED_PLUGIN_OWNERS = setOf()
@Composable
fun PluginScreen(
viewModel: PluginViewModel = koinViewModel(),
+ onboarding: Boolean = false,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val shellBottomInset = LocalAppShellBottomInset.current
@@ -215,8 +224,11 @@ fun PluginScreen(
Scaffold(
topBar = {
- ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
- }
+ if (!onboarding) {
+ ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
+ }
+ },
+ containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface,
) { innerPadding ->
when (val data = uiState) {
is PluginUiState.Loading -> {
@@ -244,114 +256,75 @@ fun PluginScreen(
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- item {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 4.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- stringResource(Res.string.plugin_configure_title),
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.SemiBold
- )
- PrimaryButton(onClick = { viewModel.showInstallSheet() }) {
- Icon(
- Iconsax.IconsaxAdd,
- contentDescription = "Install a plugin",
+ if (onboarding) {
+ item {
+ Column(
+ modifier = Modifier.padding(
+ horizontal = 4.dp,
+ vertical = 16.dp
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ stringResource(Res.string.welcome_plugins_title),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.SemiBold,
)
- Text(stringResource(Res.string.plugin_install_section_title))
+ Text(
+ stringResource(Res.string.welcome_plugins_setup_guide),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ item {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(bottom = 12.dp)
+ ) {
+ OutlineButton(onClick = { viewModel.showOnboardingDiscovery(true) }) {
+ Text(
+ stringResource(Res.string.welcome_plugins_discover),
+ fontWeight = if (data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal
+ )
+ }
+ OutlineButton(onClick = {
+ viewModel.showOnboardingDiscovery(
+ false
+ )
+ }) {
+ Text(
+ stringResource(Res.string.welcome_plugins_sources),
+ fontWeight = if (!data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal
+ )
+ }
}
}
}
-
- item {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp),
- ) {
- Column(
+ if (!onboarding || !data.onboardingDiscover) {
+ item {
+ Row(
modifier = Modifier
.fillMaxWidth()
- .padding(top = 4.dp, bottom = 4.dp)
+ .padding(horizontal = 4.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
) {
- data.abilitySelections.forEachIndexed { index, selection ->
- if (index > 0) {
- HorizontalDivider(
- color = MaterialTheme.colorScheme.outlineVariant.copy(
- alpha = 0.5f
- ),
- )
- }
- DefaultAbilityPluginSelector(
- selection = selection,
- onSelected = { plugin ->
- viewModel.selectPlugin(selection.ability, plugin)
- },
+ Text(
+ stringResource(Res.string.plugin_configure_title),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold
+ )
+ PrimaryButton(onClick = { viewModel.showInstallSheet() }) {
+ Icon(
+ Iconsax.IconsaxAdd,
+ contentDescription = stringResource(Res.string.plugin_install_section_title),
)
+ Text(stringResource(Res.string.plugin_install_section_title))
}
}
}
- }
- if (data.plugins.isEmpty()) {
- item {
- Box(
- modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
- contentAlignment = Alignment.Center
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Surface(
- modifier = Modifier.size(72.dp)
- .clip(RoundedCornerShape(18.dp)),
- color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
- ) {
- Box(contentAlignment = Alignment.Center) {
- Icon(
- Iconsax.IconsaxBox,
- contentDescription = null,
- modifier = Modifier.size(32.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- }
- }
- Text(
- stringResource(Res.string.plugin_empty_title),
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.SemiBold
- )
- Text(
- stringResource(Res.string.plugin_empty_subtitle),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
- } else {
- item {
- val noun = if (data.plugins.size == 1) {
- stringResource(Res.string.plugin_installed_singular)
- } else {
- stringResource(Res.string.plugin_installed_plural)
- }
- Text(
- stringResource(
- Res.string.plugin_installed_count,
- data.plugins.size,
- noun
- ),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
- )
- }
item {
Card(
modifier = Modifier
@@ -359,9 +332,11 @@ fun PluginScreen(
.padding(vertical = 4.dp),
) {
Column(
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp, bottom = 4.dp)
) {
- data.plugins.forEachIndexed { index, item ->
+ data.abilitySelections.forEachIndexed { index, selection ->
if (index > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(
@@ -369,30 +344,128 @@ fun PluginScreen(
),
)
}
- PluginCard(
- plugin = item.plugin,
- isSelected = item.isSelected,
- onRemove = { viewModel.removePlugin(item.plugin) },
- isLoggedIn = item.authState.isLoggedIn,
- logoPath = item.logoPath,
- onInfo = { viewModel.showPluginInfo(item.plugin) },
- onSupport = if (item.isSelected) {
- { viewModel.loadSupport(item.plugin) }
- } else null,
- onLogin = if (item.isSelected && item.authState.requiresAuth) {
- { viewModel.login(item.plugin) }
- } else null,
- onLogout = if (item.isSelected && item.authState.requiresAuth) {
- { viewModel.logout(item.plugin) }
- } else null,
+ DefaultAbilityPluginSelector(
+ selection = selection,
+ onSelected = { plugin ->
+ viewModel.selectPlugin(
+ selection.ability,
+ plugin
+ )
+ },
)
}
}
}
}
- }
- if (data.discover.isLoading || data.discover.repos.isNotEmpty()) {
+ if (data.plugins.isEmpty()) {
+ item {
+ Box(
+ modifier = Modifier.fillMaxWidth()
+ .padding(vertical = 48.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Surface(
+ modifier = Modifier.size(72.dp)
+ .clip(RoundedCornerShape(18.dp)),
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ Iconsax.IconsaxBox,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ Text(
+ stringResource(Res.string.plugin_empty_title),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ stringResource(Res.string.plugin_empty_subtitle),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ } else {
+ item {
+ val noun = if (data.plugins.size == 1) {
+ stringResource(Res.string.plugin_installed_singular)
+ } else {
+ stringResource(Res.string.plugin_installed_plural)
+ }
+ Text(
+ stringResource(
+ Res.string.plugin_installed_count,
+ data.plugins.size,
+ noun
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(
+ horizontal = 4.dp,
+ vertical = 4.dp
+ )
+ )
+ }
+ item {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ data.plugins.forEachIndexed { index, item ->
+ if (index > 0) {
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.outlineVariant.copy(
+ alpha = 0.5f
+ ),
+ )
+ }
+ PluginCard(
+ plugin = item.plugin,
+ isSelected = item.isSelected,
+ onRemove = { viewModel.removePlugin(item.plugin) },
+ isLoggedIn = item.authState.isLoggedIn,
+ logoPath = item.logoPath,
+ onInfo = { viewModel.showPluginInfo(item.plugin) },
+ onSupport = if (item.isSelected) {
+ { viewModel.loadSupport(item.plugin) }
+ } else null,
+ onLogin = if (item.isSelected && item.authState.requiresAuth) {
+ { viewModel.login(item.plugin) }
+ } else null,
+ onLogout = if (item.isSelected && item.authState.requiresAuth) {
+ { viewModel.logout(item.plugin) }
+ } else null,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ }
+ if ((!onboarding || data.onboardingDiscover) && (onboarding || data.discover.isLoading || data.discover.repos.isNotEmpty() || data.discover.error != null)) {
+ if (onboarding) {
+ item {
+ OutlineButton(onClick = viewModel::showInstallSheet) {
+ Text(stringResource(Res.string.plugin_install_section_title))
+ }
+ }
+ }
item {
Row(
modifier = Modifier
@@ -408,12 +481,31 @@ fun PluginScreen(
tint = MaterialTheme.colorScheme.primary
)
Text(
- "Discover Plugins",
+ stringResource(Res.string.welcome_plugins_discover),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
}
}
+ if (data.discover.isLoading) {
+ item {
+ Box(
+ Modifier.fillMaxWidth().padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(Modifier.size(24.dp))
+ }
+ }
+ }
+ if (onboarding && data.discover.isInitialLoaded && !data.discover.isLoading && data.discover.repos.isEmpty() && data.discover.error == null) {
+ item {
+ Text(
+ stringResource(Res.string.welcome_plugins_discovery_empty),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(12.dp)
+ )
+ }
+ }
items(
data.discover.repos,
key = { it.id }
@@ -440,15 +532,27 @@ fun PluginScreen(
data.discover.error?.let { error ->
item {
- Text(
- error,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- modifier = Modifier.padding(
- horizontal = 4.dp,
- vertical = 8.dp
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ stringResource(Res.string.welcome_plugins_discovery_error),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(
+ horizontal = 4.dp,
+ vertical = 8.dp
+ )
)
- )
+ OutlineButton(onClick = viewModel::retryDiscovery) {
+ Text(stringResource(Res.string.welcome_plugins_retry))
+ }
+ }
+ }
+ }
+ if (onboarding && data.discover.hasMore && !data.discover.isLoading && !data.discover.isLoadingMore && data.discover.error == null) {
+ item {
+ OutlineButton(onClick = viewModel::loadNextDiscoverPage) {
+ Text(stringResource(Res.string.welcome_plugins_more))
+ }
}
}
}
@@ -468,8 +572,8 @@ fun PluginScreen(
// Pagination trigger stays in the composable because it is driven by
// LazyList layout information, which only exists in UI scope.
// The actual loading call is forwarded to the ViewModel.
- LaunchedEffect(shouldLoadMore.value) {
- if (shouldLoadMore.value) {
+ LaunchedEffect(shouldLoadMore.value, onboarding) {
+ if (!onboarding && shouldLoadMore.value) {
viewModel.loadNextDiscoverPage()
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt
index 004580f9..2f8917bb 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginViewModel.kt
@@ -87,6 +87,7 @@ sealed interface PluginUiState {
data object Loading : PluginUiState
data class Data(
+ val onboardingDiscover: Boolean = true,
val plugins: List,
val abilitySelections: List,
val pendingPlugin: PluginManager.PendingPlugin? = null,
@@ -118,6 +119,7 @@ private data class PluginManagerSnapshot(
)
private data class PluginScreenState(
+ val onboardingDiscover: Boolean = true,
val urlInput: String = "",
val urlError: UrlError? = null,
val isLoadingUrl: Boolean = false,
@@ -239,6 +241,7 @@ class PluginViewModel(
when (val state = snapshot.state) {
is PluginManagerStates.Loading -> PluginUiState.Loading
is PluginManagerStates.Data -> PluginUiState.Data(
+ onboardingDiscover = screen.onboardingDiscover,
plugins = state.plugins.map { plugin ->
PluginListItem(
plugin = plugin,
@@ -468,6 +471,10 @@ class PluginViewModel(
pluginManager.setSelectedPlugin(ability, plugin)
}
+ fun showOnboardingDiscovery(show: Boolean) {
+ _screenState.update { it.copy(onboardingDiscover = show) }
+ }
+
fun removePlugin(plugin: PluginEntry) {
viewModelScope.launch {
runCatching {
@@ -512,6 +519,11 @@ class PluginViewModel(
return repos.filter { it.htmlUrl !in installedUrls }
}
+ fun retryDiscovery() {
+ if (_screenState.value.discover.isLoading) return
+ loadFirstDiscoverPage()
+ }
+
private fun loadFirstDiscoverPage() {
viewModelScope.launch {
_screenState.update {
@@ -590,7 +602,8 @@ class PluginViewModel(
}
fun installPlugin(release: GitHubRelease, repoId: Long) {
- val smplugUrl = release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl
+ val smplugUrl =
+ release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl
if (smplugUrl != null) {
installPluginFromUrl(smplugUrl, repoId)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt
index b044e3a4..4d64ed63 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt
@@ -129,6 +129,13 @@ fun AppShell(
}
}
+ // Onboarding is a bootstrap, full-screen destination. It draws its own app bar (and thus
+ // the desktop window controls), so render it without the sidebar, players or bottom bar.
+ if (navigationState.topLevelRoute == Routes.Welcome) {
+ content()
+ return
+ }
+
ConnectionRequestDialogHost()
PlayDestinationPickerHost()
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt
new file mode 100644
index 00000000..b5ea2ede
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeContent.kt
@@ -0,0 +1,66 @@
+/*
+ * 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.welcome
+
+import dev.krtirtho.spotube.resources.iconsax.BrandBuyMeACoffee
+import dev.krtirtho.spotube.resources.iconsax.BrandOpencollective
+import dev.krtirtho.spotube.resources.iconsax.BrandPatreon
+import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import org.jetbrains.compose.resources.StringResource
+import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.welcome_donate_bmac_subtitle
+import spotube.composeapp.generated.resources.welcome_donate_bmac_title
+import spotube.composeapp.generated.resources.welcome_donate_opencollective_subtitle
+import spotube.composeapp.generated.resources.welcome_donate_opencollective_title
+import spotube.composeapp.generated.resources.welcome_donate_patreon_subtitle
+import spotube.composeapp.generated.resources.welcome_donate_patreon_title
+import spotube.composeapp.generated.resources.welcome_get_started
+import spotube.composeapp.generated.resources.welcome_lets_go
+import spotube.composeapp.generated.resources.welcome_next
+
+/** Order matches the 4 onboarding pages. */
+internal val welcomePrimaryLabels: List = listOf(
+ Res.string.welcome_lets_go,
+ Res.string.welcome_next,
+ Res.string.welcome_next,
+ Res.string.welcome_get_started,
+)
+
+internal val welcomeDonations: List = listOf(
+ DonationOption(
+ id = "patreon",
+ title = Res.string.welcome_donate_patreon_title,
+ subtitle = Res.string.welcome_donate_patreon_subtitle,
+ url = "https://patreon.com/krtirtho",
+ icon = Iconsax.BrandPatreon,
+ ),
+ DonationOption(
+ id = "buymeacoffee",
+ title = Res.string.welcome_donate_bmac_title,
+ subtitle = Res.string.welcome_donate_bmac_subtitle,
+ url = "https://www.buymeacoffee.com/krtirtho",
+ icon = Iconsax.BrandBuyMeACoffee,
+ ),
+ DonationOption(
+ id = "opencollective",
+ title = Res.string.welcome_donate_opencollective_title,
+ subtitle = Res.string.welcome_donate_opencollective_subtitle,
+ url = "https://opencollective.com/spotube",
+ icon = Iconsax.BrandOpencollective,
+ ),
+)
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt
new file mode 100644
index 00000000..b8199ef9
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeModels.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.welcome
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import org.jetbrains.compose.resources.StringResource
+
+/** A single donation destination shown on the support page. */
+data class DonationOption(
+ val id: String,
+ val title: StringResource,
+ val subtitle: StringResource,
+ val url: String,
+ val icon: ImageVector,
+)
+
+data class WelcomeUiState(
+ val currentPageIndex: Int,
+ val pageCount: Int,
+ val isLastPage: Boolean,
+ val primaryLabel: StringResource,
+ val donations: List,
+ val demoPlaying: Boolean = true,
+)
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt
new file mode 100644
index 00000000..6240e22d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeRepository.kt
@@ -0,0 +1,112 @@
+/*
+ * 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.welcome
+
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import dev.krtirtho.spotube.core.DebugConfig
+import dev.krtirtho.spotube.core.db.Database
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+/**
+ * Owns whether the Welcome/Onboarding flow should be the app's start destination.
+ *
+ * This lives in its own preference rather than [dev.krtirtho.spotube.modules.settings.UserSettings]
+ * so the very first launch can be distinguished from "settings not read yet". It is a Koin
+ * singleton so the app-level start route and the routed [WelcomeScreen] observe the same state.
+ */
+class WelcomeRepository(private val database: Database) {
+ companion object {
+ private val ONBOARDING_COMPLETED_KEY = booleanPreferencesKey("onboarding_completed")
+ }
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ /** Set when onboarding is finished for this process only; does not persist (debug forcing). */
+ private val sessionDismissed = MutableStateFlow(false)
+
+ /** Synchronous signal, available before the persisted flag loads; true on upgrades. */
+ val isExistingInstall: Boolean get() = database.isExistingInstall
+
+ private val onboardingCompleted: StateFlow = database.settingsDataStore.data
+ .map { prefs -> prefs[ONBOARDING_COMPLETED_KEY] ?: database.isExistingInstall }
+ .stateIn(scope, SharingStarted.Eagerly, null)
+
+ /**
+ * Whether the app should start on the Welcome/Onboarding route instead of the main shell.
+ *
+ * - `true` on a fresh install, or whenever [DebugConfig.FORCE_SHOW_ONBOARDING] is enabled.
+ * - `false` on an existing install (upgrade) or once onboarding is completed or skipped,
+ * including within a forced session.
+ *
+ * The initial value is derived from [isExistingInstall] so the start route can be chosen on
+ * the very first frame without flashing the wrong screen.
+ */
+ val shouldShowOnboarding: StateFlow = combine(
+ onboardingCompleted,
+ sessionDismissed,
+ ) { completed, dismissed ->
+ when {
+ dismissed -> false
+ DebugConfig.FORCE_SHOW_ONBOARDING -> true
+ else -> !(completed ?: database.isExistingInstall)
+ }
+ }.stateIn(
+ scope = scope,
+ started = SharingStarted.Eagerly,
+ initialValue = if (DebugConfig.FORCE_SHOW_ONBOARDING) true else !database.isExistingInstall,
+ )
+
+ init {
+ // One-time migration. When the flag was never written this is either a brand-new install
+ // (show onboarding) or an upgrade of an existing install (skip it). Persisting the
+ // decision also stops the fresh install's own data writes from being mistaken for an
+ // upgrade on the next launch.
+ scope.launch {
+ database.settingsDataStore.edit { prefs ->
+ if (ONBOARDING_COMPLETED_KEY !in prefs) {
+ prefs[ONBOARDING_COMPLETED_KEY] = database.isExistingInstall
+ }
+ }
+ }
+ }
+
+ suspend fun completeOnboarding() {
+ sessionDismissed.value = true
+ database.settingsDataStore.edit { prefs ->
+ prefs[ONBOARDING_COMPLETED_KEY] = true
+ }
+ }
+
+ /** Development helper to surface the welcome screen again on the next launch. */
+ suspend fun resetOnboarding() {
+ database.settingsDataStore.edit { prefs ->
+ prefs[ONBOARDING_COMPLETED_KEY] = false
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt
new file mode 100644
index 00000000..94c2055e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeScreen.kt
@@ -0,0 +1,245 @@
+/*
+ * 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.welcome
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+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.layout.widthIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+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.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import dev.krtirtho.spotube.core.ui.base.GhostButton
+import dev.krtirtho.spotube.core.ui.base.OutlineButton
+import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
+import dev.krtirtho.spotube.modules.welcome.pages.BrandingPage
+import dev.krtirtho.spotube.modules.welcome.pages.DonationPage
+import dev.krtirtho.spotube.modules.welcome.pages.HowItWorksPage
+import dev.krtirtho.spotube.modules.welcome.pages.PluginsPage
+import dev.krtirtho.spotube.resources.iconsax.Iconsax
+import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
+import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
+import org.jetbrains.compose.resources.stringResource
+import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.welcome_back
+import spotube.composeapp.generated.resources.welcome_skip
+
+/**
+ * First-run Welcome/Onboarding screen. It is a route ([dev.krtirtho.spotube.core.navigation.Routes.Welcome])
+ * rendered inside the app shell. Uses the application's base controls and keeps plugin setup
+ * in-place, with a neutral palette independent of the user's chosen accent.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WelcomeScreen(viewModel: WelcomeViewModel) {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ WelcomeTheme {
+ Box(Modifier.fillMaxSize()) {
+ WelcomeBackground()
+ Scaffold(
+ containerColor = Color.Transparent,
+ topBar = {
+ ApplicationMainBar(
+ title = {
+ WelcomeWordmark(
+ fontSize = MaterialTheme.typography.titleLarge.fontSize,
+ )
+ },
+ backButton = false,
+ transparent = true,
+ actions = {
+ if (!state.isLastPage) {
+ GhostButton(onClick = viewModel::completeOnboarding) {
+ Text(
+ text = stringResource(Res.string.welcome_skip),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ )
+ },
+ ) { innerPadding ->
+ Box(modifier = Modifier.fillMaxSize()) {
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // Central stage (animated page content)
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .widthIn(max = 1280.dp)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ AnimatedContent(
+ modifier = Modifier.fillMaxSize(),
+ targetState = state.currentPageIndex,
+ transitionSpec = {
+ if (targetState > initialState) {
+ (slideInHorizontally { it / 4 } + fadeIn()) togetherWith
+ (slideOutHorizontally { -it / 4 } + fadeOut())
+ } else {
+ (slideInHorizontally { -it / 4 } + fadeIn()) togetherWith
+ (slideOutHorizontally { it / 4 } + fadeOut())
+ }
+ },
+ label = "welcome_page",
+ ) { index ->
+ when (index) {
+ 0 -> BrandingPage()
+
+ 1 -> DonationPage(
+ donations = state.donations,
+ onDonate = viewModel::openDonation,
+ )
+
+ 2 -> PluginsPage()
+
+ else -> HowItWorksPage(
+ isPlaying = state.demoPlaying,
+ onTogglePlayback = viewModel::toggleDemo
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.height(10.dp))
+
+ // Bottom precision navigation bar: Back | Page Dots | Action Button
+ Row(
+ modifier = Modifier
+ .widthIn(max = 680.dp)
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Left: Back button
+ if (state.currentPageIndex > 0) {
+ OutlineButton(
+ onClick = viewModel::previousPage,
+ ) {
+ Text(
+ text = stringResource(Res.string.welcome_back),
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ } else {
+ Spacer(Modifier.width(76.dp))
+ }
+
+ // Center: Precision page dots
+ Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
+ PageIndicator(
+ pageCount = state.pageCount,
+ currentPageIndex = state.currentPageIndex,
+ )
+ }
+
+ // Right: Primary Next / Let's Go action
+ OutlineButton(
+ onClick = viewModel::onPrimary,
+ ) {
+ Text(
+ text = stringResource(state.primaryLabel),
+ fontWeight = FontWeight.SemiBold,
+ )
+ Icon(
+ imageVector = if (state.isLastPage) {
+ Iconsax.IconsaxCheckCircle
+ } else {
+ Iconsax.IconsaxNext
+ },
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PageIndicator(
+ pageCount: Int,
+ currentPageIndex: Int,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(7.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ repeat(pageCount) { index ->
+ val selected = index == currentPageIndex
+ val width by animateDpAsState(
+ targetValue = if (selected) 20.dp else 6.dp,
+ label = "welcome_page_indicator",
+ )
+ Box(
+ modifier = Modifier
+ .size(width = width, height = 6.dp)
+ .clip(CircleShape)
+ .background(
+ if (selected) {
+ MaterialTheme.colorScheme.onSurface
+ } else {
+ MaterialTheme.colorScheme.outlineVariant
+ }
+ ),
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt
new file mode 100644
index 00000000..b2ee940a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeViewModel.kt
@@ -0,0 +1,87 @@
+/*
+ * 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.welcome
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class WelcomeViewModel(
+ private val repository: WelcomeRepository,
+) : ViewModel() {
+ private val currentPageIndex = MutableStateFlow(0)
+ private val demoPlaying = MutableStateFlow(true)
+
+ val uiState: StateFlow =
+ combine(currentPageIndex, demoPlaying) { index, playing ->
+ val pageCount = welcomePrimaryLabels.size
+ WelcomeUiState(
+ currentPageIndex = index,
+ pageCount = pageCount,
+ isLastPage = index == pageCount - 1,
+ primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)],
+ donations = welcomeDonations,
+ demoPlaying = playing,
+ )
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Eagerly,
+ initialValue = WelcomeUiState(
+ currentPageIndex = 0,
+ pageCount = welcomePrimaryLabels.size,
+ isLastPage = welcomePrimaryLabels.size == 1,
+ primaryLabel = welcomePrimaryLabels.first(),
+ donations = welcomeDonations,
+ ),
+ )
+
+ fun nextPage() {
+ currentPageIndex.update { (it + 1).coerceAtMost(welcomePrimaryLabels.lastIndex) }
+ }
+
+ fun toggleDemo() {
+ demoPlaying.update { !it }
+ }
+
+ fun onPrimary() {
+ if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) completeOnboarding() else nextPage()
+ }
+
+ fun openDonation(url: String) {
+ // Only destinations owned by our static funding list can be opened here.
+ if (welcomeDonations.none { it.url == url }) return
+ runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) }
+ }
+
+ fun previousPage() {
+ currentPageIndex.update { (it - 1).coerceAtLeast(0) }
+ }
+
+ fun completeOnboarding() {
+ viewModelScope.launch {
+ repository.completeOnboarding()
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt
new file mode 100644
index 00000000..d5e50838
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/WelcomeVisuals.kt
@@ -0,0 +1,91 @@
+/*
+ * 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.welcome
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.TextUnit
+import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
+import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
+import org.jetbrains.compose.resources.Font
+import org.jetbrains.compose.resources.stringResource
+import spotube.composeapp.generated.resources.*
+
+/** App-native controls and surfaces, in neutral charcoal/ivory instead of the user's accent. */
+@Composable
+fun WelcomeTheme(content: @Composable () -> Unit) {
+ val dark = MaterialTheme.colorScheme.surface.luminance() < 0.5f
+ val scheme = if (dark) darkColorScheme(
+ primary = Color(0xFF8BA8DD), onPrimary = Color(0xFF10213E),
+ primaryContainer = Color(0xFF263754), onPrimaryContainer = Color(0xFFD9E4F7),
+ secondary = Color(0xFFBDBDBD), secondaryContainer = Color(0xFF343434),
+ onSecondaryContainer = Color(0xFFF0F0F0),
+ surface = Color(0xFF181818), background = Color(0xFF181818),
+ surfaceContainer = Color(0xFF222222), surfaceContainerHigh = Color(0xFF292929),
+ surfaceContainerHighest = Color(0xFF323232),
+ onSurface = Color(0xFFEDEBE7), onSurfaceVariant = Color(0xFFABA9A5),
+ outlineVariant = Color(0xFF414141),
+ ) else lightColorScheme(
+ primary = Color(0xFF244879), onPrimary = Color.White,
+ primaryContainer = Color(0xFFDDE5F1), onPrimaryContainer = Color(0xFF173252),
+ secondaryContainer = Color(0xFFE5E3DF), onSecondaryContainer = Color(0xFF292929),
+ surface = Color(0xFFF4F3F0), background = Color(0xFFF4F3F0),
+ surfaceContainer = Color(0xFFEDEBE7), surfaceContainerHigh = Color(0xFFFDFCFA),
+ surfaceContainerHighest = Color(0xFFE1DFDB),
+ onSurface = Color(0xFF252525), onSurfaceVariant = Color(0xFF696762),
+ outlineVariant = Color(0xFFD2D0CC),
+ )
+ MaterialTheme(colorScheme = scheme) {
+ CompositionLocalProvider(LocalBaseUITheme provides rememberBaseUITheme()) {
+ content()
+ }
+ }
+}
+
+@Composable
+fun WelcomeBackground(modifier: Modifier = Modifier) {
+ val scheme = MaterialTheme.colorScheme
+ Box(
+ modifier.fillMaxSize().background(
+ Brush.verticalGradient(listOf(scheme.surfaceContainer, scheme.surface)),
+ )
+ )
+}
+
+@Composable
+fun WelcomeWordmark(
+ modifier: Modifier = Modifier,
+ fontSize: TextUnit = MaterialTheme.typography.displaySmall.fontSize
+) {
+ Text(
+ stringResource(Res.string.welcome_app_name),
+ modifier = modifier,
+ fontFamily = FontFamily(Font(Res.font.cookie_regular)),
+ fontSize = fontSize,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt
new file mode 100644
index 00000000..c0f432f6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/BrandingPage.kt
@@ -0,0 +1,46 @@
+package dev.krtirtho.spotube.modules.welcome.pages
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import dev.krtirtho.spotube.modules.welcome.WelcomeWordmark
+import org.jetbrains.compose.resources.painterResource
+import org.jetbrains.compose.resources.stringResource
+import spotube.composeapp.generated.resources.*
+
+@Composable
+fun BrandingPage(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Image(painterResource(Res.drawable.spotube_logo), null, Modifier.size(88.dp))
+ Spacer(Modifier.height(24.dp))
+ WelcomeWordmark(fontSize = 48.sp)
+ Spacer(Modifier.height(24.dp))
+ Text(
+ stringResource(Res.string.welcome_brand_freedom),
+ style = MaterialTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif),
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(16.dp))
+ Text(
+ stringResource(Res.string.welcome_brand_quiet_description),
+ modifier = Modifier.widthIn(max = 440.dp),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt
new file mode 100644
index 00000000..39dcbaf5
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/DonationPage.kt
@@ -0,0 +1,70 @@
+package dev.krtirtho.spotube.modules.welcome.pages
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import dev.krtirtho.spotube.core.ui.base.OutlineButton
+import dev.krtirtho.spotube.modules.welcome.DonationOption
+import org.jetbrains.compose.resources.stringResource
+import spotube.composeapp.generated.resources.*
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun DonationPage(
+ donations: List,
+ onDonate: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ stringResource(Res.string.welcome_donate_quiet_title),
+ style = MaterialTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif),
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(20.dp))
+ Text(
+ stringResource(Res.string.welcome_donate_letter),
+ modifier = Modifier.widthIn(max = 540.dp),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(32.dp))
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ donations.forEach { option ->
+ OutlineButton(onClick = { onDonate(option.url) }) {
+ Icon(
+ option.icon, null, Modifier.size(24.dp),
+ tint = if (option.id == "patreon") MaterialTheme.colorScheme.onSurface else Color.Unspecified,
+ )
+ Text(stringResource(option.title))
+ }
+ }
+ }
+ Spacer(Modifier.height(24.dp))
+ Text(
+ stringResource(Res.string.welcome_donate_optional),
+ modifier = Modifier.widthIn(max = 500.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt
new file mode 100644
index 00000000..bfd8a2b1
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/HowItWorksPage.kt
@@ -0,0 +1,181 @@
+package dev.krtirtho.spotube.modules.welcome.pages
+
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+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.draw.rotate
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import dev.krtirtho.spotube.core.ui.base.Card
+import dev.krtirtho.spotube.core.ui.base.GhostIconButton
+import dev.krtirtho.spotube.resources.iconsax.*
+import org.jetbrains.compose.resources.stringResource
+import spotube.composeapp.generated.resources.*
+import kotlin.math.PI
+import kotlin.math.sin
+
+@Composable
+fun HowItWorksPage(
+ isPlaying: Boolean,
+ onTogglePlayback: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ BoxWithConstraints(modifier.fillMaxSize()) {
+ val wide = maxWidth >= 760.dp
+ if (wide) {
+ Row(
+ Modifier.fillMaxSize().padding(40.dp),
+ horizontalArrangement = Arrangement.spacedBy(48.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ PlayerDemonstration(isPlaying, onTogglePlayback, Modifier.weight(1f))
+ ListeningIntroduction(Modifier.weight(1f))
+ }
+ } else {
+ Column(
+ Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically),
+ ) {
+ ListeningIntroduction()
+ PlayerDemonstration(isPlaying, onTogglePlayback)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ListeningIntroduction(modifier: Modifier = Modifier) {
+ Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) {
+ Text(
+ stringResource(Res.string.welcome_how_quiet_title),
+ style = MaterialTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif),
+ )
+ Text(
+ stringResource(Res.string.welcome_how_quiet_description),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+/** Silent, illustrative player. The ViewModel owns the play/pause interaction. */
+@Composable
+private fun PlayerDemonstration(
+ isPlaying: Boolean,
+ onTogglePlayback: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val motion = rememberInfiniteTransition(label = "welcome_record")
+ val rotation by motion.animateFloat(
+ 0f, 360f,
+ infiniteRepeatable(tween(12000, easing = LinearEasing)), label = "record_rotation"
+ )
+ val phase by motion.animateFloat(
+ 0f, (2 * PI).toFloat(),
+ infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "audio_meter"
+ )
+ val scheme = MaterialTheme.colorScheme
+
+ Card(modifier.fillMaxWidth()) {
+ Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
+ Text(
+ stringResource(if (isPlaying) Res.string.welcome_demo_streaming else Res.string.welcome_demo_paused),
+ style = MaterialTheme.typography.labelSmall,
+ color = scheme.onSurfaceVariant,
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(20.dp)
+ ) {
+ Canvas(Modifier.size(104.dp).rotate(if (isPlaying) rotation else 0f)) {
+ val radius = size.minDimension / 2f
+ drawCircle(Color(0xFF171717))
+ for (groove in 1..8) {
+ drawCircle(
+ Color(0xFF343434),
+ radius * (0.4f + groove * 0.065f),
+ style = Stroke(1f)
+ )
+ }
+ drawCircle(Color(0xFF334A6B), radius * 0.32f)
+ drawCircle(Color(0xFFE1DDD5), radius * 0.045f)
+ // An off-centre label mark makes the turntable's motion visible.
+ drawCircle(
+ Color(0xFFD6CEC0),
+ radius * 0.025f,
+ Offset(center.x, center.y - radius * 0.2f)
+ )
+ }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(
+ stringResource(Res.string.welcome_how_mockup_title),
+ style = MaterialTheme.typography.titleLarge
+ )
+ Text(
+ stringResource(Res.string.welcome_how_mockup_artist),
+ style = MaterialTheme.typography.bodySmall, color = scheme.onSurfaceVariant
+ )
+ Canvas(Modifier.fillMaxWidth().height(32.dp)) {
+ val count = 18
+ val barWidth = size.width / (count * 1.7f)
+ repeat(count) { index ->
+ val wave = if (isPlaying) (sin(phase + index * 0.6f) + 1f) / 2f else 0f
+ val height = size.height * (0.12f + wave * 0.88f)
+ drawRect(
+ scheme.primary,
+ Offset(index * size.width / count, size.height - height),
+ androidx.compose.ui.geometry.Size(barWidth, height)
+ )
+ }
+ }
+ }
+ }
+ Text(
+ stringResource(Res.string.welcome_how_mockup_lyric),
+ style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Serif),
+ color = scheme.onSurfaceVariant
+ )
+ Row(
+ Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Iconsax.IconsaxPrevious,
+ null,
+ Modifier.size(18.dp),
+ tint = scheme.onSurfaceVariant
+ )
+ Spacer(Modifier.width(24.dp))
+ GhostIconButton(onClick = onTogglePlayback, modifier = Modifier.size(48.dp)) {
+ Icon(
+ if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
+ stringResource(if (isPlaying) Res.string.welcome_demo_pause_action else Res.string.welcome_demo_play_action),
+ Modifier.size(24.dp)
+ )
+ }
+ Spacer(Modifier.width(24.dp))
+ Icon(
+ Iconsax.IconsaxNext,
+ null,
+ Modifier.size(18.dp),
+ tint = scheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/PluginsPage.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/PluginsPage.kt
new file mode 100644
index 00000000..ab3e7477
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/welcome/pages/PluginsPage.kt
@@ -0,0 +1,10 @@
+package dev.krtirtho.spotube.modules.welcome.pages
+
+import androidx.compose.runtime.Composable
+import dev.krtirtho.spotube.modules.plugin.PluginScreen
+
+/** The same installation, discovery, permissions and sign-in flow as Settings, in-place. */
+@Composable
+fun PluginsPage() {
+ PluginScreen(onboarding = true)
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandBuyMeACoffee.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandBuyMeACoffee.kt
new file mode 100644
index 00000000..67779257
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandBuyMeACoffee.kt
@@ -0,0 +1,264 @@
+package dev.krtirtho.spotube.resources.iconsax
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.PathData
+import androidx.compose.ui.graphics.vector.group
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+
+val Iconsax.BrandBuyMeACoffee: ImageVector
+ get() {
+ if (_BrandBuyMeACoffee != null) {
+ return _BrandBuyMeACoffee!!
+ }
+ _BrandBuyMeACoffee = ImageVector.Builder(
+ name = "BrandBuyMeACoffee",
+ defaultWidth = 512.dp,
+ defaultHeight = 512.dp,
+ viewportWidth = 512f,
+ viewportHeight = 512f
+ ).apply {
+ group(
+ clipPathData = PathData {
+ moveTo(0f, 0f)
+ horizontalLineToRelative(512f)
+ verticalLineToRelative(512f)
+ horizontalLineTo(0f)
+ close()
+ }
+ ) {
+ path(fill = SolidColor(Color(0xFF0D0C22))) {
+ moveTo(256.17f, 256.45f)
+ lineToRelative(-0.35f, -0.21f)
+ lineTo(255f, 256f)
+ arcToRelative(1.93f, 1.93f, 0f, isMoreThanHalf = false, isPositiveArc = false, 1.16f, 0.45f)
+ close()
+ moveTo(256.15f, 256.04f)
+ arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.15f, -0.04f)
+ arcToRelative(0.54f, 0.54f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, 0.1f)
+ arcToRelative(0.3f, 0.3f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0.15f, -0.06f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF0D0C22))) {
+ moveTo(256f, 256.03f)
+ horizontalLineToRelative(0.05f)
+ verticalLineTo(256f)
+ lineToRelative(-0.05f, 0.03f)
+ close()
+ moveTo(255f, 256.67f)
+ lineToRelative(0.59f, -0.34f)
+ lineToRelative(0.22f, -0.12f)
+ lineToRelative(0.2f, -0.21f)
+ arcToRelative(3.39f, 3.39f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.01f, 0.68f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF0D0C22))) {
+ moveTo(255.97f, 256.77f)
+ lineToRelative(-0.58f, -0.55f)
+ lineTo(255f, 256f)
+ curveToRelative(0.21f, 0.37f, 0.56f, 0.65f, 0.97f, 0.76f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF0D0C22))) {
+ moveTo(256.18f, 256f)
+ curveToRelative(-0.46f, 0.2f, -0.87f, 0.51f, -1.18f, 0.91f)
+ lineToRelative(0.37f, -0.23f)
+ curveToRelative(0.25f, -0.23f, 0.6f, -0.5f, 0.81f, -0.68f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF0D0C22))) {
+ moveTo(256.2f, 255.27f)
+ curveToRelative(0f, -0.52f, -0.25f, -0.43f, -0.19f, 1.43f)
+ curveToRelative(0f, -0.15f, 0.06f, -0.3f, 0.09f, -0.44f)
+ curveToRelative(0.04f, -0.33f, 0.06f, -0.66f, 0.1f, -0.99f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF0D0C22))) {
+ moveTo(256.18f, 256f)
+ arcToRelative(3.02f, 3.02f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.18f, 0.91f)
+ lineToRelative(0.37f, -0.23f)
+ curveToRelative(0.25f, -0.23f, 0.6f, -0.5f, 0.81f, -0.68f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF0D0C22))) {
+ moveTo(256.24f, 256.58f)
+ arcTo(2.52f, 2.52f, 0f, isMoreThanHalf = false, isPositiveArc = false, 255f, 256f)
+ curveToRelative(0.37f, 0.18f, 0.74f, 0.36f, 0.99f, 0.5f)
+ lineToRelative(0.25f, 0.08f)
+ close()
+ moveTo(256.49f, 256.54f)
+ arcTo(3.96f, 3.96f, 0f, isMoreThanHalf = false, isPositiveArc = false, 256f, 255f)
+ curveToRelative(0.19f, 0.49f, 0.35f, 1f, 0.47f, 1.51f)
+ lineToRelative(0.01f, 0.03f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFFFFDD00))) {
+ moveTo(267.82f, 145.08f)
+ curveToRelative(-18.4f, 7.87f, -39.28f, 16.8f, -66.33f, 16.8f)
+ arcTo(125.64f, 125.64f, 0f, isMoreThanHalf = false, isPositiveArc = true, 168f, 157.26f)
+ lineToRelative(18.71f, 192.07f)
+ arcToRelative(32.09f, 32.09f, 0f, isMoreThanHalf = false, isPositiveArc = false, 32f, 29.45f)
+ reflectiveCurveToRelative(26.53f, 1.38f, 35.39f, 1.38f)
+ curveToRelative(9.53f, 0f, 38.1f, -1.38f, 38.1f, -1.38f)
+ arcToRelative(32.1f, 32.1f, 0f, isMoreThanHalf = false, isPositiveArc = false, 31.99f, -29.45f)
+ lineToRelative(20.04f, -212.25f)
+ curveToRelative(-8.96f, -3.06f, -18f, -5.09f, -28.19f, -5.09f)
+ curveToRelative(-17.63f, -0.01f, -31.83f, 6.06f, -48.23f, 13.08f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF0D0C22))) {
+ moveTo(256f, 256f)
+ lineToRelative(0.32f, 0.3f)
+ lineToRelative(0.21f, 0.12f)
+ arcTo(3.21f, 3.21f, 0f, isMoreThanHalf = false, isPositiveArc = false, 256f, 256f)
+ close()
+ }
+ path(fill = SolidColor(Color(0xFF0D0C22))) {
+ moveTo(431.23f, 136.85f)
+ lineToRelative(-2.82f, -14.21f)
+ curveToRelative(-2.53f, -12.75f, -8.27f, -24.8f, -21.36f, -29.41f)
+ curveToRelative(-4.2f, -1.47f, -8.96f, -2.11f, -12.18f, -5.16f)
+ curveToRelative(-3.22f, -3.05f, -4.17f, -7.79f, -4.91f, -12.18f)
+ curveToRelative(-1.38f, -8.07f, -2.67f, -16.14f, -4.09f, -24.19f)
+ curveToRelative(-1.22f, -6.92f, -2.18f, -14.7f, -5.36f, -21.05f)
+ curveToRelative(-4.13f, -8.53f, -12.71f, -13.51f, -21.24f, -16.81f)
+ arcToRelative(122.38f, 122.38f, 0f, isMoreThanHalf = false, isPositiveArc = false, -13.36f, -4.13f)
+ curveToRelative(-21.31f, -5.62f, -43.72f, -7.69f, -65.64f, -8.86f)
+ arcToRelative(551.32f, 551.32f, 0f, isMoreThanHalf = false, isPositiveArc = false, -78.96f, 1.31f)
+ curveToRelative(-19.54f, 1.78f, -40.12f, 3.93f, -58.69f, 10.68f)
+ curveToRelative(-6.79f, 2.47f, -13.78f, 5.44f, -18.94f, 10.68f)
+ curveToRelative(-6.33f, 6.44f, -8.4f, 16.4f, -3.78f, 24.43f)
+ curveToRelative(3.29f, 5.7f, 8.85f, 9.73f, 14.76f, 12.4f)
+ arcToRelative(119.72f, 119.72f, 0f, isMoreThanHalf = false, isPositiveArc = false, 23.97f, 7.8f)
+ curveToRelative(22.94f, 5.07f, 46.71f, 7.06f, 70.15f, 7.91f)
+ arcToRelative(534.38f, 534.38f, 0f, isMoreThanHalf = false, isPositiveArc = false, 77.86f, -2.54f)
+ arcToRelative(443.55f, 443.55f, 0f, isMoreThanHalf = false, isPositiveArc = false, 19.15f, -2.53f)
+ curveToRelative(7.5f, -1.15f, 12.32f, -10.96f, 10.11f, -17.79f)
+ curveToRelative(-2.65f, -8.17f, -9.76f, -11.34f, -17.8f, -10.1f)
+ curveToRelative(-1.18f, 0.19f, -2.36f, 0.36f, -3.55f, 0.53f)
+ lineToRelative(-0.85f, 0.12f)
+ curveToRelative(-2.72f, 0.34f, -5.45f, 0.67f, -8.17f, 0.96f)
+ arcToRelative(433.76f, 433.76f, 0f, isMoreThanHalf = false, isPositiveArc = true, -16.92f, 1.49f)
+ curveToRelative(-12.66f, 0.88f, -25.36f, 1.29f, -38.06f, 1.31f)
+ curveToRelative(-12.47f, 0f, -24.95f, -0.35f, -37.39f, -1.17f)
+ arcToRelative(475.74f, 475.74f, 0f, isMoreThanHalf = false, isPositiveArc = true, -16.99f, -1.42f)
+ curveToRelative(-2.57f, -0.27f, -5.13f, -0.55f, -7.7f, -0.87f)
+ lineToRelative(-2.44f, -0.31f)
+ lineToRelative(-0.53f, -0.08f)
+ lineToRelative(-2.53f, -0.37f)
+ curveToRelative(-5.17f, -0.78f, -10.34f, -1.67f, -15.45f, -2.76f)
+ arcToRelative(2.32f, 2.32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0f, -4.53f)
+ horizontalLineToRelative(0.1f)
+ arcToRelative(250.82f, 250.82f, 0f, isMoreThanHalf = false, isPositiveArc = true, 13.37f, -2.45f)
+ curveToRelative(1.49f, -0.23f, 2.99f, -0.46f, 4.49f, -0.69f)
+ horizontalLineToRelative(0.04f)
+ curveToRelative(2.81f, -0.19f, 5.62f, -0.69f, 8.41f, -1.02f)
+ arcToRelative(535.29f, 535.29f, 0f, isMoreThanHalf = false, isPositiveArc = true, 73.1f, -2.58f)
+ curveToRelative(11.84f, 0.34f, 23.68f, 1.04f, 35.47f, 2.24f)
+ arcToRelative(391.9f, 391.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, 7.58f, 0.85f)
+ curveToRelative(0.96f, 0.12f, 1.94f, 0.25f, 2.91f, 0.37f)
+ lineToRelative(1.96f, 0.28f)
+ curveToRelative(5.7f, 0.85f, 11.38f, 1.88f, 17.03f, 3.09f)
+ curveToRelative(8.36f, 1.82f, 19.11f, 2.41f, 22.83f, 11.57f)
+ curveToRelative(1.18f, 2.91f, 1.72f, 6.14f, 2.38f, 9.19f)
+ lineToRelative(0.83f, 3.89f)
+ curveToRelative(0.02f, 0.07f, 0.04f, 0.14f, 0.05f, 0.21f)
+ curveToRelative(1.97f, 9.18f, 3.94f, 18.37f, 5.92f, 27.55f)
+ arcToRelative(5.05f, 5.05f, 0f, isMoreThanHalf = false, isPositiveArc = true, -4.24f, 6.05f)
+ horizontalLineToRelative(-0.05f)
+ lineToRelative(-1.21f, 0.17f)
+ lineToRelative(-1.19f, 0.16f)
+ curveToRelative(-3.78f, 0.49f, -7.56f, 0.95f, -11.34f, 1.38f)
+ arcToRelative(732.52f, 732.52f, 0f, isMoreThanHalf = false, isPositiveArc = true, -22.4f, 2.2f)
+ arcToRelative(784.15f, 784.15f, 0f, isMoreThanHalf = false, isPositiveArc = true, -44.67f, 2.43f)
+ arcToRelative(802.63f, 802.63f, 0f, isMoreThanHalf = false, isPositiveArc = true, -22.8f, 0.28f)
+ arcToRelative(791.3f, 791.3f, 0f, isMoreThanHalf = false, isPositiveArc = true, -90.5f, -5.26f)
+ arcToRelative(944.16f, 944.16f, 0f, isMoreThanHalf = false, isPositiveArc = true, -9.76f, -1.22f)
+ curveToRelative(2.52f, 0.32f, -1.83f, -0.25f, -2.71f, -0.37f)
+ curveToRelative(-2.07f, -0.29f, -4.13f, -0.59f, -6.2f, -0.9f)
+ curveToRelative(-6.94f, -1.04f, -13.84f, -2.32f, -20.76f, -3.44f)
+ curveToRelative(-8.37f, -1.38f, -16.38f, -0.69f, -23.95f, 3.44f)
+ arcToRelative(34.83f, 34.83f, 0f, isMoreThanHalf = false, isPositiveArc = false, -14.42f, 14.95f)
+ curveToRelative(-3.27f, 6.75f, -4.24f, 14.1f, -5.7f, 21.35f)
+ curveToRelative(-1.46f, 7.25f, -3.73f, 15.06f, -2.87f, 22.5f)
+ curveToRelative(1.85f, 16.07f, 13.09f, 29.13f, 29.26f, 32.05f)
+ curveToRelative(15.21f, 2.76f, 30.5f, 4.99f, 45.83f, 6.89f)
+ arcToRelative(847.2f, 847.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 196.22f, 1.13f)
+ arcToRelative(10.34f, 10.34f, 0f, isMoreThanHalf = false, isPositiveArc = true, 11.41f, 11.32f)
+ lineToRelative(-1.53f, 14.86f)
+ lineToRelative(-9.25f, 90.1f)
+ arcToRelative(70942.69f, 70942.69f, 0f, isMoreThanHalf = false, isPositiveArc = true, -9.71f, 94.6f)
+ lineToRelative(-2.76f, 26.64f)
+ curveToRelative(-0.88f, 8.74f, -1.01f, 17.76f, -2.67f, 26.4f)
+ curveToRelative(-2.62f, 13.58f, -11.82f, 21.93f, -25.24f, 24.98f)
+ arcToRelative(175.93f, 175.93f, 0f, isMoreThanHalf = false, isPositiveArc = true, -37.47f, 4.38f)
+ curveToRelative(-13.98f, 0.08f, -27.95f, -0.54f, -41.94f, -0.47f)
+ curveToRelative(-14.93f, 0.08f, -33.2f, -1.29f, -44.72f, -12.4f)
+ curveToRelative(-10.12f, -9.75f, -11.52f, -25.02f, -12.9f, -38.23f)
+ arcToRelative(29492.9f, 29492.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, -5.46f, -52.44f)
+ lineToRelative(-10.13f, -97.18f)
+ lineToRelative(-6.55f, -62.88f)
+ curveToRelative(-0.11f, -1.04f, -0.22f, -2.07f, -0.32f, -3.11f)
+ curveToRelative(-0.79f, -7.5f, -6.1f, -14.84f, -14.47f, -14.46f)
+ curveToRelative(-7.17f, 0.32f, -15.31f, 6.41f, -14.47f, 14.46f)
+ lineToRelative(4.86f, 46.62f)
+ lineToRelative(10.05f, 96.43f)
+ curveToRelative(2.86f, 27.39f, 5.72f, 54.79f, 8.56f, 82.19f)
+ curveToRelative(0.55f, 5.25f, 1.07f, 10.51f, 1.65f, 15.76f)
+ curveToRelative(3.15f, 28.68f, 25.06f, 44.14f, 52.19f, 48.49f)
+ curveToRelative(15.85f, 2.55f, 32.08f, 3.08f, 48.16f, 3.34f)
+ curveToRelative(20.62f, 0.33f, 41.44f, 1.13f, 61.72f, -2.61f)
+ curveToRelative(30.05f, -5.51f, 52.6f, -25.57f, 55.81f, -56.68f)
+ lineToRelative(2.76f, -26.95f)
+ curveToRelative(3.05f, -29.72f, 6.11f, -59.44f, 9.15f, -89.17f)
+ lineToRelative(9.96f, -97.12f)
+ lineToRelative(4.57f, -44.51f)
+ arcToRelative(10.34f, 10.34f, 0f, isMoreThanHalf = false, isPositiveArc = true, 8.32f, -9.09f)
+ curveToRelative(8.59f, -1.67f, 16.81f, -4.53f, 22.92f, -11.07f)
+ curveToRelative(9.73f, -10.41f, 11.66f, -23.98f, 8.23f, -37.66f)
+ close()
+ moveTo(108.01f, 146.45f)
+ curveToRelative(0.13f, -0.06f, -0.11f, 1.06f, -0.21f, 1.58f)
+ curveToRelative(-0.02f, -0.79f, 0.02f, -1.49f, 0.21f, -1.58f)
+ close()
+ moveTo(108.84f, 152.9f)
+ curveToRelative(0.07f, -0.05f, 0.28f, 0.23f, 0.49f, 0.56f)
+ curveToRelative(-0.32f, -0.3f, -0.53f, -0.53f, -0.5f, -0.56f)
+ horizontalLineToRelative(0.01f)
+ close()
+ moveTo(109.66f, 153.98f)
+ curveToRelative(0.45f, 0.82f, 0.3f, 0.5f, 0f, 0f)
+ close()
+ moveTo(111.31f, 155.31f)
+ horizontalLineToRelative(0.04f)
+ curveToRelative(0f, 0.05f, 0.08f, 0.1f, 0.1f, 0.14f)
+ arcToRelative(1.04f, 1.04f, 0f, isMoreThanHalf = false, isPositiveArc = false, -0.15f, -0.14f)
+ horizontalLineToRelative(0.01f)
+ close()
+ moveTo(399.68f, 153.32f)
+ curveToRelative(-3.09f, 2.93f, -7.74f, 4.3f, -12.33f, 4.98f)
+ curveToRelative(-51.54f, 7.64f, -103.83f, 11.52f, -155.94f, 9.81f)
+ curveToRelative(-37.29f, -1.27f, -74.19f, -5.41f, -111.11f, -10.63f)
+ curveToRelative(-3.62f, -0.51f, -7.54f, -1.17f, -10.02f, -3.84f)
+ curveToRelative(-4.69f, -5.03f, -2.38f, -15.15f, -1.16f, -21.23f)
+ curveToRelative(1.12f, -5.57f, 3.25f, -12.98f, 9.87f, -13.78f)
+ curveToRelative(10.34f, -1.21f, 22.34f, 3.15f, 32.56f, 4.7f)
+ arcToRelative(616.95f, 616.95f, 0f, isMoreThanHalf = false, isPositiveArc = false, 37.07f, 4.51f)
+ curveToRelative(52.93f, 4.82f, 106.75f, 4.07f, 159.45f, -2.98f)
+ arcToRelative(666.2f, 666.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 28.71f, -4.5f)
+ curveToRelative(8.5f, -1.52f, 17.92f, -4.38f, 23.05f, 4.41f)
+ curveToRelative(3.52f, 5.99f, 3.99f, 14.01f, 3.44f, 20.78f)
+ arcToRelative(11.59f, 11.59f, 0f, isMoreThanHalf = false, isPositiveArc = true, -3.6f, 7.76f)
+ horizontalLineToRelative(0.01f)
+ close()
+ }
+ }
+ }.build()
+
+ return _BrandBuyMeACoffee!!
+ }
+
+@Suppress("ObjectPropertyName")
+private var _BrandBuyMeACoffee: ImageVector? = null
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandOpencollective.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandOpencollective.kt
new file mode 100644
index 00000000..cd5ba60f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandOpencollective.kt
@@ -0,0 +1,58 @@
+package dev.krtirtho.spotube.resources.iconsax
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathFillType
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+
+val Iconsax.BrandOpencollective: ImageVector
+ get() {
+ if (_BrandOpencollective != null) {
+ return _BrandOpencollective!!
+ }
+ _BrandOpencollective = ImageVector.Builder(
+ name = "BrandOpencollective",
+ defaultWidth = 64.dp,
+ defaultHeight = 64.dp,
+ viewportWidth = 64f,
+ viewportHeight = 64f
+ ).apply {
+ path(
+ fill = SolidColor(Color(0xFFB8D3F4)),
+ pathFillType = PathFillType.EvenOdd
+ ) {
+ moveTo(52.402f, 31.916f)
+ curveToRelative(0f, 4.03f, -1.17f, 7.895f, -3.178f, 11.087f)
+ lineToRelative(8.196f, 8.23f)
+ curveToRelative(4.014f, -5.375f, 6.523f, -12.094f, 6.523f, -19.318f)
+ reflectiveCurveToRelative(-2.51f, -13.942f, -6.523f, -19.318f)
+ lineToRelative(-8.196f, 8.23f)
+ curveToRelative(2.007f, 3.192f, 3.178f, 6.887f, 3.178f, 11.087f)
+ close()
+ }
+ path(
+ fill = SolidColor(Color(0xFF3385FF)),
+ pathFillType = PathFillType.EvenOdd
+ ) {
+ moveTo(32.004f, 52.41f)
+ curveToRelative(-11.207f, 0f, -20.406f, -9.24f, -20.406f, -20.493f)
+ reflectiveCurveToRelative(9.2f, -20.493f, 20.406f, -20.493f)
+ curveToRelative(4.182f, 0f, 7.86f, 1.176f, 11.04f, 3.36f)
+ lineToRelative(8.196f, -8.23f)
+ curveTo(45.887f, 2.52f, 39.197f, 0f, 32.004f, 0f)
+ curveTo(14.44f, 0f, 0.057f, 14.278f, 0.057f, 32.084f)
+ reflectiveCurveTo(14.44f, 64f, 32.004f, 64f)
+ curveToRelative(7.36f, 0f, 14.05f, -2.52f, 19.403f, -6.55f)
+ lineToRelative(-8.196f, -8.23f)
+ curveToRelative(-3.178f, 2.016f, -7.025f, 3.192f, -11.207f, 3.192f)
+ close()
+ }
+ }.build()
+
+ return _BrandOpencollective!!
+ }
+
+@Suppress("ObjectPropertyName")
+private var _BrandOpencollective: ImageVector? = null
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandPatreon.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandPatreon.kt
new file mode 100644
index 00000000..7029e29b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/BrandPatreon.kt
@@ -0,0 +1,54 @@
+package dev.krtirtho.spotube.resources.iconsax
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.PathData
+import androidx.compose.ui.graphics.vector.group
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+
+val Iconsax.BrandPatreon: ImageVector
+ get() {
+ if (_BrandPatreon != null) {
+ return _BrandPatreon!!
+ }
+ _BrandPatreon = ImageVector.Builder(
+ name = "BrandPatreon",
+ defaultWidth = 512.dp,
+ defaultHeight = 512.dp,
+ viewportWidth = 512f,
+ viewportHeight = 512f
+ ).apply {
+ group(
+ clipPathData = PathData {
+ moveTo(0.38f, 0.42f)
+ lineToRelative(512f, 0f)
+ lineToRelative(0f, 512f)
+ lineTo(0.38f, 512.42f)
+ close()
+ }
+ ) {
+ path(
+ fill = SolidColor(Color.Black),
+ strokeLineJoin = StrokeJoin.Round
+ ) {
+ moveTo(490.12f, 154.23f)
+ curveToRelative(-0.09f, -65.37f, -51.01f, -118.95f, -110.74f, -138.29f)
+ curveToRelative(-74.18f, -24.01f, -172.02f, -20.53f, -242.86f, 12.89f)
+ curveToRelative(-85.85f, 40.51f, -112.82f, 129.26f, -113.83f, 217.77f)
+ curveToRelative(-0.82f, 72.77f, 6.44f, 264.43f, 114.54f, 265.8f)
+ curveToRelative(80.33f, 1.02f, 92.29f, -102.48f, 129.45f, -152.33f)
+ curveToRelative(26.44f, -35.47f, 60.49f, -45.48f, 102.4f, -55.85f)
+ curveToRelative(72.03f, -17.83f, 121.13f, -74.68f, 121.03f, -149.99f)
+ close()
+ }
+ }
+ }.build()
+
+ return _BrandPatreon!!
+ }
+
+@Suppress("ObjectPropertyName")
+private var _BrandPatreon: ImageVector? = null
diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt
index cd346728..fb1c63fe 100644
--- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt
+++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt
@@ -31,6 +31,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -59,6 +61,7 @@ actual fun ApplicationMainBar(
subtitle: @Composable () -> Unit,
actions: @Composable (RowScope.() -> Unit),
backButton: Boolean,
+ transparent: Boolean,
) {
Box(
modifier = Modifier.fillMaxWidth()
@@ -75,6 +78,14 @@ actual fun ApplicationMainBar(
}
},
subtitle = subtitle,
+ colors = if (transparent) {
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Transparent,
+ scrolledContainerColor = Color.Transparent,
+ )
+ } else {
+ TopAppBarDefaults.topAppBarColors()
+ },
modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons
)
LocalWindowScope.current.WindowControls(
diff --git a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt b/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt
index 47639afc..37f6b279 100644
--- a/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt
+++ b/composeApp/src/mobileMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.mobile.kt
@@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -29,12 +31,21 @@ actual fun ApplicationMainBar(
title: @Composable (() -> Unit),
subtitle: @Composable () -> Unit,
actions: @Composable (RowScope.() -> Unit),
- backButton: Boolean
+ backButton: Boolean,
+ transparent: Boolean,
) {
return TopAppBar(
title = title,
subtitle = subtitle,
actions = actions,
+ colors = if (transparent) {
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Transparent,
+ scrolledContainerColor = Color.Transparent,
+ )
+ } else {
+ TopAppBarDefaults.topAppBarColors()
+ },
navigationIcon = {
if (backButton) {
ApplicationBackButton()