mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-06 11:14:00 +00:00
Compare commits
3 Commits
321b8ea784
...
9c9f1a0a9d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c9f1a0a9d | ||
|
|
71d7649db6 | ||
|
|
b0327fd224 |
22
composeApp/src/androidMain/res/values-sv/strings.xml
Normal file
22
composeApp/src/androidMain/res/values-sv/strings.xml
Normal 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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
169
composeApp/src/commonMain/composeResources/values-sv/strings.xml
Normal file
169
composeApp/src/commonMain/composeResources/values-sv/strings.xml
Normal 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>
|
||||
@ -202,8 +202,98 @@
|
||||
<string name="update_action_ignore">Ignore</string>
|
||||
<string name="update_action_update">Update</string>
|
||||
<string name="app_update_dialog_title">Spotube %1$s is available</string>
|
||||
<string name="app_update_dialog_no_notes">See what\'s new in this release on the Spotube downloads page.</string>
|
||||
<string name="app_update_dialog_no_notes">See what’s new in this release on the Spotube downloads page.</string>
|
||||
<string name="plugin_update_dialog_title">%1$s %2$s is available</string>
|
||||
<string name="plugin_update_dialog_current_version">Currently installed: %1$s</string>
|
||||
<string name="plugin_update_dialog_no_notes">See what\'s new in this update from the plugin\'s page.</string>
|
||||
<string name="plugin_update_dialog_no_notes">See what’s new in this update from the plugin’s page.</string>
|
||||
|
||||
<string name="welcome_skip">Skip</string>
|
||||
<string name="welcome_next">Next</string>
|
||||
<string name="welcome_back">Back</string>
|
||||
<string name="welcome_get_started">Get Started</string>
|
||||
<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_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>
|
||||
<string name="welcome_plugins_sources">Your sources</string>
|
||||
<string name="welcome_plugins_retry">Try again</string>
|
||||
<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>
|
||||
|
||||
<!-- Page 1: branding -->
|
||||
<string name="welcome_brand_tagline">Your music. Your sources. Your rules.</string>
|
||||
<string name="welcome_brand_subtitle">A free, open-source music player built by a community that simply loves music.</string>
|
||||
<string name="welcome_brand_chip_open_source">Open source</string>
|
||||
<string name="welcome_brand_chip_no_ads">No ads</string>
|
||||
<string name="welcome_brand_chip_no_tracking">No telemetry</string>
|
||||
<string name="welcome_brand_chip_plugin_powered">Plugin powered</string>
|
||||
<string name="welcome_lets_go">Let’s Go</string>
|
||||
|
||||
<!-- Page 2: donations -->
|
||||
<string name="welcome_donate_eyebrow">Supporters keep us going</string>
|
||||
<string name="welcome_donate_title">Built with passion. Kept alive by you.</string>
|
||||
<string name="welcome_donate_message">Spotube is 100% free, libre, and open source — no ads, no subscriptions, no tracking. Every feature exists because people like you chose to help keep the code flowing.</string>
|
||||
<string name="welcome_donate_patreon_title">Patreon</string>
|
||||
<string name="welcome_donate_patreon_tag">Monthly Sponsor</string>
|
||||
<string name="welcome_donate_patreon_subtitle">Support recurring development and infrastructure</string>
|
||||
<string name="welcome_donate_bmac_title">Buy Me a Coffee</string>
|
||||
<string name="welcome_donate_bmac_tag">One-Time Coffee</string>
|
||||
<string name="welcome_donate_bmac_subtitle">Fuel a late-night coding session with a quick tip</string>
|
||||
<string name="welcome_donate_opencollective_title">Open Collective</string>
|
||||
<string name="welcome_donate_opencollective_tag">Community Fund</string>
|
||||
<string name="welcome_donate_opencollective_subtitle">Transparent, community-governed financial backing</string>
|
||||
<string name="welcome_donate_thanks">Thank you from the bottom of our hearts for believing in open music.</string>
|
||||
<string name="welcome_donate_optional">Supporting is completely optional — Spotube stays free either way.</string>
|
||||
<string name="welcome_donate_action">Sponsor</string>
|
||||
<string name="welcome_donate_open_content_description">Open %1$s</string>
|
||||
|
||||
<!-- Page 3: plugins -->
|
||||
<string name="welcome_plugins_eyebrow">Modular Architecture</string>
|
||||
<string name="welcome_plugins_title">Choose where your music comes from.</string>
|
||||
<string name="welcome_plugins_subtitle">Spotube separates catalog discovery, audio streaming, and synced lyrics. Configure your engines below or switch them anytime.</string>
|
||||
<string name="welcome_plugins_metadata_title">Metadata Engine</string>
|
||||
<string name="welcome_plugins_metadata_description">Powers music discovery, search, track listings, and playlists.</string>
|
||||
<string name="welcome_plugins_audio_title">Audio Engine</string>
|
||||
<string name="welcome_plugins_audio_description">Fetches and decodes the audio streams that play.</string>
|
||||
<string name="welcome_plugins_lyrics_title">Lyrics Engine</string>
|
||||
<string name="welcome_plugins_lyrics_description">Streams time-synced karaoke lyrics in real time.</string>
|
||||
<string name="welcome_plugins_scrobble_title">Scrobble Engine</string>
|
||||
<string name="welcome_plugins_scrobble_description">Records listening history to Last.fm or ListenBrainz.</string>
|
||||
<string name="welcome_plugins_badge_active">Active</string>
|
||||
<string name="welcome_plugins_badge_builtin">Built-in</string>
|
||||
<string name="welcome_plugins_badge_ready">Ready</string>
|
||||
<string name="welcome_plugins_badge_optional">Optional</string>
|
||||
<string name="welcome_plugins_none_selected">Select an engine</string>
|
||||
<string name="welcome_plugins_installed_section">Installed Engines</string>
|
||||
<string name="welcome_plugins_set_default">Set as default</string>
|
||||
<string name="welcome_plugins_builtin_note">NewPipe YouTube and LRCLib Lyrics are built-in and ready to stream out of the box.</string>
|
||||
<string name="welcome_plugins_tip">Manage or add new community plugins anytime in Settings → Plugins.</string>
|
||||
|
||||
<!-- Page 4: how it works -->
|
||||
<string name="welcome_how_eyebrow">Retro-Modern Architecture</string>
|
||||
<string name="welcome_how_title">Pure Hi-Fi, zero middlemen</string>
|
||||
<string name="welcome_how_subtitle">Here’s how Spotube orchestrates decentralized playback directly on your machine.</string>
|
||||
<string name="welcome_how_status_badge">DIRECT PEER-TO-PEER • 256 KBPS OPUS</string>
|
||||
<string name="welcome_how_mockup_title">Blue hour</string>
|
||||
<string name="welcome_how_mockup_artist">Spotube · player demonstration</string>
|
||||
<string name="welcome_how_mockup_lyric">A little music for the moments in between.</string>
|
||||
<string name="welcome_how_step_search_title">1. Decentralized Discovery</string>
|
||||
<string name="welcome_how_step_search_desc">Queries go straight to your chosen catalog plugin. Spotube never profiles your taste.</string>
|
||||
<string name="welcome_how_step_stream_title">2. Direct Stream Resolution</string>
|
||||
<string name="welcome_how_step_stream_desc">Audio streams directly from content servers to your machine. No proxy hops or remote data harvesting.</string>
|
||||
<string name="welcome_how_step_playback_title">3. Local DSP & Playback</string>
|
||||
<string name="welcome_how_step_playback_desc">Decoded and cached locally with equalizer controls and optional offline download.</string>
|
||||
<string name="welcome_how_step_lyrics_title">4. Synced Lyrics & Jam</string>
|
||||
<string name="welcome_how_step_lyrics_desc">Karaoke lyrics track the playback timestamp, with LAN remote control and Group Jam.</string>
|
||||
<string name="welcome_how_footer">No middleman servers. Playback happens right on your device — private by design.</string>
|
||||
</resources>
|
||||
|
||||
@ -48,6 +48,7 @@ import dev.krtirtho.spotube.modules.update.AppUpdateService
|
||||
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
|
||||
import dev.krtirtho.spotube.modules.update.PluginUpdateService
|
||||
import dev.krtirtho.spotube.modules.webview.WebViewScreen
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||
@ -140,10 +141,17 @@ fun App(
|
||||
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
||||
val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
||||
|
||||
val welcomeRepository: WelcomeRepository = koinInject()
|
||||
val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle()
|
||||
|
||||
// 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
|
||||
|
||||
val navigationState = rememberNavigationState(
|
||||
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||
startRoute = startRoute, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||
)
|
||||
val navigator = remember {
|
||||
val navigator = remember(navigationState) {
|
||||
Navigator(navigationState)
|
||||
}
|
||||
|
||||
@ -151,7 +159,8 @@ fun App(
|
||||
val baseUITheme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
val currentRoute =
|
||||
navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AppShell(navigator, navigationState) {
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.core
|
||||
|
||||
/**
|
||||
* Development-only switches. These are intentionally kept out of [dev.krtirtho.spotube.modules.settings.UserSettings]
|
||||
* so they can never leak into user-facing configuration.
|
||||
*/
|
||||
object DebugConfig {
|
||||
/**
|
||||
* Flip to `true` while developing to always open the Welcome/Onboarding screen, even after
|
||||
* it has been completed (so you don't have to wipe app data to see it again). It still
|
||||
* dismisses for the session when you finish or skip it.
|
||||
*
|
||||
* Keep it `false` when committing.
|
||||
*/
|
||||
const val FORCE_SHOW_ONBOARDING: Boolean = true
|
||||
}
|
||||
@ -23,10 +23,25 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
import okio.SYSTEM
|
||||
|
||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
class Database(val paths: Paths) {
|
||||
/**
|
||||
* True when Spotube had already written its own data before this process started. Captured
|
||||
* synchronously at construction time — the earliest point, before any eager service can
|
||||
* create a fresh store — so it reliably tells a brand-new install apart from an upgrade of
|
||||
* an existing one (used to skip the first-run onboarding on upgrades).
|
||||
*/
|
||||
val isExistingInstall: Boolean = runCatching {
|
||||
val dataDirPath = paths.getApplicationDataDirPath()
|
||||
EXISTING_INSTALL_PROBE_FILES.any { fileName ->
|
||||
FileSystem.SYSTEM.exists("$dataDirPath/$fileName".toPath())
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
val settingsDataStore: DataStore<Preferences> by lazy {
|
||||
PreferenceDataStoreFactory.createWithPath(
|
||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube.preferences_pb".toPath() }
|
||||
@ -61,6 +76,19 @@ class Database(val paths: Paths) {
|
||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() }
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Preference files written by previous launches; their presence means an upgrade. */
|
||||
val EXISTING_INSTALL_PROBE_FILES = listOf(
|
||||
"spotube.preferences_pb",
|
||||
"spotube_plugins.preferences_pb",
|
||||
"spotube_matched_tracks.preferences_pb",
|
||||
"spotube_audio_player_queue.preferences_pb",
|
||||
"spotube_local_media.preferences_pb",
|
||||
"spotube_blacklist.preferences_pb",
|
||||
"spotube_cookies.preferences_pb",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object DatabaseKeys {
|
||||
|
||||
@ -86,6 +86,8 @@ import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeViewModel
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.createdAtStart
|
||||
@ -141,6 +143,10 @@ val sharedModules = module {
|
||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||
viewModelOf(::JamSettingsViewModel)
|
||||
|
||||
// Welcome / Onboarding
|
||||
singleOf(::WelcomeRepository)
|
||||
viewModelOf(::WelcomeViewModel)
|
||||
|
||||
// Updates
|
||||
single { AppUpdateService(get()) } withOptions { createdAtStart() }
|
||||
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
|
||||
|
||||
@ -34,6 +34,7 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
|
||||
import dev.krtirtho.spotube.modules.search.SearchScreen
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsScreen
|
||||
import dev.krtirtho.spotube.modules.settings.JamSettingsScreen
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
@ -91,6 +92,9 @@ sealed interface Routes : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object JamSettings : Routes
|
||||
|
||||
@Serializable
|
||||
data object Welcome : Routes
|
||||
}
|
||||
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
@ -178,4 +182,7 @@ val navigationModule = module {
|
||||
navigation<Routes.JamSettings> {
|
||||
JamSettingsScreen(viewModel = koinViewModel())
|
||||
}
|
||||
navigation<Routes.Welcome> {
|
||||
WelcomeScreen(viewModel = koinViewModel())
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,6 @@ import androidx.compose.runtime.saveable.rememberSerializable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
@ -45,7 +44,9 @@ val TOP_LEVEL_ROUTES = setOf(
|
||||
Routes.Home,
|
||||
Routes.Search,
|
||||
Routes.Library,
|
||||
Routes.Settings
|
||||
Routes.Settings,
|
||||
// Bootstrap destination: needs its own back stack when the app starts on it.
|
||||
Routes.Welcome,
|
||||
)
|
||||
|
||||
val serializersConfig = SavedStateConfiguration {
|
||||
@ -63,6 +64,7 @@ val serializersConfig = SavedStateConfiguration {
|
||||
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
|
||||
subclass(Routes.Devices::class, Routes.Devices.serializer())
|
||||
subclass(Routes.Jam::class, Routes.Jam.serializer())
|
||||
subclass(Routes.Welcome::class, Routes.Welcome.serializer())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -122,7 +124,6 @@ fun NavigationState.toEntries(
|
||||
val decoratedEntries = backStacks.mapValues { (_, stack) ->
|
||||
val decorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
)
|
||||
rememberDecoratedNavEntries(
|
||||
backStack = stack,
|
||||
|
||||
@ -47,5 +47,6 @@ expect fun ApplicationMainBar(
|
||||
title: @Composable () -> Unit = {},
|
||||
subtitle: @Composable () -> Unit = {},
|
||||
actions: @Composable (RowScope.() -> Unit) = {},
|
||||
backButton: Boolean = true
|
||||
backButton: Boolean = true,
|
||||
transparent: Boolean = false,
|
||||
)
|
||||
@ -102,6 +102,14 @@ import okio.Path
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_title
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_setup_guide
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_discover
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_retry
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_more
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_discovery_empty
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_discovery_error
|
||||
import spotube.composeapp.generated.resources.welcome_plugins_sources
|
||||
import spotube.composeapp.generated.resources.plugin_action_download
|
||||
import spotube.composeapp.generated.resources.plugin_action_install_from_file
|
||||
import spotube.composeapp.generated.resources.plugin_configure_title
|
||||
@ -138,6 +146,7 @@ private val VERIFIED_PLUGIN_OWNERS = setOf<String>()
|
||||
@Composable
|
||||
fun PluginScreen(
|
||||
viewModel: PluginViewModel = koinViewModel(),
|
||||
onboarding: Boolean = false,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
@ -215,8 +224,11 @@ fun PluginScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
|
||||
}
|
||||
if (!onboarding) {
|
||||
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
|
||||
}
|
||||
},
|
||||
containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface,
|
||||
) { innerPadding ->
|
||||
when (val data = uiState) {
|
||||
is PluginUiState.Loading -> {
|
||||
@ -244,114 +256,75 @@ fun PluginScreen(
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.plugin_configure_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
PrimaryButton(onClick = { viewModel.showInstallSheet() }) {
|
||||
Icon(
|
||||
Iconsax.IconsaxAdd,
|
||||
contentDescription = "Install a plugin",
|
||||
if (onboarding) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 4.dp,
|
||||
vertical = 16.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(stringResource(Res.string.plugin_install_section_title))
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_setup_guide),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
) {
|
||||
OutlineButton(onClick = { viewModel.showOnboardingDiscovery(true) }) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_discover),
|
||||
fontWeight = if (data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
OutlineButton(onClick = {
|
||||
viewModel.showOnboardingDiscovery(
|
||||
false
|
||||
)
|
||||
}) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_sources),
|
||||
fontWeight = if (!data.onboardingDiscover) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(
|
||||
if (!onboarding || !data.onboardingDiscover) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 4.dp)
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
data.abilitySelections.forEachIndexed { index, selection ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(
|
||||
alpha = 0.5f
|
||||
),
|
||||
)
|
||||
}
|
||||
DefaultAbilityPluginSelector(
|
||||
selection = selection,
|
||||
onSelected = { plugin ->
|
||||
viewModel.selectPlugin(selection.ability, plugin)
|
||||
},
|
||||
Text(
|
||||
stringResource(Res.string.plugin_configure_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
PrimaryButton(onClick = { viewModel.showInstallSheet() }) {
|
||||
Icon(
|
||||
Iconsax.IconsaxAdd,
|
||||
contentDescription = stringResource(Res.string.plugin_install_section_title),
|
||||
)
|
||||
Text(stringResource(Res.string.plugin_install_section_title))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.plugins.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(72.dp)
|
||||
.clip(RoundedCornerShape(18.dp)),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Iconsax.IconsaxBox,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.plugin_empty_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.plugin_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
val noun = if (data.plugins.size == 1) {
|
||||
stringResource(Res.string.plugin_installed_singular)
|
||||
} else {
|
||||
stringResource(Res.string.plugin_installed_plural)
|
||||
}
|
||||
Text(
|
||||
stringResource(
|
||||
Res.string.plugin_installed_count,
|
||||
data.plugins.size,
|
||||
noun
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@ -359,9 +332,11 @@ fun PluginScreen(
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
data.plugins.forEachIndexed { index, item ->
|
||||
data.abilitySelections.forEachIndexed { index, selection ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(
|
||||
@ -369,30 +344,128 @@ fun PluginScreen(
|
||||
),
|
||||
)
|
||||
}
|
||||
PluginCard(
|
||||
plugin = item.plugin,
|
||||
isSelected = item.isSelected,
|
||||
onRemove = { viewModel.removePlugin(item.plugin) },
|
||||
isLoggedIn = item.authState.isLoggedIn,
|
||||
logoPath = item.logoPath,
|
||||
onInfo = { viewModel.showPluginInfo(item.plugin) },
|
||||
onSupport = if (item.isSelected) {
|
||||
{ viewModel.loadSupport(item.plugin) }
|
||||
} else null,
|
||||
onLogin = if (item.isSelected && item.authState.requiresAuth) {
|
||||
{ viewModel.login(item.plugin) }
|
||||
} else null,
|
||||
onLogout = if (item.isSelected && item.authState.requiresAuth) {
|
||||
{ viewModel.logout(item.plugin) }
|
||||
} else null,
|
||||
DefaultAbilityPluginSelector(
|
||||
selection = selection,
|
||||
onSelected = { plugin ->
|
||||
viewModel.selectPlugin(
|
||||
selection.ability,
|
||||
plugin
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.discover.isLoading || data.discover.repos.isNotEmpty()) {
|
||||
if (data.plugins.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(vertical = 48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(72.dp)
|
||||
.clip(RoundedCornerShape(18.dp)),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Iconsax.IconsaxBox,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.plugin_empty_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.plugin_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
val noun = if (data.plugins.size == 1) {
|
||||
stringResource(Res.string.plugin_installed_singular)
|
||||
} else {
|
||||
stringResource(Res.string.plugin_installed_plural)
|
||||
}
|
||||
Text(
|
||||
stringResource(
|
||||
Res.string.plugin_installed_count,
|
||||
data.plugins.size,
|
||||
noun
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 4.dp,
|
||||
vertical = 4.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
data.plugins.forEachIndexed { index, item ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(
|
||||
alpha = 0.5f
|
||||
),
|
||||
)
|
||||
}
|
||||
PluginCard(
|
||||
plugin = item.plugin,
|
||||
isSelected = item.isSelected,
|
||||
onRemove = { viewModel.removePlugin(item.plugin) },
|
||||
isLoggedIn = item.authState.isLoggedIn,
|
||||
logoPath = item.logoPath,
|
||||
onInfo = { viewModel.showPluginInfo(item.plugin) },
|
||||
onSupport = if (item.isSelected) {
|
||||
{ viewModel.loadSupport(item.plugin) }
|
||||
} else null,
|
||||
onLogin = if (item.isSelected && item.authState.requiresAuth) {
|
||||
{ viewModel.login(item.plugin) }
|
||||
} else null,
|
||||
onLogout = if (item.isSelected && item.authState.requiresAuth) {
|
||||
{ viewModel.logout(item.plugin) }
|
||||
} else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
if ((!onboarding || data.onboardingDiscover) && (onboarding || data.discover.isLoading || data.discover.repos.isNotEmpty() || data.discover.error != null)) {
|
||||
if (onboarding) {
|
||||
item {
|
||||
OutlineButton(onClick = viewModel::showInstallSheet) {
|
||||
Text(stringResource(Res.string.plugin_install_section_title))
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -408,12 +481,31 @@ fun PluginScreen(
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"Discover Plugins",
|
||||
stringResource(Res.string.welcome_plugins_discover),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
if (data.discover.isLoading) {
|
||||
item {
|
||||
Box(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onboarding && data.discover.isInitialLoaded && !data.discover.isLoading && data.discover.repos.isEmpty() && data.discover.error == null) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_discovery_empty),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(
|
||||
data.discover.repos,
|
||||
key = { it.id }
|
||||
@ -440,15 +532,27 @@ fun PluginScreen(
|
||||
|
||||
data.discover.error?.let { error ->
|
||||
item {
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 4.dp,
|
||||
vertical = 8.dp
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_plugins_discovery_error),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 4.dp,
|
||||
vertical = 8.dp
|
||||
)
|
||||
)
|
||||
)
|
||||
OutlineButton(onClick = viewModel::retryDiscovery) {
|
||||
Text(stringResource(Res.string.welcome_plugins_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onboarding && data.discover.hasMore && !data.discover.isLoading && !data.discover.isLoadingMore && data.discover.error == null) {
|
||||
item {
|
||||
OutlineButton(onClick = viewModel::loadNextDiscoverPage) {
|
||||
Text(stringResource(Res.string.welcome_plugins_more))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -468,8 +572,8 @@ fun PluginScreen(
|
||||
// Pagination trigger stays in the composable because it is driven by
|
||||
// LazyList layout information, which only exists in UI scope.
|
||||
// The actual loading call is forwarded to the ViewModel.
|
||||
LaunchedEffect(shouldLoadMore.value) {
|
||||
if (shouldLoadMore.value) {
|
||||
LaunchedEffect(shouldLoadMore.value, onboarding) {
|
||||
if (!onboarding && shouldLoadMore.value) {
|
||||
viewModel.loadNextDiscoverPage()
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,6 +87,7 @@ sealed interface PluginUiState {
|
||||
data object Loading : PluginUiState
|
||||
|
||||
data class Data(
|
||||
val onboardingDiscover: Boolean = true,
|
||||
val plugins: List<PluginListItem>,
|
||||
val abilitySelections: List<AbilitySelection>,
|
||||
val pendingPlugin: PluginManager.PendingPlugin? = null,
|
||||
@ -118,6 +119,7 @@ private data class PluginManagerSnapshot(
|
||||
)
|
||||
|
||||
private data class PluginScreenState(
|
||||
val onboardingDiscover: Boolean = true,
|
||||
val urlInput: String = "",
|
||||
val urlError: UrlError? = null,
|
||||
val isLoadingUrl: Boolean = false,
|
||||
@ -239,6 +241,7 @@ class PluginViewModel(
|
||||
when (val state = snapshot.state) {
|
||||
is PluginManagerStates.Loading -> PluginUiState.Loading
|
||||
is PluginManagerStates.Data -> PluginUiState.Data(
|
||||
onboardingDiscover = screen.onboardingDiscover,
|
||||
plugins = state.plugins.map { plugin ->
|
||||
PluginListItem(
|
||||
plugin = plugin,
|
||||
@ -468,6 +471,10 @@ class PluginViewModel(
|
||||
pluginManager.setSelectedPlugin(ability, plugin)
|
||||
}
|
||||
|
||||
fun showOnboardingDiscovery(show: Boolean) {
|
||||
_screenState.update { it.copy(onboardingDiscover = show) }
|
||||
}
|
||||
|
||||
fun removePlugin(plugin: PluginEntry) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
@ -512,6 +519,11 @@ class PluginViewModel(
|
||||
return repos.filter { it.htmlUrl !in installedUrls }
|
||||
}
|
||||
|
||||
fun retryDiscovery() {
|
||||
if (_screenState.value.discover.isLoading) return
|
||||
loadFirstDiscoverPage()
|
||||
}
|
||||
|
||||
private fun loadFirstDiscoverPage() {
|
||||
viewModelScope.launch {
|
||||
_screenState.update {
|
||||
@ -590,7 +602,8 @@ class PluginViewModel(
|
||||
}
|
||||
|
||||
fun installPlugin(release: GitHubRelease, repoId: Long) {
|
||||
val smplugUrl = release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl
|
||||
val smplugUrl =
|
||||
release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl
|
||||
if (smplugUrl != null) {
|
||||
installPluginFromUrl(smplugUrl, repoId)
|
||||
}
|
||||
|
||||
@ -129,6 +129,13 @@ fun AppShell(
|
||||
}
|
||||
}
|
||||
|
||||
// Onboarding is a bootstrap, full-screen destination. It draws its own app bar (and thus
|
||||
// the desktop window controls), so render it without the sidebar, players or bottom bar.
|
||||
if (navigationState.topLevelRoute == Routes.Welcome) {
|
||||
content()
|
||||
return
|
||||
}
|
||||
|
||||
ConnectionRequestDialogHost()
|
||||
PlayDestinationPickerHost()
|
||||
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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.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
|
||||
|
||||
/** Order matches the 4 onboarding pages. */
|
||||
internal val welcomePrimaryLabels: List<StringResource> = listOf(
|
||||
Res.string.welcome_lets_go,
|
||||
Res.string.welcome_next,
|
||||
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,
|
||||
),
|
||||
)
|
||||
@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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.welcome
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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,
|
||||
)
|
||||
@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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.welcome
|
||||
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import dev.krtirtho.spotube.core.DebugConfig
|
||||
import dev.krtirtho.spotube.core.db.Database
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
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.launch
|
||||
|
||||
/**
|
||||
* Owns whether the Welcome/Onboarding flow should be the app's start destination.
|
||||
*
|
||||
* This lives in its own preference rather than [dev.krtirtho.spotube.modules.settings.UserSettings]
|
||||
* so the very first launch can be distinguished from "settings not read yet". It is a Koin
|
||||
* singleton so the app-level start route and the routed [WelcomeScreen] observe the same state.
|
||||
*/
|
||||
class WelcomeRepository(private val database: Database) {
|
||||
companion object {
|
||||
private val ONBOARDING_COMPLETED_KEY = booleanPreferencesKey("onboarding_completed")
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Set when onboarding is finished for this process only; does not persist (debug forcing). */
|
||||
private val sessionDismissed = MutableStateFlow(false)
|
||||
|
||||
/** Synchronous signal, available before the persisted flag loads; true on upgrades. */
|
||||
val isExistingInstall: Boolean get() = database.isExistingInstall
|
||||
|
||||
private val onboardingCompleted: StateFlow<Boolean?> = database.settingsDataStore.data
|
||||
.map { prefs -> prefs[ONBOARDING_COMPLETED_KEY] ?: database.isExistingInstall }
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
/**
|
||||
* Whether the app should start on the Welcome/Onboarding route instead of the main shell.
|
||||
*
|
||||
* - `true` on a fresh install, or whenever [DebugConfig.FORCE_SHOW_ONBOARDING] is enabled.
|
||||
* - `false` on an existing install (upgrade) or once onboarding is completed or skipped,
|
||||
* including within a forced session.
|
||||
*
|
||||
* The initial value is derived from [isExistingInstall] so the start route can be chosen on
|
||||
* the very first frame without flashing the wrong screen.
|
||||
*/
|
||||
val shouldShowOnboarding: StateFlow<Boolean> = combine(
|
||||
onboardingCompleted,
|
||||
sessionDismissed,
|
||||
) { completed, dismissed ->
|
||||
when {
|
||||
dismissed -> false
|
||||
DebugConfig.FORCE_SHOW_ONBOARDING -> true
|
||||
else -> !(completed ?: database.isExistingInstall)
|
||||
}
|
||||
}.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = if (DebugConfig.FORCE_SHOW_ONBOARDING) true else !database.isExistingInstall,
|
||||
)
|
||||
|
||||
init {
|
||||
// One-time migration. When the flag was never written this is either a brand-new install
|
||||
// (show onboarding) or an upgrade of an existing install (skip it). Persisting the
|
||||
// decision also stops the fresh install's own data writes from being mistaken for an
|
||||
// upgrade on the next launch.
|
||||
scope.launch {
|
||||
database.settingsDataStore.edit { prefs ->
|
||||
if (ONBOARDING_COMPLETED_KEY !in prefs) {
|
||||
prefs[ONBOARDING_COMPLETED_KEY] = database.isExistingInstall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun completeOnboarding() {
|
||||
sessionDismissed.value = true
|
||||
database.settingsDataStore.edit { prefs ->
|
||||
prefs[ONBOARDING_COMPLETED_KEY] = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Development helper to surface the welcome screen again on the next launch. */
|
||||
suspend fun resetOnboarding() {
|
||||
database.settingsDataStore.edit { prefs ->
|
||||
prefs[ONBOARDING_COMPLETED_KEY] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,245 @@
|
||||
/*
|
||||
* 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.welcome
|
||||
|
||||
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 androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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 androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.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.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostButton
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.modules.welcome.pages.BrandingPage
|
||||
import dev.krtirtho.spotube.modules.welcome.pages.DonationPage
|
||||
import dev.krtirtho.spotube.modules.welcome.pages.HowItWorksPage
|
||||
import dev.krtirtho.spotube.modules.welcome.pages.PluginsPage
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
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.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WelcomeScreen(viewModel: WelcomeViewModel) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
WelcomeTheme {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
WelcomeBackground()
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
title = {
|
||||
WelcomeWordmark(
|
||||
fontSize = MaterialTheme.typography.titleLarge.fontSize,
|
||||
)
|
||||
},
|
||||
backButton = false,
|
||||
transparent = true,
|
||||
actions = {
|
||||
if (!state.isLastPage) {
|
||||
GhostButton(onClick = viewModel::completeOnboarding) {
|
||||
Text(
|
||||
text = stringResource(Res.string.welcome_skip),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.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) {
|
||||
OutlineButton(
|
||||
onClick = viewModel::previousPage,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.welcome_back),
|
||||
color = MaterialTheme.colorScheme.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
|
||||
OutlineButton(
|
||||
onClick = viewModel::onPrimary,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(state.primaryLabel),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (state.isLastPage) {
|
||||
Iconsax.IconsaxCheckCircle
|
||||
} else {
|
||||
Iconsax.IconsaxNext
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PageIndicator(
|
||||
pageCount: Int,
|
||||
currentPageIndex: Int,
|
||||
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) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.welcome
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
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 ->
|
||||
val pageCount = welcomePrimaryLabels.size
|
||||
WelcomeUiState(
|
||||
currentPageIndex = index,
|
||||
pageCount = pageCount,
|
||||
isLastPage = index == pageCount - 1,
|
||||
primaryLabel = welcomePrimaryLabels[index.coerceIn(0, pageCount - 1)],
|
||||
donations = welcomeDonations,
|
||||
demoPlaying = playing,
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = WelcomeUiState(
|
||||
currentPageIndex = 0,
|
||||
pageCount = welcomePrimaryLabels.size,
|
||||
isLastPage = welcomePrimaryLabels.size == 1,
|
||||
primaryLabel = welcomePrimaryLabels.first(),
|
||||
donations = welcomeDonations,
|
||||
),
|
||||
)
|
||||
|
||||
fun nextPage() {
|
||||
currentPageIndex.update { (it + 1).coerceAtMost(welcomePrimaryLabels.lastIndex) }
|
||||
}
|
||||
|
||||
fun toggleDemo() {
|
||||
demoPlaying.update { !it }
|
||||
}
|
||||
|
||||
fun onPrimary() {
|
||||
if (currentPageIndex.value == welcomePrimaryLabels.lastIndex) completeOnboarding() else nextPage()
|
||||
}
|
||||
|
||||
fun openDonation(url: String) {
|
||||
// Only destinations owned by our static funding list can be opened here.
|
||||
if (welcomeDonations.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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.welcome
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
|
||||
import org.jetbrains.compose.resources.Font
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
/** App-native controls and surfaces, in neutral charcoal/ivory instead of the user's accent. */
|
||||
@Composable
|
||||
fun WelcomeTheme(content: @Composable () -> Unit) {
|
||||
val dark = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||
val scheme = if (dark) darkColorScheme(
|
||||
primary = Color(0xFF8BA8DD), onPrimary = Color(0xFF10213E),
|
||||
primaryContainer = Color(0xFF263754), onPrimaryContainer = Color(0xFFD9E4F7),
|
||||
secondary = Color(0xFFBDBDBD), secondaryContainer = Color(0xFF343434),
|
||||
onSecondaryContainer = Color(0xFFF0F0F0),
|
||||
surface = Color(0xFF181818), background = Color(0xFF181818),
|
||||
surfaceContainer = Color(0xFF222222), surfaceContainerHigh = Color(0xFF292929),
|
||||
surfaceContainerHighest = Color(0xFF323232),
|
||||
onSurface = Color(0xFFEDEBE7), onSurfaceVariant = Color(0xFFABA9A5),
|
||||
outlineVariant = Color(0xFF414141),
|
||||
) else lightColorScheme(
|
||||
primary = Color(0xFF244879), onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFDDE5F1), onPrimaryContainer = Color(0xFF173252),
|
||||
secondaryContainer = Color(0xFFE5E3DF), onSecondaryContainer = Color(0xFF292929),
|
||||
surface = Color(0xFFF4F3F0), background = Color(0xFFF4F3F0),
|
||||
surfaceContainer = Color(0xFFEDEBE7), surfaceContainerHigh = Color(0xFFFDFCFA),
|
||||
surfaceContainerHighest = Color(0xFFE1DFDB),
|
||||
onSurface = Color(0xFF252525), onSurfaceVariant = Color(0xFF696762),
|
||||
outlineVariant = Color(0xFFD2D0CC),
|
||||
)
|
||||
MaterialTheme(colorScheme = scheme) {
|
||||
CompositionLocalProvider(LocalBaseUITheme provides rememberBaseUITheme()) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WelcomeBackground(modifier: Modifier = Modifier) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(listOf(scheme.surfaceContainer, scheme.surface)),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WelcomeWordmark(
|
||||
modifier: Modifier = Modifier,
|
||||
fontSize: TextUnit = MaterialTheme.typography.displaySmall.fontSize
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_app_name),
|
||||
modifier = modifier,
|
||||
fontFamily = FontFamily(Font(Res.font.cookie_regular)),
|
||||
fontSize = fontSize,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package dev.krtirtho.spotube.modules.welcome.pages
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.krtirtho.spotube.modules.welcome.WelcomeWordmark
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
@Composable
|
||||
fun BrandingPage(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Image(painterResource(Res.drawable.spotube_logo), null, Modifier.size(88.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
WelcomeWordmark(fontSize = 48.sp)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.welcome_brand_freedom),
|
||||
style = MaterialTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
stringResource(Res.string.welcome_brand_quiet_description),
|
||||
modifier = Modifier.widthIn(max = 440.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package dev.krtirtho.spotube.modules.welcome.pages
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.modules.welcome.DonationOption
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun DonationPage(
|
||||
donations: List<DonationOption>,
|
||||
onDonate: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_donate_quiet_title),
|
||||
style = MaterialTheme.typography.displaySmall.copy(fontFamily = FontFamily.Serif),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
stringResource(Res.string.welcome_donate_letter),
|
||||
modifier = Modifier.widthIn(max = 540.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
donations.forEach { option ->
|
||||
OutlineButton(onClick = { onDonate(option.url) }) {
|
||||
Icon(
|
||||
option.icon, null, Modifier.size(24.dp),
|
||||
tint = if (option.id == "patreon") MaterialTheme.colorScheme.onSurface else Color.Unspecified,
|
||||
)
|
||||
Text(stringResource(option.title))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.welcome_donate_optional),
|
||||
modifier = Modifier.widthIn(max = 500.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,181 @@
|
||||
package dev.krtirtho.spotube.modules.welcome.pages
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.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.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.core.ui.base.Card
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.resources.iconsax.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
fun HowItWorksPage(
|
||||
isPlaying: Boolean,
|
||||
onTogglePlayback: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val wide = maxWidth >= 760.dp
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListeningIntroduction(modifier: Modifier = Modifier) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
Text(
|
||||
stringResource(Res.string.welcome_how_quiet_title),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(fontFamily = FontFamily.Serif),
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.welcome_how_quiet_description),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Silent, illustrative player. The ViewModel owns the play/pause interaction. */
|
||||
@Composable
|
||||
private fun PlayerDemonstration(
|
||||
isPlaying: Boolean,
|
||||
onTogglePlayback: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
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 = MaterialTheme.colorScheme
|
||||
|
||||
Card(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 = MaterialTheme.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 = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.welcome_how_mockup_artist),
|
||||
style = MaterialTheme.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 = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Serif),
|
||||
color = scheme.onSurfaceVariant
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Iconsax.IconsaxPrevious,
|
||||
null,
|
||||
Modifier.size(18.dp),
|
||||
tint = scheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.width(24.dp))
|
||||
GhostIconButton(onClick = onTogglePlayback, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
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))
|
||||
Icon(
|
||||
Iconsax.IconsaxNext,
|
||||
null,
|
||||
Modifier.size(18.dp),
|
||||
tint = scheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package dev.krtirtho.spotube.modules.welcome.pages
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginScreen
|
||||
|
||||
/** The same installation, discovery, permissions and sign-in flow as Settings, in-place. */
|
||||
@Composable
|
||||
fun PluginsPage() {
|
||||
PluginScreen(onboarding = true)
|
||||
}
|
||||
@ -0,0 +1,264 @@
|
||||
package dev.krtirtho.spotube.resources.iconsax
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathData
|
||||
import androidx.compose.ui.graphics.vector.group
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Iconsax.BrandBuyMeACoffee: ImageVector
|
||||
get() {
|
||||
if (_BrandBuyMeACoffee != null) {
|
||||
return _BrandBuyMeACoffee!!
|
||||
}
|
||||
_BrandBuyMeACoffee = ImageVector.Builder(
|
||||
name = "BrandBuyMeACoffee",
|
||||
defaultWidth = 512.dp,
|
||||
defaultHeight = 512.dp,
|
||||
viewportWidth = 512f,
|
||||
viewportHeight = 512f
|
||||
).apply {
|
||||
group(
|
||||
clipPathData = PathData {
|
||||
moveTo(0f, 0f)
|
||||
horizontalLineToRelative(512f)
|
||||
verticalLineToRelative(512f)
|
||||
horizontalLineTo(0f)
|
||||
close()
|
||||
}
|
||||
) {
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256.17f, 256.45f)
|
||||
lineToRelative(-0.35f, -0.21f)
|
||||
lineTo(255f, 256f)
|
||||
arcToRelative(1.93f, 1.93f, 0f, isMoreThanHalf = false, isPositiveArc = false, 1.16f, 0.45f)
|
||||
close()
|
||||
moveTo(256.15f, 256.04f)
|
||||
arcToRelative(0.75f, 0.75f, 0f, isMoreThanHalf = false, isPositiveArc = true, -0.15f, -0.04f)
|
||||
arcToRelative(0.54f, 0.54f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0f, 0.1f)
|
||||
arcToRelative(0.3f, 0.3f, 0f, isMoreThanHalf = false, isPositiveArc = false, 0.15f, -0.06f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256f, 256.03f)
|
||||
horizontalLineToRelative(0.05f)
|
||||
verticalLineTo(256f)
|
||||
lineToRelative(-0.05f, 0.03f)
|
||||
close()
|
||||
moveTo(255f, 256.67f)
|
||||
lineToRelative(0.59f, -0.34f)
|
||||
lineToRelative(0.22f, -0.12f)
|
||||
lineToRelative(0.2f, -0.21f)
|
||||
arcToRelative(3.39f, 3.39f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.01f, 0.68f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(255.97f, 256.77f)
|
||||
lineToRelative(-0.58f, -0.55f)
|
||||
lineTo(255f, 256f)
|
||||
curveToRelative(0.21f, 0.37f, 0.56f, 0.65f, 0.97f, 0.76f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256.18f, 256f)
|
||||
curveToRelative(-0.46f, 0.2f, -0.87f, 0.51f, -1.18f, 0.91f)
|
||||
lineToRelative(0.37f, -0.23f)
|
||||
curveToRelative(0.25f, -0.23f, 0.6f, -0.5f, 0.81f, -0.68f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256.2f, 255.27f)
|
||||
curveToRelative(0f, -0.52f, -0.25f, -0.43f, -0.19f, 1.43f)
|
||||
curveToRelative(0f, -0.15f, 0.06f, -0.3f, 0.09f, -0.44f)
|
||||
curveToRelative(0.04f, -0.33f, 0.06f, -0.66f, 0.1f, -0.99f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256.18f, 256f)
|
||||
arcToRelative(3.02f, 3.02f, 0f, isMoreThanHalf = false, isPositiveArc = false, -1.18f, 0.91f)
|
||||
lineToRelative(0.37f, -0.23f)
|
||||
curveToRelative(0.25f, -0.23f, 0.6f, -0.5f, 0.81f, -0.68f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256.24f, 256.58f)
|
||||
arcTo(2.52f, 2.52f, 0f, isMoreThanHalf = false, isPositiveArc = false, 255f, 256f)
|
||||
curveToRelative(0.37f, 0.18f, 0.74f, 0.36f, 0.99f, 0.5f)
|
||||
lineToRelative(0.25f, 0.08f)
|
||||
close()
|
||||
moveTo(256.49f, 256.54f)
|
||||
arcTo(3.96f, 3.96f, 0f, isMoreThanHalf = false, isPositiveArc = false, 256f, 255f)
|
||||
curveToRelative(0.19f, 0.49f, 0.35f, 1f, 0.47f, 1.51f)
|
||||
lineToRelative(0.01f, 0.03f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFFFFDD00))) {
|
||||
moveTo(267.82f, 145.08f)
|
||||
curveToRelative(-18.4f, 7.87f, -39.28f, 16.8f, -66.33f, 16.8f)
|
||||
arcTo(125.64f, 125.64f, 0f, isMoreThanHalf = false, isPositiveArc = true, 168f, 157.26f)
|
||||
lineToRelative(18.71f, 192.07f)
|
||||
arcToRelative(32.09f, 32.09f, 0f, isMoreThanHalf = false, isPositiveArc = false, 32f, 29.45f)
|
||||
reflectiveCurveToRelative(26.53f, 1.38f, 35.39f, 1.38f)
|
||||
curveToRelative(9.53f, 0f, 38.1f, -1.38f, 38.1f, -1.38f)
|
||||
arcToRelative(32.1f, 32.1f, 0f, isMoreThanHalf = false, isPositiveArc = false, 31.99f, -29.45f)
|
||||
lineToRelative(20.04f, -212.25f)
|
||||
curveToRelative(-8.96f, -3.06f, -18f, -5.09f, -28.19f, -5.09f)
|
||||
curveToRelative(-17.63f, -0.01f, -31.83f, 6.06f, -48.23f, 13.08f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(256f, 256f)
|
||||
lineToRelative(0.32f, 0.3f)
|
||||
lineToRelative(0.21f, 0.12f)
|
||||
arcTo(3.21f, 3.21f, 0f, isMoreThanHalf = false, isPositiveArc = false, 256f, 256f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF0D0C22))) {
|
||||
moveTo(431.23f, 136.85f)
|
||||
lineToRelative(-2.82f, -14.21f)
|
||||
curveToRelative(-2.53f, -12.75f, -8.27f, -24.8f, -21.36f, -29.41f)
|
||||
curveToRelative(-4.2f, -1.47f, -8.96f, -2.11f, -12.18f, -5.16f)
|
||||
curveToRelative(-3.22f, -3.05f, -4.17f, -7.79f, -4.91f, -12.18f)
|
||||
curveToRelative(-1.38f, -8.07f, -2.67f, -16.14f, -4.09f, -24.19f)
|
||||
curveToRelative(-1.22f, -6.92f, -2.18f, -14.7f, -5.36f, -21.05f)
|
||||
curveToRelative(-4.13f, -8.53f, -12.71f, -13.51f, -21.24f, -16.81f)
|
||||
arcToRelative(122.38f, 122.38f, 0f, isMoreThanHalf = false, isPositiveArc = false, -13.36f, -4.13f)
|
||||
curveToRelative(-21.31f, -5.62f, -43.72f, -7.69f, -65.64f, -8.86f)
|
||||
arcToRelative(551.32f, 551.32f, 0f, isMoreThanHalf = false, isPositiveArc = false, -78.96f, 1.31f)
|
||||
curveToRelative(-19.54f, 1.78f, -40.12f, 3.93f, -58.69f, 10.68f)
|
||||
curveToRelative(-6.79f, 2.47f, -13.78f, 5.44f, -18.94f, 10.68f)
|
||||
curveToRelative(-6.33f, 6.44f, -8.4f, 16.4f, -3.78f, 24.43f)
|
||||
curveToRelative(3.29f, 5.7f, 8.85f, 9.73f, 14.76f, 12.4f)
|
||||
arcToRelative(119.72f, 119.72f, 0f, isMoreThanHalf = false, isPositiveArc = false, 23.97f, 7.8f)
|
||||
curveToRelative(22.94f, 5.07f, 46.71f, 7.06f, 70.15f, 7.91f)
|
||||
arcToRelative(534.38f, 534.38f, 0f, isMoreThanHalf = false, isPositiveArc = false, 77.86f, -2.54f)
|
||||
arcToRelative(443.55f, 443.55f, 0f, isMoreThanHalf = false, isPositiveArc = false, 19.15f, -2.53f)
|
||||
curveToRelative(7.5f, -1.15f, 12.32f, -10.96f, 10.11f, -17.79f)
|
||||
curveToRelative(-2.65f, -8.17f, -9.76f, -11.34f, -17.8f, -10.1f)
|
||||
curveToRelative(-1.18f, 0.19f, -2.36f, 0.36f, -3.55f, 0.53f)
|
||||
lineToRelative(-0.85f, 0.12f)
|
||||
curveToRelative(-2.72f, 0.34f, -5.45f, 0.67f, -8.17f, 0.96f)
|
||||
arcToRelative(433.76f, 433.76f, 0f, isMoreThanHalf = false, isPositiveArc = true, -16.92f, 1.49f)
|
||||
curveToRelative(-12.66f, 0.88f, -25.36f, 1.29f, -38.06f, 1.31f)
|
||||
curveToRelative(-12.47f, 0f, -24.95f, -0.35f, -37.39f, -1.17f)
|
||||
arcToRelative(475.74f, 475.74f, 0f, isMoreThanHalf = false, isPositiveArc = true, -16.99f, -1.42f)
|
||||
curveToRelative(-2.57f, -0.27f, -5.13f, -0.55f, -7.7f, -0.87f)
|
||||
lineToRelative(-2.44f, -0.31f)
|
||||
lineToRelative(-0.53f, -0.08f)
|
||||
lineToRelative(-2.53f, -0.37f)
|
||||
curveToRelative(-5.17f, -0.78f, -10.34f, -1.67f, -15.45f, -2.76f)
|
||||
arcToRelative(2.32f, 2.32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0f, -4.53f)
|
||||
horizontalLineToRelative(0.1f)
|
||||
arcToRelative(250.82f, 250.82f, 0f, isMoreThanHalf = false, isPositiveArc = true, 13.37f, -2.45f)
|
||||
curveToRelative(1.49f, -0.23f, 2.99f, -0.46f, 4.49f, -0.69f)
|
||||
horizontalLineToRelative(0.04f)
|
||||
curveToRelative(2.81f, -0.19f, 5.62f, -0.69f, 8.41f, -1.02f)
|
||||
arcToRelative(535.29f, 535.29f, 0f, isMoreThanHalf = false, isPositiveArc = true, 73.1f, -2.58f)
|
||||
curveToRelative(11.84f, 0.34f, 23.68f, 1.04f, 35.47f, 2.24f)
|
||||
arcToRelative(391.9f, 391.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, 7.58f, 0.85f)
|
||||
curveToRelative(0.96f, 0.12f, 1.94f, 0.25f, 2.91f, 0.37f)
|
||||
lineToRelative(1.96f, 0.28f)
|
||||
curveToRelative(5.7f, 0.85f, 11.38f, 1.88f, 17.03f, 3.09f)
|
||||
curveToRelative(8.36f, 1.82f, 19.11f, 2.41f, 22.83f, 11.57f)
|
||||
curveToRelative(1.18f, 2.91f, 1.72f, 6.14f, 2.38f, 9.19f)
|
||||
lineToRelative(0.83f, 3.89f)
|
||||
curveToRelative(0.02f, 0.07f, 0.04f, 0.14f, 0.05f, 0.21f)
|
||||
curveToRelative(1.97f, 9.18f, 3.94f, 18.37f, 5.92f, 27.55f)
|
||||
arcToRelative(5.05f, 5.05f, 0f, isMoreThanHalf = false, isPositiveArc = true, -4.24f, 6.05f)
|
||||
horizontalLineToRelative(-0.05f)
|
||||
lineToRelative(-1.21f, 0.17f)
|
||||
lineToRelative(-1.19f, 0.16f)
|
||||
curveToRelative(-3.78f, 0.49f, -7.56f, 0.95f, -11.34f, 1.38f)
|
||||
arcToRelative(732.52f, 732.52f, 0f, isMoreThanHalf = false, isPositiveArc = true, -22.4f, 2.2f)
|
||||
arcToRelative(784.15f, 784.15f, 0f, isMoreThanHalf = false, isPositiveArc = true, -44.67f, 2.43f)
|
||||
arcToRelative(802.63f, 802.63f, 0f, isMoreThanHalf = false, isPositiveArc = true, -22.8f, 0.28f)
|
||||
arcToRelative(791.3f, 791.3f, 0f, isMoreThanHalf = false, isPositiveArc = true, -90.5f, -5.26f)
|
||||
arcToRelative(944.16f, 944.16f, 0f, isMoreThanHalf = false, isPositiveArc = true, -9.76f, -1.22f)
|
||||
curveToRelative(2.52f, 0.32f, -1.83f, -0.25f, -2.71f, -0.37f)
|
||||
curveToRelative(-2.07f, -0.29f, -4.13f, -0.59f, -6.2f, -0.9f)
|
||||
curveToRelative(-6.94f, -1.04f, -13.84f, -2.32f, -20.76f, -3.44f)
|
||||
curveToRelative(-8.37f, -1.38f, -16.38f, -0.69f, -23.95f, 3.44f)
|
||||
arcToRelative(34.83f, 34.83f, 0f, isMoreThanHalf = false, isPositiveArc = false, -14.42f, 14.95f)
|
||||
curveToRelative(-3.27f, 6.75f, -4.24f, 14.1f, -5.7f, 21.35f)
|
||||
curveToRelative(-1.46f, 7.25f, -3.73f, 15.06f, -2.87f, 22.5f)
|
||||
curveToRelative(1.85f, 16.07f, 13.09f, 29.13f, 29.26f, 32.05f)
|
||||
curveToRelative(15.21f, 2.76f, 30.5f, 4.99f, 45.83f, 6.89f)
|
||||
arcToRelative(847.2f, 847.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 196.22f, 1.13f)
|
||||
arcToRelative(10.34f, 10.34f, 0f, isMoreThanHalf = false, isPositiveArc = true, 11.41f, 11.32f)
|
||||
lineToRelative(-1.53f, 14.86f)
|
||||
lineToRelative(-9.25f, 90.1f)
|
||||
arcToRelative(70942.69f, 70942.69f, 0f, isMoreThanHalf = false, isPositiveArc = true, -9.71f, 94.6f)
|
||||
lineToRelative(-2.76f, 26.64f)
|
||||
curveToRelative(-0.88f, 8.74f, -1.01f, 17.76f, -2.67f, 26.4f)
|
||||
curveToRelative(-2.62f, 13.58f, -11.82f, 21.93f, -25.24f, 24.98f)
|
||||
arcToRelative(175.93f, 175.93f, 0f, isMoreThanHalf = false, isPositiveArc = true, -37.47f, 4.38f)
|
||||
curveToRelative(-13.98f, 0.08f, -27.95f, -0.54f, -41.94f, -0.47f)
|
||||
curveToRelative(-14.93f, 0.08f, -33.2f, -1.29f, -44.72f, -12.4f)
|
||||
curveToRelative(-10.12f, -9.75f, -11.52f, -25.02f, -12.9f, -38.23f)
|
||||
arcToRelative(29492.9f, 29492.9f, 0f, isMoreThanHalf = false, isPositiveArc = true, -5.46f, -52.44f)
|
||||
lineToRelative(-10.13f, -97.18f)
|
||||
lineToRelative(-6.55f, -62.88f)
|
||||
curveToRelative(-0.11f, -1.04f, -0.22f, -2.07f, -0.32f, -3.11f)
|
||||
curveToRelative(-0.79f, -7.5f, -6.1f, -14.84f, -14.47f, -14.46f)
|
||||
curveToRelative(-7.17f, 0.32f, -15.31f, 6.41f, -14.47f, 14.46f)
|
||||
lineToRelative(4.86f, 46.62f)
|
||||
lineToRelative(10.05f, 96.43f)
|
||||
curveToRelative(2.86f, 27.39f, 5.72f, 54.79f, 8.56f, 82.19f)
|
||||
curveToRelative(0.55f, 5.25f, 1.07f, 10.51f, 1.65f, 15.76f)
|
||||
curveToRelative(3.15f, 28.68f, 25.06f, 44.14f, 52.19f, 48.49f)
|
||||
curveToRelative(15.85f, 2.55f, 32.08f, 3.08f, 48.16f, 3.34f)
|
||||
curveToRelative(20.62f, 0.33f, 41.44f, 1.13f, 61.72f, -2.61f)
|
||||
curveToRelative(30.05f, -5.51f, 52.6f, -25.57f, 55.81f, -56.68f)
|
||||
lineToRelative(2.76f, -26.95f)
|
||||
curveToRelative(3.05f, -29.72f, 6.11f, -59.44f, 9.15f, -89.17f)
|
||||
lineToRelative(9.96f, -97.12f)
|
||||
lineToRelative(4.57f, -44.51f)
|
||||
arcToRelative(10.34f, 10.34f, 0f, isMoreThanHalf = false, isPositiveArc = true, 8.32f, -9.09f)
|
||||
curveToRelative(8.59f, -1.67f, 16.81f, -4.53f, 22.92f, -11.07f)
|
||||
curveToRelative(9.73f, -10.41f, 11.66f, -23.98f, 8.23f, -37.66f)
|
||||
close()
|
||||
moveTo(108.01f, 146.45f)
|
||||
curveToRelative(0.13f, -0.06f, -0.11f, 1.06f, -0.21f, 1.58f)
|
||||
curveToRelative(-0.02f, -0.79f, 0.02f, -1.49f, 0.21f, -1.58f)
|
||||
close()
|
||||
moveTo(108.84f, 152.9f)
|
||||
curveToRelative(0.07f, -0.05f, 0.28f, 0.23f, 0.49f, 0.56f)
|
||||
curveToRelative(-0.32f, -0.3f, -0.53f, -0.53f, -0.5f, -0.56f)
|
||||
horizontalLineToRelative(0.01f)
|
||||
close()
|
||||
moveTo(109.66f, 153.98f)
|
||||
curveToRelative(0.45f, 0.82f, 0.3f, 0.5f, 0f, 0f)
|
||||
close()
|
||||
moveTo(111.31f, 155.31f)
|
||||
horizontalLineToRelative(0.04f)
|
||||
curveToRelative(0f, 0.05f, 0.08f, 0.1f, 0.1f, 0.14f)
|
||||
arcToRelative(1.04f, 1.04f, 0f, isMoreThanHalf = false, isPositiveArc = false, -0.15f, -0.14f)
|
||||
horizontalLineToRelative(0.01f)
|
||||
close()
|
||||
moveTo(399.68f, 153.32f)
|
||||
curveToRelative(-3.09f, 2.93f, -7.74f, 4.3f, -12.33f, 4.98f)
|
||||
curveToRelative(-51.54f, 7.64f, -103.83f, 11.52f, -155.94f, 9.81f)
|
||||
curveToRelative(-37.29f, -1.27f, -74.19f, -5.41f, -111.11f, -10.63f)
|
||||
curveToRelative(-3.62f, -0.51f, -7.54f, -1.17f, -10.02f, -3.84f)
|
||||
curveToRelative(-4.69f, -5.03f, -2.38f, -15.15f, -1.16f, -21.23f)
|
||||
curveToRelative(1.12f, -5.57f, 3.25f, -12.98f, 9.87f, -13.78f)
|
||||
curveToRelative(10.34f, -1.21f, 22.34f, 3.15f, 32.56f, 4.7f)
|
||||
arcToRelative(616.95f, 616.95f, 0f, isMoreThanHalf = false, isPositiveArc = false, 37.07f, 4.51f)
|
||||
curveToRelative(52.93f, 4.82f, 106.75f, 4.07f, 159.45f, -2.98f)
|
||||
arcToRelative(666.2f, 666.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 28.71f, -4.5f)
|
||||
curveToRelative(8.5f, -1.52f, 17.92f, -4.38f, 23.05f, 4.41f)
|
||||
curveToRelative(3.52f, 5.99f, 3.99f, 14.01f, 3.44f, 20.78f)
|
||||
arcToRelative(11.59f, 11.59f, 0f, isMoreThanHalf = false, isPositiveArc = true, -3.6f, 7.76f)
|
||||
horizontalLineToRelative(0.01f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _BrandBuyMeACoffee!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _BrandBuyMeACoffee: ImageVector? = null
|
||||
@ -0,0 +1,58 @@
|
||||
package dev.krtirtho.spotube.resources.iconsax
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Iconsax.BrandOpencollective: ImageVector
|
||||
get() {
|
||||
if (_BrandOpencollective != null) {
|
||||
return _BrandOpencollective!!
|
||||
}
|
||||
_BrandOpencollective = ImageVector.Builder(
|
||||
name = "BrandOpencollective",
|
||||
defaultWidth = 64.dp,
|
||||
defaultHeight = 64.dp,
|
||||
viewportWidth = 64f,
|
||||
viewportHeight = 64f
|
||||
).apply {
|
||||
path(
|
||||
fill = SolidColor(Color(0xFFB8D3F4)),
|
||||
pathFillType = PathFillType.EvenOdd
|
||||
) {
|
||||
moveTo(52.402f, 31.916f)
|
||||
curveToRelative(0f, 4.03f, -1.17f, 7.895f, -3.178f, 11.087f)
|
||||
lineToRelative(8.196f, 8.23f)
|
||||
curveToRelative(4.014f, -5.375f, 6.523f, -12.094f, 6.523f, -19.318f)
|
||||
reflectiveCurveToRelative(-2.51f, -13.942f, -6.523f, -19.318f)
|
||||
lineToRelative(-8.196f, 8.23f)
|
||||
curveToRelative(2.007f, 3.192f, 3.178f, 6.887f, 3.178f, 11.087f)
|
||||
close()
|
||||
}
|
||||
path(
|
||||
fill = SolidColor(Color(0xFF3385FF)),
|
||||
pathFillType = PathFillType.EvenOdd
|
||||
) {
|
||||
moveTo(32.004f, 52.41f)
|
||||
curveToRelative(-11.207f, 0f, -20.406f, -9.24f, -20.406f, -20.493f)
|
||||
reflectiveCurveToRelative(9.2f, -20.493f, 20.406f, -20.493f)
|
||||
curveToRelative(4.182f, 0f, 7.86f, 1.176f, 11.04f, 3.36f)
|
||||
lineToRelative(8.196f, -8.23f)
|
||||
curveTo(45.887f, 2.52f, 39.197f, 0f, 32.004f, 0f)
|
||||
curveTo(14.44f, 0f, 0.057f, 14.278f, 0.057f, 32.084f)
|
||||
reflectiveCurveTo(14.44f, 64f, 32.004f, 64f)
|
||||
curveToRelative(7.36f, 0f, 14.05f, -2.52f, 19.403f, -6.55f)
|
||||
lineToRelative(-8.196f, -8.23f)
|
||||
curveToRelative(-3.178f, 2.016f, -7.025f, 3.192f, -11.207f, 3.192f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _BrandOpencollective!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _BrandOpencollective: ImageVector? = null
|
||||
@ -0,0 +1,54 @@
|
||||
package dev.krtirtho.spotube.resources.iconsax
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathData
|
||||
import androidx.compose.ui.graphics.vector.group
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Iconsax.BrandPatreon: ImageVector
|
||||
get() {
|
||||
if (_BrandPatreon != null) {
|
||||
return _BrandPatreon!!
|
||||
}
|
||||
_BrandPatreon = ImageVector.Builder(
|
||||
name = "BrandPatreon",
|
||||
defaultWidth = 512.dp,
|
||||
defaultHeight = 512.dp,
|
||||
viewportWidth = 512f,
|
||||
viewportHeight = 512f
|
||||
).apply {
|
||||
group(
|
||||
clipPathData = PathData {
|
||||
moveTo(0.38f, 0.42f)
|
||||
lineToRelative(512f, 0f)
|
||||
lineToRelative(0f, 512f)
|
||||
lineTo(0.38f, 512.42f)
|
||||
close()
|
||||
}
|
||||
) {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
strokeLineJoin = StrokeJoin.Round
|
||||
) {
|
||||
moveTo(490.12f, 154.23f)
|
||||
curveToRelative(-0.09f, -65.37f, -51.01f, -118.95f, -110.74f, -138.29f)
|
||||
curveToRelative(-74.18f, -24.01f, -172.02f, -20.53f, -242.86f, 12.89f)
|
||||
curveToRelative(-85.85f, 40.51f, -112.82f, 129.26f, -113.83f, 217.77f)
|
||||
curveToRelative(-0.82f, 72.77f, 6.44f, 264.43f, 114.54f, 265.8f)
|
||||
curveToRelative(80.33f, 1.02f, 92.29f, -102.48f, 129.45f, -152.33f)
|
||||
curveToRelative(26.44f, -35.47f, 60.49f, -45.48f, 102.4f, -55.85f)
|
||||
curveToRelative(72.03f, -17.83f, 121.13f, -74.68f, 121.03f, -149.99f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _BrandPatreon!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _BrandPatreon: ImageVector? = null
|
||||
@ -31,6 +31,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -59,6 +61,7 @@ actual fun ApplicationMainBar(
|
||||
subtitle: @Composable () -> Unit,
|
||||
actions: @Composable (RowScope.() -> Unit),
|
||||
backButton: Boolean,
|
||||
transparent: Boolean,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@ -75,6 +78,14 @@ actual fun ApplicationMainBar(
|
||||
}
|
||||
},
|
||||
subtitle = subtitle,
|
||||
colors = if (transparent) {
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent,
|
||||
)
|
||||
} else {
|
||||
TopAppBarDefaults.topAppBarColors()
|
||||
},
|
||||
modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons
|
||||
)
|
||||
LocalWindowScope.current.WindowControls(
|
||||
|
||||
@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@ -29,12 +31,21 @@ actual fun ApplicationMainBar(
|
||||
title: @Composable (() -> Unit),
|
||||
subtitle: @Composable () -> Unit,
|
||||
actions: @Composable (RowScope.() -> Unit),
|
||||
backButton: Boolean
|
||||
backButton: Boolean,
|
||||
transparent: Boolean,
|
||||
) {
|
||||
return TopAppBar(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
actions = actions,
|
||||
colors = if (transparent) {
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent,
|
||||
)
|
||||
} else {
|
||||
TopAppBarDefaults.topAppBarColors()
|
||||
},
|
||||
navigationIcon = {
|
||||
if (backButton) {
|
||||
ApplicationBackButton()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user