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
- name: Set up Android SDK
uses: android-actions/setup-android@v3
uses: android-actions/setup-android@v4
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable

View File

@ -31,6 +31,7 @@
<string name="settings_section_caching">Caching</string>
<string name="settings_section_desktop">Desktop</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_action_close">Close</string>
@ -230,6 +231,9 @@
<string name="player_go_to_album">Go to album</string>
<string name="player_sleep_timer">Sleep timer</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_update">Update</string>
@ -246,7 +250,7 @@
<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_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_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>
@ -255,12 +259,21 @@
<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>
<string name="welcome_step_welcome">Welcome</string>
<string name="welcome_step_support">Support</string>
<string name="welcome_step_plugins">Plugins</string>
<string name="welcome_step_guide">Guide</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 -->
<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_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 -->
<string name="welcome_plugins_eyebrow">Modular Architecture</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.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay
import dev.krtirtho.spotube.core.DebugConfig
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.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.UserSettings
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.AppUpdateService
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.User
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlin.time.Clock
import org.koin.compose.koinInject
import org.koin.compose.navigation3.koinEntryProvider
import org.koin.core.annotation.KoinExperimentalAPI
@ -138,6 +146,40 @@ fun App(
val welcomeRepository: WelcomeRepository = koinInject()
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.
// When it is completed the destination flips to Home and the navigation state is rebuilt.
val startRoute = if (showOnboarding) Routes.Welcome else Routes.Home
@ -150,52 +192,61 @@ fun App(
}
SpotubeTheme(settings = userSettings) {
Box(modifier = Modifier.fillMaxSize()) {
val currentRoute =
navigationState.backStacks[navigationState.topLevelRoute]?.last()
CompositionLocalProvider(LocalSupportPrompt provides {
supportDialogIsReminder = false
showSupportDialog = true
}) {
Box(modifier = Modifier.fillMaxSize()) {
val currentRoute =
navigationState.backStacks[navigationState.topLevelRoute]?.last()
Box(Modifier.fillMaxSize()) {
AppShell(navigator, navigationState) {
SharedTransitionLayout {
CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout
) {
Column {
NavDisplay(
modifier = Modifier.fillMaxSize(),
onBack = navigator::pop,
entries = navigationState.toEntries(koinEntryProvider())
)
Box(Modifier.fillMaxSize()) {
AppShell(navigator, navigationState) {
SharedTransitionLayout {
CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout
) {
Column {
NavDisplay(
modifier = Modifier.fillMaxSize(),
onBack = navigator::pop,
entries = navigationState.toEntries(koinEntryProvider())
)
}
}
}
}
}
when (currentRoute) {
Routes.WebView -> WebViewScreen(koinInject())
else -> {}
when (currentRoute) {
Routes.WebView -> WebViewScreen(koinInject())
else -> {}
}
}
}
}
content()
availableAppUpdate?.let { update ->
AppUpdateDialog(
update = update,
onDismiss = appUpdateService::dismissUpdate,
onIgnore = appUpdateService::ignoreUpdate,
onUpdate = {
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
appUpdateService.dismissUpdate()
})
}
availablePluginUpdate?.let { update ->
PluginUpdateDialog(
update = update,
onDismiss = pluginUpdateService::dismissUpdate,
onIgnore = pluginUpdateService::ignoreUpdate,
onUpdate = {
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
pluginUpdateService.dismissUpdate()
})
content()
availableAppUpdate?.let { update ->
AppUpdateDialog(
update = update,
onDismiss = appUpdateService::dismissUpdate,
onIgnore = appUpdateService::ignoreUpdate,
onUpdate = {
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
appUpdateService.dismissUpdate()
})
}
availablePluginUpdate?.let { update ->
PluginUpdateDialog(
update = update,
onDismiss = pluginUpdateService::dismissUpdate,
onIgnore = pluginUpdateService::ignoreUpdate,
onUpdate = {
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
pluginUpdateService.dismissUpdate()
})
}
SupportDialog(
visible = showSupportDialog,
onDismissRequest = { showSupportDialog = false },
isReminder = supportDialogIsReminder)
}
}
}

View File

@ -29,5 +29,14 @@ object DebugConfig {
*
* 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.IconsaxPause
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)
@Composable
@ -107,14 +112,16 @@ fun PlayableCard(
) {
val isHovered by interactionSource.collectIsHoveredAsState()
this@Column.AnimatedVisibility(
visible = isHovered,
visible = isHovered || (isPlaying && onPlay != null),
enter = fadeIn(),
exit = fadeOut()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
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(
onClick = onAddToQueue,
modifier = Modifier.size(34.dp),
@ -122,10 +129,11 @@ fun PlayableCard(
) {
MillenniumIcon(
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 {
MillenniumIconButton(
onClick = onPlay,
@ -137,7 +145,12 @@ fun PlayableCard(
imageVector = if (isPlaying) {
Iconsax.IconsaxPause
} 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,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
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,
color = color,
color = if (isLoadingSkeleton) Color.Transparent else color,
modifier = Modifier.shimmerApply().then(modifier),
autoSize = autoSize,
fontSize = fontSize,

View File

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

View File

@ -87,7 +87,11 @@ data class UserSettings(
// Updates
val autoCheckForUpdates: Boolean = true,
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

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.playbackSection
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.shell.LocalAppShellBottomInset
import org.jetbrains.compose.resources.stringResource
@ -154,6 +155,9 @@ private fun CompactSettingsContent(
settings = settings,
settingsViewModel = settingsViewModel)
}
supportSection(
settings = settings,
settingsViewModel = settingsViewModel)
updatesSection(
settings = settings,
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.languageRegionSection
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.updatesSection
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.IconsaxDocumentDownload
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.IconsaxInformation
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_playback
import spotube.composeapp.generated.resources.settings_section_plugins
import spotube.composeapp.generated.resources.settings_section_support
private val SettingsSidebarWidth = 236.dp
@ -123,6 +126,7 @@ internal enum class SettingsTab(
Caching(Res.string.settings_section_caching, Iconsax.IconsaxDocumentDownload),
Downloads(Res.string.settings_section_downloads, Iconsax.IconsaxDirectboxReceive),
Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2),
Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2),
About(Res.string.settings_section_about, Iconsax.IconsaxInformation),
}
@ -317,6 +321,12 @@ private fun SettingsTabContent(
SettingsTab.Desktop ->
desktopSection(settings, settingsViewModel, includeHeader = false)
SettingsTab.Support ->
supportSection(
settings = settings,
settingsViewModel = settingsViewModel,
includeHeader = false)
SettingsTab.About ->
updatesSection(settings, settingsViewModel, includeHeader = false)
}

View File

@ -74,7 +74,10 @@ internal fun SwitchSettingCard(
MillenniumSwitch(
checked = checked,
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 = {
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.modules.library.LibraryState
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.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.sidebarTabs
import org.jetbrains.compose.resources.Font
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.spotube.millennium.composables.MillenniumSidebar
import org.spotube.millennium.composables.MillenniumSidebarContent
@ -63,6 +66,8 @@ import org.spotube.millennium.composables.MillenniumSidebarMenu
import org.spotube.millennium.composables.MillenniumSidebarTrigger
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.cookie_regular
import spotube.composeapp.generated.resources.settings_screen_title
import spotube.composeapp.generated.resources.support_button_label
@Composable
fun AppSidebar(
@ -74,6 +79,7 @@ fun AppSidebar(
var expanded by rememberSaveable { mutableStateOf(true) }
val groupExpansion = remember { mutableStateMapOf<String, Boolean>() }
val currentLibraryTab by libraryState.currentTab.collectAsState()
val openSupport = LocalSupportPrompt.current
MillenniumSidebar(
modifier = modifier,
@ -143,10 +149,17 @@ fun AppSidebar(
}
}
MillenniumSidebarFooter {
val supportLabel = stringResource(Res.string.support_button_label)
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) },
selected = navigationState.topLevelRoute == Routes.Settings,
contentDescription = stringResource(Res.string.settings_screen_title),
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
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
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. */
internal val welcomePrimaryLabels: List<StringResource> = listOf(
@ -40,22 +34,9 @@ internal val welcomePrimaryLabels: List<StringResource> = listOf(
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))
/** Short labels for the step switcher. Order matches the 4 onboarding pages. */
internal val welcomeStepLabels: List<StringResource> = listOf(
Res.string.welcome_step_welcome,
Res.string.welcome_step_support,
Res.string.welcome_step_plugins,
Res.string.welcome_step_guide)

View File

@ -17,21 +17,12 @@
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
/** 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)
val donations: List<DonationOption>)

View File

@ -20,28 +20,22 @@ package dev.krtirtho.spotube.modules.welcome
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumCard
import org.spotube.millennium.composables.MillenniumIcon
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 org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumTabGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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 dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
@ -49,7 +43,6 @@ 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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -63,13 +56,15 @@ 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.
* First-run Welcome/Onboarding screen. It is a route
* ([dev.krtirtho.spotube.core.navigation.Routes.Welcome]) rendered inside the app shell.
*
* 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
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
.fillMaxSize()
.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) {
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))
}
}
.widthIn(max = 1040.dp)
.fillMaxWidth()
.fillMaxHeight(),
contentPadding = PaddingValues(0.dp)) {
Column(Modifier.fillMaxSize()) {
WelcomeStepSwitcher(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
state = state,
viewModel = viewModel)
MillenniumSeparator()
WelcomeActionBar(
state = state,
viewModel = viewModel)
}
}
}
@ -198,31 +125,59 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) {
}
@Composable
private fun PageIndicator(
pageCount: Int,
currentPageIndex: Int,
private fun WelcomeStepSwitcher(
state: WelcomeUiState,
viewModel: WelcomeViewModel,
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) {
AppTheme.colors.onSurface
} else {
AppTheme.colors.outlineVariant
}
))
val labels = welcomeStepLabels.map { stringResource(it) }
MillenniumTabGroup(
selectedTab = state.currentPageIndex,
onSelectedTabChange = viewModel::goToPage,
tabs = labels.indices.toList(),
modifier = modifier,
tabLabel = { labels[it] }) { index ->
when (index) {
0 -> BrandingPage()
1 -> DonationPage(
donations = state.donations,
onDonate = viewModel::openDonation)
2 -> PluginsPage()
else -> HowItWorksPage()
}
}
}
@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.viewModelScope
import dev.krtirtho.spotube.modules.support.donationOptions
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.flow.update
import kotlinx.coroutines.launch
@ -30,18 +31,16 @@ 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 ->
currentPageIndex.map { index ->
val pageCount = welcomePrimaryLabels.size
WelcomeUiState(
currentPageIndex = index,
pageCount = pageCount,
isLastPage = index == pageCount - 1,
primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)],
donations = welcomeDonations,
demoPlaying = playing)
donations = donationOptions)
}
.stateIn(
scope = viewModelScope,
@ -51,30 +50,26 @@ class WelcomeViewModel(
pageCount = welcomePrimaryLabels.size,
isLastPage = welcomePrimaryLabels.size == 1,
primaryLabel = welcomePrimaryLabels.first(),
donations = welcomeDonations))
donations = donationOptions))
fun nextPage() {
currentPageIndex.update { (it + 1).coerceAtMost(welcomePrimaryLabels.lastIndex) }
}
fun toggleDemo() {
demoPlaying.update { !it }
fun goToPage(index: Int) {
currentPageIndex.update { index.coerceIn(0, welcomePrimaryLabels.lastIndex) }
}
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) {
// 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) }
}
fun previousPage() {
currentPageIndex.update { (it - 1).coerceAtLeast(0) }
}
fun completeOnboarding() {
viewModelScope.launch {
repository.completeOnboarding()

View File

@ -33,13 +33,17 @@ import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.theme.MillenniumTheme
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
fun WelcomeTheme(content: @Composable () -> Unit) {
val dark = AppTheme.colors.surface.luminance() < 0.5f
MillenniumTheme(dark = dark) {
content()
}
MillenniumTheme(
dark = dark,
config = MillenniumTheme.config,
content = content)
}
@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.style.TextAlign
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 spotube.composeapp.generated.resources.*

View File

@ -1,187 +1,98 @@
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.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import org.spotube.millennium.composables.MillenniumListTile
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.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.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
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 spotube.composeapp.generated.resources.*
import kotlin.math.PI
import kotlin.math.sin
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.welcome_guide_lyrics_desc
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
fun HowItWorksPage(
isPlaying: Boolean,
onTogglePlayback: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier.fillMaxSize()) {
val wide = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded
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)
}
fun HowItWorksPage(modifier: Modifier = Modifier) {
Column(
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
stringResource(Res.string.welcome_guide_title),
style = AppTheme.typography.headlineMedium.copy(fontFamily = FontFamily.Serif))
Text(
stringResource(Res.string.welcome_guide_subtitle),
style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.onSurfaceVariant)
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
WelcomeGuideStep(
icon = Iconsax.User,
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
private fun ListeningIntroduction(modifier: Modifier = Modifier) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) {
Text(
stringResource(Res.string.welcome_how_quiet_title),
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
private fun WelcomeGuideStep(
icon: ImageVector,
title: StringResource,
description: StringResource,
) {
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 = AppTheme.colors
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
)
}
}
}
MillenniumListTile(
leading = {
MillenniumIcon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = AppTheme.colors.primary)
},
title = { Text(stringResource(title)) },
subtitle = { Text(stringResource(description)) })
}

View File

@ -25,10 +25,13 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
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 org.spotube.millennium.composables.MillenniumTopAppBar
@ -53,6 +56,8 @@ import dev.nucleusframework.window.WindowDoubleClickAction
import dev.nucleusframework.window.windowDragArea
import org.koin.compose.koinInject
private val WindowControlsReservedWidth = 132.dp
@Composable
actual fun ApplicationMainBar(
title: @Composable (() -> Unit),
@ -68,6 +73,9 @@ actual fun ApplicationMainBar(
title = title,
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 = {
if (backButton) {
@ -75,7 +83,9 @@ actual fun ApplicationMainBar(
}
},
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(
modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp),