diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 6500e4ec..4cce040a 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -119,6 +119,8 @@ class AppState: ObservableObject { // Reset mirroring state on launch to prevent auto-opening if it was open during last session self.isNativeMirroring = false + + startMediaTimer() } @Published var minAndroidVersion = Bundle.main.infoDictionary?["AndroidVersion"] as? String ?? "2.0.0" @@ -189,6 +191,13 @@ class AppState: ObservableObject { @Published var recentApps: [AndroidApp] = [] @Published var isNativeMirroring: Bool = false + // MARK: - Centralized Media Seekbar State + @Published var mediaPosition: Double = 0 + var isDraggingMedia: Bool = false + var lastMediaSeekTime: Date = .distantPast + var seekTargetPosition: Double = -1 + private var mediaTickTimer: AnyCancellable? + var isConnectedOverLocalNetwork: Bool { guard let ip = device?.ipAddress else { return true } // Tailscale IPs usually start with 100. @@ -1222,4 +1231,69 @@ class AppState: ObservableObject { print("[state] Using saved network adapter: \(savedName) -> \(validIP)") return savedName } + + // MARK: - Media Seekbar Sync Logic + + func startMediaTimer() { + guard mediaTickTimer == nil else { return } + mediaTickTimer = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + guard let self = self, + let music = self.status?.music, + music.isPlaying, + !music.isBuffering, + !self.isDraggingMedia else { return } + + let next = self.mediaPosition + 1.0 + self.mediaPosition = music.duration > 0 ? min(next, music.duration) : next + } + } + + func stopMediaTimer() { + mediaTickTimer?.cancel() + mediaTickTimer = nil + } + + func syncMediaPosition(incoming: Double) { + guard incoming >= 0 else { return } + + let sinceSeeked = Date().timeIntervalSince(lastMediaSeekTime) + + if seekTargetPosition >= 0 && sinceSeeked < 10.0 { + if abs(incoming - seekTargetPosition) <= 10.0 { + seekTargetPosition = -1 + applyMediaPosition(incoming) + } + return + } + + if seekTargetPosition >= 0 { seekTargetPosition = -1 } + + if sinceSeeked < 8.0 && incoming < mediaPosition - 5.0 { return } + + applyMediaPosition(incoming) + } + + private func applyMediaPosition(_ incoming: Double) { + let delta = incoming - mediaPosition + if delta > 3.0 || delta < -10.0 { + mediaPosition = incoming + } + } + + func handleMediaSeek(to position: Double) { + seekTargetPosition = position + lastMediaSeekTime = Date() + mediaPosition = position + WebSocketServer.shared.seekTo(positionSeconds: position) + } + + func handleTrackChange() { + seekTargetPosition = -1 + lastMediaSeekTime = .distantPast + if let pos = status?.music.position { + syncMediaPosition(incoming: pos) + } + } } diff --git a/airsync-mac/Core/Media/NowPlayingPublisher.swift b/airsync-mac/Core/Media/NowPlayingPublisher.swift new file mode 100644 index 00000000..af494869 --- /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/Storage/UserDefaults.swift b/airsync-mac/Core/Storage/UserDefaults.swift index 345a6dc6..ab2d2a7d 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 835bec1c..4da0ad4c 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 2d04f6eb..40c0cf2d 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -254,6 +254,34 @@ 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 + } + + let oldTitle = AppState.shared.status?.music.title AppState.shared.status = DeviceStatus( battery: .init(level: level, isCharging: isCharging), @@ -265,9 +293,46 @@ extension WebSocketServer { volume: volume, isMuted: isMuted, albumArt: albumArt, - likeStatus: likeStatus + likeStatus: likeStatus, + duration: durationSec, + position: positionSec, + isBuffering: isBuffering ) ) + + DispatchQueue.main.async { + if oldTitle != title { + AppState.shared.handleTrackChange() + } else { + AppState.shared.syncMediaPosition(incoming: positionSec) + } + } + + // 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() + } } } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift index 8a5e091c..c74f42fe 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift @@ -108,10 +108,32 @@ 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]) } + /// 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/Model/Device.swift b/airsync-mac/Model/Device.swift index d4528c41..fd84cfc6 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 767b5e12..014a9177 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 f3000b0a..df98b8c3 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift @@ -6,15 +6,68 @@ // import SwiftUI +import Combine + +// MARK: - Seekbar sub-view + +private struct MediaSeekbarView: View { + let music: DeviceStatus.Music + @ObservedObject var appState = AppState.shared + + var body: some View { + VStack(spacing: 2) { + // Slider + Slider( + value: $appState.mediaPosition, + in: 0...max(music.duration, 1), + onEditingChanged: { editing in + appState.isDraggingMedia = editing + if !editing { + appState.handleMediaSeek(to: appState.mediaPosition) + } + } + ) + .accentColor(.primary) + .padding(.horizontal, 2) + + // Time labels + HStack { + Text(formatTime(appState.mediaPosition)) + Spacer() + Text(formatTime(music.duration)) + } + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + + 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 +79,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 +156,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 56cf31f4..cb02e947 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)) diff --git a/airsync-mac/airsync_macApp.swift b/airsync-mac/airsync_macApp.swift index 56b14da9..739c83d4 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) diff --git a/appcast.xml b/appcast.xml index 7d37b3d8..4d9f0d22 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,12 +3,12 @@ AirSync - 3.0.0 - Sat, 14 Mar 2026 21:31:01 +0530 - 25 - 3.0.0 + 3.2.0 + Sat, 09 May 2026 14:14:48 +0530 + 28 + 3.2.0 14.5 - +