Compare commits

...

5 Commits

Author SHA1 Message Date
dependabot[bot]
896d47ea13
Merge 5e65508fed into d720467338 2026-10-14 04:53:46 +00:00
Kingkor Roy Tirtho
d720467338 feat(support): implement support reminder dialog and donation options 2026-10-14 09:51:07 +06:00
Kingkor Roy Tirtho
8efcbd4af4 feat(player): add content descriptions for play, pause, and add to queue actions 2026-10-13 13:06:22 +06:00
Kingkor Roy Tirtho
76518a01a1 feat(onboarding): enhance welcome screen with structured guide steps and improved layout 2026-10-13 13:03:27 +06:00
dependabot[bot]
5e65508fed
chore(deps): bump android-actions/setup-android from 3 to 4
Bumps [android-actions/setup-android](https://github.com/android-actions/setup-android) from 3 to 4.
- [Release notes](https://github.com/android-actions/setup-android/releases)
- [Commits](https://github.com/android-actions/setup-android/compare/v3...v4)

---
updated-dependencies:
- dependency-name: android-actions/setup-android
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 23:42:30 +00:00
23 changed files with 742 additions and 480 deletions

View File

@ -66,7 +66,7 @@ jobs:
java-version: 21 java-version: 21
- name: Set up Android SDK - name: Set up Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v4
- name: Set up Rust - name: Set up Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable

View File

@ -31,6 +31,7 @@
<string name="settings_section_caching">Caching</string> <string name="settings_section_caching">Caching</string>
<string name="settings_section_desktop">Desktop</string> <string name="settings_section_desktop">Desktop</string>
<string name="settings_section_updates">Updates</string> <string name="settings_section_updates">Updates</string>
<string name="settings_section_support">Support</string>
<string name="settings_section_about">About</string> <string name="settings_section_about">About</string>
<string name="settings_action_close">Close</string> <string name="settings_action_close">Close</string>
@ -230,6 +231,9 @@
<string name="player_go_to_album">Go to album</string> <string name="player_go_to_album">Go to album</string>
<string name="player_sleep_timer">Sleep timer</string> <string name="player_sleep_timer">Sleep timer</string>
<string name="player_track_details">Track details</string> <string name="player_track_details">Track details</string>
<string name="player_play_content_description">Play</string>
<string name="player_pause_content_description">Pause</string>
<string name="player_add_to_queue_content_description">Add to queue</string>
<string name="update_action_ignore">Ignore</string> <string name="update_action_ignore">Ignore</string>
<string name="update_action_update">Update</string> <string name="update_action_update">Update</string>
@ -246,7 +250,7 @@
<string name="welcome_app_name">Spotube</string> <string name="welcome_app_name">Spotube</string>
<string name="welcome_brand_freedom">Music, without the strings.</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_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_quiet_title">Keep this project alive.</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_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_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_discover">Discover plugins</string>
@ -255,12 +259,21 @@
<string name="welcome_plugins_more">More plugins</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_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_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_step_welcome">Welcome</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_step_support">Support</string>
<string name="welcome_demo_streaming">DEMONSTRATION</string> <string name="welcome_step_plugins">Plugins</string>
<string name="welcome_demo_paused">PAUSED</string> <string name="welcome_step_guide">Guide</string>
<string name="welcome_demo_pause_action">Pause demonstration</string>
<string name="welcome_demo_play_action">Play demonstration</string> <string name="welcome_guide_title">How to use Spotube</string>
<string name="welcome_guide_subtitle">Four quick steps and you’re listening.</string>
<string name="welcome_guide_signin_title">Sign in</string>
<string name="welcome_guide_signin_desc">Connect your Spotify account, or pick your own catalog plugin.</string>
<string name="welcome_guide_search_title">Search and play</string>
<string name="welcome_guide_search_desc">Find any song, album, or artist, then press play.</string>
<string name="welcome_guide_offline_title">Listen offline</string>
<string name="welcome_guide_offline_desc">Download tracks to keep playing without a connection.</string>
<string name="welcome_guide_lyrics_title">Follow the lyrics</string>
<string name="welcome_guide_lyrics_desc">Open synced lyrics from the player while a track plays.</string>
<!-- Page 1: branding --> <!-- Page 1: branding -->
<string name="welcome_brand_tagline">Your music. Your sources. Your rules.</string> <string name="welcome_brand_tagline">Your music. Your sources. Your rules.</string>
@ -289,6 +302,15 @@
<string name="welcome_donate_action">Sponsor</string> <string name="welcome_donate_action">Sponsor</string>
<string name="welcome_donate_open_content_description">Open %1$s</string> <string name="welcome_donate_open_content_description">Open %1$s</string>
<!-- Support / donations -->
<string name="support_button_label">Support Spotube</string>
<string name="support_dialog_title">Support Spotube</string>
<string name="support_dialog_message">Spotube is free, libre and open source — no ads, no subscriptions, no tracking. If it brings you joy, consider supporting the project.</string>
<string name="support_dialog_reminder_hint">You can turn these reminders off in Settings → Support.</string>
<string name="support_dialog_dismiss">Maybe later</string>
<string name="settings_support_reminders_title">Support reminders</string>
<string name="settings_support_reminders_subtitle">Show a gentle reminder to support the project every 7 days.</string>
<!-- Page 3: plugins --> <!-- Page 3: plugins -->
<string name="welcome_plugins_eyebrow">Modular Architecture</string> <string name="welcome_plugins_eyebrow">Modular Architecture</string>
<string name="welcome_plugins_title">Choose where your music comes from.</string> <string name="welcome_plugins_title">Choose where your music comes from.</string>

View File

@ -24,12 +24,16 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import dev.krtirtho.spotube.core.DebugConfig
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
@ -41,6 +45,9 @@ import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.shell.AppShell import dev.krtirtho.spotube.modules.shell.AppShell
import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
import dev.krtirtho.spotube.modules.support.SupportDialog
import dev.krtirtho.spotube.modules.support.SupportReminderIntervalMillis
import dev.krtirtho.spotube.modules.update.AppUpdateDialog import dev.krtirtho.spotube.modules.update.AppUpdateDialog
import dev.krtirtho.spotube.modules.update.AppUpdateService import dev.krtirtho.spotube.modules.update.AppUpdateService
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
@ -59,6 +66,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlin.time.Clock
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.navigation3.koinEntryProvider import org.koin.compose.navigation3.koinEntryProvider
import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.annotation.KoinExperimentalAPI
@ -138,6 +146,40 @@ fun App(
val welcomeRepository: WelcomeRepository = koinInject() val welcomeRepository: WelcomeRepository = koinInject()
val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle() val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle()
var showSupportDialog by remember { mutableStateOf(false) }
var supportDialogIsReminder by remember { mutableStateOf(false) }
// Nudge the user to support the project at most once every seven days, unless they opted
// out in Settings. The first eligible launch after onboarding only records the baseline, so
// the prompt never appears right after the onboarding donation page.
LaunchedEffect(showOnboarding) {
if (showOnboarding) return@LaunchedEffect
// Read the persisted value rather than the collected state, whose initial value is the
// default (0) until DataStore has emitted once.
val current = settingsRepository.getCurrentSettings()
val now = Clock.System.now().toEpochMilliseconds()
// Development override: show the reminder on every launch, regardless of timing or the
// "Support reminders" toggle.
if (DebugConfig.FORCE_SUPPORT_REMINDER) {
supportDialogIsReminder = true
showSupportDialog = true
settingsRepository.updateSettings(
current.copy(lastSupportReminderEpochMs = now))
return@LaunchedEffect
}
if (!current.supportRemindersEnabled) return@LaunchedEffect
val last = current.lastSupportReminderEpochMs
if (last == 0L) {
settingsRepository.updateSettings(
current.copy(lastSupportReminderEpochMs = now))
} else if (now - last >= SupportReminderIntervalMillis) {
supportDialogIsReminder = true
showSupportDialog = true
settingsRepository.updateSettings(
current.copy(lastSupportReminderEpochMs = now))
}
}
// The app always renders the shell + NavDisplay; onboarding is just the start destination. // 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. // 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 startRoute = if (showOnboarding) Routes.Welcome else Routes.Home
@ -150,52 +192,61 @@ fun App(
} }
SpotubeTheme(settings = userSettings) { SpotubeTheme(settings = userSettings) {
Box(modifier = Modifier.fillMaxSize()) { CompositionLocalProvider(LocalSupportPrompt provides {
val currentRoute = supportDialogIsReminder = false
navigationState.backStacks[navigationState.topLevelRoute]?.last() showSupportDialog = true
}) {
Box(modifier = Modifier.fillMaxSize()) {
val currentRoute =
navigationState.backStacks[navigationState.topLevelRoute]?.last()
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
AppShell(navigator, navigationState) { AppShell(navigator, navigationState) {
SharedTransitionLayout { SharedTransitionLayout {
CompositionLocalProvider( CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout LocalSharedTransitionScope provides this@SharedTransitionLayout
) { ) {
Column { Column {
NavDisplay( NavDisplay(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onBack = navigator::pop, onBack = navigator::pop,
entries = navigationState.toEntries(koinEntryProvider()) entries = navigationState.toEntries(koinEntryProvider())
) )
}
} }
} }
} }
} when (currentRoute) {
when (currentRoute) { Routes.WebView -> WebViewScreen(koinInject())
Routes.WebView -> WebViewScreen(koinInject()) else -> {}
else -> {} }
} }
} }
} content()
content() availableAppUpdate?.let { update ->
availableAppUpdate?.let { update -> AppUpdateDialog(
AppUpdateDialog( update = update,
update = update, onDismiss = appUpdateService::dismissUpdate,
onDismiss = appUpdateService::dismissUpdate, onIgnore = appUpdateService::ignoreUpdate,
onIgnore = appUpdateService::ignoreUpdate, onUpdate = {
onUpdate = { runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } appUpdateService.dismissUpdate()
appUpdateService.dismissUpdate() })
}) }
} availablePluginUpdate?.let { update ->
availablePluginUpdate?.let { update -> PluginUpdateDialog(
PluginUpdateDialog( update = update,
update = update, onDismiss = pluginUpdateService::dismissUpdate,
onDismiss = pluginUpdateService::dismissUpdate, onIgnore = pluginUpdateService::ignoreUpdate,
onIgnore = pluginUpdateService::ignoreUpdate, onUpdate = {
onUpdate = { runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
runCatching { openUrlInBrowser(update.info.directDownloadUrl) } pluginUpdateService.dismissUpdate()
pluginUpdateService.dismissUpdate() })
}) }
SupportDialog(
visible = showSupportDialog,
onDismissRequest = { showSupportDialog = false },
isReminder = supportDialogIsReminder)
} }
} }
} }

