Compare commits

...

5 Commits

36 changed files with 2034 additions and 1104 deletions

View File

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

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

@ -21,6 +21,28 @@
<string name="queue_filter_queue">Filter queue...</string> <string name="queue_filter_queue">Filter queue...</string>
<string name="queue_no_queue_entries">No queue entries</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_screen_title">Settings</string>
<string name="settings_section_plugins">Plugins</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_identity">Identity</string>
<string name="settings_jam_section_diagnostics">Diagnostics</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_go_to_album">Go to album</string>
<string name="player_sleep_timer">Sleep timer</string> <string name="player_sleep_timer">Sleep timer</string>
<string name="player_track_details">Track details</string> <string name="player_track_details">Track details</string>
<string name="player_play_content_description">Play</string> <string name="player_play_content_description">Play</string>
<string name="player_pause_content_description">Pause</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="player_add_to_queue_content_description">Add to queue</string>
<string name="update_action_ignore">Ignore</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.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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape 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.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -45,19 +39,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.graphics.vector.ImageVector
import com.composeunstyled.currentWindowWidthBreakpoint import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints import org.spotube.millennium.adaptive.WindowBreakpoints
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.composeunstyled.DropdownMenuPanel
import com.composeunstyled.SheetDetent import com.composeunstyled.SheetDetent
import com.composeunstyled.Text
import com.composeunstyled.UnstyledDropdownMenu
import com.composeunstyled.UnstyledDropdownMenuItem
import com.composeunstyled.rememberModalBottomSheetState import com.composeunstyled.rememberModalBottomSheetState
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.Check 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.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash 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.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumModalBottomSheet import org.spotube.millennium.composables.MillenniumModalBottomSheet
import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumText
import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.composables.MillenniumTextField
import org.spotube.millennium.theme.MillenniumTheme import spotube.composeapp.generated.resources.Res
import org.spotube.millennium.theme.gelBrush import spotube.composeapp.generated.resources.accessibility_selected
import org.spotube.millennium.theme.millenniumPanelBrush import spotube.composeapp.generated.resources.search_filter_placeholder
import org.spotube.millennium.theme.millenniumSurface
data class AdaptiveMenuItem( data class AdaptiveMenuItem(
val icon: ImageVector? = null, val icon: ImageVector? = null,
@ -108,118 +99,17 @@ fun AdaptiveDropdownBottomSheet(
Box(modifier = modifier) { Box(modifier = modifier) {
if (isLargeScreen) { if (isLargeScreen) {
UnstyledDropdownMenu( MillenniumDropdownMenu(
expanded = expanded, expanded = expanded,
onExpandedChange = { expanded = it }, onExpandedChange = { expanded = it },
panel = { items = items.map { it.label },
DropdownMenuPanel( onItemClick = { label ->
Modifier items.firstOrNull { it.label == label && it.enabled }?.onClick?.invoke()
.width(menuMinWidth) expanded = false
.millenniumSurface( },
background = millenniumPanelBrush(MillenniumTheme.colors), minimumWidth = menuMinWidth,
shape = AppTheme.shapes.medium, anchor = { trigger { expanded = true } }
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 } },
)
} else { } else {
trigger { expanded = true } trigger { expanded = true }
if (expanded) { if (expanded) {
@ -267,7 +157,7 @@ private fun AdaptiveBottomSheetContent(
MillenniumTextField( MillenniumTextField(
state = queryState, state = queryState,
label = "", label = "",
placeholder = "Search...", placeholder = stringResource(Res.string.search_filter_placeholder),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)) .padding(horizontal = 12.dp, vertical = 8.dp))
@ -285,72 +175,69 @@ private fun AdaptiveBottomSheetContent(
if (item.dividerBefore) { if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp)) MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
} }
Row( MillenniumListTile(
modifier = Modifier title = {
.fillMaxWidth() MillenniumText(
.clip(AppTheme.shapes.medium) item.label,
.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,
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
color = if (item.enabled) { color = if (item.enabled) {
AppTheme.colors.onSurface AppTheme.colors.onSurface
} else { } else {
AppTheme.colors.onSurface.copy(alpha = 0.38f) 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, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant) color = AppTheme.colors.onSurfaceVariant
)
} }
} },
leading = when {
if (item.selected && (item.icon != null || item.leadingContent != null)) { item.leadingContent != null -> item.leadingContent
MillenniumIcon( item.icon != null -> {
imageVector = FeatherIcons.Check, {
contentDescription = "Selected", MillenniumIcon(
modifier = Modifier.size(22.dp), imageVector = item.icon,
tint = AppTheme.colors.onSurfaceVariant) 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.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape 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.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage 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.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack 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.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource 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 @Composable
fun CollectionView( 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.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import org.spotube.millennium.composables.MillenniumTooltipHost 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.MillenniumColors
import org.spotube.millennium.theme.MillenniumDarkThemeComponents
import org.spotube.millennium.theme.MillenniumLightThemeComponents
import org.spotube.millennium.theme.MillenniumPalette import org.spotube.millennium.theme.MillenniumPalette
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.MillenniumThemeConfig import org.spotube.millennium.theme.MillenniumThemeConfig
@ -201,7 +206,29 @@ fun SpotubeTheme(
} }
val palette: MillenniumPalette = settings.palette 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) val colors = appColorsFrom(MillenniumTheme.colors)
MillenniumTooltipHost { MillenniumTooltipHost {
CompositionLocalProvider( CompositionLocalProvider(

View File

@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
@ -156,7 +155,7 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
ApplicationMainBar( ApplicationMainBar(
title = { title = {
Row( Row(
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -227,4 +226,4 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
navigator.evaluateJavaScript(bridgeBootstrapScript) navigator.evaluateJavaScript(bridgeBootstrapScript)
}) })
} }
} }

View File

@ -17,311 +17,468 @@
package dev.krtirtho.spotube.modules.jam package dev.krtirtho.spotube.modules.jam
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.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.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll 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.Composable
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.jam.JamRole import com.composeunstyled.Text
import dev.krtirtho.spotube.core.jam.JamRoomCode
import dev.krtirtho.spotube.core.navigation.NavigationCommands 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.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset 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.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 @Composable
fun JamScreen( fun JamScreen(navigationCommands: NavigationCommands) {
navigationCommands: NavigationCommands) {
val viewModel = koinViewModel<JamViewModel>() val viewModel = koinViewModel<JamViewModel>()
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
Column( Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) {
modifier = Modifier ApplicationMainBar(title = { Text(stringResource(Res.string.jam_screen_title)) })
.fillMaxSize()
.background(MillenniumTheme.colors.background)) {
ApplicationMainBar(
backButton = true,
title = { Text("Group Jam") })
Column( Column(
modifier = Modifier Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState())
.fillMaxWidth() .padding(horizontal = 16.dp)
.weight(1f) .padding(top = 20.dp, bottom = shellBottomInset + 20.dp),
.padding(16.dp) horizontalAlignment = Alignment.CenterHorizontally
.padding(bottom = shellBottomInset) ) {
.verticalScroll(rememberScrollState()), Column(
verticalArrangement = Arrangement.spacedBy(16.dp)) { Modifier.widthIn(max = 520.dp).fillMaxWidth(),
state.error?.let { error -> verticalArrangement = Arrangement.spacedBy(12.dp)
ErrorBanner(text = error, onDismiss = viewModel::clearError) ) {
} state.error?.let { error ->
JamPanel {
when { Text(
!state.isActive -> CreateOrJoinView( error,
state = state, color = AppTheme.colors.error,
onCreate = viewModel::createSession, style = AppTheme.typography.bodySmall
onJoin = viewModel::joinWithCode) )
MillenniumButton(
else -> SessionView( onClick = viewModel::clearError,
state = state, style = MillenniumButtonStyle.Secondary
onShareCode = viewModel::shareRoomCode, ) {
onLeave = viewModel::leave, Text(stringResource(Res.string.jam_error_dismiss))
onKick = viewModel::kickParticipant, }
onBan = viewModel::banParticipant) }
}
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 @Composable
private fun ErrorBanner(text: String, onDismiss: () -> Unit) { private fun JamPanel(content: @Composable ColumnScope.() -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(
Text( Modifier.fillMaxWidth().millenniumSurface(
text = text, background = millenniumPanelBrush(MillenniumTheme.colors),
color = AppTheme.colors.error, shape = RoundedCornerShape(10.dp),
style = AppTheme.typography.bodyMedium) borderColor = MillenniumTheme.colors.border,
MillenniumButton( borderWidth = 1.dp,
onClick = onDismiss, innerGlowColor = MillenniumTheme.colors.highlight.copy(alpha = 0.25f)
style = MillenniumButtonStyle.Secondary) { ).padding(16.dp),
Text("Dismiss") verticalArrangement = Arrangement.spacedBy(14.dp),
} content = content
} )
} }
@Composable @Composable
private fun CreateOrJoinView( internal fun SetupPanel(
state: JamUiState, state: JamUiState,
onMode: (JamSetupMode) -> Unit,
onCreate: () -> Unit, onCreate: () -> Unit,
onJoin: (String) -> Unit) { onJoin: () -> Unit
var tab by rememberSaveable { mutableIntStateOf(0) } ) {
val codeState = rememberTextFieldState() JamPanel {
MillenniumSegmentedButtonRow(Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { MillenniumSegmentedButton(
Text( selected = state.setupMode == JamSetupMode.Create,
text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.", onClick = { onMode(JamSetupMode.Create) },
style = AppTheme.typography.titleMedium) shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)
) { Text(stringResource(Res.string.jam_setup_create_tab)) }
if (!state.brokerConfigured) { 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(
text = "No jam broker configured — set one up in Settings to host or join a session.", stringResource(Res.string.jam_setup_create_hint),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.error)
} else {
Text(
text = "Broker: ${state.brokerHost}",
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant) 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")
}
}
} else { } else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { MillenniumTextField(
Text( state = state.codeInput,
text = "Enter the 6-character room code the host shared with you.", label = stringResource(Res.string.jam_setup_code_label),
style = AppTheme.typography.bodyMedium, placeholder = stringResource(Res.string.jam_setup_code_placeholder),
color = AppTheme.colors.onSurfaceVariant) enabled = !state.isBusy,
MillenniumTextField( textStyle = AppTheme.typography.headlineSmall.copy(
state = codeState, fontFamily = FontFamily.Monospace,
label = "Room code", letterSpacing = 4.sp
placeholder = "ABC123", ),
modifier = Modifier.fillMaxWidth()) modifier = Modifier.fillMaxWidth()
val normalizedCode = JamRoomCode.normalize(codeState.text.toString()) )
MillenniumButton( }
onClick = { onJoin(normalizedCode) }, MillenniumButton(
enabled = state.brokerConfigured && JamRoomCode.isValid(normalizedCode)) { onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin,
Text("Join Session") 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 @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, state: JamUiState,
onShareCode: () -> Unit, onCopy: () -> Unit,
onShare: () -> Unit,
onLeave: () -> Unit, onLeave: () -> Unit,
onKick: (String) -> Unit, onKick: (String) -> Unit,
onBan: (String) -> Unit) { onBan: (String) -> Unit,
val isHost = state.role == JamRole.Host onMenuChange: (String?) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { JamPanel {
if (!state.isConnected) { 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(
text = "Connecting to the session…", stringResource(if (state.isConnected) Res.string.jam_session_connected else Res.string.jam_session_connecting),
style = AppTheme.typography.bodyMedium, style = AppTheme.typography.labelMedium
color = AppTheme.colors.onSurfaceVariant) )
} }
Column(
ParticipantsSection( Modifier.fillMaxWidth().millenniumSurface(
participants = state.participants, background = SolidColor(MillenniumTheme.colors.background),
isHost = isHost, shape = RoundedCornerShape(5.dp),
onKick = onKick, borderColor = MillenniumTheme.colors.border,
onBan = onBan) borderWidth = 1.dp
).padding(12.dp),
if (isHost) { verticalArrangement = Arrangement.spacedBy(6.dp)
MillenniumSeparator() ) {
Text( Text(
text = "Invite someone", stringResource(Res.string.jam_setup_code_label),
style = AppTheme.typography.titleSmall) style = AppTheme.typography.labelSmall,
RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode) color = AppTheme.colors.onSurfaceVariant
} )
SelectionContainer {
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)) {
Text( Text(
text = participant.displayName, state.roomCode.orEmpty(),
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.headlineMedium,
maxLines = 1, fontFamily = FontFamily.Monospace,
overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)) letterSpacing = 5.sp,
if (participant.isHost) { color = AppTheme.colors.primary
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)
}
}
} }
} }
} if (state.isHost) {
} Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@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) {
MillenniumButton( 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, onClick = onShare,
style = MillenniumButtonStyle.Secondary) { modifier = Modifier.weight(1f),
Text("Share") 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 @Composable
private fun LeaveButton(onLeave: () -> Unit) { internal fun BrokerStatus(state: JamUiState, onConfigure: () -> Unit) {
MillenniumButton( Row(
onClick = onLeave, Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Secondary) { verticalAlignment = Alignment.CenterVertically,
Text("Leave Session") 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.ViewModel
import androidx.lifecycle.viewModelScope 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.PlatformType
import dev.krtirtho.spotube.core.jam.JamParticipant import dev.krtirtho.spotube.core.jam.JamParticipant
import dev.krtirtho.spotube.core.jam.JamRole 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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch 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( data class JamUiState(
val isActive: Boolean = false, val isActive: Boolean = false,
val isConnected: Boolean = false, val isConnected: Boolean = false,
val role: JamRole? = null, val role: JamRole? = null,
val participants: List<JamParticipant> = emptyList(), val participants: List<JamParticipantUi> = emptyList(),
val roomCode: String? = null, val roomCode: String? = null,
val brokerHost: String = "", val brokerHost: String = "",
val brokerConfigured: Boolean = false, 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( class JamViewModel(
private val jamRoomService: JamRoomService, private val jamRoomService: JamRoomService,
private val shareService: ShareService, private val shareService: ShareService,
private val settingsProvider: SettingsProvider) : ViewModel() { private val settingsProvider: SettingsProvider
) : ViewModel() {
private val _localError = MutableStateFlow<String?>(null) 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 = val supportsNativeShare: Boolean =
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
@ -62,9 +94,16 @@ class JamViewModel(
jamRoomService.roomCode, jamRoomService.roomCode,
jamRoomService.connectionError, jamRoomService.connectionError,
settingsProvider.settingsState, settingsProvider.settingsState,
_localError) { values -> _localError,
setupMode,
isBusy,
codeCopied,
snapshotFlow { codeInput.text.toString() },
participantMenu
) { values ->
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val role = values[0] as JamRole? val role = values[0] as JamRole?
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val participants = values[1] as List<JamParticipant> val participants = values[1] as List<JamParticipant>
val isConnected = values[2] as Boolean val isConnected = values[2] as Boolean
@ -72,48 +111,108 @@ class JamViewModel(
val connectionError = values[4] as String? val connectionError = values[4] as String?
val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings
val localError = values[6] as String? val localError = values[6] as String?
val busy = values[8] as Boolean
val brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank()
JamUiState( JamUiState(
isActive = role != null, isActive = role != null,
isConnected = isConnected, isConnected = isConnected,
role = role, 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, roomCode = roomCode,
brokerHost = settings?.jamBroker?.host.orEmpty(), brokerHost = settings?.jamBroker?.host.orEmpty(),
brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(), brokerConfigured = brokerConfigured,
error = localError ?: connectionError) error = localError ?: connectionError,
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState()) 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 ---------- // ---------- Session lifecycle ----------
fun createSession() { fun createSession() {
if (isBusy.value) return
isBusy.value = true
viewModelScope.launch { viewModelScope.launch {
jamRoomService.createRoom() try {
.onFailure { e -> jamRoomService.createRoom()
_localError.value = e.message ?: "Failed to create jam session" .onFailure { e ->
} _localError.value = e.message ?: getString(Res.string.jam_error_create)
.onSuccess { _localError.value = null } }
.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) { fun joinWithCode(input: String) {
if (isBusy.value) return
val code = JamRoomCode.normalize(input) val code = JamRoomCode.normalize(input)
if (!JamRoomCode.isValid(code)) { 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 return
} }
isBusy.value = true
viewModelScope.launch { viewModelScope.launch {
jamRoomService.joinRoom(code) try {
.onFailure { e -> jamRoomService.joinRoom(code)
_localError.value = e.message ?: "Failed to join jam session" .onFailure { e ->
} _localError.value = e.message ?: getString(Res.string.jam_error_join)
.onSuccess { _localError.value = null } }
.onSuccess { _localError.value = null }
} finally {
isBusy.value = false
}
} }
} }
fun shareRoomCode() { fun shareRoomCode() {
val code = uiState.value.roomCode ?: return 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() { fun leave() {
@ -130,10 +229,16 @@ class JamViewModel(
// ---------- Host moderation ---------- // ---------- Host moderation ----------
fun kickParticipant(participantId: String) { fun kickParticipant(participantId: String) {
participantMenu.value = null
viewModelScope.launch { jamRoomService.kickParticipant(participantId) } viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
} }
fun banParticipant(participantId: String) { fun banParticipant(participantId: String) {
participantMenu.value = null
viewModelScope.launch { jamRoomService.banParticipant(participantId) } viewModelScope.launch { jamRoomService.banParticipant(participantId) }
} }
}
fun showParticipantMenu(participantId: String?) {
participantMenu.value = participantId
}
}

View File

@ -17,102 +17,192 @@
package dev.krtirtho.spotube.modules.library 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.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import com.composeunstyled.Text import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.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.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.downloads.DownloadsScreen import dev.krtirtho.spotube.modules.downloads.DownloadsScreen
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen
import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen
import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen 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.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 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.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 @Composable
fun LibraryScreen( fun LibraryScreen(libraryState: LibraryState = koinInject()) {
libraryState: LibraryState = koinInject(), val state by libraryState.uiState.collectAsStateWithLifecycle()
appShellViewModel: AppShellViewModel = koinViewModel() val expandedDesktop = getPlatform().isDesktop() &&
) { (currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded)
val currentTab by libraryState.currentTab.collectAsState() val focusManager = LocalFocusManager.current
val searchMode by libraryState.searchMode.collectAsState() val keyboard = LocalSoftwareKeyboardController.current
val isLargeScreen = appShellViewModel.useSidebar()
Column(modifier = Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
Column { ApplicationMainBar(
ApplicationMainBar( backButton = false,
backButton = false, title = { LibraryToolbarTitle(state, expandedDesktop) },
title = { actions = {
if (!isLargeScreen) Text("Your Library") LibraryToolbarActions(
}, state = state,
actions = { expandedDesktop = expandedDesktop,
if (!isLargeScreen) { onOpenSearch = libraryState::openSearch,
MillenniumIconButton( onCloseSearch = {
icon = Iconsax.IconsaxSearchBroken, focusManager.clearFocus()
contentDescription = "Search", keyboard?.hide()
onClick = { libraryState.closeSearch()
libraryState.setSearchMode(!searchMode) },
}, onCreatePlaylist = libraryState::showCreatePlaylist,
style = MillenniumIconButtonStyle.Quiet) onRefresh = libraryState::refreshLocalTracks
} )
} }
) )
if (!isLargeScreen) if (!expandedDesktop) {
Column( LazyRow(
modifier = Modifier.padding(horizontal = 12.dp) contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
) { horizontalArrangement = Arrangement.spacedBy(8.dp)
Spacer(modifier = Modifier.height(4.dp)) ) {
LazyRow( items(LibraryTab.entries) { tab ->
horizontalArrangement = Arrangement.spacedBy(8.dp)) { MillenniumButton(
items(LibraryTab.entries.size) { index -> onClick = { libraryState.onTabSelected(tab) },
val tab = LibraryTab.entries[index] style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary
MillenniumButton( ) { Text(stringResource(tab.titleResource)) }
onClick = { libraryState.onTabSelected(tab) },
style = if (currentTab == tab) {
MillenniumButtonStyle.Primary
} else {
MillenniumButtonStyle.Secondary
}) {
Text(tab.title)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
} }
}
} }
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
when (state.currentTab) {
LibraryTab.Playlists -> LibraryPlaylistsScreen(
showCreatePlaylist = state.showCreatePlaylist,
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist
)
Column( LibraryTab.Albums -> LibraryAlbumsScreen()
modifier = Modifier LibraryTab.Artists -> LibraryArtistsScreen()
.weight(1f) LibraryTab.LocalTracks -> LibraryLocalTracksScreen()
.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.Downloads -> DownloadsScreen() 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 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.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 { data class LibraryToolbarUiState(
val searchMode = MutableStateFlow(false) 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) 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) { fun onTabSelected(tab: LibraryTab) {
currentTab.value = tab currentTab.value = tab
createPlaylistVisible.value = false
if (tab == LibraryTab.Downloads) searchMode.value = false
} }
fun setSearchMode(enabled: Boolean) { fun openSearch() {
searchMode.value = enabled 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.IconsaxMusicDashboard
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
import dev.krtirtho.spotube.resources.iconsax.User 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) { enum class LibraryTab(val title: String, val icon: ImageVector) {
Playlists("Playlists", Iconsax.IconsaxMusicDashboard), Playlists("Playlists", Iconsax.IconsaxMusicDashboard),
Albums("Albums", Iconsax.IconsaxCd), Albums("Albums", Iconsax.IconsaxCd),
Artists("Artists", Iconsax.User), Artists("Artists", Iconsax.User),
LocalTracks("Local Tracks", Iconsax.IconsaxMusicLibrary), 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 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.AlbumCard
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset 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.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun LibraryAlbumsScreen( fun LibraryAlbumsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchMode: Boolean = false,
viewModel: LibraryAlbumsViewModel = koinViewModel() viewModel: LibraryAlbumsViewModel = koinViewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
val searchState = rememberTextFieldState((state as? LibraryAlbumsState.Data)?.query ?: "")
LaunchedEffect(searchState) {
snapshotFlow { searchState.text.toString() }
.drop(1)
.collect(viewModel::onQueryChange)
}
LaunchedEffect(gridState) { LaunchedEffect(gridState) {
snapshotFlow { gridState.layoutInfo } snapshotFlow { gridState.layoutInfo }
@ -93,15 +77,6 @@ fun LibraryAlbumsScreen(
} }
Column(modifier = modifier) { Column(modifier = modifier) {
AnimatedVisibility(searchMode) {
MillenniumTextField(
state = searchState,
modifier = Modifier.fillMaxWidth(),
placeholder = "Search saved albums..."
)
}
Spacer(modifier = Modifier.height(12.dp))
when (state) { when (state) {
is LibraryAlbumsState.Loading -> { is LibraryAlbumsState.Loading -> {
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current

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.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.library.LibraryRepository 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -46,7 +49,8 @@ sealed interface LibraryAlbumsState {
data class Loaded( data class Loaded(
override val query: String = "", override val query: String = "",
override val allItems: List<MetadataAlbum.Detailed> = emptyList(), 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> override val items: List<MetadataAlbum.Detailed>
get() = if (query.isNotBlank()) { get() = if (query.isNotBlank()) {
allItems.filter { album -> allItems.filter { album ->
@ -62,13 +66,15 @@ sealed interface LibraryAlbumsState {
fun toLoadingMore(): LoadingMore = LoadingMore( fun toLoadingMore(): LoadingMore = LoadingMore(
query = query, query = query,
allItems = allItems, allItems = allItems,
nextPagination = nextPagination) nextPagination = nextPagination
)
} }
data class LoadingMore( data class LoadingMore(
override val query: String = "", override val query: String = "",
override val allItems: List<MetadataAlbum.Detailed> = emptyList(), 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> override val items: List<MetadataAlbum.Detailed>
get() = if (query.isNotBlank()) { get() = if (query.isNotBlank()) {
allItems.filter { album -> allItems.filter { album ->
@ -88,11 +94,21 @@ sealed interface LibraryAlbumsState {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class LibraryAlbumsViewModel( 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 logger by injectLogger<LibraryAlbumsViewModel>()
private val _state = MutableStateFlow<LibraryAlbumsState>(LibraryAlbumsState.Loading) 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 { init {
viewModelScope.launch { viewModelScope.launch {
@ -113,7 +129,8 @@ class LibraryAlbumsViewModel(
val result = repository.savedAlbums() val result = repository.savedAlbums()
_state.value = LibraryAlbumsState.Data.Loaded( _state.value = LibraryAlbumsState.Data.Loaded(
allItems = result?.items ?: emptyList(), allItems = result?.items ?: emptyList(),
nextPagination = result?.nextPagination) nextPagination = result?.nextPagination
)
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load albums" } logger.e(e) { "Failed to load albums" }
_state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error") _state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error")
@ -127,7 +144,8 @@ class LibraryAlbumsViewModel(
_state.value = LibraryAlbumsState.Data.Loaded( _state.value = LibraryAlbumsState.Data.Loaded(
query = currentState.query, query = currentState.query,
allItems = currentState.allItems + (result?.items ?: emptyList()), allItems = currentState.allItems + (result?.items ?: emptyList()),
nextPagination = result?.nextPagination) nextPagination = result?.nextPagination
)
} }
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load more albums" } logger.e(e) { "Failed to load more albums" }
@ -135,13 +153,7 @@ class LibraryAlbumsViewModel(
} }
fun onQueryChange(query: String) { fun onQueryChange(query: String) {
val currentState = _state.value libraryState.setQuery(LibraryTab.Albums, query)
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)
}
}
} }
fun refresh() { fun refresh() {

View File

@ -18,23 +18,16 @@
package dev.krtirtho.spotube.modules.library.artist 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -43,34 +36,25 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.component.ArtistCard import dev.krtirtho.spotube.core.ui.component.ArtistCard
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset 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.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun LibraryArtistsScreen( fun LibraryArtistsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchMode: Boolean = false, viewModel: LibraryArtistsViewModel = koinViewModel()
viewModel: LibraryArtistsViewModel = koinViewModel()) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
val searchState = rememberTextFieldState((state as? LibraryArtistsState.Data)?.query ?: "")
LaunchedEffect(searchState) {
snapshotFlow { searchState.text.toString() }
.drop(1)
.collect(viewModel::onQueryChange)
}
LaunchedEffect(gridState) { LaunchedEffect(gridState) {
snapshotFlow { gridState.layoutInfo } snapshotFlow { gridState.layoutInfo }
@ -92,14 +76,6 @@ fun LibraryArtistsScreen(
} }
Column(modifier = modifier) { Column(modifier = modifier) {
AnimatedVisibility(searchMode){
MillenniumTextField(
state = searchState,
modifier = Modifier.fillMaxWidth(),
placeholder = "Search saved artists...")
}
Spacer(modifier = Modifier.height(12.dp))
when (state) { when (state) {
is LibraryArtistsState.Loading -> { is LibraryArtistsState.Loading -> {
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
@ -111,13 +87,15 @@ fun LibraryArtistsScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding, contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)) { verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(8) { items(8) {
SkeletonTree(true) { SkeletonTree(true) {
AvatarCard( AvatarCard(
title = "Artist Name", title = "Artist Name",
subtitle = "Artist", subtitle = "Artist",
imageURL = "https://placehold.co/600x400") imageURL = "https://placehold.co/600x400"
)
} }
} }
} }
@ -152,22 +130,25 @@ fun LibraryArtistsScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding, contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)) { verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(dataState.items, key = { it.id }) { artist -> items(dataState.items, key = { it.id }) { artist ->
ArtistCard( ArtistCard(
artist = artist, artist = artist,
modifier = Modifier.fillMaxWidth()) modifier = Modifier.fillMaxWidth()
)
} }
// if (state is LibraryArtistsState.Data.LoadingMore) { // if (state is LibraryArtistsState.Data.LoadingMore) {
items(4) { items(4) {
SkeletonTree(true) { SkeletonTree(true) {
AvatarCard( AvatarCard(
title = "Artist Name", title = "Artist Name",
subtitle = "Artist", subtitle = "Artist",
imageURL = "https://placehold.co/400x400") 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.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.library.LibraryRepository 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -46,7 +49,8 @@ sealed interface LibraryArtistsState {
data class Loaded( data class Loaded(
override val query: String = "", override val query: String = "",
override val allItems: List<MetadataArtist.Basic> = emptyList(), 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> override val items: List<MetadataArtist.Basic>
get() = if (query.isNotBlank()) { get() = if (query.isNotBlank()) {
allItems.filter { artist -> allItems.filter { artist ->
@ -59,13 +63,15 @@ sealed interface LibraryArtistsState {
fun toLoadingMore(): LoadingMore = LoadingMore( fun toLoadingMore(): LoadingMore = LoadingMore(
query = query, query = query,
allItems = allItems, allItems = allItems,
nextPagination = nextPagination) nextPagination = nextPagination
)
} }
data class LoadingMore( data class LoadingMore(
override val query: String = "", override val query: String = "",
override val allItems: List<MetadataArtist.Basic> = emptyList(), 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> override val items: List<MetadataArtist.Basic>
get() = if (query.isNotBlank()) { get() = if (query.isNotBlank()) {
allItems.filter { artist -> allItems.filter { artist ->
@ -82,11 +88,21 @@ sealed interface LibraryArtistsState {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class LibraryArtistsViewModel( 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 logger by injectLogger<LibraryArtistsViewModel>()
private val _state = MutableStateFlow<LibraryArtistsState>(LibraryArtistsState.Loading) 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 { init {
viewModelScope.launch { viewModelScope.launch {
@ -111,7 +127,8 @@ class LibraryArtistsViewModel(
id = it.id, id = it.id,
name = it.name, name = it.name,
thumbnails = it.thumbnails, thumbnails = it.thumbnails,
externalUri = it.externalUri) externalUri = it.externalUri
)
} ?: emptyList(), } ?: emptyList(),
nextPagination = result?.nextPagination) nextPagination = result?.nextPagination)
}.onFailure { e -> }.onFailure { e ->
@ -129,12 +146,14 @@ class LibraryArtistsViewModel(
id = it.id, id = it.id,
name = it.name, name = it.name,
thumbnails = it.thumbnails, thumbnails = it.thumbnails,
externalUri = it.externalUri) externalUri = it.externalUri
)
} ?: emptyList() } ?: emptyList()
_state.value = LibraryArtistsState.Data.Loaded( _state.value = LibraryArtistsState.Data.Loaded(
query = currentState.query, query = currentState.query,
allItems = currentState.allItems + newItems, allItems = currentState.allItems + newItems,
nextPagination = result?.nextPagination) nextPagination = result?.nextPagination
)
} }
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load more artists" } logger.e(e) { "Failed to load more artists" }
@ -142,13 +161,7 @@ class LibraryArtistsViewModel(
} }
fun onQueryChange(query: String) { fun onQueryChange(query: String) {
val currentState = _state.value libraryState.setQuery(LibraryTab.Artists, query)
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)
}
}
} }
fun refresh() { 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.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumListTile 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.rememberTextFieldState
import com.composeunstyled.Text import com.composeunstyled.Text
@ -46,28 +43,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.drop
import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
import dev.krtirtho.spotube.resources.iconsax.Iconsax 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.IconsaxFolderOpen
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2 import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
import dev.krtirtho.spotube.resources.iconsax.Play import dev.krtirtho.spotube.resources.iconsax.Play
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean = false) { fun LibraryLocalTracksScreen(modifier: Modifier = Modifier) {
val viewModel = koinViewModel<LibraryLocalTracksViewModel>() val viewModel = koinViewModel<LibraryLocalTracksViewModel>()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val permissionState = rememberLocalMediaPermissionState() val permissionState = rememberLocalMediaPermissionState()
@ -85,53 +78,25 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) 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()) { Box(modifier = modifier.fillMaxWidth()) {
Column( Column(
modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter),
verticalArrangement = Arrangement.spacedBy(12.dp)) { 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,
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)
}
}
if (state.currentFolderPath != null) { if (state.currentFolderPath != null) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) { horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
MillenniumIconButton( MillenniumIconButton(
icon = Iconsax.ArrowLeft3, icon = Iconsax.ArrowLeft3,
contentDescription = "Back", contentDescription = "Back",
onClick = viewModel::navigateUp, onClick = viewModel::navigateUp,
style = MillenniumIconButtonStyle.Quiet) style = MillenniumIconButtonStyle.Quiet
)
Text(viewModel.currentFolder()?.name ?: "Folder") Text(viewModel.currentFolder()?.name ?: "Folder")
} }
MillenniumIconButton( MillenniumIconButton(
@ -140,7 +105,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
onClick = { onClick = {
viewModel.currentFolder()?.let { viewModel.playFolder(it.path) } 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) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)) { verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text("Allow audio permission to show local tracks") Text("Allow audio permission to show local tracks")
MillenniumButton( MillenniumButton(
onClick = permissionState.requestPermission, onClick = permissionState.requestPermission,
style = MillenniumButtonStyle.Quiet) { style = MillenniumButtonStyle.Quiet
) {
Text("Grant access") Text("Grant access")
} }
} }
@ -212,7 +180,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
icon = Iconsax.Play, icon = Iconsax.Play,
contentDescription = "Play", contentDescription = "Play",
onClick = { viewModel.playTrack(track.path) }, onClick = { viewModel.playTrack(track.path) },
style = MillenniumIconButtonStyle.Quiet) style = MillenniumIconButtonStyle.Quiet
)
}, },
onClick = { viewModel.playTrack(track.path) }) onClick = { viewModel.playTrack(track.path) })
} }

View File

@ -21,13 +21,17 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry 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.LocalMediaFolder
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaTrack
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class LibraryLocalTracksState( data class LibraryLocalTracksState(
@ -35,24 +39,34 @@ data class LibraryLocalTracksState(
val folders: List<LocalMediaFolder> = emptyList(), val folders: List<LocalMediaFolder> = emptyList(),
val currentFolderPath: String? = null, val currentFolderPath: String? = null,
val query: String = "", val query: String = "",
val error: String? = null) val error: String? = null
)
class LibraryLocalTracksViewModel( class LibraryLocalTracksViewModel(
private val coordinator: LocalMediaLibraryCoordinator, private val coordinator: LocalMediaLibraryCoordinator,
private val audioPlayerQueue: AudioPlayerQueue) : ViewModel() { private val audioPlayerQueue: AudioPlayerQueue,
private val libraryState: LibraryState
) : ViewModel() {
private val _state = MutableStateFlow(LibraryLocalTracksState()) 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 { init {
viewModelScope.launch {
libraryState.localRefreshRequests.collect { refresh() }
}
viewModelScope.launch { viewModelScope.launch {
coordinator.cacheState coordinator.cacheState
.collect { cache -> .collect { cache ->
_state.update { _state.update {
it.copy( it.copy(
folders = cache.folders, folders = cache.folders,
error = null) error = null
)
}
} }
}
} }
refreshIfEmpty() refreshIfEmpty()
@ -60,7 +74,7 @@ class LibraryLocalTracksViewModel(
} }
fun onQueryChange(query: String) { fun onQueryChange(query: String) {
_state.update { it.copy(query = query) } libraryState.setQuery(LibraryTab.LocalTracks, query)
} }
fun openFolder(folderPath: String) { fun openFolder(folderPath: String) {
@ -87,7 +101,7 @@ class LibraryLocalTracksViewModel(
fun playFolder(folderPath: String) { fun playFolder(folderPath: String) {
val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return 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 if (tracks.isEmpty()) return
viewModelScope.launch { viewModelScope.launch {
@ -95,13 +109,14 @@ class LibraryLocalTracksViewModel(
entries = tracks.map { it.toQueueEntry() }, entries = tracks.map { it.toQueueEntry() },
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null
)
} }
} }
fun playTrack(trackPath: String) { fun playTrack(trackPath: String) {
val folder = currentFolder() ?: return 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 } val selectedIndex = tracks.indexOfFirst { it.path == trackPath }
if (selectedIndex < 0) return if (selectedIndex < 0) return
@ -110,7 +125,8 @@ class LibraryLocalTracksViewModel(
entries = tracks.map { it.toQueueEntry() }, entries = tracks.map { it.toQueueEntry() },
autoPlay = true, autoPlay = true,
startPosition = selectedIndex, startPosition = selectedIndex,
collectionEntry = null) collectionEntry = null
)
} }
} }
@ -120,10 +136,10 @@ class LibraryLocalTracksViewModel(
return state.value.folders.filter { folder -> return state.value.folders.filter { folder ->
folder.name.contains(query, ignoreCase = true) || folder.name.contains(query, ignoreCase = true) ||
folder.tracks.any { track -> folder.tracks.any { track ->
track.name.contains(query, ignoreCase = true) || track.name.contains(query, ignoreCase = true) ||
track.artists.any { it.contains(query, ignoreCase = true) } track.artists.any { it.contains(query, ignoreCase = true) }
} }
} }
} }
@ -156,8 +172,8 @@ class LibraryLocalTracksViewModel(
return tracks.filter { track -> return tracks.filter { track ->
track.name.contains(normalizedQuery, ignoreCase = true) || track.name.contains(normalizedQuery, ignoreCase = true) ||
track.album?.contains(normalizedQuery, ignoreCase = true) == true || track.album?.contains(normalizedQuery, ignoreCase = true) == true ||
track.artists.any { it.contains(normalizedQuery, ignoreCase = true) } track.artists.any { it.contains(normalizedQuery, ignoreCase = true) }
} }
} }
@ -168,6 +184,7 @@ class LibraryLocalTracksViewModel(
duration = durationMs, duration = durationMs,
album = album, album = album,
coverBytes = coverBytes, coverBytes = coverBytes,
url = path) url = path
)
} }
} }

View File

@ -18,68 +18,45 @@
package dev.krtirtho.spotube.modules.library.playlist 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.LikedTracksCard import dev.krtirtho.spotube.core.ui.component.LikedTracksCard
import dev.krtirtho.spotube.core.ui.component.PlaylistCard import dev.krtirtho.spotube.core.ui.component.PlaylistCard
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset 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.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun LibraryPlaylistsScreen( fun LibraryPlaylistsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchMode: Boolean = false, showCreatePlaylist: Boolean = false,
viewModel: LibraryPlaylistsViewModel = koinViewModel()) { onDismissCreatePlaylist: () -> Unit = {},
viewModel: LibraryPlaylistsViewModel = koinViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState() 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) { LaunchedEffect(gridState) {
snapshotFlow { gridState.layoutInfo } snapshotFlow { gridState.layoutInfo }
@ -101,26 +78,6 @@ fun LibraryPlaylistsScreen(
} }
Column(modifier = modifier) { 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,
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) { when (state) {
is LibraryPlaylistsState.Loading -> { is LibraryPlaylistsState.Loading -> {
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
@ -139,7 +96,8 @@ fun LibraryPlaylistsScreen(
PlayableCard( PlayableCard(
title = "Playlist Name", title = "Playlist Name",
subtitle = "By Artist", subtitle = "By Artist",
imageURL = "https://placehold.co/600x400") imageURL = "https://placehold.co/600x400"
)
} }
} }
} }
@ -195,7 +153,8 @@ fun LibraryPlaylistsScreen(
PlayableCard( PlayableCard(
title = "Playlist Name", title = "Playlist Name",
subtitle = "By Artist", subtitle = "By Artist",
imageURL = "https://placehold.co/600x400") imageURL = "https://placehold.co/600x400"
)
} }
} }
} }
@ -206,7 +165,7 @@ fun LibraryPlaylistsScreen(
PlaylistFormSheet( PlaylistFormSheet(
visible = showCreatePlaylist, visible = showCreatePlaylist,
onDismiss = { showCreatePlaylist = false }, onDismiss = onDismissCreatePlaylist,
onSubmit = { data -> onSubmit = { data ->
viewModel.createPlaylist( viewModel.createPlaylist(
name = data.name, name = data.name,
@ -214,7 +173,8 @@ fun LibraryPlaylistsScreen(
isPublic = data.isPublic, isPublic = data.isPublic,
isCollaborating = data.isCollaborating, isCollaborating = data.isCollaborating,
imageBase64 = data.imageBase64, 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.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.library.LibraryRepository 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -46,12 +49,13 @@ sealed interface LibraryPlaylistsState {
data class Loaded( data class Loaded(
override val query: String = "", override val query: String = "",
override val allItems: List<MetadataPlaylist> = emptyList(), override val allItems: List<MetadataPlaylist> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data { override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataPlaylist> override val items: List<MetadataPlaylist>
get() = if (query.isNotBlank()) { get() = if (query.isNotBlank()) {
allItems.filter { allItems.filter {
it.title.contains(query, ignoreCase = true) || it.title.contains(query, ignoreCase = true) ||
(it.description?.contains(query, ignoreCase = true) == true) (it.description?.contains(query, ignoreCase = true) == true)
} }
} else { } else {
allItems allItems
@ -60,18 +64,20 @@ sealed interface LibraryPlaylistsState {
fun toLoadingMore(): LoadingMore = LoadingMore( fun toLoadingMore(): LoadingMore = LoadingMore(
query = query, query = query,
allItems = allItems, allItems = allItems,
nextPagination = nextPagination) nextPagination = nextPagination
)
} }
data class LoadingMore( data class LoadingMore(
override val query: String = "", override val query: String = "",
override val allItems: List<MetadataPlaylist> = emptyList(), override val allItems: List<MetadataPlaylist> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data { override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataPlaylist> override val items: List<MetadataPlaylist>
get() = if (query.isNotBlank()) { get() = if (query.isNotBlank()) {
allItems.filter { allItems.filter {
it.title.contains(query, ignoreCase = true) || it.title.contains(query, ignoreCase = true) ||
(it.description?.contains(query, ignoreCase = true) == true) (it.description?.contains(query, ignoreCase = true) == true)
} }
} else { } else {
allItems allItems
@ -84,11 +90,21 @@ sealed interface LibraryPlaylistsState {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class LibraryPlaylistsViewModel( 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 logger by injectLogger<LibraryPlaylistsViewModel>()
private val _state = MutableStateFlow<LibraryPlaylistsState>(LibraryPlaylistsState.Loading) 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 { init {
viewModelScope.launch { viewModelScope.launch {
@ -109,7 +125,8 @@ class LibraryPlaylistsViewModel(
val result = repository.savedPlaylists() val result = repository.savedPlaylists()
_state.value = LibraryPlaylistsState.Data.Loaded( _state.value = LibraryPlaylistsState.Data.Loaded(
allItems = result?.items ?: emptyList(), allItems = result?.items ?: emptyList(),
nextPagination = result?.nextPagination) nextPagination = result?.nextPagination
)
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load playlists" } logger.e(e) { "Failed to load playlists" }
_state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error") _state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error")
@ -123,7 +140,8 @@ class LibraryPlaylistsViewModel(
_state.value = LibraryPlaylistsState.Data.Loaded( _state.value = LibraryPlaylistsState.Data.Loaded(
query = currentState.query, query = currentState.query,
allItems = currentState.allItems + (result?.items ?: emptyList()), allItems = currentState.allItems + (result?.items ?: emptyList()),
nextPagination = result?.nextPagination) nextPagination = result?.nextPagination
)
} }
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load more playlists" } logger.e(e) { "Failed to load more playlists" }
@ -131,13 +149,7 @@ class LibraryPlaylistsViewModel(
} }
fun onQueryChange(query: String) { fun onQueryChange(query: String) {
val currentState = _state.value libraryState.setQuery(LibraryTab.Playlists, query)
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)
}
}
} }
fun refresh() { fun refresh() {
@ -153,7 +165,8 @@ class LibraryPlaylistsViewModel(
isPublic: Boolean, isPublic: Boolean,
isCollaborating: Boolean, isCollaborating: Boolean,
imageBase64: String, imageBase64: String,
trackIds: List<String>) { trackIds: List<String>
) {
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
repository.createPlaylist( repository.createPlaylist(
@ -162,7 +175,8 @@ class LibraryPlaylistsViewModel(
isPublic = isPublic, isPublic = isPublic,
isCollaborating = isCollaborating, isCollaborating = isCollaborating,
imageBase64 = imageBase64, imageBase64 = imageBase64,
trackIds = trackIds) trackIds = trackIds
)
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to create playlist" } logger.e(e) { "Failed to create playlist" }
}.onSuccess { }.onSuccess {

View File

@ -255,7 +255,7 @@ private fun PlaylistFormFields(
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant) color = AppTheme.colors.onSurfaceVariant)
} }
MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange, label = "Public") MillenniumSwitch(checked = isPublic, onCheckedChange = onIsPublicChange)
} }
Row( Row(
@ -273,7 +273,7 @@ private fun PlaylistFormFields(
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant) color = AppTheme.colors.onSurfaceVariant)
} }
MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange, label = "Collaborative") MillenniumSwitch(checked = isCollaborating, onCheckedChange = onIsCollaboratingChange)
} }
Column( Column(

View File

@ -19,13 +19,12 @@ package dev.krtirtho.spotube.modules.search
import org.spotube.millennium.composables.MillenniumIcon 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.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIndeterminateProgress import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumAutocomplete import org.spotube.millennium.composables.MillenniumAutocomplete
import org.spotube.millennium.composables.MillenniumText
import org.spotube.millennium.composables.MillenniumTextField import org.spotube.millennium.composables.MillenniumTextField
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
import androidx.compose.animation.AnimatedContent 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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import compose.icons.FeatherIcons import com.composeunstyled.currentWindowWidthBreakpoint
import compose.icons.feathericons.X
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum 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.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist 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.search.MetadataSupportedSearchType
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser 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.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.jam.JamRole 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.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree 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.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository 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.IconsaxSearchBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock 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.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.spotube.millennium.adaptive.WindowBreakpoints
private val GridMinCellSize = 180.dp private val GridMinCellSize = 180.dp
@ -145,8 +154,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) } var currentUserId by remember { mutableStateOf<String?>(null) }
val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(emptyList()) val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(
val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(emptyList()) emptyList()
)
val blacklistedArtists by blacklistRepository.blacklistedArtists.collectAsStateWithLifecycle(
emptyList()
)
val isExpanded =
currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded && getPlatform().isDesktop()
fun isTrackBlacklisted(track: MetadataTrack): Boolean { fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = blacklistedTracks.map { it.id }.toSet() val trackIds = blacklistedTracks.map { it.id }.toSet()
@ -172,7 +188,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
entries = listOf(QueueEntry.StreamingTrack(track = track, url = "")), entries = listOf(QueueEntry.StreamingTrack(track = track, url = "")),
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null
)
} }
} }
@ -188,7 +205,14 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
if (existing != null) { if (existing != null) {
// Already in the local queue: move it to the next position // Already in the local queue: move it to the next position
audioPlayerQueue.removeFromQueue(existing) audioPlayerQueue.removeFromQueue(existing)
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) audioPlayerQueue.addAllAfterCurrent(
listOf(
QueueEntry.StreamingTrack(
track = track,
url = ""
)
)
)
} else { } else {
remotePlaybackController.requestTrackPlayNext(track) remotePlaybackController.requestTrackPlayNext(track)
} }
@ -197,6 +221,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
is TrackOptionsAction.AddToQueue -> { is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track) remotePlaybackController.requestTrackAddToQueue(track)
} }
is TrackOptionsAction.AddToJam -> { is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track) remotePlaybackController.addTrackToJam(track)
} }
@ -217,6 +242,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
shareService.share(uri, track.title) shareService.share(uri, track.title)
} }
} }
is TrackOptionsAction.AddToPlaylist -> { is TrackOptionsAction.AddToPlaylist -> {
tracksToAddToPlaylist = listOf(track) tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true showAddToPlaylistPicker = true
@ -236,7 +262,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
isInQueue = queueTrackIds.contains(track.id), isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId, isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id), isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = isTrackBlacklisted(track)) isBlacklisted = isTrackBlacklisted(track)
)
} }
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle(emptyList()) val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle(emptyList())
@ -251,7 +278,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
isInQueue = queueTrackIds.contains(track.id), isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId, isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id), isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = isTrackBlacklisted(track)) isBlacklisted = isTrackBlacklisted(track)
)
} }
fun bulkAddToQueue(tracks: List<MetadataTrack>) { fun bulkAddToQueue(tracks: List<MetadataTrack>) {
@ -269,8 +297,26 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MillenniumTheme.colors.background)) { .background(MillenniumTheme.colors.background)
ApplicationMainBar(backButton = false) .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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -279,25 +325,28 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
Column( Column(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
SearchBar( if (isExpanded) {
query = state.query, SearchBar(
onQueryChange = viewModel::onQueryChange, query = state.query,
recentSearches = state.recentSearches, onQueryChange = viewModel::onQueryChange,
onRecentSearchClick = { search -> recentSearchSuggestions = state.recentSearchSuggestions,
viewModel.applyRecentSearch(search) onRecentSearchClick = { search ->
focusManager.clearFocus() viewModel.applyRecentSearch(search)
}, focusManager.clearFocus()
onRecentSearchRemove = viewModel::removeRecentSearch, },
onClearAllRecentSearches = { onClearAllRecentSearches = {
viewModel.clearAllRecentSearches() viewModel.clearAllRecentSearches()
focusManager.clearFocus() focusManager.clearFocus()
}) }
)
}
if (state.supportedSearchTypes.isNotEmpty()) { if (state.supportedSearchTypes.isNotEmpty()) {
SearchTabs( SearchTabs(
types = state.supportedSearchTypes, types = state.supportedSearchTypes,
selectedType = selectedType, selectedType = selectedType,
onTabSelected = viewModel::onTabSelected) onTabSelected = viewModel::onTabSelected
)
} }
} }
@ -305,14 +354,16 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
state.isLoadingSearchTypes -> { state.isLoadingSearchTypes -> {
SearchLoadingIndicator( SearchLoadingIndicator(
modifier = Modifier modifier = Modifier
.fillMaxSize()) .fillMaxSize()
)
} }
selectedType == null -> { selectedType == null -> {
SearchMessage( SearchMessage(
message = "No search types available", message = "No search types available",
modifier = Modifier modifier = Modifier
.fillMaxSize()) .fillMaxSize()
)
} }
selectedType == MetadataSupportedSearchType.ALL -> { selectedType == MetadataSupportedSearchType.ALL -> {
@ -348,7 +399,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
// No-op for now, could show a dialog with all artists // No-op for now, could show a dialog with all artists
}, },
modifier = Modifier modifier = Modifier
.fillMaxSize()) .fillMaxSize()
)
} }
selectedType == MetadataSupportedSearchType.TRACK -> { selectedType == MetadataSupportedSearchType.TRACK -> {
@ -379,7 +431,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
// No-op for now, could show a dialog with all artists // No-op for now, could show a dialog with all artists
}, },
modifier = Modifier modifier = Modifier
.fillMaxSize()) .fillMaxSize()
)
} }
selectedType == MetadataSupportedSearchType.PLAYLIST -> { selectedType == MetadataSupportedSearchType.PLAYLIST -> {
@ -388,7 +441,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
playlists = state.playlists, playlists = state.playlists,
onLoadNextPage = viewModel::loadNextPlaylists, onLoadNextPage = viewModel::loadNextPlaylists,
modifier = Modifier modifier = Modifier
.fillMaxSize()) .fillMaxSize()
)
} }
selectedType == MetadataSupportedSearchType.ALBUM -> { selectedType == MetadataSupportedSearchType.ALBUM -> {
@ -397,7 +451,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
albums = state.albums, albums = state.albums,
onLoadNextPage = viewModel::loadNextAlbums, onLoadNextPage = viewModel::loadNextAlbums,
modifier = Modifier modifier = Modifier
.fillMaxSize()) .fillMaxSize()
)
} }
selectedType == MetadataSupportedSearchType.ARTIST -> { selectedType == MetadataSupportedSearchType.ARTIST -> {
@ -406,7 +461,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
artists = state.artists, artists = state.artists,
onLoadNextPage = viewModel::loadNextArtists, onLoadNextPage = viewModel::loadNextArtists,
modifier = Modifier modifier = Modifier
.fillMaxSize()) .fillMaxSize()
)
} }
selectedType == MetadataSupportedSearchType.USER -> { selectedType == MetadataSupportedSearchType.USER -> {
@ -415,7 +471,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
users = state.users, users = state.users,
onLoadNextPage = viewModel::loadNextUsers, onLoadNextPage = viewModel::loadNextUsers,
modifier = Modifier modifier = Modifier
.fillMaxSize()) .fillMaxSize()
)
} }
} }
} }
@ -439,11 +496,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
private fun SearchBar( private fun SearchBar(
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
recentSearches: List<String>, recentSearchSuggestions: List<String>,
onRecentSearchClick: (String) -> Unit, onRecentSearchClick: (String) -> Unit,
onRecentSearchRemove: (String) -> Unit, onClearAllRecentSearches: () -> Unit
onClearAllRecentSearches: () -> Unit) { ) {
val showRecentSearches = query.isBlank() && recentSearches.isNotEmpty() val hasRecentSearchSuggestions = recentSearchSuggestions.isNotEmpty()
val searchState = rememberTextFieldState(query) val searchState = rememberTextFieldState(query)
LaunchedEffect(searchState) { LaunchedEffect(searchState) {
@ -455,106 +512,61 @@ private fun SearchBar(
} }
} }
val suggestions = remember(showRecentSearches, recentSearches) { MillenniumAutocomplete(
buildList { state = searchState,
if (showRecentSearches) { suggestions = recentSearchSuggestions,
add(SearchDropdownEntry.ClearAll(onClick = onClearAllRecentSearches)) onSuggestionSelected = onRecentSearchClick,
recentSearches.forEach { search -> suggestionLabel = { it },
add( leadingContent = {
SearchDropdownEntry.RecentSearch( MillenniumIcon(
query = search, imageVector = Iconsax.InconsaxClock,
onClick = { onRecentSearchClick(search) }, contentDescription = null,
onRemove = { onRecentSearchRemove(search) }) modifier = Modifier.size(16.dp),
) tint = AppTheme.colors.onSurfaceVariant
} )
} },
} placeholder = stringResource(Res.string.search_placeholder),
}
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(bottom = 8.dp)) { .padding(bottom = 8.dp),
MillenniumAutocomplete( menuHeader = if (hasRecentSearchSuggestions) {
state = searchState, {
MillenniumListTile(
suggestions = suggestions, title = {
onSuggestionSelected = { entry -> entry.onClick() }, MillenniumText(
suggestionLabel = { entry -> text = stringResource(Res.string.search_clear_history),
when (entry) { color = AppTheme.colors.error,
is SearchDropdownEntry.ClearAll -> "Clear all history" style = AppTheme.typography.bodyMedium
is SearchDropdownEntry.RecentSearch -> entry.query )
} },
}, leading = {
placeholder = "Search songs, artists, albums...", MillenniumIcon(
modifier = Modifier.fillMaxWidth(), imageVector = Iconsax.IconsaxTrash,
suggestionContent = { entry, _ -> SearchSuggestionRow(entry) }) contentDescription = null,
} modifier = Modifier.size(16.dp),
} tint = AppTheme.colors.error
)
private sealed interface SearchDropdownEntry { },
val onClick: () -> Unit onClick = onClearAllRecentSearches,
minimumHeight = 36.dp,
data class ClearAll(override val onClick: () -> Unit) : SearchDropdownEntry contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
data class RecentSearch( leadingSpacing = 8.dp,
val query: String, shape = AppTheme.shapes.small
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))
}
} }
} else {
null
} }
} )
} }
@Composable @Composable
private fun SearchTabs( private fun SearchTabs(
types: List<MetadataSupportedSearchType>, types: List<MetadataSupportedSearchType>,
selectedType: MetadataSupportedSearchType?, selectedType: MetadataSupportedSearchType?,
onTabSelected: (MetadataSupportedSearchType) -> Unit) { onTabSelected: (MetadataSupportedSearchType) -> Unit
) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
LazyRow( LazyRow(
state = listState, state = listState,
@ -570,7 +582,8 @@ private fun SearchTabs(
MillenniumButtonStyle.Primary MillenniumButtonStyle.Primary
} else { } else {
MillenniumButtonStyle.Secondary MillenniumButtonStyle.Secondary
}) { }
) {
Text(type.tabTitle()) Text(type.tabTitle())
} }
} }
@ -600,7 +613,8 @@ private fun SearchAllTab(
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier
) {
if (query.isBlank()) { if (query.isBlank()) {
SearchMessage(message = "Start typing to search", modifier = modifier) SearchMessage(message = "Start typing to search", modifier = modifier)
return return
@ -624,7 +638,8 @@ private fun SearchAllTab(
ErrorDisplay( ErrorDisplay(
errorMessage = hasAnyError, errorMessage = hasAnyError,
onRetry = { }, onRetry = { },
modifier = modifier) modifier = modifier
)
return return
} }
@ -665,7 +680,8 @@ private fun SearchAllTab(
text = "Tracks", text = "Tracks",
style = AppTheme.typography.titleLarge, style = AppTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, 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 (MetadataSupportedSearchType.TRACK in supportedSearchTypes) {
if (tracks.isLoading && tracks.items.isEmpty()) { if (tracks.isLoading && tracks.items.isEmpty()) {
@ -743,7 +759,8 @@ private fun SearchTracksTab(
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier
) {
if (query.isBlank()) { if (query.isBlank()) {
SearchMessage(message = "Start typing to search tracks", modifier = modifier) SearchMessage(message = "Start typing to search tracks", modifier = modifier)
return return
@ -775,7 +792,8 @@ private fun SearchTracksTab(
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick, onArtistsOverflowClick = onArtistsOverflowClick,
simplified = true, simplified = true,
modifier = modifier) modifier = modifier
)
} }
@Composable @Composable
@ -783,7 +801,8 @@ private fun SearchAlbumsTab(
query: String, query: String,
albums: SearchPagedState<MetadataAlbum.Basic>, albums: SearchPagedState<MetadataAlbum.Basic>,
onLoadNextPage: () -> Unit, onLoadNextPage: () -> Unit,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier
) {
SearchGridTab( SearchGridTab(
query = query, query = query,
items = albums.items, items = albums.items,
@ -793,7 +812,8 @@ private fun SearchAlbumsTab(
onLoadNextPage = onLoadNextPage, onLoadNextPage = onLoadNextPage,
emptyMessage = "No albums found", emptyMessage = "No albums found",
loadingMessage = "Loading albums...", loadingMessage = "Loading albums...",
modifier = modifier) { album -> modifier = modifier
) { album ->
AlbumCard(album = album, modifier = Modifier.fillMaxWidth()) AlbumCard(album = album, modifier = Modifier.fillMaxWidth())
} }
} }
@ -803,7 +823,8 @@ private fun SearchArtistsTab(
query: String, query: String,
artists: SearchPagedState<MetadataArtist.Basic>, artists: SearchPagedState<MetadataArtist.Basic>,
onLoadNextPage: () -> Unit, onLoadNextPage: () -> Unit,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier
) {
SearchGridTab( SearchGridTab(
query = query, query = query,
items = artists.items, items = artists.items,
@ -813,7 +834,8 @@ private fun SearchArtistsTab(
onLoadNextPage = onLoadNextPage, onLoadNextPage = onLoadNextPage,
emptyMessage = "No artists found", emptyMessage = "No artists found",
loadingMessage = "Loading artists...", loadingMessage = "Loading artists...",
modifier = modifier) { artist -> modifier = modifier
) { artist ->
ArtistCard(artist = artist, modifier = Modifier.fillMaxWidth()) ArtistCard(artist = artist, modifier = Modifier.fillMaxWidth())
} }
} }
@ -823,7 +845,8 @@ private fun SearchPlaylistsTab(
query: String, query: String,
playlists: SearchPagedState<MetadataPlaylist>, playlists: SearchPagedState<MetadataPlaylist>,
onLoadNextPage: () -> Unit, onLoadNextPage: () -> Unit,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier
) {
SearchGridTab( SearchGridTab(
query = query, query = query,
items = playlists.items, items = playlists.items,
@ -833,7 +856,8 @@ private fun SearchPlaylistsTab(
onLoadNextPage = onLoadNextPage, onLoadNextPage = onLoadNextPage,
emptyMessage = "No playlists found", emptyMessage = "No playlists found",
loadingMessage = "Loading playlists...", loadingMessage = "Loading playlists...",
modifier = modifier) { playlist -> modifier = modifier
) { playlist ->
PlaylistCard(playlist = playlist, modifier = Modifier.fillMaxWidth()) PlaylistCard(playlist = playlist, modifier = Modifier.fillMaxWidth())
} }
} }
@ -843,7 +867,8 @@ private fun SearchUsersTab(
query: String, query: String,
users: SearchPagedState<MetadataUser>, users: SearchPagedState<MetadataUser>,
onLoadNextPage: () -> Unit, onLoadNextPage: () -> Unit,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier
) {
SearchGridTab( SearchGridTab(
query = query, query = query,
items = users.items, items = users.items,
@ -853,7 +878,8 @@ private fun SearchUsersTab(
onLoadNextPage = onLoadNextPage, onLoadNextPage = onLoadNextPage,
emptyMessage = "No users found", emptyMessage = "No users found",
loadingMessage = "Loading users...", loadingMessage = "Loading users...",
modifier = modifier) { user -> modifier = modifier
) { user ->
UserCard(user = user, modifier = Modifier.fillMaxWidth()) UserCard(user = user, modifier = Modifier.fillMaxWidth())
} }
} }
@ -870,7 +896,8 @@ private fun <T> SearchGridTab(
emptyMessage: String, emptyMessage: String,
loadingMessage: String, loadingMessage: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
itemContent: @Composable (T) -> Unit) { itemContent: @Composable (T) -> Unit
) {
if (query.isBlank()) { if (query.isBlank()) {
SearchMessage(message = "Start typing to search", modifier = modifier) SearchMessage(message = "Start typing to search", modifier = modifier)
return return
@ -897,14 +924,16 @@ private fun <T> SearchGridTab(
isLoading && items.isEmpty() -> { isLoading && items.isEmpty() -> {
SearchLoadingIndicator( SearchLoadingIndicator(
message = loadingMessage, message = loadingMessage,
modifier = modifier) modifier = modifier
)
} }
error != null && items.isEmpty() -> { error != null && items.isEmpty() -> {
ErrorDisplay( ErrorDisplay(
errorMessage = error, errorMessage = error,
onRetry = onLoadNextPage, onRetry = onLoadNextPage,
modifier = modifier) modifier = modifier
)
} }
!isLoading && items.isEmpty() -> { !isLoading && items.isEmpty() -> {
@ -918,7 +947,8 @@ private fun <T> SearchGridTab(
modifier = modifier.padding(horizontal = 12.dp), modifier = modifier.padding(horizontal = 12.dp),
contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset), contentPadding = PaddingValues(top = 12.dp, bottom = 16.dp + bottomInset),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)) { verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(items) { item -> items(items) { item ->
itemContent(item) itemContent(item)
} }
@ -929,7 +959,8 @@ private fun <T> SearchGridTab(
PlayableCard( PlayableCard(
title = "Sample Title", title = "Sample Title",
subtitle = "Sample Subtitle", subtitle = "Sample Subtitle",
imageURL = "https://placehold.co/600x400") imageURL = "https://placehold.co/600x400"
)
} }
} }
} }
@ -942,32 +973,38 @@ private fun <T> SearchGridTab(
private fun SearchHorizontalSection( private fun SearchHorizontalSection(
title: String, title: String,
onSeeAll: () -> Unit, onSeeAll: () -> Unit,
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit) { content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit
) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)) { verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) { horizontalArrangement = Arrangement.SpaceBetween
) {
Text( Text(
text = title, text = title,
style = AppTheme.typography.titleLarge, style = AppTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold) fontWeight = FontWeight.SemiBold
)
Text( Text(
text = "See all", text = "See all",
style = AppTheme.typography.labelLarge, style = AppTheme.typography.labelLarge,
color = AppTheme.colors.primary, color = AppTheme.colors.primary,
modifier = Modifier.clickable(onClick = onSeeAll)) modifier = Modifier.clickable(onClick = onSeeAll)
)
} }
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
content = content) content = content
)
} }
} }
@ -975,31 +1012,37 @@ private fun SearchHorizontalSection(
private fun SearchTrackRow( private fun SearchTrackRow(
index: Int, index: Int,
track: MetadataTrack, track: MetadataTrack,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier
) {
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)) { horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text( Text(
text = (index + 1).toString(), text = (index + 1).toString(),
style = AppTheme.typography.labelMedium, style = AppTheme.typography.labelMedium,
color = AppTheme.colors.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
textAlign = TextAlign.End) textAlign = TextAlign.End
)
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)) { verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text( Text(
text = track.title, text = track.title,
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
maxLines = 1) maxLines = 1
)
Text( Text(
text = track.artists.joinToString { it.name }, text = track.artists.joinToString { it.name },
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1) maxLines = 1
)
} }
if (track.album != null) Text( if (track.album != null) Text(
@ -1007,7 +1050,8 @@ private fun SearchTrackRow(
style = AppTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1, maxLines = 1,
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f)
)
} }
} }
@ -1018,27 +1062,33 @@ private val WarmSearchMessages = listOf(
"Dusting off the vinyl...", "Dusting off the vinyl...",
"Consulting the music gods...", "Consulting the music gods...",
"Tuning the antennas...", "Tuning the antennas...",
"Flipping through the records...") "Flipping through the records..."
)
@Composable @Composable
private fun SearchLoadingIndicator( private fun SearchLoadingIndicator(
message: String? = null, message: String? = null,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier
) {
val infiniteTransition = rememberInfiniteTransition(label = "search-loading") val infiniteTransition = rememberInfiniteTransition(label = "search-loading")
val rotation by infiniteTransition.animateFloat( val rotation by infiniteTransition.animateFloat(
initialValue = 0f, initialValue = 0f,
targetValue = 360f, targetValue = 360f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1500, easing = LinearEasing), animation = tween(durationMillis = 1500, easing = LinearEasing),
repeatMode = RepeatMode.Restart), repeatMode = RepeatMode.Restart
label = "icon-rotation") ),
label = "icon-rotation"
)
val bounce by infiniteTransition.animateFloat( val bounce by infiniteTransition.animateFloat(
initialValue = -8f, initialValue = -8f,
targetValue = 8f, targetValue = 8f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800, easing = LinearEasing), animation = tween(durationMillis = 800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse), repeatMode = RepeatMode.Reverse
label = "icon-bounce") ),
label = "icon-bounce"
)
val messageIndex by infiniteTransition.animateFloat( val messageIndex by infiniteTransition.animateFloat(
initialValue = 0f, initialValue = 0f,
targetValue = WarmSearchMessages.size.toFloat(), targetValue = WarmSearchMessages.size.toFloat(),
@ -1047,18 +1097,22 @@ private fun SearchLoadingIndicator(
durationMillis = WarmSearchMessages.size * 2000, durationMillis = WarmSearchMessages.size * 2000,
easing = LinearEasing easing = LinearEasing
), ),
repeatMode = RepeatMode.Restart), repeatMode = RepeatMode.Restart
label = "message-cycle") ),
label = "message-cycle"
)
val displayMessage = val displayMessage =
message ?: WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size] message ?: WarmSearchMessages[messageIndex.toInt() % WarmSearchMessages.size]
Column( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center) { verticalArrangement = Arrangement.Center
) {
Box( Box(
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center
) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxSearchBroken, imageVector = Iconsax.IconsaxSearchBroken,
contentDescription = "Searching", contentDescription = "Searching",
@ -1073,18 +1127,21 @@ private fun SearchLoadingIndicator(
MillenniumIndeterminateProgress( MillenniumIndeterminateProgress(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 16.dp)) .padding(horizontal = 32.dp, vertical = 16.dp)
)
AnimatedContent( AnimatedContent(
targetState = displayMessage, targetState = displayMessage,
transitionSpec = { transitionSpec = {
fadeIn(tween(300)) togetherWith fadeOut(tween(300)) fadeIn(tween(300)) togetherWith fadeOut(tween(300))
}, },
label = "message-transition") { msg -> label = "message-transition"
) { msg ->
Text( Text(
text = msg, text = msg,
color = AppTheme.colors.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
style = AppTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
textAlign = TextAlign.Center) textAlign = TextAlign.Center
)
} }
} }
} }
@ -1093,30 +1150,35 @@ private fun SearchLoadingIndicator(
private fun SearchMessage( private fun SearchMessage(
message: String, message: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isError: Boolean = false) { isError: Boolean = false
) {
Box( Box(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center
) {
Text( Text(
text = message, text = message,
color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
style = AppTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)) modifier = Modifier.padding(16.dp)
)
} }
} }
@Composable @Composable
private fun SearchMessageInline( private fun SearchMessageInline(
message: String, message: String,
isError: Boolean = false) { isError: Boolean = false
) {
Text( Text(
text = message, text = message,
color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant, color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
style = AppTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp)) .padding(horizontal = 16.dp)
)
} }
@Composable @Composable

View File

@ -59,7 +59,14 @@ data class SearchScreenState(
val albums: SearchPagedState<MetadataAlbum.Basic> = SearchPagedState(), val albums: SearchPagedState<MetadataAlbum.Basic> = SearchPagedState(),
val artists: SearchPagedState<MetadataArtist.Basic> = SearchPagedState(), val artists: SearchPagedState<MetadataArtist.Basic> = SearchPagedState(),
val playlists: SearchPagedState<MetadataPlaylist> = 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) @OptIn(ExperimentalCoroutinesApi::class)
class SearchScreenViewModel( class SearchScreenViewModel(

View File

@ -18,8 +18,6 @@
package dev.krtirtho.spotube.modules.settings.components package dev.krtirtho.spotube.modules.settings.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import com.composeunstyled.currentWindowWidthBreakpoint import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints import org.spotube.millennium.adaptive.WindowBreakpoints
@ -65,7 +63,8 @@ internal fun SwitchSettingCard(
subtitle: String? = null, subtitle: String? = null,
checked: Boolean, checked: Boolean,
onCheckedChange: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit,
icon: (@Composable () -> Unit)? = null) { icon: (@Composable () -> Unit)? = null
) {
SettingCardItem( SettingCardItem(
title = title, title = title,
subtitle = subtitle, subtitle = subtitle,
@ -74,10 +73,8 @@ internal fun SwitchSettingCard(
MillenniumSwitch( MillenniumSwitch(
checked = checked, checked = checked,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
// The row itself already renders the title/subtitle, so the switch must not contentSpacing = 0.dp
// draw a second visible label next to it. )
label = "",
contentSpacing = 0.dp)
}, },
onClick = { onClick = {
onCheckedChange(!checked) onCheckedChange(!checked)
@ -96,7 +93,8 @@ internal fun <T> SelectionSettingCard(
dialogTitle: String = title, dialogTitle: String = title,
icon: (@Composable () -> Unit)? = null, icon: (@Composable () -> Unit)? = null,
inlineSelectorMinWidth: Dp = 700.dp, inlineSelectorMinWidth: Dp = 700.dp,
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) { filter: ((AdaptiveMenuItem, String) -> Boolean)? = null
) {
var isDialogOpen by remember { mutableStateOf(false) } var isDialogOpen by remember { mutableStateOf(false) }
Box { Box {
@ -112,24 +110,29 @@ internal fun <T> SelectionSettingCard(
AdaptiveMenuItem( AdaptiveMenuItem(
label = optionLabel(option), label = optionLabel(option),
onClick = { onOptionSelected(option) }, onClick = { onOptionSelected(option) },
selected = option == selectedOption) selected = option == selectedOption
)
}, },
trigger = { onClick -> trigger = { onClick ->
MillenniumButton( MillenniumButton(
onClick = onClick, onClick = onClick,
style = MillenniumButtonStyle.Secondary) { style = MillenniumButtonStyle.Secondary
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)) { horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(optionLabel(selectedOption)) Text(optionLabel(selectedOption))
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxArrowDown4, imageVector = Iconsax.IconsaxArrowDown4,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(14.dp)) modifier = Modifier.size(14.dp)
)
} }
} }
}, },
filter = filter) filter = filter
)
}, },
onClick = { onClick = {
if (isWideLayout) { if (isWideLayout) {
@ -143,7 +146,8 @@ internal fun <T> SelectionSettingCard(
MillenniumDialog( MillenniumDialog(
visible = true, visible = true,
onDismissRequest = { isDialogOpen = false }, onDismissRequest = { isDialogOpen = false },
title = dialogTitle) { title = dialogTitle
) {
MillenniumRadioGroup( MillenniumRadioGroup(
value = selectedOption, value = selectedOption,
onValueChange = { option -> onValueChange = { option ->
@ -155,7 +159,8 @@ internal fun <T> SelectionSettingCard(
optionLabel = { option -> labels[options.indexOf(option)] }) optionLabel = { option -> labels[options.indexOf(option)] })
MillenniumButton( MillenniumButton(
onClick = { isDialogOpen = false }, onClick = { isDialogOpen = false },
style = MillenniumButtonStyle.Quiet) { style = MillenniumButtonStyle.Quiet
) {
Text(stringResource(Res.string.settings_action_close)) Text(stringResource(Res.string.settings_action_close))
} }
} }
@ -177,7 +182,8 @@ internal fun TextInputSettingCard(
icon: (@Composable () -> Unit)? = null, icon: (@Composable () -> Unit)? = null,
inlineTextFieldMinWidth: Dp = 700.dp, inlineTextFieldMinWidth: Dp = 700.dp,
inlineTextFieldWidth: Dp = 220.dp, inlineTextFieldWidth: Dp = 220.dp,
enabled: Boolean = true) { enabled: Boolean = true
) {
var isDialogOpen by remember { mutableStateOf(false) } var isDialogOpen by remember { mutableStateOf(false) }
Box { Box {
@ -204,10 +210,10 @@ internal fun TextInputSettingCard(
{ {
MillenniumTextField( MillenniumTextField(
state = inlineState, state = inlineState,
label = title,
modifier = Modifier.width(inlineTextFieldWidth), modifier = Modifier.width(inlineTextFieldWidth),
placeholder = placeholder, placeholder = placeholder,
enabled = enabled) enabled = enabled
)
} }
} else { } else {
null null
@ -235,19 +241,22 @@ internal fun TextInputSettingCard(
MillenniumDialog( MillenniumDialog(
visible = true, visible = true,
onDismissRequest = { isDialogOpen = false }, onDismissRequest = { isDialogOpen = false },
title = dialogTitle) { title = dialogTitle
) {
if (dialogDescription != null) { if (dialogDescription != null) {
Text( Text(
text = dialogDescription, text = dialogDescription,
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant) color = AppTheme.colors.onSurfaceVariant
)
} }
MillenniumTextField( MillenniumTextField(
state = dialogState, state = dialogState,
label = title, label = title,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = placeholder, placeholder = placeholder,
error = errorMessage) error = errorMessage
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumButton( MillenniumButton(
onClick = { onClick = {
@ -256,12 +265,14 @@ internal fun TextInputSettingCard(
isDialogOpen = false isDialogOpen = false
} }
}, },
enabled = errorMessage == null) { enabled = errorMessage == null
) {
Text(stringResource(Res.string.settings_action_save)) Text(stringResource(Res.string.settings_action_save))
} }
MillenniumButton( MillenniumButton(
onClick = { isDialogOpen = false }, onClick = { isDialogOpen = false },
style = MillenniumButtonStyle.Secondary) { style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.settings_action_cancel)) Text(stringResource(Res.string.settings_action_cancel))
} }
} }

View File

@ -35,7 +35,9 @@ import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
fun AppBottombar( fun AppBottombar(
navigator: Navigator, navigator: Navigator,
navigationState: NavigationState, navigationState: NavigationState,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier,
libraryBadge: @Composable () -> Unit = { DownloadBadgeIndicator() }
) {
MillenniumBottomNavigationBar(modifier = modifier) { MillenniumBottomNavigationBar(modifier = modifier) {
bottombarTabs.filterIsInstance<NavigationItem.Tab<*>>() bottombarTabs.filterIsInstance<NavigationItem.Tab<*>>()
.forEach { tab -> .forEach { tab ->
@ -48,14 +50,16 @@ fun AppBottombar(
MillenniumIcon( MillenniumIcon(
imageVector = tab.icon, imageVector = tab.icon,
contentDescription = null, contentDescription = null,
tint = LocalTextStyle.current.color) tint = LocalTextStyle.current.color
)
}, },
label = { Text(tab.title) }, label = { Text(tab.title) },
badge = if (tab.route == Routes.Library) { badge = if (tab.route == Routes.Library) {
{ DownloadBadgeIndicator() } libraryBadge
} else { } else {
null null
}) }
)
} }
} }
} }

View File

@ -23,7 +23,8 @@ import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumProgress import org.spotube.millennium.composables.MillenniumProgress
import org.spotube.millennium.theme.millenniumSurface 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.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import dev.krtirtho.spotube.core.ui.theming.AppTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable 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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue 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.SavedTracksViewModel
import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import dev.krtirtho.spotube.resources.iconsax.Iconsax 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.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay 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 kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface 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 // The floating player on small screens that appears above the floating AppBottombar
// //
// on left the album art, song title, artists grouped together // on left the album art, song title, artists grouped together
@ -94,17 +92,12 @@ fun AppFloatingPlayer(
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>( savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID, key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf() } parameters = { parametersOf() }
)) { )
) {
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val albumArtModifier = val albumArtModifier =
rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope) 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() { fun onPlayPause() {
scope.launch { 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( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(76.dp) .height(FloatingPlayerContentHeight + FloatingPlayerBottomBleed)
.millenniumSurface( .millenniumSurface(
background = SolidColor(AppTheme.colors.inverseSurface), background = millenniumPanelBrush(MillenniumTheme.colors),
shape = RoundedCornerShape(24.dp, 24.dp))) { shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
Box( borderColor = MillenniumTheme.colors.border,
borderWidth = 1.dp,
)
) {
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize()) { .fillMaxSize()
// Only the surface extends beneath the navigation bar, not the controls.
.padding(bottom = FloatingPlayerBottomBleed)
) {
MillenniumProgress( MillenniumProgress(
progress = seekProgress.coerceIn(0f, 1f), progress = playerUiState.progress,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(2.dp) .height(2.dp)
) )
Spacer(modifier = Modifier.height(10.dp))
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 10.dp) .padding(horizontal = 12.dp),
.padding(bottom = 22.dp, top = 8.dp)
.align(Alignment.Center),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -148,30 +170,36 @@ fun AppFloatingPlayer(
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(42.dp)
.then(albumArtModifier) .then(albumArtModifier)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(6.dp))
.background(AppTheme.colors.onSurface.copy(alpha = 0.12f)) .background(AppTheme.colors.onSurface.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center
) { ) {
AsyncImage( if (coverModel != null) AsyncImage(
model = coverModel, model = coverModel,
contentDescription = playerUiState.title, contentDescription = playerUiState.title,
modifier = Modifier modifier = Modifier
.fillMaxSize(), .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)) { Column(modifier = Modifier.padding(start = 10.dp)) {
Text( Text(
text = playerUiState.title, text = if (playerUiState.queue.isEmpty()) stringResource(Res.string.player_nothing_playing) else playerUiState.title,
style = AppTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.inverseOnSurface, color = AppTheme.colors.onSurface,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( if (playerUiState.queue.isNotEmpty()) Text(
text = playerUiState.artists, text = playerUiState.artists,
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.inverseOnSurface.copy(alpha = 0.72f), color = AppTheme.colors.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@ -179,17 +207,17 @@ fun AppFloatingPlayer(
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
PlayerHeartButton( heartButton()
audioPlayerQueue = audioPlayerQueue,
savedTracksViewModel = savedTracksViewModel)
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
MillenniumIconButton( MillenniumIconButton(
onClick = ::onPlayPause, onClick = onPlayPause,
enabled = playerUiState.queue.isNotEmpty(), enabled = playerUiState.queue.isNotEmpty(),
style = MillenniumIconButtonStyle.Secondary) { style = MillenniumIconButtonStyle.Secondary
) {
MillenniumIcon( MillenniumIcon(
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, 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 @Composable
private fun rememberSharedAlbumArtModifier( private fun rememberSharedAlbumArtModifier(
sharedTransitionScope: SharedTransitionScope?, sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?): Modifier { animatedVisibilityScope: AnimatedVisibilityScope?
): Modifier {
if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier
with(sharedTransitionScope) { with(sharedTransitionScope) {
return Modifier.sharedElement( return Modifier.sharedElement(
sharedContentState = rememberSharedContentState(key = "player_album_art"), sharedContentState = rememberSharedContentState(key = "player_album_art"),
animatedVisibilityScope = animatedVisibilityScope) animatedVisibilityScope = animatedVisibilityScope
)
} }
} }

View File

@ -30,13 +30,13 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
@ -84,7 +85,8 @@ fun AppShell(
viewModel: AppShellViewModel = koinViewModel<AppShellViewModel>(), viewModel: AppShellViewModel = koinViewModel<AppShellViewModel>(),
queueViewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(), queueViewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(),
alternativeViewModel: AlternativeTrackContentViewModel = koinViewModel<AlternativeTrackContentViewModel>(), alternativeViewModel: AlternativeTrackContentViewModel = koinViewModel<AlternativeTrackContentViewModel>(),
content: @Composable () -> Unit) { content: @Composable () -> Unit
) {
val navigatorCommands: NavigationCommands = koinInject() val navigatorCommands: NavigationCommands = koinInject()
val remotePlaybackController: RemotePlaybackController = koinInject() val remotePlaybackController: RemotePlaybackController = koinInject()
val snackbarHostState = remember { MillenniumSnackbarHostState() } val snackbarHostState = remember { MillenniumSnackbarHostState() }
@ -128,7 +130,13 @@ fun AppShell(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
val useSidebar = viewModel.useSidebar() 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) { CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) {
if (useSidebar) { if (useSidebar) {
@ -150,8 +158,11 @@ fun AppShell(
) )
MillenniumSeparator( MillenniumSeparator(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
vertical = true) vertical = true
Box(modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)) { )
Box(
modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)
) {
content() content()
} }
} }
@ -184,19 +195,23 @@ fun AppShell(
QueueSheet( QueueSheet(
isVisible = isQueueVisible, isVisible = isQueueVisible,
onDismiss = { queueViewModel.setQueueVisibility(false) }, onDismiss = { queueViewModel.setQueueVisibility(false) },
modifier = Modifier.fillMaxSize()) { modifier = Modifier.fillMaxSize()
) {
PlayerQueueContent( PlayerQueueContent(
viewModel = queueViewModel, viewModel = queueViewModel,
modifier = Modifier.fillMaxSize()) modifier = Modifier.fillMaxSize()
)
} }
AlternativeTrackSheet( AlternativeTrackSheet(
isVisible = isAlternativeVisible, isVisible = isAlternativeVisible,
onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, onDismiss = { alternativeViewModel.setAlternativeVisibility(false) },
modifier = Modifier.fillMaxSize()) { modifier = Modifier.fillMaxSize()
) {
AlternativeTrackContent( AlternativeTrackContent(
viewModel = alternativeViewModel, viewModel = alternativeViewModel,
modifier = Modifier.fillMaxSize()) modifier = Modifier.fillMaxSize()
)
} }
} }
} else { } else {
@ -204,7 +219,8 @@ fun AppShell(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MillenniumTheme.colors.background)) .background(MillenniumTheme.colors.background)
)
CompactPlayerOverlay( CompactPlayerOverlay(
navigator = navigator, navigator = navigator,
navigationState = navigationState, navigationState = navigationState,
@ -212,7 +228,8 @@ fun AppShell(
onQueue = queueViewModel::toggleQueueVisibility, onQueue = queueViewModel::toggleQueueVisibility,
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility, onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility,
onExpandLyrics = viewModel::showLyricsOverlay, onExpandLyrics = viewModel::showLyricsOverlay,
content = content) content = content
)
QueueSheet( QueueSheet(
isVisible = isQueueVisible, isVisible = isQueueVisible,
@ -221,16 +238,19 @@ fun AppShell(
) { ) {
PlayerQueueContent( PlayerQueueContent(
viewModel = queueViewModel, viewModel = queueViewModel,
modifier = Modifier.fillMaxSize()) modifier = Modifier.fillMaxSize()
)
} }
AlternativeTrackSheet( AlternativeTrackSheet(
isVisible = isAlternativeVisible, isVisible = isAlternativeVisible,
onDismiss = { alternativeViewModel.setAlternativeVisibility(false) }, onDismiss = { alternativeViewModel.setAlternativeVisibility(false) },
modifier = Modifier.fillMaxSize()) { modifier = Modifier.fillMaxSize()
) {
AlternativeTrackContent( AlternativeTrackContent(
viewModel = alternativeViewModel, viewModel = alternativeViewModel,
modifier = Modifier.fillMaxSize()) modifier = Modifier.fillMaxSize()
)
} }
if (isLyricsOverlayVisible) { if (isLyricsOverlayVisible) {
@ -239,8 +259,13 @@ fun AppShell(
properties = DialogProperties( properties = DialogProperties(
usePlatformDefaultWidth = false, usePlatformDefaultWidth = false,
dismissOnBackPress = true, dismissOnBackPress = true,
dismissOnClickOutside = true)) { dismissOnClickOutside = true
Box(modifier = Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) { )
) {
Box(
modifier = Modifier.fillMaxSize()
.background(MillenniumTheme.colors.background)
) {
LyricsScreen( LyricsScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onClose = { viewModel.hideLyricsOverlay() }) onClose = { viewModel.hideLyricsOverlay() })
@ -257,38 +282,56 @@ fun AppShell(
hostState = snackbarHostState, hostState = snackbarHostState,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = bottomOverlayInset + 12.dp)) .padding(bottom = bottomOverlayInset + 12.dp)
)
} }
} }
@OptIn( @OptIn(
ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class
)
@Composable @Composable
private fun CompactPlayerOverlay( internal fun CompactPlayerOverlay(
navigator: Navigator, navigator: Navigator,
navigationState: NavigationState, navigationState: NavigationState,
viewModel: AppShellViewModel, viewModel: AppShellViewModel,
onQueue: () -> Unit, onQueue: () -> Unit,
onAlternativeSource: () -> Unit, onAlternativeSource: () -> Unit,
onExpandLyrics: () -> 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 density = LocalDensity.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val chromeUiState by viewModel.compactChromeUiState.collectAsState()
val floatingPlayerHeightPx = with(density) { viewModel.floatingPlayerHeight.toPx() } val floatingPlayerHeightPx = with(density) { viewModel.floatingPlayerHeight.toPx() }
val bottomBarHeightPx = with(density) { viewModel.bottomBarHeight.toPx() }
val navBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat() 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 peekHeight = with(density) { peekHeightPx.toDp() }
val partialDetent = remember(peekHeight) { val partialDetent = remember(peekHeight) {
SheetDetent("player-peek") { _, _ -> peekHeight } SheetDetent("player-peek") { _, _ -> peekHeight }
} }
val sheetState = rememberBottomSheetState( // The library remembers its detent definitions at creation. Recreate the state when
initialDetent = partialDetent, // the measured chrome height changes, rather than keeping a stale peek anchor.
detents = listOf(partialDetent, SheetDetent.FullyExpanded)) 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) sheetState.progress(partialDetent, SheetDetent.FullyExpanded)
}.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f }.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f
val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f)) val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f))
@ -298,7 +341,8 @@ private fun CompactPlayerOverlay(
UnstyledBottomSheet( UnstyledBottomSheet(
state = sheetState, state = sheetState,
modifier = Modifier.fillMaxSize()) { modifier = Modifier.fillMaxSize()
) {
Sheet(modifier = Modifier.fillMaxSize()) { Sheet(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
if (uiState.showExpandedPlayer) { if (uiState.showExpandedPlayer) {
@ -311,14 +355,14 @@ private fun CompactPlayerOverlay(
}, },
onQueue = onQueue, onQueue = onQueue,
onAlternativeSource = onAlternativeSource, onAlternativeSource = onAlternativeSource,
onExpandLyrics = onExpandLyrics) onExpandLyrics = onExpandLyrics
)
} }
if (uiState.floatingPlayerAlpha > 0.01f) { if (uiState.floatingPlayerAlpha > 0.01f) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
AppFloatingPlayer( floatingPlayer(
modifier = Modifier Modifier
.offset(y = 12.dp)
.graphicsLayer { .graphicsLayer {
alpha = uiState.floatingPlayerAlpha alpha = uiState.floatingPlayerAlpha
}) })
@ -329,12 +373,16 @@ private fun CompactPlayerOverlay(
} }
if (uiState.floatingPlayerAlpha > 0.01f) { if (uiState.floatingPlayerAlpha > 0.01f) {
AppBottombar( bottomBar(
navigator = navigator, Modifier
navigationState = navigationState, .fillMaxWidth()
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).graphicsLayer { .align(Alignment.BottomCenter)
alpha = uiState.floatingPlayerAlpha .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 showExpandedPlayer: Boolean,
val expandedPlayerAlpha: Float, val expandedPlayerAlpha: Float,
val floatingPlayerAlpha: Float, val floatingPlayerAlpha: Float,
val bottomBarAlpha: Float) val bottomBarAlpha: Float
)
data class LyricsOverlayState( data class LyricsOverlayState(
val isVisible: Boolean = false) val isVisible: Boolean = false
)
data class CompactChromeUiState(val measuredBottomBarHeight: Dp? = null)
class AppShellViewModel : ViewModel() { class AppShellViewModel : ViewModel() {
val sidebarMinWidth = 840.dp val sidebarMinWidth = 840.dp
val bottomBarEstimatedHeight = 58.dp
val bottomBarHeight = 80.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 largePlayerInset = 100.dp
val floatingPlayerDismissProgress = 0.7f val floatingPlayerDismissProgress = 0.7f
val expandedPlayerVisibilityThreshold = 0.001f val expandedPlayerVisibilityThreshold = 0.001f
private val lyricsOverlayVisible = MutableStateFlow(false) 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 isLyricsOverlayVisible: StateFlow<Boolean> = lyricsOverlayVisible.asStateFlow()
val floatingPlayerBottomOffset: Dp
get() = bottomBarEstimatedHeight + 8.dp
val compactChromeInset: Dp
get() = floatingPlayerBottomOffset + floatingPlayerHeight + 10.dp
@Composable @Composable
fun useSidebar(maxWidth: Dp? = null): Boolean { fun useSidebar(maxWidth: Dp? = null): Boolean {
if (maxWidth != null) { if (maxWidth != null) {
@ -67,8 +71,14 @@ class AppShellViewModel : ViewModel() {
return currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded return currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded
} }
fun bottomOverlayInset(useSidebar: Boolean): Dp = fun bottomOverlayInset(
if (useSidebar) largePlayerInset else compactChromeInset useSidebar: Boolean,
navigationBarInset: Dp,
chrome: CompactChromeUiState
): Dp =
if (useSidebar) largePlayerInset + navigationBarInset
else (chrome.measuredBottomBarHeight ?: (bottomBarHeight + navigationBarInset)) +
floatingPlayerHeight
fun sheetProgress(sheetOffsetPx: Float, sheetHeightPx: Float): Float = fun sheetProgress(sheetOffsetPx: Float, sheetHeightPx: Float): Float =
if (sheetHeightPx > 0f) { if (sheetHeightPx > 0f) {
@ -88,7 +98,8 @@ class AppShellViewModel : ViewModel() {
showExpandedPlayer = progress > expandedPlayerVisibilityThreshold, showExpandedPlayer = progress > expandedPlayerVisibilityThreshold,
expandedPlayerAlpha = progress, expandedPlayerAlpha = progress,
floatingPlayerAlpha = floatingPlayerAlpha(progress), floatingPlayerAlpha = floatingPlayerAlpha(progress),
bottomBarAlpha = 1f - progress) bottomBarAlpha = 1f - progress
)
} }
fun showLyricsOverlay() { 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2 import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2 import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
import dev.krtirtho.spotube.sidebarTabs import dev.krtirtho.spotube.sidebarTabs
import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.Font
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject 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.MillenniumSidebar
import org.spotube.millennium.composables.MillenniumSidebarContent import org.spotube.millennium.composables.MillenniumSidebarContent
import org.spotube.millennium.composables.MillenniumSidebarFooter import org.spotube.millennium.composables.MillenniumSidebarFooter
@ -89,7 +93,7 @@ fun AppSidebar(
) { ) {
MillenniumSidebarHeader { MillenniumSidebarHeader {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center, horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -104,10 +108,19 @@ fun AppSidebar(
color = AppTheme.colors.onSurface 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 -> sidebarTabs.forEach { entry ->
when (entry) { when (entry) {
is NavigationItem.Group<*> -> { is NavigationItem.Group<*> -> {
@ -115,13 +128,17 @@ fun AppSidebar(
MillenniumSidebarGroup( MillenniumSidebarGroup(
expanded = isGroupExpanded, expanded = isGroupExpanded,
collapsible = true, collapsible = true,
groupSpacing = 0.dp,
onExpandedChange = { groupExpansion[entry.title] = it }) { onExpandedChange = { groupExpansion[entry.title] = it }) {
MillenniumSidebarGroupLabel(entry.title) MillenniumSidebarGroupLabel(entry.title)
MillenniumSidebarGroupContent { MillenniumSidebarGroupContent(
MillenniumSidebarMenu { contentSpacing = 0.dp
) {
MillenniumSidebarMenu(contentSpacing = 0.dp) {
entry.items.forEach { item -> entry.items.forEach { item ->
val selected = navigationState.topLevelRoute == item.route && val selected =
(item.route != Routes.Library || currentLibraryTab == item.data) navigationState.topLevelRoute == item.route &&
(item.route != Routes.Library || currentLibraryTab == item.data)
SidebarNavItem( SidebarNavItem(
item = item, item = item,
selected = selected, selected = selected,
@ -154,13 +171,15 @@ fun AppSidebar(
label = supportLabel, label = supportLabel,
onClick = { openSupport() }, onClick = { openSupport() },
contentDescription = supportLabel, contentDescription = supportLabel,
icon = Iconsax.IconsaxHeart2) icon = Iconsax.IconsaxHeart2
)
MillenniumSidebarItem( MillenniumSidebarItem(
label = stringResource(Res.string.settings_screen_title), label = stringResource(Res.string.settings_screen_title),
onClick = { navigator.navigate(Routes.Settings) }, onClick = { navigator.navigate(Routes.Settings) },
selected = navigationState.topLevelRoute == Routes.Settings, selected = navigationState.topLevelRoute == Routes.Settings,
contentDescription = stringResource(Res.string.settings_screen_title), contentDescription = stringResource(Res.string.settings_screen_title),
icon = Iconsax.IconsaxSetting2) icon = Iconsax.IconsaxSetting2
)
} }
} }
} }
@ -169,11 +188,13 @@ fun AppSidebar(
private fun SidebarNavItem( private fun SidebarNavItem(
item: NavigationItem.Tab<*>, item: NavigationItem.Tab<*>,
selected: Boolean, selected: Boolean,
onClick: () -> Unit) { onClick: () -> Unit
) {
MillenniumSidebarItem( MillenniumSidebarItem(
label = item.title, label = item.title,
onClick = onClick, onClick = onClick,
selected = selected, selected = selected,
contentDescription = item.title, contentDescription = item.title,
icon = item.icon) icon = item.icon
)
} }

View File

@ -34,6 +34,9 @@ import org.spotube.millennium.composables.MillenniumModalBottomSheet
import org.spotube.millennium.composables.rememberMillenniumDrawerState import org.spotube.millennium.composables.rememberMillenniumDrawerState
private val SlidingSheetBreakpoint = 840.dp private val SlidingSheetBreakpoint = 840.dp
private val MaxHeightSheetDetent = SheetDetent("max-height-85-percent") { containerHeight, sheetHeight ->
minOf(sheetHeight, containerHeight * 0.85f)
}
@Composable @Composable
fun QueueSheet( fun QueueSheet(
@ -85,7 +88,10 @@ internal fun BottomSheetContent(
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
if (!isVisible) return if (!isVisible) return
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded) val sheetState = rememberModalBottomSheetState(
initialDetent = MaxHeightSheetDetent,
detents = listOf(SheetDetent.Hidden, MaxHeightSheetDetent)
)
LaunchedEffect(sheetState.bottomSheetState.currentDetent) { LaunchedEffect(sheetState.bottomSheetState.currentDetent) {
if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) { if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) {
onDismiss() onDismiss()

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 package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.layout.RowScope 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 org.spotube.millennium.composables.MillenniumTopAppBar
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -33,10 +35,11 @@ actual fun ApplicationMainBar(
title = title, title = title,
subtitle = subtitle, subtitle = subtitle,
actions = actions, actions = actions,
windowInsets = WindowInsets.statusBars,
navigationIcon = { navigationIcon = {
if (backButton) { if (backButton) {
ApplicationBackButton() ApplicationBackButton()
} }
} }
) )
} }

View File

@ -19,12 +19,19 @@
kotlin.code.style=official kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M kotlin.daemon.jvmargs=-Xmx3072M
kotlin.mpp.stability.nowarn=true kotlin.mpp.stability.nowarn=true
kotlin.incremental=true
#Gradle #Gradle
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
org.gradle.caching=true org.gradle.caching=true
#Android #Android
android.nonTransitiveRClass=true 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
[versions] [versions]
agp = "8.13.2" agp = "9.1.0"
android-compileSdk = "37" android-compileSdk = "37"
android-minSdk = "28" android-minSdk = "28"
android-targetSdk = "36" android-targetSdk = "36"
@ -193,4 +193,4 @@ uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" }
neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" } neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" }