From 6bcb031fbd0e8095823d56cc59557f02a6b0c8b0 Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Wed, 1 Apr 2026 18:55:07 +0530 Subject: [PATCH] feat: Implement Seekbar sync for now playing [2/2] --- .../airsync/domain/model/DeviceStatus.kt | 17 +++-- .../service/MediaNotificationListener.kt | 58 +++++++++++++---- .../sameerasw/airsync/utils/DeviceInfoUtil.kt | 12 +++- .../com/sameerasw/airsync/utils/JsonUtil.kt | 13 +++- .../airsync/utils/MediaControlUtil.kt | 21 +++++++ .../sameerasw/airsync/utils/SyncManager.kt | 62 ++++++++++++++++--- .../airsync/utils/WebSocketMessageHandler.kt | 14 +++++ 7 files changed, 169 insertions(+), 28 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 22ad0224..a38ecc9c 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 @@ -12,8 +12,14 @@ data class AudioInfo( val volume: Int, val isMuted: Boolean, val albumArt: String? = null, - // New: like status for current media ("liked", "not_liked", or "none") - val likeStatus: String = "none" + val likeStatus: String = "none", + val durationMs: Long = -1L, + val positionMs: Long = -1L, + // True when the media session is in STATE_BUFFERING (position not advancing). + val isBuffering: Boolean = false, + // System.currentTimeMillis() at the moment positionMs was captured, + // so the Mac can compensate for network transit time. + val positionTimestampMs: Long = -1L ) data class MediaInfo( @@ -21,6 +27,9 @@ data class MediaInfo( val title: String, val artist: String, val albumArt: String? = null, - // New: like status for current media ("liked", "not_liked", or "none") - val likeStatus: String = "none" + val likeStatus: String = "none", + val durationMs: Long = -1L, + val positionMs: Long = -1L, + val isBuffering: Boolean = false, + val positionTimestampMs: Long = -1L ) \ 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 0ae06ea9..e3adf562 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -118,7 +118,39 @@ 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 playbackStateCode = playbackState?.state ?: PlaybackState.STATE_NONE + val isPlaying = playbackStateCode == PlaybackState.STATE_PLAYING + val isBuffering = playbackStateCode == PlaybackState.STATE_BUFFERING + + // Seekbar: duration from metadata, position from playback state. + val durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: -1L + + // Capture the raw frozen position plus the wall-clock instant it was read. + // The Mac will apply the (now - captureTime) delta itself, which also + // compensates for WiFi transit time — better accuracy than pre-computing here. + var positionMs = playbackState?.position ?: -1L + var positionTimestampMs = -1L + if (positionMs >= 0 && playbackState != null) { + // Map elapsedRealtime of the last state update → wall-clock ms + val elapsedAtUpdate = playbackState.lastPositionUpdateTime + val elapsedNow = android.os.SystemClock.elapsedRealtime() + val wallNow = System.currentTimeMillis() + // Wall-clock instant when playbackState.position was last set + val wallAtUpdate = wallNow - (elapsedNow - elapsedAtUpdate) + // Advance position to now (if playing) or leave frozen (if paused/buffering) + if (isPlaying) { + val timeDelta = elapsedNow - elapsedAtUpdate + val speed = playbackState.playbackSpeed + if (timeDelta > 0 && speed > 0) { + positionMs += (timeDelta * speed).toLong() + } + } + // Record the wall-clock time for when this positionMs snapshot is valid. + // If playing, positionMs is "now", so timestamp = wallNow. + // If paused/buffering, positionMs is frozen at wallAtUpdate — but the Mac + // won't advance it further (isPlaying/isBuffering guards handle that). + positionTimestampMs = if (isPlaying) wallNow else wallAtUpdate + } val albumArtBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) @@ -126,22 +158,16 @@ class MediaNotificationListener : NotificationListenerService() { val albumArtBase64 = albumArtBitmap?.let { val outputStream = ByteArrayOutputStream() - // Compress to a smaller size to avoid large payloads it.compress(Bitmap.CompressFormat.JPEG, 50, outputStream) Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) } - - // Log.d(TAG, "Media session - Title: $title, Artist: $artist, Playing: $isPlaying, State: ${playbackState?.state}") - - // Determine like status; apply app filter and strict positive-only like detection + // Determine like status val (detectedStatus, source) = determineLikeStatusWithSource( context, controller ) var likeStatus = detectedStatus - - // If filtered by app, force none and skip cache if (source == "appfilter") { likeStatus = "none" } @@ -153,7 +179,11 @@ class MediaNotificationListener : NotificationListenerService() { title = title, artist = artist, albumArt = albumArtBase64, - likeStatus = likeStatus + likeStatus = likeStatus, + durationMs = durationMs, + positionMs = positionMs, + isBuffering = isBuffering, + positionTimestampMs = positionTimestampMs ) } } @@ -444,7 +474,11 @@ class MediaNotificationListener : NotificationListenerService() { // If media info changed, trigger sync if (previousMediaInfo != currentMediaInfo) { Log.d(TAG, "Media info changed, triggering sync") - SyncManager.onMediaStateChanged(this) + // Bypass suppression for play/pause AND buffering changes so the Mac + // stops its timer immediately (not after the 2.5s suppression window). + val isStateChanged = previousMediaInfo?.isPlaying != currentMediaInfo?.isPlaying + || previousMediaInfo?.isBuffering != currentMediaInfo?.isBuffering + SyncManager.onMediaStateChanged(this, isPlayingChanged = isStateChanged) } } else { Log.d( @@ -490,7 +524,9 @@ class MediaNotificationListener : NotificationListenerService() { // If media info changed, trigger sync if (previousMediaInfo != currentMediaInfo) { Log.d(TAG, "Media info changed after notification removal, triggering sync") - SyncManager.onMediaStateChanged(this) + val isStateChanged = previousMediaInfo?.isPlaying != currentMediaInfo?.isPlaying + || previousMediaInfo?.isBuffering != currentMediaInfo?.isBuffering + SyncManager.onMediaStateChanged(this, isPlayingChanged = isStateChanged) } } else { Log.d( 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 8aac4a56..0b419255 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt @@ -166,7 +166,11 @@ object DeviceInfoUtil { volume = volumePercent, isMuted = isMuted, albumArt = mediaInfo.albumArt, - likeStatus = mediaInfo.likeStatus + likeStatus = mediaInfo.likeStatus, + durationMs = mediaInfo.durationMs, + positionMs = mediaInfo.positionMs, + isBuffering = mediaInfo.isBuffering, + positionTimestampMs = mediaInfo.positionTimestampMs ) } catch (e: Exception) { Log.e("DeviceInfoUtil", "Error getting audio info: ${e.message}") @@ -188,7 +192,11 @@ object DeviceInfoUtil { volume = audioInfo.volume, isMuted = audioInfo.isMuted, albumArt = audioInfo.albumArt, - likeStatus = audioInfo.likeStatus + likeStatus = audioInfo.likeStatus, + durationMs = audioInfo.durationMs, + positionMs = audioInfo.positionMs, + isBuffering = audioInfo.isBuffering, + positionTimestampMs = audioInfo.positionTimestampMs ) } 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 99bb5afb..08210968 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt @@ -144,10 +144,19 @@ object JsonUtil { volume: Int, isMuted: Boolean, albumArt: String?, - likeStatus: String + likeStatus: String, + // Seekbar fields — omitted from JSON when unknown (-1) for backwards compat + durationMs: Long = -1L, + positionMs: Long = -1L, + isBuffering: Boolean = false, + positionTimestampMs: Long = -1L ): String { val albumArtJson = if (albumArt != null) ",\"albumArt\":\"$albumArt\"" else "" - return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson,"likeStatus":"$likeStatus"}}}""" + val durationJson = if (durationMs >= 0) ",\"duration\":$durationMs" else "" + val positionJson = if (positionMs >= 0) ",\"position\":$positionMs" else "" + val bufferingJson = if (isBuffering) ",\"isBuffering\":true" else "" + val timestampJson = if (positionTimestampMs >= 0) ",\"positionTimestamp\":$positionTimestampMs" else "" + return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired,"music":{"isPlaying":$isPlaying,"title":"${escape(title)}","artist":"${escape(artist)}","volume":$volume,"isMuted":$isMuted$albumArtJson,"likeStatus":"$likeStatus"$durationJson$positionJson$bufferingJson$timestampJson}}}""" } /** 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..291899fb 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,27 @@ object MediaControlUtil { } } + /** + * Seek to a specific position in the current media. + * @param positionMs Position in milliseconds. + */ + fun seekTo(context: Context, positionMs: Long): Boolean { + return try { + val controller = getActiveMediaController(context) + if (controller != null) { + controller.transportControls.seekTo(positionMs) + Log.d(TAG, "Seeked to $positionMs ms") + true + } else { + Log.w(TAG, "No active media controller to 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 4f1ea1cb..f4ec58db 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -23,6 +23,7 @@ object SyncManager { private var lastAudioInfo: AudioInfo? = null private var lastBatteryInfo: BatteryInfo? = null private var lastVolume: Int = -1 + private var lastSyncTimeMs: Long = 0 // wall-clock ms of the last successful status send private val isSyncing = AtomicBoolean(false) // Track skip suppression mechanism @@ -30,6 +31,11 @@ object SyncManager { private var skipCommandTimestamp: Long = 0 private const val SKIP_SUPPRESSION_DURATION = 1000L // 1 second suppression after skip command + // Track seek suppression mechanism + @Volatile + private var seekCommandTimestamp: Long = 0 + private const val SEEK_SUPPRESSION_DURATION = 2500L // 2.5 second suppression after seek command + fun startPeriodicSync(context: Context) { if (isSyncing.get()) { Log.d(TAG, "Sync already running") @@ -69,6 +75,7 @@ object SyncManager { lastAudioInfo = null lastBatteryInfo = null lastVolume = -1 + lastSyncTimeMs = 0 } fun checkAndSyncDeviceStatus(context: Context, forceSync: Boolean = false) { @@ -82,18 +89,36 @@ object SyncManager { var shouldSync = forceSync - // Check if audio-related info changed + // Check if audio-related info changed. lastAudioInfo?.let { last -> if (last.isPlaying != currentAudio.isPlaying || last.title != currentAudio.title || last.artist != currentAudio.artist || last.volume != currentAudio.volume || last.isMuted != currentAudio.isMuted || - last.likeStatus != currentAudio.likeStatus + last.likeStatus != currentAudio.likeStatus || + last.durationMs != currentAudio.durationMs || + last.isBuffering != currentAudio.isBuffering ) { shouldSync = true Log.d(TAG, "Audio info changed, syncing device status") } + + // Position-jump detection: if positionMs is more than 8 seconds away from + // what we'd expect based on normal playback since the last sync, the user + // seeked on Android — trigger an immediate sync so the Mac can update. + // Guard: lastSyncTimeMs == 0 means we haven't done a successful sync yet + // (can happen if performInitialSync failed). Skip detection to avoid a + // huge elapsedMs causing a spurious forced sync. + if (!shouldSync && currentAudio.isPlaying && last.positionMs >= 0 && currentAudio.positionMs >= 0 && lastSyncTimeMs > 0) { + val elapsedMs = System.currentTimeMillis() - lastSyncTimeMs + val expectedPositionMs = last.positionMs + elapsedMs + val positionDelta = kotlin.math.abs(currentAudio.positionMs - expectedPositionMs) + if (positionDelta > 8_000L) { + shouldSync = true + Log.d(TAG, "Position jump detected (delta=${positionDelta}ms), syncing") + } + } } ?: run { shouldSync = true // First time } @@ -115,10 +140,10 @@ object SyncManager { val success = WebSocketUtil.sendMessage(statusJson) if (success) { - // Log.d(TAG, "Device status synced successfully") lastAudioInfo = currentAudio lastBatteryInfo = currentBattery lastVolume = currentAudio.volume + lastSyncTimeMs = System.currentTimeMillis() } else { Log.w(TAG, "Failed to sync device status") } @@ -247,9 +272,11 @@ object SyncManager { val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context) if (WebSocketUtil.sendMessage(statusJson)) { Log.d(TAG, "Device status sent") - // Update cache + // Update cache — also set lastSyncTimeMs so the position-jump detector + // has a valid baseline and doesn't trigger a spurious sync on the next check. lastAudioInfo = DeviceInfoUtil.getAudioInfo(context, includeNowPlaying) lastBatteryInfo = DeviceInfoUtil.getBatteryInfo(context) + lastSyncTimeMs = System.currentTimeMillis() } else { Log.e(TAG, "Failed to send device status") } @@ -583,17 +610,34 @@ object SyncManager { } /** - * Check if media updates should be suppressed due to recent skip command + * Call this before executing a seek command to suppress the stale onPlaybackStateChanged + * callback that Android fires immediately after seekTo() with the old position. + */ + fun suppressMediaUpdatesForSeek() { + seekCommandTimestamp = System.currentTimeMillis() + Log.d(TAG, "Media update suppression activated for seek") + } + + /** + * Check if media updates should be suppressed due to recent skip or seek command */ private fun shouldSuppressMediaUpdate(): Boolean { val timeSinceSkip = System.currentTimeMillis() - skipCommandTimestamp - return timeSinceSkip < SKIP_SUPPRESSION_DURATION + val timeSinceSeek = System.currentTimeMillis() - seekCommandTimestamp + return timeSinceSkip < SKIP_SUPPRESSION_DURATION || timeSinceSeek < SEEK_SUPPRESSION_DURATION } - fun onMediaStateChanged(context: Context) { - // Check if we should suppress this update due to recent skip command + fun onMediaStateChanged(context: Context, isPlayingChanged: Boolean = false) { if (shouldSuppressMediaUpdate()) { - Log.d(TAG, "Media state change suppressed due to recent skip command") + // Always let play/pause state changes through, even during seek/skip suppression. + // This ensures "pause immediately after Mac seek" works correctly — the Mac will + // stop its local timer without waiting for the suppression window to expire. + if (isPlayingChanged) { + Log.d(TAG, "isPlaying changed during suppression — bypassing to sync") + checkAndSyncDeviceStatus(context) + } else { + Log.d(TAG, "Media state change suppressed due to recent seek/skip command") + } return } 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 31bc146b..f047f615 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -263,6 +263,20 @@ object WebSocketMessageHandler { success = MediaControlUtil.stop(context) message = if (success) "Playback stopped" else "Failed to stop playback" } + + "seekTo" -> { + val positionMs = data.optLong("positionMs", -1L) + if (positionMs >= 0) { + // Suppress the stale onPlaybackStateChanged callback Android fires + // immediately after seekTo() — this prevents sending the old position + // back to the Mac and causing the jump-back UI bug. + SyncManager.suppressMediaUpdatesForSeek() + success = MediaControlUtil.seekTo(context, positionMs) + message = if (success) "Seeked to $positionMs ms" else "Failed to seek" + } else { + message = "Invalid seek position" + } + } // New: toggle like controls "toggleLike" -> { success = MediaControlUtil.toggleLike(context)