feat(simpconnect): implement Spotify Connect-like P2P LAN control and handoff between Mobile and Desktop

This commit is contained in:
Murilo Scolari 2026-10-21 15:13:01 -03:00
parent ce630fe35d
commit b9a386fb9d
13 changed files with 748 additions and 1 deletions

View file

@ -21,6 +21,13 @@
#-renamesourcefileattribute SourceFile
-keep class kotlinx.coroutines.CoroutineExceptionHandler
-keep class kotlinx.coroutines.internal.MainDispatcherFactory
# Ktor CIO / utils debug detector referencing JVM management classes
-dontwarn java.lang.management.**
-dontwarn io.ktor.util.debug.**
-dontwarn io.ktor.server.**
-dontwarn io.ktor.network.**
# Keep `Companion` object fields of serializable classes.
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
-if @kotlinx.serialization.Serializable class **

View file

@ -22,6 +22,8 @@
Android 17 (API 37) and up, where it is a runtime permission; LoginSync.android.kt asks
for it then. Declared now so a targetSdk bump cannot make the connection silently time out. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- SimpConnect P2P Discovery: UDP broadcast/multicast packets on Wi-Fi -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-feature
android:name="android.hardware.type.automotive"

View file

@ -1238,6 +1238,7 @@ fun App(
},
)
}
com.maxrave.simpmusic.ui.component.SimpConnectAuthDialog(sharedViewModel = viewModel)
},
)
}

View file

@ -0,0 +1,65 @@
package com.maxrave.simpmusic.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.maxrave.domain.data.model.simpconnect.DiscoveredDeviceDomain
import com.maxrave.simpmusic.ui.icon.Devices
import com.maxrave.simpmusic.ui.icon.SimpIcons
import com.maxrave.simpmusic.ui.theme.typo
private val SpotifyGreen = Color(0xFF1DB954)
@Composable
fun SimpConnectRemoteBadge(
activeRemoteDevice: DiscoveredDeviceDomain?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = activeRemoteDevice != null,
modifier = modifier,
) {
if (activeRemoteDevice != null) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(SpotifyGreen.copy(alpha = 0.2f))
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = SimpIcons.Devices,
contentDescription = null,
tint = SpotifyGreen,
modifier = Modifier.size(14.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Controlando: ${activeRemoteDevice.deviceName}",
style = typo().labelSmall.copy(fontSize = 11.sp),
fontWeight = FontWeight.Bold,
color = SpotifyGreen,
maxLines = 1,
)
}
}
}
}

View file

