Compare commits

...

3 Commits

Author SHA1 Message Date
Jonatan Nyberg
f28f1f6e05
Merge b0327fd224 into d720467338 2026-10-13 21:52:51 -07:00
Kingkor Roy Tirtho
d720467338 feat(support): implement support reminder dialog and donation options 2026-10-14 09:51:07 +06:00
Jonatan Nyberg
b0327fd224 translation: add Swedish resource translations
Signed-off-by: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com>
2026-09-20 14:17:16 +03:00
17 changed files with 676 additions and 128 deletions

View File

@ -0,0 +1,22 @@
<!--
~ 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/>.
-->
<resources>
<string name="app_name">Spotube</string>
<string name="now_playing">Spelas nu</string>
<string name="car_connect_hint">Spela musik från telefonen för att börja lyssna i bilen</string>
</resources>

View File

@ -0,0 +1,169 @@
<!--
~ 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/>.
-->
<resources>
<string name="settings_screen_title">Inställningar</string>
<string name="settings_section_plugins">Tillägg</string>
<string name="settings_section_language_region">Språk och region</string>
<string name="settings_section_appearance">Utseende</string>
<string name="settings_section_playback">Uppspelning</string>
<string name="settings_section_downloads">Nedladdningar</string>
<string name="settings_section_caching">Cachelagring</string>
<string name="settings_section_desktop">Dator</string>
<string name="settings_section_updates">Uppdateringar</string>
<string name="settings_action_close">Stäng</string>
<string name="settings_action_save">Spara</string>
<string name="settings_action_cancel">Avbryt</string>
<string name="settings_language_title">Språk</string>
<string name="settings_language_subtitle_current">Språk för appens gränssnitt. Nuvarande: %1$s</string>
<string name="settings_country_title">Land</string>
<string name="settings_country_subtitle_current">Land som styr innehåll och tillgänglighet. Nuvarande: %1$s</string>
<string name="settings_option_name_and_code">%1$s (%2$s)</string>
<string name="settings_theme_title">Tema</string>
<string name="settings_theme_subtitle_current">Välj hur Spotube anpassar sig till systemets utseende. Nuvarande: %1$s</string>
<string name="settings_theme_light">Ljust</string>
<string name="settings_theme_dark">Mörkt</string>
<string name="settings_theme_system">System</string>
<string name="settings_accent_title">Accentfärg</string>
<string name="settings_accent_subtitle_current">Nuvarande färgpalett: %1$s</string>
<string name="settings_accent_dialog_title">Accentfärg</string>
<string name="settings_accent_dialog_description">Välj en accentpalett och förhandsgranska den i både ljust och mörkt tema.</string>
<string name="settings_preview_light">Ljust</string>
<string name="settings_preview_dark">Mörkt</string>
<string name="settings_preview_label">Förhandsgranskning</string>
<string name="settings_preview_button">Knapp</string>
<string name="settings_accent_green_goblin">Gröna trollet</string>
<string name="settings_accent_electric_violet">Elektriskt violett</string>
<string name="settings_accent_oceanic_cyan">Havscyan</string>
<string name="settings_accent_sunset_orange">Solnedgångsorange</string>
<string name="settings_accent_rose_garden">Rosenträdgård</string>
<string name="settings_accent_midnight_blue">Midnattsblått</string>
<string name="settings_accent_metallic_slate">Metalliskt skiffergrått</string>
<string name="settings_download_folder_title">Egen nedladdningsmapp</string>
<string name="settings_download_folder_current">Nuvarande mapp: %1$s</string>
<string name="settings_download_folder_default">Nuvarande mapp: standardmappen för musik</string>
<string name="settings_download_folder_description">Lämna fältet tomt för att använda standardmappen för musik.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Lokala mediemappar</string>
<string name="settings_local_media_folders_subtitle_current">Egna mappar att skanna på datorn. Tillagda: %1$d</string>
<string name="settings_local_media_folders_subtitle_empty">Ingen egen mapp har lagts till ännu</string>
<string name="settings_local_media_folders_description">Lägg till mappar med mappväljaren. Den egna nedladdningsmappen tas med automatiskt.</string>
<string name="settings_local_media_folders_manage">Hantera</string>
<string name="settings_local_media_folders_add_action">Lägg till mapp</string>
<string name="settings_local_media_folders_remove_action">Ta bort mapp</string>
<string name="settings_local_media_folders_none_added">Inga egna mappar har lagts till.</string>
<string name="settings_download_format_title">Musikformat för nedladdning</string>
<string name="settings_download_format_subtitle_current">Önskad kodek och önskat behållarformat för nedladdningar. Nuvarande: %1$s</string>
<string name="settings_download_quality_title">Musikkvalitet för nedladdning</string>
<string name="settings_streaming_format_title">Musikformat för strömning</string>
<string name="settings_streaming_format_subtitle_current">Önskad kodek och önskat behållarformat för musikströmning. Nuvarande: %1$s</string>
<string name="settings_streaming_quality_title">Musikkvalitet för strömning</string>
<string name="settings_subtitle_current">Nuvarande: %1$s</string>
<string name="settings_enable_music_caching_title">Aktivera cachelagring av musik</string>
<string name="settings_enable_music_caching_subtitle">Lagra strömmat ljud lokalt så att det går snabbare att spela upp igen.</string>
<string name="settings_cache_folder_title">Cachemapp</string>
<string name="settings_cache_folder_current">Nuvarande: %1$s</string>
<string name="settings_cache_folder_default">Nuvarande: appens standardcache</string>
<string name="settings_cache_size_limit_title">Maximal cachestorlek</string>
<string name="settings_cache_size_limit_unlimited">Ingen gräns</string>
<string name="settings_cache_size_limit_current">Nuvarande gräns: %1$d MB</string>
<string name="settings_cache_size_limit_description">Ange den maximala cachestorleken i MB. Ange 0 eller lämna tomt för obegränsad storlek.</string>
<string name="settings_cache_size_limit_placeholder">500</string>
<string name="settings_error_cache_size_range">Storleken måste vara 0 eller ett positivt tal.</string>
<string name="settings_enable_endless_playback_title">Fortsätt spela automatiskt</string>
<string name="settings_enable_endless_playback_subtitle">Fortsätt spela rekommenderade låtar när kön tar slut.</string>
<string name="settings_enable_connect_title">Aktivera Connect</string>
<string name="settings_enable_connect_subtitle">Gör det möjligt att fjärrstyra uppspelningen via Spotube Connect.</string>
<string name="settings_playback_port_title">Port för uppspelningens proxyserver</string>
<string name="settings_playback_port_subtitle_current">Port som uppspelningens proxyserver använder. Nuvarande: %1$d</string>
<string name="settings_playback_port_description">Välj en port mellan 1 och 65535.</string>
<string name="settings_playback_port_placeholder">14769</string>
<string name="settings_error_whole_number">Ange ett giltigt heltal.</string>
<string name="settings_error_port_range">Porten måste vara mellan 1 och 65535.</string>
<string name="settings_blacklist_title">Spärrlista</string>
<string name="settings_blacklist_subtitle">Hantera spärrade låtar och artister</string>
<string name="settings_desktop_minimize_title">Minimera till meddelandefältet</string>
<string name="settings_desktop_minimize_subtitle">Låt Spotube fortsätta köras i meddelandefältet när fönstret minimeras.</string>
<string name="settings_desktop_discord_title">Discord Rich Presence</string>
<string name="settings_desktop_discord_subtitle">Visa din aktuella uppspelningsstatus i Discord på datorn.</string>
<string name="settings_updates_auto_check_title">Sök automatiskt efter uppdateringar</string>
<string name="settings_updates_auto_check_subtitle">Sök efter nya versioner av Spotube i bakgrunden.</string>
<string name="settings_plugins_manage_title">Hantera tillägg</string>
<string name="settings_plugins_manage_subtitle">Hantera dina tillägg</string>
<string name="settings_plugins_default_ability_title">Standardtillägg: %1$s</string>
<string name="settings_plugins_ability_metadata">Metadata</string>
<string name="settings_plugins_ability_audio">Ljud</string>
<string name="settings_plugins_ability_lyrics">Låttexter</string>
<string name="settings_plugins_ability_scrobble">Skrobbling</string>
<string name="settings_plugins_no_selection">Inget tillägg valt</string>
<string name="settings_plugins_action_change">Ändra</string>
<string name="settings_plugins_action_select">Välj</string>
<string name="settings_plugins_selected">Valt</string>
<string name="settings_plugins_clear">Rensa val</string>
<string name="settings_plugins_no_plugins">Inga tillägg är tillgängliga</string>
<string name="settings_plugins_plugin_content_description">Tillägget %1$s</string>
<string name="settings_plugins_clear_content_description">Rensa</string>
<string name="plugin_configure_title">Konfigurera</string>
<string name="plugin_screen_title">Tilläggshanterare</string>
<string name="plugin_error_enter_url">Ange en URL</string>
<string name="plugin_error_url_scheme">URL:en måste börja med http:// eller https://</string>
<string name="plugin_error_download_failed">Det gick inte att ladda ner tillägget</string>
<string name="plugin_empty_title">Inga tillägg har installerats ännu</string>
<string name="plugin_empty_subtitle">Klistra in en nedladdningsadress eller välj en fil ovan.</string>
<string name="plugin_installed_count">Installerade %2$s: %1$d</string>
<string name="plugin_installed_singular">tillägg</string>
<string name="plugin_installed_plural">tillägg</string>
<string name="plugin_install_section_title">Installera ett tillägg</string>
<string name="plugin_url_placeholder">https://example.com/plugin.smplug</string>
<string name="plugin_action_download">Ladda ner</string>
<string name="plugin_action_install_from_file">Installera från fil (.smplug)</string>
<string name="plugin_state_active">Aktivt</string>
<string name="plugin_version_label">v%1$s</string>
<string name="plugin_state_builtin">Inbyggt</string>
<string name="plugin_action_remove">Ta bort tillägg</string>
<string name="plugin_action_login">Logga in</string>
<string name="plugin_action_logout">Logga ut</string>
<string name="plugin_section_url_title">Ladda ner från URL</string>
<string name="plugin_section_file_title">Installera från fil</string>
<string name="plugin_section_install">Installera</string>
<string name="plugin_permissions_author_version">• %1$s</string>
<string name="plugin_permissions_requested_title">Begärda behörigheter</string>
<string name="plugin_permissions_none">Det här tillägget kräver inga särskilda behörigheter.</string>
<string name="plugin_permissions_compare_title">Installerat och tillhandahållet</string>
<string name="plugin_permissions_installed_label">Installerat</string>
<string name="plugin_permissions_supplied_label">Tillhandahållet</string>
<string name="plugin_permissions_api_diff">API %1$s → %2$s</string>
<string name="plugin_permissions_capability_storage_title">Beständig lagring</string>
<string name="plugin_permissions_capability_storage_desc">Läsa och skriva data på disken</string>
<string name="plugin_permissions_capability_network_title">Nätverksförfrågningar</string>
<string name="plugin_permissions_capability_network_desc">Skicka och ta emot data via internet</string>
<string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Visa webbinnehåll i appen</string>
</resources>

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,151 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.support
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.openUrlInBrowser
import dev.krtirtho.spotube.resources.iconsax.BrandBuyMeACoffee
import dev.krtirtho.spotube.resources.iconsax.BrandOpencollective
import dev.krtirtho.spotube.resources.iconsax.BrandPatreon
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIcon
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.support_dialog_dismiss
import spotube.composeapp.generated.resources.support_dialog_message
import spotube.composeapp.generated.resources.support_dialog_reminder_hint
import spotube.composeapp.generated.resources.support_dialog_title
import spotube.composeapp.generated.resources.welcome_donate_bmac_subtitle
import spotube.composeapp.generated.resources.welcome_donate_bmac_title
import spotube.composeapp.generated.resources.welcome_donate_opencollective_subtitle
import spotube.composeapp.generated.resources.welcome_donate_opencollective_title
import spotube.composeapp.generated.resources.welcome_donate_patreon_subtitle
import spotube.composeapp.generated.resources.welcome_donate_patreon_title
/** A single donation destination offered across the app. */
data class DonationOption(
val id: String,
val title: StringResource,
val subtitle: StringResource,
val url: String,
val icon: ImageVector)
/** The donation destinations shown in onboarding, settings and the support prompt. */
val donationOptions: List<DonationOption> = listOf(
DonationOption(
id = "patreon",
title = Res.string.welcome_donate_patreon_title,
subtitle = Res.string.welcome_donate_patreon_subtitle,
url = "https://patreon.com/krtirtho",
icon = Iconsax.BrandPatreon),
DonationOption(
id = "buymeacoffee",
title = Res.string.welcome_donate_bmac_title,
subtitle = Res.string.welcome_donate_bmac_subtitle,
url = "https://www.buymeacoffee.com/krtirtho",
icon = Iconsax.BrandBuyMeACoffee),
DonationOption(
id = "opencollective",
title = Res.string.welcome_donate_opencollective_title,
subtitle = Res.string.welcome_donate_opencollective_subtitle,
url = "https://opencollective.com/spotube",
icon = Iconsax.BrandOpencollective))
/** Minimum time between two automatic support reminders. */
const val SupportReminderIntervalMillis: Long = 7L * 24 * 60 * 60 * 1000
/**
* Opens the support prompt from anywhere inside the app shell. Hosted at the app root so the
* sidebar, home top bar and the periodic reminder all drive the same dialog.
*/
val LocalSupportPrompt = staticCompositionLocalOf { {} }
/** Opens a donation destination in the user's browser. */
fun openDonation(option: DonationOption) {
runCatching { openUrlInBrowser(option.url) }
}
/** A dialog that lets the user pick a way to support the project. */
@Composable
fun SupportDialog(
visible: Boolean,
onDismissRequest: () -> Unit,
isReminder: Boolean = false) {
if (!visible) return
MillenniumDialog(
visible = true,
onDismissRequest = onDismissRequest,
title = stringResource(Res.string.support_dialog_title)) {
Text(
stringResource(Res.string.support_dialog_message),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant)
if (isReminder) {
Text(
stringResource(Res.string.support_dialog_reminder_hint),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
}
donationOptions.forEach { option ->
MillenniumButton(
onClick = {
openDonation(option)
onDismissRequest()
},
modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Secondary) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumIcon(
imageVector = option.icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (option.id == "patreon") {
AppTheme.colors.onSurface
} else {
Color.Unspecified
})
Text(stringResource(option.title))
}
}
}
MillenniumButton(
onClick = onDismissRequest,
modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Quiet) {
Text(stringResource(Res.string.support_dialog_dismiss))
}
}
}

View File

@ -17,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.*