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_identity">Identity</string>
<string name="settings_jam_section_diagnostics">Diagnostics</string> <string name="settings_jam_section_diagnostics">Diagnostics</string>
<!-- Group Jam -->
<string name="jam_screen_title">Group Jam</string>
<string name="jam_setup_create_tab">Create</string>
<string name="jam_setup_join_tab">Join</string>
<string name="jam_setup_create_hint">Create a room, then share its code with your friends.</string>
<string name="jam_setup_create_button">Create room</string>
<string name="jam_setup_join_button">Join room</string>
<string name="jam_setup_working">Connecting…</string>
<string name="jam_setup_code_label">Room code</string>
<string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %1$s</string>
<string name="jam_connection_missing">Set up a broker in Settings to host or join a room.</string>
<string name="jam_connection_settings">Broker settings</string>
<string name="jam_session_connected">Connected</string>
<string name="jam_session_connecting">Reconnecting…</string>
<string name="jam_invite_copy">Copy code</string>
<string name="jam_invite_copied">Copied!</string>
<string name="jam_invite_share">Share</string>
<string name="jam_participants_heading">Participants (%1$d)</string>
<string name="jam_participants_host">Host</string>
<string name="jam_participants_guest">Listener</string>
<string name="jam_participants_waiting">Waiting for listeners…</string>
<string name="jam_participants_remove">Remove</string>
<string name="jam_participants_ban">Ban</string>
<string name="jam_participants_manage">Manage</string>
<string name="jam_session_leave">Leave room</string>
<string name="jam_error_title">Couldn’t connect</string>
<string name="jam_error_dismiss">Dismiss</string>
<string name="jam_error_create">Failed to create jam session</string>
<string name="jam_error_join">Failed to join jam session</string>
<string name="jam_error_invalid_code">Enter a six-character room code using letters and digits.</string>
<string name="jam_share_message">Join my Spotube Jam with code: %1$s</string>
<string name="player_go_to_album">Go to album</string> <string name="player_go_to_album">Go to album</string>
<string name="player_sleep_timer">Sleep timer</string> <string name="player_sleep_timer">Sleep timer</string>
<string name="player_track_details">Track details</string> <string name="player_track_details">Track details</string>
<string name="player_play_content_description">Play</string> <string name="player_play_content_description">Play</string>
<string name="player_pause_content_description">Pause</string> <string name="player_pause_content_description">Pause</string>
<string name="player_nothing_playing">Nothing playing</string>
<string name="player_add_to_queue_content_description">Add to queue</string> <string name="player_add_to_queue_content_description">Add to queue</string>
<string name="update_action_ignore">Ignore</string> <string name="update_action_ignore">Ignore</string>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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