View File

@ -29,5 +29,14 @@ object DebugConfig {
* *
* Keep it `false` when committing. * Keep it `false` when committing.
*/ */
const val FORCE_SHOW_ONBOARDING: Boolean = true const val FORCE_SHOW_ONBOARDING: Boolean = false
/**
* Flip to `true` while developing to show the support reminder dialog on the next eligible
* app launch, ignoring both the 7-day interval and the "Support reminders" opt-out toggle.
* It still waits until onboarding is dismissed, and only fires once per launch.
*
* Keep it `false` when committing.
*/
const val FORCE_SUPPORT_REMINDER: Boolean = false
} }

View File

@ -62,6 +62,11 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.player_add_to_queue_content_description
import spotube.composeapp.generated.resources.player_pause_content_description
import spotube.composeapp.generated.resources.player_play_content_description
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
@ -107,14 +112,16 @@ fun PlayableCard(
) { ) {
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
this@Column.AnimatedVisibility( this@Column.AnimatedVisibility(
visible = isHovered, visible = isHovered || (isPlaying && onPlay != null),
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut()) { exit = fadeOut()) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(4.dp) modifier = Modifier.padding(4.dp)
) { ) {
onAddToQueue?.let { // While this collection is playing the play/pause toggle stays
// visible and the add-to-queue action is hidden entirely.
onAddToQueue?.takeIf { !isPlaying }?.let {
MillenniumIconButton( MillenniumIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
modifier = Modifier.size(34.dp), modifier = Modifier.size(34.dp),
@ -122,10 +129,11 @@ fun PlayableCard(
) { ) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add to queue") contentDescription = stringResource(
Res.string.player_add_to_queue_content_description))
} }
Spacer(modifier = Modifier.height(8.dp))
} }
Spacer(modifier = Modifier.height(8.dp))
onPlay?.let { onPlay?.let {
MillenniumIconButton( MillenniumIconButton(
onClick = onPlay, onClick = onPlay,
@ -137,7 +145,12 @@ fun PlayableCard(
imageVector = if (isPlaying) { imageVector = if (isPlaying) {
Iconsax.IconsaxPause Iconsax.IconsaxPause
} else Iconsax.IconsaxPlay, } else Iconsax.IconsaxPlay,
contentDescription = "Play") contentDescription = stringResource(
if (isPlaying) {
Res.string.player_pause_content_description
} else {
Res.string.player_play_content_description
}))
} }
} }
} }

View File

