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_caching">Caching</string>
<string name="settings_section_desktop">Desktop</string> <string name="settings_section_desktop">Desktop</string>
<string name="settings_section_updates">Updates</string> <string name="settings_section_updates">Updates</string>
<string name="settings_section_support">Support</string>
<string name="settings_section_about">About</string> <string name="settings_section_about">About</string>
<string name="settings_action_close">Close</string> <string name="settings_action_close">Close</string>
@ -301,6 +302,15 @@
<string name="welcome_donate_action">Sponsor</string> <string name="welcome_donate_action">Sponsor</string>
<string name="welcome_donate_open_content_description">Open %1$s</string> <string name="welcome_donate_open_content_description">Open %1$s</string>
<!-- Support / donations -->
<string name="support_button_label">Support Spotube</string>
<string name="support_dialog_title">Support Spotube</string>
<string name="support_dialog_message">Spotube is free, libre and open source — no ads, no subscriptions, no tracking. If it brings you joy, consider supporting the project.</string>
<string name="support_dialog_reminder_hint">You can turn these reminders off in Settings → Support.</string>
<string name="support_dialog_dismiss">Maybe later</string>
<string name="settings_support_reminders_title">Support reminders</string>
<string name="settings_support_reminders_subtitle">Show a gentle reminder to support the project every 7 days.</string>
<!-- Page 3: plugins --> <!-- Page 3: plugins -->
<string name="welcome_plugins_eyebrow">Modular Architecture</string> <string name="welcome_plugins_eyebrow">Modular Architecture</string>
<string name="welcome_plugins_title">Choose where your music comes from.</string> <string name="welcome_plugins_title">Choose where your music comes from.</string>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,76 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.lazy.LazyListScope
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
import dev.krtirtho.spotube.modules.support.donationOptions
import dev.krtirtho.spotube.modules.support.openDonation
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_section_support
import spotube.composeapp.generated.resources.settings_support_reminders_subtitle
import spotube.composeapp.generated.resources.settings_support_reminders_title
/** Support settings: the periodic reminder toggle plus the donation destinations. */
internal fun LazyListScope.supportSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
includeHeader: Boolean = true) {
if (includeHeader) {
settingsSectionHeader(Res.string.settings_section_support)
}
settingsSectionCard(
items = buildList {
add {
SwitchSettingCard(
title = stringResource(Res.string.settings_support_reminders_title),
subtitle = stringResource(Res.string.settings_support_reminders_subtitle),
icon = {
SettingsItemIcon(
imageVector = Iconsax.IconsaxHeart2,
contentDescription = stringResource(
Res.string.settings_support_reminders_title))
},
checked = settings.supportRemindersEnabled,
onCheckedChange = { enabled ->
settingsViewModel.updateSettings {
copy(supportRemindersEnabled = enabled)
}
})
}
donationOptions.forEach { option ->
add {
SettingCardItem(
title = stringResource(option.title),
subtitle = stringResource(option.subtitle),
icon = {
SettingsItemIcon(
imageVector = option.icon,
contentDescription = stringResource(option.title))
},
onClick = { openDonation(option) })
}
}
})
}

View File

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

View File

