diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 32787564..b8052b87 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -234,11 +234,45 @@
Identity
Diagnostics
+
+ Group Jam
+ Create
+ Join
+ Create a room, then share its code with your friends.
+ Create room
+ Join room
+ Connecting…
+ Room code
+ ABC123
+ Broker: %1$s
+ Set up a broker in Settings to host or join a room.
+ Broker settings
+ Connected
+ Reconnecting…
+ Copy code
+ Copied!
+ Share
+ Participants (%1$d)
+ Host
+ Listener
+ Waiting for listeners…
+ Remove
+ Ban
+ Manage
+ Leave room
+ Couldn’t connect
+ Dismiss
+ Failed to create jam session
+ Failed to join jam session
+ Enter a six-character room code using letters and digits.
+ Join my Spotube Jam with code: %1$s
+
Go to album
Sleep timer
Track details
Play
Pause
+ Nothing playing
Add to queue
Ignore
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt
index 8693e7d3..9180c0f8 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt
@@ -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()
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,
- 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)
+ )
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt
index c361668d..2748f093 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt
@@ -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 = emptyList(),
+ val participants: List = 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(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(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
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) }
}
-}
\ No newline at end of file
+
+ fun showParticipantMenu(participantId: String?) {
+ participantMenu.value = participantId
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt
index c7e4cd30..96b7f4c7 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppBottombar.kt
@@ -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>()
.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
- })
+ }
+ )
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt
index 3c7bcab8..420234b8 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppFloatingPlayer.kt
@@ -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(
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
+ )
}
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt
index f5c9e948..d2a2714b 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt
@@ -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(),
queueViewModel: PlayerQueueContentViewModel = koinViewModel(),
alternativeViewModel: AlternativeTrackContentViewModel = koinViewModel(),
- 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
})
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt
index 768f44c1..e5e22813 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShellViewModel.kt
@@ -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 = 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() {
diff --git a/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/jam/JamLayoutTest.kt b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/jam/JamLayoutTest.kt
new file mode 100644
index 00000000..44efa651
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/dev/krtirtho/spotube/modules/jam/JamLayoutTest.kt
@@ -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) }
+ }
+ }
+ }
+ }
+ }
+}