feat(jam): implement group jam functionality with UI components and state management

This commit is contained in:
Kingkor Roy Tirtho 2026-10-14 20:39:54 +06:00
parent d5eaf101eb
commit 5b442a93a9
8 changed files with 933 additions and 356 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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