@ -107,9 +107,12 @@ fun TextWithShimmer(
minLines: Int = 1, minLines: Int = 1,
onTextLayout: ((TextLayoutResult) -> Unit)? = null, onTextLayout: ((TextLayoutResult) -> Unit)? = null,
style: TextStyle = LocalTextStyle.current) { style: TextStyle = LocalTextStyle.current) {
// While a skeleton tree is loading, the placeholder glyphs must not be readable:
// only the shimmer bars (from `shimmerApply`) should show through.
val isLoadingSkeleton = LocalSkeletonLoading.current
Text( Text(
text = text, text = text,
color = color, color = if (isLoadingSkeleton) Color.Transparent else color,
modifier = Modifier.shimmerApply().then(modifier), modifier = Modifier.shimmerApply().then(modifier),
autoSize = autoSize, autoSize = autoSize,
fontSize = fontSize, fontSize = fontSize,

View File

@ -50,11 +50,13 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.currentWindowWidthBreakpoint
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
@ -75,21 +77,26 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.support_button_label
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.jetbrains.compose.resources.Font
import org.spotube.millennium.adaptive.WindowBreakpoints
import spotube.composeapp.generated.resources.cookie_regular
@Composable @Composable
fun HomeScreen(viewModel: HomeScreenViewModel) { fun HomeScreen(viewModel: HomeScreenViewModel) {
val navigationCommands = koinInject<NavigationCommands>() val navigationCommands = koinInject<NavigationCommands>()
val platform = getPlatform() val openSupport = LocalSupportPrompt.current
val isDesktop = platform.type == PlatformType.Windows || val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
platform.type == PlatformType.Linux ||
platform.type == PlatformType.MacOS
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@ -124,27 +131,55 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MillenniumTheme.colors.background)) { .background(MillenniumTheme.colors.background)
) {
ApplicationMainBar( ApplicationMainBar(
backButton = false, backButton = false,
title = { title = {
Text("Browse") if (isSmallScreen) {
Text(
"Spotube",
style = AppTheme.typography.headlineLarge.copy(
fontFamily = FontFamily(
listOf(Font(Res.font.cookie_regular, weight = FontWeight.Normal))
)
),
color = AppTheme.colors.onSurface
)
} else {
Text("Browse")
}
}, },
actions = { actions = {
if (!isDesktop) { if (isSmallScreen) {
MillenniumIconButton( MillenniumIconButton(
onClick = { navigationCommands.navigateTo(Routes.Devices) }, onClick = { navigationCommands.navigateTo(Routes.Devices) },
style = MillenniumIconButtonStyle.Quiet) { style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxMirroringScreen, imageVector = Iconsax.IconsaxMirroringScreen,
contentDescription = "Devices") contentDescription = "Devices"
)
} }
MillenniumIconButton( MillenniumIconButton(
onClick = { navigationCommands.navigateTo(Routes.Jam) }, onClick = { navigationCommands.navigateTo(Routes.Jam) },
style = MillenniumIconButtonStyle.Quiet) { style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.User, imageVector = Iconsax.User,
contentDescription = "Group Jam") contentDescription = "Group Jam"
)
}
val supportLabel = stringResource(Res.string.support_button_label)
MillenniumIconButton(
onClick = openSupport,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
imageVector = Iconsax.IconsaxHeart2,
contentDescription = supportLabel
)
} }
} }
}) })
@ -153,7 +188,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
.fillMaxSize() .fillMaxSize()
.weight(1f) .weight(1f)
) { ) {
if (isDesktop) { if (!isSmallScreen) {
HomeContent( HomeContent(
listState = listState, listState = listState,
state = state, state = state,
@ -165,7 +200,8 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
onRefresh = { onRefresh = {
viewModel.refresh() viewModel.refresh()
}, },
modifier = Modifier.fillMaxSize()) { modifier = Modifier.fillMaxSize()
) {
HomeContent( HomeContent(
listState = listState, listState = listState,
state = state, state = state,
@ -188,7 +224,8 @@ private fun HomeContent(
listState: androidx.compose.foundation.lazy.LazyListState, listState: androidx.compose.foundation.lazy.LazyListState,
state: HomeScreenState, state: HomeScreenState,
onRetry: () -> Unit, onRetry: () -> Unit,
onGenreSelected: (String) -> Unit) { onGenreSelected: (String) -> Unit
) {
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
val contentPadding = remember(shellBottomInset) { val contentPadding = remember(shellBottomInset) {
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
@ -198,31 +235,37 @@ private fun HomeContent(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(24.dp)) { verticalArrangement = Arrangement.spacedBy(24.dp)
) {
when (state) { when (state) {
is HomeScreenState.Loading -> { is HomeScreenState.Loading -> {
item { item {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)) { verticalArrangement = Arrangement.spacedBy(16.dp)
) {
repeat(3) { repeat(3) {
SkeletonTree(true) { SkeletonTree(true) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) { verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp) .height(180.dp)
.padding(horizontal = 16.dp)) .padding(horizontal = 16.dp)
)
LazyRow( LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 16.dp)) { contentPadding = PaddingValues(horizontal = 16.dp)
) {
items(4) { items(4) {
PlayableCard( PlayableCard(
title = "Item Title", title = "Item Title",
subtitle = "Subtitle", subtitle = "Subtitle",
imageURL = "https://placehold.co/600x400") imageURL = "https://placehold.co/600x400"
)
} }
} }
} }
@ -236,7 +279,8 @@ private fun HomeContent(
item { item {
ErrorDisplay( ErrorDisplay(
errorMessage = state.message, errorMessage = state.message,
onRetry = onRetry) onRetry = onRetry
)
} }
} }
@ -252,7 +296,8 @@ private fun HomeContent(
GenreTabs( GenreTabs(
genres = state.genres, genres = state.genres,
selectedGenreId = state.selectedGenreId, selectedGenreId = state.selectedGenreId,
onGenreSelected = onGenreSelected) onGenreSelected = onGenreSelected
)
} }
} }
@ -263,20 +308,24 @@ private fun HomeContent(
SkeletonTree(true) { SkeletonTree(true) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) { verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp) .height(180.dp)
.padding(horizontal = 16.dp)) .padding(horizontal = 16.dp)
)
LazyRow( LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 16.dp)) { contentPadding = PaddingValues(horizontal = 16.dp)
) {
items(4) { items(4) {
PlayableCard( PlayableCard(
title = "Item Title", title = "Item Title",
subtitle = "Subtitle", subtitle = "Subtitle",
imageURL = "https://placehold.co/600x400") imageURL = "https://placehold.co/600x400"
)
} }
} }
} }
@ -287,7 +336,8 @@ private fun HomeContent(
HomeSection( HomeSection(
title = section.title, title = section.title,
subtitle = section.description, subtitle = section.description,
items = section.items) items = section.items
)
} }
} }
@ -296,20 +346,24 @@ private fun HomeContent(
SkeletonTree(true) { SkeletonTree(true) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) { verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp) .height(180.dp)
.padding(horizontal = 16.dp)) .padding(horizontal = 16.dp)
)
LazyRow( LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 16.dp)) { contentPadding = PaddingValues(horizontal = 16.dp)
) {
items(4) { items(4) {
PlayableCard( PlayableCard(
title = "Item Title", title = "Item Title",
subtitle = "Subtitle", subtitle = "Subtitle",
imageURL = "https://placehold.co/600x400") imageURL = "https://placehold.co/600x400"
)
} }
} }
} }
@ -327,30 +381,36 @@ private fun HomeSection(
title: String, title: String,
subtitle: String? = null, subtitle: String? = null,
items: List<MetadataBrowseItem>, items: List<MetadataBrowseItem>,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier
) {
val rowState = rememberLazyListState() val rowState = rememberLazyListState()
Column( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)) { verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (!subtitle.isNullOrEmpty()) Text( if (!subtitle.isNullOrEmpty()) Text(
text = subtitle, text = subtitle,
style = AppTheme.typography.labelMedium.copy( style = AppTheme.typography.labelMedium.copy(
color = AppTheme.colors.secondary, color = AppTheme.colors.secondary,
fontWeight = FontWeight.Medium), fontWeight = FontWeight.Medium
modifier = Modifier.padding(horizontal = 16.dp)) ),
modifier = Modifier.padding(horizontal = 16.dp)
)
Text( Text(
text = title, text = title,
style = AppTheme.typography.titleLarge, style = AppTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp)) modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp)
)
LazyRow( LazyRow(
state = rowState, state = rowState,
modifier = Modifier.dragScrollable(rowState), modifier = Modifier.dragScrollable(rowState),
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top) { verticalAlignment = Alignment.Top
) {
items(items) { browseItem -> items(items) { browseItem ->
when (browseItem) { when (browseItem) {
is MetadataBrowseItem.Album -> AlbumCard(album = browseItem.data) is MetadataBrowseItem.Album -> AlbumCard(album = browseItem.data)
@ -368,14 +428,16 @@ private fun HomeSection(
private fun GenreTabs( private fun GenreTabs(
genres: List<MetadataBrowseGenre>, genres: List<MetadataBrowseGenre>,
selectedGenreId: String?, selectedGenreId: String?,
onGenreSelected: (String) -> Unit) { onGenreSelected: (String) -> Unit
) {
val rowState = rememberLazyListState() val rowState = rememberLazyListState()
LazyRow( LazyRow(
state = rowState, state = rowState,
modifier = Modifier.dragScrollable(rowState), modifier = Modifier.dragScrollable(rowState),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)) { horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
items(genres) { genre -> items(genres) { genre ->
MillenniumButton( MillenniumButton(
onClick = { onGenreSelected(genre.id) }, onClick = { onGenreSelected(genre.id) },
@ -383,7 +445,8 @@ private fun GenreTabs(
MillenniumButtonStyle.Primary MillenniumButtonStyle.Primary
} else { } else {
MillenniumButtonStyle.Secondary MillenniumButtonStyle.Secondary
}) { }
) {
Text(genre.name) Text(genre.name)
} }
} }

View File

@ -26,6 +26,9 @@ import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumSegmentedButton
import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults
import org.spotube.millennium.composables.MillenniumSegmentedButtonRow
import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.composables.MillenniumTextField
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.millenniumPanelBrush import org.spotube.millennium.theme.millenniumPanelBrush
@ -285,29 +288,19 @@ internal fun PluginScreenContent(
} }
} }
item { item {
Row( MillenniumSegmentedButtonRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 12.dp)) {
modifier = Modifier.padding(bottom = 12.dp) MillenniumSegmentedButton(
) { selected = data.onboardingDiscover,
MillenniumButton(
onClick = { viewModel.showOnboardingDiscovery(true) }, onClick = { viewModel.showOnboardingDiscovery(true) },
style = MillenniumButtonStyle.Secondary) { shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)) {
Text( Text(stringResource(Res.string.welcome_plugins_discover))
stringResource(Res.string.welcome_plugins_discover),
fontWeight = if (data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal
)
} }
MillenniumButton( MillenniumSegmentedButton(
onClick = { selected = !data.onboardingDiscover,
viewModel.showOnboardingDiscovery( onClick = { viewModel.showOnboardingDiscovery(false) },
false shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)) {
) Text(stringResource(Res.string.welcome_plugins_sources))
},
style = MillenniumButtonStyle.Secondary) {
Text(
stringResource(Res.string.welcome_plugins_sources),
fontWeight = if (!data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal
)
} }
} }
} }

View File

@ -87,7 +87,11 @@ data class UserSettings(
// Updates // Updates
val autoCheckForUpdates: Boolean = true, val autoCheckForUpdates: Boolean = true,
val ignoredAppUpdateVersion: String? = null, val ignoredAppUpdateVersion: String? = null,
val ignoredPluginUpdateVersions: Map<String, String> = emptyMap()) val ignoredPluginUpdateVersions: Map<String, String> = emptyMap(),
// Support
val supportRemindersEnabled: Boolean = true,
val lastSupportReminderEpochMs: Long = 0L)
/** /**
* Configuration for the MQTT broker used by Group Jam. The host is a placeholder * Configuration for the MQTT broker used by Group Jam. The host is a placeholder

View File

@ -50,6 +50,7 @@ import dev.krtirtho.spotube.modules.settings.sections.jamSection
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
import dev.krtirtho.spotube.modules.settings.sections.playbackSection import dev.krtirtho.spotube.modules.settings.sections.playbackSection
import dev.krtirtho.spotube.modules.settings.sections.pluginsSection import dev.krtirtho.spotube.modules.settings.sections.pluginsSection
import dev.krtirtho.spotube.modules.settings.sections.supportSection
import dev.krtirtho.spotube.modules.settings.sections.updatesSection import dev.krtirtho.spotube.modules.settings.sections.updatesSection
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -154,6 +155,9 @@ private fun CompactSettingsContent(
settings = settings, settings = settings,
settingsViewModel = settingsViewModel) settingsViewModel = settingsViewModel)
} }
supportSection(
settings = settings,
settingsViewModel = settingsViewModel)
updatesSection( updatesSection(
settings = settings, settings = settings,
settingsViewModel = settingsViewModel) settingsViewModel = settingsViewModel)

View File

@ -71,6 +71,7 @@ import dev.krtirtho.spotube.modules.settings.sections.desktopSection
import dev.krtirtho.spotube.modules.settings.sections.downloadsSection import dev.krtirtho.spotube.modules.settings.sections.downloadsSection
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
import dev.krtirtho.spotube.modules.settings.sections.playbackSection import dev.krtirtho.spotube.modules.settings.sections.playbackSection
import dev.krtirtho.spotube.modules.settings.sections.supportSection
import dev.krtirtho.spotube.modules.settings.sections.SettingsPageTitle import dev.krtirtho.spotube.modules.settings.sections.SettingsPageTitle
import dev.krtirtho.spotube.modules.settings.sections.updatesSection import dev.krtirtho.spotube.modules.settings.sections.updatesSection
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
@ -81,6 +82,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2 import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare
@ -102,6 +104,7 @@ import spotube.composeapp.generated.resources.settings_section_jam
import spotube.composeapp.generated.resources.settings_section_language_region import spotube.composeapp.generated.resources.settings_section_language_region
import spotube.composeapp.generated.resources.settings_section_playback import spotube.composeapp.generated.resources.settings_section_playback
import spotube.composeapp.generated.resources.settings_section_plugins import spotube.composeapp.generated.resources.settings_section_plugins
import spotube.composeapp.generated.resources.settings_section_support
private val SettingsSidebarWidth = 236.dp private val SettingsSidebarWidth = 236.dp
@ -123,6 +126,7 @@ internal enum class SettingsTab(
Caching(Res.string.settings_section_caching, Iconsax.IconsaxDocumentDownload), Caching(Res.string.settings_section_caching, Iconsax.IconsaxDocumentDownload),
Downloads(Res.string.settings_section_downloads, Iconsax.IconsaxDirectboxReceive), Downloads(Res.string.settings_section_downloads, Iconsax.IconsaxDirectboxReceive),
Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2), Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2),
Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2),
About(Res.string.settings_section_about, Iconsax.IconsaxInformation), About(Res.string.settings_section_about, Iconsax.IconsaxInformation),
} }
@ -317,6 +321,12 @@ private fun SettingsTabContent(
SettingsTab.Desktop -> SettingsTab.Desktop ->
desktopSection(settings, settingsViewModel, includeHeader = false) desktopSection(settings, settingsViewModel, includeHeader = false)
SettingsTab.Support ->
supportSection(
settings = settings,
settingsViewModel = settingsViewModel,
includeHeader = false)
SettingsTab.About -> SettingsTab.About ->
updatesSection(settings, settingsViewModel, includeHeader = false) updatesSection(settings, settingsViewModel, includeHeader = false)
} }

View File

@ -74,7 +74,10 @@ internal fun SwitchSettingCard(
MillenniumSwitch( MillenniumSwitch(
checked = checked, checked = checked,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
label = title) // The row itself already renders the title/subtitle, so the switch must not
// draw a second visible label next to it.
label = "",
contentSpacing = 0.dp)
}, },
onClick = { onClick = {
onCheckedChange(!checked) onCheckedChange(!checked)

View File

@ -0,0 +1,76 @@
/*
* 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.settings.sections
import androidx.compose.foundation.lazy.LazyListScope
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
import dev.krtirtho.spotube.modules.support.donationOptions
import dev.krtirtho.spotube.modules.support.openDonation
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_section_support
import spotube.composeapp.generated.resources.settings_support_reminders_subtitle
import spotube.composeapp.generated.resources.settings_support_reminders_title
/** Support settings: the periodic reminder toggle plus the donation destinations. */
internal fun LazyListScope.supportSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
includeHeader: Boolean = true) {
if (includeHeader) {
settingsSectionHeader(Res.string.settings_section_support)
}
settingsSectionCard(
items = buildList {
add {
SwitchSettingCard(
title = stringResource(Res.string.settings_support_reminders_title),
subtitle = stringResource(Res.string.settings_support_reminders_subtitle),
icon = {
SettingsItemIcon(
imageVector = Iconsax.IconsaxHeart2,
contentDescription = stringResource(
Res.string.settings_support_reminders_title))
},
checked = settings.supportRemindersEnabled,
onCheckedChange = { enabled ->
settingsViewModel.updateSettings {
copy(supportRemindersEnabled = enabled)
}
})
}
donationOptions.forEach { option ->
add {
SettingCardItem(
title = stringResource(option.title),
subtitle = stringResource(option.subtitle),
icon = {
SettingsItemIcon(
imageVector = option.icon,
contentDescription = stringResource(option.title))
},
onClick = { openDonation(option) })
}
}
})
}

View File

@ -46,10 +46,13 @@ import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.sidebarTabs import dev.krtirtho.spotube.sidebarTabs
import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.Font
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.spotube.millennium.composables.MillenniumSidebar import org.spotube.millennium.composables.MillenniumSidebar
import org.spotube.millennium.composables.MillenniumSidebarContent import org.spotube.millennium.composables.MillenniumSidebarContent
@ -63,6 +66,8 @@ import org.spotube.millennium.composables.MillenniumSidebarMenu
import org.spotube.millennium.composables.MillenniumSidebarTrigger import org.spotube.millennium.composables.MillenniumSidebarTrigger
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.cookie_regular import spotube.composeapp.generated.resources.cookie_regular
import spotube.composeapp.generated.resources.settings_screen_title
import spotube.composeapp.generated.resources.support_button_label
@Composable @Composable
fun AppSidebar( fun AppSidebar(
@ -74,6 +79,7 @@ fun AppSidebar(
var expanded by rememberSaveable { mutableStateOf(true) } var expanded by rememberSaveable { mutableStateOf(true) }
val groupExpansion = remember { mutableStateMapOf<String, Boolean>() } val groupExpansion = remember { mutableStateMapOf<String, Boolean>() }
val currentLibraryTab by libraryState.currentTab.collectAsState() val currentLibraryTab by libraryState.currentTab.collectAsState()
val openSupport = LocalSupportPrompt.current
MillenniumSidebar( MillenniumSidebar(
modifier = modifier, modifier = modifier,
@ -143,10 +149,17 @@ fun AppSidebar(
} }
} }
MillenniumSidebarFooter { MillenniumSidebarFooter {
val supportLabel = stringResource(Res.string.support_button_label)
MillenniumSidebarItem( MillenniumSidebarItem(
label = "Settings", label = supportLabel,
onClick = { openSupport() },
contentDescription = supportLabel,
icon = Iconsax.IconsaxHeart2)
MillenniumSidebarItem(
label = stringResource(Res.string.settings_screen_title),
onClick = { navigator.navigate(Routes.Settings) }, onClick = { navigator.navigate(Routes.Settings) },
selected = navigationState.topLevelRoute == Routes.Settings, selected = navigationState.topLevelRoute == Routes.Settings,
contentDescription = stringResource(Res.string.settings_screen_title),
icon = Iconsax.IconsaxSetting2) icon = Iconsax.IconsaxSetting2)
} }
} }

View File

@ -0,0 +1,151 @@
/*
* 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.support
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.openUrlInBrowser
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 org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIcon
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.support_dialog_dismiss
import spotube.composeapp.generated.resources.support_dialog_message
import spotube.composeapp.generated.resources.support_dialog_reminder_hint
import spotube.composeapp.generated.resources.support_dialog_title
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
/** A single donation destination offered across the app. */
data class DonationOption(
val id: String,
val title: StringResource,
val subtitle: StringResource,
val url: String,
val icon: ImageVector)
/** The donation destinations shown in onboarding, settings and the support prompt. */
val donationOptions: 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))
/** Minimum time between two automatic support reminders. */
const val SupportReminderIntervalMillis: Long = 7L * 24 * 60 * 60 * 1000
/**
* Opens the support prompt from anywhere inside the app shell. Hosted at the app root so the
* sidebar, home top bar and the periodic reminder all drive the same dialog.
*/
val LocalSupportPrompt = staticCompositionLocalOf { {} }
/** Opens a donation destination in the user's browser. */
fun openDonation(option: DonationOption) {
runCatching { openUrlInBrowser(option.url) }
}
/** A dialog that lets the user pick a way to support the project. */
@Composable
fun SupportDialog(
visible: Boolean,
onDismissRequest: () -> Unit,
isReminder: Boolean = false) {
if (!visible) return
MillenniumDialog(
visible = true,
onDismissRequest = onDismissRequest,
title = stringResource(Res.string.support_dialog_title)) {
Text(
stringResource(Res.string.support_dialog_message),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant)
if (isReminder) {
Text(
stringResource(Res.string.support_dialog_reminder_hint),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
}
donationOptions.forEach { option ->
MillenniumButton(
onClick = {
openDonation(option)
onDismissRequest()
},
modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Secondary) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumIcon(
imageVector = option.icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (option.id == "patreon") {
AppTheme.colors.onSurface
} else {
Color.Unspecified
})
Text(stringResource(option.title))
}
}
}
MillenniumButton(
onClick = onDismissRequest,
modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.support_dialog_dismiss))
}
}
}