@ -252,19 +252,24 @@ fun AppLargePlayer(
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} }
Column(modifier = Modifier.padding(start = 12.dp)) { Column(
modifier = Modifier
.weight(1f, fill = false)
.padding(start = 12.dp)
) {
Text( Text(
text = playerUiState.title, text = playerUiState.title,
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( PlayerArtistsText(
text = playerUiState.artists, artists = playerUiState.artistList,
onArtistClick = { artistId ->
navigationCommands.navigateTo(Routes.Artist(artistId))
},
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} }
Spacer(modifier = Modifier.width(10.dp)) 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.rememberLogger
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
internal data class PlayerArtist(
val id: String?,
val name: String
)
internal data class PlayerUiState( internal data class PlayerUiState(
val queue: List<QueueEntry> = emptyList(), val queue: List<QueueEntry> = emptyList(),
val currentQueueEntry: QueueEntry? = null, val currentQueueEntry: QueueEntry? = null,
val title: String = "No-op track title", val title: String = "No-op track title",
val artists: String = "No-op artists", val artists: String = "No-op artists",
val artistList: List<PlayerArtist> = emptyList(),
val album: String = "", val album: String = "",
val coverUrl: String = "", val coverUrl: String = "",
val position: Duration = 0.milliseconds, val position: Duration = 0.milliseconds,
@ -43,7 +47,8 @@ internal data class PlayerUiState(
val playerState: PlayerState = PlayerState.IDLE, val playerState: PlayerState = PlayerState.IDLE,
val loopState: LoopState = LoopState.NONE, val loopState: LoopState = LoopState.NONE,
val isShuffling: Boolean = false, val isShuffling: Boolean = false,
val volume: Float = 0f) { val volume: Float = 0f
) {
val isPlaying: Boolean get() = playerState == PlayerState.PLAYING val isPlaying: Boolean get() = playerState == PlayerState.PLAYING
val displayDuration: Duration val displayDuration: Duration
get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration
@ -73,7 +78,8 @@ internal data class PlayerUiState(
@Composable @Composable
internal fun rememberPlayerUiState( internal fun rememberPlayerUiState(
audioPlayer: AudioPlayerInterface, audioPlayer: AudioPlayerInterface,
audioPlayerQueue: AudioPlayerQueue): PlayerUiState { audioPlayerQueue: AudioPlayerQueue
): PlayerUiState {
val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList()) val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList())
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsState(initial = null) val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsState(initial = null)
val playerState by audioPlayer.playerStateFlow.collectAsState() val playerState by audioPlayer.playerStateFlow.collectAsState()
@ -89,6 +95,7 @@ internal fun rememberPlayerUiState(
currentQueueEntry = currentQueueEntry, currentQueueEntry = currentQueueEntry,
title = currentQueueEntry?.displayTitle().orEmpty().ifBlank { "No-op track title" }, title = currentQueueEntry?.displayTitle().orEmpty().ifBlank { "No-op track title" },
artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { "No-op artists" }, artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { "No-op artists" },
artistList = currentQueueEntry?.displayArtistList().orEmpty(),
album = currentQueueEntry?.displayAlbum().orEmpty(), album = currentQueueEntry?.displayAlbum().orEmpty(),
coverUrl = currentQueueEntry?.coverUrl().orEmpty(), coverUrl = currentQueueEntry?.coverUrl().orEmpty(),
position = position, position = position,
@ -97,7 +104,8 @@ internal fun rememberPlayerUiState(
playerState = playerState, playerState = playerState,
loopState = loopState, loopState = loopState,
isShuffling = isShuffling, isShuffling = isShuffling,
volume = volume) volume = volume
)
// val logger = rememberLogger<PlayerUiState>() // val logger = rememberLogger<PlayerUiState>()
// logger.d { state.toDebugString() } // 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 { private fun QueueEntry.displayAlbum(): String {
return when (this) { return when (this) {
is QueueEntry.StreamingTrack -> track.album?.title ?: "Unknown Album" 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.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.sidebarTabs import dev.krtirtho.spotube.sidebarTabs
import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.Font
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.spotube.millennium.composables.MillenniumSidebar import org.spotube.millennium.composables.MillenniumSidebar
import org.spotube.millennium.composables.MillenniumSidebarContent import org.spotube.millennium.composables.MillenniumSidebarContent
@ -63,6 +66,8 @@ import org.spotube.millennium.composables.MillenniumSidebarMenu
import org.spotube.millennium.composables.MillenniumSidebarTrigger import org.spotube.millennium.composables.MillenniumSidebarTrigger
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.cookie_regular import spotube.composeapp.generated.resources.cookie_regular
import spotube.composeapp.generated.resources.settings_screen_title
import spotube.composeapp.generated.resources.support_button_label
@Composable @Composable
fun AppSidebar( fun AppSidebar(
@ -74,6 +79,7 @@ fun AppSidebar(
var expanded by rememberSaveable { mutableStateOf(true) } var expanded by rememberSaveable { mutableStateOf(true) }
val groupExpansion = remember { mutableStateMapOf<String, Boolean>() } val groupExpansion = remember { mutableStateMapOf<String, Boolean>() }
val currentLibraryTab by libraryState.currentTab.collectAsState() val currentLibraryTab by libraryState.currentTab.collectAsState()
val openSupport = LocalSupportPrompt.current
MillenniumSidebar( MillenniumSidebar(
modifier = modifier, modifier = modifier,
@ -143,10 +149,17 @@ fun AppSidebar(
} }
} }
MillenniumSidebarFooter { MillenniumSidebarFooter {
val supportLabel = stringResource(Res.string.support_button_label)
MillenniumSidebarItem( MillenniumSidebarItem(
label = "Settings", label = supportLabel,
onClick = { openSupport() },
contentDescription = supportLabel,
icon = Iconsax.IconsaxHeart2)
MillenniumSidebarItem(
label = stringResource(Res.string.settings_screen_title),
onClick = { navigator.navigate(Routes.Settings) }, onClick = { navigator.navigate(Routes.Settings) },
selected = navigationState.topLevelRoute == Routes.Settings, selected = navigationState.topLevelRoute == Routes.Settings,
contentDescription = stringResource(Res.string.settings_screen_title),
icon = Iconsax.IconsaxSetting2) icon = Iconsax.IconsaxSetting2)
} }
} }

View File

@ -0,0 +1,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 package dev.krtirtho.spotube.modules.welcome
import dev.krtirtho.spotube.resources.iconsax.BrandBuyMeACoffee
import dev.krtirtho.spotube.resources.iconsax.BrandOpencollective
import dev.krtirtho.spotube.resources.iconsax.BrandPatreon
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.welcome_donate_bmac_subtitle
import spotube.composeapp.generated.resources.welcome_donate_bmac_title
import spotube.composeapp.generated.resources.welcome_donate_opencollective_subtitle
import spotube.composeapp.generated.resources.welcome_donate_opencollective_title
import spotube.composeapp.generated.resources.welcome_donate_patreon_subtitle
import spotube.composeapp.generated.resources.welcome_donate_patreon_title
import spotube.composeapp.generated.resources.welcome_get_started import spotube.composeapp.generated.resources.welcome_get_started
import spotube.composeapp.generated.resources.welcome_lets_go import spotube.composeapp.generated.resources.welcome_lets_go
import spotube.composeapp.generated.resources.welcome_next import spotube.composeapp.generated.resources.welcome_next
@ -50,23 +40,3 @@ internal val welcomeStepLabels: List<StringResource> = listOf(
Res.string.welcome_step_support, Res.string.welcome_step_support,
Res.string.welcome_step_plugins, Res.string.welcome_step_plugins,
Res.string.welcome_step_guide) 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 package dev.krtirtho.spotube.modules.welcome
import androidx.compose.ui.graphics.vector.ImageVector import dev.krtirtho.spotube.modules.support.DonationOption
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
/** A single donation destination shown on the support page. */
data class DonationOption(
val id: String,
val title: StringResource,
val subtitle: StringResource,
val url: String,
val icon: ImageVector)
data class WelcomeUiState( data class WelcomeUiState(
val currentPageIndex: Int, val currentPageIndex: Int,
val pageCount: Int, val pageCount: Int,

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube.modules.welcome
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.modules.support.donationOptions
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -39,7 +40,7 @@ class WelcomeViewModel(
pageCount = pageCount, pageCount = pageCount,
isLastPage = index == pageCount - 1, isLastPage = index == pageCount - 1,
primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)], primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)],
donations = welcomeDonations) donations = donationOptions)
} }
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -49,7 +50,7 @@ class WelcomeViewModel(
pageCount = welcomePrimaryLabels.size, pageCount = welcomePrimaryLabels.size,
isLastPage = welcomePrimaryLabels.size == 1, isLastPage = welcomePrimaryLabels.size == 1,
primaryLabel = welcomePrimaryLabels.first(), primaryLabel = welcomePrimaryLabels.first(),
donations = welcomeDonations)) donations = donationOptions))
fun goToPage(index: Int) { fun goToPage(index: Int) {
currentPageIndex.update { index.coerceIn(0, welcomePrimaryLabels.lastIndex) } currentPageIndex.update { index.coerceIn(0, welcomePrimaryLabels.lastIndex) }
@ -65,7 +66,7 @@ class WelcomeViewModel(
fun openDonation(url: String) { fun openDonation(url: String) {
// Only destinations owned by our static funding list can be opened here. // Only destinations owned by our static funding list can be opened here.
if (welcomeDonations.none { it.url == url }) return if (donationOptions.none { it.url == url }) return
runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) } runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) }
} }

View File

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

View File

@ -39,5 +39,24 @@ class JVMPlatform : Platform {
actual fun getPlatform(): Platform = JVMPlatform() actual fun getPlatform(): Platform = JVMPlatform()
actual fun openUrlInBrowser(url: String) { 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)
} }