Skip to content

Commit 3bdc21f

Browse files
committed
feat: Implement Seekbar sync for now playing [2/2]
1 parent 4da51ab commit 3bdc21f

7 files changed

Lines changed: 175 additions & 22 deletions

File tree

app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ data class AudioInfo(
1414
val albumArt: String? = null,
1515
val albumArtLite: String? = null,
1616
// New: like status for current media ("liked", "not_liked", or "none")
17-
val likeStatus: String = "none"
17+
val likeStatus: String = "none",
18+
val durationMs: Long = -1L,
19+
val positionMs: Long = -1L,
20+
// True when the media session is in STATE_BUFFERING (position not advancing).
21+
val isBuffering: Boolean = false,
22+
// System.currentTimeMillis() at the moment positionMs was captured,
23+
// so the Mac can compensate for network transit time.
24+
val positionTimestampMs: Long = -1L
1825
)
1926

2027
data class MediaInfo(
@@ -24,5 +31,9 @@ data class MediaInfo(
2431
val albumArt: String? = null,
2532
val albumArtLite: String? = null,
2633
// New: like status for current media ("liked", "not_liked", or "none")
27-
val likeStatus: String = "none"
34+
val likeStatus: String = "none",
35+
val durationMs: Long = -1L,
36+
val positionMs: Long = -1L,
37+
val isBuffering: Boolean = false,
38+
val positionTimestampMs: Long = -1L
2839
)

app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,46 @@ class MediaNotificationListener : NotificationListenerService() {
118118

119119
val title = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: ""
120120
val artist = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: ""
121-
val isPlaying = playbackState?.state == PlaybackState.STATE_PLAYING
121+
val playbackStateCode = playbackState?.state ?: PlaybackState.STATE_NONE
122+
val isPlaying = playbackStateCode == PlaybackState.STATE_PLAYING
123+
val isBuffering = playbackStateCode == PlaybackState.STATE_BUFFERING
124+
125+
// Seekbar: duration from metadata, position from playback state.
126+
val durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: -1L
127+
128+
// Capture the raw frozen position plus the wall-clock instant it was read.
129+
// The Mac will apply the (now - captureTime) delta itself, which also
130+
// compensates for WiFi transit time — better accuracy than pre-computing here.
131+
var positionMs = playbackState?.position ?: -1L
132+
var positionTimestampMs = -1L
133+
if (positionMs >= 0 && playbackState != null) {
134+
// Map elapsedRealtime of the last state update → wall-clock ms
135+
val elapsedAtUpdate = playbackState.lastPositionUpdateTime
136+
val elapsedNow = android.os.SystemClock.elapsedRealtime()
137+
val wallNow = System.currentTimeMillis()
138+
// Wall-clock instant when playbackState.position was last set
139+
val wallAtUpdate = wallNow - (elapsedNow - elapsedAtUpdate)
140+
// Advance position to now (if playing) or leave frozen (if paused/buffering)
141+
if (isPlaying) {
142+
val timeDelta = elapsedNow - elapsedAtUpdate
143+
val speed = playbackState.playbackSpeed
144+
if (timeDelta > 0 && speed > 0) {
145+
positionMs += (timeDelta * speed).toLong()
146+
}
147+
}
148+
// Record the wall-clock time for when this positionMs snapshot is valid.
149+
// If playing, positionMs is "now", so timestamp = wallNow.
150+
// If paused/buffering, positionMs is frozen at wallAtUpdate — but the Mac
151+
// won't advance it further (isPlaying/isBuffering guards handle that).
152+
positionTimestampMs = if (isPlaying) wallNow else wallAtUpdate
153+
}
122154

123155
val albumArtBitmap =
124156
metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
125157
?: metadata?.getBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON)
126158

127159
val albumArtBase64 = albumArtBitmap?.let {
128160
val outputStream = ByteArrayOutputStream()
129-
// Compress to a smaller size to avoid large payloads
130161
it.compress(Bitmap.CompressFormat.JPEG, 50, outputStream)
131162
Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
132163
}
@@ -152,8 +183,6 @@ class MediaNotificationListener : NotificationListenerService() {
152183
controller
153184
)
154185
var likeStatus = detectedStatus
155-
156-
// If filtered by app, force none and skip cache
157186
if (source == "appfilter") {
158187
likeStatus = "none"
159188
}
@@ -166,7 +195,11 @@ class MediaNotificationListener : NotificationListenerService() {
166195
artist = artist,
167196
albumArt = albumArtBase64,
168197
albumArtLite = albumArtLiteBase64,
169-
likeStatus = likeStatus
198+
likeStatus = likeStatus,
199+
durationMs = durationMs,
200+
positionMs = positionMs,
201+
isBuffering = isBuffering,
202+
positionTimestampMs = positionTimestampMs
170203
)
171204
}
172205
}
@@ -457,7 +490,11 @@ class MediaNotificationListener : NotificationListenerService() {
457490
// If media info changed, trigger sync
458491
if (previousMediaInfo != currentMediaInfo) {
459492
Log.d(TAG, "Media info changed, triggering sync")
460-
SyncManager.onMediaStateChanged(this)
493+
// Bypass suppression for play/pause AND buffering changes so the Mac
494+
// stops its timer immediately (not after the 2.5s suppression window).
495+
val isStateChanged = previousMediaInfo?.isPlaying != currentMediaInfo?.isPlaying
496+
|| previousMediaInfo?.isBuffering != currentMediaInfo?.isBuffering
497+
SyncManager.onMediaStateChanged(this, isPlayingChanged = isStateChanged)
461498
}
462499
} else {
463500
Log.d(
@@ -503,7 +540,9 @@ class MediaNotificationListener : NotificationListenerService() {
503540
// If media info changed, trigger sync
504541
if (previousMediaInfo != currentMediaInfo) {
505542
Log.d(TAG, "Media info changed after notification removal, triggering sync")
506-
SyncManager.onMediaStateChanged(this)
543+
val isStateChanged = previousMediaInfo?.isPlaying != currentMediaInfo?.isPlaying
544+
|| previousMediaInfo?.isBuffering != currentMediaInfo?.isBuffering
545+
SyncManager.onMediaStateChanged(this, isPlayingChanged = isStateChanged)
507546
}
508547
} else {
509548
Log.d(

app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,21 @@ object DeviceInfoUtil {
168168
isMuted = isMuted,
169169
albumArt = mediaInfo.albumArt,
170170
albumArtLite = mediaInfo.albumArtLite,
171-
likeStatus = mediaInfo.likeStatus
171+
likeStatus = mediaInfo.likeStatus,
172+
durationMs = mediaInfo.durationMs,
173+
positionMs = mediaInfo.positionMs,
174+
isBuffering = mediaInfo.isBuffering,
175+
positionTimestampMs = mediaInfo.positionTimestampMs
172176
)
173177
} catch (e: Exception) {
174178
Log.e("DeviceInfoUtil", "Error getting audio info: ${e.message}")
175-
AudioInfo(false, "", "", 0, true, null, "none")
179+
AudioInfo(
180+
isPlaying = false,
181+
title = "",
182+
artist = "",
183+
volume = 0,
184+
isMuted = true
185+
)
176186
}
177187
}
178188

@@ -191,7 +201,11 @@ object DeviceInfoUtil {
191201
isMuted = audioInfo.isMuted,
192202
albumArt = audioInfo.albumArt,
193203
albumArtLite = audioInfo.albumArtLite,
194-
likeStatus = audioInfo.likeStatus
204+
likeStatus = audioInfo.likeStatus,
205+
durationMs = audioInfo.durationMs,
206+
positionMs = audioInfo.positionMs,
207+
isBuffering = audioInfo.isBuffering,
208+
positionTimestampMs = audioInfo.positionTimestampMs
195209
)
196210
}
197211

app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,20 @@ object JsonUtil {
157157
isMuted: Boolean,
158158
albumArt: String?,
159159
albumArtLite: String? = null,
160-
likeStatus: String
160+
likeStatus: String,
161+
// Seekbar fields — omitted from JSON when unknown (-1) for backwards compat
162+
durationMs: Long = -1L,
163+
positionMs: Long = -1L,
164+
isBuffering: Boolean = false,
165+
positionTimestampMs: Long = -1L
161166
): String {
162167
val albumArtJson = if (albumArt != null) ",\"albumArt\":\"$albumArt\"" else ""
163168
val albumArtLiteJson = if (albumArtLite != null) ",\"albumArtLite\":\"$albumArtLite\"" else ""
164-
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"}}}"""
169+
val durationJson = if (durationMs >= 0) ",\"duration\":$durationMs" else ""
170+
val positionJson = if (positionMs >= 0) ",\"position\":$positionMs" else ""
171+
val bufferingJson = if (isBuffering) ",\"isBuffering\":true" else ""
172+
val timestampJson = if (positionTimestampMs >= 0) ",\"positionTimestamp\":$positionTimestampMs" else ""
173+
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$albumArtLiteJson,"likeStatus":"$likeStatus"$durationJson$positionJson$bufferingJson$timestampJson}}}"""
165174
}
166175

167176
/**

app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,27 @@ object MediaControlUtil {
100100
}
101101
}
102102

103+
/**
104+
* Seek to a specific position in the current media.
105+
* @param positionMs Position in milliseconds.
106+
*/
107+
fun seekTo(context: Context, positionMs: Long): Boolean {
108+
return try {
109+
val controller = getActiveMediaController(context)
110+
if (controller != null) {
111+
controller.transportControls.seekTo(positionMs)
112+
Log.d(TAG, "Seeked to $positionMs ms")
113+
true
114+
} else {
115+
Log.w(TAG, "No active media controller to seek")
116+
false
117+
}
118+
} catch (e: Exception) {
119+
Log.e(TAG, "Error in seekTo: ${e.message}")
120+
false
121+
}
122+
}
123+
103124
/**
104125
* Toggle like status by invoking the Like/Unlike action in the active media notification.
105126
*/

app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,19 @@ object SyncManager {
2323
private var lastAudioInfo: AudioInfo? = null
2424
private var lastBatteryInfo: BatteryInfo? = null
2525
private var lastVolume: Int = -1
26+
private var lastSyncTimeMs: Long = 0 // wall-clock ms of the last successful status send
2627
private val isSyncing = AtomicBoolean(false)
2728

2829
// Track skip suppression mechanism
2930
@Volatile
3031
private var skipCommandTimestamp: Long = 0
3132
private const val SKIP_SUPPRESSION_DURATION = 1000L // 1 second suppression after skip command
3233

34+
// Track seek suppression mechanism
35+
@Volatile
36+
private var seekCommandTimestamp: Long = 0
37+
private const val SEEK_SUPPRESSION_DURATION = 2500L // 2.5 second suppression after seek command
38+
3339
fun startPeriodicSync(context: Context) {
3440
if (isSyncing.get()) {
3541
Log.d(TAG, "Sync already running")
@@ -75,6 +81,7 @@ object SyncManager {
7581
lastAudioInfo = null
7682
lastBatteryInfo = null
7783
lastVolume = -1
84+
lastSyncTimeMs = 0
7885
}
7986

8087
fun checkAndSyncDeviceStatus(context: Context, forceSync: Boolean = false) {
@@ -88,18 +95,36 @@ object SyncManager {
8895

8996
var shouldSync = forceSync
9097

91-
// Check if audio-related info changed
98+
// Check if audio-related info changed.
9299
lastAudioInfo?.let { last ->
93100
if (last.isPlaying != currentAudio.isPlaying ||
94101
last.title != currentAudio.title ||
95102
last.artist != currentAudio.artist ||
96103
last.volume != currentAudio.volume ||
97104
last.isMuted != currentAudio.isMuted ||
98-
last.likeStatus != currentAudio.likeStatus
105+
last.likeStatus != currentAudio.likeStatus ||
106+
last.durationMs != currentAudio.durationMs ||
107+
last.isBuffering != currentAudio.isBuffering
99108
) {
100109
shouldSync = true
101110
Log.d(TAG, "Audio info changed, syncing device status")
102111
}
112+
113+
// Position-jump detection: if positionMs is more than 8 seconds away from
114+
// what we'd expect based on normal playback since the last sync, the user
115+
// seeked on Android — trigger an immediate sync so the Mac can update.
116+
// Guard: lastSyncTimeMs == 0 means we haven't done a successful sync yet
117+
// (can happen if performInitialSync failed). Skip detection to avoid a
118+
// huge elapsedMs causing a spurious forced sync.
119+
if (!shouldSync && currentAudio.isPlaying && last.positionMs >= 0 && currentAudio.positionMs >= 0 && lastSyncTimeMs > 0) {
120+
val elapsedMs = System.currentTimeMillis() - lastSyncTimeMs
121+
val expectedPositionMs = last.positionMs + elapsedMs
122+
val positionDelta = kotlin.math.abs(currentAudio.positionMs - expectedPositionMs)
123+
if (positionDelta > 8_000L) {
124+
shouldSync = true
125+
Log.d(TAG, "Position jump detected (delta=${positionDelta}ms), syncing")
126+
}
127+
}
103128
} ?: run {
104129
shouldSync = true // First time
105130
}
@@ -125,6 +150,7 @@ object SyncManager {
125150
lastAudioInfo = currentAudio
126151
lastBatteryInfo = currentBattery
127152
lastVolume = currentAudio.volume
153+
lastSyncTimeMs = System.currentTimeMillis()
128154
} else {
129155
Log.w(TAG, "Failed to sync device status (WS/BLE)")
130156
}
@@ -259,9 +285,11 @@ object SyncManager {
259285
val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context)
260286
if (WebSocketUtil.sendMessage(statusJson)) {
261287
Log.d(TAG, "Device status sent")
262-
// Update cache
288+
// Update cache — also set lastSyncTimeMs so the position-jump detector
289+
// has a valid baseline and doesn't trigger a spurious sync on the next check.
263290
lastAudioInfo = DeviceInfoUtil.getAudioInfo(context, includeNowPlaying)
264291
lastBatteryInfo = DeviceInfoUtil.getBatteryInfo(context)
292+
lastSyncTimeMs = System.currentTimeMillis()
265293
} else {
266294
Log.e(TAG, "Failed to send device status")
267295
}
@@ -595,17 +623,34 @@ object SyncManager {
595623
}
596624

597625
/**
598-
* Check if media updates should be suppressed due to recent skip command
626+
* Call this before executing a seek command to suppress the stale onPlaybackStateChanged
627+
* callback that Android fires immediately after seekTo() with the old position.
628+
*/
629+
fun suppressMediaUpdatesForSeek() {
630+
seekCommandTimestamp = System.currentTimeMillis()
631+
Log.d(TAG, "Media update suppression activated for seek")
632+
}
633+
634+
/**
635+
* Check if media updates should be suppressed due to recent skip or seek command
599636
*/
600637
private fun shouldSuppressMediaUpdate(): Boolean {
601638
val timeSinceSkip = System.currentTimeMillis() - skipCommandTimestamp
602-
return timeSinceSkip < SKIP_SUPPRESSION_DURATION
639+
val timeSinceSeek = System.currentTimeMillis() - seekCommandTimestamp
640+
return timeSinceSkip < SKIP_SUPPRESSION_DURATION || timeSinceSeek < SEEK_SUPPRESSION_DURATION
603641
}
604642

605-
fun onMediaStateChanged(context: Context) {
606-
// Check if we should suppress this update due to recent skip command
643+
fun onMediaStateChanged(context: Context, isPlayingChanged: Boolean = false) {
607644
if (shouldSuppressMediaUpdate()) {
608-
Log.d(TAG, "Media state change suppressed due to recent skip command")
645+
// Always let play/pause state changes through, even during seek/skip suppression.
646+
// This ensures "pause immediately after Mac seek" works correctly — the Mac will
647+
// stop its local timer without waiting for the suppression window to expire.
648+
if (isPlayingChanged) {
649+
Log.d(TAG, "isPlaying changed during suppression — bypassing to sync")
650+
checkAndSyncDeviceStatus(context)
651+
} else {
652+
Log.d(TAG, "Media state change suppressed due to recent seek/skip command")
653+
}
609654
return
610655
}
611656

app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,20 @@ object WebSocketMessageHandler {
263263
success = MediaControlUtil.stop(context)
264264
message = if (success) "Playback stopped" else "Failed to stop playback"
265265
}
266+
267+
"seekTo" -> {
268+
val positionMs = data.optLong("positionMs", -1L)
269+
if (positionMs >= 0) {
270+
// Suppress the stale onPlaybackStateChanged callback Android fires
271+
// immediately after seekTo() — this prevents sending the old position
272+
// back to the Mac and causing the jump-back UI bug.
273+
SyncManager.suppressMediaUpdatesForSeek()
274+
success = MediaControlUtil.seekTo(context, positionMs)
275+
message = if (success) "Seeked to $positionMs ms" else "Failed to seek"
276+
} else {
277+
message = "Invalid seek position"
278+
}
279+
}
266280
// New: toggle like controls
267281
"toggleLike" -> {
268282
success = MediaControlUtil.toggleLike(context)

0 commit comments

Comments
 (0)