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