Compare commits

...

2 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
93dfea9a30 feat(player): refactor artist display and improve search field labels 2026-10-14 13:53:19 +06:00
Kingkor Roy Tirtho
d720467338 feat(support): implement support reminder dialog and donation options 2026-10-14 09:51:07 +06:00
27 changed files with 637 additions and 155 deletions

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

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

@ -93,12 +93,13 @@ fun LibraryAlbumsScreen(
}
Column(modifier = modifier) {
AnimatedVisibility(searchMode){
AnimatedVisibility(searchMode) {
MillenniumTextField(
state = searchState,
label = "Search",
modifier = Modifier.fillMaxWidth(),
placeholder = "Search saved albums...")
placeholder = "Search saved albums..."
)
}
Spacer(modifier = Modifier.height(12.dp))
when (state) {
@ -119,7 +120,8 @@ fun LibraryAlbumsScreen(
PlayableCard(
title = "Album Title",
subtitle = "Artist Name",
imageURL = "https://placehold.co/600x400")
imageURL = "https://placehold.co/600x400"
)
}
}
}
@ -168,7 +170,8 @@ fun LibraryAlbumsScreen(
PlayableCard(
title = "Album Title",
subtitle = "Artist Name",
imageURL = "https://placehold.co/600x400")
imageURL = "https://placehold.co/600x400"
)
}
}
}

View File