View File

@ -17,21 +17,15 @@
package dev.krtirtho.spotube.modules.welcome 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 org.jetbrains.compose.resources.StringResource
import spotube.composeapp.generated.resources.Res 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_get_started
import spotube.composeapp.generated.resources.welcome_lets_go import spotube.composeapp.generated.resources.welcome_lets_go
import spotube.composeapp.generated.resources.welcome_next import spotube.composeapp.generated.resources.welcome_next
import spotube.composeapp.generated.resources.welcome_step_guide
import spotube.composeapp.generated.resources.welcome_step_plugins
import spotube.composeapp.generated.resources.welcome_step_support
import spotube.composeapp.generated.resources.welcome_step_welcome
/** Order matches the 4 onboarding pages. */ /** Order matches the 4 onboarding pages. */
internal val welcomePrimaryLabels: List<StringResource> = listOf( internal val welcomePrimaryLabels: List<StringResource> = listOf(
@ -40,22 +34,9 @@ internal val welcomePrimaryLabels: List<StringResource> = listOf(
Res.string.welcome_next, Res.string.welcome_next,
Res.string.welcome_get_started) Res.string.welcome_get_started)
internal val welcomeDonations: List<DonationOption> = listOf( /** Short labels for the step switcher. Order matches the 4 onboarding pages. */
DonationOption( internal val welcomeStepLabels: List<StringResource> = listOf(
id = "patreon", Res.string.welcome_step_welcome,
title = Res.string.welcome_donate_patreon_title, Res.string.welcome_step_support,
subtitle = Res.string.welcome_donate_patreon_subtitle, Res.string.welcome_step_plugins,
url = "https://patreon.com/krtirtho", Res.string.welcome_step_guide)
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

@ -17,21 +17,12 @@
package dev.krtirtho.spotube.modules.welcome package dev.krtirtho.spotube.modules.welcome
import androidx.compose.ui.graphics.vector.ImageVector import dev.krtirtho.spotube.modules.support.DonationOption
import org.jetbrains.compose.resources.StringResource 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( data class WelcomeUiState(
val currentPageIndex: Int, val currentPageIndex: Int,
val pageCount: Int, val pageCount: Int,
val isLastPage: Boolean, val isLastPage: Boolean,
val primaryLabel: StringResource, val primaryLabel: StringResource,
val donations: List<DonationOption>, val donations: List<DonationOption>)
val demoPlaying: Boolean = true)

View File

@ -20,28 +20,22 @@ package dev.krtirtho.spotube.modules.welcome
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumCard
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import androidx.compose.animation.AnimatedContent import org.spotube.millennium.composables.MillenniumSeparator
import androidx.compose.animation.core.animateDpAsState import org.spotube.millennium.composables.MillenniumTabGroup
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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text import com.composeunstyled.Text
@ -49,7 +43,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -63,13 +56,15 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.welcome_back
import spotube.composeapp.generated.resources.welcome_skip import spotube.composeapp.generated.resources.welcome_skip
/** /**
* First-run Welcome/Onboarding screen. It is a route ([dev.krtirtho.spotube.core.navigation.Routes.Welcome]) * First-run Welcome/Onboarding screen. It is a route
* rendered inside the app shell. Uses the application's base controls and keeps plugin setup * ([dev.krtirtho.spotube.core.navigation.Routes.Welcome]) rendered inside the app shell.
* in-place, with a neutral palette independent of the user's chosen accent. *
* The steps are exposed through a Millennium tab group rather than ad-hoc dots or a footer
* walk-through, so the screen reads as one deliberate panel on every window size.
* Each step owns its content and the footer only carries the forward action.
*/ */
@Composable @Composable
fun WelcomeScreen(viewModel: WelcomeViewModel) { fun WelcomeScreen(viewModel: WelcomeViewModel) {
@ -96,99 +91,31 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) {
} }
} }
}) })
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Column( Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
contentAlignment = Alignment.Center) {
MillenniumCard(
modifier = Modifier modifier = Modifier
.fillMaxSize() .widthIn(max = 1040.dp)
.padding(horizontal = 16.dp, vertical = 8.dp), .fillMaxWidth()
horizontalAlignment = Alignment.CenterHorizontally) { .fillMaxHeight(),
// Central stage (animated page content) contentPadding = PaddingValues(0.dp)) {
Box( Column(Modifier.fillMaxSize()) {
modifier = Modifier WelcomeStepSwitcher(
.weight(1f) modifier = Modifier
.widthIn(max = 1280.dp) .weight(1f)
.fillMaxWidth(), .fillMaxWidth()
contentAlignment = Alignment.Center) { .padding(horizontal = 12.dp, vertical = 12.dp),
AnimatedContent( state = state,
modifier = Modifier.fillMaxSize(), viewModel = viewModel)
targetState = state.currentPageIndex, MillenniumSeparator()
transitionSpec = { WelcomeActionBar(
if (targetState > initialState) { state = state,
(slideInHorizontally { it / 4 } + fadeIn()) togetherWith viewModel = viewModel)
(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) {
MillenniumButton(
onClick = viewModel::previousPage,
style = MillenniumButtonStyle.Secondary) {
Text(
text = stringResource(Res.string.welcome_back),
color = AppTheme.colors.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
MillenniumButton(
onClick = viewModel::onPrimary,
style = MillenniumButtonStyle.Secondary) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = stringResource(state.primaryLabel),
fontWeight = FontWeight.SemiBold)
MillenniumIcon(
imageVector = if (state.isLastPage) {
Iconsax.IconsaxCheckCircle
} else {
Iconsax.IconsaxNext
},
contentDescription = null,
modifier = Modifier.size(16.dp))
}
}
} }
} }
} }
@ -198,31 +125,59 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) {
} }
@Composable @Composable
private fun PageIndicator( private fun WelcomeStepSwitcher(
pageCount: Int, state: WelcomeUiState,
currentPageIndex: Int, viewModel: WelcomeViewModel,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier) {
Row( val labels = welcomeStepLabels.map { stringResource(it) }
modifier = modifier MillenniumTabGroup(
.padding(horizontal = 12.dp, vertical = 8.dp), selectedTab = state.currentPageIndex,
horizontalArrangement = Arrangement.spacedBy(7.dp), onSelectedTabChange = viewModel::goToPage,
verticalAlignment = Alignment.CenterVertically) { tabs = labels.indices.toList(),
repeat(pageCount) { index -> modifier = modifier,
val selected = index == currentPageIndex tabLabel = { labels[it] }) { index ->
val width by animateDpAsState( when (index) {
targetValue = if (selected) 20.dp else 6.dp, 0 -> BrandingPage()
label = "welcome_page_indicator")
Box( 1 -> DonationPage(
modifier = Modifier donations = state.donations,
.size(width = width, height = 6.dp) onDonate = viewModel::openDonation)
.clip(CircleShape)
.background( 2 -> PluginsPage()
if (selected) {
AppTheme.colors.onSurface else -> HowItWorksPage()
} else { }
AppTheme.colors.outlineVariant }
} }
))
@Composable
private fun WelcomeActionBar(
state: WelcomeUiState,
viewModel: WelcomeViewModel) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.weight(1f))
MillenniumButton(
onClick = viewModel::onPrimary,
style = MillenniumButtonStyle.Secondary) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = stringResource(state.primaryLabel),
fontWeight = FontWeight.SemiBold)
MillenniumIcon(
imageVector = if (state.isLastPage) {
Iconsax.IconsaxCheckCircle
} else {
Iconsax.IconsaxNext
},
contentDescription = null,
modifier = Modifier.size(16.dp))
}
} }
} }
} }

View File

@ -19,10 +19,11 @@ package dev.krtirtho.spotube.modules.welcome
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.modules.support.donationOptions
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -30,18 +31,16 @@ import kotlinx.coroutines.launch
class WelcomeViewModel( class WelcomeViewModel(
private val repository: WelcomeRepository) : ViewModel() { private val repository: WelcomeRepository) : ViewModel() {
private val currentPageIndex = MutableStateFlow(0) private val currentPageIndex = MutableStateFlow(0)
private val demoPlaying = MutableStateFlow(true)
val uiState: StateFlow<WelcomeUiState> = val uiState: StateFlow<WelcomeUiState> =
combine(currentPageIndex, demoPlaying) { index, playing -> currentPageIndex.map { index ->
val pageCount = welcomePrimaryLabels.size val pageCount = welcomePrimaryLabels.size
WelcomeUiState( WelcomeUiState(
currentPageIndex = index, currentPageIndex = index,
pageCount = pageCount, pageCount = pageCount,
isLastPage = index == pageCount - 1, isLastPage = index == pageCount - 1,
primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)], primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)],
donations = welcomeDonations, donations = donationOptions)
demoPlaying = playing)
} }
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -51,30 +50,26 @@ class WelcomeViewModel(
pageCount = welcomePrimaryLabels.size, pageCount = welcomePrimaryLabels.size,
isLastPage = welcomePrimaryLabels.size == 1, isLastPage = welcomePrimaryLabels.size == 1,
primaryLabel = welcomePrimaryLabels.first(), primaryLabel = welcomePrimaryLabels.first(),
donations = welcomeDonations)) donations = donationOptions))
fun nextPage() { fun goToPage(index: Int) {
currentPageIndex.update { (it + 1).coerceAtMost(welcomePrimaryLabels.lastIndex) } currentPageIndex.update { index.coerceIn(0, welcomePrimaryLabels.lastIndex) }
}
fun toggleDemo() {
demoPlaying.update { !it }
} }
fun onPrimary() { fun onPrimary() {
if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) completeOnboarding() else nextPage() if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) {
completeOnboarding()
} else {
goToPage(currentPageIndex.value + 1)
}
} }
fun openDonation(url: String) { fun openDonation(url: String) {
// Only destinations owned by our static funding list can be opened here. // Only destinations owned by our static funding list can be opened here.
if (welcomeDonations.none { it.url == url }) return if (donationOptions.none { it.url == url }) return
runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) } runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) }
} }
fun previousPage() {
currentPageIndex.update { (it - 1).coerceAtLeast(0) }
}
fun completeOnboarding() { fun completeOnboarding() {
viewModelScope.launch { viewModelScope.launch {
repository.completeOnboarding() repository.completeOnboarding()

View File

@ -33,13 +33,17 @@ import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
/** Installs the Millennium theme for the onboarding pages. */ /**
* Reapplies the Millennium theme for the onboarding pages using the app's active configuration,
* so onboarding matches the palette and appearance used by the rest of the app.
*/
@Composable @Composable
fun WelcomeTheme(content: @Composable () -> Unit) { fun WelcomeTheme(content: @Composable () -> Unit) {
val dark = AppTheme.colors.surface.luminance() < 0.5f val dark = AppTheme.colors.surface.luminance() < 0.5f
MillenniumTheme(dark = dark) { MillenniumTheme(
content() dark = dark,
} config = MillenniumTheme.config,
content = content)
} }
@Composable @Composable

View File

@ -17,7 +17,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.modules.welcome.DonationOption import dev.krtirtho.spotube.modules.support.DonationOption
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*

View File

@ -1,187 +1,98 @@
package dev.krtirtho.spotube.modules.welcome.pages package dev.krtirtho.spotube.modules.welcome.pages
import androidx.compose.foundation.layout.Box
import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints
import org.spotube.millennium.composables.MillenniumCard
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumIconButtonStyle import androidx.compose.foundation.layout.Arrangement
import androidx.compose.animation.core.* import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector
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.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.resources.iconsax.* import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch
import dev.krtirtho.spotube.resources.iconsax.User
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.Res
import kotlin.math.PI import spotube.composeapp.generated.resources.welcome_guide_lyrics_desc
import kotlin.math.sin import spotube.composeapp.generated.resources.welcome_guide_lyrics_title
import spotube.composeapp.generated.resources.welcome_guide_offline_desc
import spotube.composeapp.generated.resources.welcome_guide_offline_title
import spotube.composeapp.generated.resources.welcome_guide_search_desc
import spotube.composeapp.generated.resources.welcome_guide_search_title
import spotube.composeapp.generated.resources.welcome_guide_signin_desc
import spotube.composeapp.generated.resources.welcome_guide_signin_title
import spotube.composeapp.generated.resources.welcome_guide_subtitle
import spotube.composeapp.generated.resources.welcome_guide_title
/**
* The onboarding guide: a short, concrete answer to "how do I actually use this app?".
* No marketing prose — just the four things a new user needs to do.
*/
@Composable @Composable
fun HowItWorksPage( fun HowItWorksPage(modifier: Modifier = Modifier) {
isPlaying: Boolean, Column(
onTogglePlayback: () -> Unit, modifier
modifier: Modifier = Modifier .fillMaxSize()
) { .verticalScroll(rememberScrollState())
Box(modifier.fillMaxSize()) { .padding(28.dp),
val wide = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded verticalArrangement = Arrangement.spacedBy(20.dp)) {
if (wide) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row( Text(
Modifier.fillMaxSize().padding(40.dp), stringResource(Res.string.welcome_guide_title),
horizontalArrangement = Arrangement.spacedBy(48.dp), style = AppTheme.typography.headlineMedium.copy(fontFamily = FontFamily.Serif))
verticalAlignment = Alignment.CenterVertically) { Text(
PlayerDemonstration(isPlaying, onTogglePlayback, Modifier.weight(1f)) stringResource(Res.string.welcome_guide_subtitle),
ListeningIntroduction(Modifier.weight(1f)) style = AppTheme.typography.bodyLarge,
} color = AppTheme.colors.onSurfaceVariant)
} else { }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically)) { WelcomeGuideStep(
ListeningIntroduction() icon = Iconsax.User,
PlayerDemonstration(isPlaying, onTogglePlayback) title = Res.string.welcome_guide_signin_title,
} description = Res.string.welcome_guide_signin_desc)
WelcomeGuideStep(
icon = Iconsax.IconsaxSearch,
title = Res.string.welcome_guide_search_title,
description = Res.string.welcome_guide_search_desc)
WelcomeGuideStep(
icon = Iconsax.IconsaxDocumentDownload,
title = Res.string.welcome_guide_offline_title,
description = Res.string.welcome_guide_offline_desc)
WelcomeGuideStep(
icon = Iconsax.IconsaxMusic,
title = Res.string.welcome_guide_lyrics_title,
description = Res.string.welcome_guide_lyrics_desc)
} }
} }
} }
@Composable @Composable
private fun ListeningIntroduction(modifier: Modifier = Modifier) { private fun WelcomeGuideStep(
Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) { icon: ImageVector,
Text( title: StringResource,
stringResource(Res.string.welcome_how_quiet_title), description: StringResource,
style = AppTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif))
Text(
stringResource(Res.string.welcome_how_quiet_description),
style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.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") MillenniumListTile(
val rotation by motion.animateFloat( leading = {
0f, 360f, MillenniumIcon(
infiniteRepeatable(tween(12000, easing = LinearEasing)), label = "record_rotation" imageVector = icon,
) contentDescription = null,
val phase by motion.animateFloat( modifier = Modifier.size(20.dp),
0f, (2 * PI).toFloat(), tint = AppTheme.colors.primary)
infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "audio_meter" },
) title = { Text(stringResource(title)) },
val scheme = AppTheme.colors subtitle = { Text(stringResource(description)) })
MillenniumCard(modifier = 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 = AppTheme.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 = AppTheme.typography.titleLarge
)
Text(
stringResource(Res.string.welcome_how_mockup_artist),
style = AppTheme.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 = AppTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Serif),
color = scheme.onSurfaceVariant
)
Row(
Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
MillenniumIcon(
Iconsax.IconsaxPrevious,
null,
Modifier.size(18.dp),
tint = scheme.onSurfaceVariant
)
Spacer(Modifier.width(24.dp))
MillenniumIconButton(
onClick = onTogglePlayback,
modifier = Modifier.size(48.dp),
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
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))
MillenniumIcon(
Iconsax.IconsaxNext,
null,
Modifier.size(18.dp),
tint = scheme.onSurfaceVariant
)
}
}
}
} }

