mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(jam): implement group jam functionality with UI components and state management
This commit is contained in:
parent
d5eaf101eb
commit
5b442a93a9
@ -234,11 +234,45 @@
|
||||
<string name="settings_jam_section_identity">Identity</string>
|
||||
<string name="settings_jam_section_diagnostics">Diagnostics</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
<string name="jam_screen_title">Group Jam</string>
|
||||
<string name="jam_setup_create_tab">Create</string>
|
||||
<string name="jam_setup_join_tab">Join</string>
|
||||
<string name="jam_setup_create_hint">Create a room, then share its code with your friends.</string>
|
||||
<string name="jam_setup_create_button">Create room</string>
|
||||
<string name="jam_setup_join_button">Join room</string>
|
||||
<string name="jam_setup_working">Connecting…</string>
|
||||
<string name="jam_setup_code_label">Room code</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker: %1$s</string>
|
||||
<string name="jam_connection_missing">Set up a broker in Settings to host or join a room.</string>
|
||||
<string name="jam_connection_settings">Broker settings</string>
|
||||
<string name="jam_session_connected">Connected</string>
|
||||
<string name="jam_session_connecting">Reconnecting…</string>
|
||||
<string name="jam_invite_copy">Copy code</string>
|
||||
<string name="jam_invite_copied">Copied!</string>
|
||||
<string name="jam_invite_share">Share</string>
|
||||
<string name="jam_participants_heading">Participants (%1$d)</string>
|
||||
<string name="jam_participants_host">Host</string>
|
||||
<string name="jam_participants_guest">Listener</string>
|
||||
<string name="jam_participants_waiting">Waiting for listeners…</string>
|
||||
<string name="jam_participants_remove">Remove</string>
|
||||
<string name="jam_participants_ban">Ban</string>
|
||||
<string name="jam_participants_manage">Manage</string>
|
||||
<string name="jam_session_leave">Leave room</string>
|
||||
<string name="jam_error_title">Couldn’t connect</string>
|
||||
<string name="jam_error_dismiss">Dismiss</string>
|
||||
<string name="jam_error_create">Failed to create jam session</string>
|
||||
<string name="jam_error_join">Failed to join jam session</string>
|
||||
<string name="jam_error_invalid_code">Enter a six-character room code using letters and digits.</string>
|
||||
<string name="jam_share_message">Join my Spotube Jam with code: %1$s</string>
|
||||
|
||||
<string name="player_go_to_album">Go to album</string>
|
||||
<string name="player_sleep_timer">Sleep timer</string>
|
||||
<string name="player_track_details">Track details</string>
|
||||
<string name="player_play_content_description">Play</string>
|
||||
<string name="player_pause_content_description">Pause</string>
|
||||
<string name="player_nothing_playing">Nothing playing</string>
|
||||
<string name="player_add_to_queue_content_description">Add to queue</string>
|
||||
|
||||
<string name="update_action_ignore">Ignore</string>
|
||||
|
||||
@ -17,311 +17,468 @@
|
||||
|
||||
package dev.krtirtho.spotube.modules.jam
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButton
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButtonRow
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomCode
|
||||
import com.composeunstyled.Text
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButton
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButtonDefaults
|
||||
import org.spotube.millennium.composables.MillenniumSegmentedButtonRow
|
||||
import org.spotube.millennium.composables.MillenniumSeparator
|
||||
import org.spotube.millennium.composables.MillenniumTextField
|
||||
import org.spotube.millennium.composables.MillenniumTooltip
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.millennium.theme.millenniumBevelBrush
|
||||
import org.spotube.millennium.theme.millenniumPanelBrush
|
||||
import org.spotube.millennium.theme.millenniumSurface
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
/**
|
||||
* Group Jam session screen. Playback controls and the queue live in the app's
|
||||
* regular player / queue sheet (the shared jam queue is the local queue), so
|
||||
* this screen only covers participation and session management.
|
||||
*/
|
||||
@Composable
|
||||
fun JamScreen(
|
||||
navigationCommands: NavigationCommands) {
|
||||
fun JamScreen(navigationCommands: NavigationCommands) {
|
||||
val viewModel = koinViewModel<JamViewModel>()
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MillenniumTheme.colors.background)) {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = { Text("Group Jam") })
|
||||
Column(Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) {
|
||||
ApplicationMainBar(title = { Text(stringResource(Res.string.jam_screen_title)) })
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(16.dp)
|
||||
.padding(bottom = shellBottomInset)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
state.error?.let { error ->
|
||||
ErrorBanner(text = error, onDismiss = viewModel::clearError)
|
||||
}
|
||||
|
||||
when {
|
||||
!state.isActive -> CreateOrJoinView(
|
||||
state = state,
|
||||
onCreate = viewModel::createSession,
|
||||
onJoin = viewModel::joinWithCode)
|
||||
|
||||
else -> SessionView(
|
||||
state = state,
|
||||
onShareCode = viewModel::shareRoomCode,
|
||||
onLeave = viewModel::leave,
|
||||
onKick = viewModel::kickParticipant,
|
||||
onBan = viewModel::banParticipant)
|
||||
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 20.dp, bottom = shellBottomInset + 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
Modifier.widthIn(max = 520.dp).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
state.error?.let { error ->
|
||||
JamPanel {
|
||||
Text(
|
||||
error,
|
||||
color = AppTheme.colors.error,
|
||||
style = AppTheme.typography.bodySmall
|
||||
)
|
||||
MillenniumButton(
|
||||
onClick = viewModel::clearError,
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text(stringResource(Res.string.jam_error_dismiss))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.isActive) {
|
||||
SessionPanel(
|
||||
state = state,
|
||||
onCopy = {
|
||||
clipboard.setText(AnnotatedString(state.roomCode.orEmpty()))
|
||||
viewModel.onCodeCopied()
|
||||
},
|
||||
onShare = viewModel::shareRoomCode,
|
||||
onLeave = viewModel::leave,
|
||||
onKick = viewModel::kickParticipant,
|
||||
onBan = viewModel::banParticipant,
|
||||
onMenuChange = viewModel::showParticipantMenu
|
||||
)
|
||||
} else {
|
||||
SetupPanel(
|
||||
state,
|
||||
viewModel::selectSetupMode,
|
||||
viewModel::createSession,
|
||||
viewModel::joinSession
|
||||
)
|
||||
}
|
||||
BrokerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBanner(text: String, onDismiss: () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = text,
|
||||
color = AppTheme.colors.error,
|
||||
style = AppTheme.typography.bodyMedium)
|
||||
MillenniumButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
private fun JamPanel(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().millenniumSurface(
|
||||
background = millenniumPanelBrush(MillenniumTheme.colors),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
borderColor = MillenniumTheme.colors.border,
|
||||
borderWidth = 1.dp,
|
||||
innerGlowColor = MillenniumTheme.colors.highlight.copy(alpha = 0.25f)
|
||||
).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateOrJoinView(
|
||||
internal fun SetupPanel(
|
||||
state: JamUiState,
|
||||
onMode: (JamSetupMode) -> Unit,
|
||||
onCreate: () -> Unit,
|
||||
onJoin: (String) -> Unit) {
|
||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||
val codeState = rememberTextFieldState()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.",
|
||||
style = AppTheme.typography.titleMedium)
|
||||
|
||||
if (!state.brokerConfigured) {
|
||||
onJoin: () -> Unit
|
||||
) {
|
||||
JamPanel {
|
||||
MillenniumSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||
MillenniumSegmentedButton(
|
||||
selected = state.setupMode == JamSetupMode.Create,
|
||||
onClick = { onMode(JamSetupMode.Create) },
|
||||
shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)
|
||||
) { Text(stringResource(Res.string.jam_setup_create_tab)) }
|
||||
MillenniumSegmentedButton(
|
||||
selected = state.setupMode == JamSetupMode.Join,
|
||||
onClick = { onMode(JamSetupMode.Join) },
|
||||
shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)
|
||||
) { Text(stringResource(Res.string.jam_setup_join_tab)) }
|
||||
}
|
||||
if (state.setupMode == JamSetupMode.Create) {
|
||||
SharedRoomIllustration(Modifier.fillMaxWidth().height(100.dp))
|
||||
Text(
|
||||
text = "No jam broker configured — set one up in Settings to host or join a session.",
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.error)
|
||||
} else {
|
||||
Text(
|
||||
text = "Broker: ${state.brokerHost}",
|
||||
stringResource(Res.string.jam_setup_create_hint),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
|
||||
MillenniumSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
MillenniumSegmentedButton(
|
||||
selected = tab == 0,
|
||||
onClick = { tab = 0 },
|
||||
shape = MillenniumSegmentedButtonDefaults.itemShape(0, 2)) { Text("Create") }
|
||||
MillenniumSegmentedButton(
|
||||
selected = tab == 1,
|
||||
onClick = { tab = 1 },
|
||||
shape = MillenniumSegmentedButtonDefaults.itemShape(1, 2)) { Text("Join") }
|
||||
}
|
||||
|
||||
if (tab == 0) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "Start a session as the host. You'll get a 6-character room code to " +
|
||||
"share with friends; you control the queue.",
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
MillenniumButton(
|
||||
onClick = onCreate,
|
||||
enabled = state.brokerConfigured) {
|
||||
Text("Create Session")
|
||||
}
|
||||
}
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "Enter the 6-character room code the host shared with you.",
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
MillenniumTextField(
|
||||
state = codeState,
|
||||
label = "Room code",
|
||||
placeholder = "ABC123",
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
val normalizedCode = JamRoomCode.normalize(codeState.text.toString())
|
||||
MillenniumButton(
|
||||
onClick = { onJoin(normalizedCode) },
|
||||
enabled = state.brokerConfigured && JamRoomCode.isValid(normalizedCode)) {
|
||||
Text("Join Session")
|
||||
}
|
||||
}
|
||||
MillenniumTextField(
|
||||
state = state.codeInput,
|
||||
label = stringResource(Res.string.jam_setup_code_label),
|
||||
placeholder = stringResource(Res.string.jam_setup_code_placeholder),
|
||||
enabled = !state.isBusy,
|
||||
textStyle = AppTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
letterSpacing = 4.sp
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
MillenniumButton(
|
||||
onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin,
|
||||
enabled = if (state.setupMode == JamSetupMode.Create) state.canCreate else state.canJoin,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
when {
|
||||
state.isBusy -> Res.string.jam_setup_working
|
||||
state.setupMode == JamSetupMode.Create -> Res.string.jam_setup_create_button
|
||||
else -> Res.string.jam_setup_join_button
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A small media-player diagram: one source feeding two listeners. No stock artwork. */
|
||||
@Composable
|
||||
private fun SessionView(
|
||||
private fun SharedRoomIllustration(modifier: Modifier) {
|
||||
val colors = MillenniumTheme.colors
|
||||
Canvas(modifier) {
|
||||
val scale = minOf(size.width / 260f, size.height / 100f)
|
||||
val origin = Offset((size.width - 260f * scale) / 2f, (size.height - 100f * scale) / 2f)
|
||||
fun point(x: Float, y: Float) = origin + Offset(x * scale, y * scale)
|
||||
val stroke = 1.2f * scale
|
||||
val metal = Brush.verticalGradient(
|
||||
listOf(colors.raised, colors.surface),
|
||||
startY = origin.y,
|
||||
endY = origin.y + 100f * scale
|
||||
)
|
||||
drawLine(colors.border, point(45f, 50f), point(215f, 50f), 2f * scale)
|
||||
drawLine(colors.accent.copy(alpha = 0.7f), point(45f, 49f), point(215f, 49f), stroke)
|
||||
// Two miniature speakers, with an accent power LED and concentric drivers.
|
||||
for (x in listOf(15f, 207f)) {
|
||||
drawRoundRect(
|
||||
metal,
|
||||
point(x, 18f),
|
||||
Size(38f * scale, 64f * scale),
|
||||
CornerRadius(5f * scale)
|
||||
)
|
||||
drawRoundRect(
|
||||
colors.border,
|
||||
point(x, 18f),
|
||||
Size(38f * scale, 64f * scale),
|
||||
CornerRadius(5f * scale),
|
||||
style = Stroke(stroke)
|
||||
)
|
||||
drawCircle(colors.background, 10f * scale, point(x + 19f, 57f))
|
||||
drawCircle(colors.border, 10f * scale, point(x + 19f, 57f), style = Stroke(stroke))
|
||||
drawCircle(colors.raised, 5f * scale, point(x + 19f, 57f))
|
||||
drawCircle(colors.background, 4f * scale, point(x + 19f, 34f))
|
||||
drawCircle(colors.accent, 1.5f * scale, point(x + 19f, 74f))
|
||||
}
|
||||
// Brushed-metal source unit with a backlit equalizer display.
|
||||
drawRoundRect(
|
||||
metal,
|
||||
point(82f, 10f),
|
||||
Size(96f * scale, 80f * scale),
|
||||
CornerRadius(8f * scale)
|
||||
)
|
||||
drawRoundRect(
|
||||
colors.border,
|
||||
point(82f, 10f),
|
||||
Size(96f * scale, 80f * scale),
|
||||
CornerRadius(8f * scale),
|
||||
style = Stroke(stroke)
|
||||
)
|
||||
drawRoundRect(
|
||||
colors.highlight.copy(alpha = 0.25f),
|
||||
point(86f, 13f),
|
||||
Size(88f * scale, 1f * scale),
|
||||
CornerRadius(scale)
|
||||
)
|
||||
drawRoundRect(
|
||||
colors.background,
|
||||
point(92f, 22f),
|
||||
Size(76f * scale, 30f * scale),
|
||||
CornerRadius(3f * scale)
|
||||
)
|
||||
val bars = listOf(8f, 16f, 12f, 22f, 18f, 10f, 15f, 7f)
|
||||
bars.forEachIndexed { index, height ->
|
||||
drawRoundRect(
|
||||
colors.accent.copy(alpha = 0.85f),
|
||||
point(99f + index * 8f, 47f - height),
|
||||
Size(4f * scale, height * scale),
|
||||
CornerRadius(scale)
|
||||
)
|
||||
}
|
||||
drawCircle(colors.background, 11f * scale, point(130f, 70f))
|
||||
drawCircle(colors.border, 11f * scale, point(130f, 70f), style = Stroke(stroke))
|
||||
drawPath(Path().apply {
|
||||
moveTo(point(127f, 65f).x, point(127f, 65f).y)
|
||||
lineTo(point(135f, 70f).x, point(135f, 70f).y)
|
||||
lineTo(point(127f, 75f).x, point(127f, 75f).y)
|
||||
close()
|
||||
}, colors.text)
|
||||
for (x in listOf(101f, 159f)) drawRoundRect(
|
||||
colors.border,
|
||||
point(x - 4f, 68f),
|
||||
Size(8f * scale, 4f * scale),
|
||||
CornerRadius(scale)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SessionPanel(
|
||||
state: JamUiState,
|
||||
onShareCode: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onLeave: () -> Unit,
|
||||
onKick: (String) -> Unit,
|
||||
onBan: (String) -> Unit) {
|
||||
val isHost = state.role == JamRole.Host
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
if (!state.isConnected) {
|
||||
onBan: (String) -> Unit,
|
||||
onMenuChange: (String?) -> Unit
|
||||
) {
|
||||
JamPanel {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(7.dp).background(
|
||||
if (state.isConnected) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant,
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "Connecting to the session…",
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
stringResource(if (state.isConnected) Res.string.jam_session_connected else Res.string.jam_session_connecting),
|
||||
style = AppTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
ParticipantsSection(
|
||||
participants = state.participants,
|
||||
isHost = isHost,
|
||||
onKick = onKick,
|
||||
onBan = onBan)
|
||||
|
||||
if (isHost) {
|
||||
MillenniumSeparator()
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth().millenniumSurface(
|
||||
background = SolidColor(MillenniumTheme.colors.background),
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
borderColor = MillenniumTheme.colors.border,
|
||||
borderWidth = 1.dp
|
||||
).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Invite someone",
|
||||
style = AppTheme.typography.titleSmall)
|
||||
RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode)
|
||||
}
|
||||
|
||||
MillenniumSeparator()
|
||||
|
||||
Text(
|
||||
text = "The queue and playback controls are in the player at the bottom of the app — " +
|
||||
"the jam queue is shared with every participant.",
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
|
||||
LeaveButton(onLeave)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParticipantsSection(
|
||||
participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>,
|
||||
isHost: Boolean,
|
||||
onKick: (String) -> Unit,
|
||||
onBan: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Participants (${participants.size})",
|
||||
style = AppTheme.typography.titleSmall)
|
||||
participants.forEach { participant ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
stringResource(Res.string.jam_setup_code_label),
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = participant.displayName,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f))
|
||||
if (participant.isHost) {
|
||||
Text(
|
||||
text = "Host",
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.primary)
|
||||
}
|
||||
if (isHost && !participant.isHost) {
|
||||
MillenniumButton(
|
||||
onClick = { onKick(participant.id) },
|
||||
modifier = Modifier.height(32.dp),
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Kick", style = AppTheme.typography.labelSmall)
|
||||
}
|
||||
MillenniumButton(
|
||||
onClick = { onBan(participant.id) },
|
||||
modifier = Modifier.height(32.dp),
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Ban", style = AppTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
state.roomCode.orEmpty(),
|
||||
style = AppTheme.typography.headlineMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 5.sp,
|
||||
color = AppTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomCodeBox(
|
||||
code: String,
|
||||
onShare: () -> Unit) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val viewModel: JamViewModel = koinViewModel()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = code,
|
||||
style = AppTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = AppTheme.colors.primary,
|
||||
modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MillenniumButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(code)) }) {
|
||||
Text("Copy")
|
||||
}
|
||||
if (viewModel.supportsNativeShare) {
|
||||
if (state.isHost) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MillenniumButton(
|
||||
onClick = onCopy,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text(stringResource(if (state.codeCopied) Res.string.jam_invite_copied else Res.string.jam_invite_copy))
|
||||
}
|
||||
if (state.supportsNativeShare) MillenniumButton(
|
||||
onClick = onShare,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Share")
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text(stringResource(Res.string.jam_invite_share))
|
||||
}
|
||||
}
|
||||
}
|
||||
MillenniumSeparator()
|
||||
Text(
|
||||
stringResource(Res.string.jam_participants_heading, state.participants.size),
|
||||
style = AppTheme.typography.titleSmall
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxWidth().millenniumSurface(
|
||||
background = SolidColor(MillenniumTheme.colors.background),
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
borderColor = MillenniumTheme.colors.border,
|
||||
borderWidth = 1.dp
|
||||
).padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (state.participants.isEmpty()) Text(
|
||||
stringResource(Res.string.jam_participants_waiting),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
state.participants.forEach { participant ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(32.dp).millenniumSurface(
|
||||
background = millenniumBevelBrush(MillenniumTheme.colors),
|
||||
shape = CircleShape,
|
||||
borderColor = MillenniumTheme.colors.border,
|
||||
borderWidth = 1.dp
|
||||
), contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(participant.initial, style = AppTheme.typography.labelMedium)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
participant.displayName,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
stringResource(if (participant.isHost) Res.string.jam_participants_host else Res.string.jam_participants_guest),
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (participant.canModerate) {
|
||||
val removeLabel = stringResource(Res.string.jam_participants_remove)
|
||||
val banLabel = stringResource(Res.string.jam_participants_ban)
|
||||
// Keep destructive moderation out of the participant's primary row.
|
||||
org.spotube.millennium.composables.MillenniumDropdownMenu(
|
||||
expanded = participant.menuExpanded,
|
||||
onExpandedChange = { expanded -> onMenuChange(if (expanded) participant.id else null) },
|
||||
items = listOf(removeLabel, banLabel),
|
||||
onItemClick = { label ->
|
||||
if (label == removeLabel) onKick(participant.id) else onBan(
|
||||
participant.id
|
||||
)
|
||||
},
|
||||
anchor = {
|
||||
MillenniumButton(
|
||||
onClick = { onMenuChange(participant.id) },
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) { Text(stringResource(Res.string.jam_participants_manage)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MillenniumButton(
|
||||
onClick = onLeave,
|
||||
style = MillenniumButtonStyle.Secondary,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text(stringResource(Res.string.jam_session_leave))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveButton(onLeave: () -> Unit) {
|
||||
MillenniumButton(
|
||||
onClick = onLeave,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Leave Session")
|
||||
internal fun BrokerStatus(state: JamUiState, onConfigure: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
if (state.brokerConfigured) stringResource(
|
||||
Res.string.jam_connection_host,
|
||||
state.brokerHost
|
||||
) else stringResource(Res.string.jam_connection_missing),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
MillenniumTooltip(stringResource(Res.string.jam_connection_settings)) {
|
||||
MillenniumIconButton(onClick = onConfigure, style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
Iconsax.IconsaxSetting2,
|
||||
contentDescription = stringResource(Res.string.jam_connection_settings)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,8 @@ package dev.krtirtho.spotube.modules.jam
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import dev.krtirtho.spotube.PlatformType
|
||||
import dev.krtirtho.spotube.core.jam.JamParticipant
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
@ -30,27 +32,57 @@ import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Job
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
enum class JamSetupMode { Create, Join }
|
||||
|
||||
data class JamParticipantUi(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val initial: String,
|
||||
val isHost: Boolean,
|
||||
val canModerate: Boolean,
|
||||
val menuExpanded: Boolean = false
|
||||
)
|
||||
|
||||
data class JamUiState(
|
||||
val isActive: Boolean = false,
|
||||
val isConnected: Boolean = false,
|
||||
val role: JamRole? = null,
|
||||
val participants: List<JamParticipant> = emptyList(),
|
||||
val participants: List<JamParticipantUi> = emptyList(),
|
||||
val roomCode: String? = null,
|
||||
val brokerHost: String = "",
|
||||
val brokerConfigured: Boolean = false,
|
||||
val error: String? = null)
|
||||
val error: String? = null,
|
||||
val setupMode: JamSetupMode = JamSetupMode.Create,
|
||||
val codeInput: TextFieldState = TextFieldState(),
|
||||
val canCreate: Boolean = false,
|
||||
val canJoin: Boolean = false,
|
||||
val isBusy: Boolean = false,
|
||||
val isHost: Boolean = false,
|
||||
val supportsNativeShare: Boolean = false,
|
||||
val codeCopied: Boolean = false
|
||||
)
|
||||
|
||||
class JamViewModel(
|
||||
private val jamRoomService: JamRoomService,
|
||||
private val shareService: ShareService,
|
||||
private val settingsProvider: SettingsProvider) : ViewModel() {
|
||||
private val settingsProvider: SettingsProvider
|
||||
) : ViewModel() {
|
||||
|
||||
private val _localError = MutableStateFlow<String?>(null)
|
||||
private val setupMode = MutableStateFlow(JamSetupMode.Create)
|
||||
private val isBusy = MutableStateFlow(false)
|
||||
private val codeCopied = MutableStateFlow(false)
|
||||
private val codeInput = TextFieldState()
|
||||
private var copyFeedbackJob: Job? = null
|
||||
private val participantMenu = MutableStateFlow<String?>(null)
|
||||
|
||||
val supportsNativeShare: Boolean =
|
||||
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
|
||||
@ -62,9 +94,16 @@ class JamViewModel(
|
||||
jamRoomService.roomCode,
|
||||
jamRoomService.connectionError,
|
||||
settingsProvider.settingsState,
|
||||
_localError) { values ->
|
||||
_localError,
|
||||
setupMode,
|
||||
isBusy,
|
||||
codeCopied,
|
||||
snapshotFlow { codeInput.text.toString() },
|
||||
participantMenu
|
||||
) { values ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val role = values[0] as JamRole?
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val participants = values[1] as List<JamParticipant>
|
||||
val isConnected = values[2] as Boolean
|
||||
@ -72,48 +111,108 @@ class JamViewModel(
|
||||
val connectionError = values[4] as String?
|
||||
val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
val localError = values[6] as String?
|
||||
val busy = values[8] as Boolean
|
||||
val brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank()
|
||||
|
||||
JamUiState(
|
||||
isActive = role != null,
|
||||
isConnected = isConnected,
|
||||
role = role,
|
||||
participants = participants,
|
||||
participants = participants.sortedByDescending { it.isHost }.map { participant ->
|
||||
JamParticipantUi(
|
||||
id = participant.id,
|
||||
displayName = participant.displayName,
|
||||
initial = participant.displayName.firstOrNull()?.uppercase() ?: "?",
|
||||
isHost = participant.isHost,
|
||||
canModerate = role == JamRole.Host && !participant.isHost,
|
||||
menuExpanded = values[11] == participant.id
|
||||
)
|
||||
},
|
||||
roomCode = roomCode,
|
||||
brokerHost = settings?.jamBroker?.host.orEmpty(),
|
||||
brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(),
|
||||
error = localError ?: connectionError)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState())
|
||||
brokerConfigured = brokerConfigured,
|
||||
error = localError ?: connectionError,
|
||||
setupMode = values[7] as JamSetupMode,
|
||||
isBusy = busy,
|
||||
codeCopied = values[9] as Boolean,
|
||||
codeInput = codeInput,
|
||||
canCreate = brokerConfigured && !busy,
|
||||
canJoin = brokerConfigured && !busy &&
|
||||
JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)),
|
||||
isHost = role == JamRole.Host,
|
||||
supportsNativeShare = supportsNativeShare
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
JamUiState(codeInput = codeInput)
|
||||
)
|
||||
|
||||
// ---------- Session lifecycle ----------
|
||||
|
||||
fun createSession() {
|
||||
if (isBusy.value) return
|
||||
isBusy.value = true
|
||||
viewModelScope.launch {
|
||||
jamRoomService.createRoom()
|
||||
.onFailure { e ->
|
||||
_localError.value = e.message ?: "Failed to create jam session"
|
||||
}
|
||||
.onSuccess { _localError.value = null }
|
||||
try {
|
||||
jamRoomService.createRoom()
|
||||
.onFailure { e ->
|
||||
_localError.value = e.message ?: getString(Res.string.jam_error_create)
|
||||
}
|
||||
.onSuccess { _localError.value = null }
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSetupMode(mode: JamSetupMode) {
|
||||
setupMode.value = mode
|
||||
_localError.value = null
|
||||
}
|
||||
|
||||
fun joinSession() = joinWithCode(codeInput.text.toString())
|
||||
|
||||
fun onCodeCopied() {
|
||||
copyFeedbackJob?.cancel()
|
||||
copyFeedbackJob = viewModelScope.launch {
|
||||
codeCopied.value = true
|
||||
delay(2_000)
|
||||
codeCopied.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun joinWithCode(input: String) {
|
||||
if (isBusy.value) return
|
||||
val code = JamRoomCode.normalize(input)
|
||||
if (!JamRoomCode.isValid(code)) {
|
||||
_localError.value = "Room codes are ${JamRoomCode.LENGTH} characters (letters and digits)"
|
||||
viewModelScope.launch {
|
||||
_localError.value = getString(Res.string.jam_error_invalid_code)
|
||||
}
|
||||
return
|
||||
}
|
||||
isBusy.value = true
|
||||
viewModelScope.launch {
|
||||
jamRoomService.joinRoom(code)
|
||||
.onFailure { e ->
|
||||
_localError.value = e.message ?: "Failed to join jam session"
|
||||
}
|
||||
.onSuccess { _localError.value = null }
|
||||
try {
|
||||
jamRoomService.joinRoom(code)
|
||||
.onFailure { e ->
|
||||
_localError.value = e.message ?: getString(Res.string.jam_error_join)
|
||||
}
|
||||
.onSuccess { _localError.value = null }
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shareRoomCode() {
|
||||
val code = uiState.value.roomCode ?: return
|
||||
shareService.share("Join my Spotube Jam with code: $code", "Spotube Group Jam")
|
||||
viewModelScope.launch {
|
||||
shareService.share(
|
||||
getString(Res.string.jam_share_message, code),
|
||||
getString(Res.string.jam_screen_title)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun leave() {
|
||||
@ -130,10 +229,16 @@ class JamViewModel(
|
||||
// ---------- Host moderation ----------
|
||||
|
||||
fun kickParticipant(participantId: String) {
|
||||
participantMenu.value = null
|
||||
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
|
||||
}
|
||||
|
||||
fun banParticipant(participantId: String) {
|
||||
participantMenu.value = null
|
||||
viewModelScope.launch { jamRoomService.banParticipant(participantId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun showParticipantMenu(participantId: String?) {
|
||||
participantMenu.value = participantId
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +35,9 @@ import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
||||
fun AppBottombar(
|
||||
navigator: Navigator,
|
||||
navigationState: NavigationState,
|
||||
modifier: Modifier = Modifier) {
|
||||
modifier: Modifier = Modifier,
|
||||
libraryBadge: @Composable () -> Unit = { DownloadBadgeIndicator() }
|
||||
) {
|
||||
MillenniumBottomNavigationBar(modifier = modifier) {
|
||||
bottombarTabs.filterIsInstance<NavigationItem.Tab<*>>()
|
||||
.forEach { tab ->
|
||||
@ -48,14 +50,16 @@ fun AppBottombar(
|
||||
MillenniumIcon(
|
||||
imageVector = tab.icon,
|
||||
contentDescription = null,
|
||||
tint = LocalTextStyle.current.color)
|
||||
tint = LocalTextStyle.current.color
|
||||
)
|
||||
},
|
||||
label = { Text(tab.title) },
|
||||
badge = if (tab.route == Routes.Library) {
|
||||
{ DownloadBadgeIndicator() }
|
||||
libraryBadge
|
||||
} else {
|
||||
null
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,7 +23,8 @@ import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumProgress
|
||||
import org.spotube.millennium.theme.millenniumSurface
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import org.spotube.millennium.theme.millenniumPanelBrush
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
@ -38,43 +39,40 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.player_play_content_description
|
||||
import spotube.composeapp.generated.resources.player_pause_content_description
|
||||
import spotube.composeapp.generated.resources.player_nothing_playing
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
|
||||
internal val FloatingPlayerContentHeight = 64.dp
|
||||
internal val FloatingPlayerBottomBleed = 16.dp
|
||||
|
||||
// The floating player on small screens that appears above the floating AppBottombar
|
||||
//
|
||||
// on left the album art, song title, artists grouped together
|
||||
@ -94,17 +92,12 @@ fun AppFloatingPlayer(
|
||||
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
|
||||
key = SAVED_TRACKS_COLLECTION_ID,
|
||||
parameters = { parametersOf() }
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
||||
val scope = rememberCoroutineScope()
|
||||
val albumArtModifier =
|
||||
rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope)
|
||||
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
|
||||
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
|
||||
|
||||
LaunchedEffect(playerUiState.progress) {
|
||||
seekProgress = playerUiState.progress
|
||||
}
|
||||
|
||||
fun onPlayPause() {
|
||||
scope.launch {
|
||||
@ -116,29 +109,58 @@ fun AppFloatingPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
FloatingPlayerContent(
|
||||
playerUiState = playerUiState,
|
||||
modifier = modifier,
|
||||
albumArtModifier = albumArtModifier,
|
||||
onPlayPause = ::onPlayPause,
|
||||
heartButton = {
|
||||
PlayerHeartButton(
|
||||
audioPlayerQueue = audioPlayerQueue,
|
||||
savedTracksViewModel = savedTracksViewModel
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun FloatingPlayerContent(
|
||||
playerUiState: PlayerUiState,
|
||||
modifier: Modifier = Modifier,
|
||||
albumArtModifier: Modifier = Modifier,
|
||||
onPlayPause: () -> Unit,
|
||||
heartButton: @Composable () -> Unit
|
||||
) {
|
||||
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(76.dp)
|
||||
.height(FloatingPlayerContentHeight + FloatingPlayerBottomBleed)
|
||||
.millenniumSurface(
|
||||
background = SolidColor(AppTheme.colors.inverseSurface),
|
||||
shape = RoundedCornerShape(24.dp, 24.dp))) {
|
||||
Box(
|
||||
background = millenniumPanelBrush(MillenniumTheme.colors),
|
||||
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||
borderColor = MillenniumTheme.colors.border,
|
||||
borderWidth = 1.dp,
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()) {
|
||||
.fillMaxSize()
|
||||
// Only the surface extends beneath the navigation bar, not the controls.
|
||||
.padding(bottom = FloatingPlayerBottomBleed)
|
||||
) {
|
||||
MillenniumProgress(
|
||||
progress = seekProgress.coerceIn(0f, 1f),
|
||||
progress = playerUiState.progress,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp)
|
||||
.padding(bottom = 22.dp, top = 8.dp)
|
||||
.align(Alignment.Center),
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@ -148,30 +170,36 @@ fun AppFloatingPlayer(
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.size(42.dp)
|
||||
.then(albumArtModifier)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(AppTheme.colors.onSurface.copy(alpha = 0.12f))
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(AppTheme.colors.onSurface.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AsyncImage(
|
||||
if (coverModel != null) AsyncImage(
|
||||
model = coverModel,
|
||||
contentDescription = playerUiState.title,
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop)
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
else MillenniumIcon(
|
||||
Iconsax.IconsaxMusic, contentDescription = null,
|
||||
tint = AppTheme.colors.onSurfaceVariant, modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Text(
|
||||
text = playerUiState.title,
|
||||
text = if (playerUiState.queue.isEmpty()) stringResource(Res.string.player_nothing_playing) else playerUiState.title,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.inverseOnSurface,
|
||||
color = AppTheme.colors.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
if (playerUiState.queue.isNotEmpty()) Text(
|
||||
text = playerUiState.artists,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.inverseOnSurface.copy(alpha = 0.72f),
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@ -179,17 +207,17 @@ fun AppFloatingPlayer(
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
PlayerHeartButton(
|
||||
audioPlayerQueue = audioPlayerQueue,
|
||||
savedTracksViewModel = savedTracksViewModel)
|
||||
heartButton()
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
MillenniumIconButton(
|
||||
onClick = ::onPlayPause,
|
||||
onClick = onPlayPause,
|
||||
enabled = playerUiState.queue.isNotEmpty(),
|
||||
style = MillenniumIconButtonStyle.Secondary) {
|
||||
style = MillenniumIconButtonStyle.Secondary
|
||||
) {
|
||||
MillenniumIcon(
|
||||
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play")
|
||||
contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_content_description)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -201,12 +229,14 @@ fun AppFloatingPlayer(
|
||||
@Composable
|
||||
private fun rememberSharedAlbumArtModifier(
|
||||
sharedTransitionScope: SharedTransitionScope?,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?): Modifier {
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?
|
||||
): Modifier {
|
||||
if (sharedTransitionScope == null || animatedVisibilityScope == null) return Modifier
|
||||
|
||||
with(sharedTransitionScope) {
|
||||
return Modifier.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(key = "player_album_art"),
|
||||
animatedVisibilityScope = animatedVisibilityScope)
|
||||
animatedVisibilityScope = animatedVisibilityScope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +36,7 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
@ -43,6 +44,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@ -83,7 +85,8 @@ fun AppShell(
|
||||
viewModel: AppShellViewModel = koinViewModel<AppShellViewModel>(),
|
||||
queueViewModel: PlayerQueueContentViewModel = koinViewModel<PlayerQueueContentViewModel>(),
|
||||
alternativeViewModel: AlternativeTrackContentViewModel = koinViewModel<AlternativeTrackContentViewModel>(),
|
||||
content: @Composable () -> Unit) {
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val navigatorCommands: NavigationCommands = koinInject()
|
||||
val remotePlaybackController: RemotePlaybackController = koinInject()
|
||||
val snackbarHostState = remember { MillenniumSnackbarHostState() }
|
||||
@ -127,11 +130,13 @@ fun AppShell(
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val useSidebar = viewModel.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
|
||||
val bottomOverlayInset =
|
||||
viewModel.bottomOverlayInset(useSidebar, navigationBarInset, chromeUiState)
|
||||
|
||||
CompositionLocalProvider(LocalAppShellBottomInset provides bottomOverlayInset) {
|
||||
if (useSidebar) {
|
||||
@ -153,8 +158,11 @@ fun AppShell(
|
||||
)
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
vertical = true)
|
||||
Box(modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)) {
|
||||
vertical = true
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.weight(1f).fillMaxHeight().hazeSource(hazeState)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@ -187,19 +195,23 @@ fun AppShell(
|
||||
QueueSheet(
|
||||
isVisible = isQueueVisible,
|
||||
onDismiss = { queueViewModel.setQueueVisibility(false) },
|
||||
modifier = Modifier.fillMaxSize()) {
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
PlayerQueueContent(
|
||||
viewModel = queueViewModel,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
AlternativeTrackSheet(
|
||||
isVisible = isAlternativeVisible,
|
||||
onDismiss = { alternativeViewModel.setAlternativeVisibility(false) },
|
||||
modifier = Modifier.fillMaxSize()) {
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
AlternativeTrackContent(
|
||||
viewModel = alternativeViewModel,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -207,7 +219,8 @@ fun AppShell(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MillenniumTheme.colors.background))
|
||||
.background(MillenniumTheme.colors.background)
|
||||
)
|
||||
CompactPlayerOverlay(
|
||||
navigator = navigator,
|
||||
navigationState = navigationState,
|
||||
@ -215,7 +228,8 @@ fun AppShell(
|
||||
onQueue = queueViewModel::toggleQueueVisibility,
|
||||
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility,
|
||||
onExpandLyrics = viewModel::showLyricsOverlay,
|
||||
content = content)
|
||||
content = content
|
||||
)
|
||||
|
||||
QueueSheet(
|
||||
isVisible = isQueueVisible,
|
||||
@ -224,16 +238,19 @@ fun AppShell(
|
||||
) {
|
||||
PlayerQueueContent(
|
||||
viewModel = queueViewModel,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
AlternativeTrackSheet(
|
||||
isVisible = isAlternativeVisible,
|
||||
onDismiss = { alternativeViewModel.setAlternativeVisibility(false) },
|
||||
modifier = Modifier.fillMaxSize()) {
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
AlternativeTrackContent(
|
||||
viewModel = alternativeViewModel,
|
||||
modifier = Modifier.fillMaxSize())
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
if (isLyricsOverlayVisible) {
|
||||
@ -242,8 +259,13 @@ fun AppShell(
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true)) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(MillenniumTheme.colors.background)) {
|
||||
dismissOnClickOutside = true
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
.background(MillenniumTheme.colors.background)
|
||||
) {
|
||||
LyricsScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onClose = { viewModel.hideLyricsOverlay() })
|
||||
@ -260,40 +282,56 @@ fun AppShell(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = bottomOverlayInset + 12.dp))
|
||||
.padding(bottom = bottomOverlayInset + 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
|
||||
ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class
|
||||
)
|
||||
@Composable
|
||||
private fun CompactPlayerOverlay(
|
||||
internal fun CompactPlayerOverlay(
|
||||
navigator: Navigator,
|
||||
navigationState: NavigationState,
|
||||
viewModel: AppShellViewModel,
|
||||
onQueue: () -> Unit,
|
||||
onAlternativeSource: () -> Unit,
|
||||
onExpandLyrics: () -> Unit,
|
||||
content: @Composable () -> Unit) {
|
||||
floatingPlayer: @Composable (Modifier) -> Unit = { AppFloatingPlayer(modifier = it) },
|
||||
bottomBar: @Composable (Modifier) -> Unit = {
|
||||
AppBottombar(navigator = navigator, navigationState = navigationState, modifier = it)
|
||||
},
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val chromeUiState by viewModel.compactChromeUiState.collectAsState()
|
||||
|
||||
val floatingPlayerHeightPx = with(density) { viewModel.floatingPlayerHeight.toPx() }
|
||||
val bottomBarHeightPx = with(density) { viewModel.bottomBarHeight.toPx() }
|
||||
val navBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat()
|
||||
val playerBarSpacingPx = with(density) { viewModel.floatingPlayerSpacing.toPx() }
|
||||
val peekHeightPx =
|
||||
floatingPlayerHeightPx + bottomBarHeightPx + navBarInsetPx + playerBarSpacingPx
|
||||
val bottomBarHeightPx = with(density) {
|
||||
chromeUiState.measuredBottomBarHeight?.toPx()
|
||||
?: (viewModel.bottomBarHeight.toPx() + navBarInsetPx)
|
||||
}
|
||||
val peekHeightPx = floatingPlayerHeightPx + bottomBarHeightPx
|
||||
val peekHeight = with(density) { peekHeightPx.toDp() }
|
||||
|
||||
val partialDetent = remember(peekHeight) {
|
||||
SheetDetent("player-peek") { _, _ -> peekHeight }
|
||||
}
|
||||
val sheetState = rememberBottomSheetState(
|
||||
initialDetent = partialDetent,
|
||||
detents = listOf(partialDetent, SheetDetent.FullyExpanded))
|
||||
// The library remembers its detent definitions at creation. Recreate the state when
|
||||
// the measured chrome height changes, rather than keeping a stale peek anchor.
|
||||
val sheetState = key(peekHeight) {
|
||||
rememberBottomSheetState(
|
||||
initialDetent = partialDetent,
|
||||
detents = listOf(partialDetent, SheetDetent.FullyExpanded)
|
||||
)
|
||||
}
|
||||
|
||||
val progress = runCatching {
|
||||
val progress = if (!sheetState.offset.isFinite() ||
|
||||
(sheetState.isIdle && sheetState.currentDetent == partialDetent)
|
||||
) 0f else runCatching {
|
||||
sheetState.progress(partialDetent, SheetDetent.FullyExpanded)
|
||||
}.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f
|
||||
val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f))
|
||||
@ -303,7 +341,8 @@ private fun CompactPlayerOverlay(
|
||||
|
||||
UnstyledBottomSheet(
|
||||
state = sheetState,
|
||||
modifier = Modifier.fillMaxSize()) {
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Sheet(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (uiState.showExpandedPlayer) {
|
||||
@ -316,13 +355,14 @@ private fun CompactPlayerOverlay(
|
||||
},
|
||||
onQueue = onQueue,
|
||||
onAlternativeSource = onAlternativeSource,
|
||||
onExpandLyrics = onExpandLyrics)
|
||||
onExpandLyrics = onExpandLyrics
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.floatingPlayerAlpha > 0.01f) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
AppFloatingPlayer(
|
||||
modifier = Modifier
|
||||
floatingPlayer(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
alpha = uiState.floatingPlayerAlpha
|
||||
})
|
||||
@ -333,12 +373,13 @@ private fun CompactPlayerOverlay(
|
||||
}
|
||||
|
||||
if (uiState.floatingPlayerAlpha > 0.01f) {
|
||||
AppBottombar(
|
||||
navigator = navigator,
|
||||
navigationState = navigationState,
|
||||
modifier = Modifier
|
||||
bottomBar(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.onSizeChanged { size ->
|
||||
viewModel.onBottomBarMeasured(with(density) { size.height.toDp() })
|
||||
}
|
||||
.graphicsLayer {
|
||||
alpha = uiState.floatingPlayerAlpha
|
||||
})
|
||||
|
||||
@ -35,27 +35,34 @@ data class CompactSheetUiState(
|
||||
val showExpandedPlayer: Boolean,
|
||||
val expandedPlayerAlpha: Float,
|
||||
val floatingPlayerAlpha: Float,
|
||||
val bottomBarAlpha: Float)
|
||||
val bottomBarAlpha: Float
|
||||
)
|
||||
|
||||
data class LyricsOverlayState(
|
||||
val isVisible: Boolean = false)
|
||||
val isVisible: Boolean = false
|
||||
)
|
||||
|
||||
data class CompactChromeUiState(val measuredBottomBarHeight: Dp? = null)
|
||||
|
||||
class AppShellViewModel : ViewModel() {
|
||||
val sidebarMinWidth = 840.dp
|
||||
val bottomBarHeight = 80.dp
|
||||
val floatingPlayerHeight = 76.dp
|
||||
val floatingPlayerSpacing = 12.dp
|
||||
// The background bleed overlaps the bottom bar and is not part of the peek height.
|
||||
val floatingPlayerHeight = FloatingPlayerContentHeight
|
||||
val largePlayerInset = 100.dp
|
||||
val floatingPlayerDismissProgress = 0.7f
|
||||
val expandedPlayerVisibilityThreshold = 0.001f
|
||||
|
||||
private val lyricsOverlayVisible = MutableStateFlow(false)
|
||||
private val compactChrome = MutableStateFlow(CompactChromeUiState())
|
||||
val compactChromeUiState = compactChrome.asStateFlow()
|
||||
|
||||
fun onBottomBarMeasured(height: Dp) {
|
||||
if (height > 0.dp) compactChrome.value = CompactChromeUiState(height)
|
||||
}
|
||||
|
||||
val isLyricsOverlayVisible: StateFlow<Boolean> = lyricsOverlayVisible.asStateFlow()
|
||||
|
||||
val compactChromeInset: Dp
|
||||
get() = bottomBarHeight + floatingPlayerHeight + floatingPlayerSpacing
|
||||
|
||||
@Composable
|
||||
fun useSidebar(maxWidth: Dp? = null): Boolean {
|
||||
if (maxWidth != null) {
|
||||
@ -64,8 +71,14 @@ class AppShellViewModel : ViewModel() {
|
||||
return currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Expanded
|
||||
}
|
||||
|
||||
fun bottomOverlayInset(useSidebar: Boolean): Dp =
|
||||
if (useSidebar) largePlayerInset else compactChromeInset
|
||||
fun bottomOverlayInset(
|
||||
useSidebar: Boolean,
|
||||
navigationBarInset: Dp,
|
||||
chrome: CompactChromeUiState
|
||||
): Dp =
|
||||
if (useSidebar) largePlayerInset + navigationBarInset
|
||||
else (chrome.measuredBottomBarHeight ?: (bottomBarHeight + navigationBarInset)) +
|
||||
floatingPlayerHeight
|
||||
|
||||
fun sheetProgress(sheetOffsetPx: Float, sheetHeightPx: Float): Float =
|
||||
if (sheetHeightPx > 0f) {
|
||||
@ -85,7 +98,8 @@ class AppShellViewModel : ViewModel() {
|
||||
showExpandedPlayer = progress > expandedPlayerVisibilityThreshold,
|
||||
expandedPlayerAlpha = progress,
|
||||
floatingPlayerAlpha = floatingPlayerAlpha(progress),
|
||||
bottomBarAlpha = 1f - progress)
|
||||
bottomBarAlpha = 1f - progress
|
||||
)
|
||||
}
|
||||
|
||||
fun showLyricsOverlay() {
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user