mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
8 Commits
f28f1f6e05
...
6b71e58584
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b71e58584 | ||
|
|
cf9476ab08 | ||
|
|
5b442a93a9 | ||
|
|
d5eaf101eb | ||
|
|
fda5bb8a60 | ||
|
|
1ccd70a18c | ||
|
|
93dfea9a30 | ||
|
|
b0327fd224 |
@ -108,6 +108,7 @@ kotlin {
|
||||
}
|
||||
|
||||
jvm()
|
||||
jvmToolchain(21)
|
||||
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
|
||||
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: 19 KiB |
@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
|
||||
<g id="Alapkör">
|
||||
<circle id="XMLID_23_" fill="#CD201F" cx="50" cy="50" r="50"/>
|
||||
</g>
|
||||
<g id="Elemek">
|
||||
<path id="XMLID_19_" fill="#FFFFFF" d="M47,28.2c-9-5.3-15.3-9-15.3-9v61.7c0,0,30.4-18,52.3-30.9C72.1,43,57.7,34.5,47,28.2z"/>
|
||||
</g>
|
||||
<g id="Fedő">
|
||||
<path id="XMLID_5_" fill="#CD201F" d="M48.4,40.1c-4.1-2.4-7-4.1-7-4.1V64c0,0,13.9-8.2,23.8-14C59.8,46.8,53.3,42.9,48.4,40.1z"/>
|
||||
<rect id="XMLID_4_" x="41.4" y="55.6" fill="#CD201F" width="6.2" height="21"/>
|
||||
</g>
|
||||
<g id="Vonalak">
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 787 B |
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>
|
||||
@ -21,6 +21,28 @@
|
||||
<string name="queue_filter_queue">Filter queue...</string>
|
||||
<string name="queue_no_queue_entries">No queue entries</string>
|
||||
|
||||
<string name="search_placeholder">Search songs, artists, albums...</string>
|
||||
<string name="search_clear_history">Clear all history</string>
|
||||
<string name="search_filter_placeholder">Search...</string>
|
||||
|
||||
<!-- Library toolbar -->
|
||||
<string name="library_screen_title">Your Library</string>
|
||||
<string name="library_tab_playlists">Playlists</string>
|
||||
<string name="library_tab_albums">Albums</string>
|
||||
<string name="library_tab_artists">Artists</string>
|
||||
<string name="library_tab_local_tracks">Local Tracks</string>
|
||||
<string name="library_tab_downloads">Downloads</string>
|
||||
<string name="library_filter_playlists">Search saved playlists…</string>
|
||||
<string name="library_filter_albums">Search saved albums…</string>
|
||||
<string name="library_filter_artists">Search saved artists…</string>
|
||||
<string name="library_filter_local_tracks">Search folders or tracks…</string>
|
||||
<string name="library_action_search">Filter library</string>
|
||||
<string name="library_action_close_search">Close filter</string>
|
||||
<string name="library_action_create_playlist">Create playlist</string>
|
||||
<string name="library_action_refresh_local_tracks">Refresh local tracks</string>
|
||||
|
||||
<string name="accessibility_selected">Selected</string>
|
||||
|
||||
<string name="settings_screen_title">Settings</string>
|
||||
|
||||
<string name="settings_section_plugins">Plugins</string>
|
||||
@ -228,11 +250,45 @@
|
||||
<string name="settings_jam_section_identity">Identity</string>
|
||||
<string name="settings_jam_section_diagnostics">Diagnostics</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
<string name="jam_screen_title">Group Jam</string>
|
||||
<string name="jam_setup_create_tab">Create</string>
|
||||
<string name="jam_setup_join_tab">Join</string>
|
||||
<string name="jam_setup_create_hint">Create a room, then share its code with your friends.</string>
|
||||
<string name="jam_setup_create_button">Create room</string>
|
||||
<string name="jam_setup_join_button">Join room</string>
|
||||
<string name="jam_setup_working">Connecting…</string>
|
||||
<string name="jam_setup_code_label">Room code</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker: %1$s</string>
|
||||
<string name="jam_connection_missing">Set up a broker in Settings to host or join a room.</string>
|
||||
<string name="jam_connection_settings">Broker settings</string>
|
||||
<string name="jam_session_connected">Connected</string>
|
||||
<string name="jam_session_connecting">Reconnecting…</string>
|
||||
<string name="jam_invite_copy">Copy code</string>
|
||||
<string name="jam_invite_copied">Copied!</string>
|
||||
<string name="jam_invite_share">Share</string>
|
||||
<string name="jam_participants_heading">Participants (%1$d)</string>
|
||||
<string name="jam_participants_host">Host</string>
|
||||
<string name="jam_participants_guest">Listener</string>
|
||||
<string name="jam_participants_waiting">Waiting for listeners…</string>
|
||||
<string name="jam_participants_remove">Remove</string>
|
||||
<string name="jam_participants_ban">Ban</string>
|
||||
<string name="jam_participants_manage">Manage</string>
|
||||
<string name="jam_session_leave">Leave room</string>
|
||||
<string name="jam_error_title">Couldn’t connect</string>
|
||||
<string name="jam_error_dismiss">Dismiss</string>
|
||||
<string name="jam_error_create">Failed to create jam session</string>
|
||||
<string name="jam_error_join">Failed to join jam session</string>
|
||||
<string name="jam_error_invalid_code">Enter a six-character room code using letters and digits.</string>
|
||||
<string name="jam_share_message">Join my Spotube Jam with code: %1$s</string>
|
||||
|
||||
<string name="player_go_to_album">Go to album</string>
|
||||
<string name="player_sleep_timer">Sleep timer</string>
|
||||
<string name="player_track_details">Track details</string>
|
||||
<string name="player_play_content_description">Play</string>
|
||||
<string name="player_pause_content_description">Pause</string>
|
||||
<string name="player_nothing_playing">Nothing playing</string>
|
||||
<string name="player_add_to_queue_content_description">Add to queue</string>
|
||||
|
||||
<string name="update_action_ignore">Ignore</string>
|
||||
|
||||
@ -19,23 +19,17 @@ package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@ -45,19 +39,13 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.graphics.vector.ImageVector
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composeunstyled.DropdownMenuPanel
|
||||
import com.composeunstyled.SheetDetent
|
||||
import com.composeunstyled.Text
|
||||
import com.composeunstyled.UnstyledDropdownMenu
|
||||
import com.composeunstyled.UnstyledDropdownMenuItem
|
||||
import com.composeunstyled.rememberModalBottomSheetState
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.Check
|
||||
@ -67,16 +55,19 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.spotube.millennium.composables.MillenniumDropdownMenu
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumListTile
|
||||
import org.spotube.millennium.composables.MillenniumModalBottomSheet
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import org.spotube.millennium.composables.MillenniumText
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.millennium.theme.gelBrush
|
||||
import org.spotube.millennium.theme.millenniumPanelBrush
|
||||
import org.spotube.millennium.theme.millenniumSurface
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.accessibility_selected
|
||||
import spotube.composeapp.generated.resources.search_filter_placeholder
|
||||
|
||||
data class AdaptiveMenuItem(
|
||||
val icon: ImageVector? = null,
|
||||
@ -108,118 +99,17 @@ fun AdaptiveDropdownBottomSheet(
|
||||
|
||||
Box(modifier = modifier) {
|
||||
if (isLargeScreen) {
|
||||
UnstyledDropdownMenu(
|
||||
MillenniumDropdownMenu(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
panel = {
|
||||
DropdownMenuPanel(
|
||||
Modifier
|
||||
.width(menuMinWidth)
|
||||
.millenniumSurface(
|
||||
background = millenniumPanelBrush(MillenniumTheme.colors),
|
||||
shape = AppTheme.shapes.medium,
|
||||
borderColor = AppTheme.colors.outlineVariant,
|
||||
borderWidth = 1.dp)
|
||||
.padding(4.dp)) {
|
||||
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
||||
header()
|
||||
}
|
||||
|
||||
val dropdownQueryState = rememberTextFieldState()
|
||||
val query = dropdownQueryState.text.toString()
|
||||
if (filter != null) {
|
||||
MillenniumTextField(
|
||||
state = dropdownQueryState,
|
||||
label = "",
|
||||
placeholder = "Search...",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp))
|
||||
}
|
||||
|
||||
items.forEach { item ->
|
||||
if (query.isNotBlank() && filter != null && !filter(item, query)) {
|
||||
return@forEach
|
||||
}
|
||||
if (item.dividerBefore) {
|
||||
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
val itemInteraction = remember { MutableInteractionSource() }
|
||||
val itemHovered by itemInteraction.collectIsHoveredAsState()
|
||||
val itemPressed by itemInteraction.collectIsPressedAsState()
|
||||
val itemEnabled = item.enabled
|
||||
val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected)
|
||||
val itemForeground = when {
|
||||
!itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
itemHighlighted -> MillenniumTheme.colors.onAccent
|
||||
else -> AppTheme.colors.onSurface
|
||||
}
|
||||
UnstyledDropdownMenuItem(
|
||||
onClick = { item.onClick() },
|
||||
enabled = itemEnabled,
|
||||
indication = null,
|
||||
interactionSource = itemInteraction) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (itemHighlighted) {
|
||||
Modifier.millenniumSurface(
|
||||
background = gelBrush(
|
||||
MillenniumTheme.colors.accentTop,
|
||||
MillenniumTheme.colors.accentBottom),
|
||||
shape = RoundedCornerShape(8.dp))
|
||||
} else {
|
||||
Modifier
|
||||
})
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
val itemIcon = item.icon
|
||||
when {
|
||||
item.leadingContent != null -> item.leadingContent.invoke()
|
||||
itemIcon != null -> MillenniumIcon(
|
||||
imageVector = itemIcon,
|
||||
contentDescription = null,
|
||||
tint = itemForeground,
|
||||
modifier = Modifier.size(20.dp))
|
||||
|
||||
item.selected -> MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = itemForeground,
|
||||
modifier = Modifier.size(20.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = itemForeground)
|
||||
item.subtitle?.let { subtitle ->
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = if (itemHighlighted) {
|
||||
itemForeground.copy(alpha = 0.85f)
|
||||
} else {
|
||||
AppTheme.colors.onSurfaceVariant
|
||||
})
|
||||
}
|
||||
}
|
||||
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = itemForeground,
|
||||
modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
anchor = { trigger { expanded = true } },
|
||||
)
|
||||
items = items.map { it.label },
|
||||
onItemClick = { label ->
|
||||
items.firstOrNull { it.label == label && it.enabled }?.onClick?.invoke()
|
||||
expanded = false
|
||||
},
|
||||
minimumWidth = menuMinWidth,
|
||||
anchor = { trigger { expanded = true } }
|
||||
)
|
||||
} else {
|
||||
trigger { expanded = true }
|
||||
if (expanded) {
|
||||
@ -267,7 +157,7 @@ private fun AdaptiveBottomSheetContent(
|
||||
MillenniumTextField(
|
||||
state = queryState,
|
||||
label = "",
|
||||
placeholder = "Search...",
|
||||
placeholder = stringResource(Res.string.search_filter_placeholder),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp))
|
||||
@ -285,72 +175,69 @@ private fun AdaptiveBottomSheetContent(
|
||||
if (item.dividerBefore) {
|
||||
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(AppTheme.shapes.medium)
|
||||
.clickable(
|
||||
enabled = item.enabled,
|
||||
onClick = {
|
||||
item.onClick()
|
||||
onDismiss()
|
||||
})
|
||||
.background(
|
||||
color = if (item.selected) {
|
||||
AppTheme.colors.secondary.copy(alpha = 0.12f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = AppTheme.shapes.medium)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
when {
|
||||
item.leadingContent != null -> item.leadingContent.invoke()
|
||||
item.icon != null -> MillenniumIcon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (item.enabled) {
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
|
||||
item.selected -> MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
|
||||
hasSelection -> Spacer(modifier = Modifier.size(22.dp))
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
MillenniumListTile(
|
||||
title = {
|
||||
MillenniumText(
|
||||
item.label,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
color = if (item.enabled) {
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
item.subtitle?.let { subtitle ->
|
||||
Text(
|
||||
text = subtitle,
|
||||
}
|
||||
)
|
||||
},
|
||||
subtitle = item.subtitle?.let { subtitle ->
|
||||
{
|
||||
MillenniumText(
|
||||
subtitle,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
},
|
||||
leading = when {
|
||||
item.leadingContent != null -> item.leadingContent
|
||||
item.icon != null -> {
|
||||
{
|
||||
MillenniumIcon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
item.selected -> {
|
||||
{
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = stringResource(Res.string.accessibility_selected),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
hasSelection -> {{ Spacer(modifier = Modifier.size(22.dp)) }}
|
||||
else -> null
|
||||
},
|
||||
trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
||||
{
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = stringResource(Res.string.accessibility_selected),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
selected = item.selected,
|
||||
enabled = item.enabled,
|
||||
onClick = {
|
||||
item.onClick()
|
||||
onDismiss()
|
||||
},
|
||||
minimumHeight = 56.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,15 +24,8 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -44,16 +37,21 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.composeunstyled.Text
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
|
||||
import org.jetbrains.compose.resources.DrawableResource
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
|
||||
@Composable
|
||||
fun CollectionView(
|
||||
|
||||
@ -36,7 +36,12 @@ import androidx.compose.ui.unit.sp
|
||||
import dev.krtirtho.spotube.modules.settings.Theme
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
import org.spotube.millennium.composables.MillenniumTooltipHost
|
||||
import org.spotube.millennium.composables.MillenniumTopAppBarTheme
|
||||
import org.spotube.millennium.composables.MillenniumTopAppBarThemeProperties
|
||||
import org.spotube.millennium.composables.defaultMillenniumTopAppBarTheme
|
||||
import org.spotube.millennium.theme.MillenniumColors
|
||||
import org.spotube.millennium.theme.MillenniumDarkThemeComponents
|
||||
import org.spotube.millennium.theme.MillenniumLightThemeComponents
|
||||
import org.spotube.millennium.theme.MillenniumPalette
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.millennium.theme.MillenniumThemeConfig
|
||||
@ -201,7 +206,29 @@ fun SpotubeTheme(
|
||||
}
|
||||
val palette: MillenniumPalette = settings.palette
|
||||
|
||||
MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) {
|
||||
val darkTheme = MillenniumDarkThemeComponents
|
||||
val lightTheme = MillenniumLightThemeComponents
|
||||
|
||||
MillenniumTheme(
|
||||
dark = isDarkTheme,
|
||||
config = MillenniumThemeConfig(
|
||||
palette = palette,
|
||||
dark = darkTheme.copy(
|
||||
topAppBarTheme = MillenniumTopAppBarTheme(
|
||||
properties = darkTheme.topAppBarTheme.properties.copy(
|
||||
separatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
),
|
||||
light = lightTheme.copy(
|
||||
topAppBarTheme = MillenniumTopAppBarTheme(
|
||||
properties = lightTheme.topAppBarTheme.properties.copy(
|
||||
separatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
val colors = appColorsFrom(MillenniumTheme.colors)
|
||||
MillenniumTooltipHost {
|
||||
CompositionLocalProvider(
|
||||
|
||||
@ -32,7 +32,6 @@ 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.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
@ -156,7 +155,7 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
ApplicationMainBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@ -227,4 +226,4 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,311 +17,468 @@
|
||||
|
||||
package dev.krtirtho.spotube.modules.jam
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButton
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButtonRow
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomCode
|
||||
import com.composeunstyled.Text
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButton
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButtonRow
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import org.spotube.millennium.composables.MillenniumTooltip
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.millennium.theme.millenniumBevelBrush
|
||||
import org.spotube.millennium.theme.millenniumPanelBrush
|
||||
import org.spotube.millennium.theme.millenniumSurface
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
/**
|
||||
* Group Jam session screen. Playback controls and the queue live in the app's
|
||||
* regular player / queue sheet (the shared jam queue is the local queue), so
|
||||
* this screen only covers participation and session management.
|
||||
*/
|
||||
@Composable
|
||||
fun JamScreen(
|
||||
navigationCommands: NavigationCommands) {
|
||||
fun JamScreen(navigationCommands: NavigationCommands) {
|
||||
val viewModel = koinViewModel<JamViewModel>()
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MillenniumTheme.colors.background)) {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = { Text("Group Jam") })
|
||||
Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) {
|
||||
ApplicationMainBar(title = { Text(stringResource(Res.string.jam_screen_title)) })
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(16.dp)
|
||||
.padding(bottom = shellBottomInset)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
state.error?.let { error ->
|
||||
ErrorBanner(text = error, onDismiss = viewModel::clearError)
|
||||
}
|
||||
|
||||
when {
|
||||
!state.isActive -> CreateOrJoinView(
|
||||
state = state,
|
||||
onCreate = viewModel::createSession,
|
||||
onJoin = viewModel::joinWithCode)
|
||||
|
||||
else -> SessionView(
|
||||
state = state,
|
||||
onShareCode = viewModel::shareRoomCode,
|
||||
onLeave = viewModel::leave,
|
||||
onKick = viewModel::kickParticipant,
|
||||
onBan = viewModel::banParticipant)
|
||||
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 20.dp, bottom = shellBottomInset + 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
Modifier.widthIn(max = 520.dp).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
state.error?.let { error ->
|
||||
JamPanel {
|
||||
Text(
|
||||
error,
|
||||
color = AppTheme.colors.error,
|
||||
style = AppTheme.typography.bodySmall
|
||||
)
|
||||
MillenniumButton(
|
||||
onClick = viewModel::clearError,
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text(stringResource(Res.string.jam_error_dismiss))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.isActive) {
|
||||
SessionPanel(
|
||||
state = state,
|
||||
onCopy = {
|
||||
clipboard.setText(AnnotatedString(state.roomCode.orEmpty()))
|
||||
viewModel.onCodeCopied()
|
||||
},
|
||||
onShare = viewModel::shareRoomCode,
|
||||
onLeave = viewModel::leave,
|
||||
onKick = viewModel::kickParticipant,
|
||||
onBan = viewModel::banParticipant,
|
||||
onMenuChange = viewModel::showParticipantMenu
|
||||
)
|
||||
} else {
|
||||
SetupPanel(
|
||||
state,
|
||||
viewModel::selectSetupMode,
|
||||
viewModel::createSession,
|
||||
viewModel::joinSession
|
||||
)
|
||||
}
|
||||
BrokerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBanner(text: String, onDismiss: () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = text,
|
||||
color = AppTheme.colors.error,
|
||||
style = AppTheme.typography.bodyMedium)
|
||||
MillenniumButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
private fun JamPanel(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().millenniumSurface(
|
||||
background = millenniumPanelBrush(MillenniumTheme.colors),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
borderColor = MillenniumTheme.colors.border,
|
||||
borderWidth = 1.dp,
|
||||
innerGlowColor = MillenniumTheme.colors.highlight.copy(alpha = 0.25f)
|
||||
).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateOrJoinView(
|
||||
internal fun SetupPanel(
|
||||
state: JamUiState,
|
||||
onMode: (JamSetupMode) -> Unit,
|
||||
onCreate: () -> Unit,
|
||||
onJoin: (String) -> Unit) {
|
||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||
val codeState = rememberTextFieldState()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.",
|
||||
style = AppTheme.typography.titleMedium)
|
||||
|
||||
if (!state.brokerConfigured) {
|
||||
onJoin: () -> Unit
|
||||
) {
|
||||
JamPanel {
|
||||
MillenniumSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||
MillenniumSegmentedButton(
|
||||
selected = state.setupMode == JamSetupMode.Create,
|
||||
onClick = { onMode(JamSetupMode.Create) },
|
||||
shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)
|
||||
) { Text(stringResource(Res.string.jam_setup_create_tab)) }
|
||||
MillenniumSegmentedButton(
|
||||
selected = state.setupMode == JamSetupMode.Join,
|
||||
onClick = { onMode(JamSetupMode.Join) },
|
||||
shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)
|
||||
) { Text(stringResource(Res.string.jam_setup_join_tab)) }
|
||||
}
|
||||
if (state.setupMode == JamSetupMode.Create) {
|
||||
SharedRoomIllustration(Modifier.fillMaxWidth().height(100.dp))
|
||||
Text(
|
||||
text = "No jam broker configured — set one up in Settings to host or join a session.",
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.error)
|
||||
} else {
|
||||
Text(
|
||||
text = "Broker: ${state.brokerHost}",
|
||||
stringResource(Res.string.jam_setup_create_hint),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
|
||||
MillenniumSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
MillenniumSegmentedButton(
|
||||
selected = tab == 0,
|
||||
onClick = { tab = 0 },
|
||||
shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)) { Text("Create") }
|
||||
MillenniumSegmentedButton(
|
||||
selected = tab == 1,
|
||||
onClick = { tab = 1 },
|
||||
shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)) { Text("Join") }
|
||||
}
|
||||
|
||||
if (tab == 0) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "Start a session as the host. You'll get a 6-character room code to " +
|
||||
"share with friends; you control the queue.",
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
MillenniumButton(
|
||||
onClick = onCreate,
|
||||
enabled = state.brokerConfigured) {
|
||||
Text("Create Session")
|
||||
}
|
||||
}
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "Enter the 6-character room code the host shared with you.",
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
MillenniumTextField(
|
||||
state = codeState,
|
||||
label = "Room code",
|
||||
placeholder = "ABC123",
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
val normalizedCode = JamRoomCode.normalize(codeState.text.toString())
|
||||
MillenniumButton(
|
||||
onClick = { onJoin(normalizedCode) },
|
||||
enabled = state.brokerConfigured && JamRoomCode.isValid(normalizedCode)) {
|
||||
Text("Join Session")
|
||||
}
|
||||
}
|
||||
MillenniumTextField(
|
||||
state = state.codeInput,
|
||||
label = stringResource(Res.string.jam_setup_code_label),
|
||||
placeholder = stringResource(Res.string.jam_setup_code_placeholder),
|
||||
enabled = !state.isBusy,
|
||||
textStyle = AppTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
letterSpacing = 4.sp
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
MillenniumButton(
|
||||
onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin,
|
||||
enabled = if (state.setupMode == JamSetupMode.Create) state.canCreate else state.canJoin,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
when {
|
||||
state.isBusy -> Res.string.jam_setup_working
|
||||
state.setupMode == JamSetupMode.Create -> Res.string.jam_setup_create_button
|
||||
else -> Res.string.jam_setup_join_button
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A small media-player diagram: one source feeding two listeners. No stock artwork. */
|
||||
@Composable
|
||||
private fun SessionView(
|
||||
private fun SharedRoomIllustration(modifier: Modifier) {
|
||||
val colors = MillenniumTheme.colors
|
||||
Canvas(modifier) {
|
||||
val scale = minOf(size.width / 260f, size.height / 100f)
|
||||
val origin = Offset((size.width - 260f * scale) / 2f, (size.height - 100f * scale) / 2f)
|
||||
fun point(x: Float, y: Float) = origin + Offset(x * scale, y * scale)
|
||||
val stroke = 1.2f * scale
|
||||
val metal = Brush.verticalGradient(
|
||||
listOf(colors.raised, colors.surface),
|
||||
startY = origin.y,
|
||||
endY = origin.y + 100f * scale
|
||||
)
|
||||
drawLine(colors.border, point(45f, 50f), point(215f, 50f), 2f * scale)
|
||||
drawLine(colors.accent.copy(alpha = 0.7f), point(45f, 49f), point(215f, 49f), stroke)
|
||||
// Two miniature speakers, with an accent power LED and concentric drivers.
|
||||
for (x in listOf(15f, 207f)) {
|
||||
drawRoundRect(
|
||||
metal,
|
||||
point(x, 18f),
|
||||
Size(38f * scale, 64f * scale),
|
||||
CornerRadius(5f * scale)
|
||||
)
|
||||
drawRoundRect(
|
||||
colors.border,
|
||||
point(x, 18f),
|
||||
Size(38f * scale, 64f * scale),
|
||||
CornerRadius(5f * scale),
|
||||
style = Stroke(stroke)
|
||||
)
|
||||
drawCircle(colors.background, 10f * scale, point(x + 19f, 57f))
|
||||
drawCircle(colors.border, 10f * scale, point(x + 19f, 57f), style = Stroke(stroke))
|
||||
drawCircle(colors.raised, 5f * scale, point(x + 19f, 57f))
|
||||
drawCircle(colors.background, 4f * scale, point(x + 19f, 34f))
|
||||
drawCircle(colors.accent, 1.5f * scale, point(x + 19f, 74f))
|
||||
}
|
||||
// Brushed-metal source unit with a backlit equalizer display.
|
||||
drawRoundRect(
|
||||
metal,
|
||||
point(82f, 10f),
|
||||
Size(96f * scale, 80f * scale),
|
||||
CornerRadius(8f * scale)
|
||||
)
|
||||
drawRoundRect(
|
||||
colors.border,
|
||||
point(82f, 10f),
|
||||
Size(96f * scale, 80f * scale),
|
||||
CornerRadius(8f * scale),
|
||||
style = Stroke(stroke)
|
||||
)
|
||||
drawRoundRect(
|
||||
colors.highlight.copy(alpha = 0.25f),
|
||||
point(86f, 13f),
|
||||
Size(88f * scale, 1f * scale),
|
||||
CornerRadius(scale)
|
||||
)
|
||||
drawRoundRect(
|
||||
colors.background,
|
||||
point(92f, 22f),
|
||||
Size(76f * scale, 30f * scale),
|
||||
CornerRadius(3f * scale)
|
||||
)
|
||||
val bars = listOf(8f, 16f, 12f, 22f, 18f, 10f, 15f, 7f)
|
||||
bars.forEachIndexed { index, height ->
|
||||
drawRoundRect(
|
||||
colors.accent.copy(alpha = 0.85f),
|
||||
point(99f + index * 8f, 47f - height),
|
||||
Size(4f * scale, height * scale),
|
||||
CornerRadius(scale)
|
||||
)
|
||||
}
|
||||
drawCircle(colors.background, 11f * scale, point(130f, 70f))
|
||||
drawCircle(colors.border, 11f * scale, point(130f, 70f), style = Stroke(stroke))
|
||||
drawPath(Path().apply {
|
||||
moveTo(point(127f, 65f).x, point(127f, 65f).y)
|
||||
lineTo(point(135f, 70f).x, point(135f, 70f).y)
|
||||
lineTo(point(127f, 75f).x, point(127f, 75f).y)
|
||||
close()
|
||||
}, colors.text)
|
||||
for (x in listOf(101f, 159f)) drawRoundRect(
|
||||
colors.border,
|
||||
point(x - 4f, 68f),
|
||||
Size(8f * scale, 4f * scale),
|
||||
CornerRadius(scale)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SessionPanel(
|
||||
state: JamUiState,
|
||||
onShareCode: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onLeave: () -> Unit,
|
||||
onKick: (String) -> Unit,
|
||||
onBan: (String) -> Unit) {
|
||||
val isHost = state.role == JamRole.Host
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
if (!state.isConnected) {
|
||||
onBan: (String) -> Unit,
|
||||
onMenuChange: (String?) -> Unit
|
||||
) {
|
||||
JamPanel {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(7.dp).background(
|
||||
if (state.isConnected) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant,
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "Connecting to the session…",
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
stringResource(if (state.isConnected) Res.string.jam_session_connected else Res.string.jam_session_connecting),
|
||||
style = AppTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
ParticipantsSection(
|
||||
participants = state.participants,
|
||||
isHost = isHost,
|
||||
onKick = onKick,
|
||||
onBan = onBan)
|
||||
|
||||
if (isHost) {
|
||||
MillenniumSeparator()
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth().millenniumSurface(
|
||||
background = SolidColor(MillenniumTheme.colors.background),
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
borderColor = MillenniumTheme.colors.border,
|
||||
borderWidth = 1.dp
|
||||
).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Invite someone",
|
||||
style = AppTheme.typography.titleSmall)
|
||||
RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode)
|
||||
}
|
||||
|
||||
MillenniumSeparator()
|
||||
|
||||
Text(
|
||||
text = "The queue and playback controls are in the player at the bottom of the app — " +
|
||||
"the jam queue is shared with every participant.",
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
|
||||
LeaveButton(onLeave)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParticipantsSection(
|
||||
participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>,
|
||||
isHost: Boolean,
|
||||
onKick: (String) -> Unit,
|
||||
onBan: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Participants (${participants.size})",
|
||||
style = AppTheme.typography.titleSmall)
|
||||
participants.forEach { participant ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
stringResource(Res.string.jam_setup_code_label),
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = participant.displayName,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f))
|
||||
if (participant.isHost) {
|
||||
Text(
|
||||
text = "Host",
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.primary)
|
||||
}
|
||||
if (isHost && !participant.isHost) {
|
||||
MillenniumButton(
|
||||
onClick = { onKick(participant.id) },
|
||||
modifier = Modifier.height(32.dp),
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Kick", style = AppTheme.typography.labelSmall)
|
||||
}
|
||||
MillenniumButton(
|
||||
onClick = { onBan(participant.id) },
|
||||
modifier = Modifier.height(32.dp),
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Ban", style = AppTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
state.roomCode.orEmpty(),
|
||||
style = AppTheme.typography.headlineMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 5.sp,
|
||||
color = AppTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomCodeBox(
|
||||
code: String,
|
||||
onShare: () -> Unit) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val viewModel: JamViewModel = koinViewModel()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = code,
|
||||
style = AppTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = AppTheme.colors.primary,
|
||||
modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MillenniumButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(code)) }) {
|
||||
Text("Copy")
|
||||
}
|
||||
if (viewModel.supportsNativeShare) {
|
||||
if (state.isHost) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MillenniumButton(
|
||||
onClick = onCopy,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text(stringResource(if (state.codeCopied) Res.string.jam_invite_copied else Res.string.jam_invite_copy))
|
||||
}
|
||||
if (state.supportsNativeShare) MillenniumButton(
|
||||
onClick = onShare,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Share")
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text(stringResource(Res.string.jam_invite_share))
|
||||
}
|
||||
}
|
||||
}
|
||||
MillenniumSeparator()
|
||||
Text(
|
||||
stringResource(Res.string.jam_participants_heading, state.participants.size),
|
||||
style = AppTheme.typography.titleSmall
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxWidth().millenniumSurface(
|
||||
background = SolidColor(MillenniumTheme.colors.background),
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
borderColor = MillenniumTheme.colors.border,
|
||||
borderWidth = 1.dp
|
||||
).padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (state.participants.isEmpty()) Text(
|
||||
stringResource(Res.string.jam_participants_waiting),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
state.participants.forEach { participant ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(32.dp).millenniumSurface(
|
||||
background = millenniumBevelBrush(MillenniumTheme.colors),
|
||||
shape = CircleShape,
|
||||
borderColor = MillenniumTheme.colors.border,
|
||||
borderWidth = 1.dp
|
||||
), contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(participant.initial, style = AppTheme.typography.labelMedium)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
participant.displayName,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
stringResource(if (participant.isHost) Res.string.jam_participants_host else Res.string.jam_participants_guest),
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (participant.canModerate) {
|
||||
val removeLabel = stringResource(Res.string.jam_participants_remove)
|
||||
val banLabel = stringResource(Res.string.jam_participants_ban)
|
||||
// Keep destructive moderation out of the participant's primary row.
|
||||
org.spotube.millennium.composables.MillenniumDropdownMenu(
|
||||
expanded = participant.menuExpanded,
|
||||
onExpandedChange = { expanded -> onMenuChange(if (expanded) participant.id else null) },
|
||||
items = listOf(removeLabel, banLabel),
|
||||
onItemClick = { label ->
|
||||
if (label == removeLabel) onKick(participant.id) else onBan(
|
||||
participant.id
|
||||
)
|
||||
},
|
||||
anchor = {
|
||||
MillenniumButton(
|
||||
onClick = { onMenuChange(participant.id) },
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) { Text(stringResource(Res.string.jam_participants_manage)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MillenniumButton(
|
||||
onClick = onLeave,
|
||||
style = MillenniumButtonStyle.Secondary,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text(stringResource(Res.string.jam_session_leave))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveButton(onLeave: () -> Unit) {
|
||||
MillenniumButton(
|
||||
onClick = onLeave,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Leave Session")
|
||||
internal fun BrokerStatus(state: JamUiState, onConfigure: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
if (state.brokerConfigured) stringResource(
|
||||
Res.string.jam_connection_host,
|
||||
state.brokerHost
|
||||
) else stringResource(Res.string.jam_connection_missing),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
MillenniumTooltip(stringResource(Res.string.jam_connection_settings)) {
|
||||
MillenniumIconButton(onClick = onConfigure, style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
Iconsax.IconsaxSetting2,
|
||||
contentDescription = stringResource(Res.string.jam_connection_settings)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,8 @@ package dev.krtirtho.spotube.modules.jam
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import dev.krtirtho.spotube.PlatformType
|
||||
import dev.krtirtho.spotube.core.jam.JamParticipant
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
@ -30,27 +32,57 @@ import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Job
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
enum class JamSetupMode { Create, Join }
|
||||
|
||||
data class JamParticipantUi(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val initial: String,
|
||||
val isHost: Boolean,
|
||||
val canModerate: Boolean,
|
||||
val menuExpanded: Boolean = false
|
||||
)
|
||||
|
||||
data class JamUiState(
|
||||
val isActive: Boolean = false,
|
||||
val isConnected: Boolean = false,
|
||||
val role: JamRole? = null,
|
||||
val participants: List<JamParticipant> = emptyList(),
|
||||
val participants: List<JamParticipantUi> = emptyList(),
|
||||
val roomCode: String? = null,
|
||||
val brokerHost: String = "",
|
||||
val brokerConfigured: Boolean = false,
|
||||
val error: String? = null)
|
||||
val error: String? = null,
|
||||
val setupMode: JamSetupMode = JamSetupMode.Create,
|
||||
val codeInput: TextFieldState = TextFieldState(),
|
||||
val canCreate: Boolean = false,
|
||||
val canJoin: Boolean = false,
|
||||
val isBusy: Boolean = false,
|
||||
val isHost: Boolean = false,
|
||||
val supportsNativeShare: Boolean = false,
|
||||
val codeCopied: Boolean = false
|
||||
)
|
||||
|
||||
class JamViewModel(
|
||||
private val jamRoomService: JamRoomService,
|
||||
private val shareService: ShareService,
|
||||
private val settingsProvider: SettingsProvider) : ViewModel() {
|
||||
private val settingsProvider: SettingsProvider
|
||||
) : ViewModel() {
|
||||
|
||||
private val _localError = MutableStateFlow<String?>(null)
|
||||
private val setupMode = MutableStateFlow(JamSetupMode.Create)
|
||||
private val isBusy = MutableStateFlow(false)
|
||||
private val codeCopied = MutableStateFlow(false)
|
||||
private val codeInput = TextFieldState()
|
||||
private var copyFeedbackJob: Job? = null
|
||||
private val participantMenu = MutableStateFlow<String?>(null)
|
||||
|
||||
val supportsNativeShare: Boolean =
|
||||
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
|
||||
@ -62,9 +94,16 @@ class JamViewModel(
|
||||
jamRoomService.roomCode,
|
||||
jamRoomService.connectionError,
|
||||
settingsProvider.settingsState,
|
||||
_localError) { values ->
|
||||
_localError,
|
||||
setupMode,
|
||||
isBusy,
|
||||
codeCopied,
|
||||
snapshotFlow { codeInput.text.toString() },
|
||||
participantMenu
|
||||
) { values ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val role = values[0] as JamRole?
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val participants = values[1] as List<JamParticipant>
|
||||
val isConnected = values[2] as Boolean
|
||||
@ -72,48 +111,108 @@ class JamViewModel(
|
||||
val connectionError = values[4] as String?
|
||||
val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
val localError = values[6] as String?
|
||||
val busy = values[8] as Boolean
|
||||
val brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank()
|
||||
|
||||
JamUiState(
|
||||
isActive = role != null,
|
||||
isConnected = isConnected,
|
||||
role = role,
|
||||
participants = participants,
|
||||
participants = participants.sortedByDescending { it.isHost }.map { participant ->
|
||||
JamParticipantUi(
|
||||
id = participant.id,
|
||||
displayName = participant.displayName,
|
||||
initial = participant.displayName.firstOrNull()?.uppercase() ?: "?",
|
||||
isHost = participant.isHost,
|
||||
canModerate = role == JamRole.Host && !participant.isHost,
|
||||
menuExpanded = values[11] == participant.id
|
||||
)
|
||||
},
|
||||
roomCode = roomCode,
|
||||
brokerHost = settings?.jamBroker?.host.orEmpty(),
|
||||
brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(),
|
||||
error = localError ?: connectionError)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState())
|
||||
brokerConfigured = brokerConfigured,
|
||||
error = localError ?: connectionError,
|
||||
setupMode = values[7] as JamSetupMode,
|
||||
isBusy = busy,
|
||||
codeCopied = values[9] as Boolean,
|
||||
codeInput = codeInput,
|
||||
canCreate = brokerConfigured && !busy,
|
||||
canJoin = brokerConfigured && !busy &&
|
||||
JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)),
|
||||
isHost = role == JamRole.Host,
|
||||
supportsNativeShare = supportsNativeShare
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
JamUiState(codeInput = codeInput)
|
||||
)
|
||||
|
||||
// ---------- Session lifecycle ----------
|
||||
|
||||
fun createSession() {
|
||||
if (isBusy.value) return
|
||||
isBusy.value = true
|
||||
viewModelScope.launch {
|
||||
jamRoomService.createRoom()
|
||||
.onFailure { e ->
|
||||
_localError.value = e.message ?: "Failed to create jam session"
|
||||
}
|
||||
.onSuccess { _localError.value = null }
|
||||
try {
|
||||
jamRoomService.createRoom()
|
||||
.onFailure { e ->
|
||||
_localError.value = e.message ?: getString(Res.string.jam_error_create)
|
||||
}
|
||||
.onSuccess { _localError.value = null }
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSetupMode(mode: JamSetupMode) {
|
||||
setupMode.value = mode
|
||||
_localError.value = null
|
||||
}
|
||||
|
||||
fun joinSession() = joinWithCode(codeInput.text.toString())
|
||||
|
||||
fun onCodeCopied() {
|
||||
copyFeedbackJob?.cancel()
|
||||
copyFeedbackJob = viewModelScope.launch {
|
||||
codeCopied.value = true
|
||||
delay(2_000)
|
||||
codeCopied.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun joinWithCode(input: String) {
|
||||
if (isBusy.value) return
|
||||
val code = JamRoomCode.normalize(input)
|
||||
if (!JamRoomCode.isValid(code)) {
|
||||
_localError.value = "Room codes are ${JamRoomCode.LENGTH} characters (letters and digits)"
|
||||
viewModelScope.launch {
|
||||
_localError.value = getString(Res.string.jam_error_invalid_code)
|
||||
}
|
||||
return
|
||||
}
|
||||
isBusy.value = true
|
||||
viewModelScope.launch {
|
||||
jamRoomService.joinRoom(code)
|
||||
.onFailure { e ->
|
||||
_localError.value = e.message ?: "Failed to join jam session"
|
||||
}
|
||||
.onSuccess { _localError.value = null }
|
||||
try {
|
||||
jamRoomService.joinRoom(code)
|
||||
.onFailure { e ->
|
||||
_localError.value = e.message ?: getString(Res.string.jam_error_join)
|
||||
}
|
||||
.onSuccess { _localError.value = null }
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shareRoomCode() {
|
||||
val code = uiState.value.roomCode ?: return
|
||||
shareService.share("Join my Spotube Jam with code: $code", "Spotube Group Jam")
|
||||
viewModelScope.launch {
|
||||
shareService.share(
|
||||
getString(Res.string.jam_share_message, code),
|
||||
getString(Res.string.jam_screen_title)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun leave() {
|
||||
@ -130,10 +229,16 @@ class JamViewModel(
|
||||
// ---------- Host moderation ----------
|
||||
|
||||
fun kickParticipant(participantId: String) {
|
||||
participantMenu.value = null
|
||||
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
|
||||
}
|
||||
|
||||
fun banParticipant(participantId: String) {
|
||||
participantMenu.value = null
|
||||
viewModelScope.launch { jamRoomService.banParticipant(participantId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun showParticipantMenu(participantId: String?) {
|
||||
participantMenu.value = participantId
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,102 +17,192 @@
|
||||
|
||||
package dev.krtirtho.spotube.modules.library
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composeunstyled.Text
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import dev.krtirtho.spotube.getPlatform
|
||||
import dev.krtirtho.spotube.isDesktop
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadsScreen
|
||||
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen
|
||||
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen
|
||||
import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen
|
||||
import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen
|
||||
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
libraryState: LibraryState = koinInject(),
|
||||
appShellViewModel: AppShellViewModel = koinViewModel()
|
||||
) {
|
||||
val currentTab by libraryState.currentTab.collectAsState()
|
||||
val searchMode by libraryState.searchMode.collectAsState()
|
||||
val isLargeScreen = appShellViewModel.useSidebar()
|
||||
fun LibraryScreen(libraryState: LibraryState = koinInject()) {
|
||||
val state by libraryState.uiState.collectAsStateWithLifecycle()
|
||||
val expandedDesktop = getPlatform().isDesktop() &&
|
||||
(currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded)
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column {
|
||||
ApplicationMainBar(
|
||||
backButton = false,
|
||||
title = {
|
||||
if (!isLargeScreen) Text("Your Library")
|
||||
},
|
||||
actions = {
|
||||
if (!isLargeScreen) {
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = "Search",
|
||||
onClick = {
|
||||
libraryState.setSearchMode(!searchMode)
|
||||
},
|
||||
style = MillenniumIconButtonStyle.Quiet)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!isLargeScreen)
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(LibraryTab.entries.size) { index ->
|
||||
val tab = LibraryTab.entries[index]
|
||||
MillenniumButton(
|
||||
onClick = { libraryState.onTabSelected(tab) },
|
||||
style = if (currentTab == tab) {
|
||||
MillenniumButtonStyle.Primary
|
||||
} else {
|
||||
MillenniumButtonStyle.Secondary
|
||||
}) {
|
||||
Text(tab.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
ApplicationMainBar(
|
||||
backButton = false,
|
||||
title = { LibraryToolbarTitle(state, expandedDesktop) },
|
||||
actions = {
|
||||
LibraryToolbarActions(
|
||||
state = state,
|
||||
expandedDesktop = expandedDesktop,
|
||||
onOpenSearch = libraryState::openSearch,
|
||||
onCloseSearch = {
|
||||
focusManager.clearFocus()
|
||||
keyboard?.hide()
|
||||
libraryState.closeSearch()
|
||||
},
|
||||
onCreatePlaylist = libraryState::showCreatePlaylist,
|
||||
onRefresh = libraryState::refreshLocalTracks
|
||||
)
|
||||
}
|
||||
)
|
||||
if (!expandedDesktop) {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(LibraryTab.entries) { tab ->
|
||||
MillenniumButton(
|
||||
onClick = { libraryState.onTabSelected(tab) },
|
||||
style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary
|
||||
) { Text(stringResource(tab.titleResource)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
when (state.currentTab) {
|
||||
LibraryTab.Playlists -> LibraryPlaylistsScreen(
|
||||
showCreatePlaylist = state.showCreatePlaylist,
|
||||
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
when (currentTab) {
|
||||
LibraryTab.Playlists -> LibraryPlaylistsScreen(searchMode = isLargeScreen || searchMode)
|
||||
LibraryTab.Albums -> LibraryAlbumsScreen(searchMode = isLargeScreen || searchMode)
|
||||
LibraryTab.Artists -> LibraryArtistsScreen(searchMode = isLargeScreen || searchMode)
|
||||
LibraryTab.LocalTracks -> LibraryLocalTracksScreen(searchMode = isLargeScreen || searchMode)
|
||||
LibraryTab.Albums -> LibraryAlbumsScreen()
|
||||
LibraryTab.Artists -> LibraryArtistsScreen()
|
||||
LibraryTab.LocalTracks -> LibraryLocalTracksScreen()
|
||||
LibraryTab.Downloads -> DownloadsScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop: Boolean) {
|
||||
if (!expandedDesktop && state.searchMode) {
|
||||
LibraryFilterField(
|
||||
state.searchState,
|
||||
state.currentTab.filterPlaceholder,
|
||||
Modifier.fillMaxWidth(),
|
||||
autoFocus = true
|
||||
)
|
||||
} else if (!expandedDesktop) {
|
||||
Text(stringResource(Res.string.library_screen_title))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RowScope.LibraryToolbarActions(
|
||||
state: LibraryToolbarUiState,
|
||||
expandedDesktop: Boolean,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCloseSearch: () -> Unit,
|
||||
onCreatePlaylist: () -> Unit,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
if (!expandedDesktop && state.searchMode) {
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.IconsaxCloseSquare,
|
||||
contentDescription = stringResource(Res.string.library_action_close_search),
|
||||
onClick = onCloseSearch,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
} else {
|
||||
if (state.canFilter) {
|
||||
if (expandedDesktop) {
|
||||
LibraryFilterField(
|
||||
state.searchState,
|
||||
state.currentTab.filterPlaceholder,
|
||||
Modifier.width(300.dp)
|
||||
)
|
||||
} else {
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = stringResource(Res.string.library_action_search),
|
||||
onClick = onOpenSearch,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.canCreatePlaylist) {
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = stringResource(Res.string.library_action_create_playlist),
|
||||
onClick = onCreatePlaylist,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
}
|
||||
if (state.canRefresh) {
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.IconsaxRefreshRight,
|
||||
contentDescription = stringResource(Res.string.library_action_refresh_local_tracks),
|
||||
onClick = onRefresh,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryFilterField(
|
||||
state: TextFieldState,
|
||||
placeholder: StringResource,
|
||||
modifier: Modifier,
|
||||
autoFocus: Boolean = false
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(autoFocus) {
|
||||
if (autoFocus) focusRequester.requestFocus()
|
||||
}
|
||||
MillenniumTextField(
|
||||
state = state,
|
||||
placeholder = stringResource(placeholder),
|
||||
minimumHeight = 36.dp,
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
|
||||
modifier = modifier.padding(vertical = 4.dp).focusRequester(focusRequester)
|
||||
)
|
||||
}
|
||||
|
||||
@ -17,17 +17,90 @@
|
||||
|
||||
package dev.krtirtho.spotube.modules.library
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
class LibraryState {
|
||||
val searchMode = MutableStateFlow(false)
|
||||
data class LibraryToolbarUiState(
|
||||
val currentTab: LibraryTab = LibraryTab.Playlists,
|
||||
val searchMode: Boolean = false,
|
||||
val searchState: TextFieldState = TextFieldState(),
|
||||
val canFilter: Boolean = true,
|
||||
val canCreatePlaylist: Boolean = true,
|
||||
val canRefresh: Boolean = false,
|
||||
val showCreatePlaylist: Boolean = false
|
||||
)
|
||||
|
||||
/** Shared with the sidebar for the app lifetime; each library tab retains its own filter. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LibraryState : ViewModel() {
|
||||
private val searchMode = MutableStateFlow(false)
|
||||
val currentTab = MutableStateFlow(LibraryTab.Playlists)
|
||||
private val searchStates = LibraryTab.entries.associateWith { TextFieldState() }
|
||||
private val createPlaylistVisible = MutableStateFlow(false)
|
||||
private val refreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val localRefreshRequests = refreshRequests.asSharedFlow()
|
||||
|
||||
val uiState = combine(
|
||||
currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } },
|
||||
searchMode,
|
||||
createPlaylistVisible
|
||||
) { (tab, query), searching, creating ->
|
||||
LibraryToolbarUiState(
|
||||
currentTab = tab,
|
||||
searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()),
|
||||
searchState = searchStates.getValue(tab),
|
||||
canFilter = tab != LibraryTab.Downloads,
|
||||
canCreatePlaylist = tab == LibraryTab.Playlists,
|
||||
canRefresh = tab == LibraryTab.LocalTracks,
|
||||
showCreatePlaylist = tab == LibraryTab.Playlists && creating
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope, SharingStarted.Eagerly,
|
||||
LibraryToolbarUiState(searchState = searchStates.getValue(LibraryTab.Playlists))
|
||||
)
|
||||
|
||||
fun queryFlow(tab: LibraryTab) = snapshotFlow { searchStates.getValue(tab).text.toString() }
|
||||
|
||||
fun setQuery(tab: LibraryTab, query: String) {
|
||||
searchStates.getValue(tab).setTextAndPlaceCursorAtEnd(query)
|
||||
}
|
||||
|
||||
fun onTabSelected(tab: LibraryTab) {
|
||||
currentTab.value = tab
|
||||
createPlaylistVisible.value = false
|
||||
if (tab == LibraryTab.Downloads) searchMode.value = false
|
||||
}
|
||||
|
||||
fun setSearchMode(enabled: Boolean) {
|
||||
searchMode.value = enabled
|
||||
fun openSearch() {
|
||||
if (currentTab.value != LibraryTab.Downloads) searchMode.value = true
|
||||
}
|
||||
|
||||
fun closeSearch() {
|
||||
setQuery(currentTab.value, "")
|
||||
searchMode.value = false
|
||||
}
|
||||
|
||||
fun showCreatePlaylist() {
|
||||
if (currentTab.value == LibraryTab.Playlists) createPlaylistVisible.value = true
|
||||
}
|
||||
|
||||
fun dismissCreatePlaylist() {
|
||||
createPlaylistVisible.value = false
|
||||
}
|
||||
|
||||
fun refreshLocalTracks() {
|
||||
refreshRequests.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,11 +24,31 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
|
||||
import dev.krtirtho.spotube.resources.iconsax.User
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
enum class LibraryTab(val title: String, val icon: ImageVector) {
|
||||
Playlists("Playlists", Iconsax.IconsaxMusicDashboard),
|
||||
Albums("Albums", Iconsax.IconsaxCd),
|
||||
Artists("Artists", Iconsax.User),
|
||||
LocalTracks("Local Tracks", Iconsax.IconsaxMusicLibrary),
|
||||
Downloads("Downloads", Iconsax.IconsaxDirectboxReceive),
|
||||
Downloads("Downloads", Iconsax.IconsaxDirectboxReceive);
|
||||
|
||||
val titleResource: StringResource
|
||||
get() = when (this) {
|
||||
Playlists -> Res.string.library_tab_playlists
|
||||
Albums -> Res.string.library_tab_albums
|
||||
Artists -> Res.string.library_tab_artists
|
||||
LocalTracks -> Res.string.library_tab_local_tracks
|
||||
Downloads -> Res.string.library_tab_downloads
|
||||
}
|
||||
|
||||
val filterPlaceholder: StringResource
|
||||
get() = when (this) {
|
||||
Playlists -> Res.string.library_filter_playlists
|
||||
Albums -> Res.string.library_filter_albums
|
||||
Artists -> Res.string.library_filter_artists
|
||||
LocalTracks -> Res.string.library_filter_local_tracks
|
||||
Downloads -> Res.string.search_filter_placeholder
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,17 +18,12 @@
|
||||
package dev.krtirtho.spotube.modules.library.album
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
@ -43,35 +38,24 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.core.ui.component.AlbumCard
|
||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun LibraryAlbumsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
searchMode: Boolean = false,
|
||||
viewModel: LibraryAlbumsViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
|
||||
val searchState = rememberTextFieldState((state as? LibraryAlbumsState.Data)?.query ?: "")
|
||||
LaunchedEffect(searchState) {
|
||||
snapshotFlow { searchState.text.toString() }
|
||||
.drop(1)
|
||||
.collect(viewModel::onQueryChange)
|
||||
}
|
||||
|
||||
LaunchedEffect(gridState) {
|
||||
snapshotFlow { gridState.layoutInfo }
|
||||
@ -93,14 +77,6 @@ fun LibraryAlbumsScreen(
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
AnimatedVisibility(searchMode){
|
||||
MillenniumTextField(
|
||||
state = searchState,
|
||||
label = "Search",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Search saved albums...")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
when (state) {
|
||||
is LibraryAlbumsState.Loading -> {
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
@ -119,7 +95,8 @@ fun LibraryAlbumsScreen(
|
||||
PlayableCard(
|
||||
title = "Album Title",
|
||||
subtitle = "Artist Name",
|
||||
imageURL = "https://placehold.co/600x400")
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -168,7 +145,8 @@ fun LibraryAlbumsScreen(
|
||||
PlayableCard(
|
||||
title = "Album Title",
|
||||
subtitle = "Artist Name",
|
||||
imageURL = "https://placehold.co/600x400")
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@ -46,7 +49,8 @@ sealed interface LibraryAlbumsState {
|
||||
data class Loaded(
|
||||
override val query: String = "",
|
||||
override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null) : Data {
|
||||
override val nextPagination: PaginationStrategy? = null
|
||||
) : Data {
|
||||
override val items: List<MetadataAlbum.Detailed>
|
||||
get() = if (query.isNotBlank()) {
|
||||
allItems.filter { album ->
|
||||
@ -62,13 +66,15 @@ sealed interface LibraryAlbumsState {
|
||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||
query = query,
|
||||
allItems = allItems,
|
||||
nextPagination = nextPagination)
|
||||
nextPagination = nextPagination
|
||||
)
|
||||
}
|
||||
|
||||
data class LoadingMore(
|
||||
override val query: String = "",
|
||||
override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null) : Data {
|
||||
override val nextPagination: PaginationStrategy? = null
|
||||
) : Data {
|
||||
override val items: List<MetadataAlbum.Detailed>
|
||||
get() = if (query.isNotBlank()) {
|
||||
allItems.filter { album ->
|
||||
@ -88,11 +94,21 @@ sealed interface LibraryAlbumsState {
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LibraryAlbumsViewModel(
|
||||
private val repository: LibraryRepository) : ViewModel(), KoinComponent {
|
||||
private val repository: LibraryRepository,
|
||||
private val libraryState: LibraryState
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<LibraryAlbumsViewModel>()
|
||||
|
||||
private val _state = MutableStateFlow<LibraryAlbumsState>(LibraryAlbumsState.Loading)
|
||||
val uiState: StateFlow<LibraryAlbumsState> = _state.asStateFlow()
|
||||
val uiState: StateFlow<LibraryAlbumsState> = combine(
|
||||
_state, libraryState.queryFlow(LibraryTab.Albums)
|
||||
) { state, query ->
|
||||
when (state) {
|
||||
is LibraryAlbumsState.Data.Loaded -> state.copy(query = query)
|
||||
is LibraryAlbumsState.Data.LoadingMore -> state.copy(query = query)
|
||||
else -> state
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryAlbumsState.Loading)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
@ -113,7 +129,8 @@ class LibraryAlbumsViewModel(
|
||||
val result = repository.savedAlbums()
|
||||
_state.value = LibraryAlbumsState.Data.Loaded(
|
||||
allItems = result?.items ?: emptyList(),
|
||||
nextPagination = result?.nextPagination)
|
||||
nextPagination = result?.nextPagination
|
||||
)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load albums" }
|
||||
_state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error")
|
||||
@ -127,7 +144,8 @@ class LibraryAlbumsViewModel(
|
||||
_state.value = LibraryAlbumsState.Data.Loaded(
|
||||
query = currentState.query,
|
||||
allItems = currentState.allItems + (result?.items ?: emptyList()),
|
||||
nextPagination = result?.nextPagination)
|
||||
nextPagination = result?.nextPagination
|
||||
)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load more albums" }
|
||||
@ -135,13 +153,7 @@ class LibraryAlbumsViewModel(
|
||||
}
|
||||
|
||||
fun onQueryChange(query: String) {
|
||||
val currentState = _state.value
|
||||
if (currentState is LibraryAlbumsState.Data) {
|
||||
_state.value = when (currentState) {
|
||||
is LibraryAlbumsState.Data.Loaded -> currentState.copy(query = query)
|
||||
is LibraryAlbumsState.Data.LoadingMore -> currentState.copy(query = query)
|
||||
}
|
||||
}
|
||||
libraryState.setQuery(LibraryTab.Albums, query)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
|
||||
@ -18,23 +18,16 @@
|
||||
package dev.krtirtho.spotube.modules.library.artist
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -43,34 +36,25 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composeunstyled.Text
|
||||
import dev.krtirtho.spotube.core.ui.component.ArtistCard
|
||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
|
||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun LibraryArtistsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
searchMode: Boolean = false,
|
||||
viewModel: LibraryArtistsViewModel = koinViewModel()) {
|
||||
viewModel: LibraryArtistsViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
|
||||
val searchState = rememberTextFieldState((state as? LibraryArtistsState.Data)?.query ?: "")
|
||||
LaunchedEffect(searchState) {
|
||||
snapshotFlow { searchState.text.toString() }
|
||||
.drop(1)
|
||||
.collect(viewModel::onQueryChange)
|
||||
}
|
||||
|
||||
LaunchedEffect(gridState) {
|
||||
snapshotFlow { gridState.layoutInfo }
|
||||
@ -92,14 +76,6 @@ fun LibraryArtistsScreen(
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
AnimatedVisibility(searchMode){
|
||||
MillenniumTextField(
|
||||
state = searchState,
|
||||
label = "Search",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Search saved artists...")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
when (state) {
|
||||
is LibraryArtistsState.Loading -> {
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
@ -111,13 +87,15 @@ fun LibraryArtistsScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(8) {
|
||||
SkeletonTree(true) {
|
||||
AvatarCard(
|
||||
title = "Artist Name",
|
||||
subtitle = "Artist",
|
||||
imageURL = "https://placehold.co/600x400")
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -152,22 +130,25 @@ fun LibraryArtistsScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = contentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(dataState.items, key = { it.id }) { artist ->
|
||||
ArtistCard(
|
||||
artist = artist,
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// if (state is LibraryArtistsState.Data.LoadingMore) {
|
||||
items(4) {
|
||||
SkeletonTree(true) {
|
||||
AvatarCard(
|
||||
title = "Artist Name",
|
||||
subtitle = "Artist",
|
||||
imageURL = "https://placehold.co/400x400")
|
||||
}
|
||||
items(4) {
|
||||
SkeletonTree(true) {
|
||||
AvatarCard(
|
||||
title = "Artist Name",
|
||||
subtitle = "Artist",
|
||||
imageURL = "https://placehold.co/400x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@ -46,7 +49,8 @@ sealed interface LibraryArtistsState {
|
||||
data class Loaded(
|
||||
override val query: String = "",
|
||||
override val allItems: List<MetadataArtist.Basic> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null) : Data {
|
||||
override val nextPagination: PaginationStrategy? = null
|
||||
) : Data {
|
||||
override val items: List<MetadataArtist.Basic>
|
||||
get() = if (query.isNotBlank()) {
|
||||
allItems.filter { artist ->
|
||||
@ -59,13 +63,15 @@ sealed interface LibraryArtistsState {
|
||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||
query = query,
|
||||
allItems = allItems,
|
||||
nextPagination = nextPagination)
|
||||
nextPagination = nextPagination
|
||||
)
|
||||
}
|
||||
|
||||
data class LoadingMore(
|
||||
override val query: String = "",
|
||||
override val allItems: List<MetadataArtist.Basic> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null) : Data {
|
||||
override val nextPagination: PaginationStrategy? = null
|
||||
) : Data {
|
||||
override val items: List<MetadataArtist.Basic>
|
||||
get() = if (query.isNotBlank()) {
|
||||
allItems.filter { artist ->
|
||||
@ -82,11 +88,21 @@ sealed interface LibraryArtistsState {
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LibraryArtistsViewModel(
|
||||
private val repository: LibraryRepository) : ViewModel(), KoinComponent {
|
||||
private val repository: LibraryRepository,
|
||||
private val libraryState: LibraryState
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<LibraryArtistsViewModel>()
|
||||
|
||||
private val _state = MutableStateFlow<LibraryArtistsState>(LibraryArtistsState.Loading)
|
||||
val uiState: StateFlow<LibraryArtistsState> = _state.asStateFlow()
|
||||
val uiState: StateFlow<LibraryArtistsState> = combine(
|
||||
_state, libraryState.queryFlow(LibraryTab.Artists)
|
||||
) { state, query ->
|
||||
when (state) {
|
||||
is LibraryArtistsState.Data.Loaded -> state.copy(query = query)
|
||||
is LibraryArtistsState.Data.LoadingMore -> state.copy(query = query)
|
||||
else -> state
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryArtistsState.Loading)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
@ -111,7 +127,8 @@ class LibraryArtistsViewModel(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
thumbnails = it.thumbnails,
|
||||
externalUri = it.externalUri)
|
||||
externalUri = it.externalUri
|
||||
)
|
||||
} ?: emptyList(),
|
||||
nextPagination = result?.nextPagination)
|
||||
}.onFailure { e ->
|
||||
@ -129,12 +146,14 @@ class LibraryArtistsViewModel(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
thumbnails = it.thumbnails,
|
||||
externalUri = it.externalUri)
|
||||
externalUri = it.externalUri
|
||||
)
|
||||
} ?: emptyList()
|
||||
_state.value = LibraryArtistsState.Data.Loaded(
|
||||
query = currentState.query,
|
||||
allItems = currentState.allItems + newItems,
|
||||
nextPagination = result?.nextPagination)
|
||||
nextPagination = result?.nextPagination
|
||||
)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load more artists" }
|
||||
@ -142,13 +161,7 @@ class LibraryArtistsViewModel(
|
||||
}
|
||||
|
||||
fun onQueryChange(query: String) {
|
||||
val currentState = _state.value
|
||||
if (currentState is LibraryArtistsState.Data) {
|
||||
_state.value = when (currentState) {
|
||||
is LibraryArtistsState.Data.Loaded -> currentState.copy(query = query)
|
||||
is LibraryArtistsState.Data.LoadingMore -> currentState.copy(query = query)
|
||||
}
|
||||
}
|
||||
libraryState.setQuery(LibraryTab.Artists, query)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
|
||||
@ -25,8 +25,6 @@ import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
|
||||
import org.spotube.millennium.composables.MillenniumListTile
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@ -38,7 +36,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
|
||||
import com.composeunstyled.Text
|
||||
|
||||
@ -46,28 +43,24 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import dev.krtirtho.spotube.PlatformType
|
||||
import dev.krtirtho.spotube.getPlatform
|
||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
|
||||
import dev.krtirtho.spotube.resources.iconsax.Play
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean = false) {
|
||||
fun LibraryLocalTracksScreen(modifier: Modifier = Modifier) {
|
||||
val viewModel = koinViewModel<LibraryLocalTracksViewModel>()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val permissionState = rememberLocalMediaPermissionState()
|
||||
@ -85,53 +78,25 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
}
|
||||
|
||||
val searchState = rememberTextFieldState(state.query)
|
||||
LaunchedEffect(searchState) {
|
||||
snapshotFlow { searchState.text.toString() }
|
||||
.drop(1)
|
||||
.collect(viewModel::onQueryChange)
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
AnimatedVisibility(searchMode) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Search")
|
||||
MillenniumTextField(
|
||||
state = searchState,
|
||||
label = "Search",
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = if (state.currentFolderPath == null) {
|
||||
"Search folders or tracks"
|
||||
} else {
|
||||
"Search tracks in folder"
|
||||
})
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.IconsaxRefreshRight,
|
||||
contentDescription = "Refresh",
|
||||
onClick = { viewModel.refresh() },
|
||||
style = MillenniumIconButtonStyle.Quiet)
|
||||
}
|
||||
}
|
||||
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (state.currentFolderPath != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.ArrowLeft3,
|
||||
contentDescription = "Back",
|
||||
onClick = viewModel::navigateUp,
|
||||
style = MillenniumIconButtonStyle.Quiet)
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
Text(viewModel.currentFolder()?.name ?: "Folder")
|
||||
}
|
||||
MillenniumIconButton(
|
||||
@ -140,7 +105,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
|
||||
onClick = {
|
||||
viewModel.currentFolder()?.let { viewModel.playFolder(it.path) }
|
||||
},
|
||||
style = MillenniumIconButtonStyle.Quiet)
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,11 +117,13 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text("Allow audio permission to show local tracks")
|
||||
MillenniumButton(
|
||||
onClick = permissionState.requestPermission,
|
||||
style = MillenniumButtonStyle.Quiet) {
|
||||
style = MillenniumButtonStyle.Quiet
|
||||
) {
|
||||
Text("Grant access")
|
||||
}
|
||||
}
|
||||
@ -212,7 +180,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
|
||||
icon = Iconsax.Play,
|
||||
contentDescription = "Play",
|
||||
onClick = { viewModel.playTrack(track.path) },
|
||||
style = MillenniumIconButtonStyle.Quiet)
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.playTrack(track.path) })
|
||||
}
|
||||
|
||||
@ -21,13 +21,17 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaFolder
|
||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
|
||||
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LibraryLocalTracksState(
|
||||
@ -35,24 +39,34 @@ data class LibraryLocalTracksState(
|
||||
val folders: List<LocalMediaFolder> = emptyList(),
|
||||
val currentFolderPath: String? = null,
|
||||
val query: String = "",
|
||||
val error: String? = null)
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class LibraryLocalTracksViewModel(
|
||||
private val coordinator: LocalMediaLibraryCoordinator,
|
||||
private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() {
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val libraryState: LibraryState
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(LibraryLocalTracksState())
|
||||
val state: StateFlow<LibraryLocalTracksState> = _state.asStateFlow()
|
||||
val state: StateFlow<LibraryLocalTracksState> = combine(
|
||||
_state, libraryState.queryFlow(LibraryTab.LocalTracks)
|
||||
) { state, query -> state.copy(query = query) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryLocalTracksState())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
libraryState.localRefreshRequests.collect { refresh() }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
coordinator.cacheState
|
||||
.collect { cache ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
folders = cache.folders,
|
||||
error = null)
|
||||
_state.update {
|
||||
it.copy(
|
||||
folders = cache.folders,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshIfEmpty()
|
||||
@ -60,7 +74,7 @@ class LibraryLocalTracksViewModel(
|
||||
}
|
||||
|
||||
fun onQueryChange(query: String) {
|
||||
_state.update { it.copy(query = query) }
|
||||
libraryState.setQuery(LibraryTab.LocalTracks, query)
|
||||
}
|
||||
|
||||
fun openFolder(folderPath: String) {
|
||||
@ -87,7 +101,7 @@ class LibraryLocalTracksViewModel(
|
||||
|
||||
fun playFolder(folderPath: String) {
|
||||
val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return
|
||||
val tracks = filterTracks(folder.tracks, _state.value.query)
|
||||
val tracks = filterTracks(folder.tracks, state.value.query)
|
||||
if (tracks.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
@ -95,13 +109,14 @@ class LibraryLocalTracksViewModel(
|
||||
entries = tracks.map { it.toQueueEntry() },
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null)
|
||||
collectionEntry = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun playTrack(trackPath: String) {
|
||||
val folder = currentFolder() ?: return
|
||||
val tracks = filterTracks(folder.tracks, _state.value.query)
|
||||
val tracks = filterTracks(folder.tracks, state.value.query)
|
||||
val selectedIndex = tracks.indexOfFirst { it.path == trackPath }
|
||||
if (selectedIndex < 0) return
|
||||
|
||||
@ -110,7 +125,8 @@ class LibraryLocalTracksViewModel(
|
||||
entries = tracks.map { it.toQueueEntry() },
|
||||
autoPlay = true,
|
||||
startPosition = selectedIndex,
|
||||
collectionEntry = null)
|
||||
collectionEntry = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,10 +136,10 @@ class LibraryLocalTracksViewModel(
|
||||
|
||||
return state.value.folders.filter { folder ->
|
||||
folder.name.contains(query, ignoreCase = true) ||
|
||||
folder.tracks.any { track ->
|
||||
track.name.contains(query, ignoreCase = true) ||
|
||||
track.artists.any { it.contains(query, ignoreCase = true) }
|
||||
}
|
||||
folder.tracks.any { track ->
|
||||
track.name.contains(query, ignoreCase = true) ||
|
||||
track.artists.any { it.contains(query, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,8 +172,8 @@ class LibraryLocalTracksViewModel(
|
||||
|
||||
return tracks.filter { track ->
|
||||
track.name.contains(normalizedQuery, ignoreCase = true) ||
|
||||
track.album?.contains(normalizedQuery, ignoreCase = true) == true ||
|
||||
track.artists.any { it.contains(normalizedQuery, ignoreCase = true) }
|
||||
track.album?.contains(normalizedQuery, ignoreCase = true) == true ||
|
||||
track.artists.any { it.contains(normalizedQuery, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,6 +184,7 @@ class LibraryLocalTracksViewModel(
|
||||
duration = durationMs,
|
||||
album = album,
|
||||
coverBytes = coverBytes,
|
||||
url = path)
|
||||
url = path
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,7 +129,7 @@ fun AddToPlaylistPicker(
|
||||
contentDescription = "Search")
|
||||
MillenniumTextField(
|
||||
state = searchState,
|
||||
label = "Search",
|
||||
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = "Search your playlists...")
|
||||
}
|
||||
|
||||
@ -18,68 +18,45 @@
|
||||
package dev.krtirtho.spotube.modules.library.playlist
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composeunstyled.Text
|
||||
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
|
||||
import dev.krtirtho.spotube.core.ui.component.LikedTracksCard
|
||||
import dev.krtirtho.spotube.core.ui.component.PlaylistCard
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun LibraryPlaylistsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
searchMode: Boolean = false,
|
||||
viewModel: LibraryPlaylistsViewModel = koinViewModel()) {
|
||||
showCreatePlaylist: Boolean = false,
|
||||
onDismissCreatePlaylist: () -> Unit = {},
|
||||
viewModel: LibraryPlaylistsViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
var showCreatePlaylist by remember { mutableStateOf(false) }
|
||||
|
||||
val searchState = rememberTextFieldState((state as? LibraryPlaylistsState.Data)?.query ?: "")
|
||||
LaunchedEffect(searchState) {
|
||||
snapshotFlow { searchState.text.toString() }
|
||||
.drop(1)
|
||||
.collect(viewModel::onQueryChange)
|
||||
}
|
||||
|
||||
LaunchedEffect(gridState) {
|
||||
snapshotFlow { gridState.layoutInfo }
|
||||
@ -101,26 +78,6 @@ fun LibraryPlaylistsScreen(
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
AnimatedVisibility(
|
||||
visible = searchMode,
|
||||
modifier = Modifier.weight(1f)) {
|
||||
MillenniumTextField(
|
||||
state = searchState,
|
||||
label = "Search",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "Search saved playlists...")
|
||||
}
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Create Playlist",
|
||||
onClick = { showCreatePlaylist = true },
|
||||
modifier = Modifier.size(48.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
when (state) {
|
||||
is LibraryPlaylistsState.Loading -> {
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
@ -139,7 +96,8 @@ fun LibraryPlaylistsScreen(
|
||||
PlayableCard(
|
||||
title = "Playlist Name",
|
||||
subtitle = "By Artist",
|
||||
imageURL = "https://placehold.co/600x400")
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -195,7 +153,8 @@ fun LibraryPlaylistsScreen(
|
||||
PlayableCard(
|
||||
title = "Playlist Name",
|
||||
subtitle = "By Artist",
|
||||
imageURL = "https://placehold.co/600x400")
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -206,7 +165,7 @@ fun LibraryPlaylistsScreen(
|
||||
|
||||
PlaylistFormSheet(
|
||||
visible = showCreatePlaylist,
|
||||
onDismiss = { showCreatePlaylist = false },
|
||||
onDismiss = onDismissCreatePlaylist,
|
||||
onSubmit = { data ->
|
||||
viewModel.createPlaylist(
|
||||
name = data.name,
|
||||
@ -214,7 +173,8 @@ fun LibraryPlaylistsScreen(
|
||||
isPublic = data.isPublic,
|
||||
isCollaborating = data.isCollaborating,
|
||||
imageBase64 = data.imageBase64,
|
||||
trackIds = emptyList())
|
||||
trackIds = emptyList()
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStra
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@ -46,12 +49,13 @@ sealed interface LibraryPlaylistsState {
|
||||
data class Loaded(
|
||||
override val query: String = "",
|
||||
override val allItems: List<MetadataPlaylist> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null) : Data {
|
||||
override val nextPagination: PaginationStrategy? = null
|
||||
) : Data {
|
||||
override val items: List<MetadataPlaylist>
|
||||
get() = if (query.isNotBlank()) {
|
||||
allItems.filter {
|
||||
it.title.contains(query, ignoreCase = true) ||
|
||||
(it.description?.contains(query, ignoreCase = true) == true)
|
||||
(it.description?.contains(query, ignoreCase = true) == true)
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
@ -60,18 +64,20 @@ sealed interface LibraryPlaylistsState {
|
||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||
query = query,
|
||||
allItems = allItems,
|
||||
nextPagination = nextPagination)
|
||||
nextPagination = nextPagination
|
||||
)
|
||||
}
|
||||
|
||||
data class LoadingMore(
|
||||
override val query: String = "",
|
||||
override val allItems: List<MetadataPlaylist> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null) : Data {
|
||||
override val nextPagination: PaginationStrategy? = null
|
||||
) : Data {
|
||||
override val items: List<MetadataPlaylist>
|
||||
get() = if (query.isNotBlank()) {
|
||||
allItems.filter {
|
||||
it.title.contains(query, ignoreCase = true) ||
|
||||
(it.description?.contains(query, ignoreCase = true) == true)
|
||||
(it.description?.contains(query, ignoreCase = true) == true)
|
||||
}
|
||||
} else {
|
||||
allItems
|
||||
@ -84,11 +90,21 @@ sealed interface LibraryPlaylistsState {
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LibraryPlaylistsViewModel(
|
||||
private val repository: LibraryRepository) : ViewModel(), KoinComponent {
|
||||
private val repository: LibraryRepository,
|
||||
private val libraryState: LibraryState
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<LibraryPlaylistsViewModel>()
|
||||
|
||||
private val _state = MutableStateFlow<LibraryPlaylistsState>(LibraryPlaylistsState.Loading)
|
||||
val uiState: StateFlow<LibraryPlaylistsState> = _state.asStateFlow()
|
||||
val uiState: StateFlow<LibraryPlaylistsState> = combine(
|
||||
_state, libraryState.queryFlow(LibraryTab.Playlists)
|
||||
) { state, query ->
|
||||
when (state) {
|
||||
is LibraryPlaylistsState.Data.Loaded -> state.copy(query = query)
|
||||
is LibraryPlaylistsState.Data.LoadingMore -> state.copy(query = query)
|
||||
else -> state
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryPlaylistsState.Loading)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
@ -109,7 +125,8 @@ class LibraryPlaylistsViewModel(
|
||||
val result = repository.savedPlaylists()
|
||||
_state.value = LibraryPlaylistsState.Data.Loaded(
|
||||
allItems = result?.items ?: emptyList(),
|
||||
nextPagination = result?.nextPagination)
|
||||
nextPagination = result?.nextPagination
|
||||
)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load playlists" }
|
||||
_state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error")
|
||||
@ -123,7 +140,8 @@ class LibraryPlaylistsViewModel(
|
||||
_state.value = LibraryPlaylistsState.Data.Loaded(
|
||||
query = currentState.query,
|
||||
allItems = currentState.allItems + (result?.items ?: emptyList()),
|
||||
nextPagination = result?.nextPagination)
|
||||
nextPagination = result?.nextPagination
|
||||
)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load more playlists" }
|
||||
@ -131,13 +149,7 @@ class LibraryPlaylistsViewModel(
|
||||
}
|
||||
|
||||
fun onQueryChange(query: String) {
|
||||
val currentState = _state.value
|
||||
if (currentState is LibraryPlaylistsState.Data) {
|
||||
_state.value = when (currentState) {
|
||||
is LibraryPlaylistsState.Data.Loaded -> currentState.copy(query = query)
|
||||
is LibraryPlaylistsState.Data.LoadingMore -> currentState.copy(query = query)
|
||||
}
|
||||
}
|
||||
libraryState.setQuery(LibraryTab.Playlists, query)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
@ -153,7 +165,8 @@ class LibraryPlaylistsViewModel(
|
||||
isPublic: Boolean,
|
||||
isCollaborating: Boolean,
|
||||
imageBase64: String,
|
||||
trackIds: List<String>) {
|
||||
trackIds: List<String>
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
repository.createPlaylist(
|
||||
@ -162,7 +175,8 @@ class LibraryPlaylistsViewModel(
|
||||
isPublic = isPublic,
|
||||
isCollaborating = isCollaborating,
|
||||
imageBase64 = imageBase64,
|
||||
trackIds = trackIds)
|
||||
trackIds = trackIds
|
||||
)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to create playlist" }
|
||||
}.onSuccess {
|
||||
|
||||
@ -255,7 +255,7 @@ private fun PlaylistFormFields(
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange, label = "Public")
|
||||
MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange)
|
||||
}
|
||||
|
||||
Row(
|
||||
@ -273,7 +273,7 @@ private fun PlaylistFormFields(
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange, label = "Collaborative")
|
||||
MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange)
|
||||
}
|
||||
|
||||
Column(
|
||||
|
||||
@ -143,7 +143,6 @@ fun AddTracksToPlaylistDialog(
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumTextField(
|
||||
state = queryState,
|
||||
label = "Search",
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = "Search tracks...")
|
||||
MillenniumIconButton(
|
||||
|
||||
@ -19,13 +19,12 @@ package dev.krtirtho.spotube.modules.search
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
|
||||
import org.spotube.millennium.composables.MillenniumListTile
|
||||
import org.spotube.millennium.composables.MillenniumAutocomplete
|
||||
import org.spotube.millennium.composables.MillenniumText
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
@ -46,10 +45,13 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
@ -78,14 +80,14 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.X
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
||||
import dev.krtirtho.spotube.Platform
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
@ -106,6 +108,8 @@ import dev.krtirtho.spotube.core.ui.component.UserCard
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||
import dev.krtirtho.spotube.getPlatform
|
||||
import dev.krtirtho.spotube.isDesktop
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
@ -115,11 +119,16 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.search_clear_history
|
||||
import spotube.composeapp.generated.resources.search_placeholder
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
|
||||
private val GridMinCellSize = 180.dp
|
||||
|
||||
@ -145,8 +154,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(emptyList())
|
||||
val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(emptyList())
|
||||
val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(
|
||||
emptyList()
|
||||
)
|
||||
val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(
|
||||
emptyList()
|
||||
)
|
||||
|
||||
val isExpanded =
|
||||
currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded && getPlatform().isDesktop()
|
||||
|
||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||
val trackIds = blacklistedTracks.map { it.id }.toSet()
|
||||
@ -172,7 +188,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
entries = listOf(QueueEntry.StreamingTrack(track = track, url = "")),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null)
|
||||
collectionEntry = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,7 +205,14 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
if (existing != null) {
|
||||
// Already in the local queue: move it to the next position
|
||||
audioPlayerQueue.removeFromQueue(existing)
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
audioPlayerQueue.addAllAfterCurrent(
|
||||
listOf(
|
||||
QueueEntry.StreamingTrack(
|
||||
track = track,
|
||||
url = ""
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
remotePlaybackController.requestTrackPlayNext(track)
|
||||
}
|
||||
@ -197,6 +221,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToJam -> {
|
||||
remotePlaybackController.addTrackToJam(track)
|
||||
}
|
||||
@ -217,6 +242,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
shareService.share(uri, track.title)
|
||||
}
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToPlaylist -> {
|
||||
tracksToAddToPlaylist = listOf(track)
|
||||
showAddToPlaylistPicker = true
|
||||
@ -236,7 +262,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = savedTrackIds.contains(track.id),
|
||||
isBlacklisted = isTrackBlacklisted(track))
|
||||
isBlacklisted = isTrackBlacklisted(track)
|
||||
)
|
||||
}
|
||||
|
||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle(emptyList())
|
||||
@ -251,7 +278,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
isInQueue = queueTrackIds.contains(track.id),
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = savedTrackIds.contains(track.id),
|
||||
isBlacklisted = isTrackBlacklisted(track))
|
||||
isBlacklisted = isTrackBlacklisted(track)
|
||||
)
|
||||
}
|
||||
|
||||
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
|
||||
@ -269,8 +297,26 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MillenniumTheme.colors.background)) {
|
||||
ApplicationMainBar(backButton = false)
|
||||
.background(MillenniumTheme.colors.background)
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
) {
|
||||
if (!isExpanded) {
|
||||
SearchBar(
|
||||
query = state.query,
|
||||
onQueryChange = viewModel::onQueryChange,
|
||||
recentSearchSuggestions = state.recentSearchSuggestions,
|
||||
onRecentSearchClick = { search ->
|
||||
viewModel.applyRecentSearch(search)
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
onClearAllRecentSearches = {
|
||||
viewModel.clearAllRecentSearches()
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ApplicationMainBar(backButton = false)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -279,25 +325,28 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
SearchBar(
|
||||
query = state.query,
|
||||
onQueryChange = viewModel::onQueryChange,
|
||||
recentSearches = state.recentSearches,
|
||||
onRecentSearchClick = { search ->
|
||||
viewModel.applyRecentSearch(search)
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
onRecentSearchRemove = viewModel::removeRecentSearch,
|
||||
onClearAllRecentSearches = {
|
||||
viewModel.clearAllRecentSearches()
|
||||
focusManager.clearFocus()
|
||||
})
|
||||
if (isExpanded) {
|
||||
SearchBar(
|
||||
query = state.query,
|
||||
onQueryChange = viewModel::onQueryChange,
|
||||
recentSearchSuggestions = state.recentSearchSuggestions,
|
||||
onRecentSearchClick = { search ->
|
||||
viewModel.applyRecentSearch(search)
|
||||
focusManager.clearFocus()
|
||||
},
|
||||
onClearAllRecentSearches = {
|
||||
viewModel.clearAllRecentSearches()
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (state.supportedSearchTypes.isNotEmpty()) {
|
||||
SearchTabs(
|
||||
types = state.supportedSearchTypes,
|
||||
selectedType = selectedType,
|
||||
onTabSelected = viewModel::onTabSelected)
|
||||
onTabSelected = viewModel::onTabSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -305,14 +354,16 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
state.isLoadingSearchTypes -> {
|
||||
SearchLoadingIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxSize())
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
selectedType == null -> {
|
||||
SearchMessage(
|
||||
message = "No search types available",
|
||||
modifier = Modifier
|
||||
.fillMaxSize())
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
selectedType == MetadataSupportedSearchType.ALL -> {
|
||||
@ -348,7 +399,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
// No-op for now, could show a dialog with all artists
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize())
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
selectedType == MetadataSupportedSearchType.TRACK -> {
|
||||
@ -379,7 +431,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
// No-op for now, could show a dialog with all artists
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize())
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
selectedType == MetadataSupportedSearchType.PLAYLIST -> {
|
||||
@ -388,7 +441,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
playlists = state.playlists,
|
||||
onLoadNextPage = viewModel::loadNextPlaylists,
|
||||
modifier = Modifier
|
||||
.fillMaxSize())
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
selectedType == MetadataSupportedSearchType.ALBUM -> {
|
||||
@ -397,7 +451,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
albums = state.albums,
|
||||
onLoadNextPage = viewModel::loadNextAlbums,
|
||||
modifier = Modifier
|
||||
.fillMaxSize())
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
selectedType == MetadataSupportedSearchType.ARTIST -> {
|
||||
@ -406,7 +461,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
artists = state.artists,
|
||||
onLoadNextPage = viewModel::loadNextArtists,
|
||||
modifier = Modifier
|
||||
.fillMaxSize())
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
selectedType == MetadataSupportedSearchType.USER -> {
|
||||
@ -415,7 +471,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
users = state.users,
|
||||
onLoadNextPage = viewModel::loadNextUsers,
|
||||
modifier = Modifier
|
||||
.fillMaxSize())
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -439,11 +496,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
private fun SearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
recentSearches: List<String>,
|
||||
recentSearchSuggestions: List<String>,
|
||||
onRecentSearchClick: (String) -> Unit,
|
||||
onRecentSearchRemove: (String) -> Unit,
|
||||
onClearAllRecentSearches: () -> Unit) {
|
||||
val showRecentSearches = query.isBlank() && recentSearches.isNotEmpty()
|
||||
onClearAllRecentSearches: () -> Unit
|
||||
) {
|
||||
val hasRecentSearchSuggestions = recentSearchSuggestions.isNotEmpty()
|
||||
val searchState = rememberTextFieldState(query)
|
||||
|
||||
LaunchedEffect(searchState) {
|
||||
@ -455,106 +512,61 @@ private fun SearchBar(
|
||||
}
|
||||
}
|
||||
|
||||
val suggestions = remember(showRecentSearches, recentSearches) {
|
||||
buildList {
|
||||
if (showRecentSearches) {
|
||||
add(SearchDropdownEntry.ClearAll(onClick = onClearAllRecentSearches))
|
||||
recentSearches.forEach { search ->
|
||||
add(
|
||||
SearchDropdownEntry.RecentSearch(
|
||||
query = search,
|
||||
onClick = { onRecentSearchClick(search) },
|
||||
onRemove = { onRecentSearchRemove(search) })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
MillenniumAutocomplete(
|
||||
state = searchState,
|
||||
suggestions = recentSearchSuggestions,
|
||||
onSuggestionSelected = onRecentSearchClick,
|
||||
suggestionLabel = { it },
|
||||
leadingContent = {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.InconsaxClock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
placeholder = stringResource(Res.string.search_placeholder),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 8.dp)) {
|
||||
MillenniumAutocomplete(
|
||||
state = searchState,
|
||||
label = "Search",
|
||||
suggestions = suggestions,
|
||||
onSuggestionSelected = { entry -> entry.onClick() },
|
||||
suggestionLabel = { entry ->
|
||||
when (entry) {
|
||||
is SearchDropdownEntry.ClearAll -> "Clear all history"
|
||||
is SearchDropdownEntry.RecentSearch -> entry.query
|
||||
}
|
||||
},
|
||||
placeholder = "Search songs, artists, albums...",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
suggestionContent = { entry, _ -> SearchSuggestionRow(entry) })
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface SearchDropdownEntry {
|
||||
val onClick: () -> Unit
|
||||
|
||||
data class ClearAll(override val onClick: () -> Unit) : SearchDropdownEntry
|
||||
data class RecentSearch(
|
||||
val query: String,
|
||||
override val onClick: () -> Unit,
|
||||
val onRemove: () -> Unit) : SearchDropdownEntry
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchSuggestionRow(entry: SearchDropdownEntry) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
when (entry) {
|
||||
is SearchDropdownEntry.ClearAll -> {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxTrash,
|
||||
contentDescription = null,
|
||||
tint = AppTheme.colors.error,
|
||||
modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
"Clear all history",
|
||||
color = AppTheme.colors.error,
|
||||
style = AppTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
is SearchDropdownEntry.RecentSearch -> {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.InconsaxClock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
Text(
|
||||
entry.query,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f))
|
||||
MillenniumIconButton(
|
||||
onClick = entry.onRemove,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.X,
|
||||
contentDescription = "Remove",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f))
|
||||
}
|
||||
.padding(bottom = 8.dp),
|
||||
menuHeader = if (hasRecentSearchSuggestions) {
|
||||
{
|
||||
MillenniumListTile(
|
||||
title = {
|
||||
MillenniumText(
|
||||
text = stringResource(Res.string.search_clear_history),
|
||||
color = AppTheme.colors.error,
|
||||
style = AppTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
leading = {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxTrash,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = AppTheme.colors.error
|
||||
)
|
||||
},
|
||||
onClick = onClearAllRecentSearches,
|
||||
minimumHeight = 36.dp,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
leadingSpacing = 8.dp,
|
||||
shape = AppTheme.shapes.small
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchTabs(
|
||||
types: List<MetadataSupportedSearchType>,
|
||||
selectedType: MetadataSupportedSearchType?,
|
||||
onTabSelected: (MetadataSupportedSearchType) -> Unit) {
|
||||
onTabSelected: (MetadataSupportedSearchType) -> Unit
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
LazyRow(
|
||||
state = listState,
|
||||
@ -570,7 +582,8 @@ private fun SearchTabs(
|
||||
MillenniumButtonStyle.Primary
|
||||
} else {
|
||||
MillenniumButtonStyle.Secondary
|
||||
}) {
|
||||
}
|
||||
) {
|
||||
Text(type.tabTitle())
|
||||
}
|
||||
}
|
||||
@ -600,7 +613,8 @@ private fun SearchAllTab(
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||
modifier: Modifier = Modifier) {
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (query.isBlank()) {
|
||||
SearchMessage(message = "Start typing to search", modifier = modifier)
|
||||
return
|
||||
@ -624,7 +638,8 @@ private fun SearchAllTab(
|
||||
ErrorDisplay(
|
||||
errorMessage = hasAnyError,
|
||||
onRetry = { },
|
||||
modifier = modifier)
|
||||
modifier = modifier
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@ -665,7 +680,8 @@ private fun SearchAllTab(
|
||||
text = "Tracks",
|
||||
style = AppTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp))
|
||||
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp)
|
||||
)
|
||||
}
|
||||
if (MetadataSupportedSearchType.TRACK in supportedSearchTypes) {
|
||||
if (tracks.isLoading && tracks.items.isEmpty()) {
|
||||
@ -743,7 +759,8 @@ private fun SearchTracksTab(
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||
modifier: Modifier = Modifier) {
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (query.isBlank()) {
|
||||
SearchMessage(message = "Start typing to search tracks", modifier = modifier)
|
||||
return
|
||||
@ -775,7 +792,8 @@ private fun SearchTracksTab(
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||
simplified = true,
|
||||
modifier = modifier)
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -783,7 +801,8 @@ private fun SearchAlbumsTab(
|
||||
query: String,
|
||||
albums: SearchPagedState<MetadataAlbum.Basic>,
|
||||
onLoadNextPage: () -> Unit,
|
||||
modifier: Modifier = Modifier) {
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SearchGridTab(
|
||||
query = query,
|
||||
items = albums.items,
|
||||
@ -793,7 +812,8 @@ private fun SearchAlbumsTab(
|
||||
onLoadNextPage = onLoadNextPage,
|
||||
emptyMessage = "No albums found",
|
||||
loadingMessage = "Loading albums...",
|
||||
modifier = modifier) { album ->
|
||||
modifier = modifier
|
||||
) { album ->
|
||||
AlbumCard(album = album, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
@ -803,7 +823,8 @@ private fun SearchArtistsTab(
|
||||
query: String,
|
||||
artists: SearchPagedState<MetadataArtist.Basic>,
|
||||
onLoadNextPage: () -> Unit,
|
||||
modifier: Modifier = Modifier) {
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SearchGridTab(
|
||||
query = query,
|
||||
items = artists.items,
|
||||
@ -813,7 +834,8 @@ private fun SearchArtistsTab(
|
||||
onLoadNextPage = onLoadNextPage,
|
||||
emptyMessage = "No artists found",
|
||||
loadingMessage = "Loading artists...",
|
||||
modifier = modifier) { artist ->
|
||||
modifier = modifier
|
||||
) { artist ->
|
||||
ArtistCard(artist = artist, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
@ -823,7 +845,8 @@ private fun SearchPlaylistsTab(
|
||||
query: String,
|
||||
playlists: SearchPagedState<MetadataPlaylist>,
|
||||
onLoadNextPage: () -> Unit,
|
||||
modifier: Modifier = Modifier) {
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SearchGridTab(
|
||||
query = query,
|
||||
items = playlists.items,
|
||||
@ -833,7 +856,8 @@ private fun SearchPlaylistsTab(
|
||||
onLoadNextPage = onLoadNextPage,
|
||||
emptyMessage = "No playlists found",
|
||||
loadingMessage = "Loading playlists...",
|
||||
modifier = modifier) { playlist ->
|
||||
modifier = modifier
|
||||
) { playlist ->
|
||||
PlaylistCard(playlist = playlist, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
@ -843,7 +867,8 @@ private fun SearchUsersTab(
|
||||
query: String,
|
||||
users: SearchPagedState<MetadataUser>,
|
||||
onLoadNextPage: () -> Unit,
|
||||
modifier: Modifier = Modifier) {
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SearchGridTab(
|
||||
query = query,
|
||||
items = users.items,
|
||||
@ -853,7 +878,8 @@ private fun SearchUsersTab(
|
||||
onLoadNextPage = onLoadNextPage,
|
||||
emptyMessage = "No users found",
|
||||
loadingMessage = "Loading users...",
|
||||
modifier = modifier) { user ->
|
||||
modifier = modifier
|
||||
) { user ->
|
||||
UserCard(user = user, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
@ -870,7 +896,8 @@ private fun <T> SearchGridTab(
|
||||
emptyMessage: String,
|
||||
loadingMessage: String,
|
||||
modifier: Modifier = Modifier,
|
||||
itemContent: @Composable (T) -> Unit) {
|
||||
itemContent: @Composable (T) -> Unit
|
||||
) {
|
||||
if (query.isBlank()) {
|
||||
SearchMessage(message = "Start typing to search", modifier = modifier)
|
||||
return
|
||||
@ -897,14 +924,16 @@ private fun <T> SearchGridTab(
|
||||
isLoading && items.isEmpty() -> {
|
||||
SearchLoadingIndicator(
|
||||
message = loadingMessage,
|
||||
modifier = modifier)
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
error != null && items.isEmpty() -> {
|
||||
ErrorDisplay(
|
||||
errorMessage = error,
|
||||
onRetry = onLoadNextPage,
|
||||
modifier = modifier)
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
!isLoading && items.isEmpty() -> {
|
||||
@ -918,7 +947,8 @@ private fun <T> SearchGridTab(
|
||||
modifier = modifier.padding(horizontal = 12.dp),
|
||||
contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(items) { item ->
|
||||
itemContent(item)
|
||||
}
|
||||
@ -929,7 +959,8 @@ private fun <T> SearchGridTab(
|
||||
PlayableCard(
|
||||
title = "Sample Title",
|
||||
subtitle = "Sample Subtitle",
|
||||
imageURL = "https://placehold.co/600x400")
|
||||
imageURL = "https://placehold.co/600x400"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -942,32 +973,38 @@ private fun <T> SearchGridTab(
|
||||
private fun SearchHorizontalSection(
|
||||
title: String,
|
||||
onSeeAll: () -> Unit,
|
||||
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit) {
|
||||
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = AppTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "See all",
|
||||
style = AppTheme.typography.labelLarge,
|
||||
color = AppTheme.colors.primary,
|
||||
modifier = Modifier.clickable(onClick = onSeeAll))
|
||||
modifier = Modifier.clickable(onClick = onSeeAll)
|
||||
)
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
content = content)
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -975,31 +1012,37 @@ private fun SearchHorizontalSection(
|
||||
private fun SearchTrackRow(
|
||||
index: Int,
|
||||
track: MetadataTrack,
|
||||
modifier: Modifier = Modifier) {
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = (index + 1).toString(),
|
||||
style = AppTheme.typography.labelMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
textAlign = TextAlign.End)
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
maxLines = 1)
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = track.artists.joinToString { it.name },
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1)
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
if (track.album != null) Text(
|
||||
@ -1007,7 +1050,8 @@ private fun SearchTrackRow(
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.weight(1f))
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1018,27 +1062,33 @@ private val WarmSearchMessages = listOf(
|
||||
"Dusting off the vinyl...",
|
||||
"Consulting the music gods...",
|
||||
"Tuning the antennas...",
|
||||
"Flipping through the records...")
|
||||
"Flipping through the records..."
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun SearchLoadingIndicator(
|
||||
message: String? = null,
|
||||
modifier: Modifier = Modifier) {
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "search-loading")
|
||||
val rotation by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1500, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart),
|
||||
label = "icon-rotation")
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "icon-rotation"
|
||||
)
|
||||
val bounce by infiniteTransition.animateFloat(
|
||||
initialValue = -8f,
|
||||
targetValue = 8f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 800, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse),
|
||||
label = "icon-bounce")
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "icon-bounce"
|
||||
)
|
||||
val messageIndex by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = WarmSearchMessages.size.toFloat(),
|
||||
@ -1047,18 +1097,22 @@ private fun SearchLoadingIndicator(
|
||||
durationMillis = WarmSearchMessages.size * 2000,
|
||||
easing = LinearEasing
|
||||
),
|
||||
repeatMode = RepeatMode.Restart),
|
||||
label = "message-cycle")
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "message-cycle"
|
||||
)
|
||||
val displayMessage =
|
||||
message ?: WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size]
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center) {
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(64.dp),
|
||||
contentAlignment = Alignment.Center) {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = "Searching",
|
||||
@ -1073,18 +1127,21 @@ private fun SearchLoadingIndicator(
|
||||
MillenniumIndeterminateProgress(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp, vertical = 16.dp))
|
||||
.padding(horizontal = 32.dp, vertical = 16.dp)
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = displayMessage,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(300)) togetherWith fadeOut(tween(300))
|
||||
},
|
||||
label = "message-transition") { msg ->
|
||||
label = "message-transition"
|
||||
) { msg ->
|
||||
Text(
|
||||
text = msg,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center)
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1093,30 +1150,35 @@ private fun SearchLoadingIndicator(
|
||||
private fun SearchMessage(
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isError: Boolean = false) {
|
||||
isError: Boolean = false
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center) {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(16.dp))
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchMessageInline(
|
||||
message: String,
|
||||
isError: Boolean = false) {
|
||||
isError: Boolean = false
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp))
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@ -59,7 +59,14 @@ data class SearchScreenState(
|
||||
val albums: SearchPagedState<MetadataAlbum.Basic> = SearchPagedState(),
|
||||
val artists: SearchPagedState<MetadataArtist.Basic> = SearchPagedState(),
|
||||
val playlists: SearchPagedState<MetadataPlaylist> = SearchPagedState(),
|
||||
val users: SearchPagedState<MetadataUser> = SearchPagedState())
|
||||
val users: SearchPagedState<MetadataUser> = SearchPagedState()) {
|
||||
val recentSearchSuggestions: List<String>
|
||||
get() = if (query.isBlank()) {
|
||||
recentSearches
|
||||
} else {
|
||||
recentSearches.filter { it.contains(query, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SearchScreenViewModel(
|
||||
|
||||
@ -18,8 +18,6 @@
|
||||
package dev.krtirtho.spotube.modules.settings.components
|
||||
|
||||
|
||||
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
@ -65,7 +63,8 @@ internal fun SwitchSettingCard(
|
||||
subtitle: String? = null,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
icon: (@Composable () -> Unit)? = null) {
|
||||
icon: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
SettingCardItem(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
@ -74,10 +73,8 @@ internal fun SwitchSettingCard(
|
||||
MillenniumSwitch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
// The row itself already renders the title/subtitle, so the switch must not
|
||||
// draw a second visible label next to it.
|
||||
label = "",
|
||||
contentSpacing = 0.dp)
|
||||
contentSpacing = 0.dp
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onCheckedChange(!checked)
|
||||
@ -96,7 +93,8 @@ internal fun <T> SelectionSettingCard(
|
||||
dialogTitle: String = title,
|
||||
icon: (@Composable () -> Unit)? = null,
|
||||
inlineSelectorMinWidth: Dp = 700.dp,
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null
|
||||
) {
|
||||
var isDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
@ -112,24 +110,29 @@ internal fun <T> SelectionSettingCard(
|
||||
AdaptiveMenuItem(
|
||||
label = optionLabel(option),
|
||||
onClick = { onOptionSelected(option) },
|
||||
selected = option == selectedOption)
|
||||
selected = option == selectedOption
|
||||
)
|
||||
},
|
||||
trigger = { onClick ->
|
||||
MillenniumButton(
|
||||
onClick = onClick,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(optionLabel(selectedOption))
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxArrowDown4,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp))
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
filter = filter)
|
||||
filter = filter
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (isWideLayout) {
|
||||
@ -143,7 +146,8 @@ internal fun <T> SelectionSettingCard(
|
||||
MillenniumDialog(
|
||||
visible = true,
|
||||
onDismissRequest = { isDialogOpen = false },
|
||||
title = dialogTitle) {
|
||||
title = dialogTitle
|
||||
) {
|
||||
MillenniumRadioGroup(
|
||||
value = selectedOption,
|
||||
onValueChange = { option ->
|
||||
@ -155,7 +159,8 @@ internal fun <T> SelectionSettingCard(
|
||||
optionLabel = { option -> labels[options.indexOf(option)] })
|
||||
MillenniumButton(
|
||||
onClick = { isDialogOpen = false },
|
||||
style = MillenniumButtonStyle.Quiet) {
|
||||
style = MillenniumButtonStyle.Quiet
|
||||
) {
|
||||
Text(stringResource(Res.string.settings_action_close))
|
||||
}
|
||||
}
|
||||
@ -177,7 +182,8 @@ internal fun TextInputSettingCard(
|
||||
icon: (@Composable () -> Unit)? = null,
|
||||
inlineTextFieldMinWidth: Dp = 700.dp,
|
||||
inlineTextFieldWidth: Dp = 220.dp,
|
||||
enabled: Boolean = true) {
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
var isDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
@ -204,10 +210,10 @@ internal fun TextInputSettingCard(
|
||||
{
|
||||
MillenniumTextField(
|
||||
state = inlineState,
|
||||
label = title,
|
||||
modifier = Modifier.width(inlineTextFieldWidth),
|
||||
placeholder = placeholder,
|
||||
enabled = enabled)
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
@ -235,19 +241,22 @@ internal fun TextInputSettingCard(
|
||||
MillenniumDialog(
|
||||
visible = true,
|
||||
onDismissRequest = { isDialogOpen = false },
|
||||
title = dialogTitle) {
|
||||
title = dialogTitle
|
||||
) {
|
||||
if (dialogDescription != null) {
|
||||
Text(
|
||||
text = dialogDescription,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
MillenniumTextField(
|
||||
state = dialogState,
|
||||
label = title,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = placeholder,
|
||||
error = errorMessage)
|
||||
error = errorMessage
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
@ -256,12 +265,14 @@ internal fun TextInputSettingCard(
|
||||
isDialogOpen = false
|
||||
}
|
||||
},
|
||||
enabled = errorMessage == null) {
|
||||
enabled = errorMessage == null
|
||||
) {
|
||||
Text(stringResource(Res.string.settings_action_save))
|
||||
}
|
||||
MillenniumButton(
|
||||
onClick = { isDialogOpen = false },
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text(stringResource(Res.string.settings_action_cancel))
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +35,9 @@ import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
||||
fun AppBottombar(
|
||||
navigator: Navigator,
|
||||
navigationState: NavigationState,
|
||||
modifier: Modifier = Modifier) {
|
||||
modifier: Modifier = Modifier,
|
||||
libraryBadge: @Composable () -> Unit = { DownloadBadgeIndicator() }
|
||||
) {
|
||||
MillenniumBottomNavigationBar(modifier = modifier) {
|
||||
bottombarTabs.filterIsInstance<NavigationItem.Tab<*>>()
|
||||
.forEach { tab ->
|
||||
@ -48,14 +50,16 @@ fun AppBottombar(
|
||||
MillenniumIcon(
|
||||
imageVector = tab.icon,
|
||||
contentDescription = null,
|
||||
tint = LocalTextStyle.current.color)
|
||||
tint = LocalTextStyle.current.color
|
||||
)
|
||||
},
|
||||
label = { Text(tab.title) },
|
||||
badge = if (tab.route == Routes.Library) {
|
||||
{ DownloadBadgeIndicator() }
|
||||
libraryBadge
|
||||
} else {
|
||||
null
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -481,12 +481,13 @@ fun AppExpandedPlayer(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center)
|
||||
Text(
|
||||
text = playerUiState.artists,
|
||||
PlayerArtistsText(
|
||||
artists = playerUiState.artistList,
|
||||
onArtistClick = { artistId ->
|
||||
navigationCommands.navigateTo(Routes.Artist(artistId))
|
||||
},
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center)
|
||||
}
|
||||
MillenniumIconButton(
|
||||
|
||||
@ -23,7 +23,8 @@ import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumProgress
|
||||
import org.spotube.millennium.theme.millenniumSurface
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import org.spotube.millennium.theme.millenniumPanelBrush
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
@ -38,43 +39,40 @@ 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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.player_play_content_description
|
||||
import spotube.composeapp.generated.resources.player_pause_content_description
|
||||
import spotube.composeapp.generated.resources.player_nothing_playing
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
|
||||
internal val FloatingPlayerContentHeight = 64.dp
|
||||
internal val FloatingPlayerBottomBleed = 16.dp
|
||||
|
||||
// The floating player on small screens that appears above the floating AppBottombar
|
||||
//
|
||||
// on left the album art, song title, artists grouped together
|
||||
@ -94,17 +92,12 @@ fun AppFloatingPlayer(
|
||||
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
|
||||
key = SAVED_TRACKS_COLLECTION_ID,
|
||||
parameters = { parametersOf() }
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
||||
val scope = rememberCoroutineScope()
|
||||
val albumArtModifier =
|
||||
rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope)
|
||||
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
|
||||
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
|
||||
|
||||
LaunchedEffect(playerUiState.progress) {
|
||||
seekProgress = playerUiState.progress
|
||||
}
|
||||
|
||||
fun onPlayPause() {
|
||||
scope.launch {
|
||||
@ -116,29 +109,58 @@ fun AppFloatingPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
FloatingPlayerContent(
|
||||
playerUiState = playerUiState,
|
||||
modifier = modifier,
|
||||
albumArtModifier = albumArtModifier,
|
||||
onPlayPause = ::onPlayPause,
|
||||
heartButton = {
|
||||
PlayerHeartButton(
|
||||
audioPlayerQueue = audioPlayerQueue,
|
||||
savedTracksViewModel = savedTracksViewModel
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun FloatingPlayerContent(
|
||||
playerUiState: PlayerUiState,
|
||||
modifier: Modifier = Modifier,
|
||||
albumArtModifier: Modifier = Modifier,
|
||||
onPlayPause: () -> Unit,
|
||||
heartButton: @Composable () -> Unit
|
||||
) {
|
||||
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(76.dp)
|
||||
.height(FloatingPlayerContentHeight + FloatingPlayerBottomBleed)
|
||||
.millenniumSurface(
|
||||
background = SolidColor(AppTheme.colors.inverseSurface),
|
||||
shape = RoundedCornerShape(24.dp, 24.dp))) {
|
||||
Box(
|
||||
background = millenniumPanelBrush(MillenniumTheme.colors),
|
||||
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||
borderColor = MillenniumTheme.colors.border,
|
||||
borderWidth = 1.dp,
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()) {
|
||||
.fillMaxSize()
|
||||
// Only the surface extends beneath the navigation bar, not the controls.
|
||||
.padding(bottom = FloatingPlayerBottomBleed)
|
||||
) {
|
||||
MillenniumProgress(
|
||||
progress = seekProgress.coerceIn(0f, 1f),
|
||||
progress = playerUiState.progress,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp)
|
||||
.padding(bottom = 22.dp, top = 8.dp)
|
||||
.align(Alignment.Center),
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@ -148,30 +170,36 @@ fun AppFloatingPlayer(
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.size(42.dp)
|
||||
.then(albumArtModifier)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(AppTheme.colors.onSurface.copy(alpha = 0.12f))
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(AppTheme.colors.onSurface.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AsyncImage(
|
||||
if (coverModel != null) AsyncImage(
|
||||
model = coverModel,
|
||||
contentDescription = playerUiState.title,
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop)
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
else MillenniumIcon(
|
||||
Iconsax.IconsaxMusic, contentDescription = null,
|
||||
tint = AppTheme.colors.onSurfaceVariant, modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Text(
|
||||
text = playerUiState.title,
|
||||
text = if (playerUiState.queue.isEmpty()) stringResource(Res.string.player_nothing_playing) else playerUiState.title,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.inverseOnSurface,
|
||||
color = AppTheme.colors.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
if (playerUiState.queue.isNotEmpty()) Text(
|
||||
text = playerUiState.artists,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.inverseOnSurface.copy(alpha = 0.72f),
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@ -179,17 +207,17 @@ fun AppFloatingPlayer(
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
PlayerHeartButton(
|
||||
audioPlayerQueue = audioPlayerQueue,
|
||||
savedTracksViewModel = savedTracksViewModel)
|
||||
heartButton()
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
MillenniumIconButton(
|
||||
onClick = ::onPlayPause,
|
||||
onClick = onPlayPause,
|
||||
enabled = playerUiState.queue.isNotEmpty(),
|
||||
style = MillenniumIconButtonStyle.Secondary) {
|
||||
style = MillenniumIconButtonStyle.Secondary
|
||||
) {
|
||||
MillenniumIcon(
|
||||
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play")
|
||||
contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -201,12 +229,14 @@ fun AppFloatingPlayer(
|
||||
@Composable
|
||||
private fun rememberSharedAlbumArtModifier(
|
||||
sharedTransitionScope: SharedTransitionScope?,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?): Modifier {
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?
|
||||
): Modifier {
|
||||
if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier
|
||||
|
||||
with(sharedTransitionScope) {
|
||||
return Modifier.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(key = "player_album_art"),
|
||||
animatedVisibilityScope = animatedVisibilityScope)
|
||||
animatedVisibilityScope = animatedVisibilityScope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,19 +252,24 @@ fun AppLargePlayer(
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = 12.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.padding(start = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = playerUiState.title,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = playerUiState.artists,
|
||||
PlayerArtistsText(
|
||||
artists = playerUiState.artistList,
|
||||
onArtistClick = { artistId ->
|
||||
navigationCommands.navigateTo(Routes.Artist(artistId))
|
||||
},
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
@ -20,21 +20,25 @@ package dev.krtirtho.spotube.modules.shell
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.rememberLogger
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
internal data class PlayerArtist(
|
||||
val id: String?,
|
||||
val name: String
|
||||
)
|
||||
|
||||
internal data class PlayerUiState(
|
||||
val queue: List<QueueEntry> = emptyList(),
|
||||
val currentQueueEntry: QueueEntry? = null,
|
||||
val title: String = "No-op track title",
|
||||
val artists: String = "No-op artists",
|
||||
val artistList: List<PlayerArtist> = emptyList(),
|
||||
val album: String = "",
|
||||
val coverUrl: String = "",
|
||||
val position: Duration = 0.milliseconds,
|
||||
@ -43,7 +47,8 @@ internal data class PlayerUiState(
|
||||
val playerState: PlayerState = PlayerState.IDLE,
|
||||
val loopState: LoopState = LoopState.NONE,
|
||||
val isShuffling: Boolean = false,
|
||||
val volume: Float = 0f) {
|
||||
val volume: Float = 0f
|
||||
) {
|
||||
val isPlaying: Boolean get() = playerState == PlayerState.PLAYING
|
||||
val displayDuration: Duration
|
||||
get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration
|
||||
@ -73,7 +78,8 @@ internal data class PlayerUiState(
|
||||
@Composable
|
||||
internal fun rememberPlayerUiState(
|
||||
audioPlayer: AudioPlayerInterface,
|
||||
audioPlayerQueue: AudioPlayerQueue): PlayerUiState {
|
||||
audioPlayerQueue: AudioPlayerQueue
|
||||
): PlayerUiState {
|
||||
val queue by audioPlayerQueue.queueFlow.collectAsState(initial = emptyList())
|
||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsState(initial = null)
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsState()
|
||||
@ -89,6 +95,7 @@ internal fun rememberPlayerUiState(
|
||||
currentQueueEntry = currentQueueEntry,
|
||||
title = currentQueueEntry?.displayTitle().orEmpty().ifBlank { "No-op track title" },
|
||||
artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { "No-op artists" },
|
||||
artistList = currentQueueEntry?.displayArtistList().orEmpty(),
|
||||
album = currentQueueEntry?.displayAlbum().orEmpty(),
|
||||
coverUrl = currentQueueEntry?.coverUrl().orEmpty(),
|
||||
position = position,
|
||||
@ -97,7 +104,8 @@ internal fun rememberPlayerUiState(
|
||||
playerState = playerState,
|
||||
loopState = loopState,
|
||||
isShuffling = isShuffling,
|
||||
volume = volume)
|
||||
volume = volume
|
||||
)
|
||||
|
||||
// val logger = rememberLogger<PlayerUiState>()
|
||||
// logger.d { state.toDebugString() }
|
||||
@ -118,6 +126,14 @@ private fun QueueEntry.displayArtists(): String {
|
||||
}
|
||||
}
|
||||
|
||||
private fun QueueEntry.displayArtistList(): List<PlayerArtist> {
|
||||
return when (this) {
|
||||
is QueueEntry.StreamingTrack -> track.artists.map { PlayerArtist(it.id, it.name) }
|
||||
// Local tracks have no artist ids, so they are shown but not navigable.
|
||||
is QueueEntry.LocalTrack -> artists.map { PlayerArtist(id = null, name = it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun QueueEntry.displayAlbum(): String {
|
||||
return when (this) {
|
||||
is QueueEntry.StreamingTrack -> track.album?.title ?: "Unknown Album"
|
||||
|
||||
@ -30,13 +30,13 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@ -84,7 +85,8 @@ fun AppShell(
|
||||
viewModel: AppShellViewModel = koinViewModel<AppShellViewModel>(),
|
||||
queueViewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(),
|
||||
alternativeViewModel: AlternativeTrackContentViewModel = koinViewModel<AlternativeTrackContentViewModel>(),
|
||||
content: @Composable () -> Unit) {
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val navigatorCommands: NavigationCommands = koinInject()
|
||||
val remotePlaybackController: RemotePlaybackController = koinInject()
|
||||
val snackbarHostState = remember { MillenniumSnackbarHostState() }
|
||||
@ -128,7 +130,13 @@ fun AppShell(
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val useSidebar = viewModel.useSidebar()
|
||||
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)
|
||||
val chromeUiState by viewModel.compactChromeUiState.collectAsState()
|
||||
val density = LocalDensity.current
|
||||
val navigationBarInset = with(density) {
|
||||
WindowInsets.navigationBars.getBottom(this).toDp()
|
||||
}
|
||||
val bottomOverlayInset =
|
||||
viewModel.bottomOverlayInset(useSidebar, navigationBarInset, chromeUiState)
|
||||
|
||||
CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) {
|
||||
if (useSidebar) {
|
||||
@ -150,8 +158,11 @@ fun AppShell(
|
||||
)
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
vertical = true)
|
||||
Box(modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)) {
|
||||
vertical = true
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@ -184,19 +195,23 @@ fun AppShell(
|
||||
QueueSheet(
|
||||
isVisible = isQueueVisible,
|
||||
onDismiss = { queueViewModel.setQueueVisibility(false) },
|
||||
modifier = Modifier.fillMaxSize()) {
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
PlayerQueueContent(
|
||||
viewModel = queueViewModel,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
AlternativeTrackSheet(
|
||||
isVisible = isAlternativeVisible,
|
||||
onDismiss = { alternativeViewModel.setAlternativeVisibility(false) },
|
||||
modifier = Modifier.fillMaxSize()) {
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
AlternativeTrackContent(
|
||||
viewModel = alternativeViewModel,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -204,7 +219,8 @@ fun AppShell(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MillenniumTheme.colors.background))
|
||||
.background(MillenniumTheme.colors.background)
|
||||
)
|
||||
CompactPlayerOverlay(
|
||||
navigator = navigator,
|
||||
navigationState = navigationState,
|
||||
@ -212,7 +228,8 @@ fun AppShell(
|
||||
onQueue = queueViewModel::toggleQueueVisibility,
|
||||
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility,
|
||||
onExpandLyrics = viewModel::showLyricsOverlay,
|
||||
content = content)
|
||||
content = content
|
||||
)
|
||||
|
||||
QueueSheet(
|
||||
isVisible = isQueueVisible,
|
||||
@ -221,16 +238,19 @@ fun AppShell(
|
||||
) {
|
||||
PlayerQueueContent(
|
||||
viewModel = queueViewModel,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
AlternativeTrackSheet(
|
||||
isVisible = isAlternativeVisible,
|
||||
onDismiss = { alternativeViewModel.setAlternativeVisibility(false) },
|
||||
modifier = Modifier.fillMaxSize()) {
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
AlternativeTrackContent(
|
||||
viewModel = alternativeViewModel,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
if (isLyricsOverlayVisible) {
|
||||
@ -239,8 +259,13 @@ fun AppShell(
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true)) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) {
|
||||
dismissOnClickOutside = true
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
.background(MillenniumTheme.colors.background)
|
||||
) {
|
||||
LyricsScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onClose = { viewModel.hideLyricsOverlay() })
|
||||
@ -257,38 +282,56 @@ fun AppShell(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = bottomOverlayInset + 12.dp))
|
||||
.padding(bottom = bottomOverlayInset + 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
|
||||
ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class
|
||||
)
|
||||
@Composable
|
||||
private fun CompactPlayerOverlay(
|
||||
internal fun CompactPlayerOverlay(
|
||||
navigator: Navigator,
|
||||
navigationState: NavigationState,
|
||||
viewModel: AppShellViewModel,
|
||||
onQueue: () -> Unit,
|
||||
onAlternativeSource: () -> Unit,
|
||||
onExpandLyrics: () -> Unit,
|
||||
content: @Composable () -> Unit) {
|
||||
floatingPlayer: @Composable (Modifier) -> Unit = { AppFloatingPlayer(modifier = it) },
|
||||
bottomBar: @Composable (Modifier) -> Unit = {
|
||||
AppBottombar(navigator = navigator, navigationState = navigationState, modifier = it)
|
||||
},
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val chromeUiState by viewModel.compactChromeUiState.collectAsState()
|
||||
|
||||
val floatingPlayerHeightPx = with(density) { viewModel.floatingPlayerHeight.toPx() }
|
||||
val bottomBarHeightPx = with(density) { viewModel.bottomBarHeight.toPx() }
|
||||
val navBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat()
|
||||
val peekHeightPx = floatingPlayerHeightPx + bottomBarHeightPx + navBarInsetPx
|
||||
val bottomBarHeightPx = with(density) {
|
||||
chromeUiState.measuredBottomBarHeight?.toPx()
|
||||
?: (viewModel.bottomBarHeight.toPx() + navBarInsetPx)
|
||||
}
|
||||
val peekHeightPx = floatingPlayerHeightPx + bottomBarHeightPx
|
||||
val peekHeight = with(density) { peekHeightPx.toDp() }
|
||||
|
||||
val partialDetent = remember(peekHeight) {
|
||||
SheetDetent("player-peek") { _, _ -> peekHeight }
|
||||
}
|
||||
val sheetState = rememberBottomSheetState(
|
||||
initialDetent = partialDetent,
|
||||
detents = listOf(partialDetent, SheetDetent.FullyExpanded))
|
||||
// The library remembers its detent definitions at creation. Recreate the state when
|
||||
// the measured chrome height changes, rather than keeping a stale peek anchor.
|
||||
val sheetState = key(peekHeight) {
|
||||
rememberBottomSheetState(
|
||||
initialDetent = partialDetent,
|
||||
detents = listOf(partialDetent, SheetDetent.FullyExpanded)
|
||||
)
|
||||
}
|
||||
|
||||
val progress = runCatching {
|
||||
val progress = if (!sheetState.offset.isFinite() ||
|
||||
(sheetState.isIdle && sheetState.currentDetent == partialDetent)
|
||||
) 0f else runCatching {
|
||||
sheetState.progress(partialDetent, SheetDetent.FullyExpanded)
|
||||
}.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f
|
||||
val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f))
|
||||
@ -298,7 +341,8 @@ private fun CompactPlayerOverlay(
|
||||
|
||||
UnstyledBottomSheet(
|
||||
state = sheetState,
|
||||
modifier = Modifier.fillMaxSize()) {
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Sheet(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (uiState.showExpandedPlayer) {
|
||||
@ -311,14 +355,14 @@ private fun CompactPlayerOverlay(
|
||||
},
|
||||
onQueue = onQueue,
|
||||
onAlternativeSource = onAlternativeSource,
|
||||
onExpandLyrics = onExpandLyrics)
|
||||
onExpandLyrics = onExpandLyrics
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.floatingPlayerAlpha > 0.01f) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
AppFloatingPlayer(
|
||||
modifier = Modifier
|
||||
.offset(y = 12.dp)
|
||||
floatingPlayer(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
alpha = uiState.floatingPlayerAlpha
|
||||
})
|
||||
@ -329,12 +373,16 @@ private fun CompactPlayerOverlay(
|
||||
}
|
||||
|
||||
if (uiState.floatingPlayerAlpha > 0.01f) {
|
||||
AppBottombar(
|
||||
navigator = navigator,
|
||||
navigationState = navigationState,
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).graphicsLayer {
|
||||
alpha = uiState.floatingPlayerAlpha
|
||||
})
|
||||
bottomBar(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.onSizeChanged { size ->
|
||||
viewModel.onBottomBarMeasured(with(density) { size.height.toDp() })
|
||||
}
|
||||
.graphicsLayer {
|
||||
alpha = uiState.floatingPlayerAlpha
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,30 +35,34 @@ data class CompactSheetUiState(
|
||||
val showExpandedPlayer: Boolean,
|
||||
val expandedPlayerAlpha: Float,
|
||||
val floatingPlayerAlpha: Float,
|
||||
val bottomBarAlpha: Float)
|
||||
val bottomBarAlpha: Float
|
||||
)
|
||||
|
||||
data class LyricsOverlayState(
|
||||
val isVisible: Boolean = false)
|
||||
val isVisible: Boolean = false
|
||||
)
|
||||
|
||||
data class CompactChromeUiState(val measuredBottomBarHeight: Dp? = null)
|
||||
|
||||
class AppShellViewModel : ViewModel() {
|
||||
val sidebarMinWidth = 840.dp
|
||||
val bottomBarEstimatedHeight = 58.dp
|
||||
val bottomBarHeight = 80.dp
|
||||
val floatingPlayerHeight = 74.dp
|
||||
// The background bleed overlaps the bottom bar and is not part of the peek height.
|
||||
val floatingPlayerHeight = FloatingPlayerContentHeight
|
||||
val largePlayerInset = 100.dp
|
||||
val floatingPlayerDismissProgress = 0.7f
|
||||
val expandedPlayerVisibilityThreshold = 0.001f
|
||||
|
||||
private val lyricsOverlayVisible = MutableStateFlow(false)
|
||||
private val compactChrome = MutableStateFlow(CompactChromeUiState())
|
||||
val compactChromeUiState = compactChrome.asStateFlow()
|
||||
|
||||
fun onBottomBarMeasured(height: Dp) {
|
||||
if (height > 0.dp) compactChrome.value = CompactChromeUiState(height)
|
||||
}
|
||||
|
||||
val isLyricsOverlayVisible: StateFlow<Boolean> = lyricsOverlayVisible.asStateFlow()
|
||||
|
||||
val floatingPlayerBottomOffset: Dp
|
||||
get() = bottomBarEstimatedHeight + 8.dp
|
||||
|
||||
val compactChromeInset: Dp
|
||||
get() = floatingPlayerBottomOffset + floatingPlayerHeight + 10.dp
|
||||
|
||||
@Composable
|
||||
fun useSidebar(maxWidth: Dp? = null): Boolean {
|
||||
if (maxWidth != null) {
|
||||
@ -67,8 +71,14 @@ class AppShellViewModel : ViewModel() {
|
||||
return currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded
|
||||
}
|
||||
|
||||
fun bottomOverlayInset(useSidebar: Boolean): Dp =
|
||||
if (useSidebar) largePlayerInset else compactChromeInset
|
||||
fun bottomOverlayInset(
|
||||
useSidebar: Boolean,
|
||||
navigationBarInset: Dp,
|
||||
chrome: CompactChromeUiState
|
||||
): Dp =
|
||||
if (useSidebar) largePlayerInset + navigationBarInset
|
||||
else (chrome.measuredBottomBarHeight ?: (bottomBarHeight + navigationBarInset)) +
|
||||
floatingPlayerHeight
|
||||
|
||||
fun sheetProgress(sheetOffsetPx: Float, sheetHeightPx: Float): Float =
|
||||
if (sheetHeightPx > 0f) {
|
||||
@ -88,7 +98,8 @@ class AppShellViewModel : ViewModel() {
|
||||
showExpandedPlayer = progress > expandedPlayerVisibilityThreshold,
|
||||
expandedPlayerAlpha = progress,
|
||||
floatingPlayerAlpha = floatingPlayerAlpha(progress),
|
||||
bottomBarAlpha = 1f - progress)
|
||||
bottomBarAlpha = 1f - progress
|
||||
)
|
||||
}
|
||||
|
||||
fun showLyricsOverlay() {
|
||||
|
||||
@ -50,10 +50,14 @@ import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
|
||||
import dev.krtirtho.spotube.sidebarTabs
|
||||
import org.jetbrains.compose.resources.Font
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumSidebar
|
||||
import org.spotube.millennium.composables.MillenniumSidebarContent
|
||||
import org.spotube.millennium.composables.MillenniumSidebarFooter
|
||||
@ -89,7 +93,7 @@ fun AppSidebar(
|
||||
) {
|
||||
MillenniumSidebarHeader {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@ -104,10 +108,19 @@ fun AppSidebar(
|
||||
color = AppTheme.colors.onSurface
|
||||
)
|
||||
}
|
||||
MillenniumSidebarTrigger()
|
||||
MillenniumSidebarTrigger {
|
||||
MillenniumIconButton(
|
||||
if (it.collapsed) Iconsax.IconsaxSidebarRightBroken else Iconsax.IconsaxSidebarLeftBroken,
|
||||
contentDescription = "Toggle Sidebar",
|
||||
style = MillenniumIconButtonStyle.Quiet,
|
||||
onClick = { it.toggle() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
MillenniumSidebarContent {
|
||||
MillenniumSidebarContent(
|
||||
contentSpacing = 0.dp
|
||||
) {
|
||||
sidebarTabs.forEach { entry ->
|
||||
when (entry) {
|
||||
is NavigationItem.Group<*> -> {
|
||||
@ -115,13 +128,17 @@ fun AppSidebar(
|
||||
MillenniumSidebarGroup(
|
||||
expanded = isGroupExpanded,
|
||||
collapsible = true,
|
||||
groupSpacing = 0.dp,
|
||||
onExpandedChange = { groupExpansion[entry.title] = it }) {
|
||||
MillenniumSidebarGroupLabel(entry.title)
|
||||
MillenniumSidebarGroupContent {
|
||||
MillenniumSidebarMenu {
|
||||
MillenniumSidebarGroupContent(
|
||||
contentSpacing = 0.dp
|
||||
) {
|
||||
MillenniumSidebarMenu(contentSpacing = 0.dp) {
|
||||
entry.items.forEach { item ->
|
||||
val selected = navigationState.topLevelRoute == item.route &&
|
||||
(item.route != Routes.Library || currentLibraryTab == item.data)
|
||||
val selected =
|
||||
navigationState.topLevelRoute == item.route &&
|
||||
(item.route != Routes.Library || currentLibraryTab == item.data)
|
||||
SidebarNavItem(
|
||||
item = item,
|
||||
selected = selected,
|
||||
@ -154,13 +171,15 @@ fun AppSidebar(
|
||||
label = supportLabel,
|
||||
onClick = { openSupport() },
|
||||
contentDescription = supportLabel,
|
||||
icon = Iconsax.IconsaxHeart2)
|
||||
icon = Iconsax.IconsaxHeart2
|
||||
)
|
||||
MillenniumSidebarItem(
|
||||
label = stringResource(Res.string.settings_screen_title),
|
||||
onClick = { navigator.navigate(Routes.Settings) },
|
||||
selected = navigationState.topLevelRoute == Routes.Settings,
|
||||
contentDescription = stringResource(Res.string.settings_screen_title),
|
||||
icon = Iconsax.IconsaxSetting2)
|
||||
icon = Iconsax.IconsaxSetting2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -169,11 +188,13 @@ fun AppSidebar(
|
||||
private fun SidebarNavItem(
|
||||
item: NavigationItem.Tab<*>,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit) {
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
MillenniumSidebarItem(
|
||||
label = item.title,
|
||||
onClick = onClick,
|
||||
selected = selected,
|
||||
contentDescription = item.title,
|
||||
icon = item.icon)
|
||||
icon = item.icon
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.shell
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withLink
|
||||
import com.composeunstyled.Text
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
|
||||
/**
|
||||
* Renders a track's artists on a single, ellipsized line. Artists that have an [PlayerArtist.id]
|
||||
* are tappable links that open their artist page; local tracks (no ids) render as plain text.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PlayerArtistsText(
|
||||
artists: List<PlayerArtist>,
|
||||
onArtistClick: (String) -> Unit,
|
||||
style: TextStyle,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
textAlign: TextAlign = TextAlign.Unspecified) {
|
||||
if (artists.isEmpty()) return
|
||||
val hoverColor = AppTheme.colors.primary
|
||||
val annotatedArtists = buildAnnotatedString {
|
||||
artists.forEachIndexed { index, artist ->
|
||||
if (index > 0) append(", ")
|
||||
val artistId = artist.id
|
||||
if (artistId == null) {
|
||||
append(artist.name)
|
||||
} else {
|
||||
withLink(
|
||||
LinkAnnotation.Clickable(
|
||||
tag = artistId,
|
||||
styles = TextLinkStyles(
|
||||
style = SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
hoveredStyle = SpanStyle(
|
||||
color = hoverColor,
|
||||
textDecoration = TextDecoration.Underline),
|
||||
),
|
||||
linkInteractionListener = { onArtistClick(artistId) },
|
||||
)
|
||||
) {
|
||||
append(artist.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = annotatedArtists,
|
||||
modifier = modifier,
|
||||
style = style,
|
||||
color = color,
|
||||
textAlign = textAlign,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
@ -34,6 +34,9 @@ import org.spotube.millennium.composables.MillenniumModalBottomSheet
|
||||
import org.spotube.millennium.composables.rememberMillenniumDrawerState
|
||||
|
||||
private val SlidingSheetBreakpoint = 840.dp
|
||||
private val MaxHeightSheetDetent = SheetDetent("max-height-85-percent") { containerHeight, sheetHeight ->
|
||||
minOf(sheetHeight, containerHeight * 0.85f)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QueueSheet(
|
||||
@ -85,7 +88,10 @@ internal fun BottomSheetContent(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
if (!isVisible) return
|
||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
initialDetent = MaxHeightSheetDetent,
|
||||
detents = listOf(SheetDetent.Hidden, MaxHeightSheetDetent)
|
||||
)
|
||||
LaunchedEffect(sheetState.bottomSheetState.currentDetent) {
|
||||
if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) {
|
||||
onDismiss()
|
||||
|
||||
@ -39,5 +39,24 @@ class JVMPlatform : Platform {
|
||||
actual fun getPlatform(): Platform = JVMPlatform()
|
||||
|
||||
actual fun openUrlInBrowser(url: String) {
|
||||
Desktop.getDesktop().browse(URI(url))
|
||||
// The app runs on a native (Tao) window, not an AWT frame, so java.awt.Desktop is not a
|
||||
// reliable way to hand a URL to the OS. Prefer the platform's own opener process and only
|
||||
// fall back to AWT when no opener is available on PATH.
|
||||
if (openWithSystemOpener(url)) return
|
||||
runCatching { Desktop.getDesktop().browse(URI(url)) }
|
||||
}
|
||||
|
||||
private fun openWithSystemOpener(url: String): Boolean {
|
||||
val osName = System.getProperty("os.name").lowercase()
|
||||
val command = when {
|
||||
osName.contains("win") -> listOf("rundll32", "url.dll,FileProtocolHandler", url)
|
||||
osName.contains("mac") -> listOf("open", url)
|
||||
osName.contains("nux") || osName.contains("nix") || osName.contains("aix") ->
|
||||
listOf("xdg-open", url)
|
||||
else -> return false
|
||||
}
|
||||
return runCatching {
|
||||
ProcessBuilder(command).start()
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@ -0,0 +1,192 @@
|
||||
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
package dev.krtirtho.spotube.modules.jam
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.use
|
||||
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
|
||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
|
||||
import dev.krtirtho.spotube.modules.settings.Theme
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
||||
import dev.krtirtho.spotube.modules.shell.FloatingPlayerContent
|
||||
import dev.krtirtho.spotube.modules.shell.FloatingPlayerBottomBleed
|
||||
import dev.krtirtho.spotube.modules.shell.PlayerUiState
|
||||
import dev.krtirtho.spotube.modules.shell.AppBottombar
|
||||
import dev.krtirtho.spotube.modules.shell.CompactPlayerOverlay
|
||||
import org.spotube.millennium.composables.MillenniumTopAppBar
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import com.composeunstyled.Text
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.jam_screen_title
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class JamLayoutTest {
|
||||
@Test
|
||||
fun measuredDockHeightIncludesSystemInsetExactlyOnce() {
|
||||
val viewModel = AppShellViewModel()
|
||||
viewModel.onBottomBarMeasured(76.dp)
|
||||
assertEquals(
|
||||
140.dp,
|
||||
viewModel.bottomOverlayInset(false, 0.dp, viewModel.compactChromeUiState.value)
|
||||
)
|
||||
viewModel.onBottomBarMeasured(100.dp)
|
||||
assertEquals(
|
||||
164.dp,
|
||||
viewModel.bottomOverlayInset(false, 24.dp, viewModel.compactChromeUiState.value)
|
||||
)
|
||||
viewModel.onBottomBarMeasured(0.dp)
|
||||
assertEquals(100.dp, viewModel.compactChromeUiState.value.measuredBottomBarHeight)
|
||||
}
|
||||
|
||||
/** Actual Compose controls rendered without native-window capture or an MQTT connection. */
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Test
|
||||
fun renderRoomControlsAtNarrowAndWideSizes() {
|
||||
val output = File("build/reports/jam-ui").apply { mkdirs() }
|
||||
val states = mapOf(
|
||||
"create" to JamUiState(
|
||||
brokerConfigured = true,
|
||||
brokerHost = "test.mosquitto.org",
|
||||
canCreate = true
|
||||
),
|
||||
"join" to JamUiState(
|
||||
brokerConfigured = true,
|
||||
brokerHost = "test.mosquitto.org",
|
||||
setupMode = JamSetupMode.Join
|
||||
),
|
||||
"host" to JamUiState(
|
||||
isActive = true,
|
||||
isConnected = true,
|
||||
isHost = true,
|
||||
roomCode = "RC5FJ8",
|
||||
brokerConfigured = true,
|
||||
brokerHost = "test.mosquitto.org",
|
||||
participants = listOf(
|
||||
JamParticipantUi("host", "Host-8316", "H", true, false),
|
||||
JamParticipantUi("guest", "Guest-6684", "G", false, true)
|
||||
)
|
||||
),
|
||||
"guest" to JamUiState(
|
||||
isActive = true,
|
||||
isConnected = true,
|
||||
roomCode = "RC5FJ8",
|
||||
brokerConfigured = true,
|
||||
brokerHost = "test.mosquitto.org",
|
||||
participants = listOf(JamParticipantUi("host", "Host-8316", "H", true, false))
|
||||
)
|
||||
)
|
||||
for (width in listOf(360, 920)) {
|
||||
for ((name, state) in states) {
|
||||
var playerBottom = Float.NaN
|
||||
var navigationTop = Float.NaN
|
||||
var navigationBottom = Float.NaN
|
||||
ImageComposeScene(width, 760) {
|
||||
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(MillenniumTheme.colors.background),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
MillenniumTopAppBar(title = { Text(stringResource(Res.string.jam_screen_title)) })
|
||||
val navigation = rememberNavigationState(Routes.Home, TOP_LEVEL_ROUTES)
|
||||
val navigator = remember(navigation) { Navigator(navigation) }
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
CompactPlayerOverlay(
|
||||
navigator = navigator,
|
||||
navigationState = navigation,
|
||||
viewModel = remember { AppShellViewModel() },
|
||||
onQueue = {}, onAlternativeSource = {}, onExpandLyrics = {},
|
||||
floatingPlayer = { modifier ->
|
||||
FloatingPlayerContent(
|
||||
PlayerUiState(),
|
||||
modifier = modifier.onGloballyPositioned {
|
||||
playerBottom = it.boundsInRoot().bottom
|
||||
},
|
||||
onPlayPause = {},
|
||||
heartButton = {})
|
||||
},
|
||||
bottomBar = { modifier ->
|
||||
AppBottombar(
|
||||
navigator,
|
||||
navigation,
|
||||
modifier = modifier.onGloballyPositioned {
|
||||
navigationTop = it.boundsInRoot().top
|
||||
navigationBottom = it.boundsInRoot().bottom
|
||||
},
|
||||
libraryBadge = {})
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
Modifier.widthIn(max = 520.dp).fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (state.isActive) SessionPanel(
|
||||
state,
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{})
|
||||
else SetupPanel(state, {}, {}, {})
|
||||
BrokerStatus(state, {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.use { scene ->
|
||||
repeat(8) { scene.render().close() }
|
||||
assertTrue(playerBottom.isFinite())
|
||||
assertEquals(
|
||||
playerBottom - FloatingPlayerBottomBleed.value,
|
||||
navigationTop,
|
||||
"Only the player's bottom surface bleed should overlap navigation"
|
||||
)
|
||||
assertEquals(
|
||||
760f,
|
||||
navigationBottom,
|
||||
"Navigation must reach the window's bottom edge"
|
||||
)
|
||||
scene.render().use { image ->
|
||||
val data = assertNotNull(image.encodeToData())
|
||||
data.use { File(output, "$name-$width.png").writeBytes(it.bytes) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
package dev.krtirtho.spotube.modules.library
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.SemanticsNode
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.use
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
|
||||
import dev.krtirtho.spotube.modules.settings.Theme
|
||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.spotube.millennium.composables.MillenniumTopAppBar
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LibraryToolbarTest {
|
||||
private lateinit var library: LibraryState
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
library = LibraryState()
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun cleanup() {
|
||||
library.viewModelScope.cancel()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closingSearchClearsTheActiveFilter() = runTest {
|
||||
library.openSearch()
|
||||
library.uiState.value.searchState.setTextAndPlaceCursorAtEnd("Muse")
|
||||
Snapshot.sendApplyNotifications()
|
||||
runCurrent()
|
||||
assertTrue(library.uiState.value.searchMode)
|
||||
assertEquals("Muse", library.queryFlow(LibraryTab.Playlists).first())
|
||||
|
||||
library.closeSearch()
|
||||
Snapshot.sendApplyNotifications()
|
||||
runCurrent()
|
||||
assertFalse(library.uiState.value.searchMode)
|
||||
assertEquals("", library.uiState.value.searchState.text.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eachTabRetainsItsOwnFilterAndActions() = runTest {
|
||||
library.setQuery(LibraryTab.Playlists, "Rock")
|
||||
library.onTabSelected(LibraryTab.Albums)
|
||||
library.setQuery(LibraryTab.Albums, "Jazz")
|
||||
Snapshot.sendApplyNotifications()
|
||||
runCurrent()
|
||||
assertEquals("Jazz", library.uiState.value.searchState.text.toString())
|
||||
assertFalse(library.uiState.value.canCreatePlaylist)
|
||||
|
||||
library.onTabSelected(LibraryTab.Playlists)
|
||||
runCurrent()
|
||||
assertEquals("Rock", library.uiState.value.searchState.text.toString())
|
||||
assertTrue(library.uiState.value.canCreatePlaylist)
|
||||
library.showCreatePlaylist()
|
||||
runCurrent()
|
||||
assertTrue(library.uiState.value.showCreatePlaylist)
|
||||
|
||||
library.onTabSelected(LibraryTab.LocalTracks)
|
||||
runCurrent()
|
||||
assertTrue(library.uiState.value.canRefresh)
|
||||
assertFalse(library.uiState.value.showCreatePlaylist)
|
||||
library.onTabSelected(LibraryTab.Downloads)
|
||||
runCurrent()
|
||||
assertFalse(library.uiState.value.canFilter)
|
||||
assertFalse(library.uiState.value.searchMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInlineDesktopQueryRemainsVisibleWhenTheLayoutBecomesCompact() = runTest {
|
||||
library.setQuery(LibraryTab.Artists, "Radiohead")
|
||||
library.onTabSelected(LibraryTab.Artists)
|
||||
Snapshot.sendApplyNotifications()
|
||||
runCurrent()
|
||||
assertTrue(library.uiState.value.searchMode)
|
||||
assertEquals("Radiohead", library.uiState.value.searchState.text.toString())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Test
|
||||
fun renderExpandedCompactAndSearchToolbars() {
|
||||
val output = File("build/reports/library-toolbar").apply { mkdirs() }
|
||||
for (tab in listOf(LibraryTab.Playlists, LibraryTab.Albums, LibraryTab.LocalTracks)) {
|
||||
for ((mode, width) in listOf(
|
||||
"desktop" to 575,
|
||||
"mobile" to 360,
|
||||
"search" to 360,
|
||||
"compact-desktop" to 392,
|
||||
"compact-desktop-search" to 392
|
||||
)) {
|
||||
val expandedDesktop = mode == "desktop"
|
||||
val searching = mode.contains("search")
|
||||
val state = LibraryToolbarUiState(
|
||||
currentTab = tab,
|
||||
searchMode = searching,
|
||||
searchState = TextFieldState(if (searching) "Muse" else ""),
|
||||
canCreatePlaylist = tab == LibraryTab.Playlists,
|
||||
canRefresh = tab == LibraryTab.LocalTracks
|
||||
)
|
||||
ImageComposeScene(width, 88) {
|
||||
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
|
||||
MillenniumTopAppBar(
|
||||
title = { LibraryToolbarTitle(state, expandedDesktop) },
|
||||
actions = {
|
||||
LibraryToolbarActions(state, expandedDesktop, {}, {}, {}, {})
|
||||
if (expandedDesktop || mode.startsWith("compact-desktop")) Spacer(
|
||||
Modifier.width(132.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}.use { scene ->
|
||||
repeat(8) { scene.render().close() }
|
||||
fun flatten(node: SemanticsNode): List<SemanticsNode> =
|
||||
listOf(node) + node.children.flatMap(::flatten)
|
||||
|
||||
val nodes =
|
||||
scene.semanticsOwners.flatMap { flatten(it.unmergedRootSemanticsNode) }
|
||||
val descriptions = nodes.flatMap {
|
||||
it.config.getOrNull(SemanticsProperties.ContentDescription).orEmpty()
|
||||
}
|
||||
val textFields =
|
||||
nodes.filter { it.config.getOrNull(SemanticsProperties.EditableText) != null }
|
||||
assertEquals(if (expandedDesktop || searching) 1 else 0, textFields.size)
|
||||
if (searching) {
|
||||
assertTrue("Close filter" in descriptions)
|
||||
assertFalse("Create playlist" in descriptions)
|
||||
assertFalse("Refresh local tracks" in descriptions)
|
||||
assertEquals(
|
||||
"Muse",
|
||||
textFields.single().config[SemanticsProperties.EditableText].text
|
||||
)
|
||||
} else {
|
||||
assertEquals(tab == LibraryTab.Playlists, "Create playlist" in descriptions)
|
||||
assertEquals(
|
||||
tab == LibraryTab.LocalTracks,
|
||||
"Refresh local tracks" in descriptions
|
||||
)
|
||||
}
|
||||
textFields.forEach { node ->
|
||||
assertTrue(
|
||||
node.boundsInRoot.left >= 0f && node.boundsInRoot.right <= width,
|
||||
"Filter must fit beside desktop window controls"
|
||||
)
|
||||
}
|
||||
scene.render().use { image ->
|
||||
assertNotNull(image.encodeToData()).use {
|
||||
File(
|
||||
output,
|
||||
"${tab.name}-$mode.png"
|
||||
).writeBytes(it.bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,8 @@
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import org.spotube.millennium.composables.MillenniumTopAppBar
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -33,10 +35,11 @@ actual fun ApplicationMainBar(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
actions = actions,
|
||||
windowInsets = WindowInsets.statusBars,
|
||||
navigationIcon = {
|
||||
if (backButton) {
|
||||
ApplicationBackButton()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,12 +19,19 @@
|
||||
kotlin.code.style=official
|
||||
kotlin.daemon.jvmargs=-Xmx3072M
|
||||
kotlin.mpp.stability.nowarn=true
|
||||
kotlin.incremental=true
|
||||
|
||||
#Gradle
|
||||
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
|
||||
org.gradle.configuration-cache=true
|
||||
org.gradle.caching=true
|
||||
|
||||
#Android
|
||||
android.nonTransitiveRClass=true
|
||||
android.useAndroidX=true
|
||||
android.useAndroidX=true
|
||||
# Preserve compatibility with this project's current Kotlin Multiplatform Android setup on AGP 9.
|
||||
android.builtInKotlin=false
|
||||
android.newDsl=false
|
||||
android.sourceset.disallowProvider=false
|
||||
|
||||
org.gradle.vfs.watch=true
|
||||
@ -14,7 +14,7 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
[versions]
|
||||
agp = "8.13.2"
|
||||
agp = "9.1.0"
|
||||
android-compileSdk = "37"
|
||||
android-minSdk = "28"
|
||||
android-targetSdk = "36"
|
||||
@ -193,4 +193,4 @@ uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
|
||||
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
|
||||
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
|
||||
vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" }
|
||||
neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" }
|
||||
neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user