Compare commits

...

5 Commits

36 changed files with 2034 additions and 1104 deletions

View File

@ -108,6 +108,7 @@ kotlin {
}
jvm()
jvmToolchain(21)
sourceSets {
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_no_queue_entries">No queue entries</string>
<string name="search_placeholder">Search songs, artists, albums...</string>
<string name="search_clear_history">Clear all history</string>
<string name="search_filter_placeholder">Search...</string>
<!-- Library toolbar -->
<string name="library_screen_title">Your Library</string>
<string name="library_tab_playlists">Playlists</string>
<string name="library_tab_albums">Albums</string>
<string name="library_tab_artists">Artists</string>
<string name="library_tab_local_tracks">Local Tracks</string>
<string name="library_tab_downloads">Downloads</string>
<string name="library_filter_playlists">Search saved playlists…</string>
<string name="library_filter_albums">Search saved albums…</string>
<string name="library_filter_artists">Search saved artists…</string>
<string name="library_filter_local_tracks">Search folders or tracks…</string>
<string name="library_action_search">Filter library</string>
<string name="library_action_close_search">Close filter</string>
<string name="library_action_create_playlist">Create playlist</string>
<string name="library_action_refresh_local_tracks">Refresh local tracks</string>
<string name="accessibility_selected">Selected</string>
<string name="settings_screen_title">Settings</string>
<string name="settings_section_plugins">Plugins</string>
@ -228,11 +250,45 @@
<string name="settings_jam_section_identity">Identity</string>
<string name="settings_jam_section_diagnostics">Diagnostics</string>
<!-- Group Jam -->
<string name="jam_screen_title">Group Jam</string>
<string name="jam_setup_create_tab">Create</string>
<string name="jam_setup_join_tab">Join</string>
<string name="jam_setup_create_hint">Create a room, then share its code with your friends.</string>
<string name="jam_setup_create_button">Create room</string>
<string name="jam_setup_join_button">Join room</string>
<string name="jam_setup_working">Connecting…</string>
<string name="jam_setup_code_label">Room code</string>
<string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %1$s</string>
<string name="jam_connection_missing">Set up a broker in Settings to host or join a room.</string>
<string name="jam_connection_settings">Broker settings</string>
<string name="jam_session_connected">Connected</string>
<string name="jam_session_connecting">Reconnecting…</string>
<string name="jam_invite_copy">Copy code</string>
<string name="jam_invite_copied">Copied!</string>
<string name="jam_invite_share">Share</string>
<string name="jam_participants_heading">Participants (%1$d)</string>
<string name="jam_participants_host">Host</string>
<string name="jam_participants_guest">Listener</string>
<string name="jam_participants_waiting">Waiting for listeners…</string>
<string name="jam_participants_remove">Remove</string>
<string name="jam_participants_ban">Ban</string>
<string name="jam_participants_manage">Manage</string>
<string name="jam_session_leave">Leave room</string>
<string name="jam_error_title">Couldn’t connect</string>
<string name="jam_error_dismiss">Dismiss</string>
<string name="jam_error_create">Failed to create jam session</string>
<string name="jam_error_join">Failed to join jam session</string>
<string name="jam_error_invalid_code">Enter a six-character room code using letters and digits.</string>
<string name="jam_share_message">Join my Spotube Jam with code: %1$s</string>
<string name="player_go_to_album">Go to album</string>
<string name="player_sleep_timer">Sleep timer</string>
<string name="player_track_details">Track details</string>
<string name="player_play_content_description">Play</string>
<string name="player_pause_content_description">Pause</string>
<string name="player_nothing_playing">Nothing playing</string>
<string name="player_add_to_queue_content_description">Add to queue</string>
<string name="update_action_ignore">Ignore</string>

View File

@ -19,23 +19,17 @@ package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -45,19 +39,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composeunstyled.DropdownMenuPanel
import com.composeunstyled.SheetDetent
import com.composeunstyled.Text
import com.composeunstyled.UnstyledDropdownMenu
import com.composeunstyled.UnstyledDropdownMenuItem
import com.composeunstyled.rememberModalBottomSheetState
import compose.icons.FeatherIcons
import compose.icons.feathericons.Check
@ -67,16 +55,19 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import org.jetbrains.compose.resources.stringResource
import org.spotube.millennium.composables.MillenniumDropdownMenu
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumModalBottomSheet
import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumText
import org.spotube.millennium.composables.MillenniumTextField
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.gelBrush
import org.spotube.millennium.theme.millenniumPanelBrush
import org.spotube.millennium.theme.millenniumSurface
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.accessibility_selected
import spotube.composeapp.generated.resources.search_filter_placeholder
data class AdaptiveMenuItem(
val icon: ImageVector? = null,
@ -108,118 +99,17 @@ fun AdaptiveDropdownBottomSheet(
Box(modifier = modifier) {
if (isLargeScreen) {
UnstyledDropdownMenu(
MillenniumDropdownMenu(
expanded = expanded,
onExpandedChange = { expanded = it },
panel = {
DropdownMenuPanel(
Modifier
.width(menuMinWidth)
.millenniumSurface(
background = millenniumPanelBrush(MillenniumTheme.colors),
shape = AppTheme.shapes.medium,
borderColor = AppTheme.colors.outlineVariant,
borderWidth = 1.dp)
.padding(4.dp)) {
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
header()
}
val dropdownQueryState = rememberTextFieldState()
val query = dropdownQueryState.text.toString()
if (filter != null) {
MillenniumTextField(
state = dropdownQueryState,
label = "",
placeholder = "Search...",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp))
}
items.forEach { item ->
if (query.isNotBlank() && filter != null && !filter(item, query)) {
return@forEach
}
if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
}
val itemInteraction = remember { MutableInteractionSource() }
val itemHovered by itemInteraction.collectIsHoveredAsState()
val itemPressed by itemInteraction.collectIsPressedAsState()
val itemEnabled = item.enabled
val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected)
val itemForeground = when {
!itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f)
itemHighlighted -> MillenniumTheme.colors.onAccent
else -> AppTheme.colors.onSurface
}
UnstyledDropdownMenuItem(
onClick = { item.onClick() },
enabled = itemEnabled,
indication = null,
interactionSource = itemInteraction) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (itemHighlighted) {
Modifier.millenniumSurface(
background = gelBrush(
MillenniumTheme.colors.accentTop,
MillenniumTheme.colors.accentBottom),
shape = RoundedCornerShape(8.dp))
} else {
Modifier
})
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
val itemIcon = item.icon
when {
item.leadingContent != null -> item.leadingContent.invoke()
itemIcon != null -> MillenniumIcon(
imageVector = itemIcon,
contentDescription = null,
tint = itemForeground,
modifier = Modifier.size(20.dp))
item.selected -> MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
tint = itemForeground,
modifier = Modifier.size(20.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.label,
style = AppTheme.typography.bodyMedium,
color = itemForeground)
item.subtitle?.let { subtitle ->
Text(
text = subtitle,
style = AppTheme.typography.bodySmall,
color = if (itemHighlighted) {
itemForeground.copy(alpha = 0.85f)
} else {
AppTheme.colors.onSurfaceVariant
})
}
}
if (item.selected && (item.icon != null || item.leadingContent != null)) {
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
tint = itemForeground,
modifier = Modifier.size(20.dp))
}
}
}
}
}
},
anchor = { trigger { expanded = true } },
)
items = items.map { it.label },
onItemClick = { label ->
items.firstOrNull { it.label == label && it.enabled }?.onClick?.invoke()
expanded = false
},
minimumWidth = menuMinWidth,
anchor = { trigger { expanded = true } }
)
} else {
trigger { expanded = true }
if (expanded) {
@ -267,7 +157,7 @@ private fun AdaptiveBottomSheetContent(
MillenniumTextField(
state = queryState,
label = "",
placeholder = "Search...",
placeholder = stringResource(Res.string.search_filter_placeholder),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp))
@ -285,72 +175,69 @@ private fun AdaptiveBottomSheetContent(
if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(AppTheme.shapes.medium)
.clickable(
enabled = item.enabled,
onClick = {
item.onClick()
onDismiss()
})
.background(
color = if (item.selected) {
AppTheme.colors.secondary.copy(alpha = 0.12f)
} else {
Color.Transparent
},
shape = AppTheme.shapes.medium)
.padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
when {
item.leadingContent != null -> item.leadingContent.invoke()
item.icon != null -> MillenniumIcon(
imageVector = item.icon,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = if (item.enabled) {
AppTheme.colors.onSurface
} else {
AppTheme.colors.onSurface.copy(alpha = 0.38f)
})
item.selected -> MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
modifier = Modifier.size(22.dp),
tint = AppTheme.colors.onSurfaceVariant)
hasSelection -> Spacer(modifier = Modifier.size(22.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.label,
MillenniumListTile(
title = {
MillenniumText(
item.label,
style = AppTheme.typography.bodyLarge,
color = if (item.enabled) {
AppTheme.colors.onSurface
} else {
AppTheme.colors.onSurface.copy(alpha = 0.38f)
})
item.subtitle?.let { subtitle ->
Text(
text = subtitle,
}
)
},
subtitle = item.subtitle?.let { subtitle ->
{
MillenniumText(
subtitle,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
color = AppTheme.colors.onSurfaceVariant
)
}
}
if (item.selected && (item.icon != null || item.leadingContent != null)) {
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
modifier = Modifier.size(22.dp),
tint = AppTheme.colors.onSurfaceVariant)
}
}
},
leading = when {
item.leadingContent != null -> item.leadingContent
item.icon != null -> {
{
MillenniumIcon(
imageVector = item.icon,
contentDescription = null,
modifier = Modifier.size(22.dp)
)
}
}
item.selected -> {
{
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = stringResource(Res.string.accessibility_selected),
modifier = Modifier.size(22.dp)
)
}
}
hasSelection -> {{ Spacer(modifier = Modifier.size(22.dp)) }}
else -> null
},
trailing = if (item.selected && (item.icon != null || item.leadingContent != null)) {
{
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = stringResource(Res.string.accessibility_selected),
modifier = Modifier.size(22.dp)
)
}
} else {
null
},
selected = item.selected,
enabled = item.enabled,
onClick = {
item.onClick()
onDismiss()
},
minimumHeight = 56.dp
)
}
}
}

View File

