From d7f97fd66911f70fd27454a705a87c48b226f239 Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Thu, 21 May 2026 14:24:28 +0530 Subject: [PATCH] feat: Implement Seekbar sync for now playing [2/2] Handle mediaControl seekTo commands on Android and sync playback position metadata back to macOS. - add seekTo transport control support - include duration, position, timestamp, and buffering in status payloads - update sync logic so seekbar state can reconcile after remote scrubs --- .../airsync/domain/model/DeviceStatus.kt | 10 ++++++- .../service/MediaNotificationListener.kt | 18 ++++++++++--- .../sameerasw/airsync/utils/DeviceInfoUtil.kt | 16 +++++++++-- .../com/sameerasw/airsync/utils/JsonUtil.kt | 8 ++++-- .../airsync/utils/MediaControlUtil.kt | 20 ++++++++++++++ .../sameerasw/airsync/utils/SyncManager.kt | 5 +++- .../airsync/utils/WebSocketMessageHandler.kt | 27 ++++++++++++------- 7 files changed, 85 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt index 6d942489..5e6efe6a 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt @@ -13,6 +13,10 @@ data class AudioInfo( val isMuted: Boolean, val albumArt: String? = null, val albumArtLite: String? = null, + val durationMs: Long = 0L, + val positionMs: Long = 0L, + val positionTimestampMs: Long = 0L, + val isBuffering: Boolean = false, // New: like status for current media ("liked", "not_liked", or "none") val likeStatus: String = "none" ) @@ -23,6 +27,10 @@ data class MediaInfo( val artist: String, val albumArt: String? = null, val albumArtLite: String? = null, + val durationMs: Long = 0L, + val positionMs: Long = 0L, + val positionTimestampMs: Long = 0L, + val isBuffering: Boolean = false, // New: like status for current media ("liked", "not_liked", or "none") val likeStatus: String = "none" -) \ No newline at end of file +) diff --git a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt index 13366288..40ab1648 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -86,7 +86,7 @@ class MediaNotificationListener : NotificationListenerService() { fun getMediaInfo(context: Context): MediaInfo { // Respect global toggle; if disabled, return empty media if (!isNowPlayingEnabled) { - return MediaInfo(false, "", "", null, "none") + return MediaInfo(false, "", "", null, null, 0L, 0L, 0L, false, "none") } return try { val mediaSessionManager = @@ -119,6 +119,14 @@ class MediaNotificationListener : NotificationListenerService() { val title = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "" val artist = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "" val isPlaying = playbackState?.state == PlaybackState.STATE_PLAYING + val durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L + val positionMs = playbackState?.position ?: 0L + val positionTimestampMs = System.currentTimeMillis() + val isBuffering = when (playbackState?.state) { + PlaybackState.STATE_BUFFERING, + PlaybackState.STATE_CONNECTING -> true + else -> false + } val albumArtBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) @@ -166,6 +174,10 @@ class MediaNotificationListener : NotificationListenerService() { artist = artist, albumArt = albumArtBase64, albumArtLite = albumArtLiteBase64, + durationMs = durationMs, + positionMs = positionMs, + positionTimestampMs = positionTimestampMs, + isBuffering = isBuffering, likeStatus = likeStatus ) } @@ -181,10 +193,10 @@ class MediaNotificationListener : NotificationListenerService() { } // Log.d(TAG, "No media info found") - MediaInfo(false, "", "", null, "none") + MediaInfo(false, "", "", null, null, 0L, 0L, 0L, false, "none") } catch (e: Exception) { Log.e(TAG, "Error getting media info: ${e.message}") - MediaInfo(false, "", "", null, "none") + MediaInfo(false, "", "", null, null, 0L, 0L, 0L, false, "none") } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt index 0658b703..49d006cd 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt @@ -152,6 +152,10 @@ object DeviceInfoUtil { isMuted = isMuted, albumArt = null, albumArtLite = null, + durationMs = 0L, + positionMs = 0L, + positionTimestampMs = 0L, + isBuffering = false, likeStatus = "none" ) } @@ -168,11 +172,15 @@ object DeviceInfoUtil { isMuted = isMuted, albumArt = mediaInfo.albumArt, albumArtLite = mediaInfo.albumArtLite, + durationMs = mediaInfo.durationMs, + positionMs = mediaInfo.positionMs, + positionTimestampMs = mediaInfo.positionTimestampMs, + isBuffering = mediaInfo.isBuffering, likeStatus = mediaInfo.likeStatus ) } catch (e: Exception) { Log.e("DeviceInfoUtil", "Error getting audio info: ${e.message}") - AudioInfo(false, "", "", 0, true, null, "none") + AudioInfo(false, "", "", 0, true, null, null, 0L, 0L, 0L, false, "none") } } @@ -191,6 +199,10 @@ object DeviceInfoUtil { isMuted = audioInfo.isMuted, albumArt = audioInfo.albumArt, albumArtLite = audioInfo.albumArtLite, + duration = audioInfo.durationMs, + position = audioInfo.positionMs, + positionTimestamp = audioInfo.positionTimestampMs, + isBuffering = audioInfo.isBuffering, likeStatus = audioInfo.likeStatus ) } @@ -215,4 +227,4 @@ object DeviceInfoUtil { val powerManager = context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager return powerManager?.isPowerSaveMode == true } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt index b8466fb0..0ee5ca05 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt @@ -157,11 +157,15 @@ object JsonUtil { isMuted: Boolean, albumArt: String?, albumArtLite: String? = null, + duration: Long = 0L, + position: Long = 0L, + positionTimestamp: Long = 0L, + isBuffering: Boolean = false, likeStatus: String ): String { val albumArtJson = if (albumArt != null) ",\"albumArt\":\"$albumArt\"" else "" val albumArtLiteJson = if (albumArtLite != null) ",\"albumArtLite\":\"$albumArtLite\"" else "" - return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson$albumArtLiteJson,"likeStatus":"$likeStatus"}}}""" + return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson$albumArtLiteJson,"duration":$duration,"position":$position,"positionTimestamp":$positionTimestamp,"isBuffering":$isBuffering,"likeStatus":"$likeStatus"}}}""" } /** @@ -266,4 +270,4 @@ object JsonUtil { ) }"}}""" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt index aaa0b7f6..6fc55738 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt @@ -100,6 +100,26 @@ object MediaControlUtil { } } + /** + * Seek active media playback to an absolute position in milliseconds. + */ + fun seekTo(context: Context, positionMs: Long): Boolean { + return try { + val controller = getActiveMediaController(context) + if (controller != null) { + controller.transportControls.seekTo(positionMs.coerceAtLeast(0L)) + Log.d(TAG, "Seeked media to ${positionMs}ms") + true + } else { + Log.w(TAG, "No active media controller for seek") + false + } + } catch (e: Exception) { + Log.e(TAG, "Error in seekTo: ${e.message}") + false + } + } + /** * Toggle like status by invoking the Like/Unlike action in the active media notification. */ diff --git a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt index 3e3b7c8c..e3fe5cb9 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -95,6 +95,9 @@ object SyncManager { last.artist != currentAudio.artist || last.volume != currentAudio.volume || last.isMuted != currentAudio.isMuted || + last.isBuffering != currentAudio.isBuffering || + last.durationMs != currentAudio.durationMs || + kotlin.math.abs(last.positionMs - currentAudio.positionMs) >= 5_000L || last.likeStatus != currentAudio.likeStatus ) { shouldSync = true @@ -618,4 +621,4 @@ object SyncManager { lastBatteryInfo = null lastVolume = -1 } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt index cfaf25d9..09961f41 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -212,7 +212,7 @@ object WebSocketMessageHandler { } /** - * Handles media control commands (play/pause, next, previous, like). + * Handles media control commands (play/pause, seek, next, previous, like). * Sends a response back to Mac and updates local media state after a short delay. */ private fun handleMediaControl(context: Context, data: JSONObject?) { @@ -243,6 +243,13 @@ object WebSocketMessageHandler { message = if (success) "Playback paused" else "Failed to pause playback" } + "seekTo" -> { + val positionMs = data.optLong("positionMs", -1L) + success = positionMs >= 0L && MediaControlUtil.seekTo(context, positionMs) + message = + if (success) "Seeked to ${positionMs}ms" else "Failed to seek playback" + } + "next" -> { // Suppress automatic media updates before executing skip command SyncManager.suppressMediaUpdatesForSkip() @@ -289,14 +296,15 @@ object WebSocketMessageHandler { // Send updated media state after successful control if (success) { - // For track skip actions (next/previous), add a delay to allow media player to update - CoroutineScope(Dispatchers.IO).launch { - val delayMs = when (action) { - "next", "previous" -> 1200L - else -> 400L // smaller delay for like/others - } - delay(delayMs) - SyncManager.onMediaStateChanged(context) + // For track skip actions (next/previous), add a delay to allow media player to update + CoroutineScope(Dispatchers.IO).launch { + val delayMs = when (action) { + "seekTo" -> 650L + "next", "previous" -> 1200L + else -> 400L // smaller delay for like/others + } + delay(delayMs) + SyncManager.onMediaStateChanged(context) } } } catch (e: Exception) { @@ -915,4 +923,3 @@ object WebSocketMessageHandler { } } } -