From 5a5aa85b2e399a7fbc6d13c688731a27a27291df Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Tue, 31 Mar 2026 13:39:54 +0530 Subject: [PATCH 1/2] feat: Expose nowplaying to mac Now airsync's now playing also shows in the notification panel and also works with boring notch Although the seekbar is not synced from android to mac as the implementation is missing in airsync[BoringNotch] --- .../Core/Media/NowPlayingPublisher.swift | 240 ++++++++++++++++++ .../WebSocket/WebSocketServer+Handlers.swift | 10 + .../WebSocket/WebSocketServer+Outgoing.swift | 16 ++ airsync-mac/airsync_macApp.swift | 3 + 4 files changed, 269 insertions(+) create mode 100644 airsync-mac/Core/Media/NowPlayingPublisher.swift diff --git a/airsync-mac/Core/Media/NowPlayingPublisher.swift b/airsync-mac/Core/Media/NowPlayingPublisher.swift new file mode 100644 index 0000000..af49486 --- /dev/null +++ b/airsync-mac/Core/Media/NowPlayingPublisher.swift @@ -0,0 +1,240 @@ +// +// NowPlayingPublisher.swift +// AirSync +// +// Publishes Android now-playing info into macOS MPNowPlayingInfoCenter +// so boring.notch (via MediaRemote.framework) picks it up naturally. +// Uses silent audio to make the app audio-eligible for MediaRemote reporting. +// + +import Foundation +import AppKit +import AVFoundation +import MediaPlayer + +final class NowPlayingPublisher { + static let shared = NowPlayingPublisher() + + // MARK: - Silent Audio Engine (makes app audio-eligible for MediaRemote) + private var audioEngine: AVAudioEngine? + private var playerNode: AVAudioPlayerNode? + private var isSilentAudioRunning: Bool = false + + // MARK: - State + private var currentInfo: NowPlayingInfo? + private var commandCenterRegistered = false + + /// Timestamp of the last remote command we sent to Android. + private var lastCommandSentAt: Date = .distantPast + /// Timestamp of the last time we updated MPNowPlayingInfoCenter. + private var lastStateUpdateAt: Date = .distantPast + + // Short debounces to provide an instant UI while preventing macOS feedback loops: + // 0.35s limits how fast the user can mash buttons, and blocks automated + // counter-commands that macOS fires right after we update the info center. + private let commandDebounceInterval: TimeInterval = 0.35 + private let stateUpdateDebounceInterval: TimeInterval = 0.35 + + private init() {} + + // MARK: - Public API + + /// Call once at app startup. Sets up remote commands and starts silent audio. + func start() { + registerRemoteCommands() + // Start silent audio immediately so the app is ALWAYS audio-eligible. + // If we wait until the first play command, macOS sees us publish + // MPNowPlayingInfoCenter data without backing audio and fires a pauseCommand + // to "correct" the state — which is the root cause of the glitch loop. + startSilentAudio() + } + + /// Update now-playing with Android media info. + /// During the 1-second window after the user clicks a button, we ignore incoming + /// status updates. This protects our instant optimistic UI from being overwritten + /// by stale network packets that Android dispatched before the command took effect. + func update(info: NowPlayingInfo) { + let timeSinceCommand = Date().timeIntervalSince(lastCommandSentAt) + if timeSinceCommand < 1.0 { + return + } + + currentInfo = info + + // Always publish metadata on the main thread (MPNowPlayingInfoCenter requirement) + DispatchQueue.main.async { + self.lastStateUpdateAt = Date() + self.publishToNowPlayingInfoCenter(info: info) + } + // Silent audio is always running (started in start()), nothing to do here. + } + + /// Clear now-playing info (e.g., Android disconnected) + func clear() { + currentInfo = nil + stopSilentAudio() // Only place we stop the engine + DispatchQueue.main.async { + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + MPNowPlayingInfoCenter.default().playbackState = .stopped + } + } + + // MARK: - Silent Audio + + private func startSilentAudio() { + guard !isSilentAudioRunning else { return } + isSilentAudioRunning = true + + let engine = AVAudioEngine() + let player = AVAudioPlayerNode() + engine.attach(player) + engine.connect(player, to: engine.mainMixerNode, format: nil) + + // Generate one second of silence + let format = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 2)! + let frameCount = AVAudioFrameCount(format.sampleRate) + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { + isSilentAudioRunning = false + return + } + buffer.frameLength = frameCount + // Buffer is already zeroed by default — silence + + // BUG FIX: engine.start() MUST come before player.play() / scheduleBuffer. + // Calling player.play() on an un-started engine produces: + // "Engine is not running because it was not explicitly started" + do { + try engine.start() + } catch { + print("[NowPlayingPublisher] Failed to start silent audio engine: \(error)") + isSilentAudioRunning = false + return + } + + audioEngine = engine + playerNode = player + + player.scheduleBuffer(buffer, at: nil, options: .loops) + player.play() + + print("[NowPlayingPublisher] Silent audio engine started — app is now audio-eligible") + } + + private func stopSilentAudio() { + guard isSilentAudioRunning else { return } + playerNode?.stop() + audioEngine?.stop() + audioEngine?.reset() + audioEngine = nil + playerNode = nil + isSilentAudioRunning = false + print("[NowPlayingPublisher] Silent audio engine stopped") + } + + // MARK: - Publish to MPNowPlayingInfoCenter + + private func publishToNowPlayingInfoCenter(info: NowPlayingInfo) { + let center = MPNowPlayingInfoCenter.default() + + var mpInfo: [String: Any] = [ + MPMediaItemPropertyTitle: info.title ?? "", + MPMediaItemPropertyArtist: info.artist ?? "", + MPMediaItemPropertyAlbumTitle: info.album ?? "", + ] + + if let duration = info.duration, duration > 0 { + mpInfo[MPMediaItemPropertyPlaybackDuration] = duration + } + if let elapsed = info.elapsedTime { + mpInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsed + } + mpInfo[MPNowPlayingInfoPropertyPlaybackRate] = info.isPlaying == true ? 1.0 : 0.0 + + if let artworkData = info.artworkData, + let nsImage = NSImage(data: artworkData) { + let artwork = MPMediaItemArtwork(boundsSize: CGSize(width: nsImage.size.width, height: nsImage.size.height)) { _ in + return nsImage + } + mpInfo[MPMediaItemPropertyArtwork] = artwork + } + + center.nowPlayingInfo = mpInfo + // Restore playbackState so UI elements like boringNotch know it's explicitly playing/paused. + // Automated counter-commands triggered by this change will be dropped by stateUpdateDebounceInterval. + center.playbackState = info.isPlaying == true ? .playing : .paused + } + + // MARK: - Remote Commands + + private func processCommand(name: String, action: String, optimisticUpdate: ((NowPlayingPublisher) -> Void)? = nil) -> MPRemoteCommandHandlerStatus { + let now = Date() + let timeSinceCommand = now.timeIntervalSince(lastCommandSentAt) + let timeSinceState = now.timeIntervalSince(lastStateUpdateAt) + + if timeSinceCommand < commandDebounceInterval { + return .success + } + if timeSinceState < stateUpdateDebounceInterval { + return .success + } + + lastCommandSentAt = now + WebSocketServer.shared.sendAndroidMediaControl(action: action) + optimisticUpdate?(self) + + return .success + } + + private func registerRemoteCommands() { + guard !commandCenterRegistered else { return } + commandCenterRegistered = true + + let commandCenter = MPRemoteCommandCenter.shared() + + // NOTE: Commands are forwarded to Android via WebSocket (not NowPlayingCLI which + // controls LOCAL Mac media via the `media-control` binary). This music is from + // the phone, so control actions must go back over the WebSocket connection. + // IMPORTANT: macOS often fires automated counter-commands when we update MPNowPlayingInfoCenter. + // We drop any commands received within `stateUpdateDebounceInterval` of our last update. + // We also do optimistic updates so the UI responds instantly to clicks. + + commandCenter.playCommand.addTarget { [weak self] _ in + return self?.processCommand(name: "Play", action: "play") { $0.publishPlaybackStateUpdate(playing: true) } ?? .commandFailed + } + + commandCenter.pauseCommand.addTarget { [weak self] _ in + return self?.processCommand(name: "Pause", action: "pause") { $0.publishPlaybackStateUpdate(playing: false) } ?? .commandFailed + } + + commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in + let isPlaying = self?.currentInfo?.isPlaying == true + let explicitAction = isPlaying ? "pause" : "play" + return self?.processCommand(name: "TogglePlayPause", action: explicitAction) { publisher in + publisher.publishPlaybackStateUpdate(playing: !isPlaying) + } ?? .commandFailed + } + + commandCenter.nextTrackCommand.addTarget { [weak self] _ in + return self?.processCommand(name: "NextTrack", action: "nextTrack") ?? .commandFailed + } + + commandCenter.previousTrackCommand.addTarget { [weak self] _ in + return self?.processCommand(name: "PreviousTrack", action: "previousTrack") ?? .commandFailed + } + + // Seeking not yet supported for Android remote + commandCenter.changePlaybackPositionCommand.isEnabled = false + + print("[NowPlayingPublisher] Remote commands registered") + } + + private func publishPlaybackStateUpdate(playing: Bool) { + guard var info = currentInfo else { return } + info.isPlaying = playing + currentInfo = info + DispatchQueue.main.async { + self.lastStateUpdateAt = Date() + self.publishToNowPlayingInfoCenter(info: info) + } + } +} diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 9512178..cd5d1ec 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -268,6 +268,16 @@ extension WebSocketServer { likeStatus: likeStatus ) ) + + // Publish Android now-playing info to MPNowPlayingInfoCenter + var npInfo = NowPlayingInfo() + npInfo.title = title + npInfo.artist = artist + npInfo.isPlaying = playing + if let data = Data(base64Encoded: albumArt) { + npInfo.artworkData = data + } + NowPlayingPublisher.shared.update(info: npInfo) } } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift index 8a5e091..865fd1a 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift @@ -112,6 +112,22 @@ extension WebSocketServer { sendMessage(type: "mediaControl", data: ["action": action]) } + /// Forward a system media command (from MPRemoteCommandCenter) back to Android. + /// - action: "play", "pause", "playPause", "nextTrack", "previousTrack" + func sendAndroidMediaControl(action: String) { + // Map MPRemoteCommandCenter-style names to the Android protocol's action names + let androidAction: String + switch action { + case "play": androidAction = "play" + case "pause": androidAction = "pause" + case "playPause": androidAction = "playPause" + case "nextTrack": androidAction = "next" + case "previousTrack": androidAction = "previous" + default: androidAction = action + } + sendMediaAction(androidAction) + } + // MARK: - Volume Controls func volumeUp() { sendVolumeAction("volumeUp") } diff --git a/airsync-mac/airsync_macApp.swift b/airsync-mac/airsync_macApp.swift index 66832d8..1f9e319 100644 --- a/airsync-mac/airsync_macApp.swift +++ b/airsync-mac/airsync_macApp.swift @@ -26,6 +26,9 @@ struct airsync_macApp: App { init() { + // Initialize NowPlayingPublisher for MPNowPlayingInfoCenter integration + NowPlayingPublisher.shared.start() + let center = UNUserNotificationCenter.current() center.delegate = notificationDelegate updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) From b2005f8334a68dca46bf5c050812a26fe4449385 Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Wed, 1 Apr 2026 15:15:24 +0530 Subject: [PATCH 2/2] 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 --- airsync-mac/Core/Storage/UserDefaults.swift | 10 + .../Util/MacInfo/MacInfoSyncManager.swift | 15 ++ .../WebSocket/WebSocketServer+Handlers.swift | 63 ++++- .../WebSocket/WebSocketServer+Outgoing.swift | 6 + airsync-mac/Model/Device.swift | 5 +- airsync-mac/Model/DeviceStatus.swift | 6 + .../PhoneView/MediaPlayerView.swift | 248 +++++++++++++----- .../Settings/SettingsFeaturesView.swift | 19 ++ 8 files changed, 302 insertions(+), 70 deletions(-) diff --git a/airsync-mac/Core/Storage/UserDefaults.swift b/airsync-mac/Core/Storage/UserDefaults.swift index 345a6dc..ab2d2a7 100644 --- a/airsync-mac/Core/Storage/UserDefaults.swift +++ b/airsync-mac/Core/Storage/UserDefaults.swift @@ -26,6 +26,7 @@ extension UserDefaults { static let continueApp = "continueApp" static let directKeyInput = "directKeyInput" static let sendNowPlayingStatus = "sendNowPlayingStatus" + static let syncAndroidPlaybackSeekbar = "syncAndroidPlaybackSeekbar" static let isMusicCardHidden = "isMusicCardHidden" static let lastOnboarding = "lastOnboarding" @@ -130,6 +131,15 @@ extension UserDefaults { set { set(newValue, forKey: Keys.sendNowPlayingStatus)} } + /// When enabled, AirSync plays a silent audio loop to claim macOS Now Playing focus, + /// allowing the Android playback seekbar to be exposed in boringNotch / Control Center. + /// Disabled by default because it causes Bluetooth multipoint headphones to route + /// audio to the Mac, preventing Android media from playing through the headphones. + var syncAndroidPlaybackSeekbar: Bool { + get { bool(forKey: Keys.syncAndroidPlaybackSeekbar) } + set { set(newValue, forKey: Keys.syncAndroidPlaybackSeekbar) } + } + var isMusicCardHidden: Bool { get { bool(forKey: Keys.isMusicCardHidden) } set { set(newValue, forKey: Keys.isMusicCardHidden) } diff --git a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift index 835bec1..4da0ad4 100644 --- a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift +++ b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift @@ -108,6 +108,21 @@ class MacInfoSyncManager: ObservableObject { self?.sendDeviceStatusWithoutMusic() return } + + // IMPORTANT: Filter out AirSync's own bundle ID. + // NowPlayingPublisher writes Android's media info into macOS + // MPNowPlayingInfoCenter so boringNotch can display it. + // media-control reads from the same source, so without this guard + // we'd forward AirSync's own published entry back to Android, + // creating a play/pause feedback loop. + let ownBundleId = Bundle.main.bundleIdentifier ?? "" + if let bundleId = info.bundleIdentifier, !ownBundleId.isEmpty, + bundleId == ownBundleId { + // This is our own reflection — treat as nothing playing on Mac + self?.sendDeviceStatusWithoutMusic() + return + } + // MUST update @Published properties on main thread DispatchQueue.main.async { // print("Now Playing fetched:", info) // debug diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index cd5d1ec..b9642d6 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -254,6 +254,32 @@ extension WebSocketServer { { let albumArt = (music["albumArt"] as? String) ?? "" let likeStatus = (music["likeStatus"] as? String) ?? "none" + let isBuffering = (music["isBuffering"] as? Bool) ?? false + + // Android sends duration/position in ms; convert to seconds. + // Using NSNumber because Swift's `as? Double` fails if the JSON parser inferred an Int. + let durationSec = (music["duration"] as? NSNumber).map { $0.doubleValue / 1000.0 } ?? -1.0 + var positionSec = (music["position"] as? NSNumber).map { $0.doubleValue / 1000.0 } ?? -1.0 + + // Timestamp-based position correction: + // Android includes the wall-clock ms when the position snapshot was taken. + // We add the elapsed time since then (which includes WiFi transit) to get a + // much more accurate "current" position — effectively NTP-style compensation. + // Clamp: only correct for realistic WiFi delays (< 5s). Larger deltas likely + // indicate clock skew between devices, which would worsen accuracy if applied. + if positionSec >= 0, playing, !isBuffering, + let tsMs = music["positionTimestamp"] as? NSNumber { + let capturedAt = tsMs.doubleValue / 1000.0 + let nowSec = Date().timeIntervalSince1970 + let networkDelta = nowSec - capturedAt + if networkDelta > -2.0 && networkDelta < 5.0 { + positionSec += max(0.0, networkDelta) + } + } + // Clamp to duration to prevent the seekbar going past the end + if durationSec > 0 && positionSec > durationSec { + positionSec = durationSec + } AppState.shared.status = DeviceStatus( battery: .init(level: level, isCharging: isCharging), @@ -265,19 +291,38 @@ extension WebSocketServer { volume: volume, isMuted: isMuted, albumArt: albumArt, - likeStatus: likeStatus + likeStatus: likeStatus, + duration: durationSec, + position: positionSec, + isBuffering: isBuffering ) ) - // Publish Android now-playing info to MPNowPlayingInfoCenter - var npInfo = NowPlayingInfo() - npInfo.title = title - npInfo.artist = artist - npInfo.isPlaying = playing - if let data = Data(base64Encoded: albumArt) { - npInfo.artworkData = data + // Publish Android now-playing info to MPNowPlayingInfoCenter only when + // the user has opted in, because this requires playing silent audio which + // causes multipoint Bluetooth headphones to route audio to the Mac. + if UserDefaults.standard.syncAndroidPlaybackSeekbar { + var npInfo = NowPlayingInfo() + npInfo.title = title + npInfo.artist = artist + npInfo.isPlaying = playing + if let data = Data(base64Encoded: albumArt) { + npInfo.artworkData = data + } + // Seekbar: Android sends duration/position in ms; MPNowPlayingInfoCenter needs seconds. + // positionMs uses optDouble so missing/null safely falls back to -1. + // NOTE: Use NSNumber because Swift's JSON parser returns an Int type for flat numbers. + if let nsNum = music["duration"] as? NSNumber, nsNum.doubleValue > 0 { + npInfo.duration = nsNum.doubleValue / 1000.0 + } + if let pMs = music["position"] as? NSNumber, pMs.doubleValue >= 0 { + npInfo.elapsedTime = pMs.doubleValue / 1000.0 + } + NowPlayingPublisher.shared.update(info: npInfo) + } else { + // If the setting is off, ensure any previously running session is cleared + NowPlayingPublisher.shared.clear() } - NowPlayingPublisher.shared.update(info: npInfo) } } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift index 865fd1a..c74f42f 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift @@ -108,6 +108,12 @@ extension WebSocketServer { func like() { sendMediaAction("like") } func unlike() { sendMediaAction("unlike") } + /// Seek Android playback to a specific position (in seconds). + func seekTo(positionSeconds: Double) { + let positionMs = Int(positionSeconds * 1000) + sendMessage(type: "mediaControl", data: ["action": "seekTo", "positionMs": positionMs]) + } + private func sendMediaAction(_ action: String) { sendMessage(type: "mediaControl", data: ["action": action]) } diff --git a/airsync-mac/Model/Device.swift b/airsync-mac/Model/Device.swift index d4528c4..fd84cfc 100644 --- a/airsync-mac/Model/Device.swift +++ b/airsync-mac/Model/Device.swift @@ -47,7 +47,10 @@ struct MockData{ volume: 50, isMuted: false, albumArt: "", - likeStatus: "none" + likeStatus: "none", + duration: 214, + position: 42, + isBuffering: false ) static let sampleDevices = [ diff --git a/airsync-mac/Model/DeviceStatus.swift b/airsync-mac/Model/DeviceStatus.swift index 767b5e1..014a917 100644 --- a/airsync-mac/Model/DeviceStatus.swift +++ b/airsync-mac/Model/DeviceStatus.swift @@ -21,6 +21,12 @@ struct DeviceStatus: Codable { let isMuted: Bool let albumArt: String let likeStatus: String + /// Total track duration in seconds. -1 means not available. + let duration: Double + /// Current playback position in seconds (corrected for network transit on Mac side). + let position: Double + /// True when Android is buffering — position is frozen, Mac timer should pause. + let isBuffering: Bool } let battery: Battery diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift index f3000b0..de0b7e6 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift @@ -6,15 +6,158 @@ // import SwiftUI +import Combine + +// MARK: - Seekbar sub-view + +private struct MediaSeekbarView: View { + let music: DeviceStatus.Music + + @State private var displayedPosition: Double = 0 + @State private var isDragging = false + /// When the user last performed a seek (to enforce a blackout window) + @State private var lastSeekTime: Date = .distantPast + /// The position the user seeked to, used for delta-based stale-packet rejection + @State private var seekTargetPosition: Double = -1 + + // A single declarative timer that ticks every second. + // We simply ignore ticks when paused or dragging. + @State private var timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() + + var body: some View { + VStack(spacing: 2) { + // Slider + Slider( + value: $displayedPosition, + in: 0...max(music.duration, 1), + onEditingChanged: { editing in + isDragging = editing + if !editing { + seekTargetPosition = displayedPosition + lastSeekTime = Date() + WebSocketServer.shared.seekTo(positionSeconds: displayedPosition) + } + } + ) + .accentColor(.primary) + .padding(.horizontal, 2) + + // Time labels + HStack { + Text(formatTime(displayedPosition)) + Spacer() + Text(formatTime(music.duration)) + } + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + } + .onAppear { syncFromStatus() } + .onChange(of: music.position) { _ in + guard !isDragging else { return } + let incoming = music.position >= 0 ? music.position : 0 + let sinceSeeked = Date().timeIntervalSince(lastSeekTime) + + // Seek-target confirmation guard (replaces the old 5-second blanket blackout): + // After a Mac-initiated seek, we know the target position. Instead of blocking + // everything for N seconds, we selectively accept packets that land near our target + // and reject packets that are still at the old pre-seek position. + // + // - seekTargetPosition >= 0 means we have a pending unconfirmed Mac seek. + // - Accept: incoming is within 10s of the target → fresh post-seek packet. Confirmed! + // - Reject: incoming is far behind the target → stale pre-seek packet. Drop it. + // - Expire: if 10 seconds pass without confirmation, clear the guard and resume normally. + if seekTargetPosition >= 0 && sinceSeeked < 10.0 { + // A packet is a "fresh confirmation" only if its position is within 10 seconds + // of the seek target in EITHER direction (handles both forward and backward seeks). + // + // Forward seek (3:00→6:00): stale=3:05 → |3:05-6:00|=175s → reject ✅ + // fresh=6:01 → |6:01-6:00|=1s → accept ✅ + // + // Backward seek (6:00→3:00): stale=6:05 → |6:05-3:00|=185s → reject ✅ + // fresh=3:01 → |3:01-3:00|=1s → accept ✅ + if abs(incoming - seekTargetPosition) <= 10.0 { + seekTargetPosition = -1 + syncFromStatus() + } + // else: stale packet (far from seek target), drop silently. + return + } + // If we get here, either no pending seek or the 10s guard expired. + // Clear any stale seekTargetPosition. + if seekTargetPosition >= 0 { seekTargetPosition = -1 } + + // Residual delta guard (8s window): catches very delayed stale packets that + // somehow slipped past the seek-target guard after it expired. + if sinceSeeked < 8.0 && incoming < displayedPosition - 5.0 { return } + + syncFromStatus() + } + .onReceive(timer) { _ in + // Declarative tick: advance only when playing, not buffering, and not dragging + guard music.isPlaying, !music.isBuffering, !isDragging else { return } + let next = displayedPosition + 1.0 + displayedPosition = music.duration > 0 ? min(next, music.duration) : next + } + .onChange(of: music.title) { _ in + // Track changed — immediately clear the seek guard and adopt the new position. + // Without this, the seek-target filter would reject the new song's 0:00 start + // as a "stale packet" for up to 10 seconds if a seek was recent. + seekTargetPosition = -1 + lastSeekTime = .distantPast + syncFromStatus() + } + } + + // MARK: - Helpers + + private func syncFromStatus() { + guard music.position >= 0 else { return } + let incoming = music.position + let delta = incoming - displayedPosition // positive = Android ahead, negative = Android behind + + // Asymmetric dead zone: + // + // • Android ahead of Mac by > 3s → we drifted behind → snap forward. + // This is uncommon (Mac's local timer usually runs slightly ahead). + // + // • Android behind Mac by > 10s → genuine desync (Android was buffering/paused and + // we didn't catch it) → snap backward to re-sync. + // + // • |delta| <= threshold → ignore. The Mac's local timer is already close. + // In particular, this swallows the "7:50 arrives while Mac is at 7:55" case + // (Mac ran 5s ahead of the stale packet → within the 10s backward tolerance → no snap). + if delta > 3.0 || delta < -10.0 { + displayedPosition = incoming + } + } + + private func formatTime(_ seconds: Double) -> String { + guard seconds >= 0 else { return "--:--" } + let s = Int(seconds) + let m = s / 60 + let h = m / 60 + if h > 0 { + return String(format: "%d:%02d:%02d", h, m % 60, s % 60) + } + return String(format: "%d:%02d", m, s % 60) + } +} + +// MARK: - Main MediaPlayerView struct MediaPlayerView: View { var music: DeviceStatus.Music @State private var showingPlusPopover = false + @AppStorage("syncAndroidPlaybackSeekbar") private var syncSeekbar: Bool = false - var body: some View { - ZStack{ + private var hasSeekbar: Bool { + music.duration > 0 && syncSeekbar + } - VStack{ + var body: some View { + ZStack { + VStack(spacing: 6) { + // Title + artist HStack(spacing: 4) { Image(systemName: "music.note.list") EllipsesTextView( @@ -26,82 +169,68 @@ struct MediaPlayerView: View { EllipsesTextView( text: music.artist, - font: .footnote, + font: .footnote ) - - Group { if AppState.shared.isPlus && AppState.shared.licenseCheck { - HStack{ - if (AppState.shared.status?.music.likeStatus == "liked" || AppState.shared.status?.music.likeStatus == "not_liked") { - GlassButtonView( - label: "", - systemImage: { - if let like = AppState.shared.status?.music.likeStatus { - switch like { - case "liked": return "heart.fill" + VStack(spacing: 6) { + // Seekbar (shown only when duration is known and toggle is enabled) + if hasSeekbar { + MediaSeekbarView(music: music) + .padding(.top, 2) + .transition(.opacity.combined(with: .scale(scale: 0.97))) + } + + // Media control buttons + HStack { + if music.likeStatus == "liked" || music.likeStatus == "not_liked" { + GlassButtonView( + label: "", + systemImage: { + switch music.likeStatus { + case "liked": return "heart.fill" case "not_liked": return "heart" - default: return "heart.slash" + default: return "heart.slash" + } + }(), + iconOnly: true, + action: { + if music.likeStatus == "liked" { + WebSocketServer.shared.unlike() + } else { + WebSocketServer.shared.like() } } - return "heart.slash" - }(), - iconOnly: true, - action: { - guard let like = AppState.shared.status?.music.likeStatus else { return } - if like == "liked" { - WebSocketServer.shared.unlike() - } else if like == "not_liked" { - WebSocketServer.shared.like() - } else { - WebSocketServer.shared.toggleLike() - } - } - ) - .help("Like / Unlike") - } else { + ) + .help("Like / Unlike") + } else { + GlassButtonView( + label: "", + systemImage: "backward.end", + iconOnly: true, + action: { WebSocketServer.shared.skipPrevious() } + ) + .keyboardShortcut(.leftArrow, modifiers: .control) + } - GlassButtonView( - label: "", - systemImage: "backward.end", - iconOnly: true, - action: { - WebSocketServer.shared.skipPrevious() - } - ) - .keyboardShortcut( - .leftArrow, - modifiers: .control - ) - } - GlassButtonView( label: "", systemImage: music.isPlaying ? "pause.fill" : "play.fill", iconOnly: true, primary: true, - action: { - WebSocketServer.shared.togglePlayPause() - } - ) - .keyboardShortcut( - .space, - modifiers: .control + action: { WebSocketServer.shared.togglePlayPause() } ) + .keyboardShortcut(.space, modifiers: .control) GlassButtonView( label: "", systemImage: "forward.end", iconOnly: true, - action: { - WebSocketServer.shared.skipNext() - } - ) - .keyboardShortcut( - .rightArrow, - modifiers: .control + action: { WebSocketServer.shared.skipNext() } ) + .keyboardShortcut(.rightArrow, modifiers: .control) + } } } } @@ -117,7 +246,6 @@ struct MediaPlayerView: View { } } - #Preview { MediaPlayerView(music: MockData.sampleMusic) } diff --git a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift index a1f920c..621eed2 100644 --- a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift +++ b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift @@ -19,6 +19,7 @@ struct SettingsFeaturesView: View { @AppStorage("manualPosition") private var manualPosition = false @AppStorage("continueApp") private var continueApp = false @AppStorage("directKeyInput") private var directKeyInput = true + @AppStorage("syncAndroidPlaybackSeekbar") private var syncAndroidPlaybackSeekbar = false @AppStorage("scrcpyDesktopDpi") private var scrcpyDesktopDpi = "" @State private var showingPlusPopover = false @@ -372,6 +373,24 @@ struct SettingsFeaturesView: View { } SettingsToggleView(name: "Send now playing status", icon: "play.circle", isOn: $appState.sendNowPlayingStatus) + + HStack { + Label("Sync Android playback seekbar", systemImage: "slider.horizontal.below.rectangle") + Button(action: {}) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Publishes Android media info (track, artist, artwork, seekbar position) to macOS Now Playing / boringNotch by playing a silent audio loop in the background.\n\nⓘ Multipoint Bluetooth users: this may cause your headphones to switch audio focus to the Mac, interrupting Android audio. Disable this toggle if that happens.") + Spacer() + Toggle("", isOn: $syncAndroidPlaybackSeekbar) + .toggleStyle(.switch) + .onChange(of: syncAndroidPlaybackSeekbar) { _, enabled in + if !enabled { + NowPlayingPublisher.shared.clear() + } + } + } } .padding() .background(.background.opacity(0.3))