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