feat(welcome): implement onboarding flow with multiple pages and donation options

This commit is contained in:
Kingkor Roy Tirtho 2026-10-06 15:29:27 +06:00
parent fab60107e3
commit 71d7649db6
27 changed files with 1784 additions and 140 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@ -202,8 +202,98 @@
<string name="update_action_ignore">Ignore</string>
<string name="update_action_update">Update</string>
<string name="app_update_dialog_title">Spotube %1$s is available</string>
<string name="app_update_dialog_no_notes">See what\'s new in this release on the Spotube downloads page.</string>
<string name="app_update_dialog_no_notes">See what’s new in this release on the Spotube downloads page.</string>
<string name="plugin_update_dialog_title">%1$s %2$s is available</string>
<string name="plugin_update_dialog_current_version">Currently installed: %1$s</string>
<string name="plugin_update_dialog_no_notes">See what\'s new in this update from the plugin\'s page.</string>
<string name="plugin_update_dialog_no_notes">See what’s new in this update from the plugin’s page.</string>
<string name="welcome_skip">Skip</string>
<string name="welcome_next">Next</string>
<string name="welcome_back">Back</string>
<string name="welcome_get_started">Get Started</string>
<string name="welcome_app_name">Spotube</string>
<string name="welcome_brand_freedom">Music, without the strings.</string>
<string name="welcome_brand_quiet_description">An open-source player for a world of music.\nYours to explore. Yours to make your own.</string>
<string name="welcome_donate_quiet_title">Keep music free.</string>
<string name="welcome_donate_letter">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.</string>
<string name="welcome_plugins_setup_guide">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.</string>
<string name="welcome_plugins_discover">Discover plugins</string>
<string name="welcome_plugins_sources">Your sources</string>
<string name="welcome_plugins_retry">Try again</string>
<string name="welcome_plugins_more">More plugins</string>
<string name="welcome_plugins_discovery_empty">No new plugins found. You can also install one from a link or a .smplug file above.</string>
<string name="welcome_plugins_discovery_error">Plugin discovery is unavailable right now. Try again, or install from a link or file.</string>
<string name="welcome_how_quiet_title">A familiar player. A different kind of freedom.</string>
<string name="welcome_how_quiet_description">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.</string>
<string name="welcome_demo_streaming">DEMONSTRATION</string>
<string name="welcome_demo_paused">PAUSED</string>
<string name="welcome_demo_pause_action">Pause demonstration</string>
<string name="welcome_demo_play_action">Play demonstration</string>
<!-- Page 1: branding -->
<string name="welcome_brand_tagline">Your music. Your sources. Your rules.</string>
<string name="welcome_brand_subtitle">A free, open-source music player built by a community that simply loves music.</string>
<string name="welcome_brand_chip_open_source">Open source</string>
<string name="welcome_brand_chip_no_ads">No ads</string>
<string name="welcome_brand_chip_no_tracking">No telemetry</string>
<string name="welcome_brand_chip_plugin_powered">Plugin powered</string>
<string name="welcome_lets_go">Let’s Go</string>
<!-- Page 2: donations -->
<string name="welcome_donate_eyebrow">Supporters keep us going</string>
<string name="welcome_donate_title">Built with passion. Kept alive by you.</string>
<string name="welcome_donate_message">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.</string>
<string name="welcome_donate_patreon_title">Patreon</string>
<string name="welcome_donate_patreon_tag">Monthly Sponsor</string>
<string name="welcome_donate_patreon_subtitle">Support recurring development and infrastructure</string>
<string name="welcome_donate_bmac_title">Buy Me a Coffee</string>
<string name="welcome_donate_bmac_tag">One-Time Coffee</string>
<string name="welcome_donate_bmac_subtitle">Fuel a late-night coding session with a quick tip</string>
<string name="welcome_donate_opencollective_title">Open Collective</string>
<string name="welcome_donate_opencollective_tag">Community Fund</string>
<string name="welcome_donate_opencollective_subtitle">Transparent, community-governed financial backing</string>
<string name="welcome_donate_thanks">Thank you from the bottom of our hearts for believing in open music.</string>
<string name="welcome_donate_optional">Supporting is completely optional — Spotube stays free either way.</string>
<string name="welcome_donate_action">Sponsor</string>
<string name="welcome_donate_open_content_description">Open %1$s</string>
<!-- Page 3: plugins -->
<string name="welcome_plugins_eyebrow">Modular Architecture</string>
<string name="welcome_plugins_title">Choose where your music comes from.</string>
<string name="welcome_plugins_subtitle">Spotube separates catalog discovery, audio streaming, and synced lyrics. Configure your engines below or switch them anytime.</string>
<string name="welcome_plugins_metadata_title">Metadata Engine</string>
<string name="welcome_plugins_metadata_description">Powers music discovery, search, track listings, and playlists.</string>
<string name="welcome_plugins_audio_title">Audio Engine</string>
<string name="welcome_plugins_audio_description">Fetches and decodes the audio streams that play.</string>
<string name="welcome_plugins_lyrics_title">Lyrics Engine</string>
<string name="welcome_plugins_lyrics_description">Streams time-synced karaoke lyrics in real time.</string>
<string name="welcome_plugins_scrobble_title">Scrobble Engine</string>
<string name="welcome_plugins_scrobble_description">Records listening history to Last.fm or ListenBrainz.</string>
<string name="welcome_plugins_badge_active">Active</string>
<string name="welcome_plugins_badge_builtin">Built-in</string>
<string name="welcome_plugins_badge_ready">Ready</string>
<string name="welcome_plugins_badge_optional">Optional</string>
<string name="welcome_plugins_none_selected">Select an engine</string>
<string name="welcome_plugins_installed_section">Installed Engines</string>
<string name="welcome_plugins_set_default">Set as default</string>
<string name="welcome_plugins_builtin_note">NewPipe YouTube and LRCLib Lyrics are built-in and ready to stream out of the box.</string>
<string name="welcome_plugins_tip">Manage or add new community plugins anytime in Settings → Plugins.</string>
<!-- Page 4: how it works -->
<string name="welcome_how_eyebrow">Retro-Modern Architecture</string>
<string name="welcome_how_title">Pure Hi-Fi, zero middlemen</string>
<string name="welcome_how_subtitle">Here’s how Spotube orchestrates decentralized playback directly on your machine.</string>
<string name="welcome_how_status_badge">DIRECT PEER-TO-PEER • 256 KBPS OPUS</string>
<string name="welcome_how_mockup_title">Blue hour</string>
<string name="welcome_how_mockup_artist">Spotube · player demonstration</string>
<string name="welcome_how_mockup_lyric">A little music for the moments in between.</string>
<string name="welcome_how_step_search_title">1. Decentralized Discovery</string>
<string name="welcome_how_step_search_desc">Queries go straight to your chosen catalog plugin. Spotube never profiles your taste.</string>
<string name="welcome_how_step_stream_title">2. Direct Stream Resolution</string>
<string name="welcome_how_step_stream_desc">Audio streams directly from content servers to your machine. No proxy hops or remote data harvesting.</string>
<string name="welcome_how_step_playback_title">3. Local DSP &amp; Playback</string>
<string name="welcome_how_step_playback_desc">Decoded and cached locally with equalizer controls and optional offline download.</string>
<string name="welcome_how_step_lyrics_title">4. Synced Lyrics &amp; Jam</string>
<string name="welcome_how_step_lyrics_desc">Karaoke lyrics track the playback timestamp, with LAN remote control and Group Jam.</string>
<string name="welcome_how_footer">No middleman servers. Playback happens right on your device — private by design.</string>
</resources>

