mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
5 Commits
937edf8dfc
...
d5ffe51e97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5ffe51e97 | ||
|
|
d720467338 | ||
|
|
8efcbd4af4 | ||
|
|
76518a01a1 | ||
|
|
eb30f98056 |
10
.github/workflows/spotube-release-binary.yml
vendored
10
.github/workflows/spotube-release-binary.yml
vendored
@ -106,7 +106,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download maven local
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: maven-local
|
||||
path: ~/.m2/repository/
|
||||
@ -168,7 +168,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download maven local
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: maven-local
|
||||
path: ~/.m2/repository/
|
||||
@ -290,7 +290,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download maven local
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: maven-local
|
||||
path: ~/.m2/repository/
|
||||
@ -336,7 +336,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download maven local
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: maven-local
|
||||
path: ~/.m2/repository/
|
||||
@ -408,7 +408,7 @@ jobs:
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
<string name="settings_section_caching">Caching</string>
|
||||
<string name="settings_section_desktop">Desktop</string>
|
||||
<string name="settings_section_updates">Updates</string>
|
||||
<string name="settings_section_support">Support</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
|
||||
<string name="settings_action_close">Close</string>
|
||||
@ -230,6 +231,9 @@
|
||||
<string name="player_go_to_album">Go to album</string>
|
||||
<string name="player_sleep_timer">Sleep timer</string>
|
||||
<string name="player_track_details">Track details</string>
|
||||
<string name="player_play_content_description">Play</string>
|
||||
<string name="player_pause_content_description">Pause</string>
|
||||
<string name="player_add_to_queue_content_description">Add to queue</string>
|
||||
|
||||
<string name="update_action_ignore">Ignore</string>
|
||||
<string name="update_action_update">Update</string>
|
||||
@ -246,7 +250,7 @@
|
||||
<string name="welcome_app_name">Spotube</string>
|
||||
<string name="welcome_brand_freedom">Music, without the strings.</string>
|
||||
<string name="welcome_brand_quiet_description">An open-source player for a world of music.\nYours to explore. Yours to make your own.</string>
|
||||
<string name="welcome_donate_quiet_title">Keep music free.</string>
|
||||
<string name="welcome_donate_quiet_title">Keep this project alive.</string>
|
||||
<string name="welcome_donate_letter">Spotube is built by people, not a subscription business. If it becomes part of your everyday life, a little support helps us give it the care it deserves.\n\nThank you for making room for independent software.</string>
|
||||
<string name="welcome_plugins_setup_guide">Start with a catalog plugin to find songs and playlists. Choose an audio plugin to play them. Lyrics and listening history are optional. Discover a plugin below, review its permissions, install it, then select it here.</string>
|
||||
<string name="welcome_plugins_discover">Discover plugins</string>
|
||||
@ -255,12 +259,21 @@
|
||||
<string name="welcome_plugins_more">More plugins</string>
|
||||
<string name="welcome_plugins_discovery_empty">No new plugins found. You can also install one from a link or a .smplug file above.</string>
|
||||
<string name="welcome_plugins_discovery_error">Plugin discovery is unavailable right now. Try again, or install from a link or file.</string>
|
||||
<string name="welcome_how_quiet_title">A familiar player. A different kind of freedom.</string>
|
||||
<string name="welcome_how_quiet_description">Browse through your chosen catalog. Press play and your audio plugin finds the stream.\n\nYour device handles playback and caching. Add synced lyrics, save tracks for later, or listen together with Group Jam.</string>
|
||||
<string name="welcome_demo_streaming">DEMONSTRATION</string>
|
||||
<string name="welcome_demo_paused">PAUSED</string>
|
||||
<string name="welcome_demo_pause_action">Pause demonstration</string>
|
||||
<string name="welcome_demo_play_action">Play demonstration</string>
|
||||
<string name="welcome_step_welcome">Welcome</string>
|
||||
<string name="welcome_step_support">Support</string>
|
||||
<string name="welcome_step_plugins">Plugins</string>
|
||||
<string name="welcome_step_guide">Guide</string>
|
||||
|
||||
<string name="welcome_guide_title">How to use Spotube</string>
|
||||
<string name="welcome_guide_subtitle">Four quick steps and you’re listening.</string>
|
||||
<string name="welcome_guide_signin_title">Sign in</string>
|
||||
<string name="welcome_guide_signin_desc">Connect your Spotify account, or pick your own catalog plugin.</string>
|
||||
<string name="welcome_guide_search_title">Search and play</string>
|
||||
<string name="welcome_guide_search_desc">Find any song, album, or artist, then press play.</string>
|
||||
<string name="welcome_guide_offline_title">Listen offline</string>
|
||||
<string name="welcome_guide_offline_desc">Download tracks to keep playing without a connection.</string>
|
||||
<string name="welcome_guide_lyrics_title">Follow the lyrics</string>
|
||||
<string name="welcome_guide_lyrics_desc">Open synced lyrics from the player while a track plays.</string>
|
||||
|
||||
<!-- Page 1: branding -->
|
||||
<string name="welcome_brand_tagline">Your music. Your sources. Your rules.</string>
|
||||
@ -289,6 +302,15 @@
|
||||
<string name="welcome_donate_action">Sponsor</string>
|
||||
<string name="welcome_donate_open_content_description">Open %1$s</string>
|
||||
|
||||
<!-- Support / donations -->
|
||||
<string name="support_button_label">Support Spotube</string>
|
||||
<string name="support_dialog_title">Support Spotube</string>
|
||||
<string name="support_dialog_message">Spotube is free, libre and open source — no ads, no subscriptions, no tracking. If it brings you joy, consider supporting the project.</string>
|
||||
<string name="support_dialog_reminder_hint">You can turn these reminders off in Settings → Support.</string>
|
||||
<string name="support_dialog_dismiss">Maybe later</string>
|
||||
<string name="settings_support_reminders_title">Support reminders</string>
|
||||
<string name="settings_support_reminders_subtitle">Show a gentle reminder to support the project every 7 days.</string>
|
||||
|
||||
<!-- Page 3: plugins -->
|
||||
<string name="welcome_plugins_eyebrow">Modular Architecture</string>
|
||||
<string name="welcome_plugins_title">Choose where your music comes from.</string>
|
||||
|
||||
@ -24,12 +24,16 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import dev.krtirtho.spotube.core.DebugConfig
|
||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||
@ -41,6 +45,9 @@ import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
import dev.krtirtho.spotube.modules.shell.AppShell
|
||||
import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
|
||||
import dev.krtirtho.spotube.modules.support.SupportDialog
|
||||
import dev.krtirtho.spotube.modules.support.SupportReminderIntervalMillis
|
||||
import dev.krtirtho.spotube.modules.update.AppUpdateDialog
|
||||
import dev.krtirtho.spotube.modules.update.AppUpdateService
|
||||
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
|
||||
@ -59,6 +66,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
|
||||
import dev.krtirtho.spotube.resources.iconsax.User
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlin.time.Clock
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.navigation3.koinEntryProvider
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
@ -138,6 +146,40 @@ fun App(
|
||||
val welcomeRepository: WelcomeRepository = koinInject()
|
||||
val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle()
|
||||
|
||||
var showSupportDialog by remember { mutableStateOf(false) }
|
||||
var supportDialogIsReminder by remember { mutableStateOf(false) }
|
||||
|
||||
// Nudge the user to support the project at most once every seven days, unless they opted
|
||||
// out in Settings. The first eligible launch after onboarding only records the baseline, so
|
||||
// the prompt never appears right after the onboarding donation page.
|
||||
LaunchedEffect(showOnboarding) {
|
||||
if (showOnboarding) return@LaunchedEffect
|
||||
// Read the persisted value rather than the collected state, whose initial value is the
|
||||
// default (0) until DataStore has emitted once.
|
||||
val current = settingsRepository.getCurrentSettings()
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
// Development override: show the reminder on every launch, regardless of timing or the
|
||||
// "Support reminders" toggle.
|
||||
if (DebugConfig.FORCE_SUPPORT_REMINDER) {
|
||||
supportDialogIsReminder = true
|
||||
showSupportDialog = true
|
||||
settingsRepository.updateSettings(
|
||||
current.copy(lastSupportReminderEpochMs = now))
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (!current.supportRemindersEnabled) return@LaunchedEffect
|
||||
val last = current.lastSupportReminderEpochMs
|
||||
if (last == 0L) {
|
||||
settingsRepository.updateSettings(
|
||||
current.copy(lastSupportReminderEpochMs = now))
|
||||
} else if (now - last >= SupportReminderIntervalMillis) {
|
||||
supportDialogIsReminder = true
|
||||
showSupportDialog = true
|
||||
settingsRepository.updateSettings(
|
||||
current.copy(lastSupportReminderEpochMs = now))
|
||||
}
|
||||
}
|
||||
|
||||
// The app always renders the shell + NavDisplay; onboarding is just the start destination.
|
||||
// When it is completed the destination flips to Home and the navigation state is rebuilt.
|
||||
val startRoute = if (showOnboarding) Routes.Welcome else Routes.Home
|
||||
@ -150,52 +192,61 @@ fun App(
|
||||
}
|
||||
|
||||
SpotubeTheme(settings = userSettings) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val currentRoute =
|
||||
navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
CompositionLocalProvider(LocalSupportPrompt provides {
|
||||
supportDialogIsReminder = false
|
||||
showSupportDialog = true
|
||||
}) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val currentRoute =
|
||||
navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AppShell(navigator, navigationState) {
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(
|
||||
LocalSharedTransitionScope provides this@SharedTransitionLayout
|
||||
) {
|
||||
Column {
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onBack = navigator::pop,
|
||||
entries = navigationState.toEntries(koinEntryProvider())
|
||||
)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AppShell(navigator, navigationState) {
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(
|
||||
LocalSharedTransitionScope provides this@SharedTransitionLayout
|
||||
) {
|
||||
Column {
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onBack = navigator::pop,
|
||||
entries = navigationState.toEntries(koinEntryProvider())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
when (currentRoute) {
|
||||
Routes.WebView -> WebViewScreen(koinInject())
|
||||
else -> {}
|
||||
when (currentRoute) {
|
||||
Routes.WebView -> WebViewScreen(koinInject())
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
content()
|
||||
availableAppUpdate?.let { update ->
|
||||
AppUpdateDialog(
|
||||
update = update,
|
||||
onDismiss = appUpdateService::dismissUpdate,
|
||||
onIgnore = appUpdateService::ignoreUpdate,
|
||||
onUpdate = {
|
||||
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
||||
appUpdateService.dismissUpdate()
|
||||
})
|
||||
}
|
||||
availablePluginUpdate?.let { update ->
|
||||
PluginUpdateDialog(
|
||||
update = update,
|
||||
onDismiss = pluginUpdateService::dismissUpdate,
|
||||
onIgnore = pluginUpdateService::ignoreUpdate,
|
||||
onUpdate = {
|
||||
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
||||
pluginUpdateService.dismissUpdate()
|
||||
})
|
||||
content()
|
||||
availableAppUpdate?.let { update ->
|
||||
AppUpdateDialog(
|
||||
update = update,
|
||||
onDismiss = appUpdateService::dismissUpdate,
|
||||
onIgnore = appUpdateService::ignoreUpdate,
|
||||
onUpdate = {
|
||||
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
||||
appUpdateService.dismissUpdate()
|
||||
})
|
||||
}
|
||||
availablePluginUpdate?.let { update ->
|
||||
PluginUpdateDialog(
|
||||
update = update,
|
||||
onDismiss = pluginUpdateService::dismissUpdate,
|
||||
onIgnore = pluginUpdateService::ignoreUpdate,
|
||||
onUpdate = {
|
||||
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
||||
pluginUpdateService.dismissUpdate()
|
||||
})
|
||||
}
|
||||
SupportDialog(
|
||||
visible = showSupportDialog,
|
||||
onDismissRequest = { showSupportDialog = false },
|
||||
isReminder = supportDialogIsReminder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,5 +29,14 @@ object DebugConfig {
|
||||
*
|
||||
* Keep it `false` when committing.
|
||||
*/
|
||||
const val FORCE_SHOW_ONBOARDING: Boolean = true
|
||||
const val FORCE_SHOW_ONBOARDING: Boolean = false
|
||||
|
||||
/**
|
||||
* Flip to `true` while developing to show the support reminder dialog on the next eligible
|
||||
* app launch, ignoring both the 7-day interval and the "Support reminders" opt-out toggle.
|
||||
* It still waits until onboarding is dismissed, and only fires once per launch.
|
||||
*
|
||||
* Keep it `false` when committing.
|
||||
*/
|
||||
const val FORCE_SUPPORT_REMINDER: Boolean = false
|
||||
}
|
||||
|
||||
@ -62,6 +62,11 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.player_add_to_queue_content_description
|
||||
import spotube.composeapp.generated.resources.player_pause_content_description
|
||||
import spotube.composeapp.generated.resources.player_play_content_description
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
@ -107,14 +112,16 @@ fun PlayableCard(
|
||||
) {
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
this@Column.AnimatedVisibility(
|
||||
visible = isHovered,
|
||||
visible = isHovered || (isPlaying && onPlay != null),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
onAddToQueue?.let {
|
||||
// While this collection is playing the play/pause toggle stays
|
||||
// visible and the add-to-queue action is hidden entirely.
|
||||
onAddToQueue?.takeIf { !isPlaying }?.let {
|
||||
MillenniumIconButton(
|
||||
onClick = onAddToQueue,
|
||||
modifier = Modifier.size(34.dp),
|
||||
@ -122,10 +129,11 @@ fun PlayableCard(
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to queue")
|
||||
contentDescription = stringResource(
|
||||
Res.string.player_add_to_queue_content_description))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
onPlay?.let {
|
||||
MillenniumIconButton(
|
||||
onClick = onPlay,
|
||||
@ -137,7 +145,12 @@ fun PlayableCard(
|
||||
imageVector = if (isPlaying) {
|
||||
Iconsax.IconsaxPause
|
||||
} else Iconsax.IconsaxPlay,
|
||||
contentDescription = "Play")
|
||||
contentDescription = stringResource(
|
||||
if (isPlaying) {
|
||||
Res.string.player_pause_content_description
|
||||
} else {
|
||||
Res.string.player_play_content_description
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,9 +107,12 @@ fun TextWithShimmer(
|
||||
minLines: Int = 1,
|
||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||
style: TextStyle = LocalTextStyle.current) {
|
||||
// While a skeleton tree is loading, the placeholder glyphs must not be readable:
|
||||
// only the shimmer bars (from `shimmerApply`) should show through.
|
||||
val isLoadingSkeleton = LocalSkeletonLoading.current
|
||||
Text(
|
||||
text = text,
|
||||
color = color,
|
||||
color = if (isLoadingSkeleton) Color.Transparent else color,
|
||||
modifier = Modifier.shimmerApply().then(modifier),
|
||||
autoSize = autoSize,
|
||||
fontSize = fontSize,
|
||||
|
||||
@ -50,11 +50,13 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
||||
@ -75,21 +77,26 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.getPlatform
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||
import dev.krtirtho.spotube.resources.iconsax.User
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.support_button_label
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.jetbrains.compose.resources.Font
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import spotube.composeapp.generated.resources.cookie_regular
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
val platform = getPlatform()
|
||||
val isDesktop = platform.type == PlatformType.Windows ||
|
||||
platform.type == PlatformType.Linux ||
|
||||
platform.type == PlatformType.MacOS
|
||||
|
||||
val openSupport = LocalSupportPrompt.current
|
||||
val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@ -124,27 +131,55 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MillenniumTheme.colors.background)) {
|
||||
.background(MillenniumTheme.colors.background)
|
||||
) {
|
||||
ApplicationMainBar(
|
||||
backButton = false,
|
||||
title = {
|
||||
Text("Browse")
|
||||
if (isSmallScreen) {
|
||||
Text(
|
||||
"Spotube",
|
||||
style = AppTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily(
|
||||
listOf(Font(Res.font.cookie_regular, weight = FontWeight.Normal))
|
||||
)
|
||||
),
|
||||
color = AppTheme.colors.onSurface
|
||||
)
|
||||
} else {
|
||||
Text("Browse")
|
||||
}
|
||||
|
||||
},
|
||||
actions = {
|
||||
if (!isDesktop) {
|
||||
if (isSmallScreen) {
|
||||
MillenniumIconButton(
|
||||
onClick = { navigationCommands.navigateTo(Routes.Devices) },
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxMirroringScreen,
|
||||
contentDescription = "Devices")
|
||||
contentDescription = "Devices"
|
||||
)
|
||||
}
|
||||
MillenniumIconButton(
|
||||
onClick = { navigationCommands.navigateTo(Routes.Jam) },
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.User,
|
||||
contentDescription = "Group Jam")
|
||||
contentDescription = "Group Jam"
|
||||
)
|
||||
}
|
||||
val supportLabel = stringResource(Res.string.support_button_label)
|
||||
MillenniumIconButton(
|
||||
onClick = openSupport,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxHeart2,
|
||||
contentDescription = supportLabel
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -153,7 +188,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||
.fillMaxSize()
|
||||
.weight(1f)
|
||||
) {
|
||||
if (isDesktop) {
|
||||
if (!isSmallScreen) {
|
||||
HomeContent(
|
||||
listState = listState,
|
||||
state = state,
|
||||
@ -165,7 +200,8 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||
onRefresh = {
|
||||
viewModel.refresh()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()) {
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
HomeContent(
|
||||
listState = listState,
|
||||
state = state,
|
||||
@ -188,7 +224,8 @@ private fun HomeContent(
|
||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||
state: HomeScreenState,
|
||||
onRetry: () -> Unit,
|
||||
onGenreSelected: (String) -> Unit) {
|
||||
onGenreSelected: (String) -> Unit
|
||||
) {
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
@ -198,31 +235,37 @@ private fun HomeContent(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
when (state) {
|
||||
is HomeScreenState.Loading -> {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
repeat(3) {
|
||||
SkeletonTree(true) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp)
|
||||
.padding(horizontal = 16.dp))
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp)) {
|
||||
contentPadding = PaddingValues(horizontal = 16.dp)
|
||||
) {
|
||||
items(4) {
|
||||
PlayableCard(
|
||||
title = "Item Title",
|
||||
subtitle = "Subtitle",
|
||||
imageURL = "https://placehold.co/600x400")
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -236,7 +279,8 @@ private fun HomeContent(
|
||||
item {
|
||||
ErrorDisplay(
|
||||
errorMessage = state.message,
|
||||
onRetry = onRetry)
|
||||
onRetry = onRetry
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,7 +296,8 @@ private fun HomeContent(
|
||||
GenreTabs(
|
||||
genres = state.genres,
|
||||
selectedGenreId = state.selectedGenreId,
|
||||
onGenreSelected = onGenreSelected)
|
||||
onGenreSelected = onGenreSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -263,20 +308,24 @@ private fun HomeContent(
|
||||
SkeletonTree(true) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp)
|
||||
.padding(horizontal = 16.dp))
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp)) {
|
||||
contentPadding = PaddingValues(horizontal = 16.dp)
|
||||
) {
|
||||
items(4) {
|
||||
PlayableCard(
|
||||
title = "Item Title",
|
||||
subtitle = "Subtitle",
|
||||
imageURL = "https://placehold.co/600x400")
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -287,7 +336,8 @@ private fun HomeContent(
|
||||
HomeSection(
|
||||
title = section.title,
|
||||
subtitle = section.description,
|
||||
items = section.items)
|
||||
items = section.items
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -296,20 +346,24 @@ private fun HomeContent(
|
||||
SkeletonTree(true) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp)
|
||||
.padding(horizontal = 16.dp))
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp)) {
|
||||
contentPadding = PaddingValues(horizontal = 16.dp)
|
||||
) {
|
||||
items(4) {
|
||||
PlayableCard(
|
||||
title = "Item Title",
|
||||
subtitle = "Subtitle",
|
||||
imageURL = "https://placehold.co/600x400")
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -327,30 +381,36 @@ private fun HomeSection(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
items: List<MetadataBrowseItem>,
|
||||
modifier: Modifier = Modifier) {
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val rowState = rememberLazyListState()
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (!subtitle.isNullOrEmpty()) Text(
|
||||
text = subtitle,
|
||||
style = AppTheme.typography.labelMedium.copy(
|
||||
color = AppTheme.colors.secondary,
|
||||
fontWeight = FontWeight.Medium),
|
||||
modifier = Modifier.padding(horizontal = 16.dp))
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = AppTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp))
|
||||
modifier = Modifier.padding(horizontal = 16.dp).offset(y = (-12).dp)
|
||||
)
|
||||
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
modifier = Modifier.dragScrollable(rowState),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top) {
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
items(items) { browseItem ->
|
||||
when (browseItem) {
|
||||
is MetadataBrowseItem.Album -> AlbumCard(album = browseItem.data)
|
||||
@ -368,14 +428,16 @@ private fun HomeSection(
|
||||
private fun GenreTabs(
|
||||
genres: List<MetadataBrowseGenre>,
|
||||
selectedGenreId: String?,
|
||||
onGenreSelected: (String) -> Unit) {
|
||||
onGenreSelected: (String) -> Unit
|
||||
) {
|
||||
val rowState = rememberLazyListState()
|
||||
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
modifier = Modifier.dragScrollable(rowState),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
items(genres) { genre ->
|
||||
MillenniumButton(
|
||||
onClick = { onGenreSelected(genre.id) },
|
||||
@ -383,7 +445,8 @@ private fun GenreTabs(
|
||||
MillenniumButtonStyle.Primary
|
||||
} else {
|
||||
MillenniumButtonStyle.Secondary
|
||||
}) {
|
||||
}
|
||||
) {
|
||||
Text(genre.name)
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,6 +26,9 @@ import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButton
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButtonRow
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.millennium.theme.millenniumPanelBrush
|
||||
@ -285,29 +288,19 @@ internal fun PluginScreenContent(
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
) {
|
||||
MillenniumButton(
|
||||
MillenniumSegmentedButtonRow(
|
||||
modifier = Modifier.padding(bottom = 12.dp)) {
|
||||
MillenniumSegmentedButton(
|
||||
selected = data.onboardingDiscover,
|
||||
onClick = { viewModel.showOnboardingDiscovery(true) },
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_discover),
|
||||
fontWeight = if (data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)) {
|
||||
Text(stringResource(Res.string.welcome_plugins_discover))
|
||||
}
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
viewModel.showOnboardingDiscovery(
|
||||
false
|
||||
)
|
||||
},
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_sources),
|
||||
fontWeight = if (!data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
MillenniumSegmentedButton(
|
||||
selected = !data.onboardingDiscover,
|
||||
onClick = { viewModel.showOnboardingDiscovery(false) },
|
||||
shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)) {
|
||||
Text(stringResource(Res.string.welcome_plugins_sources))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,7 +87,11 @@ data class UserSettings(
|
||||
// Updates
|
||||
val autoCheckForUpdates: Boolean = true,
|
||||
val ignoredAppUpdateVersion: String? = null,
|
||||
val ignoredPluginUpdateVersions: Map<String, String> = emptyMap())
|
||||
val ignoredPluginUpdateVersions: Map<String, String> = emptyMap(),
|
||||
|
||||
// Support
|
||||
val supportRemindersEnabled: Boolean = true,
|
||||
val lastSupportReminderEpochMs: Long = 0L)
|
||||
|
||||
/**
|
||||
* Configuration for the MQTT broker used by Group Jam. The host is a placeholder
|
||||
|
||||
@ -50,6 +50,7 @@ import dev.krtirtho.spotube.modules.settings.sections.jamSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.pluginsSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.supportSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.updatesSection
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@ -154,6 +155,9 @@ private fun CompactSettingsContent(
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel)
|
||||
}
|
||||
supportSection(
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel)
|
||||
updatesSection(
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel)
|
||||
|
||||
@ -71,6 +71,7 @@ import dev.krtirtho.spotube.modules.settings.sections.desktopSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.downloadsSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.supportSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.SettingsPageTitle
|
||||
import dev.krtirtho.spotube.modules.settings.sections.updatesSection
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
@ -81,6 +82,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare
|
||||
@ -102,6 +104,7 @@ import spotube.composeapp.generated.resources.settings_section_jam
|
||||
import spotube.composeapp.generated.resources.settings_section_language_region
|
||||
import spotube.composeapp.generated.resources.settings_section_playback
|
||||
import spotube.composeapp.generated.resources.settings_section_plugins
|
||||
import spotube.composeapp.generated.resources.settings_section_support
|
||||
|
||||
private val SettingsSidebarWidth = 236.dp
|
||||
|
||||
@ -123,6 +126,7 @@ internal enum class SettingsTab(
|
||||
Caching(Res.string.settings_section_caching, Iconsax.IconsaxDocumentDownload),
|
||||
Downloads(Res.string.settings_section_downloads, Iconsax.IconsaxDirectboxReceive),
|
||||
Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2),
|
||||
Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2),
|
||||
About(Res.string.settings_section_about, Iconsax.IconsaxInformation),
|
||||
}
|
||||
|
||||
@ -317,6 +321,12 @@ private fun SettingsTabContent(
|
||||
SettingsTab.Desktop ->
|
||||
desktopSection(settings, settingsViewModel, includeHeader = false)
|
||||
|
||||
SettingsTab.Support ->
|
||||
supportSection(
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
includeHeader = false)
|
||||
|
||||
SettingsTab.About ->
|
||||
updatesSection(settings, settingsViewModel, includeHeader = false)
|
||||
}
|
||||
|
||||
@ -74,7 +74,10 @@ internal fun SwitchSettingCard(
|
||||
MillenniumSwitch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
label = title)
|
||||
// The row itself already renders the title/subtitle, so the switch must not
|
||||
// draw a second visible label next to it.
|
||||
label = "",
|
||||
contentSpacing = 0.dp)
|
||||
},
|
||||
onClick = {
|
||||
onCheckedChange(!checked)
|
||||
|
||||
@ -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) })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -46,10 +46,13 @@ import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||
import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
|
||||
import dev.krtirtho.spotube.sidebarTabs
|
||||
import org.jetbrains.compose.resources.Font
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.spotube.millennium.composables.MillenniumSidebar
|
||||
import org.spotube.millennium.composables.MillenniumSidebarContent
|
||||
@ -63,6 +66,8 @@ import org.spotube.millennium.composables.MillenniumSidebarMenu
|
||||
import org.spotube.millennium.composables.MillenniumSidebarTrigger
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.cookie_regular
|
||||
import spotube.composeapp.generated.resources.settings_screen_title
|
||||
import spotube.composeapp.generated.resources.support_button_label
|
||||
|
||||
@Composable
|
||||
fun AppSidebar(
|
||||
@ -74,6 +79,7 @@ fun AppSidebar(
|
||||
var expanded by rememberSaveable { mutableStateOf(true) }
|
||||
val groupExpansion = remember { mutableStateMapOf<String, Boolean>() }
|
||||
val currentLibraryTab by libraryState.currentTab.collectAsState()
|
||||
val openSupport = LocalSupportPrompt.current
|
||||
|
||||
MillenniumSidebar(
|
||||
modifier = modifier,
|
||||
@ -143,10 +149,17 @@ fun AppSidebar(
|
||||
}
|
||||
}
|
||||
MillenniumSidebarFooter {
|
||||
val supportLabel = stringResource(Res.string.support_button_label)
|
||||
MillenniumSidebarItem(
|
||||
label = "Settings",
|
||||
label = supportLabel,
|
||||
onClick = { openSupport() },
|
||||
contentDescription = supportLabel,
|
||||
icon = Iconsax.IconsaxHeart2)
|
||||
MillenniumSidebarItem(
|
||||
label = stringResource(Res.string.settings_screen_title),
|
||||
onClick = { navigator.navigate(Routes.Settings) },
|
||||
selected = navigationState.topLevelRoute == Routes.Settings,
|
||||
contentDescription = stringResource(Res.string.settings_screen_title),
|
||||
icon = Iconsax.IconsaxSetting2)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,21 +17,15 @@
|
||||
|
||||
package dev.krtirtho.spotube.modules.welcome
|
||||
|
||||
import dev.krtirtho.spotube.resources.iconsax.BrandBuyMeACoffee
|
||||
import dev.krtirtho.spotube.resources.iconsax.BrandOpencollective
|
||||
import dev.krtirtho.spotube.resources.iconsax.BrandPatreon
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.welcome_donate_bmac_subtitle
|
||||
import spotube.composeapp.generated.resources.welcome_donate_bmac_title
|
||||
import spotube.composeapp.generated.resources.welcome_donate_opencollective_subtitle
|
||||
import spotube.composeapp.generated.resources.welcome_donate_opencollective_title
|
||||
import spotube.composeapp.generated.resources.welcome_donate_patreon_subtitle
|
||||
import spotube.composeapp.generated.resources.welcome_donate_patreon_title
|
||||
import spotube.composeapp.generated.resources.welcome_get_started
|
||||
import spotube.composeapp.generated.resources.welcome_lets_go
|
||||
import spotube.composeapp.generated.resources.welcome_next
|
||||
import spotube.composeapp.generated.resources.welcome_step_guide
|
||||
import spotube.composeapp.generated.resources.welcome_step_plugins
|
||||
import spotube.composeapp.generated.resources.welcome_step_support
|
||||
import spotube.composeapp.generated.resources.welcome_step_welcome
|
||||
|
||||
/** Order matches the 4 onboarding pages. */
|
||||
internal val welcomePrimaryLabels: List<StringResource> = listOf(
|
||||
@ -40,22 +34,9 @@ internal val welcomePrimaryLabels: List<StringResource> = listOf(
|
||||
Res.string.welcome_next,
|
||||
Res.string.welcome_get_started)
|
||||
|
||||
internal val welcomeDonations: List<DonationOption> = listOf(
|
||||
DonationOption(
|
||||
id = "patreon",
|
||||
title = Res.string.welcome_donate_patreon_title,
|
||||
subtitle = Res.string.welcome_donate_patreon_subtitle,
|
||||
url = "https://patreon.com/krtirtho",
|
||||
icon = Iconsax.BrandPatreon),
|
||||
DonationOption(
|
||||
id = "buymeacoffee",
|
||||
title = Res.string.welcome_donate_bmac_title,
|
||||
subtitle = Res.string.welcome_donate_bmac_subtitle,
|
||||
url = "https://www.buymeacoffee.com/krtirtho",
|
||||
icon = Iconsax.BrandBuyMeACoffee),
|
||||
DonationOption(
|
||||
id = "opencollective",
|
||||
title = Res.string.welcome_donate_opencollective_title,
|
||||
subtitle = Res.string.welcome_donate_opencollective_subtitle,
|
||||
url = "https://opencollective.com/spotube",
|
||||
icon = Iconsax.BrandOpencollective))
|
||||
/** Short labels for the step switcher. Order matches the 4 onboarding pages. */
|
||||
internal val welcomeStepLabels: List<StringResource> = listOf(
|
||||
Res.string.welcome_step_welcome,
|
||||
Res.string.welcome_step_support,
|
||||
Res.string.welcome_step_plugins,
|
||||
Res.string.welcome_step_guide)
|
||||
|
||||
@ -17,21 +17,12 @@
|
||||
|
||||
package dev.krtirtho.spotube.modules.welcome
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import dev.krtirtho.spotube.modules.support.DonationOption
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/** A single donation destination shown on the support page. */
|
||||
data class DonationOption(
|
||||
val id: String,
|
||||
val title: StringResource,
|
||||
val subtitle: StringResource,
|
||||
val url: String,
|
||||
val icon: ImageVector)
|
||||
|
||||
data class WelcomeUiState(
|
||||
val currentPageIndex: Int,
|
||||
val pageCount: Int,
|
||||
val isLastPage: Boolean,
|
||||
val primaryLabel: StringResource,
|
||||
val donations: List<DonationOption>,
|
||||
val demoPlaying: Boolean = true)
|
||||
val donations: List<DonationOption>)
|
||||
|
||||
@ -20,28 +20,22 @@ package dev.krtirtho.spotube.modules.welcome
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumCard
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import org.spotube.millennium.composables.MillenniumTabGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
@ -49,7 +43,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@ -63,13 +56,15 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.welcome_back
|
||||
import spotube.composeapp.generated.resources.welcome_skip
|
||||
|
||||
/**
|
||||
* First-run Welcome/Onboarding screen. It is a route ([dev.krtirtho.spotube.core.navigation.Routes.Welcome])
|
||||
* rendered inside the app shell. Uses the application's base controls and keeps plugin setup
|
||||
* in-place, with a neutral palette independent of the user's chosen accent.
|
||||
* First-run Welcome/Onboarding screen. It is a route
|
||||
* ([dev.krtirtho.spotube.core.navigation.Routes.Welcome]) rendered inside the app shell.
|
||||
*
|
||||
* The steps are exposed through a Millennium tab group rather than ad-hoc dots or a footer
|
||||
* walk-through, so the screen reads as one deliberate panel on every window size.
|
||||
* Each step owns its content and the footer only carries the forward action.
|
||||
*/
|
||||
@Composable
|
||||
fun WelcomeScreen(viewModel: WelcomeViewModel) {
|
||||
@ -96,99 +91,31 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) {
|
||||
}
|
||||
}
|
||||
})
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
|
||||
Column(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center) {
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// Central stage (animated page content)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.widthIn(max = 1280.dp)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center) {
|
||||
AnimatedContent(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
targetState = state.currentPageIndex,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
(slideInHorizontally { it / 4 } + fadeIn()) togetherWith
|
||||
(slideOutHorizontally { -it / 4 } + fadeOut())
|
||||
} else {
|
||||
(slideInHorizontally { -it / 4 } + fadeIn()) togetherWith
|
||||
(slideOutHorizontally { it / 4 } + fadeOut())
|
||||
}
|
||||
},
|
||||
label = "welcome_page") { index ->
|
||||
when (index) {
|
||||
0 -> BrandingPage()
|
||||
|
||||
1 -> DonationPage(
|
||||
donations = state.donations,
|
||||
onDonate = viewModel::openDonation)
|
||||
|
||||
2 -> PluginsPage()
|
||||
|
||||
else -> HowItWorksPage(
|
||||
isPlaying = state.demoPlaying,
|
||||
onTogglePlayback = viewModel::toggleDemo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// Bottom precision navigation bar: Back | Page Dots | Action Button
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 680.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
// Left: Back button
|
||||
if (state.currentPageIndex > 0) {
|
||||
MillenniumButton(
|
||||
onClick = viewModel::previousPage,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text(
|
||||
text = stringResource(Res.string.welcome_back),
|
||||
color = AppTheme.colors.onSurface)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.width(76.dp))
|
||||
}
|
||||
|
||||
// Center: Precision page dots
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
PageIndicator(
|
||||
pageCount = state.pageCount,
|
||||
currentPageIndex = state.currentPageIndex)
|
||||
}
|
||||
|
||||
// Right: Primary Next / Let's Go action
|
||||
MillenniumButton(
|
||||
onClick = viewModel::onPrimary,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = stringResource(state.primaryLabel),
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
MillenniumIcon(
|
||||
imageVector = if (state.isLastPage) {
|
||||
Iconsax.IconsaxCheckCircle
|
||||
} else {
|
||||
Iconsax.IconsaxNext
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
.widthIn(max = 1040.dp)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
WelcomeStepSwitcher(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
state = state,
|
||||
viewModel = viewModel)
|
||||
MillenniumSeparator()
|
||||
WelcomeActionBar(
|
||||
state = state,
|
||||
viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -198,31 +125,59 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PageIndicator(
|
||||
pageCount: Int,
|
||||
currentPageIndex: Int,
|
||||
private fun WelcomeStepSwitcher(
|
||||
state: WelcomeUiState,
|
||||
viewModel: WelcomeViewModel,
|
||||
modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
repeat(pageCount) { index ->
|
||||
val selected = index == currentPageIndex
|
||||
val width by animateDpAsState(
|
||||
targetValue = if (selected) 20.dp else 6.dp,
|
||||
label = "welcome_page_indicator")
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = width, height = 6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (selected) {
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
AppTheme.colors.outlineVariant
|
||||
}
|
||||
))
|
||||
val labels = welcomeStepLabels.map { stringResource(it) }
|
||||
MillenniumTabGroup(
|
||||
selectedTab = state.currentPageIndex,
|
||||
onSelectedTabChange = viewModel::goToPage,
|
||||
tabs = labels.indices.toList(),
|
||||
modifier = modifier,
|
||||
tabLabel = { labels[it] }) { index ->
|
||||
when (index) {
|
||||
0 -> BrandingPage()
|
||||
|
||||
1 -> DonationPage(
|
||||
donations = state.donations,
|
||||
onDonate = viewModel::openDonation)
|
||||
|
||||
2 -> PluginsPage()
|
||||
|
||||
else -> HowItWorksPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeActionBar(
|
||||
state: WelcomeUiState,
|
||||
viewModel: WelcomeViewModel) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
MillenniumButton(
|
||||
onClick = viewModel::onPrimary,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = stringResource(state.primaryLabel),
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
MillenniumIcon(
|
||||
imageVector = if (state.isLastPage) {
|
||||
Iconsax.IconsaxCheckCircle
|
||||
} else {
|
||||
Iconsax.IconsaxNext
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,10 +19,11 @@ package dev.krtirtho.spotube.modules.welcome
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.krtirtho.spotube.modules.support.donationOptions
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
@ -30,18 +31,16 @@ import kotlinx.coroutines.launch
|
||||
class WelcomeViewModel(
|
||||
private val repository: WelcomeRepository) : ViewModel() {
|
||||
private val currentPageIndex = MutableStateFlow(0)
|
||||
private val demoPlaying = MutableStateFlow(true)
|
||||
|
||||
val uiState: StateFlow<WelcomeUiState> =
|
||||
combine(currentPageIndex, demoPlaying) { index, playing ->
|
||||
currentPageIndex.map { index ->
|
||||
val pageCount = welcomePrimaryLabels.size
|
||||
WelcomeUiState(
|
||||
currentPageIndex = index,
|
||||
pageCount = pageCount,
|
||||
isLastPage = index == pageCount - 1,
|
||||
primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)],
|
||||
donations = welcomeDonations,
|
||||
demoPlaying = playing)
|
||||
donations = donationOptions)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
@ -51,30 +50,26 @@ class WelcomeViewModel(
|
||||
pageCount = welcomePrimaryLabels.size,
|
||||
isLastPage = welcomePrimaryLabels.size == 1,
|
||||
primaryLabel = welcomePrimaryLabels.first(),
|
||||
donations = welcomeDonations))
|
||||
donations = donationOptions))
|
||||
|
||||
fun nextPage() {
|
||||
currentPageIndex.update { (it + 1).coerceAtMost(welcomePrimaryLabels.lastIndex) }
|
||||
}
|
||||
|
||||
fun toggleDemo() {
|
||||
demoPlaying.update { !it }
|
||||
fun goToPage(index: Int) {
|
||||
currentPageIndex.update { index.coerceIn(0, welcomePrimaryLabels.lastIndex) }
|
||||
}
|
||||
|
||||
fun onPrimary() {
|
||||
if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) completeOnboarding() else nextPage()
|
||||
if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) {
|
||||
completeOnboarding()
|
||||
} else {
|
||||
goToPage(currentPageIndex.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun openDonation(url: String) {
|
||||
// Only destinations owned by our static funding list can be opened here.
|
||||
if (welcomeDonations.none { it.url == url }) return
|
||||
if (donationOptions.none { it.url == url }) return
|
||||
runCatching { dev.krtirtho.spotube.openUrlInBrowser(url) }
|
||||
}
|
||||
|
||||
fun previousPage() {
|
||||
currentPageIndex.update { (it - 1).coerceAtLeast(0) }
|
||||
}
|
||||
|
||||
fun completeOnboarding() {
|
||||
viewModelScope.launch {
|
||||
repository.completeOnboarding()
|
||||
|
||||
@ -33,13 +33,17 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
/** Installs the Millennium theme for the onboarding pages. */
|
||||
/**
|
||||
* Reapplies the Millennium theme for the onboarding pages using the app's active configuration,
|
||||
* so onboarding matches the palette and appearance used by the rest of the app.
|
||||
*/
|
||||
@Composable
|
||||
fun WelcomeTheme(content: @Composable () -> Unit) {
|
||||
val dark = AppTheme.colors.surface.luminance() < 0.5f
|
||||
MillenniumTheme(dark = dark) {
|
||||
content()
|
||||
}
|
||||
MillenniumTheme(
|
||||
dark = dark,
|
||||
config = MillenniumTheme.config,
|
||||
content = content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@ -17,7 +17,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.modules.welcome.DonationOption
|
||||
import dev.krtirtho.spotube.modules.support.DonationOption
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
|
||||
@ -1,187 +1,98 @@
|
||||
package dev.krtirtho.spotube.modules.welcome.pages
|
||||
|
||||
|
||||
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import org.spotube.millennium.composables.MillenniumCard
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import org.spotube.millennium.composables.MillenniumListTile
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.resources.iconsax.*
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch
|
||||
import dev.krtirtho.spotube.resources.iconsax.User
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.welcome_guide_lyrics_desc
|
||||
import spotube.composeapp.generated.resources.welcome_guide_lyrics_title
|
||||
import spotube.composeapp.generated.resources.welcome_guide_offline_desc
|
||||
import spotube.composeapp.generated.resources.welcome_guide_offline_title
|
||||
import spotube.composeapp.generated.resources.welcome_guide_search_desc
|
||||
import spotube.composeapp.generated.resources.welcome_guide_search_title
|
||||
import spotube.composeapp.generated.resources.welcome_guide_signin_desc
|
||||
import spotube.composeapp.generated.resources.welcome_guide_signin_title
|
||||
import spotube.composeapp.generated.resources.welcome_guide_subtitle
|
||||
import spotube.composeapp.generated.resources.welcome_guide_title
|
||||
|
||||
/**
|
||||
* The onboarding guide: a short, concrete answer to "how do I actually use this app?".
|
||||
* No marketing prose — just the four things a new user needs to do.
|
||||
*/
|
||||
@Composable
|
||||
fun HowItWorksPage(
|
||||
isPlaying: Boolean,
|
||||
onTogglePlayback: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(modifier.fillMaxSize()) {
|
||||
val wide = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded
|
||||
if (wide) {
|
||||
Row(
|
||||
Modifier.fillMaxSize().padding(40.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
PlayerDemonstration(isPlaying, onTogglePlayback, Modifier.weight(1f))
|
||||
ListeningIntroduction(Modifier.weight(1f))
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterVertically)) {
|
||||
ListeningIntroduction()
|
||||
PlayerDemonstration(isPlaying, onTogglePlayback)
|
||||
}
|
||||
fun HowItWorksPage(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_guide_title),
|
||||
style = AppTheme.typography.headlineMedium.copy(fontFamily = FontFamily.Serif))
|
||||
Text(
|
||||
stringResource(Res.string.welcome_guide_subtitle),
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
WelcomeGuideStep(
|
||||
icon = Iconsax.User,
|
||||
title = Res.string.welcome_guide_signin_title,
|
||||
description = Res.string.welcome_guide_signin_desc)
|
||||
WelcomeGuideStep(
|
||||
icon = Iconsax.IconsaxSearch,
|
||||
title = Res.string.welcome_guide_search_title,
|
||||
description = Res.string.welcome_guide_search_desc)
|
||||
WelcomeGuideStep(
|
||||
icon = Iconsax.IconsaxDocumentDownload,
|
||||
title = Res.string.welcome_guide_offline_title,
|
||||
description = Res.string.welcome_guide_offline_desc)
|
||||
WelcomeGuideStep(
|
||||
icon = Iconsax.IconsaxMusic,
|
||||
title = Res.string.welcome_guide_lyrics_title,
|
||||
description = Res.string.welcome_guide_lyrics_desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListeningIntroduction(modifier: Modifier = Modifier) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_how_quiet_title),
|
||||
style = AppTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif))
|
||||
Text(
|
||||
stringResource(Res.string.welcome_how_quiet_description),
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
/** Silent, illustrative player. The ViewModel owns the play/pause interaction. */
|
||||
@Composable
|
||||
private fun PlayerDemonstration(
|
||||
isPlaying: Boolean,
|
||||
onTogglePlayback: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
private fun WelcomeGuideStep(
|
||||
icon: ImageVector,
|
||||
title: StringResource,
|
||||
description: StringResource,
|
||||
) {
|
||||
val motion = rememberInfiniteTransition(label = "welcome_record")
|
||||
val rotation by motion.animateFloat(
|
||||
0f, 360f,
|
||||
infiniteRepeatable(tween(12000, easing = LinearEasing)), label = "record_rotation"
|
||||
)
|
||||
val phase by motion.animateFloat(
|
||||
0f, (2 * PI).toFloat(),
|
||||
infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "audio_meter"
|
||||
)
|
||||
val scheme = AppTheme.colors
|
||||
|
||||
MillenniumCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
Text(
|
||||
stringResource(if (isPlaying) Res.string.welcome_demo_streaming else Res.string.welcome_demo_paused),
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = scheme.onSurfaceVariant)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
Canvas(Modifier.size(104.dp).rotate(if (isPlaying) rotation else 0f)) {
|
||||
val radius = size.minDimension / 2f
|
||||
drawCircle(Color(0xFF171717))
|
||||
for (groove in 1..8) {
|
||||
drawCircle(
|
||||
Color(0xFF343434),
|
||||
radius * (0.4f + groove * 0.065f),
|
||||
style = Stroke(1f)
|
||||
)
|
||||
}
|
||||
drawCircle(Color(0xFF334A6B), radius * 0.32f)
|
||||
drawCircle(Color(0xFFE1DDD5), radius * 0.045f)
|
||||
// An off-centre label mark makes the turntable's motion visible.
|
||||
drawCircle(
|
||||
Color(0xFFD6CEC0),
|
||||
radius * 0.025f,
|
||||
Offset(center.x, center.y - radius * 0.2f)
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_how_mockup_title),
|
||||
style = AppTheme.typography.titleLarge
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.welcome_how_mockup_artist),
|
||||
style = AppTheme.typography.bodySmall, color = scheme.onSurfaceVariant
|
||||
)
|
||||
Canvas(Modifier.fillMaxWidth().height(32.dp)) {
|
||||
val count = 18
|
||||
val barWidth = size.width / (count * 1.7f)
|
||||
repeat(count) { index ->
|
||||
val wave = if (isPlaying) (sin(phase + index * 0.6f) + 1f) / 2f else 0f
|
||||
val height = size.height * (0.12f + wave * 0.88f)
|
||||
drawRect(
|
||||
scheme.primary,
|
||||
Offset(index * size.width / count, size.height - height),
|
||||
androidx.compose.ui.geometry.Size(barWidth, height)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.welcome_how_mockup_lyric),
|
||||
style = AppTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Serif),
|
||||
color = scheme.onSurfaceVariant
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
MillenniumIcon(
|
||||
Iconsax.IconsaxPrevious,
|
||||
null,
|
||||
Modifier.size(18.dp),
|
||||
tint = scheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.width(24.dp))
|
||||
MillenniumIconButton(
|
||||
onClick = onTogglePlayback,
|
||||
modifier = Modifier.size(48.dp),
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
stringResource(if (isPlaying) Res.string.welcome_demo_pause_action else Res.string.welcome_demo_play_action),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(24.dp))
|
||||
MillenniumIcon(
|
||||
Iconsax.IconsaxNext,
|
||||
null,
|
||||
Modifier.size(18.dp),
|
||||
tint = scheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
MillenniumListTile(
|
||||
leading = {
|
||||
MillenniumIcon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = AppTheme.colors.primary)
|
||||
},
|
||||
title = { Text(stringResource(title)) },
|
||||
subtitle = { Text(stringResource(description)) })
|
||||
}
|
||||
|
||||
@ -25,10 +25,13 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.foundation.window.WindowDraggableArea
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumTopAppBar
|
||||
@ -53,6 +56,8 @@ import dev.nucleusframework.window.WindowDoubleClickAction
|
||||
import dev.nucleusframework.window.windowDragArea
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
private val WindowControlsReservedWidth = 132.dp
|
||||
|
||||
@Composable
|
||||
actual fun ApplicationMainBar(
|
||||
title: @Composable (() -> Unit),
|
||||
@ -68,6 +73,9 @@ actual fun ApplicationMainBar(
|
||||
title = title,
|
||||
actions = {
|
||||
actions()
|
||||
// Reserve room for the platform window controls instead of shrinking the
|
||||
// whole bar, so it spans the full width with no seam under the controls.
|
||||
Spacer(Modifier.width(WindowControlsReservedWidth))
|
||||
},
|
||||
navigationIcon = {
|
||||
if (backButton) {
|
||||
@ -75,7 +83,9 @@ actual fun ApplicationMainBar(
|
||||
}
|
||||
},
|
||||
subtitle = subtitle,
|
||||
modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons
|
||||
background = if (transparent) SolidColor(Color.Transparent) else null,
|
||||
separatorColor = if (transparent) Color.Transparent else null,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
LocalWindowScope.current.WindowControls(
|
||||
modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user