@ -24,15 +24,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -44,16 +37,21 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composeunstyled.Text
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
@Composable
fun CollectionView(

View File

@ -36,7 +36,12 @@ import androidx.compose.ui.unit.sp
import dev.krtirtho.spotube.modules.settings.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings
import org.spotube.millennium.composables.MillenniumTooltipHost
import org.spotube.millennium.composables.MillenniumTopAppBarTheme
import org.spotube.millennium.composables.MillenniumTopAppBarThemeProperties
import org.spotube.millennium.composables.defaultMillenniumTopAppBarTheme
import org.spotube.millennium.theme.MillenniumColors
import org.spotube.millennium.theme.MillenniumDarkThemeComponents
import org.spotube.millennium.theme.MillenniumLightThemeComponents
import org.spotube.millennium.theme.MillenniumPalette
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.MillenniumThemeConfig
@ -201,7 +206,29 @@ fun SpotubeTheme(
}
val palette: MillenniumPalette = settings.palette
MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) {
val darkTheme = MillenniumDarkThemeComponents
val lightTheme = MillenniumLightThemeComponents
MillenniumTheme(
dark = isDarkTheme,
config = MillenniumThemeConfig(
palette = palette,
dark = darkTheme.copy(
topAppBarTheme = MillenniumTopAppBarTheme(
properties = darkTheme.topAppBarTheme.properties.copy(
separatorColor = Color.Transparent
)
)
),
light = lightTheme.copy(
topAppBarTheme = MillenniumTopAppBarTheme(
properties = lightTheme.topAppBarTheme.properties.copy(
separatorColor = Color.Transparent
)
)
)
)
) {
val colors = appColorsFrom(MillenniumTheme.colors)
MillenniumTooltipHost {
CompositionLocalProvider(

View File

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

View File

@ -17,311 +17,468 @@
package dev.krtirtho.spotube.modules.jam
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumSegmentedButton
import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults
import org.spotube.millennium.composables.MillenniumSegmentedButtonRow
import org.spotube.millennium.composables.MillenniumTextField
import com.composeunstyled.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomCode
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumSegmentedButton
import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults
import org.spotube.millennium.composables.MillenniumSegmentedButtonRow
import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumTextField
import org.spotube.millennium.composables.MillenniumTooltip
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.millenniumBevelBrush
import org.spotube.millennium.theme.millenniumPanelBrush
import org.spotube.millennium.theme.millenniumSurface
import spotube.composeapp.generated.resources.*
/**
* Group Jam session screen. Playback controls and the queue live in the app's
* regular player / queue sheet (the shared jam queue is the local queue), so
* this screen only covers participation and session management.
*/
@Composable
fun JamScreen(
navigationCommands: NavigationCommands) {
fun JamScreen(navigationCommands: NavigationCommands) {
val viewModel = koinViewModel<JamViewModel>()
val state by viewModel.uiState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current
val shellBottomInset = LocalAppShellBottomInset.current
Column(
modifier = Modifier
.fillMaxSize()
.background(MillenniumTheme.colors.background)) {
ApplicationMainBar(
backButton = true,
title = { Text("Group Jam") })
Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) {
ApplicationMainBar(title = { Text(stringResource(Res.string.jam_screen_title)) })
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(16.dp)
.padding(bottom = shellBottomInset)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)) {
state.error?.let { error ->
ErrorBanner(text = error, onDismiss = viewModel::clearError)
}
when {
!state.isActive -> CreateOrJoinView(
state = state,
onCreate = viewModel::createSession,
onJoin = viewModel::joinWithCode)
else -> SessionView(
state = state,
onShareCode = viewModel::shareRoomCode,
onLeave = viewModel::leave,
onKick = viewModel::kickParticipant,
onBan = viewModel::banParticipant)
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(top = 20.dp, bottom = shellBottomInset + 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
Modifier.widthIn(max = 520.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
state.error?.let { error ->
JamPanel {
Text(
error,
color = AppTheme.colors.error,
style = AppTheme.typography.bodySmall
)
MillenniumButton(
onClick = viewModel::clearError,
style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.jam_error_dismiss))
}
}
}
if (state.isActive) {
SessionPanel(
state = state,
onCopy = {
clipboard.setText(AnnotatedString(state.roomCode.orEmpty()))
viewModel.onCodeCopied()
},
onShare = viewModel::shareRoomCode,
onLeave = viewModel::leave,
onKick = viewModel::kickParticipant,
onBan = viewModel::banParticipant,
onMenuChange = viewModel::showParticipantMenu
)
} else {
SetupPanel(
state,
viewModel::selectSetupMode,
viewModel::createSession,
viewModel::joinSession
)
}
BrokerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) }
}
}
}
}
@Composable
private fun ErrorBanner(text: String, onDismiss: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = text,
color = AppTheme.colors.error,
style = AppTheme.typography.bodyMedium)
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Secondary) {
Text("Dismiss")
}
}
private fun JamPanel(content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier.fillMaxWidth().millenniumSurface(
background = millenniumPanelBrush(MillenniumTheme.colors),
shape = RoundedCornerShape(10.dp),
borderColor = MillenniumTheme.colors.border,
borderWidth = 1.dp,
innerGlowColor = MillenniumTheme.colors.highlight.copy(alpha = 0.25f)
).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
content = content
)
}
@Composable
private fun CreateOrJoinView(
internal fun SetupPanel(
state: JamUiState,
onMode: (JamSetupMode) -> Unit,
onCreate: () -> Unit,
onJoin: (String) -> Unit) {
var tab by rememberSaveable { mutableIntStateOf(0) }
val codeState = rememberTextFieldState()
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.",
style = AppTheme.typography.titleMedium)
if (!state.brokerConfigured) {
onJoin: () -> Unit
) {
JamPanel {
MillenniumSegmentedButtonRow(Modifier.fillMaxWidth()) {
MillenniumSegmentedButton(
selected = state.setupMode == JamSetupMode.Create,
onClick = { onMode(JamSetupMode.Create) },
shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)
) { Text(stringResource(Res.string.jam_setup_create_tab)) }
MillenniumSegmentedButton(
selected = state.setupMode == JamSetupMode.Join,
onClick = { onMode(JamSetupMode.Join) },
shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)
) { Text(stringResource(Res.string.jam_setup_join_tab)) }
}
if (state.setupMode == JamSetupMode.Create) {
SharedRoomIllustration(Modifier.fillMaxWidth().height(100.dp))
Text(
text = "No jam broker configured — set one up in Settings to host or join a session.",
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.error)
} else {
Text(
text = "Broker: ${state.brokerHost}",
stringResource(Res.string.jam_setup_create_hint),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
}
MillenniumSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
MillenniumSegmentedButton(
selected = tab == 0,
onClick = { tab = 0 },
shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)) { Text("Create") }
MillenniumSegmentedButton(
selected = tab == 1,
onClick = { tab = 1 },
shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)) { Text("Join") }
}
if (tab == 0) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Start a session as the host. You'll get a 6-character room code to " +
"share with friends; you control the queue.",
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant)
MillenniumButton(
onClick = onCreate,
enabled = state.brokerConfigured) {
Text("Create Session")
}
}
color = AppTheme.colors.onSurfaceVariant
)
} else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Enter the 6-character room code the host shared with you.",
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant)
MillenniumTextField(
state = codeState,
label = "Room code",
placeholder = "ABC123",
modifier = Modifier.fillMaxWidth())
val normalizedCode = JamRoomCode.normalize(codeState.text.toString())
MillenniumButton(
onClick = { onJoin(normalizedCode) },
enabled = state.brokerConfigured && JamRoomCode.isValid(normalizedCode)) {
Text("Join Session")
}
}
MillenniumTextField(
state = state.codeInput,
label = stringResource(Res.string.jam_setup_code_label),
placeholder = stringResource(Res.string.jam_setup_code_placeholder),
enabled = !state.isBusy,
textStyle = AppTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
letterSpacing = 4.sp
),
modifier = Modifier.fillMaxWidth()
)
}
MillenniumButton(
onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin,
enabled = if (state.setupMode == JamSetupMode.Create) state.canCreate else state.canJoin,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(
when {
state.isBusy -> Res.string.jam_setup_working
state.setupMode == JamSetupMode.Create -> Res.string.jam_setup_create_button
else -> Res.string.jam_setup_join_button
}
)
)
}
}
}
/** A small media-player diagram: one source feeding two listeners. No stock artwork. */
@Composable
private fun SessionView(
private fun SharedRoomIllustration(modifier: Modifier) {
val colors = MillenniumTheme.colors
Canvas(modifier) {
val scale = minOf(size.width / 260f, size.height / 100f)
val origin = Offset((size.width - 260f * scale) / 2f, (size.height - 100f * scale) / 2f)
fun point(x: Float, y: Float) = origin + Offset(x * scale, y * scale)
val stroke = 1.2f * scale
val metal = Brush.verticalGradient(
listOf(colors.raised, colors.surface),
startY = origin.y,
endY = origin.y + 100f * scale
)
drawLine(colors.border, point(45f, 50f), point(215f, 50f), 2f * scale)
drawLine(colors.accent.copy(alpha = 0.7f), point(45f, 49f), point(215f, 49f), stroke)
// Two miniature speakers, with an accent power LED and concentric drivers.
for (x in listOf(15f, 207f)) {
drawRoundRect(
metal,
point(x, 18f),
Size(38f * scale, 64f * scale),
CornerRadius(5f * scale)
)
drawRoundRect(
colors.border,
point(x, 18f),
Size(38f * scale, 64f * scale),
CornerRadius(5f * scale),
style = Stroke(stroke)
)
drawCircle(colors.background, 10f * scale, point(x + 19f, 57f))
drawCircle(colors.border, 10f * scale, point(x + 19f, 57f), style = Stroke(stroke))
drawCircle(colors.raised, 5f * scale, point(x + 19f, 57f))
drawCircle(colors.background, 4f * scale, point(x + 19f, 34f))
drawCircle(colors.accent, 1.5f * scale, point(x + 19f, 74f))
}
// Brushed-metal source unit with a backlit equalizer display.
drawRoundRect(
metal,
point(82f, 10f),
Size(96f * scale, 80f * scale),
CornerRadius(8f * scale)
)
drawRoundRect(
colors.border,
point(82f, 10f),
Size(96f * scale, 80f * scale),
CornerRadius(8f * scale),
style = Stroke(stroke)
)
drawRoundRect(
colors.highlight.copy(alpha = 0.25f),
point(86f, 13f),
Size(88f * scale, 1f * scale),
CornerRadius(scale)
)
drawRoundRect(
colors.background,
point(92f, 22f),
Size(76f * scale, 30f * scale),
CornerRadius(3f * scale)
)
val bars = listOf(8f, 16f, 12f, 22f, 18f, 10f, 15f, 7f)
bars.forEachIndexed { index, height ->
drawRoundRect(
colors.accent.copy(alpha = 0.85f),
point(99f + index * 8f, 47f - height),
Size(4f * scale, height * scale),
CornerRadius(scale)
)
}
drawCircle(colors.background, 11f * scale, point(130f, 70f))
drawCircle(colors.border, 11f * scale, point(130f, 70f), style = Stroke(stroke))
drawPath(Path().apply {
moveTo(point(127f, 65f).x, point(127f, 65f).y)
lineTo(point(135f, 70f).x, point(135f, 70f).y)
lineTo(point(127f, 75f).x, point(127f, 75f).y)
close()
}, colors.text)
for (x in listOf(101f, 159f)) drawRoundRect(
colors.border,
point(x - 4f, 68f),
Size(8f * scale, 4f * scale),
CornerRadius(scale)
)
}
}
@Composable
internal fun SessionPanel(
state: JamUiState,
onShareCode: () -> Unit,
onCopy: () -> Unit,
onShare: () -> Unit,
onLeave: () -> Unit,
onKick: (String) -> Unit,
onBan: (String) -> Unit) {
val isHost = state.role == JamRole.Host
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (!state.isConnected) {
onBan: (String) -> Unit,
onMenuChange: (String?) -> Unit
) {
JamPanel {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
Modifier.size(7.dp).background(
if (state.isConnected) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant,
CircleShape
)
)
Text(
text = "Connecting to the session…",
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant)
stringResource(if (state.isConnected) Res.string.jam_session_connected else Res.string.jam_session_connecting),
style = AppTheme.typography.labelMedium
)
}
ParticipantsSection(
participants = state.participants,
isHost = isHost,
onKick = onKick,
onBan = onBan)
if (isHost) {
MillenniumSeparator()
Column(
Modifier.fillMaxWidth().millenniumSurface(
background = SolidColor(MillenniumTheme.colors.background),
shape = RoundedCornerShape(5.dp),
borderColor = MillenniumTheme.colors.border,
borderWidth = 1.dp
).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "Invite someone",
style = AppTheme.typography.titleSmall)
RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode)
}
MillenniumSeparator()
Text(
text = "The queue and playback controls are in the player at the bottom of the app — " +
"the jam queue is shared with every participant.",
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant)
LeaveButton(onLeave)
}
}
@Composable
private fun ParticipantsSection(
participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>,
isHost: Boolean,
onKick: (String) -> Unit,
onBan: (String) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Participants (${participants.size})",
style = AppTheme.typography.titleSmall)
participants.forEach { participant ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
stringResource(Res.string.jam_setup_code_label),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
SelectionContainer {
Text(
text = participant.displayName,
style = AppTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f))
if (participant.isHost) {
Text(
text = "Host",
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.primary)
}
if (isHost && !participant.isHost) {
MillenniumButton(
onClick = { onKick(participant.id) },
modifier = Modifier.height(32.dp),
style = MillenniumButtonStyle.Secondary) {
Text("Kick", style = AppTheme.typography.labelSmall)
}
MillenniumButton(
onClick = { onBan(participant.id) },
modifier = Modifier.height(32.dp),
style = MillenniumButtonStyle.Secondary) {
Text("Ban", style = AppTheme.typography.labelSmall)
}
}
state.roomCode.orEmpty(),
style = AppTheme.typography.headlineMedium,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
letterSpacing = 5.sp,
color = AppTheme.colors.primary
)
}
}
}
}
@Composable
private fun RoomCodeBox(
code: String,
onShare: () -> Unit) {
val clipboard = LocalClipboardManager.current
val viewModel: JamViewModel = koinViewModel()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SelectionContainer {
Text(
text = code,
style = AppTheme.typography.displaySmall,
fontWeight = FontWeight.Bold,
color = AppTheme.colors.primary,
modifier = Modifier.padding(vertical = 8.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumButton(
onClick = { clipboard.setText(AnnotatedString(code)) }) {
Text("Copy")
}
if (viewModel.supportsNativeShare) {
if (state.isHost) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumButton(
onClick = onCopy,
modifier = Modifier.weight(1f),
style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(if (state.codeCopied) Res.string.jam_invite_copied else Res.string.jam_invite_copy))
}
if (state.supportsNativeShare) MillenniumButton(
onClick = onShare,
style = MillenniumButtonStyle.Secondary) {
Text("Share")
modifier = Modifier.weight(1f),
style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.jam_invite_share))
}
}
}
MillenniumSeparator()
Text(
stringResource(Res.string.jam_participants_heading, state.participants.size),
style = AppTheme.typography.titleSmall
)
Column(
Modifier.fillMaxWidth().millenniumSurface(
background = SolidColor(MillenniumTheme.colors.background),
shape = RoundedCornerShape(5.dp),
borderColor = MillenniumTheme.colors.border,
borderWidth = 1.dp
).padding(10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (state.participants.isEmpty()) Text(
stringResource(Res.string.jam_participants_waiting),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant
)
state.participants.forEach { participant ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
Modifier.size(32.dp).millenniumSurface(
background = millenniumBevelBrush(MillenniumTheme.colors),
shape = CircleShape,
borderColor = MillenniumTheme.colors.border,
borderWidth = 1.dp
), contentAlignment = Alignment.Center
) {
Text(participant.initial, style = AppTheme.typography.labelMedium)
}
Column(Modifier.weight(1f)) {
Text(
participant.displayName,
style = AppTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
stringResource(if (participant.isHost) Res.string.jam_participants_host else Res.string.jam_participants_guest),
style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant
)
}
if (participant.canModerate) {
val removeLabel = stringResource(Res.string.jam_participants_remove)
val banLabel = stringResource(Res.string.jam_participants_ban)
// Keep destructive moderation out of the participant's primary row.
org.spotube.millennium.composables.MillenniumDropdownMenu(
expanded = participant.menuExpanded,
onExpandedChange = { expanded -> onMenuChange(if (expanded) participant.id else null) },
items = listOf(removeLabel, banLabel),
onItemClick = { label ->
if (label == removeLabel) onKick(participant.id) else onBan(
participant.id
)
},
anchor = {
MillenniumButton(
onClick = { onMenuChange(participant.id) },
style = MillenniumButtonStyle.Secondary
) { Text(stringResource(Res.string.jam_participants_manage)) }
}
)
}
}
}
}
MillenniumButton(
onClick = onLeave,
style = MillenniumButtonStyle.Secondary,
modifier = Modifier.align(Alignment.End)
) {
Text(stringResource(Res.string.jam_session_leave))
}
}
}
@Composable
private fun LeaveButton(onLeave: () -> Unit) {
MillenniumButton(
onClick = onLeave,
style = MillenniumButtonStyle.Secondary) {
Text("Leave Session")
internal fun BrokerStatus(state: JamUiState, onConfigure: () -> Unit) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
if (state.brokerConfigured) stringResource(
Res.string.jam_connection_host,
state.brokerHost
) else stringResource(Res.string.jam_connection_missing),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
MillenniumTooltip(stringResource(Res.string.jam_connection_settings)) {
MillenniumIconButton(onClick = onConfigure, style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
Iconsax.IconsaxSetting2,
contentDescription = stringResource(Res.string.jam_connection_settings)
)
}
}
}
}
}