@ -0,0 +1,398 @@
package com.maxrave.simpmusic.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.maxrave.domain.data.model.simpconnect.DiscoveredDeviceDomain
import com.maxrave.domain.data.model.simpconnect.SimpConnectStatusDomain
import com.maxrave.simpmusic.ui.icon.Computer
import com.maxrave.simpmusic.ui.icon.Devices
import com.maxrave.simpmusic.ui.icon.SimpIcons
import com.maxrave.simpmusic.ui.icon.Smartphone
import com.maxrave.simpmusic.ui.icon.Sync
import com.maxrave.simpmusic.ui.theme.typo
import com.maxrave.simpmusic.viewModel.SharedViewModel
private val SpotifyGreen = Color(0xFF1DB954)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SimpConnectDevicePickerBottomSheet(
onDismiss: () -> Unit,
sharedViewModel: SharedViewModel,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val localDevice by sharedViewModel.simpConnectLocalDevice.collectAsStateWithLifecycle()
val discoveredDevices by sharedViewModel.simpConnectDiscoveredDevices.collectAsStateWithLifecycle()
val activeRemoteDevice by sharedViewModel.simpConnectActiveRemoteDevice.collectAsStateWithLifecycle()
val status by sharedViewModel.simpConnectStatus.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
sharedViewModel.refreshSimpConnectDevices()
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
dragHandle = null,
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
) {
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 24.dp, vertical = 20.dp)
.windowInsetsPadding(WindowInsets.navigationBars),
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(
text = "Conectar a um dispositivo",
style = typo().titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "SimpConnect P2P",
style = typo().bodySmall,
color = SpotifyGreen,
fontWeight = FontWeight.SemiBold,
)
}
IconButton(
onClick = { sharedViewModel.refreshSimpConnectDevices() },
) {
Icon(
imageVector = SimpIcons.Sync,
contentDescription = "Atualizar",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Handoff Banner if remote is active
AnimatedVisibility(visible = activeRemoteDevice != null) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
colors = CardDefaults.cardColors(
containerColor = SpotifyGreen.copy(alpha = 0.15f),
),
shape = RoundedCornerShape(16.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = SimpIcons.Devices,
contentDescription = null,
tint = SpotifyGreen,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Controlando: ${activeRemoteDevice?.deviceName}",
style = typo().bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "O som está saindo no outro aparelho",
style = typo().bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
sharedViewModel.requestSimpConnectHandoff()
onDismiss()
},
colors = ButtonDefaults.buttonColors(
containerColor = SpotifyGreen,
contentColor = Color.White,
),
shape = RoundedCornerShape(20.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Continuar tocando neste aparelho",
style = typo().labelMedium,
fontWeight = FontWeight.Bold,
)
}
}
}
}
// Current Device Row
Text(
text = "ESTE DISPOSITIVO",
style = typo().labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(vertical = 8.dp),
)
DeviceRow(
name = localDevice.deviceName,
subtitle = if (activeRemoteDevice == null) "Tocando neste aparelho" else "Toque para voltar a este aparelho",
isDesktop = localDevice.platform == "desktop",
isActive = activeRemoteDevice == null,
onClick = {
if (activeRemoteDevice != null) {
sharedViewModel.disconnectSimpConnect()
}
},
)
Spacer(modifier = Modifier.height(16.dp))
// Discovered Devices
Text(
text = "DISPOSITIVOS NA REDE (WI-FI)",
style = typo().labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(vertical = 8.dp),
)
if (discoveredDevices.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = SimpIcons.Devices,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(36.dp),
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Nenhum outro aparelho encontrado na rede",
style = typo().bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Abra o SimpMusic no PC ou outro celular na mesma Wi-Fi",
style = typo().bodySmall.copy(fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(discoveredDevices, key = { it.deviceId }) { device ->
val isConnectedToThis = activeRemoteDevice?.deviceId == device.deviceId
val isConnectingToThis = isConnectedToThis && status == SimpConnectStatusDomain.CONNECTING
val isWaitingApproval = isConnectedToThis && status == SimpConnectStatusDomain.WAITING_FOR_APPROVAL
val subText = when {
isConnectedToThis -> "Conectado como controle remoto"
isWaitingApproval -> "Aguardando confirmação na tela do aparelho..."
isConnectingToThis -> "Conectando..."
else -> "Disponível na rede local (${device.hostAddress})"
}
DeviceRow(
name = device.deviceName,
subtitle = subText,
isDesktop = device.platform == "desktop",
isActive = isConnectedToThis,
isLoading = isConnectingToThis || isWaitingApproval,
onClick = {
if (isConnectedToThis) {
sharedViewModel.disconnectSimpConnect()
} else {
sharedViewModel.connectSimpConnectDevice(device)
}
},
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Conexão direta P2P local instantânea. Nenhum dado sai da sua rede.",
style = typo().bodySmall.copy(fontSize = 10.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
}
@Composable
private fun DeviceRow(
name: String,
subtitle: String,
isDesktop: Boolean,
isActive: Boolean,
isLoading: Boolean = false,
onClick: () -> Unit,
) {
val icon = if (isDesktop) SimpIcons.Computer else SimpIcons.Smartphone
val tint = if (isActive) SpotifyGreen else MaterialTheme.colorScheme.onSurface
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.background(
if (isActive) SpotifyGreen.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surfaceVariant,
CircleShape,
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(20.dp),
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = name,
style = typo().bodyMedium,
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Medium,
color = if (isActive) SpotifyGreen else MaterialTheme.colorScheme.onSurface,
)
Text(
text = subtitle,
style = typo().bodySmall.copy(fontSize = 12.sp),
color = if (isActive) SpotifyGreen.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = SpotifyGreen,
)
} else if (isActive) {
Box(
modifier = Modifier
.size(10.dp)
.background(SpotifyGreen, CircleShape),
)
}
}
}
@Composable
fun SimpConnectAuthDialog(
sharedViewModel: SharedViewModel,
) {
val request by sharedViewModel.simpConnectIncomingAuthRequest.collectAsStateWithLifecycle()
if (request != null) {
AlertDialog(
onDismissRequest = { request?.onResponse?.invoke(false) },
title = {
Text(
text = "Permitir controle remoto?",
style = typo().titleMedium,
fontWeight = FontWeight.Bold,
)
},
text = {
Text(
text = "O aparelho \"${request?.deviceName}\" deseja se conectar para controlar a música do SimpMusic nesta sessão.",
style = typo().bodyMedium,
)
},
confirmButton = {
Button(
onClick = { request?.onResponse?.invoke(true) },
colors = ButtonDefaults.buttonColors(containerColor = SpotifyGreen),
) {
Text("Permitir", color = Color.White)
}
},
dismissButton = {
TextButton(onClick = { request?.onResponse?.invoke(false) }) {
Text("Recusar")
}
},
)
}
}

View file

@ -0,0 +1,62 @@
package com.maxrave.simpmusic.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
@Suppress("CheckReturnValue")
val SimpIcons.Computer: ImageVector
get() {
if (_Computer != null) {
return _Computer!!
}
_Computer =
ImageVector.Builder(
name = "Computer",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
).apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.Companion.NonZero,
) {
moveTo(20f, 18f)
curveToRelative(1.1f, 0f, 1.99f, -0.9f, 1.99f, -2f)
lineTo(22f, 6f)
curveToRelative(0f, -1.1f, -0.9f, -2f, -2f, -2f)
horizontalLineTo(4f)
curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f)
verticalLineToRelative(10f)
curveToRelative(0f, 1.1f, 0.9f, 2f, 2f, 2f)
horizontalLineTo(0f)
verticalLineToRelative(2f)
horizontalLineToRelative(24f)
verticalLineToRelative(-2f)
horizontalLineToRelative(-4f)
close()
moveTo(4f, 6f)
horizontalLineToRelative(16f)
verticalLineToRelative(10f)
horizontalLineTo(4f)
verticalLineTo(6f)
close()
}
}.build()
return _Computer!!
}
private var _Computer: ImageVector? = null

