Compare commits

...

8 Commits

Author SHA1 Message Date
Jonatan Nyberg
6b71e58584
Merge b0327fd224 into cf9476ab08 2026-10-14 15:09:34 -07:00
Kingkor Roy Tirtho
cf9476ab08 feat(library): enhance library UI with new tab titles, filtering options, and improved state management 2026-10-14 21:52:24 +06:00
Kingkor Roy Tirtho
5b442a93a9 feat(jam): implement group jam functionality with UI components and state management 2026-10-14 20:39:54 +06:00
Kingkor Roy Tirtho
d5eaf101eb fix: add window insets to top bar and bottom navigation bar, queue sheet 2026-10-14 19:26:54 +06:00
Kingkor Roy Tirtho
fda5bb8a60 feat(sidebar): adjust spacing in sidebar and setting cards for improved layout 2026-10-14 16:41:48 +06:00
Kingkor Roy Tirtho
1ccd70a18c feat(search): enhance search functionality with recent search suggestions and improved UI components 2026-10-14 16:29:45 +06:00
Kingkor Roy Tirtho
93dfea9a30 feat(player): refactor artist display and improve search field labels 2026-10-14 13:53:19 +06:00
Jonatan Nyberg
b0327fd224 translation: add Swedish resource translations
Signed-off-by: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com>
2026-09-20 14:17:16 +03:00
45 changed files with 2369 additions and 1123 deletions

View File

@ -108,6 +108,7 @@ kotlin {
}
jvm()
jvmToolchain(21)
sourceSets {
val commonMain by getting {

View File

@ -0,0 +1,22 @@
<!--
~ Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU Affero General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU Affero General Public License for more details.
~
~ You should have received a copy of the GNU Affero General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<resources>
<string name="app_name">Spotube</string>
<string name="now_playing">Spelas nu</string>
<string name="car_connect_hint">Spela musik från telefonen för att börja lyssna i bilen</string>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -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

View File

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

View File

@ -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>

View File

@ -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
)
}
}
}

View File

@ -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(

View File

@ -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(

View File

@ -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)
})
}
}
}

View File

@ -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)
)
}
}
}
}
}

View File

@ -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
}
}

View File

@ -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)
)
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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"
)
}
}
}

View File

@ -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() {

View File

@ -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"
)
}
}
// }
}
}

View File

@ -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() {

View File

@ -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) })
}

View File

@ -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
)
}
}

View File

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

View File

@ -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()
)
})
}
}

View File

@ -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 {

View File

@ -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(

View File

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

View File

@ -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

View File

@ -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(

View File

@ -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))
}
}

View File

@ -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
})
}
)
}
}
}

View File

@ -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(

View File

@ -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
)
}
}

View File

@ -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))

View File

@ -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"

View File

@ -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
})
}
}
}

View File

@ -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() {

View File

@ -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
)
}

View File

@ -0,0 +1,82 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.shell
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.theming.AppTheme
/**
* Renders a track's artists on a single, ellipsized line. Artists that have an [PlayerArtist.id]
* are tappable links that open their artist page; local tracks (no ids) render as plain text.
*/
@Composable
internal fun PlayerArtistsText(
artists: List<PlayerArtist>,
onArtistClick: (String) -> Unit,
style: TextStyle,
color: Color,
modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Unspecified) {
if (artists.isEmpty()) return
val hoverColor = AppTheme.colors.primary
val annotatedArtists = buildAnnotatedString {
artists.forEachIndexed { index, artist ->
if (index > 0) append(", ")
val artistId = artist.id
if (artistId == null) {
append(artist.name)
} else {
withLink(
LinkAnnotation.Clickable(
tag = artistId,
styles = TextLinkStyles(
style = SpanStyle(textDecoration = TextDecoration.Underline),
hoveredStyle = SpanStyle(
color = hoverColor,
textDecoration = TextDecoration.Underline),
),
linkInteractionListener = { onArtistClick(artistId) },
)
) {
append(artist.name)
}
}
}
}
Text(
text = annotatedArtists,
modifier = modifier,
style = style,
color = color,
textAlign = textAlign,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}

View File

@ -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()

View File

@ -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)
}

View File

@ -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) }
}
}
}
}
}
}

View File

@ -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)
}
}
}
}
}
}
}

View File

@ -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()
}
}
)
}
}

View File

@ -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

View File

@ -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" }