View File

@ -19,6 +19,8 @@ package dev.krtirtho.spotube.modules.jam
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.snapshotFlow
import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.core.jam.JamParticipant
import dev.krtirtho.spotube.core.jam.JamRole
@ -30,27 +32,57 @@ import dev.krtirtho.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import kotlinx.coroutines.Job
import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.*
enum class JamSetupMode { Create, Join }
data class JamParticipantUi(
val id: String,
val displayName: String,
val initial: String,
val isHost: Boolean,
val canModerate: Boolean,
val menuExpanded: Boolean = false
)
data class JamUiState(
val isActive: Boolean = false,
val isConnected: Boolean = false,
val role: JamRole? = null,
val participants: List<JamParticipant> = emptyList(),
val participants: List<JamParticipantUi> = emptyList(),
val roomCode: String? = null,
val brokerHost: String = "",
val brokerConfigured: Boolean = false,
val error: String? = null)
val error: String? = null,
val setupMode: JamSetupMode = JamSetupMode.Create,
val codeInput: TextFieldState = TextFieldState(),
val canCreate: Boolean = false,
val canJoin: Boolean = false,
val isBusy: Boolean = false,
val isHost: Boolean = false,
val supportsNativeShare: Boolean = false,
val codeCopied: Boolean = false
)
class JamViewModel(
private val jamRoomService: JamRoomService,
private val shareService: ShareService,
private val settingsProvider: SettingsProvider) : ViewModel() {
private val settingsProvider: SettingsProvider
) : ViewModel() {
private val _localError = MutableStateFlow<String?>(null)
private val setupMode = MutableStateFlow(JamSetupMode.Create)
private val isBusy = MutableStateFlow(false)
private val codeCopied = MutableStateFlow(false)
private val codeInput = TextFieldState()
private var copyFeedbackJob: Job? = null
private val participantMenu = MutableStateFlow<String?>(null)
val supportsNativeShare: Boolean =
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
@ -62,9 +94,16 @@ class JamViewModel(
jamRoomService.roomCode,
jamRoomService.connectionError,
settingsProvider.settingsState,
_localError) { values ->
_localError,
setupMode,
isBusy,
codeCopied,
snapshotFlow { codeInput.text.toString() },
participantMenu
) { values ->
@Suppress("UNCHECKED_CAST")
val role = values[0] as JamRole?
@Suppress("UNCHECKED_CAST")
val participants = values[1] as List<JamParticipant>
val isConnected = values[2] as Boolean
@ -72,48 +111,108 @@ class JamViewModel(
val connectionError = values[4] as String?
val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings
val localError = values[6] as String?
val busy = values[8] as Boolean
val brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank()
JamUiState(
isActive = role != null,
isConnected = isConnected,
role = role,
participants = participants,
participants = participants.sortedByDescending { it.isHost }.map { participant ->
JamParticipantUi(
id = participant.id,
displayName = participant.displayName,
initial = participant.displayName.firstOrNull()?.uppercase() ?: "?",
isHost = participant.isHost,
canModerate = role == JamRole.Host && !participant.isHost,
menuExpanded = values[11] == participant.id
)
},
roomCode = roomCode,
brokerHost = settings?.jamBroker?.host.orEmpty(),
brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(),
error = localError ?: connectionError)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState())
brokerConfigured = brokerConfigured,
error = localError ?: connectionError,
setupMode = values[7] as JamSetupMode,
isBusy = busy,
codeCopied = values[9] as Boolean,
codeInput = codeInput,
canCreate = brokerConfigured && !busy,
canJoin = brokerConfigured && !busy &&
JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)),
isHost = role == JamRole.Host,
supportsNativeShare = supportsNativeShare
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
JamUiState(codeInput = codeInput)
)
// ---------- Session lifecycle ----------
fun createSession() {
if (isBusy.value) return
isBusy.value = true
viewModelScope.launch {
jamRoomService.createRoom()
.onFailure { e ->
_localError.value = e.message ?: "Failed to create jam session"
}
.onSuccess { _localError.value = null }
try {
jamRoomService.createRoom()
.onFailure { e ->
_localError.value = e.message ?: getString(Res.string.jam_error_create)
}
.onSuccess { _localError.value = null }
} finally {
isBusy.value = false
}
}
}
fun selectSetupMode(mode: JamSetupMode) {
setupMode.value = mode
_localError.value = null
}
fun joinSession() = joinWithCode(codeInput.text.toString())
fun onCodeCopied() {
copyFeedbackJob?.cancel()
copyFeedbackJob = viewModelScope.launch {
codeCopied.value = true
delay(2_000)
codeCopied.value = false
}
}
fun joinWithCode(input: String) {
if (isBusy.value) return
val code = JamRoomCode.normalize(input)
if (!JamRoomCode.isValid(code)) {
_localError.value = "Room codes are ${JamRoomCode.LENGTH} characters (letters and digits)"
viewModelScope.launch {
_localError.value = getString(Res.string.jam_error_invalid_code)
}
return
}
isBusy.value = true
viewModelScope.launch {
jamRoomService.joinRoom(code)
.onFailure { e ->
_localError.value = e.message ?: "Failed to join jam session"
}
.onSuccess { _localError.value = null }
try {
jamRoomService.joinRoom(code)
.onFailure { e ->
_localError.value = e.message ?: getString(Res.string.jam_error_join)
}
.onSuccess { _localError.value = null }
} finally {
isBusy.value = false
}
}
}
fun shareRoomCode() {
val code = uiState.value.roomCode ?: return
shareService.share("Join my Spotube Jam with code: $code", "Spotube Group Jam")
viewModelScope.launch {
shareService.share(
getString(Res.string.jam_share_message, code),
getString(Res.string.jam_screen_title)
)
}
}
fun leave() {
@ -130,10 +229,16 @@ class JamViewModel(
// ---------- Host moderation ----------
fun kickParticipant(participantId: String) {
participantMenu.value = null
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
}
fun banParticipant(participantId: String) {
participantMenu.value = null
viewModelScope.launch { jamRoomService.banParticipant(participantId) }
}
}
fun showParticipantMenu(participantId: String?) {
participantMenu.value = participantId
}
}