View file

@ -0,0 +1,71 @@
package com.maxrave.simpmusic.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
@Suppress("CheckReturnValue")
val SimpIcons.Devices: ImageVector
get() {
if (_Devices != null) {
return _Devices!!
}
_Devices =
ImageVector.Builder(
name = "Devices",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
).apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.Companion.NonZero,
) {
moveTo(4f, 6f)
horizontalLineToRelative(18f)
verticalLineTo(4f)
horizontalLineTo(4f)
curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f)
verticalLineToRelative(11f)
horizontalLineTo(0f)
verticalLineToRelative(3f)
horizontalLineToRelative(14f)
verticalLineToRelative(-3f)
horizontalLineTo(4f)
verticalLineTo(6f)
close()
moveTo(23f, 8f)
horizontalLineToRelative(-6f)
curveToRelative(-0.55f, 0f, -1f, 0.45f, -1f, 1f)
verticalLineToRelative(10f)
curveToRelative(0f, 0.55f, 0.45f, 1f, 1f, 1f)
horizontalLineToRelative(6f)
curveToRelative(0.55f, 0f, 1f, -0.45f, 1f, -1f)
verticalLineTo(9f)
curveToRelative(0f, -0.55f, -0.45f, -1f, -1f, -1f)
close()
moveTo(22f, 17f)
horizontalLineToRelative(-4f)
verticalLineToRelative(-7f)
horizontalLineToRelative(4f)
verticalLineToRelative(7f)
close()
}
}.build()
return _Devices!!
}
private var _Devices: ImageVector? = null

View file

