Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "adb-pair-prompt.jpeg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions airsync-mac/Assets.xcassets/Images/adb-pair.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "adb-pair.jpeg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
195 changes: 195 additions & 0 deletions airsync-mac/Components/Text/MarqueeText.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
//
// MarqueeText.swift
// AirSync
//
// Created by Sameera Sandakelum on 2026-05-28.
//

import SwiftUI
import AppKit

/// Seamlessly looping marquee text backed by Core Animation (zero CPU per frame).
/// Falls back to a static view when the text fits within `containerWidth`.
struct MarqueeText: NSViewRepresentable {
let text: String
var fontSize: CGFloat = 12
var fontWeight: NSFont.Weight = .regular
var containerWidth: CGFloat
/// Scroll speed in points per second.
var speed: Double = 40
/// Gap between the end of one copy and the start of the next.
var gap: CGFloat = 44

func makeNSView(context: Context) -> MarqueeNSView {
MarqueeNSView()
}

func updateNSView(_ nsView: MarqueeNSView, context: Context) {
nsView.update(
text: text,
fontSize: fontSize,
fontWeight: fontWeight,
containerWidth: containerWidth,
speed: speed,
gap: gap
)
}

func sizeThatFits(_ proposal: ProposedViewSize, nsView: MarqueeNSView, context: Context) -> CGSize? {
CGSize(width: containerWidth, height: nsView.contentHeight)
}
}

// MARK: - NSView

final class MarqueeNSView: NSView {
private(set) var contentHeight: CGFloat = 16

private let clipLayer = CALayer()
private let contentLayer = CALayer()
private let textLayer1 = CATextLayer()
private let textLayer2 = CATextLayer()

// Track last values to avoid unnecessary redraws
private var lastText = ""
private var lastFontSize: CGFloat = -1
private var lastFontWeight: NSFont.Weight = .regular
private var lastContainerWidth: CGFloat = -1
private var lastSpeed: Double = -1
private var lastGap: CGFloat = -1

override init(frame: NSRect) {
super.init(frame: frame)
buildLayers()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
buildLayers()
}

private func buildLayers() {
wantsLayer = true
layer?.masksToBounds = true

clipLayer.masksToBounds = true
layer?.addSublayer(clipLayer)

contentLayer.masksToBounds = false
clipLayer.addSublayer(contentLayer)

let scale = NSScreen.main?.backingScaleFactor ?? 2.0
for tl in [textLayer1, textLayer2] {
tl.contentsScale = scale
tl.truncationMode = .none
tl.isWrapped = false
tl.alignmentMode = .left
contentLayer.addSublayer(tl)
}
}

func update(text: String, fontSize: CGFloat, fontWeight: NSFont.Weight,
containerWidth: CGFloat, speed: Double, gap: CGFloat) {
let changed = text != lastText
|| fontSize != lastFontSize
|| fontWeight != lastFontWeight
|| containerWidth != lastContainerWidth
|| speed != lastSpeed
|| gap != lastGap
guard changed else { return }

lastText = text
lastFontSize = fontSize
lastFontWeight = fontWeight
lastContainerWidth = containerWidth
lastSpeed = speed
lastGap = gap

refresh()
}

// Called on system dark/light mode switch
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
applyTextColor()
}

override var intrinsicContentSize: NSSize {
NSSize(width: lastContainerWidth, height: contentHeight)
}

// MARK: - Layout & Animation

private func refresh() {
let nsFont = NSFont.systemFont(ofSize: lastFontSize, weight: lastFontWeight)
let attrs: [NSAttributedString.Key: Any] = [.font: nsFont]
let measured = (lastText as NSString).size(withAttributes: attrs)
let tw = ceil(measured.width)
let th = ceil(measured.height)
contentHeight = th

let loopWidth = tw + lastGap
let needsScroll = tw > lastContainerWidth

CATransaction.begin()
CATransaction.setDisableActions(true)

let newFrame = NSRect(x: 0, y: 0, width: lastContainerWidth, height: th)
if frame != newFrame {
frame = newFrame
invalidateIntrinsicContentSize()
}

clipLayer.frame = CGRect(x: 0, y: 0, width: lastContainerWidth, height: th)

for tl in [textLayer1, textLayer2] {
tl.string = lastText
tl.font = nsFont
tl.fontSize = lastFontSize
}

if needsScroll {
contentLayer.frame = CGRect(x: 0, y: 0, width: loopWidth * 2, height: th)
textLayer1.frame = CGRect(x: 0, y: 0, width: tw, height: th)
textLayer2.frame = CGRect(x: loopWidth, y: 0, width: tw, height: th)
textLayer2.isHidden = false
} else {
contentLayer.frame = CGRect(x: 0, y: 0, width: tw, height: th)
textLayer1.frame = CGRect(x: 0, y: 0, width: tw, height: th)
textLayer2.isHidden = true
}

CATransaction.commit()

applyTextColor()

// Restart scroll animation
contentLayer.removeAnimation(forKey: "marquee")
guard needsScroll else { return }

// Reset model position so beginTime fill works correctly
contentLayer.setValue(0, forKeyPath: "transform.translation.x")

let anim = CABasicAnimation(keyPath: "transform.translation.x")
anim.fromValue = 0
anim.toValue = -loopWidth
anim.duration = CFTimeInterval(loopWidth) / lastSpeed
anim.repeatCount = .infinity
anim.isRemovedOnCompletion = false
anim.fillMode = .backwards
anim.beginTime = CACurrentMediaTime() + 1.0 // 1s initial pause
contentLayer.add(anim, forKey: "marquee")
}

