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