Skip to content
Merged
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ Note that the `buffering` state is intentionally not supported for the following
- Since buffering can happen at any time, adding this state makes it harder to detect transitions to and from the `playing` state;
- Buffering can not be considered as a sub-state of `playing` because some applications pre-buffer playback even before the user requests playing the content (e.g. Amazon Prime Video).

### mediaSession/{deviceId}/playbackPosition

The current playback position in milliseconds of the currently playing or paused media. Updated at the same time as the playback state.
In the `playing` state, the position won't be updated periodically: the difference between the current time and the playback position last update time must be added to this value to calculate the current playback position.
When no media is currently playing or paused, the value is an empty string (`""`).

### mediaSession/{deviceId}/applicationId

The Android application id of the currently active MediaSession, or an empty String (`""`) if no MediaSession is currently active.
Expand All @@ -121,6 +127,10 @@ The title of the currently playing or paused media, or an empty String (`""`) if

Note that many applications don't report any title, for example: Netflix, Disney+ for Android TV or Amazon Prime Video for Android TV.

### mediaSession/{deviceId}/mediaDuration

The duration of the currently playing or paused media in milliseconds, or an empty String (`""`) if no media is currently playing or paused or the duration is unavailable.

## A note about the Netflix app

The Netflix app reports the `playing` state right from the home screen, especially if video previews are enabled. To limit this effect, you can disable video previews in Netflix or add a condition in your home automation rules to ignore the action if the Netflix application id is detected.
Expand Down
51 changes: 43 additions & 8 deletions app/src/main/java/be/digitalia/mediasession2mqtt/MainWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import be.digitalia.mediasession2mqtt.mediasession.playbackStateFlow
import be.digitalia.mediasession2mqtt.mqtt.MQTTPublishClient
import be.digitalia.mediasession2mqtt.mqtt.MQTTQoSLevel
import be.digitalia.mediasession2mqtt.mqtt.tryConnectAndPublish
import be.digitalia.mediasession2mqtt.mqttmediaplayer.MQTTMediaMetadata
import be.digitalia.mediasession2mqtt.mqttmediaplayer.MQTTPlaybackState
import be.digitalia.mediasession2mqtt.mqttmediaplayer.toMQTTPlaybackStateOrNull
import be.digitalia.mediasession2mqtt.mqttmediaplayer.toMediaDurationInMillis
import be.digitalia.mediasession2mqtt.mqttmediaplayer.toMediaTitle
import be.digitalia.mediasession2mqtt.settings.SettingsProvider
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -43,20 +45,25 @@ class MainWorker @Inject constructor(
private val playbackStateFlow: Flow<MQTTPlaybackState> =
currentMediaControllerDetector.currentMediaController.flatMapLatest { mediaController ->
when (mediaController) {
null -> flowOf(MQTTPlaybackState.idle)
null -> flowOf(MQTTPlaybackState.Idle)
else -> mediaController.playbackStateFlow
.map { it.toMQTTPlaybackStateOrNull() }
.filterNotNull()
}
}.distinctUntilChanged()

@OptIn(ExperimentalCoroutinesApi::class)
private val mediaTitleFlow: Flow<String> =
private val mediaMetadataFlow: Flow<MQTTMediaMetadata> =
currentMediaControllerDetector.currentMediaController.flatMapLatest { mediaController ->
when (mediaController) {
null -> flowOf("")
null -> flowOf(MQTTMediaMetadata())
else -> mediaController.metadataFlow
.map { it.toMediaTitle() }
.map {
MQTTMediaMetadata(
title = it.toMediaTitle(),
durationInMillis = it.toMediaDurationInMillis()
)
}
}
}.distinctUntilChanged()

Expand All @@ -70,7 +77,7 @@ class MainWorker @Inject constructor(
launch { publishHassConfigurationIfEnabled(client, qosLevel, deviceId) }
launch { publishApplicationId(client, qosLevel, deviceId) }
launch { publishPlaybackState(client, qosLevel, deviceId) }
launch { publishMediaTitle(client, qosLevel, deviceId) }
launch { publishMediaMetadata(client, qosLevel, deviceId) }
}
}
} finally {
Expand Down Expand Up @@ -128,19 +135,29 @@ class MainWorker @Inject constructor(
"$ROOT_TOPIC/$deviceId/$PLAYBACK_STATE_SUB_TOPIC",
playbackState.name
)
client.tryConnectAndPublish(
qosLevel,
"$ROOT_TOPIC/$deviceId/$PLAYBACK_POSITION_SUB_TOPIC",
playbackState.positionInMillis
)
}
}

