Skip to content

Commit b2005f8

Browse files
committed
feat: Implement Seekbar sync for now playing [1/2]
- Guarded behind button called 'Sync Android playback seekbar' - Also added a tooltip - Added the seekbar in Menu bar widget and also the phone display - Requires changes in android app
1 parent 5a5aa85 commit b2005f8

8 files changed

Lines changed: 302 additions & 70 deletions

File tree

airsync-mac/Core/Storage/UserDefaults.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ extension UserDefaults {
2626
static let continueApp = "continueApp"
2727
static let directKeyInput = "directKeyInput"
2828
static let sendNowPlayingStatus = "sendNowPlayingStatus"
29+
static let syncAndroidPlaybackSeekbar = "syncAndroidPlaybackSeekbar"
2930
static let isMusicCardHidden = "isMusicCardHidden"
3031
static let lastOnboarding = "lastOnboarding"
3132

@@ -130,6 +131,15 @@ extension UserDefaults {
130131
set { set(newValue, forKey: Keys.sendNowPlayingStatus)}
131132
}
132133

134+
/// When enabled, AirSync plays a silent audio loop to claim macOS Now Playing focus,
135+
/// allowing the Android playback seekbar to be exposed in boringNotch / Control Center.
136+
/// Disabled by default because it causes Bluetooth multipoint headphones to route
137+
/// audio to the Mac, preventing Android media from playing through the headphones.
138+
var syncAndroidPlaybackSeekbar: Bool {
139+
get { bool(forKey: Keys.syncAndroidPlaybackSeekbar) }
140+
set { set(newValue, forKey: Keys.syncAndroidPlaybackSeekbar) }
141+
}
142+
133143
var isMusicCardHidden: Bool {
134144
get { bool(forKey: Keys.isMusicCardHidden) }
135145
set { set(newValue, forKey: Keys.isMusicCardHidden) }

airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,21 @@ class MacInfoSyncManager: ObservableObject {
108108
self?.sendDeviceStatusWithoutMusic()
109109
return
110110
}
111+
112+
// IMPORTANT: Filter out AirSync's own bundle ID.
113+
// NowPlayingPublisher writes Android's media info into macOS
114+
// MPNowPlayingInfoCenter so boringNotch can display it.
115+
// media-control reads from the same source, so without this guard
116+
// we'd forward AirSync's own published entry back to Android,
117+
// creating a play/pause feedback loop.
118+
let ownBundleId = Bundle.main.bundleIdentifier ?? ""
119+
if let bundleId = info.bundleIdentifier, !ownBundleId.isEmpty,
120+
bundleId == ownBundleId {
121+
// This is our own reflection — treat as nothing playing on Mac
122+
self?.sendDeviceStatusWithoutMusic()
123+
return
124+
}
125+
111126
// MUST update @Published properties on main thread
112127
DispatchQueue.main.async {
113128
// print("Now Playing fetched:", info) // debug

airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,32 @@ extension WebSocketServer {
254254
{
255255
let albumArt = (music["albumArt"] as? String) ?? ""
256256
let likeStatus = (music["likeStatus"] as? String) ?? "none"
257+
let isBuffering = (music["isBuffering"] as? Bool) ?? false
258+
259+
// Android sends duration/position in ms; convert to seconds.
260+
// Using NSNumber because Swift's `as? Double` fails if the JSON parser inferred an Int.
261+
let durationSec = (music["duration"] as? NSNumber).map { $0.doubleValue / 1000.0 } ?? -1.0
262+
var positionSec = (music["position"] as? NSNumber).map { $0.doubleValue / 1000.0 } ?? -1.0
263+
264+
// Timestamp-based position correction:
265+
// Android includes the wall-clock ms when the position snapshot was taken.
266+
// We add the elapsed time since then (which includes WiFi transit) to get a
267+
// much more accurate "current" position — effectively NTP-style compensation.
268+
// Clamp: only correct for realistic WiFi delays (< 5s). Larger deltas likely
269+
// indicate clock skew between devices, which would worsen accuracy if applied.
270+
if positionSec >= 0, playing, !isBuffering,
271+
let tsMs = music["positionTimestamp"] as? NSNumber {
272+
let capturedAt = tsMs.doubleValue / 1000.0
273+
let nowSec = Date().timeIntervalSince1970
274+
let networkDelta = nowSec - capturedAt
275+
if networkDelta > -2.0 && networkDelta < 5.0 {
276+
positionSec += max(0.0, networkDelta)
277+
}
278+
}
279+
// Clamp to duration to prevent the seekbar going past the end
280+
if durationSec > 0 && positionSec > durationSec {
281+
positionSec = durationSec
282+
}
257283

258284
AppState.shared.status = DeviceStatus(
259285
battery: .init(level: level, isCharging: isCharging),
@@ -265,19 +291,38 @@ extension WebSocketServer {
265291
volume: volume,
266292
isMuted: isMuted,
267293
albumArt: albumArt,
268-
likeStatus: likeStatus
294+
likeStatus: likeStatus,
295+
duration: durationSec,
296+
position: positionSec,
297+
isBuffering: isBuffering
269298
)
270299
)
271300

272-
// Publish Android now-playing info to MPNowPlayingInfoCenter
273-
var npInfo = NowPlayingInfo()
274-
npInfo.title = title
275-
npInfo.artist = artist
276-
npInfo.isPlaying = playing
277-
if let data = Data(base64Encoded: albumArt) {
278-
npInfo.artworkData = data
301+
// Publish Android now-playing info to MPNowPlayingInfoCenter only when
302+
// the user has opted in, because this requires playing silent audio which
303+
// causes multipoint Bluetooth headphones to route audio to the Mac.
304+
if UserDefaults.standard.syncAndroidPlaybackSeekbar {
305+
var npInfo = NowPlayingInfo()
306+
npInfo.title = title
307+
npInfo.artist = artist
308+
npInfo.isPlaying = playing
309+
if let data = Data(base64Encoded: albumArt) {
310+
npInfo.artworkData = data
311+
}
312+
// Seekbar: Android sends duration/position in ms; MPNowPlayingInfoCenter needs seconds.
313+
// positionMs uses optDouble so missing/null safely falls back to -1.
314+
// NOTE: Use NSNumber because Swift's JSON parser returns an Int type for flat numbers.
315+
if let nsNum = music["duration"] as? NSNumber, nsNum.doubleValue > 0 {
316+
npInfo.duration = nsNum.doubleValue / 1000.0
317+
}
318+
if let pMs = music["position"] as? NSNumber, pMs.doubleValue >= 0 {
319+
npInfo.elapsedTime = pMs.doubleValue / 1000.0
320+
}
321+
NowPlayingPublisher.shared.update(info: npInfo)
322+
} else {
323+
// If the setting is off, ensure any previously running session is cleared
324+
NowPlayingPublisher.shared.clear()
279325
}
280-
NowPlayingPublisher.shared.update(info: npInfo)
281326
}
282327
}
283328

airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ extension WebSocketServer {
108108
func like() { sendMediaAction("like") }
109109
func unlike() { sendMediaAction("unlike") }
110110

111+
/// Seek Android playback to a specific position (in seconds).
112+
func seekTo(positionSeconds: Double) {
113+
let positionMs = Int(positionSeconds * 1000)
114+
sendMessage(type: "mediaControl", data: ["action": "seekTo", "positionMs": positionMs])
115+
}
116+
111117
private func sendMediaAction(_ action: String) {
112118
sendMessage(type: "mediaControl", data: ["action": action])
113119
}

airsync-mac/Model/Device.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ struct MockData{
4747
volume: 50,
4848
isMuted: false,
4949
albumArt: "",
50-
likeStatus: "none"
50+
likeStatus: "none",
51+
duration: 214,
52+
position: 42,
53+
isBuffering: false
5154
)
5255

5356
static let sampleDevices = [

airsync-mac/Model/DeviceStatus.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ struct DeviceStatus: Codable {
2121
let isMuted: Bool
2222
let albumArt: String
2323
let likeStatus: String
24+
/// Total track duration in seconds. -1 means not available.
25+
let duration: Double
26+
/// Current playback position in seconds (corrected for network transit on Mac side).
27+
let position: Double
28+
/// True when Android is buffering — position is frozen, Mac timer should pause.
29+
let isBuffering: Bool
2430
}
2531

2632
let battery: Battery

0 commit comments

Comments
 (0)