Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow casting music to networked devices #239

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ android {
textOutput "stdout"
explainIssues !project.hasProperty("isCI")
}
packagingOptions {
exclude "META-INF/beans.xml"
}
}

tasks.lint.dependsOn(ktlintCheck)
Expand Down Expand Up @@ -131,6 +134,13 @@ 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.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"

// Tests
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test:runner:1.4.0'
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- for upnp support -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

<application
android:label="@string/app_name"
Expand Down Expand Up @@ -40,6 +43,10 @@
<action android:name="androidx.media2.session.MediaSessionService" />
</intent-filter>
</service>

<service
android:name=".devices.DeviceService"
android:exported="false" />
</application>

</manifest>
63 changes: 63 additions & 0 deletions app/src/main/java/me/vanpetegem/accentor/devices/Device.kt
Original file line number Diff line number Diff line change
@@ -0,0 +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

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

other as Device

if (udn != other.udn) return false

return true
}

override fun hashCode(): Int {
return udn.hashCode()
}


}




145 changes: 145 additions & 0 deletions app/src/main/java/me/vanpetegem/accentor/devices/DeviceManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package me.vanpetegem.accentor.devices


import android.content.ComponentName
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
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.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 playerDevices = MutableLiveData<Map<UDN, Device>>(emptyMap())
val selectedDevice = MutableLiveData<Device?>(null)

val connection = DeviceServiceConnection()

private lateinit var upnp: AndroidUpnpService
private val isConnected = MutableLiveData(false)
private val registryListener = DeviceRegistryListener()
private var discovered: Map<UDN, Device> = emptyMap()

fun search() {
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<out Service<*, *>>?) {
super.success(invocation)
Log.e(TAG, "SetURI invocation succeeded: $invocation")
upnp.controlPoint.execute(Play(device))
}
override fun failure(invocation: ActionInvocation<out Service<*, *>>?, 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<out Service<*, *>>?) {
super.success(invocation)
Log.e(TAG, "Play invocation succeeded: $invocation")
upnp.controlPoint.execute(GetInfo(device))
}
override fun failure(invocation: ActionInvocation<out Service<*, *>>?, 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<out Service<*, *>>?, mediaInfo: MediaInfo?) {
Log.e(TAG, "GetInfo invocation succeeded: $invocation $mediaInfo")
}

override fun failure(invocation: ActionInvocation<out Service<*, *>>?, 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<out Service<*, *>>?, operation: UpnpResponse?, defaultMsg: String?) {
Log.e(TAG, "GetCapabilities invocation failed: $defaultMsg")
}

override fun received(actionInvocation: ActionInvocation<out Service<*, *>>?, caps: DeviceCapabilities?) {
Log.e(TAG, "GetCapabilities invocation succeeded: $actionInvocation $caps")
}

}

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
discovered = upnp.registry.devices
.filterIsInstance<RemoteDevice>()
.map { it.identity.udn to Device(it) }
.toMap()

playerDevices.postValue(discovered.filter { it.value.isPlayer() })

upnp.registry.addListener(registryListener)
search()
}

override fun onServiceDisconnected(className: ComponentName?) {
isConnected.value = false
}
}

private inner class DeviceRegistryListener(): DefaultRegistryListener() {

override fun remoteDeviceAdded(registry: Registry?, remote: RemoteDevice?) {
val udn = remote!!.identity.udn
val dev = Device(remote)
discovered = discovered + (udn to dev)
Log.i(TAG, "Device added: $dev")

if (dev.isPlayer()) {
playerDevices.postValue(playerDevices.value!! + (udn to dev))
Log.i(TAG,"Device added to players: $dev")
}
}

override fun remoteDeviceRemoved(registry: Registry?, remote: RemoteDevice?) {
val udn = remote!!.identity.udn
Log.i(TAG, "Removing device ${remote.displayString} ($udn)")
playerDevices.postValue(playerDevices.value!! - udn)
}
}
}

const val TAG: String = "DeviceManagexr"
20 changes: 20 additions & 0 deletions app/src/main/java/me/vanpetegem/accentor/devices/DeviceService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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.UDA10ServiceDescriptorBinderImpl

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()
}
}
}

}
86 changes: 86 additions & 0 deletions app/src/main/java/me/vanpetegem/accentor/ui/devices/DevicesView.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package me.vanpetegem.accentor.ui.devices

import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import me.vanpetegem.accentor.R
import me.vanpetegem.accentor.devices.Device

@Composable
fun Devices(devicesViewModel: DevicesViewModel = hiltViewModel()) {
val devices: List<Device>? by devicesViewModel.devices().observeAsState()
DeviceList(devices ?: emptyList(), selectFn = { devicesViewModel.selectDevice(it) })
}

@Composable
fun DeviceList(devices: List<Device>, selectFn: (d: Device) -> Unit = {}) {
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,
onClick = { selectFn(device) }
)
}
}
}

@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).fillMaxWidth().clickable(onClick = onClick)
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Image(
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
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package me.vanpetegem.accentor.ui.devices

import android.app.Application
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.DeviceManager
import javax.inject.Inject

@HiltViewModel
class DevicesViewModel @Inject constructor(
application: Application,
private val deviceManager: DeviceManager,
) : AndroidViewModel(application) {

fun devices(): LiveData<List<Device>> = map(deviceManager.playerDevices) { devices ->
devices.values.sortedWith(compareBy { it.friendlyName })
}

fun selectDevice(device: Device) {
deviceManager.select(device = device)
}
}
Loading