diff --git a/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt b/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt index ed861c5..a6742fc 100644 --- a/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt +++ b/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.test.printToLog import org.junit.Rule import org.junit.Test import tech.capullo.radio.data.RadioRepository.IPv4AddressesResult -import tech.capullo.radio.snapcast.Client import tech.capullo.radio.ui.BroadcasterScreenContent import tech.capullo.radio.ui.EspotiSessionLoadingScreenContent import tech.capullo.radio.ui.model.AudioChannel @@ -73,11 +72,11 @@ class RadioBroadcasterEspotiConnectTest { @Test fun whenPlayerReadyState_showsRadioBroadcasterPlaybackScreen() { // Given: UI state is EspotiPlayerReady - var ipv4AddressesResult: IPv4AddressesResult = IPv4AddressesResult.Success( + val ipv4AddressesResult: IPv4AddressesResult = IPv4AddressesResult.Success( listOf("192.168.0.1", "10.0.0.2"), ) - var uiState = BroadcasterUiState( + val uiState = BroadcasterUiState( ipv4AddressesResult = ipv4AddressesResult, audioChannel = AudioChannel.STEREO, ) @@ -88,7 +87,7 @@ class RadioBroadcasterEspotiConnectTest { uiState, onAudioChannelChange = { }, onRefreshHostAddresses = { }, - clients = listOf(), + groups = listOf(), onClientVolumeChange = { _, _, _ -> }, ) } diff --git a/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt b/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt index 3c868fd..8ff8918 100644 --- a/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt +++ b/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt @@ -4,7 +4,6 @@ import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -21,8 +20,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearWavyProgressIndicator -import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -36,12 +33,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import tech.capullo.radio.R import tech.capullo.radio.data.RadioRepository.IPv4AddressesResult import tech.capullo.radio.snapcast.Client import tech.capullo.radio.snapcast.ClientConfig @@ -56,7 +50,6 @@ import tech.capullo.radio.ui.theme.SchemeChoice import tech.capullo.radio.ui.theme.Typography import tech.capullo.radio.viewmodels.BroadcasterUiState import tech.capullo.radio.viewmodels.BroadcasterViewModel -import tech.capullo.radio.viewmodels.GroupUIState @Composable fun BroadcasterScreen(viewModel: BroadcasterViewModel = hiltViewModel()) { @@ -69,7 +62,7 @@ fun BroadcasterScreen(viewModel: BroadcasterViewModel = hiltViewModel()) { uiState = uiState, onAudioChannelChange = viewModel::updateAudioChannel, onRefreshHostAddresses = viewModel::refreshIPv4Addresses, - clients = viewModel.snapserverGroups, + groups = viewModel.groups, onClientVolumeChange = viewModel::onClientVolumeChange, ) } @@ -80,7 +73,7 @@ fun BroadcasterScreenContent( uiState: BroadcasterUiState, onAudioChannelChange: (AudioChannel) -> Unit, onRefreshHostAddresses: () -> Unit, - clients: List, + groups: List, onClientVolumeChange: (String, Boolean, Int) -> Unit, ) { var showChannelDialog by remember { mutableStateOf(false) } @@ -104,7 +97,7 @@ fun BroadcasterScreenContent( ) { IPv4AddressesCard(uiState.ipv4AddressesResult, onRefreshHostAddresses) SnapserverGroups( - clients = clients, + groups = groups, onClientVolumeChange = onClientVolumeChange, ) } @@ -233,33 +226,41 @@ fun PreviewBroadcasterScreenContent() { uiState, onAudioChannelChange = { }, onRefreshHostAddresses = { }, - clients = listOf( - Client( - config = - ClientConfig( - instance = 1, - latency = 10, - name = "OnePlus", - volume = Volume(muted = false, percent = 40), - ), - connected = true, - host = Host( - arch = "", - ip = "", - mac = "", - name = "OnePlus", - os = "", - ), - id = "xxxxx", - lastSeen = LastSeen( - sec = 0, - usec = 0, - ), - snapclient = SnapClient( - name = "Snapclient", - protocolVersion = 2, - version = "0.34.0", + groups = listOf( + Group( + clients = listOf( + Client( + config = + ClientConfig( + instance = 1, + latency = 10, + name = "OnePlus", + volume = Volume(muted = false, percent = 40), + ), + connected = true, + host = Host( + arch = "", + ip = "", + mac = "", + name = "OnePlus", + os = "", + ), + id = "xxxxx", + lastSeen = LastSeen( + sec = 0, + usec = 0, + ), + snapclient = SnapClient( + name = "Snapclient", + protocolVersion = 2, + version = "0.34.0", + ), + ), ), + id = "group1", + muted = false, + name = "Group 1", + streamId = "stream1", ), ), onClientVolumeChange = { _, _, _ -> }, diff --git a/app/src/main/java/tech/capullo/radio/ui/NowPlayingScreen.kt b/app/src/main/java/tech/capullo/radio/ui/NowPlayingScreen.kt index 6139e56..1ab37a1 100644 --- a/app/src/main/java/tech/capullo/radio/ui/NowPlayingScreen.kt +++ b/app/src/main/java/tech/capullo/radio/ui/NowPlayingScreen.kt @@ -16,31 +16,49 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import tech.capullo.radio.snapcast.Client +import tech.capullo.radio.snapcast.ClientConfig +import tech.capullo.radio.snapcast.Group +import tech.capullo.radio.snapcast.Host +import tech.capullo.radio.snapcast.LastSeen +import tech.capullo.radio.snapcast.SnapClient +import tech.capullo.radio.snapcast.Volume import tech.capullo.radio.ui.model.AudioChannel import tech.capullo.radio.ui.theme.Typography -import tech.capullo.radio.viewmodels.TuneInViewModel +import tech.capullo.radio.viewmodels.NowPlayingUiState +import tech.capullo.radio.viewmodels.NowPlayingViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun NowPlayingScreen(viewModel: TuneInViewModel = hiltViewModel()) { - val uiState by viewModel.tuneInState.collectAsStateWithLifecycle() - var showChannelDialog by remember { mutableStateOf(false) } - var selectedChannel by remember { mutableStateOf(AudioChannel.STEREO) } +fun NowPlayingScreen(viewModel: NowPlayingViewModel = hiltViewModel()) { + val uiState by viewModel.nowPlayingUiState.collectAsStateWithLifecycle() + NowPlayingScreenContent( + uiState = uiState, + groups = viewModel.groups, + onClientVolumeChange = viewModel::onClientVolumeChange, + onUpdateAudioChannel = viewModel::updateAudioChannel, + ) +} - LaunchedEffect(uiState.audioChannel) { - selectedChannel = uiState.audioChannel - } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NowPlayingScreenContent( + uiState: NowPlayingUiState, + groups: List, + onClientVolumeChange: (String, Boolean, Int) -> Unit, + onUpdateAudioChannel: (AudioChannel) -> Unit, +) { + var showChannelDialog by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -59,7 +77,6 @@ fun NowPlayingScreen(viewModel: TuneInViewModel = hiltViewModel()) { .padding(innerPadding) .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, ) { Card( modifier = Modifier @@ -91,39 +108,93 @@ fun NowPlayingScreen(viewModel: TuneInViewModel = hiltViewModel()) { text = "Playing music via Snapclient", style = Typography.bodyMedium, ) - - if (uiState.isTunedIn) { - Text( - text = "✓ Service is running", - style = Typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Text( - text = "⏳ Connecting...", - style = Typography.bodyMedium, - color = MaterialTheme.colorScheme.secondary, - ) - } } } SnapserverGroups( - clients = viewModel.snapserverGroups, - onClientVolumeChange = viewModel::onClientVolumeChange, + groups = groups, + onClientVolumeChange = onClientVolumeChange, ) } if (showChannelDialog) { AudioSettingsDialog( onDismissRequest = { showChannelDialog = false }, - selectedChannel = selectedChannel, + selectedChannel = uiState.audioChannel, onCheckedChanged = { isChecked: Boolean, audioChannel: AudioChannel -> - if (isChecked && selectedChannel != audioChannel) { - selectedChannel = audioChannel - viewModel.updateAudioChannel(audioChannel) + if (isChecked && audioChannel != uiState.audioChannel) { + onUpdateAudioChannel(audioChannel) } }, ) } } } + +@Preview(showBackground = true) +@Composable +fun NowPlayingScreenContentPreview() { + val mockGroup = Group( + clients = listOf( + Client( + config = ClientConfig( + instance = 1, + latency = 0, + name = "Living Room", + volume = Volume(muted = false, percent = 50), + ), + connected = true, + host = Host( + arch = "x86_64", + ip = "192.168.1.101", + mac = "00:11:22:33:44:55", + name = "LivingRoomPC", + os = "Linux", + ), + id = "client1", + lastSeen = LastSeen(sec = 0, usec = 0), + snapclient = SnapClient( + name = "Snapclient", + protocolVersion = 1, + version = "0.26.0", + ), + ), + Client( + config = ClientConfig( + instance = 1, + latency = 0, + name = "Kitchen", + volume = Volume(muted = false, percent = 75), + ), + connected = true, + host = Host( + arch = "arm64", + ip = "192.168.1.102", + mac = "AA:BB:CC:DD:EE:FF", + name = "KitchenPi", + os = "Linux", + ), + id = "client2", + lastSeen = LastSeen(sec = 0, usec = 0), + snapclient = SnapClient( + name = "Snapclient", + protocolVersion = 1, + version = "0.26.0", + ), + ), + ), + id = "group1", + muted = false, + name = "Default", + streamId = "stream1", + ) + + NowPlayingScreenContent( + uiState = NowPlayingUiState( + audioChannel = AudioChannel.STEREO, + serverIp = "192.168.1.100", + ), + groups = listOf(mockGroup), + onClientVolumeChange = { _, _, _ -> }, + onUpdateAudioChannel = {}, + ) +} diff --git a/app/src/main/java/tech/capullo/radio/ui/SnapclientsList.kt b/app/src/main/java/tech/capullo/radio/ui/SnapclientsList.kt index 952de06..7ec3789 100644 --- a/app/src/main/java/tech/capullo/radio/ui/SnapclientsList.kt +++ b/app/src/main/java/tech/capullo/radio/ui/SnapclientsList.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -27,44 +28,66 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import tech.capullo.radio.R import tech.capullo.radio.snapcast.Client import tech.capullo.radio.snapcast.ClientConfig +import tech.capullo.radio.snapcast.Group import tech.capullo.radio.snapcast.Host import tech.capullo.radio.snapcast.LastSeen import tech.capullo.radio.snapcast.SnapClient import tech.capullo.radio.snapcast.Volume import tech.capullo.radio.ui.theme.RadioTheme -import tech.capullo.radio.viewmodels.GroupUIState +import kotlin.math.round @Composable fun SnapserverGroups( modifier: Modifier = Modifier, - clients: List, + groups: List, onClientVolumeChange: (String, Boolean, Int) -> Unit, ) { LazyColumn(modifier = modifier.padding(vertical = 4.dp)) { - items( - items = clients, - key = { client -> client.id }, - ) { client -> - SnapcastClientCard( - name = client.host.name, - muted = client.config.volume.muted, - volume = client.config.volume.percent.toFloat(), - onVolumeChange = { muted, volume -> - onClientVolumeChange( - client.id, - muted, - volume, - ) - }, - ) + groups.forEach { group -> + stickyHeader { + Row( + modifier = Modifier.fillMaxWidth().background( + MaterialTheme.colorScheme.surfaceVariant, + ).padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text(group.name.ifEmpty { group.streamId.ifEmpty { group.id } }) + } + } + + items( + items = group.clients.filter { it.connected }, + key = { client -> client.id }, + ) { client -> + SnapcastClientCard( + name = client.host.name, + muted = client.config.volume.muted, + onMutedChange = { muted -> + // pass over new muted state, keep the same volume value + onClientVolumeChange( + client.id, + muted, + client.config.volume.percent, + ) + }, + volume = client.config.volume.percent.toFloat() / 100f, + onVolumeChange = { volume -> + // pass over new volume value, keep the same muted state + onClientVolumeChange( + client.id, + client.config.volume.muted, + volume, + ) + }, + ) + } } } } @@ -74,71 +97,77 @@ fun SnapserverGroups( private fun SnapcastClientCard( name: String, muted: Boolean, + onMutedChange: (Boolean) -> Unit, volume: Float, - onVolumeChange: (Boolean, Int) -> Unit, + onVolumeChange: (Int) -> Unit, ) { + // Context: When sending a ClientSetVolumeRequest, we DO NOT process the request's response + // therefore, we manage the slider state locally for UI interactions + // Incoming ClientOnVolumeChanged notifications are being reflected with LaunchedEffect + var mutedState by remember { mutableStateOf(muted) } + var volumeState by remember { mutableFloatStateOf(volume) } + + LaunchedEffect(muted) { + mutedState = muted + } + LaunchedEffect(volume) { + volumeState = volume + } + Card( modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp), ) { - var mutedState by remember { mutableStateOf(muted) } - var volumeState by remember { mutableFloatStateOf(volume) } - - LaunchedEffect(muted) { - mutedState = muted - } - LaunchedEffect(volume) { - volumeState = volume - } - - Row( - modifier = Modifier - .padding(12.dp) - .animateContentSize( - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow, - ), + Column { + Text( + modifier = Modifier.padding(start = 24.dp, top = 24.dp, bottom = 12.dp), + text = name, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.ExtraBold, ), - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(12.dp), + ) + Row( + modifier = Modifier.padding(start = 12.dp, end = 24.dp, bottom = 24.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = name, - style = MaterialTheme.typography.headlineMedium.copy( - fontWeight = FontWeight.ExtraBold, - ), - ) - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton( - onClick = { mutedState = !mutedState }, - ) { - Icon( - painter = if (mutedState) { - painterResource(id = R.drawable.volume_off_24px) - } else { - painterResource(id = R.drawable.volume_up_24px) - }, - contentDescription = if (mutedState) { - stringResource(R.string.app_name) - } else { - stringResource(R.string.app_name) - }, - - ) - } - Slider( - value = volumeState, - onValueChange = { - volumeState = it - onVolumeChange(mutedState, volumeState.toInt()) + IconButton( + onClick = { + mutedState = !mutedState + onMutedChange(mutedState) + }, + ) { + Icon( + painter = if (mutedState) { + painterResource(id = R.drawable.volume_off_24px) + } else { + painterResource(id = R.drawable.volume_up_24px) + }, + contentDescription = if (mutedState) { + "Unmute" + } else { + "Mute" }, - valueRange = 0f..100f, - modifier = Modifier.weight(2f), + ) } + Slider( + value = volumeState, + onValueChange = { + volumeState = it + onVolumeChange(round(volumeState * 100).toInt()) + }, + steps = 99, + valueRange = 0f..1f, + modifier = Modifier.weight(2f), + track = { sliderState -> + SliderDefaults.Track( + sliderState = sliderState, + colors = SliderDefaults.colors( + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent, + ), + ) + }, + ) } } } @@ -153,101 +182,107 @@ private fun SnapcastClientCard( @Preview(showBackground = true, widthDp = 320) @Composable fun DefaultPreview() { - val clients = listOf( - Client( - config = - ClientConfig(1, 10, "OnePlus", Volume(false, 40)), - connected = true, - host = Host("", "", "", "LEFT Device 1", ""), - id = "LEFT Device 1", - lastSeen = LastSeen(0, 0), - snapclient = SnapClient("Snapclient", 2, "0.34.0"), - ), - Client( - config = - ClientConfig(1, 10, "OnePlus", Volume(false, 40)), - connected = true, - host = Host("", "", "", "LEFT Device 2", ""), - id = "LEFT Device 2", - lastSeen = LastSeen(0, 0), - snapclient = SnapClient("Snapclient", 2, "0.34.0"), - ), - Client( - config = - ClientConfig(1, 10, "OnePlus", Volume(false, 40)), - connected = true, - host = Host("", "", "", "LEFT Device 3", ""), - id = "LEFT Device 3", - lastSeen = LastSeen(0, 0), - snapclient = SnapClient("Snapclient", 2, "0.34.0"), - ), - Client( - config = - ClientConfig(1, 10, "OnePlus", Volume(false, 40)), - connected = true, - host = Host("", "", "", "LEFT Device 4", ""), - id = "LEFT Device 4", - lastSeen = LastSeen(0, 0), - snapclient = SnapClient("Snapclient", 2, "0.34.0"), - ), - Client( - config = - ClientConfig(1, 10, "OnePlus", Volume(false, 40)), - connected = true, - host = Host("", "", "", "LEFT Device 5", ""), - id = "LEFT Device 5", - lastSeen = LastSeen(0, 0), - snapclient = SnapClient("Snapclient", 2, "0.34.0"), - ), - Client( - config = - ClientConfig(1, 10, "OnePlus", Volume(false, 40)), - connected = true, - host = Host("", "", "", "RIGHT Device 1", ""), - id = "RIGHT Device 1", - lastSeen = LastSeen(0, 0), - snapclient = SnapClient("Snapclient", 2, "0.34.0"), - ), - Client( - config = - ClientConfig(1, 10, "OnePlus", Volume(false, 40)), - connected = true, - host = Host("", "", "", "RIGHT Device 2", ""), - id = "RIGHT Device 2", - lastSeen = LastSeen(0, 0), - snapclient = SnapClient("Snapclient", 2, "0.34.0"), - ), - Client( - config = - ClientConfig(1, 10, "OnePlus", Volume(false, 40)), - connected = true, - host = Host("", "", "", "RIGHT Device 3", ""), - id = "RIGHT Device 3", - lastSeen = LastSeen(0, 0), - snapclient = SnapClient("Snapclient", 2, "0.34.0"), - ), - Client( - config = - ClientConfig(1, 10, "OnePlus", Volume(false, 40)), - connected = true, - host = Host("", "", "", "RIGHT Device 4", ""), - id = "RIGHT Device 4", - lastSeen = LastSeen(0, 0), - snapclient = SnapClient("Snapclient", 2, "0.34.0"), + val groups = listOf( + Group( + clients = listOf( + Client( + config = ClientConfig(1, 10, "OnePlus", Volume(false, 40)), + connected = true, + host = Host("", "", "", "LEFT Device 1", ""), + id = "LEFT Device 1", + lastSeen = LastSeen(0, 0), + snapclient = SnapClient("Snapclient", 2, "0.34.0"), + ), + Client( + config = ClientConfig(1, 10, "OnePlus", Volume(false, 40)), + connected = true, + host = Host("", "", "", "LEFT Device 2", ""), + id = "LEFT Device 2", + lastSeen = LastSeen(0, 0), + snapclient = SnapClient("Snapclient", 2, "0.34.0"), + ), + Client( + config = ClientConfig(1, 10, "OnePlus", Volume(false, 40)), + connected = true, + host = Host("", "", "", "LEFT Device 3", ""), + id = "LEFT Device 3", + lastSeen = LastSeen(0, 0), + snapclient = SnapClient("Snapclient", 2, "0.34.0"), + ), + Client( + config = ClientConfig(1, 10, "OnePlus", Volume(false, 40)), + connected = true, + host = Host("", "", "", "LEFT Device 4", ""), + id = "LEFT Device 4", + lastSeen = LastSeen(0, 0), + snapclient = SnapClient("Snapclient", 2, "0.34.0"), + ), + Client( + config = ClientConfig(1, 10, "OnePlus", Volume(false, 40)), + connected = true, + host = Host("", "", "", "LEFT Device 5", ""), + id = "LEFT Device 5", + lastSeen = LastSeen(0, 0), + snapclient = SnapClient("Snapclient", 2, "0.34.0"), + ), + ), + id = "left_group", + muted = false, + name = "LEFT", + streamId = "left_stream", ), - Client( - config = - ClientConfig(1, 10, "OnePlus", Volume(false, 40)), - connected = true, - host = Host("", "", "", "RIGHT Device 5", ""), - id = "RIGHT Device 5", - lastSeen = LastSeen(0, 0), - snapclient = SnapClient("Snapclient", 2, "0.34.0"), + Group( + clients = listOf( + Client( + config = ClientConfig(1, 10, "OnePlus", Volume(false, 40)), + connected = true, + host = Host("", "", "", "RIGHT Device 1", ""), + id = "RIGHT Device 1", + lastSeen = LastSeen(0, 0), + snapclient = SnapClient("Snapclient", 2, "0.34.0"), + ), + Client( + config = ClientConfig(1, 10, "OnePlus", Volume(false, 40)), + connected = true, + host = Host("", "", "", "RIGHT Device 2", ""), + id = "RIGHT Device 2", + lastSeen = LastSeen(0, 0), + snapclient = SnapClient("Snapclient", 2, "0.34.0"), + ), + Client( + config = ClientConfig(1, 10, "OnePlus", Volume(false, 40)), + connected = true, + host = Host("", "", "", "RIGHT Device 3", ""), + id = "RIGHT Device 3", + lastSeen = LastSeen(0, 0), + snapclient = SnapClient("Snapclient", 2, "0.34.0"), + ), + Client( + config = ClientConfig(1, 10, "OnePlus", Volume(false, 40)), + connected = true, + host = Host("", "", "", "RIGHT Device 4", ""), + id = "RIGHT Device 4", + lastSeen = LastSeen(0, 0), + snapclient = SnapClient("Snapclient", 2, "0.34.0"), + ), + Client( + config = ClientConfig(1, 10, "OnePlus", Volume(false, 40)), + connected = true, + host = Host("", "", "", "RIGHT Device 5", ""), + id = "RIGHT Device 5", + lastSeen = LastSeen(0, 0), + snapclient = SnapClient("Snapclient", 2, "0.34.0"), + ), + ), + id = "right_group", + muted = false, + name = "RIGHT", + streamId = "right_stream", ), ) RadioTheme { SnapserverGroups( - clients = clients, + groups = groups, onClientVolumeChange = { _, _, _ -> }, ) } diff --git a/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt b/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt index 20532e3..4f4001d 100644 --- a/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt +++ b/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt @@ -8,8 +8,6 @@ import android.os.Build import android.os.IBinder import android.util.Log import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.toMutableStateList import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -23,7 +21,6 @@ import kotlinx.coroutines.launch import tech.capullo.radio.data.RadioRepository import tech.capullo.radio.data.RadioRepository.IPv4AddressesResult import tech.capullo.radio.services.RadioBroadcasterService -import tech.capullo.radio.snapcast.Client import tech.capullo.radio.snapcast.ClientOnVolumeChanged import tech.capullo.radio.snapcast.Group import tech.capullo.radio.snapcast.ServerGetStatusResponse @@ -38,8 +35,6 @@ data class BroadcasterUiState( val audioChannel: AudioChannel, ) -data class GroupUIState(val displayName: String, val isMuted: Boolean, val clients: List) - @HiltViewModel class BroadcasterViewModel @Inject constructor( @ApplicationContext private val appContext: Context, @@ -52,8 +47,8 @@ class BroadcasterViewModel @Inject constructor( "127.0.0.1", ) - private var _snapserverGroups = mutableStateListOf() - val snapserverGroups: List = _snapserverGroups + private var _groups = mutableStateListOf() + val groups: List = _groups private val _uiState = MutableStateFlow( BroadcasterUiState( @@ -135,31 +130,35 @@ class BroadcasterViewModel @Inject constructor( when (notification) { is ServerGetStatusResponse -> { - _snapserverGroups.clear() - _snapserverGroups.addAll( - notification.result.server.groups.flatMap { group -> group.clients }, - ) + _groups.clear() + _groups.addAll(notification.result.server.groups) } is ServerOnUpdate -> { - _snapserverGroups.clear() - _snapserverGroups.addAll( - notification.params.server.groups.flatMap { group -> group.clients }, - ) + _groups.clear() + _groups.addAll(notification.params.server.groups) } is ClientOnVolumeChanged -> { - val clients = snapserverGroups.map { client -> - if (client.id == notification.params.clientId) { - client.copy( - config = client.config.copy(volume = notification.params.volume), - ) - } else { - client + // index of the group we are going to replace + val targetGroupIndex = _groups.find { group -> + group.clients.any { client -> client.id == notification.params.clientId } + }?.let { group -> + _groups.indexOf(group) + } + + targetGroupIndex?.let { i -> + val updatedClientList = _groups[i].clients.map { client -> + if (client.id == notification.params.clientId) { + client.copy( + config = client.config.copy(volume = notification.params.volume), + ) + } else { + client + } } + _groups[i] = _groups[i].copy(clients = updatedClientList) } - _snapserverGroups.clear() - _snapserverGroups.addAll(clients) } else -> { } diff --git a/app/src/main/java/tech/capullo/radio/viewmodels/NowPlayingViewModel.kt b/app/src/main/java/tech/capullo/radio/viewmodels/NowPlayingViewModel.kt new file mode 100644 index 0000000..f3667d9 --- /dev/null +++ b/app/src/main/java/tech/capullo/radio/viewmodels/NowPlayingViewModel.kt @@ -0,0 +1,142 @@ +package tech.capullo.radio.viewmodels + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import tech.capullo.radio.services.SnapclientService +import tech.capullo.radio.snapcast.ClientOnVolumeChanged +import tech.capullo.radio.snapcast.Group +import tech.capullo.radio.snapcast.ServerGetStatusResponse +import tech.capullo.radio.snapcast.ServerOnUpdate +import tech.capullo.radio.snapcast.SnapcastControlClient +import tech.capullo.radio.snapcast.SnapcastJSONRPCResponse +import tech.capullo.radio.ui.model.AudioChannel +import javax.inject.Inject + +data class NowPlayingUiState(val audioChannel: AudioChannel, val serverIp: String) + +@HiltViewModel +class NowPlayingViewModel @Inject constructor( + @ApplicationContext private val applicationContext: Context, +) : ViewModel() { + private var _groups = mutableStateListOf() + val groups: List = _groups + + private var _nowPlayingUiState = MutableStateFlow( + NowPlayingUiState( + audioChannel = AudioChannel.STEREO, + serverIp = "", + ), + ) + val nowPlayingUiState: StateFlow = _nowPlayingUiState + + var snapcastControlClient: SnapcastControlClient? = null + + private var binder: SnapclientService.SnapclientBinder? = null + private var isBound = false + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + binder = service as SnapclientService.SnapclientBinder + isBound = true + + // collect both server ip and audio channel settings + viewModelScope.launch { + launch { + binder?.getSnapserverIpFlow()?.collect { snapserverIp -> + _nowPlayingUiState.update { it.copy(serverIp = snapserverIp) } + + snapcastControlClient = SnapcastControlClient( + snapserverIp, + ) + snapcastControlClient?.initialize() + snapcastControlClient?.sendGetStatus() + snapcastControlClient?.notifications?.collect { notification -> + notification?.let { handleNotification(it) } + } + } + } + launch { + binder?.getAudioChannelFlow()?.collect { audioChannel -> + _nowPlayingUiState.update { it.copy(audioChannel = audioChannel) } + } + } + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + binder = null + isBound = false + } + } + + init { + Intent(applicationContext, SnapclientService::class.java).also { intent -> + applicationContext.bindService(intent, serviceConnection, 0) + } + } + + fun updateAudioChannel(channel: AudioChannel) { + binder?.updateAudioChannel(channel) + } + + fun handleNotification(notification: SnapcastJSONRPCResponse) { + Log.d(TAG, "Handling notification: $notification") + + when (notification) { + is ServerGetStatusResponse -> { + _groups.clear() + _groups.addAll(notification.result.server.groups) + } + + is ServerOnUpdate -> { + _groups.clear() + _groups.addAll(notification.params.server.groups) + } + + is ClientOnVolumeChanged -> { + // index of the group we are going to replace + val targetGroupIndex = _groups.find { group -> + group.clients.any { client -> client.id == notification.params.clientId } + }?.let { group -> + _groups.indexOf(group) + } + + targetGroupIndex?.let { i -> + val updatedClientList = _groups[i].clients.map { client -> + if (client.id == notification.params.clientId) { + client.copy( + config = client.config.copy(volume = notification.params.volume), + ) + } else { + client + } + } + _groups[i] = _groups[i].copy(clients = updatedClientList) + } + } + } + } + + fun onClientVolumeChange(clientId: String, muted: Boolean, volume: Int) { + viewModelScope.launch { + snapcastControlClient?.sendSetVolume(clientId, muted, volume) + } + } + + companion object { + private val TAG = NowPlayingViewModel::class.simpleName + } +} diff --git a/app/src/main/java/tech/capullo/radio/viewmodels/TuneInViewModel.kt b/app/src/main/java/tech/capullo/radio/viewmodels/TuneInViewModel.kt index c970cac..3be4e44 100644 --- a/app/src/main/java/tech/capullo/radio/viewmodels/TuneInViewModel.kt +++ b/app/src/main/java/tech/capullo/radio/viewmodels/TuneInViewModel.kt @@ -7,9 +7,6 @@ import android.content.ServiceConnection import android.os.Build import android.os.IBinder import android.util.Log -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.toMutableStateList import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey @@ -25,26 +22,13 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import tech.capullo.radio.services.SnapclientService -import tech.capullo.radio.snapcast.Client -import tech.capullo.radio.snapcast.ClientOnVolumeChanged import tech.capullo.radio.snapcast.DiscoveredSnapserver -import tech.capullo.radio.snapcast.ServerGetStatusResponse -import tech.capullo.radio.snapcast.ServerOnUpdate -import tech.capullo.radio.snapcast.SnapcastControlClient -import tech.capullo.radio.snapcast.SnapcastJSONRPCResponse import tech.capullo.radio.snapcast.SnapserverDiscoveryManager -import tech.capullo.radio.ui.model.AudioChannel import javax.inject.Inject -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.map -import kotlin.collections.set -import kotlin.text.ifEmpty data class TuneInState( val availableServers: List = emptyList(), val serverIp: String = "", - val audioChannel: AudioChannel = AudioChannel.STEREO, val isTunedIn: Boolean = false, ) @@ -60,11 +44,6 @@ class TuneInViewModel @Inject constructor( val LAST_SERVER_TEXT = stringPreferencesKey("last_server_text") } - private var _snapserverGroups = mutableStateListOf() - val snapserverGroups: List = _snapserverGroups - - var snapcastControlClient: SnapcastControlClient? = null - private var binder: SnapclientService.SnapclientBinder? = null private var isBound = false @@ -88,34 +67,6 @@ class TuneInViewModel @Inject constructor( binder = service as SnapclientService.SnapclientBinder isBound = true - // collect both server ip and audio channel settings - viewModelScope.launch { - launch { - binder?.getSnapserverIpFlow()?.collect { snapserverIp -> - Log.d(TAG, "latest serverIP: $snapserverIp") - _tuneInState.value = tuneInState.value.copy( - serverIp = snapserverIp, - ) - Log.d(TAG, "tuneInState: ${tuneInState.value}") - snapcastControlClient = SnapcastControlClient( - snapserverIp, - ) - snapcastControlClient?.initialize() - snapcastControlClient?.sendGetStatus() - snapcastControlClient?.notifications?.collect { notification -> - notification?.let { handleNotification(it) } - } - } - } - launch { - binder?.getAudioChannelFlow()?.collect { audioChannel -> - _tuneInState.value = tuneInState.value.copy( - audioChannel = audioChannel, - ) - } - } - } - _tuneInState.value = tuneInState.value.copy( isTunedIn = isBound, ) @@ -158,12 +109,6 @@ class TuneInViewModel @Inject constructor( discoveryManager.startDiscovery() } - suspend fun saveLastServerText(text: String) { - applicationContext.dataStore.edit { preferences -> - preferences[PreferencesKeys.LAST_SERVER_TEXT] = text - } - } - fun onServerIPTextFieldValueChanged(serverIpText: String) { _tuneInState.value = _tuneInState.value.copy(serverIp = serverIpText) } @@ -173,7 +118,6 @@ class TuneInViewModel @Inject constructor( // as a flow to then display on the UI val intent = Intent(applicationContext, SnapclientService::class.java).apply { putExtra(SnapclientService.KEY_IP, tuneInState.value.serverIp) - putExtra(SnapclientService.KEY_AUDIO_CHANNEL, tuneInState.value.audioChannel.ordinal) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -183,49 +127,12 @@ class TuneInViewModel @Inject constructor( } applicationContext.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) - } - - fun updateAudioChannel(channel: AudioChannel) { - binder?.updateAudioChannel(channel) - } - fun handleNotification(notification: SnapcastJSONRPCResponse) { - Log.d(TAG, "Handling notification: $notification") - - when (notification) { - is ServerGetStatusResponse -> { - _snapserverGroups.clear() - _snapserverGroups.addAll( - notification.result.server.groups.flatMap { group -> group.clients }, - ) - } - - is ServerOnUpdate -> { - _snapserverGroups.clear() - _snapserverGroups.addAll( - notification.params.server.groups.flatMap { group -> group.clients }, - ) - } - - is ClientOnVolumeChanged -> { - val clients = snapserverGroups.map { client -> - if (client.id == notification.params.clientId) { - client.copy( - config = client.config.copy(volume = notification.params.volume), - ) - } else { - client - } - } - _snapserverGroups.clear() - _snapserverGroups.addAll(clients) - } - } - } - - fun onClientVolumeChange(clientId: String, muted: Boolean, volume: Int) { + // Save the server host address viewModelScope.launch { - snapcastControlClient?.sendSetVolume(clientId, muted, volume) + applicationContext.dataStore.edit { preferences -> + preferences[PreferencesKeys.LAST_SERVER_TEXT] = tuneInState.value.serverIp + } } } diff --git a/app/src/test/java/tech/capullo/radio/SnapcastControlClient.kt b/app/src/test/java/tech/capullo/radio/SnapcastControlClient.kt deleted file mode 100644 index bed127f..0000000 --- a/app/src/test/java/tech/capullo/radio/SnapcastControlClient.kt +++ /dev/null @@ -1,246 +0,0 @@ -package tech.capullo.radio - -import android.R -import kotlinx.serialization.json.Json -import org.junit.Test -import tech.capullo.radio.snapcast.ClientOnVolumeChanged -import tech.capullo.radio.snapcast.NotificationSerializer -import tech.capullo.radio.snapcast.ServerGetStatusResponse -import tech.capullo.radio.snapcast.ServerOnUpdate -import tech.capullo.radio.snapcast.SnapcastJSONRPCResponse -import tech.capullo.radio.snapcast.SnapcastJSONRPCResponseSerializer - -class SnapcastControlClient { - - @Test - fun serverGetStatusResponseDeserializeTest() { - val serverGetStatusString = """ - { - "id": 2, - "jsonrpc": "2.0", - "result": { - "server": { - "groups": [ - { - "clients": [ - { - "config": { - "instance": 1, - "latency": 0, - "name": "", - "volume": { - "muted": false, - "percent": 100 - } - }, - "connected": true, - "host": { - "arch": "x86_64", - "ip": "::ffff:127.0.0.1", - "mac": "00:00:00:00:00:00", - "name": "sdk_gphone64_x86_64", - "os": "Android 16" - }, - "id": "031a7306-6a7d-4a52-ab7d-3c08051b1d84", - "lastSeen": { - "sec": 1762503830, - "usec": 240402 - }, - "snapclient": { - "name": "Snapclient", - "protocolVersion": 2, - "version": "0.34.0" - } - } - ], - "id": "5447d658-940b-a745-a122-b0c213b9b400", - "muted": false, - "name": "", - "stream_id": "RadioCapullo" - } - ], - "server": { - "host": { - "arch": "x86_64", - "ip": "", - "mac": "", - "name": "sdk_gphone64_x86_64", - "os": "Android 16" - }, - "snapserver": { - "controlProtocolVersion": 1, - "name": "Snapserver", - "protocolVersion": 1, - "version": "0.34.0" - } - }, - "streams": [ - { - "id": "RadioCapullo", - "properties": { - "canControl": false, - "canGoNext": false, - "canGoPrevious": false, - "canPause": false, - "canPlay": false, - "canSeek": false - }, - "status": "idle", - "uri": { - "fragment": "", - "host": "", - "path": "/data/user/0/tech.capullo.radio/cache/filifo", - "query": { - "chunk_ms": "20", - "codec": "flac", - "dryout_ms": "2000", - "mode": "read", - "name": "RadioCapullo", - "sampleformat": "44100:16:2" - }, - "raw": "pipe:///data/user/0/tech.capullo.radio/cache/filifo?chunk_ms=20&codec=flac&dryout_ms=2000&mode=read&name=RadioCapullo&sampleformat=44100%3A16%3A2", - "scheme": "pipe" - } - } - ] - } - } - } - """.trimIndent() - val response = - Json.decodeFromString(SnapcastJSONRPCResponseSerializer, serverGetStatusString) - - assert(response is ServerGetStatusResponse) - } - - @Test - fun clientOnVolumeChangedDeserializeTest() { - val clientId = "82c9349e-da57-401a-aefa-2eb68b358ef3" - val muted = true - val percent = 20 - val onVolumeChangedString = """ - { - "jsonrpc": "2.0", - "method": "Client.OnVolumeChanged", - "params": { - "id": "$clientId", - "volume": { - "muted": $muted, - "percent": $percent - } - } - } - """ - - val response = - Json.decodeFromString(SnapcastJSONRPCResponseSerializer, onVolumeChangedString) - - assert(response is ClientOnVolumeChanged) - (response as ClientOnVolumeChanged).let { - assert(it.params.clientId == clientId) - assert(it.params.volume.muted == muted) - assert(it.params.volume.percent == percent) - } - } - - @Test - fun serverOnUpdateDeserializeTest() { - val onUpdateString = """ - { - "jsonrpc": "2.0", - "method": "Server.OnUpdate", - "params": { - "server": { - "groups": [ - { - "clients": [ - { - "config": { - "instance": 1, - "latency": 0, - "name": "", - "volume": { - "muted": false, - "percent": 100 - } - }, - "connected": true, - "host": { - "arch": "x86_64", - "ip": "::ffff:127.0.0.1", - "mac": "00:00:00:00:00:00", - "name": "sdk_gphone64_x86_64", - "os": "Android 16" - }, - "id": "e694ba4d-3b37-4303-ab0c-c0aa41165385", - "lastSeen": { - "sec": 1762426399, - "usec": 509162 - }, - "snapclient": { - "name": "Snapclient", - "protocolVersion": 2, - "version": "0.34.0" - } - } - ], - "id": "3f80e383-6603-682c-ebc7-c8b1d4314640", - "muted": false, - "name": "", - "stream_id": "RadioCapullo" - } - ], - "server": { - "host": { - "arch": "x86_64", - "ip": "", - "mac": "", - "name": "sdk_gphone64_x86_64", - "os": "Android 16" - }, - "snapserver": { - "controlProtocolVersion": 1, - "name": "Snapserver", - "protocolVersion": 1, - "version": "0.34.0" - } - }, - "streams": [ - { - "id": "RadioCapullo", - "properties": { - "canControl": false, - "canGoNext": false, - "canGoPrevious": false, - "canPause": false, - "canPlay": false, - "canSeek": false - }, - "status": "idle", - "uri": { - "fragment": "", - "host": "", - "path": "/data/user/0/tech.capullo.radio/cache/filifo", - "query": { - "chunk_ms": "20", - "codec": "flac", - "dryout_ms": "2000", - "mode": "read", - "name": "RadioCapullo", - "sampleformat": "44100:16:2" - }, - "raw": "pipe:///data/user/0/tech.capullo.radio/cache/filifo?chunk_ms=20&codec=flac&dryout_ms=2000&mode=read&name=RadioCapullo&sampleformat=44100%3A16%3A2", - "scheme": "pipe" - } - } - ] - } - } - } - """.trimIndent() - - val response = - Json.decodeFromString(SnapcastJSONRPCResponseSerializer, onUpdateString) - assert(response is ServerOnUpdate) - } -} diff --git a/app/src/test/java/tech/capullo/radio/SnapcastControlClientTest.kt b/app/src/test/java/tech/capullo/radio/SnapcastControlClientTest.kt index 72fe1ec..dae6ebc 100644 --- a/app/src/test/java/tech/capullo/radio/SnapcastControlClientTest.kt +++ b/app/src/test/java/tech/capullo/radio/SnapcastControlClientTest.kt @@ -16,7 +16,7 @@ class SnapcastControlClientTest { "id": 2, "jsonrpc": "2.0", "result": { - "server": { + "server": { "groups": [ { "clients": [ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6ad6f7d..181616a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] accompanistPermissions = "0.37.3" -activityCompose = "1.11.0" +activityCompose = "1.12.0" agp = "8.13.1" androidx-junit = "1.3.0" composeLatest = "1.7.8" -composeBom = "2025.11.00" +composeBom = "2025.11.01" androidx-test-core = "1.7.0" -datastorePreferences = "1.1.7" +datastorePreferences = "1.2.0" espressoCore = "3.7.0" hiltViewmodelCompose = "1.3.0" junit = "4.13.2" @@ -20,16 +20,16 @@ hilt = "2.57.2" ksp = "2.2.21-2.0.4" ktor = "3.3.2" libSnapcastAndroid = "lr_snapcast-SNAPSHOT" -lifecycleRuntimeKtx = "2.9.4" -lifecycleViewmodelNav3 = "2.10.0-rc01" -material3 = "1.5.0-alpha08" +lifecycleRuntimeKtx = "2.10.0" +lifecycleViewmodelNav3 = "2.10.0" +material3 = "1.5.0-alpha09" media = "1.7.1" mockk = "1.14.6" -navigation3 = "1.0.0-rc01" +navigation3 = "1.0.0" robolectric = "4.16" slf4jHandroid = "2.0.13" startupRuntime = "1.2.0" -spotless = "8.0.0" +spotless = "8.1.0" [libraries] accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" }