private func applyTextColor() {
var resolved: CGColor = NSColor.labelColor.cgColor
effectiveAppearance.performAsCurrentDrawingAppearance {
resolved = NSColor.labelColor.cgColor
}
CATransaction.begin()
CATransaction.setDisableActions(true)
textLayer1.foregroundColor = resolved
textLayer2.foregroundColor = resolved
CATransaction.commit()
}
}
50 changes: 36 additions & 14 deletions airsync-mac/Core/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ class AppState: ObservableObject {
self.showMenubarDeviceName = UserDefaults.standard.object(forKey: "showMenubarDeviceName") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarDeviceName")

let savedMaxLength = UserDefaults.standard.integer(forKey: "menubarTextMaxLength")
self.menubarTextMaxLength = savedMaxLength > 0 ? savedMaxLength : 30
// Values < 50 are from the old char-count era; migrate them to the new point-width default
self.menubarTextMaxLength = (savedMaxLength >= 50) ? savedMaxLength : 150

self.showMenubarIcon = UserDefaults.standard.object(forKey: "showMenubarIcon") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarIcon")
self.menubarBatteryStyle = UserDefaults.standard.string(forKey: "menubarBatteryStyle") ?? "both"
Expand All @@ -59,6 +60,7 @@ class AppState: ObservableObject {
self.showMenubarCallDetails = UserDefaults.standard.bool(forKey: "showMenubarCallDetails") && (!licenseCheck || isPlusLoaded)
}
self.menubarFontSize = UserDefaults.standard.object(forKey: "menubarFontSize") == nil ? 12.0 : UserDefaults.standard.double(forKey: "menubarFontSize")
self.enableMarquee = UserDefaults.standard.bool(forKey: "enableMarquee")
self.menubarUnreadBadgeStyle = UserDefaults.standard.string(forKey: "menubarUnreadBadgeStyle") ?? "badge"
self.menubarUnreadBadgeColor = UserDefaults.standard.string(forKey: "menubarUnreadBadgeColor") ?? "accent"
self.showMenubarPillStroke = UserDefaults.standard.bool(forKey: "showMenubarPillStroke")
Expand Down Expand Up @@ -282,6 +284,7 @@ class AppState: ObservableObject {
}
}
@Published var shouldRefreshQR: Bool = false
@Published var isConnectionWeak: Bool = false
@Published var webSocketStatus: WebSocketStatus = .stopped
@Published var selectedTab: TabIdentifier = .qr
@Published var selectedSettingsTab: SettingsTab = .myMac
Expand Down Expand Up @@ -342,6 +345,12 @@ class AppState: ObservableObject {
}
}

@Published var enableMarquee: Bool {
didSet {
UserDefaults.standard.set(enableMarquee, forKey: "enableMarquee")
}
}

@Published var showMenubarIcon: Bool {
didSet {
UserDefaults.standard.set(showMenubarIcon, forKey: "showMenubarIcon")
Expand Down Expand Up @@ -942,7 +951,7 @@ class AppState: ObservableObject {
withAnimation {
self.notifications.removeAll { $0.id == notif.id }
}
self.removeNotification(notif)
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [notif.nid])
}
}

Expand All @@ -964,6 +973,7 @@ class AppState: ObservableObject {

// Then locally reset state
self.device = nil
self.isConnectionWeak = false
self.activeMacIp = nil
self.notifications.removeAll()
self.status = nil
Expand Down Expand Up @@ -1056,12 +1066,19 @@ class AppState: ObservableObject {

func addNotification(_ notif: Notification) {
DispatchQueue.main.async {
var contentChanged = true
withAnimation {
self.notifications.insert(notif, at: 0)
if let idx = self.notifications.firstIndex(where: { $0.nid == notif.nid }) {
let old = self.notifications[idx]
contentChanged = (old.title != notif.title || old.body != notif.body || old.actions != notif.actions)
self.notifications[idx] = notif
} else {
self.notifications.insert(notif, at: 0)
}
}
// Trigger native macOS notification if not silent
// Trigger native macOS notification if not silent and content actually changed/new
// Default to alerting if priority is missing (backwards compatibility)
if notif.priority != "silent" {
if notif.priority != "silent" && contentChanged {
var appIcon: NSImage? = nil
if let iconPath = self.androidApps[notif.package]?.iconUrl {
appIcon = NSImage(contentsOfFile: iconPath)
Expand Down Expand Up @@ -1194,17 +1211,22 @@ class AppState: ObservableObject {
}

func syncWithSystemNotifications() {
UNUserNotificationCenter.current().getDeliveredNotifications { systemNotifs in
let systemNIDs = Set(systemNotifs.map { $0.request.identifier })
UNUserNotificationCenter.current().getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized else {
return
}
UNUserNotificationCenter.current().getDeliveredNotifications { systemNotifs in
let systemNIDs = Set(systemNotifs.map { $0.request.identifier })

DispatchQueue.main.async {
// Only sync notifications that were actually posted to system (non-silent)
let currentSystemNIDs = Set(self.notifications.filter { $0.priority != "silent" }.map { $0.nid })
let removedNIDs = currentSystemNIDs.subtracting(systemNIDs)
DispatchQueue.main.async {
// Only sync notifications that were actually posted to system (non-silent)
let currentSystemNIDs = Set(self.notifications.filter { $0.priority != "silent" }.map { $0.nid })
let removedNIDs = currentSystemNIDs.subtracting(systemNIDs)

for nid in removedNIDs {
print("[state] (notification) System notification \(nid) was dismissed manually.")
self.removeNotificationById(nid)
for nid in removedNIDs {
print("[state] (notification) System notification \(nid) was dismissed manually.")
self.removeNotificationById(nid)
}
}
}
}
Expand Down
Loading