@ -95,7 +95,7 @@ fun LibraryArtistsScreen(
AnimatedVisibility(searchMode){
MillenniumTextField(
state = searchState,
label = "Search",
modifier = Modifier.fillMaxWidth(),
placeholder = "Search saved artists...")
}

View File

@ -106,7 +106,7 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
contentDescription = "Search")
MillenniumTextField(
state = searchState,
label = "Search",
modifier = Modifier.weight(1f),
placeholder = if (state.currentFolderPath == null) {
"Search folders or tracks"

View File

@ -129,7 +129,7 @@ fun AddToPlaylistPicker(
contentDescription = "Search")
MillenniumTextField(
state = searchState,
label = "Search",
modifier = Modifier.weight(1f),
placeholder = "Search your playlists...")
}

View File

@ -110,7 +110,7 @@ fun LibraryPlaylistsScreen(
modifier = Modifier.weight(1f)) {
MillenniumTextField(
state = searchState,
label = "Search",
modifier = Modifier.fillMaxWidth(),
placeholder = "Search saved playlists...")
}

View File

@ -143,7 +143,6 @@ fun AddTracksToPlaylistDialog(
verticalAlignment = Alignment.CenterVertically) {
MillenniumTextField(
state = queryState,
label = "Search",
modifier = Modifier.weight(1f),
placeholder = "Search tracks...")
MillenniumIconButton(

View File

@ -478,7 +478,7 @@ private fun SearchBar(
.padding(bottom = 8.dp)) {
MillenniumAutocomplete(
state = searchState,
label = "Search",
suggestions = suggestions,
onSuggestionSelected = { entry -> entry.onClick() },
suggestionLabel = { entry ->

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

@ -481,12 +481,13 @@ fun AppExpandedPlayer(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center)
Text(
text = playerUiState.artists,
PlayerArtistsText(
artists = playerUiState.artistList,
onArtistClick = { artistId ->
navigationCommands.navigateTo(Routes.Artist(artistId))
},
style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center)
}
MillenniumIconButton(

View File

@ -252,19 +252,24 @@ fun AppLargePlayer(
contentScale = ContentScale.Crop
)
}
Column(modifier = Modifier.padding(start = 12.dp)) {
Column(
modifier = Modifier
.weight(1f, fill = false)
.padding(start = 12.dp)
) {
Text(
text = playerUiState.title,
style = AppTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = playerUiState.artists,
PlayerArtistsText(
artists = playerUiState.artistList,
onArtistClick = { artistId ->
navigationCommands.navigateTo(Routes.Artist(artistId))
},
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
color = AppTheme.colors.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(10.dp))

View File

@ -20,21 +20,25 @@ package dev.krtirtho.spotube.modules.shell
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.rememberLogger
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
internal data class PlayerArtist(
val id: String?,
val name: String
)
internal data class PlayerUiState(
val queue: List<QueueEntry> = emptyList(),
val currentQueueEntry: QueueEntry? = null,
val title: String = "No-op track title",
val artists: String = "No-op artists",
val artistList: List<PlayerArtist> = emptyList(),
val album: String = "",
val coverUrl: String = "",
val position: Duration = 0.milliseconds,
@ -43,7 +47,8 @@ internal data class PlayerUiState(
val playerState: PlayerState = PlayerState.IDLE,
val loopState: LoopState = LoopState.NONE,
val isShuffling: Boolean = false,
val volume: Float = 0f) {
val volume: Float = 0f
) {
val isPlaying: Boolean get() = playerState == PlayerState.PLAYING
val displayDuration: Duration
get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration
@ -73,7 +78,8 @@ internal data class PlayerUiState(
@Composable
internal fun rememberPlayerUiState(
audioPlayer: AudioPlayerInterface,
audioPlayerQueue: AudioPlayerQueue): PlayerUiState {
audioPlayerQueue: AudioPlayerQueue
): PlayerUiState {
val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList())
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsState(initial = null)
val playerState by audioPlayer.playerStateFlow.collectAsState()
@ -89,6 +95,7 @@ internal fun rememberPlayerUiState(
currentQueueEntry = currentQueueEntry,
title = currentQueueEntry?.displayTitle().orEmpty().ifBlank { "No-op track title" },
artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { "No-op artists" },
artistList = currentQueueEntry?.displayArtistList().orEmpty(),
album = currentQueueEntry?.displayAlbum().orEmpty(),
coverUrl = currentQueueEntry?.coverUrl().orEmpty(),
position = position,
@ -97,7 +104,8 @@ internal fun rememberPlayerUiState(
playerState = playerState,
loopState = loopState,
isShuffling = isShuffling,
volume = volume)
volume = volume
)
// val logger = rememberLogger<PlayerUiState>()
// logger.d { state.toDebugString() }
@ -118,6 +126,14 @@ private fun QueueEntry.displayArtists(): String {
}
}
private fun QueueEntry.displayArtistList(): List<PlayerArtist> {
return when (this) {
is QueueEntry.StreamingTrack -> track.artists.map { PlayerArtist(it.id, it.name) }
// Local tracks have no artist ids, so they are shown but not navigable.
is QueueEntry.LocalTrack -> artists.map { PlayerArtist(id = null, name = it) }
}
}
private fun QueueEntry.displayAlbum(): String {
return when (this) {
is QueueEntry.StreamingTrack -> track.album?.title ?: "Unknown Album"

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,82 @@
/*
* 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.shell
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.theming.AppTheme
/**
* Renders a track's artists on a single, ellipsized line. Artists that have an [PlayerArtist.id]
* are tappable links that open their artist page; local tracks (no ids) render as plain text.
*/
@Composable
internal fun PlayerArtistsText(
artists: List<PlayerArtist>,
onArtistClick: (String) -> Unit,
style: TextStyle,
color: Color,
modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Unspecified) {
if (artists.isEmpty()) return
val hoverColor = AppTheme.colors.primary
val annotatedArtists = buildAnnotatedString {
artists.forEachIndexed { index, artist ->
if (index > 0) append(", ")
val artistId = artist.id
if (artistId == null) {
append(artist.name)
} else {
withLink(
LinkAnnotation.Clickable(
tag = artistId,
styles = TextLinkStyles(
style = SpanStyle(textDecoration = TextDecoration.Underline),
hoveredStyle = SpanStyle(
color = hoverColor,
textDecoration = TextDecoration.Underline),
),
linkInteractionListener = { onArtistClick(artistId) },
)
) {
append(artist.name)
}
}
}
}
Text(
text = annotatedArtists,
modifier = modifier,
style = style,
color = color,
textAlign = textAlign,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}

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,18 +17,8 @@
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
@ -50,23 +40,3 @@ internal val welcomeStepLabels: List<StringResource> = listOf(
Res.string.welcome_step_support,
Res.string.welcome_step_plugins,
Res.string.welcome_step_guide)
internal val welcomeDonations: List<DonationOption> = listOf(
DonationOption(
id = "patreon",
title = Res.string.welcome_donate_patreon_title,
subtitle = Res.string.welcome_donate_patreon_subtitle,
url = "https://patreon.com/krtirtho",
icon = Iconsax.BrandPatreon),
DonationOption(
id = "buymeacoffee",
title = Res.string.welcome_donate_bmac_title,
subtitle = Res.string.welcome_donate_bmac_subtitle,
url = "https://www.buymeacoffee.com/krtirtho",
icon = Iconsax.BrandBuyMeACoffee),
DonationOption(
id = "opencollective",
title = Res.string.welcome_donate_opencollective_title,
subtitle = Res.string.welcome_donate_opencollective_subtitle,
url = "https://opencollective.com/spotube",
icon = Iconsax.BrandOpencollective))

View File

@ -17,17 +17,9 @@
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,

View File

@ -19,6 +19,7 @@ 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
@ -39,7 +40,7 @@ class WelcomeViewModel(
pageCount = pageCount,
isLastPage = index == pageCount - 1,
primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)],
donations = welcomeDonations)
donations = donationOptions)
}
.stateIn(
scope = viewModelScope,
@ -49,7 +50,7 @@ class WelcomeViewModel(
pageCount = welcomePrimaryLabels.size,
isLastPage = welcomePrimaryLabels.size == 1,
primaryLabel = welcomePrimaryLabels.first(),
donations = welcomeDonations))
donations = donationOptions))
fun goToPage(index: Int) {
currentPageIndex.update { index.coerceIn(0, welcomePrimaryLabels.lastIndex) }
@ -65,7 +66,7 @@ class WelcomeViewModel(
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) }
}

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

@ -39,5 +39,24 @@ class JVMPlatform : Platform {
actual fun getPlatform(): Platform = JVMPlatform()
actual fun openUrlInBrowser(url: String) {
Desktop.getDesktop().browse(URI(url))
// The app runs on a native (Tao) window, not an AWT frame, so java.awt.Desktop is not a
// reliable way to hand a URL to the OS. Prefer the platform's own opener process and only
// fall back to AWT when no opener is available on PATH.
if (openWithSystemOpener(url)) return
runCatching { Desktop.getDesktop().browse(URI(url)) }
}
private fun openWithSystemOpener(url: String): Boolean {
val osName = System.getProperty("os.name").lowercase()
val command = when {
osName.contains("win") -> listOf("rundll32", "url.dll,FileProtocolHandler", url)
osName.contains("mac") -> listOf("open", url)
osName.contains("nux") || osName.contains("nix") || osName.contains("aix") ->
listOf("xdg-open", url)
else -> return false
}
return runCatching {
ProcessBuilder(command).start()
true
}.getOrDefault(false)
}