@ -128,10 +128,13 @@ import com.maxrave.simpmusic.ui.component.HeartCheckBox
import com.maxrave.simpmusic.ui.component.PlayPauseButton
import com.maxrave.simpmusic.ui.component.PlayerControlLayout
import com.maxrave.simpmusic.ui.component.QueueBottomSheet
import com.maxrave.simpmusic.ui.component.SimpConnectDevicePickerBottomSheet
import com.maxrave.simpmusic.ui.component.SimpConnectRemoteBadge
import com.maxrave.simpmusic.ui.component.liquidGlass
import com.maxrave.simpmusic.ui.component.rememberGlassLuminance
import com.maxrave.simpmusic.ui.component.rememberHolderPainter
import com.maxrave.simpmusic.ui.icon.Close
import com.maxrave.simpmusic.ui.icon.Devices
import com.maxrave.simpmusic.ui.icon.Headphones
import com.maxrave.simpmusic.ui.icon.OpenInFull
import com.maxrave.simpmusic.ui.icon.PictureInPictureAlt
@ -236,6 +239,18 @@ fun MiniPlayer(
mutableStateOf(true)
}
val activeRemoteDevice by sharedViewModel.simpConnectActiveRemoteDevice.collectAsStateWithLifecycle()
var showSimpConnectSheet by rememberSaveable {
mutableStateOf(false)
}
if (showSimpConnectSheet) {
SimpConnectDevicePickerBottomSheet(
onDismiss = { showSimpConnectSheet = false },
sharedViewModel = sharedViewModel,
)
}
LaunchedEffect(key1 = true) {
val job1 =
launch {
@ -480,6 +495,11 @@ fun MiniPlayer(
.wrapContentHeight()
.align(Alignment.CenterVertically),
) {
SimpConnectRemoteBadge(
activeRemoteDevice = activeRemoteDevice,
onClick = { showSimpConnectSheet = true },
modifier = Modifier.padding(bottom = 2.dp),
)
Text(
text = (songEntity?.title ?: "").toString(),
style = if (isFlat) typo().titleSmall else typo().labelSmall,
@ -546,6 +566,29 @@ fun MiniPlayer(
sharedViewModel.onUIEvent(UIEvent.ToggleLike)
}
}
Spacer(modifier = Modifier.width(if (isFlat) 4.dp else 8.dp))
Box(
modifier =
if (isFlat) {
Modifier
.size(controlSize)
.background(MaterialTheme.colorScheme.surfaceContainerHighest, CircleShape)
} else {
Modifier
},
contentAlignment = Alignment.Center,
) {
IconButton(
onClick = { showSimpConnectSheet = true },
) {
Icon(
imageVector = SimpIcons.Devices,
tint = if (activeRemoteDevice != null) Color(0xFF1DB954) else textColor,
contentDescription = "SimpConnect Devices",
modifier = Modifier.size(22.dp),
)
}
}
Spacer(modifier = Modifier.width(if (isFlat) 8.dp else 15.dp))
Box(
modifier =
@ -1050,6 +1093,17 @@ fun MiniPlayer(
contentDescription = "",
)
}
IconButton(
onClick = {
showSimpConnectSheet = true
},
) {
Icon(
imageVector = SimpIcons.Devices,
tint = if (activeRemoteDevice != null) Color(0xFF1DB954) else textColor,
contentDescription = "SimpConnect Devices",
)
}
// Desktop mini player button (JVM only)
if (getPlatform() == Platform.Desktop) {
IconButton(onClick = { toggleMiniPlayer() }) {

View file

@ -68,6 +68,11 @@ import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.koinInject
import com.maxrave.simpmusic.viewModel.SharedViewModel
import com.maxrave.simpmusic.ui.icon.Devices
import com.maxrave.simpmusic.ui.component.SimpConnectDevicePickerBottomSheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@ -213,6 +218,16 @@ fun NowPlayingContentSpotify(
var showShareLyricsSheet by rememberSaveable { mutableStateOf(false) }
var showLyricsOffsetBar by rememberSaveable { mutableStateOf(false) }
var showOutputSheet by rememberSaveable { mutableStateOf(false) }
var showSimpConnectSheet by rememberSaveable { mutableStateOf(false) }
val sharedViewModel: SharedViewModel = koinInject()
val activeRemoteDevice by sharedViewModel.simpConnectActiveRemoteDevice.collectAsStateWithLifecycle()
if (showSimpConnectSheet) {
SimpConnectDevicePickerBottomSheet(
onDismiss = { showSimpConnectSheet = false },
sharedViewModel = sharedViewModel,
)
}
// Height
var topAppBarHeightDp by rememberSaveable {
@ -1101,6 +1116,30 @@ fun NowPlayingContentSpotify(
overflow = TextOverflow.Ellipsis,
)
}
IconButton(
modifier =
Modifier
.size(24.dp)
.aspectRatio(1f)
.clip(CircleShape),
onClick = { showSimpConnectSheet = true },
) {
Icon(
imageVector = SimpIcons.Devices,
tint = if (activeRemoteDevice != null) Color(0xFF1DB954) else Color.White,
contentDescription = "SimpConnect Devices",
)
}
AnimatedVisibility(visible = activeRemoteDevice != null) {
Text(
text = "Controlando: ${activeRemoteDevice?.deviceName}",
style = typo().bodySmall,
color = Color(0xFF1DB954),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable { showSimpConnectSheet = true },
)
}
}
Row(

View file

@ -151,6 +151,19 @@ class SharedViewModel(
var showedUpdateDialog: Boolean = false
private val appIdentity: AppIdentity by inject()
val simpConnectRepository: com.maxrave.domain.repository.SimpConnectRepository by inject()
val simpConnectDiscoveredDevices = simpConnectRepository.discoveredDevices
val simpConnectLocalDevice = simpConnectRepository.localDevice
val simpConnectActiveRemoteDevice = simpConnectRepository.activeRemoteDevice
val simpConnectRemotePlaybackState = simpConnectRepository.remotePlaybackState
val simpConnectStatus = simpConnectRepository.connectionStatus
val simpConnectIncomingAuthRequest = simpConnectRepository.incomingAuthRequest
fun refreshSimpConnectDevices() = simpConnectRepository.refreshDiscovery()
fun connectSimpConnectDevice(device: com.maxrave.domain.data.model.simpconnect.DiscoveredDeviceDomain) = simpConnectRepository.connectToDevice(device)
fun disconnectSimpConnect() = simpConnectRepository.disconnect()
fun requestSimpConnectHandoff() = simpConnectRepository.requestHandoff()
private val _isCheckingUpdate = MutableStateFlow(false)
val isCheckingUpdate: StateFlow<Boolean> = _isCheckingUpdate
@ -937,6 +950,13 @@ class SharedViewModel(
index: Int? = null,
) {
viewModelScope.launch {
if (simpConnectActiveRemoteDevice.value != null) {
simpConnectRepository.sendPlayTrack(
videoId = track.videoId,
startIndex = index ?: 0,
)
return@launch
}
mediaPlayerHandler.clearMediaItems()
songRepository.insertSong(track.toSongEntity()).lastOrNull()?.let {
println("insertSong: $it")
@ -997,6 +1017,31 @@ class SharedViewModel(
fun onUIEvent(uiEvent: UIEvent) =
viewModelScope.launch {
if (simpConnectActiveRemoteDevice.value != null) {
when (uiEvent) {
UIEvent.PlayPause -> simpConnectRepository.sendPlayPause()
UIEvent.Next -> simpConnectRepository.sendNext()
UIEvent.Previous -> simpConnectRepository.sendPrevious()
UIEvent.SkipToPrevious -> simpConnectRepository.sendPrevious()
is UIEvent.UpdateProgress -> {
val duration = simpConnectRemotePlaybackState.value?.durationMs ?: 0L
if (duration > 0L) {
simpConnectRepository.sendSeekTo((uiEvent.newProgress * duration).toLong())
}
}
is UIEvent.UpdateVolume -> simpConnectRepository.sendSetVolume(uiEvent.newVolume)
UIEvent.Shuffle -> {
val current = simpConnectRemotePlaybackState.value?.isShuffle ?: false
simpConnectRepository.sendSetShuffle(!current)
}
UIEvent.Repeat -> {
val current = simpConnectRemotePlaybackState.value?.repeatMode ?: 0
simpConnectRepository.sendSetRepeat((current + 1) % 3)
}
else -> {}
}
return@launch
}
when (uiEvent) {
UIEvent.Backward -> {
mediaPlayerHandler.onPlayerEvent(

2
core

@ -1 +1 @@
Subproject commit 7efa4c9b447248c98269711d209e0fdd87b0fe24
Subproject commit d1934699b565fe66e63fd8ee6c98e3242207eb75

View file

@ -168,6 +168,7 @@ ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref =
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-encoding = { group = "io.ktor", name = "ktor-client-encoding", version.ref = "ktor" }

View file

@ -62,6 +62,7 @@ include(
":kizzy",
":listenTogether",
":loginSync",
":simpConnect",
)
// core modules
@ -79,6 +80,7 @@ project(":spotify").projectDir = File(serviceDir, "spotify")
project(":kizzy").projectDir = File(serviceDir, "kizzy")
project(":listenTogether").projectDir = File(serviceDir, "listenTogether")
project(":loginSync").projectDir = File(serviceDir, "loginSync")
project(":simpConnect").projectDir = File(serviceDir, "simpConnect")
// media modules
project(":media-jvm").projectDir = File(mediaDir, "media-jvm")