View File

@ -25,10 +25,13 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.foundation.window.WindowDraggableArea
import org.spotube.millennium.composables.MillenniumTopAppBar import org.spotube.millennium.composables.MillenniumTopAppBar
@ -53,6 +56,8 @@ import dev.nucleusframework.window.WindowDoubleClickAction
import dev.nucleusframework.window.windowDragArea import dev.nucleusframework.window.windowDragArea
import org.koin.compose.koinInject import org.koin.compose.koinInject
private val WindowControlsReservedWidth = 132.dp
@Composable @Composable
actual fun ApplicationMainBar( actual fun ApplicationMainBar(
title: @Composable (() -> Unit), title: @Composable (() -> Unit),
@ -68,6 +73,9 @@ actual fun ApplicationMainBar(
title = title, title = title,
actions = { actions = {
actions() actions()
// Reserve room for the platform window controls instead of shrinking the
// whole bar, so it spans the full width with no seam under the controls.
Spacer(Modifier.width(WindowControlsReservedWidth))
}, },
navigationIcon = { navigationIcon = {
if (backButton) { if (backButton) {
@ -75,7 +83,9 @@ actual fun ApplicationMainBar(
} }
}, },
subtitle = subtitle, subtitle = subtitle,
modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons background = if (transparent) SolidColor(Color.Transparent) else null,
separatorColor = if (transparent) Color.Transparent else null,
modifier = Modifier.fillMaxWidth()
) )
LocalWindowScope.current.WindowControls( LocalWindowScope.current.WindowControls(
modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp), modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp),