diff --git a/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt b/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt index f11b5bb..bd6663f 100644 --- a/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt +++ b/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt @@ -12,7 +12,8 @@ import androidx.compose.ui.test.printToLog import org.junit.Rule import org.junit.Test import tech.capullo.radio.snapcast.Client -import tech.capullo.radio.ui.RadioBroadcasterScreenContent +import tech.capullo.radio.ui.BroadcasterScreenContent +import tech.capullo.radio.ui.model.AudioChannel import tech.capullo.radio.viewmodels.RadioBroadcasterUiState class RadioBroadcasterEspotiConnectTest { @@ -29,7 +30,10 @@ class RadioBroadcasterEspotiConnectTest { // When: RadioBroadcasterScreen is displayed composeTestRule.setContent { - RadioBroadcasterScreenContent(uiState) + BroadcasterScreenContent( + uiState, + onAudioChannelChange = { }, + ) } composeTestRule.onRoot().printToLog("TAG") @@ -57,7 +61,10 @@ class RadioBroadcasterEspotiConnectTest { // When: RadioBroadcasterScreen is displayed composeTestRule.setContent { - RadioBroadcasterScreenContent(uiState) + BroadcasterScreenContent( + uiState, + onAudioChannelChange = { }, + ) } composeTestRule.onRoot().printToLog("TAG") @@ -76,11 +83,15 @@ class RadioBroadcasterEspotiConnectTest { val uiState = RadioBroadcasterUiState.EspotiPlayerReady( hostAddresses = hostAddresses, snapcastClients = mockClients, + audioChannel = AudioChannel.STEREO, ) // When: Composable is displayed composeTestRule.setContent { - RadioBroadcasterScreenContent(uiState) + BroadcasterScreenContent( + uiState, + onAudioChannelChange = { }, + ) } // Then: Broadcaster host addresses are displayed diff --git a/app/src/androidTest/java/tech/capullo/radio/TuneInScreen.kt b/app/src/androidTest/java/tech/capullo/radio/TuneInScreen.kt index ed9ae2f..55af336 100644 --- a/app/src/androidTest/java/tech/capullo/radio/TuneInScreen.kt +++ b/app/src/androidTest/java/tech/capullo/radio/TuneInScreen.kt @@ -12,8 +12,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import tech.capullo.radio.ui.AudioChannel -import tech.capullo.radio.ui.RadioTuneInScreenContent +import tech.capullo.radio.ui.TuneInScreenContent +import tech.capullo.radio.ui.model.AudioChannel @RunWith(AndroidJUnit4::class) class TuneInScreen { @@ -24,13 +24,14 @@ class TuneInScreen { @Test fun testDefaultChannelSelection() { val lastServerText = "192.168.0.1" - val isTunedIn = false composeTestRule.setContent { - RadioTuneInScreenContent( + TuneInScreenContent( lastServerText = lastServerText, - isTunedIn = isTunedIn, onTextChange = {}, onTuneInClick = {}, + isButtonEnabled = true, + selectedChannel = AudioChannel.STEREO, + onChannelChange = {}, ) } @@ -51,13 +52,14 @@ class TuneInScreen { @Test fun testSameChannelSelection() { val lastServerText = "192.168.0.1" - val isTunedIn = false composeTestRule.setContent { - RadioTuneInScreenContent( + TuneInScreenContent( lastServerText = lastServerText, - isTunedIn = isTunedIn, onTextChange = {}, onTuneInClick = {}, + isButtonEnabled = false, + selectedChannel = AudioChannel.STEREO, + onChannelChange = { }, ) } @@ -82,15 +84,16 @@ class TuneInScreen { @Test fun testChannelSelectionRestoration() { val lastServerText = "192.168.0.1" - val isTunedIn = false val restorationTester = StateRestorationTester(composeTestRule) restorationTester.setContent { - RadioTuneInScreenContent( + TuneInScreenContent( lastServerText = lastServerText, - isTunedIn = isTunedIn, onTextChange = {}, onTuneInClick = {}, + isButtonEnabled = false, + selectedChannel = AudioChannel.RIGHT, + onChannelChange = { }, ) } diff --git a/app/src/main/java/tech/capullo/radio/RadioNavHost.kt b/app/src/main/java/tech/capullo/radio/RadioNavHost.kt index 4ce7109..4d73778 100644 --- a/app/src/main/java/tech/capullo/radio/RadioNavHost.kt +++ b/app/src/main/java/tech/capullo/radio/RadioNavHost.kt @@ -5,10 +5,10 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.navigation3.runtime.NavEntry import androidx.navigation3.ui.NavDisplay +import tech.capullo.radio.ui.BroadcasterScreen import tech.capullo.radio.ui.NowPlayingScreen -import tech.capullo.radio.ui.RadioApp -import tech.capullo.radio.ui.RadioBroadcasterScreen -import tech.capullo.radio.ui.RadioTuneInScreen +import tech.capullo.radio.ui.RadioHomeScreen +import tech.capullo.radio.ui.TuneInScreen import tech.capullo.radio.ui.theme.RadioTheme import tech.capullo.radio.ui.theme.SchemeChoice @@ -26,7 +26,7 @@ fun RadioCapulloNavHost() { entryProvider = { key -> when (key) { is Home -> NavEntry(key) { - RadioApp( + RadioHomeScreen( onStartBroadcastingClicked = { backStack.add(Broadcast) }, onTuneInClicked = { backStack.add(TuneIn) }, ) @@ -36,7 +36,7 @@ fun RadioCapulloNavHost() { RadioTheme( schemeChoice = SchemeChoice.GREEN, ) { - RadioBroadcasterScreen() + BroadcasterScreen() } } @@ -44,10 +44,8 @@ fun RadioCapulloNavHost() { RadioTheme( schemeChoice = SchemeChoice.ORANGE, ) { - RadioTuneInScreen( - onConnected = { serverIp, channel -> - backStack.add(NowPlaying) - }, + TuneInScreen( + onConnected = { backStack.add(NowPlaying) }, ) } } diff --git a/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt b/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt index 32705ea..e411e34 100644 --- a/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt +++ b/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt @@ -14,11 +14,8 @@ import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -29,6 +26,7 @@ import tech.capullo.radio.espoti.EspotiPlayerManager import tech.capullo.radio.espoti.EspotiSessionRepository import tech.capullo.radio.snapcast.SnapclientProcess import tech.capullo.radio.snapcast.SnapserverProcess +import tech.capullo.radio.ui.model.AudioChannel import xyz.gianlu.librespot.audio.MetadataWrapper import xyz.gianlu.librespot.core.Session import xyz.gianlu.librespot.metadata.PlayableId @@ -53,16 +51,21 @@ class RadioBroadcasterService : Service() { private val playbackExecutor: ExecutorService = Executors.newSingleThreadExecutor() private var player: Player? = null private var session: Session? = null - private val _isPlayerLoading = MutableStateFlow(true) - val isPlayerLoading = _isPlayerLoading.asStateFlow() + private val _isPlayerLoadingFlow = MutableStateFlow(true) + val isPlayerLoadingFlow = _isPlayerLoadingFlow.asStateFlow() private val scope = CoroutineScope(Dispatchers.IO + Job()) - private lateinit var snapserverJob: Deferred - private lateinit var snapclientJob: Deferred + + private var snapserverJob: Job? = null + private var snapclientJob: Job? = null + + private var currentAudioChannel = AudioChannel.STEREO private val binder = LocalBinder() inner class LocalBinder : Binder() { - fun getService(): RadioBroadcasterService = this@RadioBroadcasterService + fun getIsPlayerLoadingFlow() = this@RadioBroadcasterService.isPlayerLoadingFlow + fun updateAudioChannel(channel: AudioChannel) = + this@RadioBroadcasterService.updateAudioChannel(channel) } private fun runOnPlayback(func: () -> Unit): Future<*>? = playbackExecutor.submit(func) @@ -207,12 +210,12 @@ class RadioBroadcasterService : Service() { } override fun onStartedLoading(player: Player) { - _isPlayerLoading.value = true + _isPlayerLoadingFlow.value = true println("started loading") } override fun onFinishedLoading(player: Player) { - _isPlayerLoading.value = false + _isPlayerLoadingFlow.value = false println("finished loading") } } @@ -253,11 +256,16 @@ class RadioBroadcasterService : Service() { } fun startSnapcast() { - scope.launch { - snapserverJob = async { snapserverProcess.start() } - snapclientJob = async { snapclientProcess.start() } - awaitAll(snapclientJob, snapserverJob) - } + snapserverJob = scope.launch { snapserverProcess.start() } + snapclientJob = scope.launch { snapclientProcess.start() } + } + + fun updateAudioChannel(channel: AudioChannel) { + currentAudioChannel = channel + // Restart snapclient with new channel + snapclientJob?.cancel() + snapclientJob = + scope.launch { snapclientProcess.start(audioChannel = currentAudioChannel.ordinal) } } companion object { diff --git a/app/src/main/java/tech/capullo/radio/services/SnapclientService.kt b/app/src/main/java/tech/capullo/radio/services/SnapclientService.kt index 3b15ad7..b47c6d7 100644 --- a/app/src/main/java/tech/capullo/radio/services/SnapclientService.kt +++ b/app/src/main/java/tech/capullo/radio/services/SnapclientService.kt @@ -21,7 +21,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import tech.capullo.radio.snapcast.SnapclientProcess -import tech.capullo.radio.ui.AudioChannel +import tech.capullo.radio.ui.model.AudioChannel import javax.inject.Inject @AndroidEntryPoint diff --git a/app/src/main/java/tech/capullo/radio/snapcast/SnapclientProcess.kt b/app/src/main/java/tech/capullo/radio/snapcast/SnapclientProcess.kt index fa385c2..d59afc9 100644 --- a/app/src/main/java/tech/capullo/radio/snapcast/SnapclientProcess.kt +++ b/app/src/main/java/tech/capullo/radio/snapcast/SnapclientProcess.kt @@ -11,7 +11,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import tech.capullo.radio.data.RadioRepository -import tech.capullo.radio.ui.AudioChannel +import tech.capullo.radio.ui.model.AudioChannel import java.io.BufferedReader import java.io.InputStreamReader import java.util.UUID diff --git a/app/src/main/java/tech/capullo/radio/ui/AudioSettingsDialog.kt b/app/src/main/java/tech/capullo/radio/ui/AudioSettingsDialog.kt new file mode 100644 index 0000000..89de767 --- /dev/null +++ b/app/src/main/java/tech/capullo/radio/ui/AudioSettingsDialog.kt @@ -0,0 +1,80 @@ +package tech.capullo.radio.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import tech.capullo.radio.ui.model.AudioChannel + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AudioSettingsDialog( + onDismissRequest: () -> Unit, + selectedChannel: AudioChannel, + onCheckedChanged: (Boolean, AudioChannel) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text("Audio Channel Settings") }, + text = { + Row( + horizontalArrangement = Arrangement.spacedBy( + ButtonGroupDefaults.ConnectedSpaceBetween, + ), + ) { + AudioChannel.entries.forEach { channel -> + ToggleButton( + checked = selectedChannel == channel, + onCheckedChange = { isChecked -> onCheckedChanged(isChecked, channel) }, + modifier = Modifier.Companion.weight(channel.modifierWeight), + shapes = + when (channel) { + AudioChannel.LEFT -> { + ButtonGroupDefaults.connectedLeadingButtonShapes() + } + + AudioChannel.RIGHT -> { + ButtonGroupDefaults.connectedTrailingButtonShapes() + } + + AudioChannel.STEREO -> { + ButtonGroupDefaults.connectedMiddleButtonShapes() + } + }, + contentPadding = PaddingValues(0.dp), + ) { + Icon( + if (selectedChannel == channel) { + channel.selectedIcon + } else { + channel.unselectedIcon + }, + contentDescription = channel.label, + ) + Spacer(Modifier.Companion.size(ToggleButtonDefaults.IconSpacing)) + Text( + text = channel.label, + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text("Dismiss") + } + }, + ) +} diff --git a/app/src/main/java/tech/capullo/radio/ui/RadioBroadcasterScreen.kt b/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt similarity index 79% rename from app/src/main/java/tech/capullo/radio/ui/RadioBroadcasterScreen.kt rename to app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt index a780002..d7177ea 100644 --- a/app/src/main/java/tech/capullo/radio/ui/RadioBroadcasterScreen.kt +++ b/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt @@ -11,18 +11,26 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +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 +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable 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.res.painterResource @@ -37,6 +45,7 @@ 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.RadioTheme import tech.capullo.radio.ui.theme.SchemeChoice import tech.capullo.radio.ui.theme.Typography @@ -44,19 +53,27 @@ import tech.capullo.radio.viewmodels.RadioBroadcasterUiState import tech.capullo.radio.viewmodels.RadioBroadcasterViewModel @Composable -fun RadioBroadcasterScreen(viewModel: RadioBroadcasterViewModel = hiltViewModel()) { +fun BroadcasterScreen(viewModel: RadioBroadcasterViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsState() - RadioBroadcasterScreenContent(uiState) + BroadcasterScreenContent( + uiState = uiState, + onAudioChannelChange = viewModel::updateAudioChannel, + ) } @Composable -fun RadioBroadcasterScreenContent(uiState: RadioBroadcasterUiState) { +fun BroadcasterScreenContent( + uiState: RadioBroadcasterUiState, + onAudioChannelChange: (AudioChannel) -> Unit, +) { when (val state = uiState) { is RadioBroadcasterUiState.EspotiPlayerReady -> { - RadioBroadcasterPlayback( + BroadcasterPlayback( hostAddresses = state.hostAddresses, snapcastClients = state.snapcastClients, + audioChannel = state.audioChannel, + onAudioChannelChange = onAudioChannelChange, ) } @@ -64,7 +81,7 @@ fun RadioBroadcasterScreenContent(uiState: RadioBroadcasterUiState) { if (state.isLoading) { LoadingSessionScreen() } else { - RadioBroadcasterEspotiConnect( + BroadcasterEspotiConnect( deviceName = state.deviceName, ) } @@ -72,6 +89,72 @@ fun RadioBroadcasterScreenContent(uiState: RadioBroadcasterUiState) { } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable fun BroadcasterPlayback( + hostAddresses: List, + snapcastClients: List = emptyList(), + audioChannel: AudioChannel, + onAudioChannelChange: (AudioChannel) -> Unit, +) { + var showChannelDialog by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { }, + actions = { + IconButton(onClick = { showChannelDialog = true }) { + Icon(Icons.Filled.Menu, contentDescription = "Menu") + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Card( + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 8.dp) + .fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Host Addresses:", + style = Typography.bodyMedium, + ) + LazyColumn(modifier = Modifier.padding(vertical = 4.dp)) { + items(items = hostAddresses) { name -> + Text( + text = name, + style = Typography.titleLarge, + ) + } + } + } + } + + SnapclientList(snapcastClients) + } + + if (showChannelDialog) { + AudioSettingsDialog( + onDismissRequest = { showChannelDialog = false }, + selectedChannel = audioChannel, + onCheckedChanged = { isChecked: Boolean, channel: AudioChannel -> + if (isChecked && audioChannel != channel) { + onAudioChannelChange(channel) + } + }, + ) + } + } +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun LoadingSessionScreen() { @@ -101,7 +184,7 @@ fun LoadingSessionScreen() { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun RadioBroadcasterEspotiConnect(deviceName: String) { +fun BroadcasterEspotiConnect(deviceName: String) { Scaffold { innerPadding -> Column( modifier = Modifier @@ -136,55 +219,17 @@ fun RadioBroadcasterEspotiConnect(deviceName: String) { } } -@Composable fun RadioBroadcasterPlayback( - hostAddresses: List, - snapcastClients: List = emptyList(), -) { - Scaffold { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - Card( - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 8.dp) - .fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - shape = MaterialTheme.shapes.medium, - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Host Addresses:", - style = Typography.bodyMedium, - ) - LazyColumn(modifier = Modifier.padding(vertical = 4.dp)) { - items(items = hostAddresses) { name -> - Text( - text = name, - style = Typography.titleLarge, - ) - } - } - } - } - - SnapclientList(snapcastClients) - } - } -} - @Preview( showBackground = true, uiMode = UI_MODE_NIGHT_YES, - name = "PreviewRadioBroadcasterEspotiConnectDark", + name = "PreviewBroadcasterEspotiConnectDark", ) @Preview(showBackground = true) @Composable -fun PreviewRadioBroadcasterEspotiConnect() { +fun PreviewBroadcasterEspotiConnect() { val deviceName = "Samsung Galaxy S21 Ultra Max" RadioTheme(schemeChoice = SchemeChoice.GREEN) { - RadioBroadcasterEspotiConnect(deviceName = deviceName) + BroadcasterEspotiConnect(deviceName = deviceName) } } @@ -204,7 +249,7 @@ fun PreviewLoadingIndicator() { @Preview( showBackground = true, uiMode = UI_MODE_NIGHT_YES, - name = "PreviewRadioBroadcasterPlaybackDark", + name = "PreviewBroadcasterPlaybackDark", showSystemUi = true, ) @Preview( @@ -212,7 +257,7 @@ fun PreviewLoadingIndicator() { showSystemUi = true, ) @Composable -fun PreviewRadioBroadcasterPlayback() { +fun PreviewBroadcasterPlayback() { val hostAddresses = listOf("192.168.0.1", "0.0.0.0", "100.10.14.7") val sampleClients = listOf( @@ -288,9 +333,11 @@ fun PreviewRadioBroadcasterPlayback() { ) RadioTheme(schemeChoice = SchemeChoice.GREEN) { - RadioBroadcasterPlayback( + BroadcasterPlayback( hostAddresses = hostAddresses, snapcastClients = sampleClients, + audioChannel = AudioChannel.STEREO, + onAudioChannelChange = { _: AudioChannel -> }, ) } } 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 a52556c..ae64c96 100644 --- a/app/src/main/java/tech/capullo/radio/ui/NowPlayingScreen.kt +++ b/app/src/main/java/tech/capullo/radio/ui/NowPlayingScreen.kt @@ -2,28 +2,18 @@ package tech.capullo.radio.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.ToggleButton -import androidx.compose.material3.ToggleButtonDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -36,6 +26,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import tech.capullo.radio.ui.model.AudioChannel import tech.capullo.radio.ui.theme.Typography import tech.capullo.radio.viewmodels.RadioTuneInModel @@ -121,7 +112,7 @@ fun NowPlayingScreen(radioTuneInModel: RadioTuneInModel = hiltViewModel()) { AudioSettingsDialog( onDismissRequest = { showChannelDialog = false }, selectedChannel = selectedChannel, - onCheckedChanged = { isChecked, audioChannel -> + onCheckedChanged = { isChecked: Boolean, audioChannel: AudioChannel -> if (isChecked && selectedChannel != audioChannel) { selectedChannel = audioChannel radioTuneInModel.updateAudioChannel(audioChannel) @@ -131,62 +122,3 @@ fun NowPlayingScreen(radioTuneInModel: RadioTuneInModel = hiltViewModel()) { } } } - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun AudioSettingsDialog( - onDismissRequest: () -> Unit, - selectedChannel: AudioChannel, - onCheckedChanged: (Boolean, AudioChannel) -> Unit, -) { - AlertDialog( - onDismissRequest = onDismissRequest, - title = { Text("Audio Channel Settings") }, - text = { - Row( - horizontalArrangement = Arrangement.spacedBy( - ButtonGroupDefaults.ConnectedSpaceBetween, - ), - ) { - AudioChannel.entries.forEach { channel -> - ToggleButton( - checked = selectedChannel == channel, - onCheckedChange = { isChecked -> onCheckedChanged(isChecked, channel) }, - modifier = Modifier.weight(channel.modifierWeight), - shapes = - when (channel) { - AudioChannel.LEFT -> { - ButtonGroupDefaults.connectedLeadingButtonShapes() - } - AudioChannel.RIGHT -> { - ButtonGroupDefaults.connectedTrailingButtonShapes() - } - AudioChannel.STEREO -> { - ButtonGroupDefaults.connectedMiddleButtonShapes() - } - }, - contentPadding = PaddingValues(0.dp), - ) { - Icon( - if (selectedChannel == channel) { - channel.selectedIcon - } else { - channel.unselectedIcon - }, - contentDescription = channel.label, - ) - Spacer(Modifier.size(ToggleButtonDefaults.IconSpacing)) - Text( - text = channel.label, - ) - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismissRequest) { - Text("Dismiss") - } - }, - ) -} diff --git a/app/src/main/java/tech/capullo/radio/ui/RadioApp.kt b/app/src/main/java/tech/capullo/radio/ui/RadioHomeScreen.kt similarity index 96% rename from app/src/main/java/tech/capullo/radio/ui/RadioApp.kt rename to app/src/main/java/tech/capullo/radio/ui/RadioHomeScreen.kt index 92e0131..6869bbe 100644 --- a/app/src/main/java/tech/capullo/radio/ui/RadioApp.kt +++ b/app/src/main/java/tech/capullo/radio/ui/RadioHomeScreen.kt @@ -45,7 +45,7 @@ import tech.capullo.radio.ui.theme.SchemeChoice @OptIn(ExperimentalPermissionsApi::class) @Composable -fun RadioApp(onStartBroadcastingClicked: () -> Unit, onTuneInClicked: () -> Unit) { +fun RadioHomeScreen(onStartBroadcastingClicked: () -> Unit, onTuneInClicked: () -> Unit) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val multiplePermissionsState = rememberMultiplePermissionsState( @@ -55,7 +55,7 @@ fun RadioApp(onStartBroadcastingClicked: () -> Unit, onTuneInClicked: () -> Unit ), ) if (multiplePermissionsState.allPermissionsGranted) { - RadioMainScreen( + RadioHomeScreenContent( onStartBroadcastingClicked = onStartBroadcastingClicked, onTuneInClicked = onTuneInClicked, ) @@ -67,7 +67,7 @@ fun RadioApp(onStartBroadcastingClicked: () -> Unit, onTuneInClicked: () -> Unit } } else { // For devices below TIRAMISU, show the main screen directly - RadioMainScreen( + RadioHomeScreenContent( onStartBroadcastingClicked = onStartBroadcastingClicked, onTuneInClicked = onTuneInClicked, ) @@ -76,7 +76,7 @@ fun RadioApp(onStartBroadcastingClicked: () -> Unit, onTuneInClicked: () -> Unit @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun RadioMainScreen(onStartBroadcastingClicked: () -> Unit, onTuneInClicked: () -> Unit) { +fun RadioHomeScreenContent(onStartBroadcastingClicked: () -> Unit, onTuneInClicked: () -> Unit) { Scaffold { innerPadding -> Column( modifier = Modifier @@ -215,9 +215,9 @@ fun HelpTooltip() { showSystemUi = true, ) @Composable -fun RadioAppPreview() { +fun RadioHomeScreenPreview() { RadioTheme { - RadioMainScreen( + RadioHomeScreenContent( onStartBroadcastingClicked = {}, onTuneInClicked = {}, ) diff --git a/app/src/main/java/tech/capullo/radio/ui/RadioTuneInScreen.kt b/app/src/main/java/tech/capullo/radio/ui/TuneInScreen.kt similarity index 86% rename from app/src/main/java/tech/capullo/radio/ui/RadioTuneInScreen.kt rename to app/src/main/java/tech/capullo/radio/ui/TuneInScreen.kt index e9fdcc4..0c0bc21 100644 --- a/app/src/main/java/tech/capullo/radio/ui/RadioTuneInScreen.kt +++ b/app/src/main/java/tech/capullo/radio/ui/TuneInScreen.kt @@ -13,13 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material.icons.outlined.Notifications -import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonGroupDefaults @@ -44,21 +37,18 @@ 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.graphics.vector.ImageVector import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import tech.capullo.radio.ui.model.AudioChannel import tech.capullo.radio.ui.theme.RadioTheme import tech.capullo.radio.ui.theme.SchemeChoice import tech.capullo.radio.ui.theme.Typography import tech.capullo.radio.viewmodels.RadioTuneInModel @Composable -fun RadioTuneInScreen( - radioTuneInModel: RadioTuneInModel = hiltViewModel(), - onConnected: (serverIp: String, channel: AudioChannel) -> Unit = { _, _ -> }, -) { +fun TuneInScreen(radioTuneInModel: RadioTuneInModel = hiltViewModel(), onConnected: () -> Unit) { var lastServerText by remember { mutableStateOf(radioTuneInModel.getLastServerText()) } @@ -71,11 +61,11 @@ fun RadioTuneInScreen( if (connectionState.isConnected && connectionState.serverIp.isNotEmpty() ) { - onConnected(connectionState.serverIp, connectionState.channel) + onConnected() } Scaffold { innerPadding -> - RadioTuneInScreenContent( + TuneInScreenContent( modifier = Modifier .padding(innerPadding), lastServerText = lastServerText, @@ -96,35 +86,9 @@ fun RadioTuneInScreen( } } -enum class AudioChannel( - val selectedIcon: ImageVector, - val unselectedIcon: ImageVector, - val modifierWeight: Float, - val label: String, -) { - LEFT( - selectedIcon = Icons.Filled.MoreVert, - unselectedIcon = Icons.Outlined.MoreVert, - modifierWeight = 1f, - "Left", - ), - STEREO( - selectedIcon = Icons.Filled.Notifications, - unselectedIcon = Icons.Outlined.Notifications, - modifierWeight = 1.5f, - "Stereo", - ), - RIGHT( - selectedIcon = Icons.Filled.PlayArrow, - unselectedIcon = Icons.Outlined.PlayArrow, - modifierWeight = 1f, - "Right", - ), -} - @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun RadioTuneInScreenContent( +fun TuneInScreenContent( modifier: Modifier = Modifier, lastServerText: String, isButtonEnabled: Boolean, @@ -256,7 +220,7 @@ fun PreviewRadioTuneInContent() { val selectedChannel = AudioChannel.STEREO RadioTheme(schemeChoice = SchemeChoice.ORANGE) { - RadioTuneInScreenContent( + TuneInScreenContent( lastServerText = lastServerText, isButtonEnabled = isButtonEnabled, selectedChannel = selectedChannel, diff --git a/app/src/main/java/tech/capullo/radio/ui/model/AudioChannel.kt b/app/src/main/java/tech/capullo/radio/ui/model/AudioChannel.kt new file mode 100644 index 0000000..d102036 --- /dev/null +++ b/app/src/main/java/tech/capullo/radio/ui/model/AudioChannel.kt @@ -0,0 +1,36 @@ +package tech.capullo.radio.ui.model + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.ui.graphics.vector.ImageVector + +enum class AudioChannel( + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, + val modifierWeight: Float, + val label: String, +) { + LEFT( + selectedIcon = Icons.Filled.MoreVert, + unselectedIcon = Icons.Outlined.MoreVert, + modifierWeight = 1f, + "Left", + ), + STEREO( + selectedIcon = Icons.Filled.Notifications, + unselectedIcon = Icons.Outlined.Notifications, + modifierWeight = 1.5f, + "Stereo", + ), + RIGHT( + selectedIcon = Icons.Filled.PlayArrow, + unselectedIcon = Icons.Outlined.PlayArrow, + modifierWeight = 1f, + "Right", + ), +} diff --git a/app/src/main/java/tech/capullo/radio/viewmodels/RadioBroadcasterViewModel.kt b/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt similarity index 92% rename from app/src/main/java/tech/capullo/radio/viewmodels/RadioBroadcasterViewModel.kt rename to app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt index 798cfa9..ac9c8ac 100644 --- a/app/src/main/java/tech/capullo/radio/viewmodels/RadioBroadcasterViewModel.kt +++ b/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt @@ -24,6 +24,7 @@ import tech.capullo.radio.espoti.EspotiSessionRepository import tech.capullo.radio.services.RadioBroadcasterService import tech.capullo.radio.snapcast.Client import tech.capullo.radio.snapcast.SnapcastControlClient +import tech.capullo.radio.ui.model.AudioChannel import javax.inject.Inject sealed interface RadioBroadcasterUiState { @@ -31,6 +32,7 @@ sealed interface RadioBroadcasterUiState { data class EspotiPlayerReady( val hostAddresses: List, val snapcastClients: List, + val audioChannel: AudioChannel, ) : RadioBroadcasterUiState data class EspotiConnect(val isLoading: Boolean, val deviceName: String) : @@ -43,6 +45,7 @@ private data class RadioBroadcasterViewModelState( val deviceName: String = "", val snapcastClients: List = emptyList(), val hostAddresses: List = emptyList(), + val audioChannel: AudioChannel = AudioChannel.STEREO, ) { /** * Converts this [RadioBroadcasterViewModelState] state into a strongly typed @@ -57,6 +60,7 @@ private data class RadioBroadcasterViewModelState( RadioBroadcasterUiState.EspotiPlayerReady( hostAddresses = hostAddresses, snapcastClients = snapcastClients, + audioChannel = audioChannel, ) } } @@ -93,6 +97,7 @@ class RadioBroadcasterViewModel @Inject constructor( deviceName = repository.getDeviceName(), snapcastClients = emptyList(), hostAddresses = emptyList(), + audioChannel = AudioChannel.STEREO, ), ) @@ -108,21 +113,17 @@ class RadioBroadcasterViewModel @Inject constructor( private val _snapcastClients = MutableStateFlow>(emptyList()) val snapcastClients = _snapcastClients.asStateFlow() - class RadioServiceWrapper(service: RadioBroadcasterService) { - val isPlayerLoading = service.isPlayerLoading - } - - private var serviceWrapper: RadioServiceWrapper? = null private var mBound: Boolean = false + private var mService: RadioBroadcasterService.LocalBinder? = null private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { val binder = service as RadioBroadcasterService.LocalBinder - serviceWrapper = RadioServiceWrapper(binder.getService()) mBound = true + mService = binder viewModelScope.launch { - serviceWrapper?.isPlayerLoading?.collect { isLoading -> + binder.getIsPlayerLoadingFlow().collect { isLoading -> // TODO: (potentially) display a screen saying the sessions is established // and the player is loading if (!isLoading) { @@ -139,8 +140,8 @@ class RadioBroadcasterViewModel @Inject constructor( } override fun onServiceDisconnected(name: ComponentName?) { - serviceWrapper = null mBound = false + mService = null } } @@ -211,6 +212,12 @@ class RadioBroadcasterViewModel @Inject constructor( } } + fun updateAudioChannel(channel: AudioChannel) { + viewModelState.value = viewModelState.value.copy(audioChannel = channel) + // Notify service to update audio channel + mService?.updateAudioChannel(channel) + } + fun unbindBroadcasterService() { if (mBound) { applicationContext.unbindService(serviceConnection) diff --git a/app/src/main/java/tech/capullo/radio/viewmodels/RadioTuneInModel.kt b/app/src/main/java/tech/capullo/radio/viewmodels/TuneInViewModel.kt similarity index 98% rename from app/src/main/java/tech/capullo/radio/viewmodels/RadioTuneInModel.kt rename to app/src/main/java/tech/capullo/radio/viewmodels/TuneInViewModel.kt index eb32ccb..adbea7a 100644 --- a/app/src/main/java/tech/capullo/radio/viewmodels/RadioTuneInModel.kt +++ b/app/src/main/java/tech/capullo/radio/viewmodels/TuneInViewModel.kt @@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import tech.capullo.radio.services.SnapclientService -import tech.capullo.radio.ui.AudioChannel +import tech.capullo.radio.ui.model.AudioChannel import javax.inject.Inject data class ServiceConnectionState(