View File

@ -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) {

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View File

@ -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<Preferences> 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 {

View File

@ -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<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel)
// Welcome / Onboarding
singleOf(::WelcomeRepository)
viewModelOf(::WelcomeViewModel)
// Updates
single { AppUpdateService(get()) } withOptions { createdAtStart() }
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }

View File

@ -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<Routes.JamSettings> {
JamSettingsScreen(viewModel = koinViewModel())
}
navigation<Routes.Welcome> {
WelcomeScreen(viewModel = koinViewModel())
}
}

View File

@ -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<NavKey>(),
rememberViewModelStoreNavEntryDecorator()
)
rememberDecoratedNavEntries(
backStack = stack,

View File

@ -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,
)

View File

@ -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<String>()
@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 = {
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,6 +256,52 @@ fun PluginScreen(
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
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.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
)
}
}
}
}
if (!onboarding || !data.onboardingDiscover) {
item {
Row(
modifier = Modifier
@ -260,7 +318,7 @@ fun PluginScreen(
PrimaryButton(onClick = { viewModel.showInstallSheet() }) {
Icon(
Iconsax.IconsaxAdd,
contentDescription = "Install a plugin",
contentDescription = stringResource(Res.string.plugin_install_section_title),
)
Text(stringResource(Res.string.plugin_install_section_title))
}
@ -289,7 +347,10 @@ fun PluginScreen(
DefaultAbilityPluginSelector(
selection = selection,
onSelected = { plugin ->
viewModel.selectPlugin(selection.ability, plugin)
viewModel.selectPlugin(
selection.ability,
plugin
)
},
)
}
@ -300,7 +361,8 @@ fun PluginScreen(
if (data.plugins.isEmpty()) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
modifier = Modifier.fillMaxWidth()
.padding(vertical = 48.dp),
contentAlignment = Alignment.Center
) {
Column(
@ -349,7 +411,10 @@ fun PluginScreen(
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
modifier = Modifier.padding(
horizontal = 4.dp,
vertical = 4.dp
)
)
}
item {
@ -392,7 +457,15 @@ fun PluginScreen(
}
}
if (data.discover.isLoading || data.discover.repos.isNotEmpty()) {
}
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,8 +532,9 @@ fun PluginScreen(
data.discover.error?.let { error ->
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
error,
stringResource(Res.string.welcome_plugins_discovery_error),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(
@ -449,6 +542,17 @@ fun PluginScreen(
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()
}
}

View File

@ -87,6 +87,7 @@ sealed interface PluginUiState {
data object Loading : PluginUiState
data class Data(
val onboardingDiscover: Boolean = true,
val plugins: List<PluginListItem>,
val abilitySelections: List<AbilitySelection>,
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)
}

View File

@ -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()

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<StringResource> = listOf(
Res.string.welcome_lets_go,
Res.string.welcome_next,
Res.string.welcome_next,
Res.string.welcome_get_started,
)
internal val welcomeDonations: List<DonationOption> = 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,
),
)

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<DonationOption>,
val demoPlaying: Boolean = true,
)

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Boolean?> = 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<Boolean> = 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
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
),
)
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<WelcomeUiState> =
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()
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}

View File

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

View File

@ -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<DonationOption>,
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,
)
}
}

View File

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

View File

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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()