View File

@ -17,102 +17,192 @@
package dev.krtirtho.spotube.modules.library
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import com.composeunstyled.Text
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import com.composeunstyled.currentWindowWidthBreakpoint
import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.isDesktop
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.downloads.DownloadsScreen
import dev.krtirtho.spotube.modules.library.album.LibraryAlbumsScreen
import dev.krtirtho.spotube.modules.library.artist.LibraryArtistsScreen
import dev.krtirtho.spotube.modules.library.local_tracks.LibraryLocalTracksScreen
import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsScreen
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.spotube.millennium.adaptive.WindowBreakpoints
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumTextField
import spotube.composeapp.generated.resources.*
@Composable
fun LibraryScreen(
libraryState: LibraryState = koinInject(),
appShellViewModel: AppShellViewModel = koinViewModel()
) {
val currentTab by libraryState.currentTab.collectAsState()
val searchMode by libraryState.searchMode.collectAsState()
val isLargeScreen = appShellViewModel.useSidebar()
fun LibraryScreen(libraryState: LibraryState = koinInject()) {
val state by libraryState.uiState.collectAsStateWithLifecycle()
val expandedDesktop = getPlatform().isDesktop() &&
(currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded)
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
Column(modifier = Modifier.fillMaxSize()) {
Column {
ApplicationMainBar(
backButton = false,
title = {
if (!isLargeScreen) Text("Your Library")
},
actions = {
if (!isLargeScreen) {
MillenniumIconButton(
icon = Iconsax.IconsaxSearchBroken,
contentDescription = "Search",
onClick = {
libraryState.setSearchMode(!searchMode)
},
style = MillenniumIconButtonStyle.Quiet)
}
}
)
if (!isLargeScreen)
Column(
modifier = Modifier.padding(horizontal = 12.dp)
) {
Spacer(modifier = Modifier.height(4.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(LibraryTab.entries.size) { index ->
val tab = LibraryTab.entries[index]
MillenniumButton(
onClick = { libraryState.onTabSelected(tab) },
style = if (currentTab == tab) {
MillenniumButtonStyle.Primary
} else {
MillenniumButtonStyle.Secondary
}) {
Text(tab.title)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Column(Modifier.fillMaxSize()) {
ApplicationMainBar(
backButton = false,
title = { LibraryToolbarTitle(state, expandedDesktop) },
actions = {
LibraryToolbarActions(
state = state,
expandedDesktop = expandedDesktop,
onOpenSearch = libraryState::openSearch,
onCloseSearch = {
focusManager.clearFocus()
keyboard?.hide()
libraryState.closeSearch()
},
onCreatePlaylist = libraryState::showCreatePlaylist,
onRefresh = libraryState::refreshLocalTracks
)
}
)
if (!expandedDesktop) {
LazyRow(
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(LibraryTab.entries) { tab ->
MillenniumButton(
onClick = { libraryState.onTabSelected(tab) },
style = if (state.currentTab == tab) MillenniumButtonStyle.Primary else MillenniumButtonStyle.Secondary
) { Text(stringResource(tab.titleResource)) }
}
}
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
when (state.currentTab) {
LibraryTab.Playlists -> LibraryPlaylistsScreen(
showCreatePlaylist = state.showCreatePlaylist,
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist
)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
) {
when (currentTab) {
LibraryTab.Playlists -> LibraryPlaylistsScreen(searchMode = isLargeScreen || searchMode)
LibraryTab.Albums -> LibraryAlbumsScreen(searchMode = isLargeScreen || searchMode)
LibraryTab.Artists -> LibraryArtistsScreen(searchMode = isLargeScreen || searchMode)
LibraryTab.LocalTracks -> LibraryLocalTracksScreen(searchMode = isLargeScreen || searchMode)
LibraryTab.Albums -> LibraryAlbumsScreen()
LibraryTab.Artists -> LibraryArtistsScreen()
LibraryTab.LocalTracks -> LibraryLocalTracksScreen()
LibraryTab.Downloads -> DownloadsScreen()
}
}
}
}
}
@Composable
internal fun LibraryToolbarTitle(state: LibraryToolbarUiState, expandedDesktop: Boolean) {
if (!expandedDesktop && state.searchMode) {
LibraryFilterField(
state.searchState,
state.currentTab.filterPlaceholder,
Modifier.fillMaxWidth(),
autoFocus = true
)
} else if (!expandedDesktop) {
Text(stringResource(Res.string.library_screen_title))
}
}
@Composable
internal fun RowScope.LibraryToolbarActions(
state: LibraryToolbarUiState,
expandedDesktop: Boolean,
onOpenSearch: () -> Unit,
onCloseSearch: () -> Unit,
onCreatePlaylist: () -> Unit,
onRefresh: () -> Unit
) {
if (!expandedDesktop && state.searchMode) {
MillenniumIconButton(
icon = Iconsax.IconsaxCloseSquare,
contentDescription = stringResource(Res.string.library_action_close_search),
onClick = onCloseSearch,
style = MillenniumIconButtonStyle.Quiet
)
} else {
if (state.canFilter) {
if (expandedDesktop) {
LibraryFilterField(
state.searchState,
state.currentTab.filterPlaceholder,
Modifier.width(300.dp)
)
} else {
MillenniumIconButton(
icon = Iconsax.IconsaxSearchBroken,
contentDescription = stringResource(Res.string.library_action_search),
onClick = onOpenSearch,
style = MillenniumIconButtonStyle.Quiet
)
}
}
if (state.canCreatePlaylist) {
MillenniumIconButton(
icon = Iconsax.IconsaxAddSquare,
contentDescription = stringResource(Res.string.library_action_create_playlist),
onClick = onCreatePlaylist,
style = MillenniumIconButtonStyle.Quiet
)
}
if (state.canRefresh) {
MillenniumIconButton(
icon = Iconsax.IconsaxRefreshRight,
contentDescription = stringResource(Res.string.library_action_refresh_local_tracks),
onClick = onRefresh,
style = MillenniumIconButtonStyle.Quiet
)
}
}
}
@Composable
private fun LibraryFilterField(
state: TextFieldState,
placeholder: StringResource,
modifier: Modifier,
autoFocus: Boolean = false
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(autoFocus) {
if (autoFocus) focusRequester.requestFocus()
}
MillenniumTextField(
state = state,
placeholder = stringResource(placeholder),
minimumHeight = 36.dp,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
modifier = modifier.padding(vertical = 4.dp).focusRequester(focusRequester)
)
}

View File

@ -17,17 +17,90 @@
package dev.krtirtho.spotube.modules.library
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class LibraryState {
val searchMode = MutableStateFlow(false)
data class LibraryToolbarUiState(
val currentTab: LibraryTab = LibraryTab.Playlists,
val searchMode: Boolean = false,
val searchState: TextFieldState = TextFieldState(),
val canFilter: Boolean = true,
val canCreatePlaylist: Boolean = true,
val canRefresh: Boolean = false,
val showCreatePlaylist: Boolean = false
)
/** Shared with the sidebar for the app lifetime; each library tab retains its own filter. */
@OptIn(ExperimentalCoroutinesApi::class)
class LibraryState : ViewModel() {
private val searchMode = MutableStateFlow(false)
val currentTab = MutableStateFlow(LibraryTab.Playlists)
private val searchStates = LibraryTab.entries.associateWith { TextFieldState() }
private val createPlaylistVisible = MutableStateFlow(false)
private val refreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val localRefreshRequests = refreshRequests.asSharedFlow()
val uiState = combine(
currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } },
searchMode,
createPlaylistVisible
) { (tab, query), searching, creating ->
LibraryToolbarUiState(
currentTab = tab,
searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()),
searchState = searchStates.getValue(tab),
canFilter = tab != LibraryTab.Downloads,
canCreatePlaylist = tab == LibraryTab.Playlists,
canRefresh = tab == LibraryTab.LocalTracks,
showCreatePlaylist = tab == LibraryTab.Playlists && creating
)
}.stateIn(
viewModelScope, SharingStarted.Eagerly,
LibraryToolbarUiState(searchState = searchStates.getValue(LibraryTab.Playlists))
)
fun queryFlow(tab: LibraryTab) = snapshotFlow { searchStates.getValue(tab).text.toString() }
fun setQuery(tab: LibraryTab, query: String) {
searchStates.getValue(tab).setTextAndPlaceCursorAtEnd(query)
}
fun onTabSelected(tab: LibraryTab) {
currentTab.value = tab
createPlaylistVisible.value = false
if (tab == LibraryTab.Downloads) searchMode.value = false
}
fun setSearchMode(enabled: Boolean) {
searchMode.value = enabled
fun openSearch() {
if (currentTab.value != LibraryTab.Downloads) searchMode.value = true
}
fun closeSearch() {
setQuery(currentTab.value, "")
searchMode.value = false
}
fun showCreatePlaylist() {
if (currentTab.value == LibraryTab.Playlists) createPlaylistVisible.value = true
}
fun dismissCreatePlaylist() {
createPlaylistVisible.value = false
}
fun refreshLocalTracks() {
refreshRequests.tryEmit(Unit)
}
}

View File

@ -24,11 +24,31 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
import dev.krtirtho.spotube.resources.iconsax.User
import org.jetbrains.compose.resources.StringResource
import spotube.composeapp.generated.resources.*
enum class LibraryTab(val title: String, val icon: ImageVector) {
Playlists("Playlists", Iconsax.IconsaxMusicDashboard),
Albums("Albums", Iconsax.IconsaxCd),
Artists("Artists", Iconsax.User),
LocalTracks("Local Tracks", Iconsax.IconsaxMusicLibrary),
Downloads("Downloads", Iconsax.IconsaxDirectboxReceive),
Downloads("Downloads", Iconsax.IconsaxDirectboxReceive);
val titleResource: StringResource
get() = when (this) {
Playlists -> Res.string.library_tab_playlists
Albums -> Res.string.library_tab_albums
Artists -> Res.string.library_tab_artists
LocalTracks -> Res.string.library_tab_local_tracks
Downloads -> Res.string.library_tab_downloads
}
val filterPlaceholder: StringResource
get() = when (this) {
Playlists -> Res.string.library_filter_playlists
Albums -> Res.string.library_filter_albums
Artists -> Res.string.library_filter_artists
LocalTracks -> Res.string.library_filter_local_tracks
Downloads -> Res.string.search_filter_placeholder
}
}

View File

@ -18,17 +18,12 @@
package dev.krtirtho.spotube.modules.library.album
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumTextField
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
@ -43,35 +38,24 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.ui.component.AlbumCard
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun LibraryAlbumsScreen(
modifier: Modifier = Modifier,
searchMode: Boolean = false,
viewModel: LibraryAlbumsViewModel = koinViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState()
val searchState = rememberTextFieldState((state as? LibraryAlbumsState.Data)?.query ?: "")
LaunchedEffect(searchState) {
snapshotFlow { searchState.text.toString() }
.drop(1)
.collect(viewModel::onQueryChange)
}
LaunchedEffect(gridState) {
snapshotFlow { gridState.layoutInfo }
@ -93,15 +77,6 @@ fun LibraryAlbumsScreen(
}
Column(modifier = modifier) {
AnimatedVisibility(searchMode) {
MillenniumTextField(
state = searchState,
modifier = Modifier.fillMaxWidth(),
placeholder = "Search saved albums..."
)
}
Spacer(modifier = Modifier.height(12.dp))
when (state) {
is LibraryAlbumsState.Loading -> {
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.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -46,7 +49,8 @@ sealed interface LibraryAlbumsState {
data class Loaded(
override val query: String = "",
override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data {
override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataAlbum.Detailed>
get() = if (query.isNotBlank()) {
allItems.filter { album ->
@ -62,13 +66,15 @@ sealed interface LibraryAlbumsState {
fun toLoadingMore(): LoadingMore = LoadingMore(
query = query,
allItems = allItems,
nextPagination = nextPagination)
nextPagination = nextPagination
)
}
data class LoadingMore(
override val query: String = "",
override val allItems: List<MetadataAlbum.Detailed> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data {
override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataAlbum.Detailed>
get() = if (query.isNotBlank()) {
allItems.filter { album ->
@ -88,11 +94,21 @@ sealed interface LibraryAlbumsState {
@OptIn(ExperimentalCoroutinesApi::class)
class LibraryAlbumsViewModel(
private val repository: LibraryRepository) : ViewModel(), KoinComponent {
private val repository: LibraryRepository,
private val libraryState: LibraryState
) : ViewModel(), KoinComponent {
private val logger by injectLogger<LibraryAlbumsViewModel>()
private val _state = MutableStateFlow<LibraryAlbumsState>(LibraryAlbumsState.Loading)
val uiState: StateFlow<LibraryAlbumsState> = _state.asStateFlow()
val uiState: StateFlow<LibraryAlbumsState> = combine(
_state, libraryState.queryFlow(LibraryTab.Albums)
) { state, query ->
when (state) {
is LibraryAlbumsState.Data.Loaded -> state.copy(query = query)
is LibraryAlbumsState.Data.LoadingMore -> state.copy(query = query)
else -> state
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryAlbumsState.Loading)
init {
viewModelScope.launch {
@ -113,7 +129,8 @@ class LibraryAlbumsViewModel(
val result = repository.savedAlbums()
_state.value = LibraryAlbumsState.Data.Loaded(
allItems = result?.items ?: emptyList(),
nextPagination = result?.nextPagination)
nextPagination = result?.nextPagination
)
}.onFailure { e ->
logger.e(e) { "Failed to load albums" }
_state.value = LibraryAlbumsState.Error(e.message ?: "Unknown error")
@ -127,7 +144,8 @@ class LibraryAlbumsViewModel(
_state.value = LibraryAlbumsState.Data.Loaded(
query = currentState.query,
allItems = currentState.allItems + (result?.items ?: emptyList()),
nextPagination = result?.nextPagination)
nextPagination = result?.nextPagination
)
}
}.onFailure { e ->
logger.e(e) { "Failed to load more albums" }
@ -135,13 +153,7 @@ class LibraryAlbumsViewModel(
}
fun onQueryChange(query: String) {
val currentState = _state.value
if (currentState is LibraryAlbumsState.Data) {
_state.value = when (currentState) {
is LibraryAlbumsState.Data.Loaded -> currentState.copy(query = query)
is LibraryAlbumsState.Data.LoadingMore -> currentState.copy(query = query)
}
}
libraryState.setQuery(LibraryTab.Albums, query)
}
fun refresh() {

View File

@ -18,23 +18,16 @@
package dev.krtirtho.spotube.modules.library.artist
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumTextField
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import com.composeunstyled.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -43,34 +36,25 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.component.ArtistCard
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun LibraryArtistsScreen(
modifier: Modifier = Modifier,
searchMode: Boolean = false,
viewModel: LibraryArtistsViewModel = koinViewModel()) {
viewModel: LibraryArtistsViewModel = koinViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState()
val searchState = rememberTextFieldState((state as? LibraryArtistsState.Data)?.query ?: "")
LaunchedEffect(searchState) {
snapshotFlow { searchState.text.toString() }
.drop(1)
.collect(viewModel::onQueryChange)
}
LaunchedEffect(gridState) {
snapshotFlow { gridState.layoutInfo }
@ -92,14 +76,6 @@ fun LibraryArtistsScreen(
}
Column(modifier = modifier) {
AnimatedVisibility(searchMode){
MillenniumTextField(
state = searchState,
modifier = Modifier.fillMaxWidth(),
placeholder = "Search saved artists...")
}
Spacer(modifier = Modifier.height(12.dp))
when (state) {
is LibraryArtistsState.Loading -> {
val shellBottomInset = LocalAppShellBottomInset.current
@ -111,13 +87,15 @@ fun LibraryArtistsScreen(
modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)) {
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(8) {
SkeletonTree(true) {
AvatarCard(
title = "Artist Name",
subtitle = "Artist",
imageURL = "https://placehold.co/600x400")
imageURL = "https://placehold.co/600x400"
)
}
}
}
@ -152,22 +130,25 @@ fun LibraryArtistsScreen(
modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)) {
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(dataState.items, key = { it.id }) { artist ->
ArtistCard(
artist = artist,
modifier = Modifier.fillMaxWidth())
modifier = Modifier.fillMaxWidth()
)
}
// if (state is LibraryArtistsState.Data.LoadingMore) {
items(4) {
SkeletonTree(true) {
AvatarCard(
title = "Artist Name",
subtitle = "Artist",
imageURL = "https://placehold.co/400x400")
}
items(4) {
SkeletonTree(true) {
AvatarCard(
title = "Artist Name",
subtitle = "Artist",
imageURL = "https://placehold.co/400x400"
)
}
}
// }
}
}

View File

@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -46,7 +49,8 @@ sealed interface LibraryArtistsState {
data class Loaded(
override val query: String = "",
override val allItems: List<MetadataArtist.Basic> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data {
override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataArtist.Basic>
get() = if (query.isNotBlank()) {
allItems.filter { artist ->
@ -59,13 +63,15 @@ sealed interface LibraryArtistsState {
fun toLoadingMore(): LoadingMore = LoadingMore(
query = query,
allItems = allItems,
nextPagination = nextPagination)
nextPagination = nextPagination
)
}
data class LoadingMore(
override val query: String = "",
override val allItems: List<MetadataArtist.Basic> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data {
override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataArtist.Basic>
get() = if (query.isNotBlank()) {
allItems.filter { artist ->
@ -82,11 +88,21 @@ sealed interface LibraryArtistsState {
@OptIn(ExperimentalCoroutinesApi::class)
class LibraryArtistsViewModel(
private val repository: LibraryRepository) : ViewModel(), KoinComponent {
private val repository: LibraryRepository,
private val libraryState: LibraryState
) : ViewModel(), KoinComponent {
private val logger by injectLogger<LibraryArtistsViewModel>()
private val _state = MutableStateFlow<LibraryArtistsState>(LibraryArtistsState.Loading)
val uiState: StateFlow<LibraryArtistsState> = _state.asStateFlow()
val uiState: StateFlow<LibraryArtistsState> = combine(
_state, libraryState.queryFlow(LibraryTab.Artists)
) { state, query ->
when (state) {
is LibraryArtistsState.Data.Loaded -> state.copy(query = query)
is LibraryArtistsState.Data.LoadingMore -> state.copy(query = query)
else -> state
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryArtistsState.Loading)
init {
viewModelScope.launch {
@ -111,7 +127,8 @@ class LibraryArtistsViewModel(
id = it.id,
name = it.name,
thumbnails = it.thumbnails,
externalUri = it.externalUri)
externalUri = it.externalUri
)
} ?: emptyList(),
nextPagination = result?.nextPagination)
}.onFailure { e ->
@ -129,12 +146,14 @@ class LibraryArtistsViewModel(
id = it.id,
name = it.name,
thumbnails = it.thumbnails,
externalUri = it.externalUri)
externalUri = it.externalUri
)
} ?: emptyList()
_state.value = LibraryArtistsState.Data.Loaded(
query = currentState.query,
allItems = currentState.allItems + newItems,
nextPagination = result?.nextPagination)
nextPagination = result?.nextPagination
)
}
}.onFailure { e ->
logger.e(e) { "Failed to load more artists" }
@ -142,13 +161,7 @@ class LibraryArtistsViewModel(
}
fun onQueryChange(query: String) {
val currentState = _state.value
if (currentState is LibraryArtistsState.Data) {
_state.value = when (currentState) {
is LibraryArtistsState.Data.Loaded -> currentState.copy(query = query)
is LibraryArtistsState.Data.LoadingMore -> currentState.copy(query = query)
}
}
libraryState.setQuery(LibraryTab.Artists, query)
}
fun refresh() {

View File

@ -25,8 +25,6 @@ import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumIndeterminateProgress
import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumTextField
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -38,7 +36,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.input.rememberTextFieldState
import com.composeunstyled.Text
@ -46,28 +43,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.drop
import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.library.local_tracks.media.rememberLocalMediaPermissionState
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxFolderOpen
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlayCircle2
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
import dev.krtirtho.spotube.resources.iconsax.Play
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean = false) {
fun LibraryLocalTracksScreen(modifier: Modifier = Modifier) {
val viewModel = koinViewModel<LibraryLocalTracksViewModel>()
val state by viewModel.state.collectAsStateWithLifecycle()
val permissionState = rememberLocalMediaPermissionState()
@ -85,53 +78,25 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
}
val searchState = rememberTextFieldState(state.query)
LaunchedEffect(searchState) {
snapshotFlow { searchState.text.toString() }
.drop(1)
.collect(viewModel::onQueryChange)
}
Box(modifier = modifier.fillMaxWidth()) {
Column(
modifier = modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter),
verticalArrangement = Arrangement.spacedBy(12.dp)) {
AnimatedVisibility(searchMode) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MillenniumIcon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Search")
MillenniumTextField(
state = searchState,
modifier = Modifier.weight(1f),
placeholder = if (state.currentFolderPath == null) {
"Search folders or tracks"
} else {
"Search tracks in folder"
})
MillenniumIconButton(
icon = Iconsax.IconsaxRefreshRight,
contentDescription = "Refresh",
onClick = { viewModel.refresh() },
style = MillenniumIconButtonStyle.Quiet)
}
}
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (state.currentFolderPath != null) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
MillenniumIconButton(
icon = Iconsax.ArrowLeft3,
contentDescription = "Back",
onClick = viewModel::navigateUp,
style = MillenniumIconButtonStyle.Quiet)
style = MillenniumIconButtonStyle.Quiet
)
Text(viewModel.currentFolder()?.name ?: "Folder")
}
MillenniumIconButton(
@ -140,7 +105,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
onClick = {
viewModel.currentFolder()?.let { viewModel.playFolder(it.path) }
},
style = MillenniumIconButtonStyle.Quiet)
style = MillenniumIconButtonStyle.Quiet
)
}
}
@ -151,11 +117,13 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)) {
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text("Allow audio permission to show local tracks")
MillenniumButton(
onClick = permissionState.requestPermission,
style = MillenniumButtonStyle.Quiet) {
style = MillenniumButtonStyle.Quiet
) {
Text("Grant access")
}
}
@ -212,7 +180,8 @@ fun LibraryLocalTracksScreen(modifier: Modifier = Modifier, searchMode: Boolean
icon = Iconsax.Play,
contentDescription = "Play",
onClick = { viewModel.playTrack(track.path) },
style = MillenniumIconButtonStyle.Quiet)
style = MillenniumIconButtonStyle.Quiet
)
},
onClick = { viewModel.playTrack(track.path) })
}

View File

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

View File

@ -18,68 +18,45 @@
package dev.krtirtho.spotube.modules.library.playlist
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumTextField
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import com.composeunstyled.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.LikedTracksCard
import dev.krtirtho.spotube.core.ui.component.PlaylistCard
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun LibraryPlaylistsScreen(
modifier: Modifier = Modifier,
searchMode: Boolean = false,
viewModel: LibraryPlaylistsViewModel = koinViewModel()) {
showCreatePlaylist: Boolean = false,
onDismissCreatePlaylist: () -> Unit = {},
viewModel: LibraryPlaylistsViewModel = koinViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val gridState = rememberLazyGridState()
var showCreatePlaylist by remember { mutableStateOf(false) }
val searchState = rememberTextFieldState((state as? LibraryPlaylistsState.Data)?.query ?: "")
LaunchedEffect(searchState) {
snapshotFlow { searchState.text.toString() }
.drop(1)
.collect(viewModel::onQueryChange)
}
LaunchedEffect(gridState) {
snapshotFlow { gridState.layoutInfo }
@ -101,26 +78,6 @@ fun LibraryPlaylistsScreen(
}
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically) {
AnimatedVisibility(
visible = searchMode,
modifier = Modifier.weight(1f)) {
MillenniumTextField(
state = searchState,
modifier = Modifier.fillMaxWidth(),
placeholder = "Search saved playlists...")
}
MillenniumIconButton(
icon = Iconsax.IconsaxAddSquare,
contentDescription = "Create Playlist",
onClick = { showCreatePlaylist = true },
modifier = Modifier.size(48.dp))
}
Spacer(modifier = Modifier.height(12.dp))
when (state) {
is LibraryPlaylistsState.Loading -> {
val shellBottomInset = LocalAppShellBottomInset.current
@ -139,7 +96,8 @@ fun LibraryPlaylistsScreen(
PlayableCard(
title = "Playlist Name",
subtitle = "By Artist",
imageURL = "https://placehold.co/600x400")
imageURL = "https://placehold.co/600x400"
)
}
}
}
@ -195,7 +153,8 @@ fun LibraryPlaylistsScreen(
PlayableCard(
title = "Playlist Name",
subtitle = "By Artist",
imageURL = "https://placehold.co/600x400")
imageURL = "https://placehold.co/600x400"
)
}
}
}
@ -206,7 +165,7 @@ fun LibraryPlaylistsScreen(
PlaylistFormSheet(
visible = showCreatePlaylist,
onDismiss = { showCreatePlaylist = false },
onDismiss = onDismissCreatePlaylist,
onSubmit = { data ->
viewModel.createPlaylist(
name = data.name,
@ -214,7 +173,8 @@ fun LibraryPlaylistsScreen(
isPublic = data.isPublic,
isCollaborating = data.isCollaborating,
imageBase64 = data.imageBase64,
trackIds = emptyList())
trackIds = emptyList()
)
})
}
}

View File

@ -23,14 +23,17 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStra
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -46,12 +49,13 @@ sealed interface LibraryPlaylistsState {
data class Loaded(
override val query: String = "",
override val allItems: List<MetadataPlaylist> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data {
override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataPlaylist>
get() = if (query.isNotBlank()) {
allItems.filter {
it.title.contains(query, ignoreCase = true) ||
(it.description?.contains(query, ignoreCase = true) == true)
(it.description?.contains(query, ignoreCase = true) == true)
}
} else {
allItems
@ -60,18 +64,20 @@ sealed interface LibraryPlaylistsState {
fun toLoadingMore(): LoadingMore = LoadingMore(
query = query,
allItems = allItems,
nextPagination = nextPagination)
nextPagination = nextPagination
)
}
data class LoadingMore(
override val query: String = "",
override val allItems: List<MetadataPlaylist> = emptyList(),
override val nextPagination: PaginationStrategy? = null) : Data {
override val nextPagination: PaginationStrategy? = null
) : Data {
override val items: List<MetadataPlaylist>
get() = if (query.isNotBlank()) {
allItems.filter {
it.title.contains(query, ignoreCase = true) ||
(it.description?.contains(query, ignoreCase = true) == true)
(it.description?.contains(query, ignoreCase = true) == true)
}
} else {
allItems
@ -84,11 +90,21 @@ sealed interface LibraryPlaylistsState {
@OptIn(ExperimentalCoroutinesApi::class)
class LibraryPlaylistsViewModel(
private val repository: LibraryRepository) : ViewModel(), KoinComponent {
private val repository: LibraryRepository,
private val libraryState: LibraryState
) : ViewModel(), KoinComponent {
private val logger by injectLogger<LibraryPlaylistsViewModel>()
private val _state = MutableStateFlow<LibraryPlaylistsState>(LibraryPlaylistsState.Loading)
val uiState: StateFlow<LibraryPlaylistsState> = _state.asStateFlow()
val uiState: StateFlow<LibraryPlaylistsState> = combine(
_state, libraryState.queryFlow(LibraryTab.Playlists)
) { state, query ->
when (state) {
is LibraryPlaylistsState.Data.Loaded -> state.copy(query = query)
is LibraryPlaylistsState.Data.LoadingMore -> state.copy(query = query)
else -> state
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LibraryPlaylistsState.Loading)
init {
viewModelScope.launch {
@ -109,7 +125,8 @@ class LibraryPlaylistsViewModel(
val result = repository.savedPlaylists()
_state.value = LibraryPlaylistsState.Data.Loaded(
allItems = result?.items ?: emptyList(),
nextPagination = result?.nextPagination)
nextPagination = result?.nextPagination
)
}.onFailure { e ->
logger.e(e) { "Failed to load playlists" }
_state.value = LibraryPlaylistsState.Error(e.message ?: "Unknown error")
@ -123,7 +140,8 @@ class LibraryPlaylistsViewModel(
_state.value = LibraryPlaylistsState.Data.Loaded(
query = currentState.query,
allItems = currentState.allItems + (result?.items ?: emptyList()),
nextPagination = result?.nextPagination)
nextPagination = result?.nextPagination
)
}
}.onFailure { e ->
logger.e(e) { "Failed to load more playlists" }
@ -131,13 +149,7 @@ class LibraryPlaylistsViewModel(
}
fun onQueryChange(query: String) {
val currentState = _state.value
if (currentState is LibraryPlaylistsState.Data) {
_state.value = when (currentState) {
is LibraryPlaylistsState.Data.Loaded -> currentState.copy(query = query)
is LibraryPlaylistsState.Data.LoadingMore -> currentState.copy(query = query)
}
}
libraryState.setQuery(LibraryTab.Playlists, query)
}
fun refresh() {
@ -153,7 +165,8 @@ class LibraryPlaylistsViewModel(
isPublic: Boolean,
isCollaborating: Boolean,
imageBase64: String,
trackIds: List<String>) {
trackIds: List<String>
) {
viewModelScope.launch {
runCatching {
repository.createPlaylist(
@ -162,7 +175,8 @@ class LibraryPlaylistsViewModel(
isPublic = isPublic,
isCollaborating = isCollaborating,
imageBase64 = imageBase64,
trackIds = trackIds)
trackIds = trackIds
)
}.onFailure { e ->
logger.e(e) { "Failed to create playlist" }
}.onSuccess {

View File

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

View File

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

View File

@ -59,7 +59,14 @@ data class SearchScreenState(
val albums: SearchPagedState<MetadataAlbum.Basic> = SearchPagedState(),
val artists: SearchPagedState<MetadataArtist.Basic> = SearchPagedState(),
val playlists: SearchPagedState<MetadataPlaylist> = SearchPagedState(),
val users: SearchPagedState<MetadataUser> = SearchPagedState())
val users: SearchPagedState<MetadataUser> = SearchPagedState()) {
val recentSearchSuggestions: List<String>
get() = if (query.isBlank()) {
recentSearches
} else {
recentSearches.filter { it.contains(query, ignoreCase = true) }
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class SearchScreenViewModel(

View File

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

View File

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

View File

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

View File

@ -30,13 +30,13 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf
@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@ -84,7 +85,8 @@ fun AppShell(
viewModel: AppShellViewModel = koinViewModel<AppShellViewModel>(),
queueViewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(),
alternativeViewModel: AlternativeTrackContentViewModel = koinViewModel<AlternativeTrackContentViewModel>(),
content: @Composable () -> Unit) {
content: @Composable () -> Unit
) {
val navigatorCommands: NavigationCommands = koinInject()
val remotePlaybackController: RemotePlaybackController = koinInject()
val snackbarHostState = remember { MillenniumSnackbarHostState() }
@ -128,7 +130,13 @@ fun AppShell(
Box(modifier = Modifier.fillMaxSize()) {
val useSidebar = viewModel.useSidebar()
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)
val chromeUiState by viewModel.compactChromeUiState.collectAsState()
val density = LocalDensity.current
val navigationBarInset = with(density) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
val bottomOverlayInset =
viewModel.bottomOverlayInset(useSidebar, navigationBarInset, chromeUiState)
CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) {
if (useSidebar) {
@ -150,8 +158,11 @@ fun AppShell(
)
MillenniumSeparator(
modifier = Modifier.fillMaxHeight(),
vertical = true)
Box(modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)) {
vertical = true
)
Box(
modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)
) {
content()
}
}
@ -184,19 +195,23 @@ fun AppShell(
QueueSheet(
isVisible = isQueueVisible,
onDismiss = { queueViewModel.setQueueVisibility(false) },
modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize()
) {
PlayerQueueContent(
viewModel = queueViewModel,
modifier = Modifier.fillMaxSize())
modifier = Modifier.fillMaxSize()
)
}
AlternativeTrackSheet(
isVisible = isAlternativeVisible,
onDismiss = { alternativeViewModel.setAlternativeVisibility(false) },
modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize()
) {
AlternativeTrackContent(
viewModel = alternativeViewModel,
modifier = Modifier.fillMaxSize())
modifier = Modifier.fillMaxSize()
)
}
}
} else {
@ -204,7 +219,8 @@ fun AppShell(
Box(
modifier = Modifier
.fillMaxSize()
.background(MillenniumTheme.colors.background))
.background(MillenniumTheme.colors.background)
)
CompactPlayerOverlay(
navigator = navigator,
navigationState = navigationState,
@ -212,7 +228,8 @@ fun AppShell(
onQueue = queueViewModel::toggleQueueVisibility,
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility,
onExpandLyrics = viewModel::showLyricsOverlay,
content = content)
content = content
)
QueueSheet(
isVisible = isQueueVisible,
@ -221,16 +238,19 @@ fun AppShell(
) {
PlayerQueueContent(
viewModel = queueViewModel,
modifier = Modifier.fillMaxSize())
modifier = Modifier.fillMaxSize()
)
}
AlternativeTrackSheet(
isVisible = isAlternativeVisible,
onDismiss = { alternativeViewModel.setAlternativeVisibility(false) },
modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize()
) {
AlternativeTrackContent(
viewModel = alternativeViewModel,
modifier = Modifier.fillMaxSize())
modifier = Modifier.fillMaxSize()
)
}
if (isLyricsOverlayVisible) {
@ -239,8 +259,13 @@ fun AppShell(
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
dismissOnClickOutside = true)) {
Box(modifier = Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) {
dismissOnClickOutside = true
)
) {
Box(
modifier = Modifier.fillMaxSize()
.background(MillenniumTheme.colors.background)
) {
LyricsScreen(
modifier = Modifier.fillMaxSize(),
onClose = { viewModel.hideLyricsOverlay() })
@ -257,38 +282,56 @@ fun AppShell(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = bottomOverlayInset + 12.dp))
.padding(bottom = bottomOverlayInset + 12.dp)
)
}
}
@OptIn(
ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class
)
@Composable
private fun CompactPlayerOverlay(
internal fun CompactPlayerOverlay(
navigator: Navigator,
navigationState: NavigationState,
viewModel: AppShellViewModel,
onQueue: () -> Unit,
onAlternativeSource: () -> Unit,
onExpandLyrics: () -> Unit,
content: @Composable () -> Unit) {
floatingPlayer: @Composable (Modifier) -> Unit = { AppFloatingPlayer(modifier = it) },
bottomBar: @Composable (Modifier) -> Unit = {
AppBottombar(navigator = navigator, navigationState = navigationState, modifier = it)
},
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val chromeUiState by viewModel.compactChromeUiState.collectAsState()
val floatingPlayerHeightPx = with(density) { viewModel.floatingPlayerHeight.toPx() }
val bottomBarHeightPx = with(density) { viewModel.bottomBarHeight.toPx() }
val navBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat()
val peekHeightPx = floatingPlayerHeightPx + bottomBarHeightPx + navBarInsetPx
val bottomBarHeightPx = with(density) {
chromeUiState.measuredBottomBarHeight?.toPx()
?: (viewModel.bottomBarHeight.toPx() + navBarInsetPx)
}
val peekHeightPx = floatingPlayerHeightPx + bottomBarHeightPx
val peekHeight = with(density) { peekHeightPx.toDp() }
val partialDetent = remember(peekHeight) {
SheetDetent("player-peek") { _, _ -> peekHeight }
}
val sheetState = rememberBottomSheetState(
initialDetent = partialDetent,
detents = listOf(partialDetent, SheetDetent.FullyExpanded))
// The library remembers its detent definitions at creation. Recreate the state when
// the measured chrome height changes, rather than keeping a stale peek anchor.
val sheetState = key(peekHeight) {
rememberBottomSheetState(
initialDetent = partialDetent,
detents = listOf(partialDetent, SheetDetent.FullyExpanded)
)
}
val progress = runCatching {
val progress = if (!sheetState.offset.isFinite() ||
(sheetState.isIdle && sheetState.currentDetent == partialDetent)
) 0f else runCatching {
sheetState.progress(partialDetent, SheetDetent.FullyExpanded)
}.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f
val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f))
@ -298,7 +341,8 @@ private fun CompactPlayerOverlay(
UnstyledBottomSheet(
state = sheetState,
modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize()
) {
Sheet(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize()) {
if (uiState.showExpandedPlayer) {
@ -311,14 +355,14 @@ private fun CompactPlayerOverlay(
},
onQueue = onQueue,
onAlternativeSource = onAlternativeSource,
onExpandLyrics = onExpandLyrics)
onExpandLyrics = onExpandLyrics
)
}
if (uiState.floatingPlayerAlpha > 0.01f) {
Column(modifier = Modifier.fillMaxWidth()) {
AppFloatingPlayer(
modifier = Modifier
.offset(y = 12.dp)
floatingPlayer(
Modifier
.graphicsLayer {
alpha = uiState.floatingPlayerAlpha
})
@ -329,12 +373,16 @@ private fun CompactPlayerOverlay(
}
if (uiState.floatingPlayerAlpha > 0.01f) {
AppBottombar(
navigator = navigator,
navigationState = navigationState,
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).graphicsLayer {
alpha = uiState.floatingPlayerAlpha
})
bottomBar(
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.onSizeChanged { size ->
viewModel.onBottomBarMeasured(with(density) { size.height.toDp() })
}
.graphicsLayer {
alpha = uiState.floatingPlayerAlpha
})
}
}
}

View File

@ -35,30 +35,34 @@ data class CompactSheetUiState(
val showExpandedPlayer: Boolean,
val expandedPlayerAlpha: Float,
val floatingPlayerAlpha: Float,
val bottomBarAlpha: Float)
val bottomBarAlpha: Float
)
data class LyricsOverlayState(
val isVisible: Boolean = false)
val isVisible: Boolean = false
)
data class CompactChromeUiState(val measuredBottomBarHeight: Dp? = null)
class AppShellViewModel : ViewModel() {
val sidebarMinWidth = 840.dp
val bottomBarEstimatedHeight = 58.dp
val bottomBarHeight = 80.dp
val floatingPlayerHeight = 74.dp
// The background bleed overlaps the bottom bar and is not part of the peek height.
val floatingPlayerHeight = FloatingPlayerContentHeight
val largePlayerInset = 100.dp
val floatingPlayerDismissProgress = 0.7f
val expandedPlayerVisibilityThreshold = 0.001f
private val lyricsOverlayVisible = MutableStateFlow(false)
private val compactChrome = MutableStateFlow(CompactChromeUiState())
val compactChromeUiState = compactChrome.asStateFlow()
fun onBottomBarMeasured(height: Dp) {
if (height > 0.dp) compactChrome.value = CompactChromeUiState(height)
}
val isLyricsOverlayVisible: StateFlow<Boolean> = lyricsOverlayVisible.asStateFlow()
val floatingPlayerBottomOffset: Dp
get() = bottomBarEstimatedHeight + 8.dp
val compactChromeInset: Dp
get() = floatingPlayerBottomOffset + floatingPlayerHeight + 10.dp
@Composable
fun useSidebar(maxWidth: Dp? = null): Boolean {
if (maxWidth != null) {
@ -67,8 +71,14 @@ class AppShellViewModel : ViewModel() {
return currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded
}
fun bottomOverlayInset(useSidebar: Boolean): Dp =
if (useSidebar) largePlayerInset else compactChromeInset
fun bottomOverlayInset(
useSidebar: Boolean,
navigationBarInset: Dp,
chrome: CompactChromeUiState
): Dp =
if (useSidebar) largePlayerInset + navigationBarInset
else (chrome.measuredBottomBarHeight ?: (bottomBarHeight + navigationBarInset)) +
floatingPlayerHeight
fun sheetProgress(sheetOffsetPx: Float, sheetHeightPx: Float): Float =
if (sheetHeightPx > 0f) {
@ -88,7 +98,8 @@ class AppShellViewModel : ViewModel() {
showExpandedPlayer = progress > expandedPlayerVisibilityThreshold,
expandedPlayerAlpha = progress,
floatingPlayerAlpha = floatingPlayerAlpha(progress),
bottomBarAlpha = 1f - progress)
bottomBarAlpha = 1f - progress
)
}
fun showLyricsOverlay() {

View File

@ -50,10 +50,14 @@ import dev.krtirtho.spotube.modules.support.LocalSupportPrompt
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
import dev.krtirtho.spotube.sidebarTabs
import org.jetbrains.compose.resources.Font
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumSidebar
import org.spotube.millennium.composables.MillenniumSidebarContent
import org.spotube.millennium.composables.MillenniumSidebarFooter
@ -89,7 +93,7 @@ fun AppSidebar(
) {
MillenniumSidebarHeader {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
@ -104,10 +108,19 @@ fun AppSidebar(
color = AppTheme.colors.onSurface
)
}
MillenniumSidebarTrigger()
MillenniumSidebarTrigger {
MillenniumIconButton(
if (it.collapsed) Iconsax.IconsaxSidebarRightBroken else Iconsax.IconsaxSidebarLeftBroken,
contentDescription = "Toggle Sidebar",
style = MillenniumIconButtonStyle.Quiet,
onClick = { it.toggle() }
)
}
}
}
MillenniumSidebarContent {
MillenniumSidebarContent(
contentSpacing = 0.dp
) {
sidebarTabs.forEach { entry ->
when (entry) {
is NavigationItem.Group<*> -> {
@ -115,13 +128,17 @@ fun AppSidebar(
MillenniumSidebarGroup(
expanded = isGroupExpanded,
collapsible = true,
groupSpacing = 0.dp,
onExpandedChange = { groupExpansion[entry.title] = it }) {
MillenniumSidebarGroupLabel(entry.title)
MillenniumSidebarGroupContent {
MillenniumSidebarMenu {
MillenniumSidebarGroupContent(
contentSpacing = 0.dp
) {
MillenniumSidebarMenu(contentSpacing = 0.dp) {
entry.items.forEach { item ->
val selected = navigationState.topLevelRoute == item.route &&
(item.route != Routes.Library || currentLibraryTab == item.data)
val selected =
navigationState.topLevelRoute == item.route &&
(item.route != Routes.Library || currentLibraryTab == item.data)
SidebarNavItem(
item = item,
selected = selected,
@ -154,13 +171,15 @@ fun AppSidebar(
label = supportLabel,
onClick = { openSupport() },
contentDescription = supportLabel,
icon = Iconsax.IconsaxHeart2)
icon = Iconsax.IconsaxHeart2
)
MillenniumSidebarItem(
label = stringResource(Res.string.settings_screen_title),
onClick = { navigator.navigate(Routes.Settings) },
selected = navigationState.topLevelRoute == Routes.Settings,
contentDescription = stringResource(Res.string.settings_screen_title),
icon = Iconsax.IconsaxSetting2)
icon = Iconsax.IconsaxSetting2
)
}
}
}
@ -169,11 +188,13 @@ fun AppSidebar(
private fun SidebarNavItem(
item: NavigationItem.Tab<*>,
selected: Boolean,
onClick: () -> Unit) {
onClick: () -> Unit
) {
MillenniumSidebarItem(
label = item.title,
onClick = onClick,
selected = selected,
contentDescription = item.title,
icon = item.icon)
icon = item.icon
)
}

View File

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

View File

@ -0,0 +1,192 @@
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package dev.krtirtho.spotube.modules.jam
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.use
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
import dev.krtirtho.spotube.modules.settings.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
import dev.krtirtho.spotube.modules.shell.FloatingPlayerContent
import dev.krtirtho.spotube.modules.shell.FloatingPlayerBottomBleed
import dev.krtirtho.spotube.modules.shell.PlayerUiState
import dev.krtirtho.spotube.modules.shell.AppBottombar
import dev.krtirtho.spotube.modules.shell.CompactPlayerOverlay
import org.spotube.millennium.composables.MillenniumTopAppBar
import org.jetbrains.compose.resources.stringResource
import com.composeunstyled.Text
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_screen_title
import org.spotube.millennium.theme.MillenniumTheme
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class JamLayoutTest {
@Test
fun measuredDockHeightIncludesSystemInsetExactlyOnce() {
val viewModel = AppShellViewModel()
viewModel.onBottomBarMeasured(76.dp)
assertEquals(
140.dp,
viewModel.bottomOverlayInset(false, 0.dp, viewModel.compactChromeUiState.value)
)
viewModel.onBottomBarMeasured(100.dp)
assertEquals(
164.dp,
viewModel.bottomOverlayInset(false, 24.dp, viewModel.compactChromeUiState.value)
)
viewModel.onBottomBarMeasured(0.dp)
assertEquals(100.dp, viewModel.compactChromeUiState.value.measuredBottomBarHeight)
}
/** Actual Compose controls rendered without native-window capture or an MQTT connection. */
@OptIn(ExperimentalComposeUiApi::class)
@Test
fun renderRoomControlsAtNarrowAndWideSizes() {
val output = File("build/reports/jam-ui").apply { mkdirs() }
val states = mapOf(
"create" to JamUiState(
brokerConfigured = true,
brokerHost = "test.mosquitto.org",
canCreate = true
),
"join" to JamUiState(
brokerConfigured = true,
brokerHost = "test.mosquitto.org",
setupMode = JamSetupMode.Join
),
"host" to JamUiState(
isActive = true,
isConnected = true,
isHost = true,
roomCode = "RC5FJ8",
brokerConfigured = true,
brokerHost = "test.mosquitto.org",
participants = listOf(
JamParticipantUi("host", "Host-8316", "H", true, false),
JamParticipantUi("guest", "Guest-6684", "G", false, true)
)
),
"guest" to JamUiState(
isActive = true,
isConnected = true,
roomCode = "RC5FJ8",
brokerConfigured = true,
brokerHost = "test.mosquitto.org",
participants = listOf(JamParticipantUi("host", "Host-8316", "H", true, false))
)
)
for (width in listOf(360, 920)) {
for ((name, state) in states) {
var playerBottom = Float.NaN
var navigationTop = Float.NaN
var navigationBottom = Float.NaN
ImageComposeScene(width, 760) {
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
Column(
Modifier.fillMaxSize().background(MillenniumTheme.colors.background),
horizontalAlignment = Alignment.CenterHorizontally
) {
MillenniumTopAppBar(title = { Text(stringResource(Res.string.jam_screen_title)) })
val navigation = rememberNavigationState(Routes.Home, TOP_LEVEL_ROUTES)
val navigator = remember(navigation) { Navigator(navigation) }
Box(Modifier.weight(1f).fillMaxWidth()) {
CompactPlayerOverlay(
navigator = navigator,
navigationState = navigation,
viewModel = remember { AppShellViewModel() },
onQueue = {}, onAlternativeSource = {}, onExpandLyrics = {},
floatingPlayer = { modifier ->
FloatingPlayerContent(
PlayerUiState(),
modifier = modifier.onGloballyPositioned {
playerBottom = it.boundsInRoot().bottom
},
onPlayPause = {},
heartButton = {})
},
bottomBar = { modifier ->
AppBottombar(
navigator,
navigation,
modifier = modifier.onGloballyPositioned {
navigationTop = it.boundsInRoot().top
navigationBottom = it.boundsInRoot().bottom
},
libraryBadge = {})
}
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
Modifier.widthIn(max = 520.dp).fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (state.isActive) SessionPanel(
state,
{},
{},
{},
{},
{},
{})
else SetupPanel(state, {}, {}, {})
BrokerStatus(state, {})
}
}
}
}
}
}
}.use { scene ->
repeat(8) { scene.render().close() }
assertTrue(playerBottom.isFinite())
assertEquals(
playerBottom - FloatingPlayerBottomBleed.value,
navigationTop,
"Only the player's bottom surface bleed should overlap navigation"
)
assertEquals(
760f,
navigationBottom,
"Navigation must reach the window's bottom edge"
)
scene.render().use { image ->
val data = assertNotNull(image.encodeToData())
data.use { File(output, "$name-$width.png").writeBytes(it.bytes) }
}
}
}
}
}
}

View File

@ -0,0 +1,191 @@
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
package dev.krtirtho.spotube.modules.library
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.unit.dp
import androidx.compose.ui.use
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
import dev.krtirtho.spotube.modules.settings.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.spotube.millennium.composables.MillenniumTopAppBar
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class LibraryToolbarTest {
private lateinit var library: LibraryState
@BeforeTest
fun setup() {
Dispatchers.setMain(UnconfinedTestDispatcher())
library = LibraryState()
}
@AfterTest
fun cleanup() {
library.viewModelScope.cancel()
Dispatchers.resetMain()
}
@Test
fun closingSearchClearsTheActiveFilter() = runTest {
library.openSearch()
library.uiState.value.searchState.setTextAndPlaceCursorAtEnd("Muse")
Snapshot.sendApplyNotifications()
runCurrent()
assertTrue(library.uiState.value.searchMode)
assertEquals("Muse", library.queryFlow(LibraryTab.Playlists).first())
library.closeSearch()
Snapshot.sendApplyNotifications()
runCurrent()
assertFalse(library.uiState.value.searchMode)
assertEquals("", library.uiState.value.searchState.text.toString())
}
@Test
fun eachTabRetainsItsOwnFilterAndActions() = runTest {
library.setQuery(LibraryTab.Playlists, "Rock")
library.onTabSelected(LibraryTab.Albums)
library.setQuery(LibraryTab.Albums, "Jazz")
Snapshot.sendApplyNotifications()
runCurrent()
assertEquals("Jazz", library.uiState.value.searchState.text.toString())
assertFalse(library.uiState.value.canCreatePlaylist)
library.onTabSelected(LibraryTab.Playlists)
runCurrent()
assertEquals("Rock", library.uiState.value.searchState.text.toString())
assertTrue(library.uiState.value.canCreatePlaylist)
library.showCreatePlaylist()
runCurrent()
assertTrue(library.uiState.value.showCreatePlaylist)
library.onTabSelected(LibraryTab.LocalTracks)
runCurrent()
assertTrue(library.uiState.value.canRefresh)
assertFalse(library.uiState.value.showCreatePlaylist)
library.onTabSelected(LibraryTab.Downloads)
runCurrent()
assertFalse(library.uiState.value.canFilter)
assertFalse(library.uiState.value.searchMode)
}
@Test
fun anInlineDesktopQueryRemainsVisibleWhenTheLayoutBecomesCompact() = runTest {
library.setQuery(LibraryTab.Artists, "Radiohead")
library.onTabSelected(LibraryTab.Artists)
Snapshot.sendApplyNotifications()
runCurrent()
assertTrue(library.uiState.value.searchMode)
assertEquals("Radiohead", library.uiState.value.searchState.text.toString())
}
@OptIn(ExperimentalComposeUiApi::class)
@Test
fun renderExpandedCompactAndSearchToolbars() {
val output = File("build/reports/library-toolbar").apply { mkdirs() }
for (tab in listOf(LibraryTab.Playlists, LibraryTab.Albums, LibraryTab.LocalTracks)) {
for ((mode, width) in listOf(
"desktop" to 575,
"mobile" to 360,
"search" to 360,
"compact-desktop" to 392,
"compact-desktop-search" to 392
)) {
val expandedDesktop = mode == "desktop"
val searching = mode.contains("search")
val state = LibraryToolbarUiState(
currentTab = tab,
searchMode = searching,
searchState = TextFieldState(if (searching) "Muse" else ""),
canCreatePlaylist = tab == LibraryTab.Playlists,
canRefresh = tab == LibraryTab.LocalTracks
)
ImageComposeScene(width, 88) {
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
MillenniumTopAppBar(
title = { LibraryToolbarTitle(state, expandedDesktop) },
actions = {
LibraryToolbarActions(state, expandedDesktop, {}, {}, {}, {})
if (expandedDesktop || mode.startsWith("compact-desktop")) Spacer(
Modifier.width(132.dp)
)
}
)
}
}.use { scene ->
repeat(8) { scene.render().close() }
fun flatten(node: SemanticsNode): List<SemanticsNode> =
listOf(node) + node.children.flatMap(::flatten)
val nodes =
scene.semanticsOwners.flatMap { flatten(it.unmergedRootSemanticsNode) }
val descriptions = nodes.flatMap {
it.config.getOrNull(SemanticsProperties.ContentDescription).orEmpty()
}
val textFields =
nodes.filter { it.config.getOrNull(SemanticsProperties.EditableText) != null }
assertEquals(if (expandedDesktop || searching) 1 else 0, textFields.size)
if (searching) {
assertTrue("Close filter" in descriptions)
assertFalse("Create playlist" in descriptions)
assertFalse("Refresh local tracks" in descriptions)
assertEquals(
"Muse",
textFields.single().config[SemanticsProperties.EditableText].text
)
} else {
assertEquals(tab == LibraryTab.Playlists, "Create playlist" in descriptions)
assertEquals(
tab == LibraryTab.LocalTracks,
"Refresh local tracks" in descriptions
)
}
textFields.forEach { node ->
assertTrue(
node.boundsInRoot.left >= 0f && node.boundsInRoot.right <= width,
"Filter must fit beside desktop window controls"
)
}
scene.render().use { image ->
assertNotNull(image.encodeToData()).use {
File(
output,
"${tab.name}-$mode.png"
).writeBytes(it.bytes)
}
}
}
}
}
}
}

View File

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

View File

@ -19,12 +19,19 @@
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M
kotlin.mpp.stability.nowarn=true
kotlin.incremental=true
#Gradle
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g
org.gradle.configuration-cache=true
org.gradle.caching=true
#Android
android.nonTransitiveRClass=true
android.useAndroidX=true
android.useAndroidX=true
# Preserve compatibility with this project's current Kotlin Multiplatform Android setup on AGP 9.
android.builtInKotlin=false
android.newDsl=false
android.sourceset.disallowProvider=false
org.gradle.vfs.watch=true

View File

@ -14,7 +14,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
[versions]
agp = "8.13.2"
agp = "9.1.0"
android-compileSdk = "37"
android-minSdk = "28"
android-targetSdk = "36"
@ -193,4 +193,4 @@ uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" }
neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" }
neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" }