diff --git a/README.md b/README.md index 96e199a..e792766 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/app/src/main/java/be/digitalia/mediasession2mqtt/MainWorker.kt b/app/src/main/java/be/digitalia/mediasession2mqtt/MainWorker.kt index 014c199..42db3f3 100644 --- a/app/src/main/java/be/digitalia/mediasession2mqtt/MainWorker.kt +++ b/app/src/main/java/be/digitalia/mediasession2mqtt/MainWorker.kt @@ -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 @@ -43,7 +45,7 @@ class MainWorker @Inject constructor( private val playbackStateFlow: Flow = currentMediaControllerDetector.currentMediaController.flatMapLatest { mediaController -> when (mediaController) { - null -> flowOf(MQTTPlaybackState.idle) + null -> flowOf(MQTTPlaybackState.Idle) else -> mediaController.playbackStateFlow .map { it.toMQTTPlaybackStateOrNull() } .filterNotNull() @@ -51,12 +53,17 @@ class MainWorker @Inject constructor( }.distinctUntilChanged() @OptIn(ExperimentalCoroutinesApi::class) - private val mediaTitleFlow: Flow = + private val mediaMetadataFlow: Flow = 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() @@ -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 { @@ -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 ) } } @@ -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( @@ -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", @@ -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" ) ) } diff --git a/app/src/main/java/be/digitalia/mediasession2mqtt/homeassistant/Sensor.kt b/app/src/main/java/be/digitalia/mediasession2mqtt/homeassistant/Sensor.kt index ac01e5e..4b33310 100644 --- a/app/src/main/java/be/digitalia/mediasession2mqtt/homeassistant/Sensor.kt +++ b/app/src/main/java/be/digitalia/mediasession2mqtt/homeassistant/Sensor.kt @@ -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" diff --git a/app/src/main/java/be/digitalia/mediasession2mqtt/homeassistant/SensorConfiguration.kt b/app/src/main/java/be/digitalia/mediasession2mqtt/homeassistant/SensorConfiguration.kt index ae8fa84..3e47467 100644 --- a/app/src/main/java/be/digitalia/mediasession2mqtt/homeassistant/SensorConfiguration.kt +++ b/app/src/main/java/be/digitalia/mediasession2mqtt/homeassistant/SensorConfiguration.kt @@ -28,7 +28,9 @@ private fun JsonWriter.writeSensor( sensorName: String, sensorUniqueId: String, sensorIcon: String, - sensorTopic: String + sensorTopic: String, + sensorDeviceClass: String?, + sensorUnitOfMeasurement: String? ) { beginObject() @@ -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() } @@ -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() diff --git a/app/src/main/java/be/digitalia/mediasession2mqtt/mediasession/CurrentMediaControllerDetector.kt b/app/src/main/java/be/digitalia/mediasession2mqtt/mediasession/CurrentMediaControllerDetector.kt index 3eba64b..419ad01 100644 --- a/app/src/main/java/be/digitalia/mediasession2mqtt/mediasession/CurrentMediaControllerDetector.kt +++ b/app/src/main/java/be/digitalia/mediasession2mqtt/mediasession/CurrentMediaControllerDetector.kt @@ -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 @@ -20,10 +21,10 @@ class CurrentMediaControllerDetector @Inject constructor(private val mediaSessio private val activeControllersMap = hashMapOf() private val _isListening = MutableStateFlow(false) - val isListening = _isListening.asStateFlow() + val isListening: StateFlow = _isListening.asStateFlow() private val _currentMediaController = MutableStateFlow(null) - val currentMediaController = _currentMediaController.asStateFlow() + val currentMediaController: StateFlow = _currentMediaController.asStateFlow() private inner class MediaControllerCallback(val mediaController: MediaController) : MediaController.Callback() { override fun onSessionDestroyed() { @@ -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 } } diff --git a/app/src/main/java/be/digitalia/mediasession2mqtt/mqtt/KMQTTClient.kt b/app/src/main/java/be/digitalia/mediasession2mqtt/mqtt/KMQTTClient.kt index 02929c2..27b15cf 100644 --- a/app/src/main/java/be/digitalia/mediasession2mqtt/mqtt/KMQTTClient.kt +++ b/app/src/main/java/be/digitalia/mediasession2mqtt/mqtt/KMQTTClient.kt @@ -82,7 +82,7 @@ class KMQTTClient( if (client.isRunning()) { try { client.disconnect(ReasonCode.SUCCESS) - } catch (ignore: Exception) { + } catch (_: Exception) { } } currentClient = null diff --git a/app/src/main/java/be/digitalia/mediasession2mqtt/mqttmediaplayer/MQTTMediaMetadata.kt b/app/src/main/java/be/digitalia/mediasession2mqtt/mqttmediaplayer/MQTTMediaMetadata.kt new file mode 100644 index 0000000..95f3d31 --- /dev/null +++ b/app/src/main/java/be/digitalia/mediasession2mqtt/mqttmediaplayer/MQTTMediaMetadata.kt @@ -0,0 +1,6 @@ +package be.digitalia.mediasession2mqtt.mqttmediaplayer + +data class MQTTMediaMetadata( + val title: String = "", + val durationInMillis: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/mediasession2mqtt/mqttmediaplayer/MQTTPlaybackState.kt b/app/src/main/java/be/digitalia/mediasession2mqtt/mqttmediaplayer/MQTTPlaybackState.kt index d662e21..f92d394 100644 --- a/app/src/main/java/be/digitalia/mediasession2mqtt/mqttmediaplayer/MQTTPlaybackState.kt +++ b/app/src/main/java/be/digitalia/mediasession2mqtt/mqttmediaplayer/MQTTPlaybackState.kt @@ -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" + } } \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/mediasession2mqtt/mqttmediaplayer/PlayerConverters.kt b/app/src/main/java/be/digitalia/mediasession2mqtt/mqttmediaplayer/PlayerConverters.kt index 0275aa8..6c80d3a 100644 --- a/app/src/main/java/be/digitalia/mediasession2mqtt/mqttmediaplayer/PlayerConverters.kt +++ b/app/src/main/java/be/digitalia/mediasession2mqtt/mqttmediaplayer/PlayerConverters.kt @@ -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 } } @@ -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() } \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/mediasession2mqtt/settings/MessageSettings.kt b/app/src/main/java/be/digitalia/mediasession2mqtt/settings/MessageSettings.kt index 40982ac..051ac7b 100644 --- a/app/src/main/java/be/digitalia/mediasession2mqtt/settings/MessageSettings.kt +++ b/app/src/main/java/be/digitalia/mediasession2mqtt/settings/MessageSettings.kt @@ -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 ) \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/mediasession2mqtt/settings/SettingsProvider.kt b/app/src/main/java/be/digitalia/mediasession2mqtt/settings/SettingsProvider.kt index fd0debe..1bfca55 100644 --- a/app/src/main/java/be/digitalia/mediasession2mqtt/settings/SettingsProvider.kt +++ b/app/src/main/java/be/digitalia/mediasession2mqtt/settings/SettingsProvider.kt @@ -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 ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5d1ee5a..69eb618 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"