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 9512178f..b9642d6b 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,9 +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 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..de0b7e6e 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 a1f920c6..621eed29 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 66832d88..1f9e3191 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)