From 6b9c71d8c98696d1635166190b6fde46bee94441 Mon Sep 17 00:00:00 2001 From: Rien Maertens Date: Fri, 23 Jul 2021 00:02:57 +0200 Subject: [PATCH 1/6] Create basic devices view --- .../me/vanpetegem/accentor/ui/devices/DevicesView.kt | 11 +++++++++++ .../accentor/ui/devices/DevicesViewModel.kt | 8 ++++++++ .../me/vanpetegem/accentor/ui/main/MainActivity.kt | 6 ++++++ app/src/main/res/drawable/ic_menu_devices.xml | 8 ++++++++ app/src/main/res/values/strings.xml | 2 ++ 5 files changed, 35 insertions(+) create mode 100644 app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt create mode 100644 app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt create mode 100644 app/src/main/res/drawable/ic_menu_devices.xml diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt new file mode 100644 index 00000000..a8fa7ab5 --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt @@ -0,0 +1,11 @@ +package me.vanpetegem.accentor.ui.devices + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +fun Devices(devicesViewModel: DevicesViewModel = viewModel()) { + Text(devicesViewModel.allDevices.joinToString(separator = ", ")) +} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt new file mode 100644 index 00000000..9587876d --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt @@ -0,0 +1,8 @@ +package me.vanpetegem.accentor.ui.devices + +import android.app.Application +import androidx.lifecycle.AndroidViewModel + +class DevicesViewModel(application: Application) : AndroidViewModel(application) { + val allDevices = listOf("Frigo", "Koelkast", "Ijstkast"); +} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt index 55ce631b..421ad762 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt @@ -81,6 +81,7 @@ import me.vanpetegem.accentor.ui.albums.AlbumViewDropdown import me.vanpetegem.accentor.ui.artists.ArtistGrid import me.vanpetegem.accentor.ui.artists.ArtistToolbar import me.vanpetegem.accentor.ui.artists.ArtistView +import me.vanpetegem.accentor.ui.devices.Devices import me.vanpetegem.accentor.ui.home.Home import me.vanpetegem.accentor.ui.login.LoginActivity import me.vanpetegem.accentor.ui.player.PlayerOverlay @@ -154,6 +155,7 @@ fun Content(mainViewModel: MainViewModel = viewModel(), playerViewModel: PlayerV AlbumView(entry.arguments!!.getInt("albumId"), navController, playerViewModel) } } + composable("devices") { Base(navController, mainViewModel) { Devices() } } } } } @@ -186,6 +188,10 @@ fun Base( navController.navigate("albums") scope.launch { scaffoldState.drawerState.close() } } + DrawerRow(stringResource(R.string.devices), currentNavigation?.destination?.route == "devices", R.drawable.ic_menu_devices) { + navController.navigate("devices") + scope.launch { scaffoldState.drawerState.close() } + } Divider() DrawerRow(stringResource(R.string.preferences), false, R.drawable.ic_menu_preferences) { context.startActivity(Intent(context, PreferencesActivity::class.java)) diff --git a/app/src/main/res/drawable/ic_menu_devices.xml b/app/src/main/res/drawable/ic_menu_devices.xml new file mode 100644 index 00000000..257ef470 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_devices.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 861c4be3..e2df4564 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,6 +62,7 @@ Artists Albums Tracks + Devices Go to album Go to %s Search @@ -69,4 +70,5 @@ No albums could be found No albums were released on this day No artists could be found + DeviceView From acd621fba05448b9b0134756903037f46c1b8243 Mon Sep 17 00:00:00 2001 From: Rien Maertens Date: Fri, 23 Jul 2021 19:05:02 +0200 Subject: [PATCH 2/6] Simple device discovery --- app/build.gradle | 6 ++ app/src/main/AndroidManifest.xml | 7 +++ .../me/vanpetegem/accentor/devices/Device.kt | 19 +++++++ .../devices/DeviceRegistryListener.kt | 56 +++++++++++++++++++ .../accentor/devices/DeviceService.kt | 5 ++ .../accentor/ui/devices/DevicesView.kt | 41 +++++++++++++- .../accentor/ui/devices/DevicesViewModel.kt | 10 +++- .../accentor/ui/main/MainActivity.kt | 46 ++++++++++++++- build.gradle | 4 ++ 9 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/me/vanpetegem/accentor/devices/Device.kt create mode 100644 app/src/main/java/me/vanpetegem/accentor/devices/DeviceRegistryListener.kt create mode 100644 app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt diff --git a/app/build.gradle b/app/build.gradle index 45569b5b..aa749a1b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -131,6 +131,12 @@ dependencies { // Material implementation 'com.google.android.material:material:1.4.0' + // Cling (UPnP/DLNA) + implementation "org.fourthline.cling:cling-core:2.1.2" + implementation "org.eclipse.jetty:jetty-servlet:8.2.0.v20160908" + implementation "org.eclipse.jetty:jetty-client:8.2.0.v20160908" + implementation "org.slf4j:slf4j-android:1.7.32" + // Tests testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test:runner:1.4.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 100a756a..10327abb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ + + + + + diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt b/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt new file mode 100644 index 00000000..2028d9c2 --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt @@ -0,0 +1,19 @@ +package me.vanpetegem.accentor.devices + +import org.fourthline.cling.model.meta.Device +import org.fourthline.cling.model.meta.Service + +class Device( + private val device: Device<*, *, *> +) { + + fun firstCharacter() = String(intArrayOf(displayString().codePointAt(0)), 0, 1) + + fun displayString(): String { + return device.details.friendlyName + } + + fun type(): String { + return device.type.displayString + } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceRegistryListener.kt b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceRegistryListener.kt new file mode 100644 index 00000000..1cc73e1c --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceRegistryListener.kt @@ -0,0 +1,56 @@ +package me.vanpetegem.accentor.devices + + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.LiveData +import org.fourthline.cling.model.meta.LocalDevice +import org.fourthline.cling.model.meta.RemoteDevice +import org.fourthline.cling.model.meta.Service +import org.fourthline.cling.registry.DefaultRegistryListener +import org.fourthline.cling.registry.Registry +import java.lang.Exception +import org.fourthline.cling.model.meta.Device as ClingDevice + +class DeviceRegistryListener: DefaultRegistryListener() { + + val devices: SnapshotStateList = mutableStateListOf() + + override fun remoteDeviceDiscoveryStarted(registry: Registry?, device: RemoteDevice?) { + addDevice(device) + } + + override fun remoteDeviceDiscoveryFailed(registry: Registry?, device: RemoteDevice?, ex: Exception?) { + removeDevice(device) + } + + override fun remoteDeviceAdded(registry: Registry?, device: RemoteDevice?) { + addDevice(device) + } + + override fun localDeviceAdded(registry: Registry?, device: LocalDevice?) { + addDevice(device) + } + + override fun localDeviceRemoved(registry: Registry?, device: LocalDevice?) { + removeDevice(device) + } + + fun addDevice( + device: ClingDevice<*, out ClingDevice<*, *, *>, out Service<*, *>>? + ) { + val d = Device(device!!) + // update? + devices.add(d) + } + + fun removeDevice( + device: org.fourthline.cling.model.meta.Device<*, out org.fourthline.cling.model.meta.Device<*, *, *>, out Service<*, *>>? + ) { + val d = Device(device!!) + devices.remove(d) + } + + +} diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt new file mode 100644 index 00000000..3689aa6f --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt @@ -0,0 +1,5 @@ +package me.vanpetegem.accentor.devices + +import org.fourthline.cling.android.AndroidUpnpServiceImpl + +class DeviceService: AndroidUpnpServiceImpl() {} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt index a8fa7ab5..9af5a8b6 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt @@ -1,11 +1,48 @@ package me.vanpetegem.accentor.ui.devices +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.rememberImagePainter +import me.vanpetegem.accentor.R +import me.vanpetegem.accentor.devices.Device +import me.vanpetegem.accentor.ui.artists.ArtistCard +import me.vanpetegem.accentor.ui.util.FastScrollableGrid @Composable -fun Devices(devicesViewModel: DevicesViewModel = viewModel()) { - Text(devicesViewModel.allDevices.joinToString(separator = ", ")) +fun Devices(devices: SnapshotStateList) { + FastScrollableGrid(devices, { it.firstCharacter().uppercase() }) { DeviceCard(it) } +} + +@Composable +fun DeviceCard(device: Device) { + Card( + modifier = Modifier.padding(8.dp), + ) { + Column { + Text( + device.displayString(), + maxLines = 1, + modifier = Modifier.padding(4.dp), + style = MaterialTheme.typography.subtitle1, + ) + Text( + device.type() + ) + } + } } diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt index 9587876d..5e8ca7d0 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt @@ -1,8 +1,12 @@ package me.vanpetegem.accentor.ui.devices import android.app.Application +import android.content.ComponentName +import android.content.ServiceConnection +import android.os.IBinder import androidx.lifecycle.AndroidViewModel +import me.vanpetegem.accentor.devices.Device +import me.vanpetegem.accentor.devices.DeviceRegistryListener +import org.fourthline.cling.android.AndroidUpnpService -class DevicesViewModel(application: Application) : AndroidViewModel(application) { - val allDevices = listOf("Frigo", "Koelkast", "Ijstkast"); -} +class DevicesViewModel(application: Application) : AndroidViewModel(application) {} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt index 421ad762..5cd8d37e 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt @@ -1,8 +1,12 @@ package me.vanpetegem.accentor.ui.main import android.app.Activity +import android.content.ComponentName +import android.content.Context import android.content.Intent +import android.content.ServiceConnection import android.os.Bundle +import android.os.IBinder import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent @@ -49,6 +53,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -73,6 +78,9 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import me.vanpetegem.accentor.R +import me.vanpetegem.accentor.devices.Device +import me.vanpetegem.accentor.devices.DeviceRegistryListener +import me.vanpetegem.accentor.devices.DeviceService import me.vanpetegem.accentor.ui.AccentorTheme import me.vanpetegem.accentor.ui.albums.AlbumGrid import me.vanpetegem.accentor.ui.albums.AlbumToolbar @@ -87,21 +95,53 @@ import me.vanpetegem.accentor.ui.login.LoginActivity import me.vanpetegem.accentor.ui.player.PlayerOverlay import me.vanpetegem.accentor.ui.player.PlayerViewModel import me.vanpetegem.accentor.ui.preferences.PreferencesActivity +import org.fourthline.cling.android.AndroidUpnpService +import org.fourthline.cling.android.FixedAndroidLogHandler +import org.seamless.util.logging.LoggingUtil @AndroidEntryPoint class MainActivity : ComponentActivity() { + + private lateinit var deviceService: AndroidUpnpService + private var isServiceConnected = false + private val registryListener = DeviceRegistryListener() + + private val deviceConnection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName?, service: IBinder?) { + deviceService = service!! as AndroidUpnpService + isServiceConnected = true + + deviceService.registry.addListener(registryListener) + for (device in deviceService.registry.devices) { + registryListener.addDevice(device) + } + + deviceService.controlPoint.search() + } + + override fun onServiceDisconnected(className: ComponentName?) { + isServiceConnected = false + } + } + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AccentorTheme() { - Content() + Content(devices = registryListener.devices) } } + + // Fix the logging integration between java.util.logging and Android internal logging + LoggingUtil.resetRootHandler(FixedAndroidLogHandler()) + + applicationContext.bindService(Intent(this, DeviceService::class.java), deviceConnection, Context.BIND_AUTO_CREATE) } } @Composable -fun Content(mainViewModel: MainViewModel = viewModel(), playerViewModel: PlayerViewModel = viewModel()) { +fun Content(mainViewModel: MainViewModel = viewModel(), playerViewModel: PlayerViewModel = viewModel(), devices: SnapshotStateList) { val navController = rememberNavController() val loginState by mainViewModel.loginState.observeAsState() @@ -155,7 +195,7 @@ fun Content(mainViewModel: MainViewModel = viewModel(), playerViewModel: PlayerV AlbumView(entry.arguments!!.getInt("albumId"), navController, playerViewModel) } } - composable("devices") { Base(navController, mainViewModel) { Devices() } } + composable("devices") { Base(navController, mainViewModel) { Devices(devices = devices) } } } } } diff --git a/build.gradle b/build.gradle index 091cea86..f0a8d6b3 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,10 @@ allprojects { repositories { google() mavenCentral() + maven { + allowInsecureProtocol true + url "http://4thline.org/m2/" + } } tasks.withType(JavaCompile) { options.compilerArgs << "-Xlint:deprecation" From 5347ed70c2c85213fe265294a48de5766f12d413 Mon Sep 17 00:00:00 2001 From: Rien Maertens Date: Fri, 23 Jul 2021 22:36:40 +0200 Subject: [PATCH 3/6] Discover only AVTransport devices --- .../me/vanpetegem/accentor/devices/Device.kt | 19 +++++---- .../devices/DeviceRegistryListener.kt | 42 +++++++++---------- .../accentor/devices/DeviceService.kt | 19 ++++++++- .../accentor/ui/devices/DevicesView.kt | 26 ++++++++---- .../accentor/ui/main/MainActivity.kt | 14 +++++-- app/src/main/res/values/strings.xml | 1 + 6 files changed, 77 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt b/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt index 2028d9c2..005187d4 100644 --- a/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt +++ b/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt @@ -1,19 +1,20 @@ package me.vanpetegem.accentor.devices import org.fourthline.cling.model.meta.Device -import org.fourthline.cling.model.meta.Service +import org.fourthline.cling.model.meta.RemoteDevice class Device( - private val device: Device<*, *, *> + private val clingDevice: RemoteDevice ) { - fun firstCharacter() = String(intArrayOf(displayString().codePointAt(0)), 0, 1) + val friendlyName: String = clingDevice.details.friendlyName + val firstCharacter: String = String(intArrayOf(friendlyName.codePointAt(0)), 0, 1) - fun displayString(): String { - return device.details.friendlyName - } + val type: String = clingDevice.type.displayString + + val imageURL: String? = clingDevice + .icons + .maxWithOrNull(compareBy({ it.height * it.width }, { it.mimeType.subtype == "png" })) + ?.let { clingDevice.normalizeURI(it.uri).toString() } - fun type(): String { - return device.type.displayString - } } diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceRegistryListener.kt b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceRegistryListener.kt index 1cc73e1c..47103ce7 100644 --- a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceRegistryListener.kt +++ b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceRegistryListener.kt @@ -1,13 +1,17 @@ package me.vanpetegem.accentor.devices +import android.util.Log import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.lifecycle.LiveData import org.fourthline.cling.model.meta.LocalDevice import org.fourthline.cling.model.meta.RemoteDevice import org.fourthline.cling.model.meta.Service +import org.fourthline.cling.model.types.UDN import org.fourthline.cling.registry.DefaultRegistryListener import org.fourthline.cling.registry.Registry import java.lang.Exception @@ -15,41 +19,35 @@ import org.fourthline.cling.model.meta.Device as ClingDevice class DeviceRegistryListener: DefaultRegistryListener() { - val devices: SnapshotStateList = mutableStateListOf() + val devices: SnapshotStateMap = mutableStateMapOf() - override fun remoteDeviceDiscoveryStarted(registry: Registry?, device: RemoteDevice?) { - addDevice(device) + override fun remoteDeviceDiscoveryStarted(registry: Registry?, remote: RemoteDevice?) { + // TODO } - override fun remoteDeviceDiscoveryFailed(registry: Registry?, device: RemoteDevice?, ex: Exception?) { - removeDevice(device) + override fun remoteDeviceDiscoveryFailed(registry: Registry?, remote: RemoteDevice?, ex: Exception?) { + // TODO } - override fun remoteDeviceAdded(registry: Registry?, device: RemoteDevice?) { - addDevice(device) + override fun remoteDeviceUpdated(registry: Registry?, device: RemoteDevice?) { + // TODO } - override fun localDeviceAdded(registry: Registry?, device: LocalDevice?) { - addDevice(device) + override fun remoteDeviceAdded(registry: Registry?, remote: RemoteDevice?) { + addDevice(remote) } - override fun localDeviceRemoved(registry: Registry?, device: LocalDevice?) { - removeDevice(device) + override fun remoteDeviceRemoved(registry: Registry?, remote: RemoteDevice?) { + removeDevice(remote) } - fun addDevice( - device: ClingDevice<*, out ClingDevice<*, *, *>, out Service<*, *>>? - ) { - val d = Device(device!!) - // update? - devices.add(d) + fun addDevice(remote: RemoteDevice?) { + val device = Device(remote!!) + devices[remote.identity.udn] = device } - fun removeDevice( - device: org.fourthline.cling.model.meta.Device<*, out org.fourthline.cling.model.meta.Device<*, *, *>, out Service<*, *>>? - ) { - val d = Device(device!!) - devices.remove(d) + fun removeDevice(remote: RemoteDevice?) { + devices.remove(remote!!.identity.udn) } diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt index 3689aa6f..fdf690f7 100644 --- a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt +++ b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt @@ -1,5 +1,22 @@ package me.vanpetegem.accentor.devices +import org.fourthline.cling.UpnpServiceConfiguration +import org.fourthline.cling.android.AndroidUpnpServiceConfiguration import org.fourthline.cling.android.AndroidUpnpServiceImpl +import org.fourthline.cling.binding.xml.ServiceDescriptorBinder +import org.fourthline.cling.binding.xml.UDA10DeviceDescriptorBinderImpl +import org.fourthline.cling.binding.xml.UDA10ServiceDescriptorBinderImpl -class DeviceService: AndroidUpnpServiceImpl() {} +class DeviceService: AndroidUpnpServiceImpl() { + + override fun createConfiguration(): UpnpServiceConfiguration { + return object: AndroidUpnpServiceConfiguration() { + // This override fixes the XML parser + // See https://github.com/4thline/cling/issues/247 + override fun getServiceDescriptorBinderUDA10(): ServiceDescriptorBinder { + return UDA10ServiceDescriptorBinderImpl() + } + } + } + +} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt index 9af5a8b6..d6772251 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt @@ -1,7 +1,6 @@ package me.vanpetegem.accentor.ui.devices import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth @@ -10,22 +9,21 @@ import androidx.compose.material.Card import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.rememberImagePainter import me.vanpetegem.accentor.R import me.vanpetegem.accentor.devices.Device -import me.vanpetegem.accentor.ui.artists.ArtistCard import me.vanpetegem.accentor.ui.util.FastScrollableGrid +import org.fourthline.cling.model.types.UDN @Composable -fun Devices(devices: SnapshotStateList) { - FastScrollableGrid(devices, { it.firstCharacter().uppercase() }) { DeviceCard(it) } +fun Devices(devices: SnapshotStateMap) { + FastScrollableGrid(devices.values.sortedBy { it.friendlyName }, { it.firstCharacter.uppercase() }) { DeviceCard(it) } } @Composable @@ -34,14 +32,26 @@ fun DeviceCard(device: Device) { modifier = Modifier.padding(8.dp), ) { Column { + Image( + painter = if (device.imageURL != null) { + rememberImagePainter(device.imageURL) { + placeholder(R.drawable.ic_artist) + } + } else { + painterResource(R.drawable.ic_artist) + }, + contentDescription = stringResource(R.string.device_image), + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + contentScale = ContentScale.Crop, + ) Text( - device.displayString(), + device.friendlyName, maxLines = 1, modifier = Modifier.padding(4.dp), style = MaterialTheme.typography.subtitle1, ) Text( - device.type() + device.type ) } } diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt index 5cd8d37e..9f823a3a 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt @@ -53,7 +53,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -97,6 +97,10 @@ import me.vanpetegem.accentor.ui.player.PlayerViewModel import me.vanpetegem.accentor.ui.preferences.PreferencesActivity import org.fourthline.cling.android.AndroidUpnpService import org.fourthline.cling.android.FixedAndroidLogHandler +import org.fourthline.cling.model.message.header.ServiceTypeHeader +import org.fourthline.cling.model.meta.RemoteDevice +import org.fourthline.cling.model.types.ServiceType +import org.fourthline.cling.model.types.UDN import org.seamless.util.logging.LoggingUtil @AndroidEntryPoint @@ -112,11 +116,12 @@ class MainActivity : ComponentActivity() { isServiceConnected = true deviceService.registry.addListener(registryListener) - for (device in deviceService.registry.devices) { + for (device in deviceService.registry.devices.filterIsInstance()) { registryListener.addDevice(device) } - deviceService.controlPoint.search() + val playerService = ServiceTypeHeader(ServiceType("schemas-upnp-org", "AVTransport", 1)) + deviceService.controlPoint.search(playerService) } override fun onServiceDisconnected(className: ComponentName?) { @@ -135,13 +140,14 @@ class MainActivity : ComponentActivity() { // Fix the logging integration between java.util.logging and Android internal logging LoggingUtil.resetRootHandler(FixedAndroidLogHandler()) + //Logger.getLogger("org.fourthline.cling").level = Level.FINE applicationContext.bindService(Intent(this, DeviceService::class.java), deviceConnection, Context.BIND_AUTO_CREATE) } } @Composable -fun Content(mainViewModel: MainViewModel = viewModel(), playerViewModel: PlayerViewModel = viewModel(), devices: SnapshotStateList) { +fun Content(mainViewModel: MainViewModel = viewModel(), playerViewModel: PlayerViewModel = viewModel(), devices: SnapshotStateMap) { val navController = rememberNavController() val loginState by mainViewModel.loginState.observeAsState() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2df4564..4f41a75e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Open drawer Navigation icon Image showing artist + Image showing casting device Image showing album Close player Various Artists From cec031183ebfe75b5d95c3caf382c7f54c181894 Mon Sep 17 00:00:00 2001 From: Rien Maertens Date: Sat, 24 Jul 2021 22:38:54 +0200 Subject: [PATCH 4/6] Clean up device code with LiveData and Hilt --- .../me/vanpetegem/accentor/devices/Device.kt | 21 +++- .../accentor/devices/DeviceManager.kt | 109 ++++++++++++++++++ .../devices/DeviceRegistryListener.kt | 54 --------- .../accentor/devices/DeviceService.kt | 2 - .../accentor/ui/devices/DevicesView.kt | 10 +- .../accentor/ui/devices/DevicesViewModel.kt | 21 +++- .../accentor/ui/main/MainActivity.kt | 36 ++---- 7 files changed, 155 insertions(+), 98 deletions(-) create mode 100644 app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt delete mode 100644 app/src/main/java/me/vanpetegem/accentor/devices/DeviceRegistryListener.kt diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt b/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt index 005187d4..245a5640 100644 --- a/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt +++ b/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt @@ -1,15 +1,14 @@ package me.vanpetegem.accentor.devices -import org.fourthline.cling.model.meta.Device import org.fourthline.cling.model.meta.RemoteDevice +import java.lang.Exception -class Device( - private val clingDevice: RemoteDevice +sealed class Device( + protected val clingDevice: RemoteDevice ) { val friendlyName: String = clingDevice.details.friendlyName val firstCharacter: String = String(intArrayOf(friendlyName.codePointAt(0)), 0, 1) - val type: String = clingDevice.type.displayString val imageURL: String? = clingDevice @@ -17,4 +16,18 @@ class Device( .maxWithOrNull(compareBy({ it.height * it.width }, { it.mimeType.subtype == "png" })) ?.let { clingDevice.normalizeURI(it.uri).toString() } + + class Discovered(clingDevice: RemoteDevice): Device(clingDevice) { + fun failed(exception: Exception?): Failed { + return Failed(clingDevice, exception) + } + + fun ready(): Ready { + return Ready(clingDevice) + } + } + class Failed(clingDevice: RemoteDevice, val exception: Exception?): Device(clingDevice) {} + class Ready(clingDevice: RemoteDevice): Device(clingDevice) {} } + + diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt new file mode 100644 index 00000000..5887dd7d --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt @@ -0,0 +1,109 @@ +package me.vanpetegem.accentor.devices + + +import android.content.ComponentName +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import dagger.Reusable +import org.fourthline.cling.android.AndroidUpnpService +import org.fourthline.cling.model.message.header.ServiceTypeHeader +import org.fourthline.cling.model.meta.RemoteDevice +import org.fourthline.cling.model.types.ServiceType +import org.fourthline.cling.model.types.UDN +import org.fourthline.cling.registry.DefaultRegistryListener +import org.fourthline.cling.registry.Registry +import java.lang.Exception +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DeviceManager @Inject constructor() { + + val devices = MutableLiveData>(emptyMap()) + val connection = DeviceServiceConnection() + + private lateinit var upnp: AndroidUpnpService + private val isConnected = MutableLiveData(false) + private val registryListener = DeviceRegistryListener() + + fun search() { + val playerService = ServiceTypeHeader(ServiceType("schemas-upnp-org", "AVTransport", 1)) + upnp.controlPoint.search(playerService) + } + + inner class DeviceServiceConnection() : ServiceConnection { + override fun onServiceConnected(className: ComponentName?, binder: IBinder?) { + upnp = binder!! as AndroidUpnpService + isConnected.value = true + + // clear devices (if any) and collect the known remote devices into a map + devices.value = upnp.registry.devices + .filterIsInstance() + .map { it.identity.udn to Device.Ready(it) } + .toMap() + + upnp.registry.addListener(registryListener) + search() + } + + override fun onServiceDisconnected(className: ComponentName?) { + isConnected.value = false + } + } + + private inner class DeviceRegistryListener(): DefaultRegistryListener() { + + override fun remoteDeviceDiscoveryStarted(registry: Registry?, remote: RemoteDevice?) { + val udn = remote!!.identity.udn + // this will only add a new device if not yet present in the map + devices.postValue(mapOf(udn to Device.Discovered(remote)) + devices.value!!) + } + + override fun remoteDeviceDiscoveryFailed(registry: Registry?, remote: RemoteDevice?, ex: Exception?) { + val udn = remote!!.identity.udn + val known = devices.value!! + when(val dev = known[udn]) { + is Device.Discovered -> devices.postValue(known + (udn to dev.failed(ex))) + else -> Log.e(TAG, "Discovery failed of existing device", ex) + } + } + + override fun remoteDeviceUpdated(registry: Registry?, remote: RemoteDevice?) { + if (devices.value!!.contains(remote!!.identity.udn)) { + // trigger an update + devices.postValue(devices.value) + } else { + Log.e(TAG, "Non-existing device updated") + } + } + + override fun remoteDeviceAdded(registry: Registry?, remote: RemoteDevice?) { + addDevice(remote!!) + } + + override fun remoteDeviceRemoved(registry: Registry?, remote: RemoteDevice?) { + val withRemoved = devices.value!!.minus(remote!!.identity.udn) + devices.postValue(withRemoved) + } + + fun addDevice(remote: RemoteDevice) { + val udn = remote.identity.udn + val known = devices.value!! + if (udn in known) { + when (val dev = known[udn]) { + is Device.Discovered -> devices.postValue(known + (udn to dev.ready())) + else -> Log.e(TAG, "Device added twice, ignoring... ${remote.displayString} ($udn)") + } + } else { + devices.postValue(known + (udn to Device.Ready(remote))) + } + } + } +} + +const val TAG: String = "DeviceManager" diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceRegistryListener.kt b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceRegistryListener.kt deleted file mode 100644 index 47103ce7..00000000 --- a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceRegistryListener.kt +++ /dev/null @@ -1,54 +0,0 @@ -package me.vanpetegem.accentor.devices - - -import android.util.Log -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.snapshots.SnapshotStateMap -import androidx.lifecycle.LiveData -import org.fourthline.cling.model.meta.LocalDevice -import org.fourthline.cling.model.meta.RemoteDevice -import org.fourthline.cling.model.meta.Service -import org.fourthline.cling.model.types.UDN -import org.fourthline.cling.registry.DefaultRegistryListener -import org.fourthline.cling.registry.Registry -import java.lang.Exception -import org.fourthline.cling.model.meta.Device as ClingDevice - -class DeviceRegistryListener: DefaultRegistryListener() { - - val devices: SnapshotStateMap = mutableStateMapOf() - - override fun remoteDeviceDiscoveryStarted(registry: Registry?, remote: RemoteDevice?) { - // TODO - } - - override fun remoteDeviceDiscoveryFailed(registry: Registry?, remote: RemoteDevice?, ex: Exception?) { - // TODO - } - - override fun remoteDeviceUpdated(registry: Registry?, device: RemoteDevice?) { - // TODO - } - - override fun remoteDeviceAdded(registry: Registry?, remote: RemoteDevice?) { - addDevice(remote) - } - - override fun remoteDeviceRemoved(registry: Registry?, remote: RemoteDevice?) { - removeDevice(remote) - } - - fun addDevice(remote: RemoteDevice?) { - val device = Device(remote!!) - devices[remote.identity.udn] = device - } - - fun removeDevice(remote: RemoteDevice?) { - devices.remove(remote!!.identity.udn) - } - - -} diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt index fdf690f7..f883409c 100644 --- a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt +++ b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt @@ -4,11 +4,9 @@ import org.fourthline.cling.UpnpServiceConfiguration import org.fourthline.cling.android.AndroidUpnpServiceConfiguration import org.fourthline.cling.android.AndroidUpnpServiceImpl import org.fourthline.cling.binding.xml.ServiceDescriptorBinder -import org.fourthline.cling.binding.xml.UDA10DeviceDescriptorBinderImpl import org.fourthline.cling.binding.xml.UDA10ServiceDescriptorBinderImpl class DeviceService: AndroidUpnpServiceImpl() { - override fun createConfiguration(): UpnpServiceConfiguration { return object: AndroidUpnpServiceConfiguration() { // This override fixes the XML parser diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt index d6772251..f54e681c 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt @@ -9,21 +9,23 @@ import androidx.compose.material.Card import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.rememberImagePainter import me.vanpetegem.accentor.R import me.vanpetegem.accentor.devices.Device import me.vanpetegem.accentor.ui.util.FastScrollableGrid -import org.fourthline.cling.model.types.UDN @Composable -fun Devices(devices: SnapshotStateMap) { - FastScrollableGrid(devices.values.sortedBy { it.friendlyName }, { it.firstCharacter.uppercase() }) { DeviceCard(it) } +fun Devices(devicesViewModel: DevicesViewModel = hiltViewModel()) { + val devices: List? by devicesViewModel.devices().observeAsState() + FastScrollableGrid(devices ?: emptyList(), { it.firstCharacter }) { DeviceCard(it) } } @Composable diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt index 5e8ca7d0..98b3bd3a 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt @@ -1,12 +1,21 @@ package me.vanpetegem.accentor.ui.devices import android.app.Application -import android.content.ComponentName -import android.content.ServiceConnection -import android.os.IBinder import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations.map +import dagger.hilt.android.lifecycle.HiltViewModel import me.vanpetegem.accentor.devices.Device -import me.vanpetegem.accentor.devices.DeviceRegistryListener -import org.fourthline.cling.android.AndroidUpnpService +import me.vanpetegem.accentor.devices.DeviceManager +import javax.inject.Inject -class DevicesViewModel(application: Application) : AndroidViewModel(application) {} +@HiltViewModel +class DevicesViewModel @Inject constructor( + application: Application, + private val deviceManager: DeviceManager, +) : AndroidViewModel(application) { + + fun devices(): LiveData> = map(deviceManager.devices) { devices -> + devices.values.sortedWith(compareBy({ it.friendlyName }, { it.firstCharacter.uppercase() })) + } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt index 9f823a3a..e3834462 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt @@ -79,7 +79,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import me.vanpetegem.accentor.R import me.vanpetegem.accentor.devices.Device -import me.vanpetegem.accentor.devices.DeviceRegistryListener +import me.vanpetegem.accentor.devices.DeviceManager import me.vanpetegem.accentor.devices.DeviceService import me.vanpetegem.accentor.ui.AccentorTheme import me.vanpetegem.accentor.ui.albums.AlbumGrid @@ -102,39 +102,19 @@ import org.fourthline.cling.model.meta.RemoteDevice import org.fourthline.cling.model.types.ServiceType import org.fourthline.cling.model.types.UDN import org.seamless.util.logging.LoggingUtil +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { - - private lateinit var deviceService: AndroidUpnpService - private var isServiceConnected = false - private val registryListener = DeviceRegistryListener() - - private val deviceConnection = object : ServiceConnection { - override fun onServiceConnected(className: ComponentName?, service: IBinder?) { - deviceService = service!! as AndroidUpnpService - isServiceConnected = true - - deviceService.registry.addListener(registryListener) - for (device in deviceService.registry.devices.filterIsInstance()) { - registryListener.addDevice(device) - } - - val playerService = ServiceTypeHeader(ServiceType("schemas-upnp-org", "AVTransport", 1)) - deviceService.controlPoint.search(playerService) - } - - override fun onServiceDisconnected(className: ComponentName?) { - isServiceConnected = false - } - } + @Inject + lateinit var deviceManager: DeviceManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { AccentorTheme() { - Content(devices = registryListener.devices) + Content() } } @@ -142,12 +122,12 @@ class MainActivity : ComponentActivity() { LoggingUtil.resetRootHandler(FixedAndroidLogHandler()) //Logger.getLogger("org.fourthline.cling").level = Level.FINE - applicationContext.bindService(Intent(this, DeviceService::class.java), deviceConnection, Context.BIND_AUTO_CREATE) + applicationContext.bindService(Intent(this, DeviceService::class.java), deviceManager.connection, Context.BIND_AUTO_CREATE) } } @Composable -fun Content(mainViewModel: MainViewModel = viewModel(), playerViewModel: PlayerViewModel = viewModel(), devices: SnapshotStateMap) { +fun Content(mainViewModel: MainViewModel = viewModel(), playerViewModel: PlayerViewModel = viewModel()) { val navController = rememberNavController() val loginState by mainViewModel.loginState.observeAsState() @@ -201,7 +181,7 @@ fun Content(mainViewModel: MainViewModel = viewModel(), playerViewModel: PlayerV AlbumView(entry.arguments!!.getInt("albumId"), navController, playerViewModel) } } - composable("devices") { Base(navController, mainViewModel) { Devices(devices = devices) } } + composable("devices") { Base(navController, mainViewModel) { Devices() } } } } } From f7743e02168702b65417252c325363cc838178da Mon Sep 17 00:00:00 2001 From: Rien Maertens Date: Sun, 25 Jul 2021 00:46:12 +0200 Subject: [PATCH 5/6] Create basic UI for devices --- .../me/vanpetegem/accentor/devices/Device.kt | 8 +- .../accentor/devices/DeviceManager.kt | 6 +- .../accentor/ui/devices/DevicesView.kt | 85 ++++++++++++------- .../accentor/ui/devices/DevicesViewModel.kt | 9 +- .../main/res/drawable/ic_smartphone_sound.xml | 8 ++ app/src/main/res/values/strings.xml | 3 + 6 files changed, 81 insertions(+), 38 deletions(-) create mode 100644 app/src/main/res/drawable/ic_smartphone_sound.xml diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt b/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt index 245a5640..5f33c9b3 100644 --- a/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt +++ b/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt @@ -8,7 +8,7 @@ sealed class Device( ) { val friendlyName: String = clingDevice.details.friendlyName - val firstCharacter: String = String(intArrayOf(friendlyName.codePointAt(0)), 0, 1) + val displayString: String = clingDevice.displayString val type: String = clingDevice.type.displayString val imageURL: String? = clingDevice @@ -17,6 +17,10 @@ sealed class Device( ?.let { clingDevice.normalizeURI(it.uri).toString() } + class Ready(clingDevice: RemoteDevice): Device(clingDevice) {} + + class Failed(clingDevice: RemoteDevice, val exception: Exception?): Device(clingDevice) {} + class Discovered(clingDevice: RemoteDevice): Device(clingDevice) { fun failed(exception: Exception?): Failed { return Failed(clingDevice, exception) @@ -26,8 +30,6 @@ sealed class Device( return Ready(clingDevice) } } - class Failed(clingDevice: RemoteDevice, val exception: Exception?): Device(clingDevice) {} - class Ready(clingDevice: RemoteDevice): Device(clingDevice) {} } diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt index 5887dd7d..4d0162ae 100644 --- a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt +++ b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt @@ -5,11 +5,8 @@ import android.content.ComponentName import android.content.ServiceConnection import android.os.IBinder import android.util.Log -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import dagger.Reusable import org.fourthline.cling.android.AndroidUpnpService import org.fourthline.cling.model.message.header.ServiceTypeHeader import org.fourthline.cling.model.meta.RemoteDevice @@ -17,7 +14,6 @@ import org.fourthline.cling.model.types.ServiceType import org.fourthline.cling.model.types.UDN import org.fourthline.cling.registry.DefaultRegistryListener import org.fourthline.cling.registry.Registry -import java.lang.Exception import javax.inject.Inject import javax.inject.Singleton @@ -25,6 +21,8 @@ import javax.inject.Singleton class DeviceManager @Inject constructor() { val devices = MutableLiveData>(emptyMap()) + val selectedDevice = MutableLiveData(null) + val connection = DeviceServiceConnection() private lateinit var upnp: AndroidUpnpService diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt index f54e681c..1da0a8ee 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt @@ -1,60 +1,85 @@ package me.vanpetegem.accentor.ui.devices +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Card import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import coil.compose.rememberImagePainter import me.vanpetegem.accentor.R import me.vanpetegem.accentor.devices.Device -import me.vanpetegem.accentor.ui.util.FastScrollableGrid @Composable fun Devices(devicesViewModel: DevicesViewModel = hiltViewModel()) { val devices: List? by devicesViewModel.devices().observeAsState() - FastScrollableGrid(devices ?: emptyList(), { it.firstCharacter }) { DeviceCard(it) } + DeviceList(devices ?: emptyList()) } @Composable -fun DeviceCard(device: Device) { +fun DeviceList(devices: List) { + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { + DeviceCard( + name = stringResource(R.string.local_device), + icon = R.drawable.ic_smartphone_sound, + iconDescription = R.string.local_device_description + ) + Spacer(Modifier.size(8.dp)) + Text( + stringResource(R.string.devices_available), + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.h5 + ) + devices.forEach { device -> + DeviceCard( + name = device.friendlyName, + icon = R.drawable.ic_menu_devices + ) + } + } +} + +@Composable +fun DeviceCard( + name: String, + @StringRes + iconDescription: Int = R.string.device_image, + @DrawableRes + icon: Int = R.drawable.ic_menu_devices, + onClick: () -> Unit = {}) { Card( - modifier = Modifier.padding(8.dp), + modifier = Modifier.padding(8.dp).fillMaxWidth().clickable(onClick = onClick) ) { - Column { + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { Image( - painter = if (device.imageURL != null) { - rememberImagePainter(device.imageURL) { - placeholder(R.drawable.ic_artist) - } - } else { - painterResource(R.drawable.ic_artist) - }, - contentDescription = stringResource(R.string.device_image), - modifier = Modifier.fillMaxWidth().aspectRatio(1f), - contentScale = ContentScale.Crop, - ) - Text( - device.friendlyName, - maxLines = 1, - modifier = Modifier.padding(4.dp), - style = MaterialTheme.typography.subtitle1, - ) - Text( - device.type + painter = painterResource(icon), + contentDescription = stringResource(iconDescription), + modifier = Modifier.requiredSize(48.dp) ) + Column() { + Text( + name, + maxLines = 1, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.subtitle1 + ) + } } } } diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt index 98b3bd3a..c4419c5f 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt @@ -16,6 +16,13 @@ class DevicesViewModel @Inject constructor( ) : AndroidViewModel(application) { fun devices(): LiveData> = map(deviceManager.devices) { devices -> - devices.values.sortedWith(compareBy({ it.friendlyName }, { it.firstCharacter.uppercase() })) + devices.values.sortedWith(compareBy( + { when(it) { + is Device.Ready -> 1 + is Device.Failed -> 2 + is Device.Discovered -> 3 + }}, + { it.friendlyName }) + ) } } diff --git a/app/src/main/res/drawable/ic_smartphone_sound.xml b/app/src/main/res/drawable/ic_smartphone_sound.xml new file mode 100644 index 00000000..2967f542 --- /dev/null +++ b/app/src/main/res/drawable/ic_smartphone_sound.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4f41a75e..ecd075ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,4 +72,7 @@ No albums were released on this day No artists could be found DeviceView + Play on this device + Sound coming from your device + Stream to devices From 33e55a8d3ed86dc25190c80ca830127f2fa5cc0b Mon Sep 17 00:00:00 2001 From: Rien Maertens Date: Wed, 29 Dec 2021 10:50:55 +0100 Subject: [PATCH 6/6] Try to play soundbyte on remtoe device --- app/build.gradle | 4 + .../me/vanpetegem/accentor/devices/Device.kt | 48 +++++-- .../accentor/devices/DeviceManager.kt | 130 +++++++++++------- .../accentor/ui/devices/DevicesView.kt | 7 +- .../accentor/ui/devices/DevicesViewModel.kt | 15 +- .../accentor/ui/main/MainActivity.kt | 2 +- 6 files changed, 137 insertions(+), 69 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index aa749a1b..b39add0a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -60,6 +60,9 @@ android { textOutput "stdout" explainIssues !project.hasProperty("isCI") } + packagingOptions { + exclude "META-INF/beans.xml" + } } tasks.lint.dependsOn(ktlintCheck) @@ -133,6 +136,7 @@ dependencies { // Cling (UPnP/DLNA) implementation "org.fourthline.cling:cling-core:2.1.2" + implementation "org.fourthline.cling:cling-support:2.1.2" implementation "org.eclipse.jetty:jetty-servlet:8.2.0.v20160908" implementation "org.eclipse.jetty:jetty-client:8.2.0.v20160908" implementation "org.slf4j:slf4j-android:1.7.32" diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt b/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt index 5f33c9b3..0d0f70a3 100644 --- a/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt +++ b/app/src/main/java/me/vanpetegem/accentor/devices/Device.kt @@ -1,35 +1,63 @@ package me.vanpetegem.accentor.devices +import androidx.compose.foundation.lazy.rememberLazyListState import org.fourthline.cling.model.meta.RemoteDevice +import org.fourthline.cling.model.meta.RemoteService +import org.fourthline.cling.model.meta.Service +import org.fourthline.cling.model.types.ServiceType +import org.fourthline.cling.model.types.UDN import java.lang.Exception -sealed class Device( +val PLAYER_SERVICE = ServiceType("schemas-upnp-org", "AVTransport", 1); + +class Device( protected val clingDevice: RemoteDevice ) { val friendlyName: String = clingDevice.details.friendlyName val displayString: String = clingDevice.displayString val type: String = clingDevice.type.displayString + val udn: UDN = clingDevice.identity.udn val imageURL: String? = clingDevice .icons .maxWithOrNull(compareBy({ it.height * it.width }, { it.mimeType.subtype == "png" })) ?.let { clingDevice.normalizeURI(it.uri).toString() } + fun isPlayer(): Boolean { + return clingDevice.findServiceTypes().contains(PLAYER_SERVICE) + } + + fun isHydrated(): Boolean { + return playerService()?.hasActions() == true + } + + fun playerService(): RemoteService? { + return clingDevice.findService(PLAYER_SERVICE) + } + + override fun toString(): String { + return "Device($friendlyName, ${clingDevice.findServiceTypes().map { it.type }})" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false - class Ready(clingDevice: RemoteDevice): Device(clingDevice) {} + other as Device - class Failed(clingDevice: RemoteDevice, val exception: Exception?): Device(clingDevice) {} + if (udn != other.udn) return false - class Discovered(clingDevice: RemoteDevice): Device(clingDevice) { - fun failed(exception: Exception?): Failed { - return Failed(clingDevice, exception) - } + return true + } - fun ready(): Ready { - return Ready(clingDevice) - } + override fun hashCode(): Int { + return udn.hashCode() } + + } + + diff --git a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt index 4d0162ae..d5b7ea55 100644 --- a/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt +++ b/app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt @@ -5,22 +5,32 @@ import android.content.ComponentName import android.content.ServiceConnection import android.os.IBinder import android.util.Log -import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import org.fourthline.cling.android.AndroidUpnpService +import org.fourthline.cling.model.action.ActionInvocation +import org.fourthline.cling.model.message.UpnpResponse import org.fourthline.cling.model.message.header.ServiceTypeHeader import org.fourthline.cling.model.meta.RemoteDevice -import org.fourthline.cling.model.types.ServiceType +import org.fourthline.cling.model.meta.Service import org.fourthline.cling.model.types.UDN import org.fourthline.cling.registry.DefaultRegistryListener import org.fourthline.cling.registry.Registry +import org.fourthline.cling.support.avtransport.callback.GetDeviceCapabilities +import org.fourthline.cling.support.avtransport.callback.GetMediaInfo +import org.fourthline.cling.support.avtransport.callback.Play +import org.fourthline.cling.support.avtransport.callback.SetAVTransportURI +import org.fourthline.cling.support.avtransport.lastchange.AVTransportVariable +import org.fourthline.cling.support.model.DIDLContent +import org.fourthline.cling.support.model.DIDLObject +import org.fourthline.cling.support.model.DeviceCapabilities +import org.fourthline.cling.support.model.MediaInfo import javax.inject.Inject import javax.inject.Singleton @Singleton class DeviceManager @Inject constructor() { - val devices = MutableLiveData>(emptyMap()) + val playerDevices = MutableLiveData>(emptyMap()) val selectedDevice = MutableLiveData(null) val connection = DeviceServiceConnection() @@ -28,10 +38,64 @@ class DeviceManager @Inject constructor() { private lateinit var upnp: AndroidUpnpService private val isConnected = MutableLiveData(false) private val registryListener = DeviceRegistryListener() + private var discovered: Map = emptyMap() fun search() { - val playerService = ServiceTypeHeader(ServiceType("schemas-upnp-org", "AVTransport", 1)) - upnp.controlPoint.search(playerService) + upnp.controlPoint.search(ServiceTypeHeader(PLAYER_SERVICE)) + } + + fun select(device: Device) { + selectedDevice.postValue(device) + //val url = "http://10.0.0.15:8200/MediaItems/22.mp3" + val url = "https://rien.maertens.io/noot.mp3" + + + val action = SetURI(device, url) + val future = upnp.controlPoint.execute(action) + } + + inner class SetURI(val device: Device, uri: String): SetAVTransportURI(device.playerService(), uri) { + override fun success(invocation: ActionInvocation>?) { + super.success(invocation) + Log.e(TAG, "SetURI invocation succeeded: $invocation") + upnp.controlPoint.execute(Play(device)) + } + override fun failure(invocation: ActionInvocation>?, operation: UpnpResponse?, defaultMsg: String?) { + Log.e(TAG, "SetURI invocation failed: $defaultMsg") + } + } + + inner class Play(val device: Device): org.fourthline.cling.support.avtransport.callback.Play(device.playerService()) { + override fun success(invocation: ActionInvocation>?) { + super.success(invocation) + Log.e(TAG, "Play invocation succeeded: $invocation") + upnp.controlPoint.execute(GetInfo(device)) + } + override fun failure(invocation: ActionInvocation>?, operation: UpnpResponse?, defaultMsg: String?) { + Log.e(TAG, "Play invocation failed: $defaultMsg") + } + + } + + inner class GetInfo(val device: Device): GetMediaInfo(device.playerService()) { + override fun received(invocation: ActionInvocation>?, mediaInfo: MediaInfo?) { + Log.e(TAG, "GetInfo invocation succeeded: $invocation $mediaInfo") + } + + override fun failure(invocation: ActionInvocation>?, operation: UpnpResponse?, defaultMsg: String?) { + Log.e(TAG, "GetInfo invocation failed: $defaultMsg") + } + } + + inner class GetCapabilities(val device: Device): GetDeviceCapabilities(device.playerService()) { + override fun failure(invocation: ActionInvocation>?, operation: UpnpResponse?, defaultMsg: String?) { + Log.e(TAG, "GetCapabilities invocation failed: $defaultMsg") + } + + override fun received(actionInvocation: ActionInvocation>?, caps: DeviceCapabilities?) { + Log.e(TAG, "GetCapabilities invocation succeeded: $actionInvocation $caps") + } + } inner class DeviceServiceConnection() : ServiceConnection { @@ -40,11 +104,13 @@ class DeviceManager @Inject constructor() { isConnected.value = true // clear devices (if any) and collect the known remote devices into a map - devices.value = upnp.registry.devices + discovered = upnp.registry.devices .filterIsInstance() - .map { it.identity.udn to Device.Ready(it) } + .map { it.identity.udn to Device(it) } .toMap() + playerDevices.postValue(discovered.filter { it.value.isPlayer() }) + upnp.registry.addListener(registryListener) search() } @@ -56,52 +122,24 @@ class DeviceManager @Inject constructor() { private inner class DeviceRegistryListener(): DefaultRegistryListener() { - override fun remoteDeviceDiscoveryStarted(registry: Registry?, remote: RemoteDevice?) { + override fun remoteDeviceAdded(registry: Registry?, remote: RemoteDevice?) { val udn = remote!!.identity.udn - // this will only add a new device if not yet present in the map - devices.postValue(mapOf(udn to Device.Discovered(remote)) + devices.value!!) - } + val dev = Device(remote) + discovered = discovered + (udn to dev) + Log.i(TAG, "Device added: $dev") - override fun remoteDeviceDiscoveryFailed(registry: Registry?, remote: RemoteDevice?, ex: Exception?) { - val udn = remote!!.identity.udn - val known = devices.value!! - when(val dev = known[udn]) { - is Device.Discovered -> devices.postValue(known + (udn to dev.failed(ex))) - else -> Log.e(TAG, "Discovery failed of existing device", ex) + if (dev.isPlayer()) { + playerDevices.postValue(playerDevices.value!! + (udn to dev)) + Log.i(TAG,"Device added to players: $dev") } } - override fun remoteDeviceUpdated(registry: Registry?, remote: RemoteDevice?) { - if (devices.value!!.contains(remote!!.identity.udn)) { - // trigger an update - devices.postValue(devices.value) - } else { - Log.e(TAG, "Non-existing device updated") - } - } - - override fun remoteDeviceAdded(registry: Registry?, remote: RemoteDevice?) { - addDevice(remote!!) - } - override fun remoteDeviceRemoved(registry: Registry?, remote: RemoteDevice?) { - val withRemoved = devices.value!!.minus(remote!!.identity.udn) - devices.postValue(withRemoved) - } - - fun addDevice(remote: RemoteDevice) { - val udn = remote.identity.udn - val known = devices.value!! - if (udn in known) { - when (val dev = known[udn]) { - is Device.Discovered -> devices.postValue(known + (udn to dev.ready())) - else -> Log.e(TAG, "Device added twice, ignoring... ${remote.displayString} ($udn)") - } - } else { - devices.postValue(known + (udn to Device.Ready(remote))) - } + val udn = remote!!.identity.udn + Log.i(TAG, "Removing device ${remote.displayString} ($udn)") + playerDevices.postValue(playerDevices.value!! - udn) } } } -const val TAG: String = "DeviceManager" +const val TAG: String = "DeviceManagexr" diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt index 1da0a8ee..a1431cdc 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt @@ -25,11 +25,11 @@ import me.vanpetegem.accentor.devices.Device @Composable fun Devices(devicesViewModel: DevicesViewModel = hiltViewModel()) { val devices: List? by devicesViewModel.devices().observeAsState() - DeviceList(devices ?: emptyList()) + DeviceList(devices ?: emptyList(), selectFn = { devicesViewModel.selectDevice(it) }) } @Composable -fun DeviceList(devices: List) { +fun DeviceList(devices: List, selectFn: (d: Device) -> Unit = {}) { Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { DeviceCard( name = stringResource(R.string.local_device), @@ -45,7 +45,8 @@ fun DeviceList(devices: List) { devices.forEach { device -> DeviceCard( name = device.friendlyName, - icon = R.drawable.ic_menu_devices + icon = R.drawable.ic_menu_devices, + onClick = { selectFn(device) } ) } } diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt index c4419c5f..78c39ef8 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesViewModel.kt @@ -15,14 +15,11 @@ class DevicesViewModel @Inject constructor( private val deviceManager: DeviceManager, ) : AndroidViewModel(application) { - fun devices(): LiveData> = map(deviceManager.devices) { devices -> - devices.values.sortedWith(compareBy( - { when(it) { - is Device.Ready -> 1 - is Device.Failed -> 2 - is Device.Discovered -> 3 - }}, - { it.friendlyName }) - ) + fun devices(): LiveData> = map(deviceManager.playerDevices) { devices -> + devices.values.sortedWith(compareBy { it.friendlyName }) + } + + fun selectDevice(device: Device) { + deviceManager.select(device = device) } } diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt index e3834462..6f77d6ff 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt @@ -181,7 +181,7 @@ fun Content(mainViewModel: MainViewModel = viewModel(), playerViewModel: PlayerV AlbumView(entry.arguments!!.getInt("albumId"), navController, playerViewModel) } } - composable("devices") { Base(navController, mainViewModel) { Devices() } } + composable("devices") { Base(navController, mainViewModel, playerViewModel) { Devices() } } } } }