private suspend fun publishMediaTitle(
private suspend fun publishMediaMetadata(
client: MQTTPublishClient,
qosLevel: MQTTQoSLevel,
deviceId: Int
) {
mediaTitleFlow.collect { mediaTitle ->
mediaMetadataFlow.collect { mediaMetadata ->
client.tryConnectAndPublish(
qosLevel,
"$ROOT_TOPIC/$deviceId/$MEDIA_TITLE_SUB_TOPIC",
mediaTitle
mediaMetadata.title
)
client.tryConnectAndPublish(
qosLevel,
"$ROOT_TOPIC/$deviceId/$MEDIA_DURATION_SUB_TOPIC",
mediaMetadata.durationInMillis
)
}
}
Expand All @@ -155,7 +172,9 @@ class MainWorker @Inject constructor(
private const val ROOT_TOPIC = "mediaSession"
private const val APPLICATION_ID_SUB_TOPIC = "applicationId"
private const val PLAYBACK_STATE_SUB_TOPIC = "playbackState"
private const val PLAYBACK_POSITION_SUB_TOPIC = "playbackPosition"
private const val MEDIA_TITLE_SUB_TOPIC = "mediaTitle"
private const val MEDIA_DURATION_SUB_TOPIC = "mediaDuration"

private const val HASS_ROOT_TOPIC = "homeassistant"
private val HASS_SENSORS = listOf(
Expand All @@ -165,6 +184,14 @@ class MainWorker @Inject constructor(
icon = "mdi:play-pause",
subTopic = PLAYBACK_STATE_SUB_TOPIC
),
Sensor(
name = "Playback Position",
serializedName = "playback_position",
icon = "mdi:progress-clock",
subTopic = PLAYBACK_POSITION_SUB_TOPIC,
deviceClass = "duration",
unitOfMeasurement = "ms"
),
Sensor(
name = "Application Id",
serializedName = "application_id",
Expand All @@ -176,6 +203,14 @@ class MainWorker @Inject constructor(
serializedName = "media_title",
icon = "mdi:information",
subTopic = MEDIA_TITLE_SUB_TOPIC
),
Sensor(
name = "Media Duration",
serializedName = "media_duration",
icon = "mdi:clock",
subTopic = MEDIA_DURATION_SUB_TOPIC,
deviceClass = "duration",
unitOfMeasurement = "ms"
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ class Sensor(
val name: String,
private val serializedName: String,
val icon: String,
val subTopic: String
val subTopic: String,
val deviceClass: String? = null,
val unitOfMeasurement: String? = null
) {
val type: String
get() = "sensor"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ private fun JsonWriter.writeSensor(
sensorName: String,
sensorUniqueId: String,
sensorIcon: String,
sensorTopic: String
sensorTopic: String,
sensorDeviceClass: String?,
sensorUnitOfMeasurement: String?
) {
beginObject()

Expand All @@ -42,6 +44,16 @@ private fun JsonWriter.writeSensor(
value(sensorTopic)
name("device")
writeDeviceInfo(deviceId)
name("device")
writeDeviceInfo(deviceId)
sensorDeviceClass?.let {
name("device_class")
value(it)
}
sensorUnitOfMeasurement?.let {
name("unit_of_measurement")
value(it)
}

endObject()
}
Expand All @@ -54,7 +66,9 @@ fun createSensorDiscoveryConfiguration(deviceId: Int, sensor: Sensor, sensorTopi
sensorName = sensor.name,
sensorUniqueId = sensor.getUniqueId(deviceId),
sensorIcon = sensor.icon,
sensorTopic = sensorTopic
sensorTopic = sensorTopic,
sensorDeviceClass = sensor.deviceClass,
sensorUnitOfMeasurement = sensor.unitOfMeasurement
)
}
return writer.toString()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.media.session.MediaSession
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -20,10 +21,10 @@ class CurrentMediaControllerDetector @Inject constructor(private val mediaSessio
private val activeControllersMap = hashMapOf<MediaSession.Token, MediaControllerCallback>()

private val _isListening = MutableStateFlow(false)
val isListening = _isListening.asStateFlow()
val isListening: StateFlow<Boolean> = _isListening.asStateFlow()

private val _currentMediaController = MutableStateFlow<MediaController?>(null)
val currentMediaController = _currentMediaController.asStateFlow()
val currentMediaController: StateFlow<MediaController?> = _currentMediaController.asStateFlow()

private inner class MediaControllerCallback(val mediaController: MediaController) : MediaController.Callback() {
override fun onSessionDestroyed() {
Expand Down Expand Up @@ -54,7 +55,7 @@ class CurrentMediaControllerDetector @Inject constructor(private val mediaSessio
activeSessionsListener,
componentName
)
} catch (ignore: SecurityException) {
} catch (_: SecurityException) {
// No permission granted to listen to notifications
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ class KMQTTClient(
if (client.isRunning()) {
try {
client.disconnect(ReasonCode.SUCCESS)
} catch (ignore: Exception) {
} catch (_: Exception) {
}
}
currentClient = null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package be.digitalia.mediasession2mqtt.mqttmediaplayer

data class MQTTMediaMetadata(
val title: String = "",
val durationInMillis: String = ""
)
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
package be.digitalia.mediasession2mqtt.mqttmediaplayer

enum class MQTTPlaybackState {
idle,
playing,
paused
sealed interface MQTTPlaybackState {
val name: String
val positionInMillis: String

data object Idle : MQTTPlaybackState {
override val name: String
get() = "idle"
override val positionInMillis: String
get() = ""
}

data class Playing(override val positionInMillis: String) : MQTTPlaybackState {
override val name: String
get() = "playing"
}

data class Paused(override val positionInMillis: String) : MQTTPlaybackState {
override val name: String
get() = "paused"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ fun PlaybackState?.toMQTTPlaybackStateOrNull(): MQTTPlaybackState? {
return null
}
return when (state) {
PlaybackState.STATE_NONE, PlaybackState.STATE_STOPPED, PlaybackState.STATE_ERROR -> MQTTPlaybackState.idle
PlaybackState.STATE_PLAYING -> MQTTPlaybackState.playing
PlaybackState.STATE_PAUSED -> MQTTPlaybackState.paused
PlaybackState.STATE_NONE, PlaybackState.STATE_STOPPED, PlaybackState.STATE_ERROR -> MQTTPlaybackState.Idle
PlaybackState.STATE_PLAYING -> MQTTPlaybackState.Playing(position.toString())
PlaybackState.STATE_PAUSED -> MQTTPlaybackState.Paused(position.toString())
else -> null
}
}
Expand All @@ -40,4 +40,15 @@ fun MediaMetadata?.toMediaTitle(): String {
// If we have a title, check if we also have an artist
val artist = getString(MediaMetadata.METADATA_KEY_ARTIST)
return if (artist.isNullOrEmpty()) title else "$artist - $title"
}

/**
* Extract the media duration in milliseconds as a String, or return an empty String if unavailable.
*/
fun MediaMetadata?.toMediaDurationInMillis(): String {
if (this == null) {
return ""
}
val duration = getLong(MediaMetadata.METADATA_KEY_DURATION)
return if (duration == 0L) "" else duration.toString()
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ package be.digitalia.mediasession2mqtt.settings
import be.digitalia.mediasession2mqtt.mqtt.MQTTQoSLevel

data class MessageSettings(
val qosLevel: MQTTQoSLevel, val deviceId: Int
val qosLevel: MQTTQoSLevel,
val deviceId: Int
)
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ class SettingsProvider @Inject constructor(context: Context) {
val deviceId = getString(PreferenceKeys.DEVICE_ID, null).orEmpty().toIntOrNull()
?: DEFAULT_DEVICE_ID
return MessageSettings(
qosLevel = MQTTQoSLevel.entries[qosLevel], deviceId = deviceId
qosLevel = MQTTQoSLevel.entries[qosLevel],
deviceId = deviceId
)
}

Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[versions]
androidGradlePlugin = "8.13.0"
dagger = "2.57.1"
dagger = "2.57.2"
kmqtt = "1.0.0"
kotlin = "2.2.20"
kotlinx-coroutines = "1.10.2"
Expand Down