diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 299ab38..7e83d64 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,8 +64,9 @@ dependencies { ksp(libs.hilt.android.compiler) implementation(libs.hilt.android) - implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) diff --git a/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt b/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt index bd6663f..e95217d 100644 --- a/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt +++ b/app/src/androidTest/java/tech/capullo/radio/RadioBroadcasterEspotiConnectTest.kt @@ -14,7 +14,7 @@ import org.junit.Test import tech.capullo.radio.snapcast.Client import tech.capullo.radio.ui.BroadcasterScreenContent import tech.capullo.radio.ui.model.AudioChannel -import tech.capullo.radio.viewmodels.RadioBroadcasterUiState +import tech.capullo.radio.viewmodels.BroadcasterUiState class RadioBroadcasterEspotiConnectTest { @@ -23,7 +23,7 @@ class RadioBroadcasterEspotiConnectTest { @Test fun whenEspotiConnectState_showsEspotiConnectScreen() { // Given: UI state is EspotiConnect - val uiState = RadioBroadcasterUiState.EspotiConnect( + val uiState = BroadcasterUiState.EspotiConnect( isLoading = false, deviceName = "Test Device", ) @@ -54,7 +54,7 @@ class RadioBroadcasterEspotiConnectTest { @Test fun whenLoadingPreviousPlaybackSessionState_showsLoadingIndicator() { // Given: UI state is EspotiConnect with loadingStoredCredentials = true - val uiState = RadioBroadcasterUiState.EspotiConnect( + val uiState = BroadcasterUiState.EspotiConnect( isLoading = true, deviceName = "Test Device", ) @@ -80,7 +80,7 @@ class RadioBroadcasterEspotiConnectTest { val hostAddresses = listOf("192.168.0.1", "10.0.0.2") val mockClients = emptyList() - val uiState = RadioBroadcasterUiState.EspotiPlayerReady( + val uiState = BroadcasterUiState.EspotiPlayerReady( hostAddresses = hostAddresses, snapcastClients = mockClients, audioChannel = AudioChannel.STEREO, diff --git a/app/src/androidTest/java/tech/capullo/radio/SnapserverDiscoveryTest.kt b/app/src/androidTest/java/tech/capullo/radio/SnapserverDiscoveryTest.kt new file mode 100644 index 0000000..c612166 --- /dev/null +++ b/app/src/androidTest/java/tech/capullo/radio/SnapserverDiscoveryTest.kt @@ -0,0 +1,33 @@ +package tech.capullo.radio + +import android.content.Context +import android.net.nsd.NsdManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import tech.capullo.radio.snapcast.SnapserverDiscoveryManager +import tech.capullo.radio.snapcast.SnapserverNsdManager + +@RunWith(AndroidJUnit4::class) +class SnapserverDiscoveryTest { + + @Test + fun testDiscoversSnapserverServices() = runBlocking { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + val nsdManager = appContext.getSystemService(Context.NSD_SERVICE) as NsdManager + + launch { + SnapserverNsdManager(nsdManager).start() + } + + val snapserverDiscoveryManager = SnapserverDiscoveryManager(nsdManager) + snapserverDiscoveryManager.startDiscovery() + + delay(5000) + assert(snapserverDiscoveryManager.discoveredServices.value.isNotEmpty()) + } +} diff --git a/app/src/androidTest/java/tech/capullo/radio/TuneInScreen.kt b/app/src/androidTest/java/tech/capullo/radio/TuneInScreen.kt deleted file mode 100644 index 55af336..0000000 --- a/app/src/androidTest/java/tech/capullo/radio/TuneInScreen.kt +++ /dev/null @@ -1,119 +0,0 @@ -package tech.capullo.radio - -import androidx.compose.ui.test.assertIsOff -import androidx.compose.ui.test.assertIsOn -import androidx.compose.ui.test.junit4.StateRestorationTester -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.printToLog -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.TuneInScreenContent -import tech.capullo.radio.ui.model.AudioChannel - -@RunWith(AndroidJUnit4::class) -class TuneInScreen { - - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun testDefaultChannelSelection() { - val lastServerText = "192.168.0.1" - composeTestRule.setContent { - TuneInScreenContent( - lastServerText = lastServerText, - onTextChange = {}, - onTuneInClick = {}, - isButtonEnabled = true, - selectedChannel = AudioChannel.STEREO, - onChannelChange = {}, - ) - } - - composeTestRule.onRoot().printToLog("TAG") - - // Assert Stereo channel is selected by default - composeTestRule - .onNodeWithText(AudioChannel.STEREO.label) - .assertIsOn() - composeTestRule - .onNodeWithText(AudioChannel.LEFT.label) - .assertIsOff() - composeTestRule - .onNodeWithText(AudioChannel.RIGHT.label) - .assertIsOff() - } - - @Test - fun testSameChannelSelection() { - val lastServerText = "192.168.0.1" - composeTestRule.setContent { - TuneInScreenContent( - lastServerText = lastServerText, - onTextChange = {}, - onTuneInClick = {}, - isButtonEnabled = false, - selectedChannel = AudioChannel.STEREO, - onChannelChange = { }, - ) - } - - AudioChannel.entries.forEach { channel -> - // [LEFT, STEREO, RIGHT] - // i.e. Press LEFT 10 times - repeat(10) { - composeTestRule - .onNodeWithText(channel.label) - .performClick() - .assertIsOn() - } - // i.e assert STEREO and RIGHT remain unpressed - AudioChannel.entries.filter { it != channel }.forEach { otherChannel -> - composeTestRule - .onNodeWithText(otherChannel.label) - .assertIsOff() - } - } - } - - @Test - fun testChannelSelectionRestoration() { - val lastServerText = "192.168.0.1" - - val restorationTester = StateRestorationTester(composeTestRule) - restorationTester.setContent { - TuneInScreenContent( - lastServerText = lastServerText, - onTextChange = {}, - onTuneInClick = {}, - isButtonEnabled = false, - selectedChannel = AudioChannel.RIGHT, - onChannelChange = { }, - ) - } - - repeat(10) { - composeTestRule - .onNodeWithText(AudioChannel.RIGHT.label) - .performClick() - .assertIsOn() - } - - // Right should remain selected after recomposition - restorationTester.emulateSavedInstanceStateRestore() - composeTestRule - .onNodeWithText(AudioChannel.RIGHT.label) - .assertIsOn() - composeTestRule - .onNodeWithText(AudioChannel.LEFT.label) - .assertIsOff() - composeTestRule - .onNodeWithText(AudioChannel.STEREO.label) - .assertIsOff() - } -} diff --git a/app/src/androidTest/java/tech/capullo/radio/espoti/EspotiSessionRepositoryInstrumentedTest.kt b/app/src/androidTest/java/tech/capullo/radio/espoti/EspotiSessionRepositoryInstrumentedTest.kt index 51fc38a..2053f94 100644 --- a/app/src/androidTest/java/tech/capullo/radio/espoti/EspotiSessionRepositoryInstrumentedTest.kt +++ b/app/src/androidTest/java/tech/capullo/radio/espoti/EspotiSessionRepositoryInstrumentedTest.kt @@ -15,8 +15,8 @@ import org.junit.runner.RunWith import tech.capullo.radio.data.PipeFileDataSource import tech.capullo.radio.data.RadioAdvertisingDataSource import tech.capullo.radio.data.RadioRepository -import tech.capullo.radio.viewmodels.RadioBroadcasterUiState -import tech.capullo.radio.viewmodels.RadioBroadcasterViewModel +import tech.capullo.radio.viewmodels.BroadcasterUiState +import tech.capullo.radio.viewmodels.BroadcasterViewModel import java.io.File import kotlin.system.measureTimeMillis @@ -48,7 +48,7 @@ class EspotiSessionRepositoryInstrumentedTest { val espotiConnectHandler = EspotiConnectHandler(sessionRepository) val espotiZeroconfServer = EspotiZeroconfServer(espotiConnectHandler) val espotiNsdManager = EspotiNsdManager(nsdManager, espotiZeroconfServer) - val viewModel = RadioBroadcasterViewModel( + val viewModel = BroadcasterViewModel( appContext, radioRepository, espotiNsdManager, @@ -56,7 +56,7 @@ class EspotiSessionRepositoryInstrumentedTest { ) var resultState = viewModel.uiState.take(2).toList() assertEquals( - RadioBroadcasterUiState.EspotiConnect( + BroadcasterUiState.EspotiConnect( isLoading = true, deviceName = radioAdvertisingDataSource.getDeviceName(), ), @@ -67,7 +67,7 @@ class EspotiSessionRepositoryInstrumentedTest { if (!credentialsFile.exists()) { assertEquals( - RadioBroadcasterUiState.EspotiConnect( + BroadcasterUiState.EspotiConnect( isLoading = false, deviceName = radioAdvertisingDataSource.getDeviceName(), ), diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b1d8540..f9c20ef 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,8 +6,7 @@ - + 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 e411e34..42711a4 100644 --- a/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt +++ b/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt @@ -25,6 +25,7 @@ import tech.capullo.radio.espoti.AudioFocusManager import tech.capullo.radio.espoti.EspotiPlayerManager import tech.capullo.radio.espoti.EspotiSessionRepository import tech.capullo.radio.snapcast.SnapclientProcess +import tech.capullo.radio.snapcast.SnapserverNsdManager import tech.capullo.radio.snapcast.SnapserverProcess import tech.capullo.radio.ui.model.AudioChannel import xyz.gianlu.librespot.audio.MetadataWrapper @@ -48,6 +49,8 @@ class RadioBroadcasterService : Service() { @Inject lateinit var snapserverProcess: SnapserverProcess + @Inject lateinit var snapserverNsdManager: SnapserverNsdManager + private val playbackExecutor: ExecutorService = Executors.newSingleThreadExecutor() private var player: Player? = null private var session: Session? = null @@ -123,6 +126,7 @@ class RadioBroadcasterService : Service() { override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) + snapserverNsdManager.stop() scope.cancel() player?.close() session?.close() @@ -258,6 +262,7 @@ class RadioBroadcasterService : Service() { fun startSnapcast() { snapserverJob = scope.launch { snapserverProcess.start() } snapclientJob = scope.launch { snapclientProcess.start() } + scope.launch { snapserverNsdManager.start() } } fun updateAudioChannel(channel: AudioChannel) { diff --git a/app/src/main/java/tech/capullo/radio/snapcast/SnapserverDiscoveryManager.kt b/app/src/main/java/tech/capullo/radio/snapcast/SnapserverDiscoveryManager.kt new file mode 100644 index 0000000..140eeab --- /dev/null +++ b/app/src/main/java/tech/capullo/radio/snapcast/SnapserverDiscoveryManager.kt @@ -0,0 +1,206 @@ +package tech.capullo.radio.snapcast + +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.Build +import android.os.ext.SdkExtensions +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +data class DiscoveredSnapserver( + val serviceName: String, + val serviceType: String, + val hostAddress: String, + val port: Int, +) + +@Singleton +class SnapserverDiscoveryManager @Inject constructor(private val nsdManager: NsdManager) { + + private val _discoveredServices = + MutableStateFlow>(emptyList()) + val discoveredServices: StateFlow> = + _discoveredServices.asStateFlow() + + // this would be the data source + val availableServers = mutableMapOf>() + + private val discoveryListeners = mutableListOf() + private val resolveListeners = mutableMapOf() + + private val discoveredServiceInfos = mutableMapOf() + + fun startDiscovery() { + startDiscoveryForServiceType(SnapserverNsdManager.SERVICE_TYPE) + startDiscoveryForServiceType(SnapserverNsdManager.STREAM_SERVICE_TYPE) + } + + fun stopDiscovery() { + discoveryListeners.forEach { listener -> + try { + nsdManager.stopServiceDiscovery(listener) + } catch (e: Exception) { + Log.e(TAG, "Error stopping service discovery", e) + } + } + discoveryListeners.clear() + + resolveListeners.values.forEach { listener -> + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + SdkExtensions.getExtensionVersion( + Build.VERSION_CODES.TIRAMISU, + ) >= 7 + ) { + nsdManager.stopServiceResolution(listener) + } + } catch (e: Exception) { + Log.e(TAG, "Error stopping service resolution", e) + } + } + resolveListeners.clear() + + discoveredServiceInfos.clear() + _discoveredServices.value = emptyList() + } + + private fun startDiscoveryForServiceType(serviceType: String) { + val discoveryListener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(regType: String) { + Log.d(TAG, "Service discovery started for $regType") + } + + override fun onServiceFound(service: NsdServiceInfo) { + Log.d( + TAG, + "Service found: " + + "${service.serviceName} (${service.serviceType})", + ) + discoveredServiceInfos[service.serviceName] = service + resolveService(service) + } + + override fun onServiceLost(service: NsdServiceInfo) { + Log.d(TAG, "Service lost: ${service.serviceName}") + discoveredServiceInfos.remove(service.serviceName) + updateDiscoveredServices() + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.d(TAG, "Service discovery stopped for $serviceType") + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Start discovery failed for $serviceType: $errorCode") + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Stop discovery failed for $serviceType: $errorCode") + } + } + + discoveryListeners.add(discoveryListener) + + nsdManager.discoverServices( + serviceType, + NsdManager.PROTOCOL_DNS_SD, + discoveryListener, + ) + } + + private fun resolveService(serviceInfo: NsdServiceInfo) { + val resolveListener = object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.e(TAG, "Resolve failed for ${serviceInfo.serviceName}: $errorCode") + resolveListeners.remove(serviceInfo.serviceName) + } + + override fun onServiceResolved(resolvedServiceInfo: NsdServiceInfo) { + Log.d( + TAG, + "Service resolved: " + + "${resolvedServiceInfo.serviceName} at " + + "${resolvedServiceInfo.host?.hostAddress}:${resolvedServiceInfo.port}", + ) + + // Update the available servers map + val resolvedHost = resolvedServiceInfo.host?.hostAddress ?: "" + val existingServices = availableServers.getOrPut( + resolvedHost, + ) { Pair(null, null) } + when { + resolvedServiceInfo.serviceType.contains( + SnapserverNsdManager.SERVICE_TYPE, + ) -> { + availableServers[resolvedHost] = + Pair(resolvedServiceInfo, existingServices.second) + println( + "putting in pair: ${Pair( + resolvedServiceInfo, + existingServices.second, + )}", + ) + } + resolvedServiceInfo.serviceType.contains( + SnapserverNsdManager.STREAM_SERVICE_TYPE, + ) -> { + availableServers[resolvedHost] = + Pair(existingServices.first, resolvedServiceInfo) + println( + "putting in pair: ${Pair(existingServices.first, resolvedServiceInfo)}", + ) + } + } + + // Update the list of discovered services + _discoveredServices.value = + availableServers.filter { it.value.first != null || it.value.second != null } + .map { + val serviceInfo = when { + it.value.first != null -> it.value.first!! + else -> it.value.second!! + } + DiscoveredSnapserver( + serviceName = serviceInfo.serviceName, + serviceType = serviceInfo.serviceType, + hostAddress = serviceInfo.host?.hostAddress ?: "", + port = serviceInfo.port, + ) + } + + resolveListeners.remove(resolvedServiceInfo.serviceName) + } + } + + resolveListeners[serviceInfo.serviceName] = resolveListener + + nsdManager.resolveService(serviceInfo, resolveListener) + } + + // TODO: use the map instead + private fun updateDiscoveredServices() { + val currentServices = discoveredServiceInfos.values.mapNotNull { serviceInfo -> + val hostAddress = serviceInfo.host?.hostAddress + if (hostAddress != null) { + DiscoveredSnapserver( + serviceName = serviceInfo.serviceName, + serviceType = serviceInfo.serviceType, + hostAddress = hostAddress, + port = serviceInfo.port, + ) + } else { + null + } + } + _discoveredServices.value = currentServices + } + + companion object { + private val TAG = SnapserverDiscoveryManager::class.java.simpleName + } +} diff --git a/app/src/main/java/tech/capullo/radio/snapcast/SnapserverNsdManager.kt b/app/src/main/java/tech/capullo/radio/snapcast/SnapserverNsdManager.kt new file mode 100644 index 0000000..cecdc8d --- /dev/null +++ b/app/src/main/java/tech/capullo/radio/snapcast/SnapserverNsdManager.kt @@ -0,0 +1,76 @@ +package tech.capullo.radio.snapcast + +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.util.Log +import javax.inject.Inject + +class SnapserverNsdManager @Inject constructor(private val nsdManager: NsdManager) { + + private val registeredListeners = mutableListOf() + + fun start() { + val controlServiceInfo = NsdServiceInfo().apply { + serviceName = SERVICE_NAME + serviceType = SERVICE_TYPE + port = SERVICE_PORT + } + + val streamServiceInfo = NsdServiceInfo().apply { + serviceName = SERVICE_NAME + serviceType = STREAM_SERVICE_TYPE + port = STREAM_SERVICE_PORT + } + + registerNsdService(controlServiceInfo) + registerNsdService(streamServiceInfo) + } + + fun stop() { + registeredListeners.forEach { listener -> + try { + nsdManager.unregisterService(listener) + } catch (e: Exception) { + Log.e(TAG, "Error unregistering service", e) + } + } + registeredListeners.clear() + } + + private fun registerNsdService(serviceInfo: NsdServiceInfo) { + val registrationListener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(nsdServiceInfo: NsdServiceInfo) { + Log.d(TAG, "Service registered: ${nsdServiceInfo.serviceType}") + } + + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.e(TAG, "Registration failed for ${serviceInfo.serviceType}: $errorCode") + } + + override fun onServiceUnregistered(arg0: NsdServiceInfo) { + Log.d(TAG, "Service unregistered: ${arg0.serviceType}") + } + + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.e(TAG, "Unregistration failed for ${serviceInfo.serviceType}: $errorCode") + } + } + + registeredListeners.add(registrationListener) + + nsdManager.registerService( + serviceInfo, + NsdManager.PROTOCOL_DNS_SD, + registrationListener, + ) + } + + companion object { + private val TAG = SnapserverNsdManager::class.java.simpleName + const val SERVICE_NAME = "Snapcast" + const val SERVICE_TYPE = "_snapcast._tcp" + const val SERVICE_PORT = 1704 + const val STREAM_SERVICE_TYPE = "_snapcast-stream._tcp" + const val STREAM_SERVICE_PORT = 1705 + } +} 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 d7177ea..a5a771a 100644 --- a/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt +++ b/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt @@ -37,7 +37,7 @@ 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.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import tech.capullo.radio.R import tech.capullo.radio.snapcast.Client import tech.capullo.radio.snapcast.ClientConfig @@ -49,11 +49,11 @@ 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.RadioBroadcasterUiState -import tech.capullo.radio.viewmodels.RadioBroadcasterViewModel +import tech.capullo.radio.viewmodels.BroadcasterUiState +import tech.capullo.radio.viewmodels.BroadcasterViewModel @Composable -fun BroadcasterScreen(viewModel: RadioBroadcasterViewModel = hiltViewModel()) { +fun BroadcasterScreen(viewModel: BroadcasterViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsState() BroadcasterScreenContent( @@ -64,11 +64,11 @@ fun BroadcasterScreen(viewModel: RadioBroadcasterViewModel = hiltViewModel()) { @Composable fun BroadcasterScreenContent( - uiState: RadioBroadcasterUiState, + uiState: BroadcasterUiState, onAudioChannelChange: (AudioChannel) -> Unit, ) { when (val state = uiState) { - is RadioBroadcasterUiState.EspotiPlayerReady -> { + is BroadcasterUiState.EspotiPlayerReady -> { BroadcasterPlayback( hostAddresses = state.hostAddresses, snapcastClients = state.snapcastClients, @@ -77,7 +77,7 @@ fun BroadcasterScreenContent( ) } - is RadioBroadcasterUiState.EspotiConnect -> { + is BroadcasterUiState.EspotiConnect -> { if (state.isLoading) { LoadingSessionScreen() } else { 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 ae64c96..6271dee 100644 --- a/app/src/main/java/tech/capullo/radio/ui/NowPlayingScreen.kt +++ b/app/src/main/java/tech/capullo/radio/ui/NowPlayingScreen.kt @@ -25,20 +25,20 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import tech.capullo.radio.ui.model.AudioChannel import tech.capullo.radio.ui.theme.Typography -import tech.capullo.radio.viewmodels.RadioTuneInModel +import tech.capullo.radio.viewmodels.TuneInModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun NowPlayingScreen(radioTuneInModel: RadioTuneInModel = hiltViewModel()) { - val connectionState by radioTuneInModel.connectionState.collectAsState() +fun NowPlayingScreen(viewModel: TuneInModel = hiltViewModel()) { + val uiState by viewModel.tuneInState.collectAsState() var showChannelDialog by remember { mutableStateOf(false) } var selectedChannel by remember { mutableStateOf(AudioChannel.STEREO) } - LaunchedEffect(connectionState.channel) { - selectedChannel = connectionState.channel + LaunchedEffect(uiState.audioChannel) { + selectedChannel = uiState.audioChannel } Scaffold( @@ -77,12 +77,12 @@ fun NowPlayingScreen(radioTuneInModel: RadioTuneInModel = hiltViewModel()) { ) Text( - text = "Server: ${connectionState.serverIp}", + text = "Server: ${uiState.serverIp}", style = Typography.bodyLarge, ) Text( - text = "Channel: ${connectionState.channel.label}", + text = "Channel: ${uiState.audioChannel.label}", style = Typography.bodyLarge, ) @@ -91,7 +91,7 @@ fun NowPlayingScreen(radioTuneInModel: RadioTuneInModel = hiltViewModel()) { style = Typography.bodyMedium, ) - if (connectionState.isConnected) { + if (uiState.isTunedIn) { Text( text = "✓ Service is running", style = Typography.bodyMedium, @@ -115,7 +115,7 @@ fun NowPlayingScreen(radioTuneInModel: RadioTuneInModel = hiltViewModel()) { onCheckedChanged = { isChecked: Boolean, audioChannel: AudioChannel -> if (isChecked && selectedChannel != audioChannel) { selectedChannel = audioChannel - radioTuneInModel.updateAudioChannel(audioChannel) + viewModel.updateAudioChannel(audioChannel) } }, ) diff --git a/app/src/main/java/tech/capullo/radio/ui/TuneInScreen.kt b/app/src/main/java/tech/capullo/radio/ui/TuneInScreen.kt index 0c0bc21..fbea9f3 100644 --- a/app/src/main/java/tech/capullo/radio/ui/TuneInScreen.kt +++ b/app/src/main/java/tech/capullo/radio/ui/TuneInScreen.kt @@ -1,202 +1,228 @@ package tech.capullo.radio.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background import androidx.compose.foundation.border +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.PaddingValues 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -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.LinearWavyProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField -import androidx.compose.material3.ToggleButton -import androidx.compose.material3.ToggleButtonDefaults 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.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow 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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import tech.capullo.radio.snapcast.DiscoveredSnapserver 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 +import tech.capullo.radio.viewmodels.TuneInModel +import tech.capullo.radio.viewmodels.TuneInState @Composable -fun TuneInScreen(radioTuneInModel: RadioTuneInModel = hiltViewModel(), onConnected: () -> Unit) { - var lastServerText by remember { - mutableStateOf(radioTuneInModel.getLastServerText()) - } - var selectedChannel by rememberSaveable { mutableStateOf(AudioChannel.STEREO) } - - // Collect connection state from ViewModel - val connectionState by radioTuneInModel.connectionState.collectAsState() +fun TuneInScreen(viewModel: TuneInModel = hiltViewModel(), onConnected: () -> Unit) { + val uiState by viewModel.tuneInState.collectAsState() // Navigate when service is connected and running - if (connectionState.isConnected && - connectionState.serverIp.isNotEmpty() - ) { + if (uiState.isTunedIn) { onConnected() } Scaffold { innerPadding -> TuneInScreenContent( - modifier = Modifier - .padding(innerPadding), - lastServerText = lastServerText, - isButtonEnabled = !connectionState.isConnected, - selectedChannel = selectedChannel, - onTextChange = { newServerText -> - lastServerText = newServerText - radioTuneInModel.saveLastServerText(newServerText) - }, - onTuneInClick = { channel -> - selectedChannel = channel - radioTuneInModel.startSnapclientService(lastServerText, channel) - }, - onChannelChange = { channel -> - selectedChannel = channel + modifier = Modifier.padding(innerPadding), + uiState = uiState, + onServerIPTextFieldValueChanged = viewModel::onServerIPTextFieldValueChanged, + onTuneInClick = viewModel::startSnapclientService, + onServerSelected = { server: DiscoveredSnapserver -> + viewModel.onServerIPTextFieldValueChanged(server.hostAddress) }, ) } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun TuneInScreenContent( modifier: Modifier = Modifier, - lastServerText: String, - isButtonEnabled: Boolean, - selectedChannel: AudioChannel, - onTextChange: (String) -> Unit, - onTuneInClick: (channel: AudioChannel) -> Unit, - onChannelChange: (AudioChannel) -> Unit, + uiState: TuneInState, + onServerIPTextFieldValueChanged: (String) -> Unit, + onTuneInClick: () -> Unit, + onServerSelected: (DiscoveredSnapserver) -> Unit, ) { - Scaffold { innerPadding -> - Column( - modifier = modifier.padding(innerPadding).fillMaxSize(), + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = MaterialTheme.shapes.medium, ) { - Card( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - shape = MaterialTheme.shapes.medium, + Column( + modifier = Modifier.padding(16.dp), ) { - Column( + TextField( + value = uiState.serverIp, + onValueChange = onServerIPTextFieldValueChanged, + textStyle = Typography.bodyLarge, + placeholder = { Text("Enter server IP address") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = "Tune In to another Radio:", - style = Typography.bodyMedium, + .fillMaxWidth() + .padding(vertical = 8.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(12.dp), ) - } + .clip(RoundedCornerShape(12.dp)), + ) - TextField( - value = lastServerText, - onValueChange = onTextChange, - textStyle = Typography.titleLarge, - placeholder = { - Text( - "Server IP", - style = - Typography.titleLarge, - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .border( - width = 1.dp, - color = Color.Black, - shape = RoundedCornerShape(12.dp), - ) - .clip(RoundedCornerShape(12.dp)), + Button( + onClick = { onTuneInClick() }, + enabled = uiState.serverIp.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text("TUNE IN", style = Typography.titleLarge) + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Scanning for servers... Found ${uiState.availableServers.size} servers", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (uiState.availableServers.isNotEmpty()) { + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp), + ) { + items(uiState.availableServers) { server -> + ServerListItem( + server = server, + isSelected = uiState.serverIp == server.hostAddress, + onClick = { onServerSelected(server) }, ) + } + } + } + } +} - Row( - horizontalArrangement = Arrangement.spacedBy( - ButtonGroupDefaults.ConnectedSpaceBetween, - ), - ) { - AudioChannel.entries.forEach { channel -> - ToggleButton( - checked = selectedChannel == channel, - onCheckedChange = { onChannelChange(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, - ) - } - } - } +@Composable +fun ServerListItem(server: DiscoveredSnapserver, isSelected: Boolean, onClick: () -> Unit) { + val scale by animateFloatAsState( + targetValue = if (isSelected) 1.03f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "scale", + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .scale(scale) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isSelected) 6.dp else 2.dp, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = server.hostAddress, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(Modifier.weight(1f)) - Button( - onClick = { onTuneInClick(selectedChannel) }, - enabled = isButtonEnabled, - modifier = Modifier - .padding(vertical = 16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + if (isSelected) { + Box( + modifier = Modifier + .size(32.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, ), - ) { - Text("TUNE IN", style = Typography.titleLarge) - } + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(16.dp), + ) } } } @@ -215,18 +241,51 @@ fun TuneInScreenContent( ) @Composable fun PreviewRadioTuneInContent() { - val lastServerText = "192.168.0.1" - val isButtonEnabled = true - val selectedChannel = AudioChannel.STEREO + val discoveredServices = listOf( + DiscoveredSnapserver("Snapcast Server 1", "_snapcast._tcp", "192.168.1.100", 1704), + DiscoveredSnapserver("Snapcast Server 2", "_snapcast._tcp", "192.168.1.101", 1704), + DiscoveredSnapserver("Snapcast Server 3", "_snapcast._tcp", "192.168.1.102", 1704), + DiscoveredSnapserver("Snapcast Server 4", "_snapcast._tcp", "192.168.1.103", 1704), + DiscoveredSnapserver("Snapcast Server 5", "_snapcast._tcp", "192.168.1.104", 1704), + DiscoveredSnapserver("Snapcast Server 6", "_snapcast._tcp", "192.168.1.105", 1704), + DiscoveredSnapserver("Snapcast Server 7", "_snapcast._tcp", "192.168.1.106", 1704), + DiscoveredSnapserver("Snapcast Server 8", "_snapcast._tcp", "192.168.1.107", 1704), + DiscoveredSnapserver("Snapcast Server 9", "_snapcast._tcp", "192.168.1.108", 1704), + DiscoveredSnapserver("Snapcast Server 10", "_snapcast._tcp", "192.168.1.109", 1704), + ) + val uiState = TuneInState( + availableServers = discoveredServices, + ) RadioTheme(schemeChoice = SchemeChoice.ORANGE) { - TuneInScreenContent( - lastServerText = lastServerText, - isButtonEnabled = isButtonEnabled, - selectedChannel = selectedChannel, - onTextChange = {}, - onTuneInClick = {}, - onChannelChange = {}, + Scaffold { innerPadding -> + TuneInScreenContent( + modifier = Modifier.padding(innerPadding), + uiState = uiState, + onServerIPTextFieldValueChanged = {}, + onTuneInClick = {}, + onServerSelected = {}, + ) + } + } +} + +@Preview( + showBackground = true, + name = "PreviewServerListItem", +) +@Composable +fun PreviewServerListItem() { + RadioTheme(schemeChoice = SchemeChoice.GREEN) { + ServerListItem( + server = DiscoveredSnapserver( + "Living Room Snapcast", + "_snapcast._tcp", + "192.168.0.10", + 1704, + ), + isSelected = true, + onClick = {}, ) } } 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 ac9c8ac..7e7dc9d 100644 --- a/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt +++ b/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt @@ -27,19 +27,19 @@ import tech.capullo.radio.snapcast.SnapcastControlClient import tech.capullo.radio.ui.model.AudioChannel import javax.inject.Inject -sealed interface RadioBroadcasterUiState { +sealed interface BroadcasterUiState { data class EspotiPlayerReady( val hostAddresses: List, val snapcastClients: List, val audioChannel: AudioChannel, - ) : RadioBroadcasterUiState + ) : BroadcasterUiState data class EspotiConnect(val isLoading: Boolean, val deviceName: String) : - RadioBroadcasterUiState + BroadcasterUiState } -private data class RadioBroadcasterViewModelState( +private data class BroadcasterViewModelState( val isPlaybackReady: Boolean = false, val isLoading: Boolean = true, val deviceName: String = "", @@ -48,16 +48,16 @@ private data class RadioBroadcasterViewModelState( val audioChannel: AudioChannel = AudioChannel.STEREO, ) { /** - * Converts this [RadioBroadcasterViewModelState] state into a strongly typed - * [RadioBroadcasterUiState] for driving the UI. + * Converts this [BroadcasterViewModelState] state into a strongly typed + * [BroadcasterUiState] for driving the UI. */ - fun toUiState(): RadioBroadcasterUiState = if (!isPlaybackReady) { - RadioBroadcasterUiState.EspotiConnect( + fun toUiState(): BroadcasterUiState = if (!isPlaybackReady) { + BroadcasterUiState.EspotiConnect( isLoading = isLoading, deviceName = deviceName, ) } else { - RadioBroadcasterUiState.EspotiPlayerReady( + BroadcasterUiState.EspotiPlayerReady( hostAddresses = hostAddresses, snapcastClients = snapcastClients, audioChannel = audioChannel, @@ -76,7 +76,7 @@ private data class RadioBroadcasterViewModelState( * @param espotiNsdManager The EspotiNsdManager instance. */ @HiltViewModel -class RadioBroadcasterViewModel @Inject constructor( +class BroadcasterViewModel @Inject constructor( @ApplicationContext private val applicationContext: Context, private val repository: RadioRepository, private val espotiNsdManager: EspotiNsdManager, @@ -91,7 +91,7 @@ class RadioBroadcasterViewModel @Inject constructor( * speaker. */ private val viewModelState = MutableStateFlow( - RadioBroadcasterViewModelState( + BroadcasterViewModelState( isPlaybackReady = false, isLoading = true, deviceName = repository.getDeviceName(), @@ -103,7 +103,7 @@ class RadioBroadcasterViewModel @Inject constructor( // UI state exposed to the UI val uiState = viewModelState - .map(RadioBroadcasterViewModelState::toUiState) + .map(BroadcasterViewModelState::toUiState) .stateIn( viewModelScope, started = SharingStarted.Eagerly, @@ -146,7 +146,7 @@ class RadioBroadcasterViewModel @Inject constructor( } /** - * Both [RadioBroadcasterViewModel] and [RadioBroadcasterService] will observe the session state. + * Both [BroadcasterViewModel] and [RadioBroadcasterService] will observe the session state. * When: * 1. EspotiSessionRepository.SessionState.Created -> RadioBroadcasterService -> * serviceWrapper?.isPlayerLoading?.isLoading -> responds -> UI updates 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 adbea7a..dfd0cf3 100644 --- a/app/src/main/java/tech/capullo/radio/viewmodels/TuneInViewModel.kt +++ b/app/src/main/java/tech/capullo/radio/viewmodels/TuneInViewModel.kt @@ -4,38 +4,65 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection -import android.content.SharedPreferences import android.os.Build import android.os.IBinder -import androidx.core.content.edit +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +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.DiscoveredSnapserver +import tech.capullo.radio.snapcast.SnapserverDiscoveryManager import tech.capullo.radio.ui.model.AudioChannel import javax.inject.Inject -data class ServiceConnectionState( - val isConnected: Boolean = false, +data class TuneInState( + val availableServers: List = emptyList(), val serverIp: String = "", - val channel: AudioChannel = AudioChannel.STEREO, + val audioChannel: AudioChannel = AudioChannel.STEREO, + val isTunedIn: Boolean = false, ) +val Context.dataStore by preferencesDataStore(name = "tune_in_prefs") + @HiltViewModel -class RadioTuneInModel @Inject constructor( +class TuneInModel @Inject constructor( @ApplicationContext private val applicationContext: Context, + private val discoveryManager: SnapserverDiscoveryManager, ) : ViewModel() { + object PreferencesKeys { + val LAST_SERVER_TEXT = stringPreferencesKey("last_server_text") + } + private var binder: SnapclientService.SnapclientBinder? = null private var isBound = false - private val _connectionState = MutableStateFlow(ServiceConnectionState()) - val connectionState: StateFlow = _connectionState.asStateFlow() + val lastServerTextFlow: Flow = applicationContext.dataStore.data + .catch { exception -> + if (exception is Exception) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + println("gitting the last server") + preferences[PreferencesKeys.LAST_SERVER_TEXT] ?: "" + } + + private val _tuneInState = MutableStateFlow(TuneInState()) + val tuneInState = _tuneInState.asStateFlow() private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { @@ -46,22 +73,22 @@ class RadioTuneInModel @Inject constructor( viewModelScope.launch { launch { binder?.getSnapserverIpFlow()?.collect { snapserverIp -> - _connectionState.value = connectionState.value.copy( + _tuneInState.value = tuneInState.value.copy( serverIp = snapserverIp, ) } } launch { binder?.getAudioChannelFlow()?.collect { audioChannel -> - _connectionState.value = connectionState.value.copy( - channel = audioChannel, + _tuneInState.value = tuneInState.value.copy( + audioChannel = audioChannel, ) } } } - _connectionState.value = connectionState.value.copy( - isConnected = isBound, + _tuneInState.value = tuneInState.value.copy( + isTunedIn = isBound, ) } @@ -69,8 +96,8 @@ class RadioTuneInModel @Inject constructor( binder = null isBound = false - _connectionState.value = connectionState.value.copy( - isConnected = isBound, + _tuneInState.value = tuneInState.value.copy( + isTunedIn = isBound, ) } } @@ -81,26 +108,43 @@ class RadioTuneInModel @Inject constructor( Intent(applicationContext, SnapclientService::class.java).also { intent -> applicationContext.bindService(intent, serviceConnection, 0) } - } - private fun getSharedPreferences(context: Context): SharedPreferences = - context.getSharedPreferences("MyApp", Context.MODE_PRIVATE) + viewModelScope.launch { + launch { + // collect saved preferences for previously connected servers + lastServerTextFlow.collect { + println("collected previously saved value") + _tuneInState.value = _tuneInState.value.copy(serverIp = it) + } + } + launch { + // update discovered server list + discoveryManager.discoveredServices.collect { + _tuneInState.value = _tuneInState.value.copy(availableServers = it) + } + } + } + + // Start discovering snapcast services + discoveryManager.startDiscovery() + } - fun saveLastServerText(text: String) { - getSharedPreferences(applicationContext).edit { - putString("my_text", text) + suspend fun saveLastServerText(text: String) { + applicationContext.dataStore.edit { preferences -> + preferences[PreferencesKeys.LAST_SERVER_TEXT] = text } } - fun getLastServerText(): String = - getSharedPreferences(applicationContext).getString("my_text", "") ?: "" + fun onServerIPTextFieldValueChanged(serverIpText: String) { + _tuneInState.value = _tuneInState.value.copy(serverIp = serverIpText) + } - fun startSnapclientService(ip: String, audioChannel: AudioChannel) { + fun startSnapclientService() { // UDF -> set the server IP and Audio Channel settings onto the Service, collect the values // as a flow to then display on the UI val intent = Intent(applicationContext, SnapclientService::class.java).apply { - putExtra(SnapclientService.KEY_IP, ip) - putExtra(SnapclientService.KEY_AUDIO_CHANNEL, audioChannel.ordinal) + 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) { @@ -123,5 +167,6 @@ class RadioTuneInModel @Inject constructor( isBound = false binder = null } + discoveryManager.stopDiscovery() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0a82cf7..0310176 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,35 +1,38 @@ [versions] accompanistPermissions = "0.37.3" -activityCompose = "1.10.1" +activityCompose = "1.11.0" agp = "8.13.0" androidx-junit = "1.3.0" composeLatest = "1.7.8" -composeBom = "2025.08.01" +composeBom = "2025.09.01" +datastorePreferences = "1.1.7" espressoCore = "3.7.0" -hiltNavigationCompose = "1.2.0" +hiltViewmodelCompose = "1.3.0" junit = "4.13.2" -kotlin = "2.2.10" +kotlin = "2.2.20" kotlinxCoroutinesAndroid = "1.10.2" kotlinxSerializationJson = "1.9.0" -ktlint = "1.5.0" +ktlint = "1.7.1" coreKtx = "1.17.0" hilt = "2.57.1" -ksp = "2.2.10-2.0.2" -ktor = "3.2.3" +ksp = "2.2.20-2.0.3" +ktor = "3.3.0" libSnapcastAndroid = "lr_snapcast-SNAPSHOT" -lifecycleRuntimeKtx = "2.9.3" -material3 = "1.5.0-alpha03" +lifecycleRuntimeKtx = "2.9.4" +material3 = "1.5.0-alpha04" media = "1.7.1" mockk = "1.14.5" -navigation3 = "1.0.0-alpha08" +navigation3 = "1.0.0-alpha10" startupRuntime = "1.2.0" -spotless = "7.2.1" +spotless = "8.0.0" [libraries] accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core", version.ref = "composeLatest" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } +androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "hiltViewmodelCompose" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } @@ -39,7 +42,6 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } @@ -48,6 +50,7 @@ androidx-media = { group = "androidx.media", name = "media", version.ref = "medi androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime", version.ref = "startupRuntime" } +#androidx-hilt-viewmodel-compose = { group = "androidx.hilt.lifecycle.viewmodel.compose", version.ref = "hiltViewmodelCompose" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } junit = { group = "junit", name = "junit", version.ref = "junit" }