Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,24 @@ 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(
val isPlaying: Boolean,
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
)
Original file line number Diff line number Diff line change
Expand Up @@ -118,30 +118,56 @@ 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)
?: metadata?.getBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON)

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"
}
Expand All @@ -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
)
}
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 10 additions & 2 deletions app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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
)
}

Expand Down
13 changes: 11 additions & 2 deletions app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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}}}"""
}

/**
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
62 changes: 53 additions & 9 deletions app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,19 @@ 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
@Volatile
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")
Expand Down Expand Up @@ -69,6 +75,7 @@ object SyncManager {
lastAudioInfo = null
lastBatteryInfo = null
lastVolume = -1
lastSyncTimeMs = 0
}

fun checkAndSyncDeviceStatus(context: Context, forceSync: Boolean = false) {
Expand All @@ -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
}
Expand All @@ -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")
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down