From cf044b4d2928b9269f1eda00166c239d1e9ea811 Mon Sep 17 00:00:00 2001 From: xand0dev Date: Thu, 6 Aug 2026 00:06:32 +0300 Subject: [PATCH 01/11] Restore NowPlayingFeed for web media using safe MediaRemote bindings --- Sources/VoidBar/Services/NowPlayingFeed.swift | 94 +++++++++++++--- Sources/VoidBar/UI/DynamicIslandView.swift | 58 ++++++++++ Sources/VoidBar/UI/TeleprompterPane.swift | 102 ++++++++++++++++++ 3 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 Sources/VoidBar/UI/DynamicIslandView.swift create mode 100644 Sources/VoidBar/UI/TeleprompterPane.swift diff --git a/Sources/VoidBar/Services/NowPlayingFeed.swift b/Sources/VoidBar/Services/NowPlayingFeed.swift index 5e88729..8d8b450 100644 --- a/Sources/VoidBar/Services/NowPlayingFeed.swift +++ b/Sources/VoidBar/Services/NowPlayingFeed.swift @@ -1,6 +1,7 @@ import AppKit +import Foundation -/// Reads the Now Playing state and turns it into snapshots. +/// Reads the Now Playing state and turns it into snapshots using MediaRemote directly. @MainActor final class NowPlayingFeed { struct Snapshot { @@ -11,9 +12,7 @@ final class NowPlayingFeed { var duration: TimeInterval = 0 var elapsed: TimeInterval = 0 var rate: Double = 0 - /// Only present on the update where the track changed. var artwork: Data? - /// Name of the app owning the session, resolved from its pid. var source: String? var isEmpty: Bool { title.isEmpty } @@ -24,24 +23,91 @@ final class NowPlayingFeed { } var onUpdate: ((Snapshot) -> Void)? - /// Raised when the feed cannot run at all, so the caller can fall back. var onUnavailable: (() -> Void)? - // MARK: - Lifecycle + typealias RegisterType = @convention(c) (DispatchQueue) -> Void + typealias GetInfoType = @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void + typealias SendCommandType = @convention(c) (UInt32, [String: Any]?) -> Void + + // MRNowPlayingClientGetBundleIdentifier + // void *client; CFStringRef bundleID = MRNowPlayingClientGetBundleIdentifier(client); + + private let getInfo: GetInfoType? + private let registerFunc: RegisterType? + private let sendCommandFunc: SendCommandType? + + init() { + let handle = dlopen("/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote", RTLD_NOW) + if let handle = handle { + let symGetInfo = dlsym(handle, "MRMediaRemoteGetNowPlayingInfo") + getInfo = symGetInfo != nil ? unsafeBitCast(symGetInfo, to: GetInfoType.self) : nil + + let symRegister = dlsym(handle, "MRMediaRemoteRegisterForNowPlayingNotifications") + registerFunc = symRegister != nil ? unsafeBitCast(symRegister, to: RegisterType.self) : nil + + let symSendCmd = dlsym(handle, "MRMediaRemoteSendCommand") + sendCommandFunc = symSendCmd != nil ? unsafeBitCast(symSendCmd, to: SendCommandType.self) : nil + } else { + getInfo = nil + registerFunc = nil + sendCommandFunc = nil + } + } func start() { - // The old media helper has been completely removed for security reasons. - // We immediately fall back to the safe AppleScript bridge. - DispatchQueue.main.async { [weak self] in - self?.onUnavailable?() + guard let registerFunc = registerFunc, let _ = getInfo else { + DispatchQueue.main.async { [weak self] in self?.onUnavailable?() } + return + } + + registerFunc(DispatchQueue.main) + + let nc = NotificationCenter.default + nc.addObserver(forName: NSNotification.Name("kMRMediaRemoteNowPlayingInfoDidChangeNotification"), object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.refresh() } + } + nc.addObserver(forName: NSNotification.Name("kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification"), object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.refresh() } + } + nc.addObserver(forName: NSNotification.Name("kMRMediaRemoteNowPlayingApplicationDidChangeNotification"), object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.refresh() } } + + refresh() } - func stop() {} + func stop() { + NotificationCenter.default.removeObserver(self) + } + + func refresh() { + guard let getInfo = getInfo else { return } + + getInfo(DispatchQueue.main) { [weak self] info in + guard let self = self else { return } + var snap = Snapshot() + + snap.title = info["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? "" + snap.artist = info["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? "" + snap.album = info["kMRMediaRemoteNowPlayingInfoAlbum"] as? String ?? "" + snap.duration = info["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval ?? 0 + snap.elapsed = info["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval ?? 0 + snap.rate = info["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 0 + snap.artwork = info["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data + + // To be safe, if we have rate > 0, we can consider it playing + snap.isPlaying = snap.rate > 0 + + self.onUpdate?(snap) + } + } - // MARK: - Commands + func send(_ command: Command) { + sendCommandFunc?(UInt32(command.rawValue), nil) + } - func refresh() {} - func send(_ command: Command) {} - func seek(to seconds: TimeInterval) {} + func seek(to seconds: TimeInterval) { + // 18 is kMRMediaRemoteCommandSeekToPlaybackPosition + sendCommandFunc?(18, ["kMRMediaRemoteOptionPlaybackPosition": seconds]) + } } diff --git a/Sources/VoidBar/UI/DynamicIslandView.swift b/Sources/VoidBar/UI/DynamicIslandView.swift new file mode 100644 index 0000000..513a08f --- /dev/null +++ b/Sources/VoidBar/UI/DynamicIslandView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct DynamicIslandView: View { + @ObservedObject var vm: NotchViewModel + + // The width of the actual physical notch + private var notchWidth: CGFloat { vm.geometry.notchSize.width } + // The height of the physical notch + private var notchHeight: CGFloat { vm.geometry.notchSize.height } + + // Animation for equalizer + @State private var phase: CGFloat = 0 + + var body: some View { + // We position the islands exactly outside the physical notch width. + // We are drawing inside a frame that is `size.width + 2*topRadius` wide. + // The center of this frame corresponds to the center of the notch. + + ZStack { + if vm.media.isPlaying { + // Left Wing (Equalizer / Icon) + HStack { + Spacer() // push to right edge of the left wing + EqualizerBars(isAnimating: true) + .padding(.trailing, 10) + } + .frame(width: 44, height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + .offset(x: -notchWidth / 2 - 22 + 8) // Overlap slightly to merge with notch + + // Right Wing (Timer / Source) + HStack { + if vm.media.track != nil { + // Very simple static text for now, ideally an updating timer + Text(formatTime(vm.media.position)) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundColor(Color.white) + .padding(.leading, 10) + } + Spacer() + } + .frame(width: 44, height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + .offset(x: notchWidth / 2 + 22 - 8) + } + } + .frame(width: notchWidth, height: notchHeight) // Center aligns with notch + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: vm.media.isPlaying) + } + + private func formatTime(_ seconds: Double) -> String { + let mins = Int(seconds) / 60 + let secs = Int(seconds) % 60 + return String(format: "%d:%02d", mins, secs) + } +} diff --git a/Sources/VoidBar/UI/TeleprompterPane.swift b/Sources/VoidBar/UI/TeleprompterPane.swift new file mode 100644 index 0000000..58b5655 --- /dev/null +++ b/Sources/VoidBar/UI/TeleprompterPane.swift @@ -0,0 +1,102 @@ +import SwiftUI + +struct TeleprompterPane: View { + @ObservedObject var store: TeleprompterStore + @FocusState private var isFocused: Bool + + // For auto-scrolling + @State private var offset: CGFloat = 0 + @State private var timer: Timer? + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text(localized("Teleprompter")) + .font(.headline) + .foregroundColor(Theme.secondary) + + Spacer() + + // Controls + Slider(value: $store.speed, in: 0.2...3.0, step: 0.1) + .frame(width: 80) + .tint(Color.white) + + Button { + togglePlay() + } label: { + Image(systemName: store.isPlaying ? "pause.fill" : "play.fill") + .foregroundColor(store.isPlaying ? Color.white : Theme.secondary) + .padding(6) + .background(Theme.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + + // Content + ZStack(alignment: .top) { + if store.isPlaying { + // Playing mode: Auto-scrolling text + GeometryReader { geo in + ScrollView(.vertical, showsIndicators: false) { + Text(store.text) + .font(.system(size: 24, weight: .medium, design: .default)) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.vertical, geo.size.height / 2) // Start from middle + .offset(y: -offset) + .frame(maxWidth: .infinity) + } + .disabled(true) // Disable manual scroll while playing + } + } else { + // Edit mode: Standard TextEditor + TextEditor(text: $store.text) + .font(.system(size: 16)) + .foregroundColor(Color.white) + .scrollContentBackground(.hidden) + .background(Color.clear) + .padding(.horizontal, 12) + .focused($isFocused) + } + } + .frame(maxHeight: .infinity) + } + .onDisappear { + stopTimer() + } + } + + private func togglePlay() { + store.isPlaying.toggle() + if store.isPlaying { + isFocused = false + offset = 0 // Reset scroll + startTimer() + } else { + stopTimer() + } + } + + private func startTimer() { + timer?.invalidate() + // 60 fps + timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { _ in + MainActor.assumeIsolated { + offset += store.speed + } + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + store.isPlaying = false + } +} From b6f2e282c67abc6d538c117d929df9b736b6c615 Mon Sep 17 00:00:00 2001 From: xand0dev Date: Thu, 6 Aug 2026 00:13:47 +0300 Subject: [PATCH 02/11] Restore media helper to bypass MediaRemote platform binary restrictions --- Scripts/bundle.sh | 4 + Sources/VoidBar/Services/NowPlayingFeed.swift | 190 +++++++++++------- Sources/VoidBarMediaHelper/helper.m | 151 ++++++++++++++ 3 files changed, 277 insertions(+), 68 deletions(-) create mode 100644 Sources/VoidBarMediaHelper/helper.m diff --git a/Scripts/bundle.sh b/Scripts/bundle.sh index 1d7cd79..7bd0823 100755 --- a/Scripts/bundle.sh +++ b/Scripts/bundle.sh @@ -60,6 +60,10 @@ if [ -f "$ROOT/Resources/AppIcon.icns" ]; then cp "$ROOT/Resources/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns" fi +echo "==> compiling media helper" +clang -fobjc-arc -dynamiclib -o "$APP/Contents/Resources/libvoidmedia.dylib" \ + "$ROOT/Sources/VoidBarMediaHelper/helper.m" + # Таблицы строк кладутся прямо в бандл, а не через ресурсы SwiftPM: бандл здесь # собирается вручную, и .lproj рядом с исполняемым файлом — то, где их ищет сама # macOS. Язык она выбирает потом сама, по списку предпочитаемых у пользователя. diff --git a/Sources/VoidBar/Services/NowPlayingFeed.swift b/Sources/VoidBar/Services/NowPlayingFeed.swift index 8d8b450..91717f6 100644 --- a/Sources/VoidBar/Services/NowPlayingFeed.swift +++ b/Sources/VoidBar/Services/NowPlayingFeed.swift @@ -1,7 +1,7 @@ import AppKit -import Foundation -/// Reads the Now Playing state and turns it into snapshots using MediaRemote directly. +/// Runs the Now Playing helper inside `/usr/bin/perl` and turns its stdout into +/// snapshots. See `Sources/VoidBarMediaHelper/helper.m` for why perl is the host. @MainActor final class NowPlayingFeed { struct Snapshot { @@ -12,7 +12,9 @@ final class NowPlayingFeed { var duration: TimeInterval = 0 var elapsed: TimeInterval = 0 var rate: Double = 0 + /// Only present on the update where the track changed. var artwork: Data? + /// Name of the app owning the session, resolved from its pid. var source: String? var isEmpty: Bool { title.isEmpty } @@ -23,91 +25,143 @@ final class NowPlayingFeed { } var onUpdate: ((Snapshot) -> Void)? + /// Raised when the helper cannot run at all, so the caller can fall back. var onUnavailable: (() -> Void)? - typealias RegisterType = @convention(c) (DispatchQueue) -> Void - typealias GetInfoType = @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void - typealias SendCommandType = @convention(c) (UInt32, [String: Any]?) -> Void - - // MRNowPlayingClientGetBundleIdentifier - // void *client; CFStringRef bundleID = MRNowPlayingClientGetBundleIdentifier(client); - - private let getInfo: GetInfoType? - private let registerFunc: RegisterType? - private let sendCommandFunc: SendCommandType? - - init() { - let handle = dlopen("/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote", RTLD_NOW) - if let handle = handle { - let symGetInfo = dlsym(handle, "MRMediaRemoteGetNowPlayingInfo") - getInfo = symGetInfo != nil ? unsafeBitCast(symGetInfo, to: GetInfoType.self) : nil - - let symRegister = dlsym(handle, "MRMediaRemoteRegisterForNowPlayingNotifications") - registerFunc = symRegister != nil ? unsafeBitCast(symRegister, to: RegisterType.self) : nil - - let symSendCmd = dlsym(handle, "MRMediaRemoteSendCommand") - sendCommandFunc = symSendCmd != nil ? unsafeBitCast(symSendCmd, to: SendCommandType.self) : nil - } else { - getInfo = nil - registerFunc = nil - sendCommandFunc = nil - } + private var process: Process? + private var input: FileHandle? + private var buffer = Data() + private var failures = 0 + private var stopped = false + + private var helperPath: String? { + Bundle.main.path(forResource: "libvoidmedia", ofType: "dylib") } + // MARK: - Lifecycle + func start() { - guard let registerFunc = registerFunc, let _ = getInfo else { - DispatchQueue.main.async { [weak self] in self?.onUnavailable?() } + stopped = false + launch() + } + + func stop() { + stopped = true + input = nil + process?.terminate() + process = nil + } + + private func launch() { + guard !stopped else { return } + guard let helperPath, FileManager.default.isExecutableFile(atPath: "/usr/bin/perl") else { + onUnavailable?() return } - registerFunc(DispatchQueue.main) - - let nc = NotificationCenter.default - nc.addObserver(forName: NSNotification.Name("kMRMediaRemoteNowPlayingInfoDidChangeNotification"), object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.refresh() } + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/perl") + task.arguments = [ + "-e", + "use DynaLoader; DynaLoader::dl_load_file($ARGV[0], 0x01); while (1) { sleep 3600; }", + helperPath, + ] + + let output = Pipe() + let commands = Pipe() + task.standardOutput = output + task.standardInput = commands + task.standardError = FileHandle.nullDevice + + output.fileHandleForReading.readabilityHandler = { [weak self] handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + Task { @MainActor in self?.consume(chunk) } } - nc.addObserver(forName: NSNotification.Name("kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification"), object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.refresh() } + + task.terminationHandler = { [weak self] _ in + Task { @MainActor in self?.handleTermination() } } - nc.addObserver(forName: NSNotification.Name("kMRMediaRemoteNowPlayingApplicationDidChangeNotification"), object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.refresh() } + + do { + try task.run() + } catch { + NSLog("VoidBar: helper failed to launch: \(error.localizedDescription)") + onUnavailable?() + return } - - refresh() + + process = task + input = commands.fileHandleForWriting } - func stop() { - NotificationCenter.default.removeObserver(self) + private func handleTermination() { + guard !stopped else { return } + process = nil + input = nil + failures += 1 + // Three straight crashes means the route is gone — perl removed, or the + // daemon closed to platform binaries too. Let the caller fall back. + guard failures < 3 else { + onUnavailable?() + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in self?.launch() } } - func refresh() { - guard let getInfo = getInfo else { return } - - getInfo(DispatchQueue.main) { [weak self] info in - guard let self = self else { return } - var snap = Snapshot() - - snap.title = info["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? "" - snap.artist = info["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? "" - snap.album = info["kMRMediaRemoteNowPlayingInfoAlbum"] as? String ?? "" - snap.duration = info["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval ?? 0 - snap.elapsed = info["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval ?? 0 - snap.rate = info["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 0 - snap.artwork = info["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data - - // To be safe, if we have rate > 0, we can consider it playing - snap.isPlaying = snap.rate > 0 - - self.onUpdate?(snap) + // MARK: - Commands + + func refresh() { write("get") } + func send(_ command: Command) { write("cmd \(command.rawValue)") } + func seek(to seconds: TimeInterval) { write("seek \(Int(seconds))") } + + private func write(_ line: String) { + guard let input, let data = (line + "\n").data(using: .utf8) else { return } + // The helper can die between our check and the write; a broken pipe + // would raise SIGPIPE-flavoured NSException from FileHandle. + do { + try input.write(contentsOf: data) + } catch { + NSLog("VoidBar: helper write failed: \(error.localizedDescription)") } } - func send(_ command: Command) { - sendCommandFunc?(UInt32(command.rawValue), nil) + // MARK: - Parsing + + private func consume(_ chunk: Data) { + buffer.append(chunk) + while let newline = buffer.firstIndex(of: 0x0A) { + let line = buffer[buffer.startIndex.. 4_000_000 { buffer.removeAll() } } - func seek(to seconds: TimeInterval) { - // 18 is kMRMediaRemoteCommandSeekToPlaybackPosition - sendCommandFunc?(18, ["kMRMediaRemoteOptionPlaybackPosition": seconds]) + private func handle(line: Data) { + guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any] else { return } + if object["error"] != nil { + onUnavailable?() + return + } + failures = 0 + + var snapshot = Snapshot() + snapshot.isPlaying = object["playing"] as? Bool ?? false + snapshot.title = object["title"] as? String ?? "" + snapshot.artist = object["artist"] as? String ?? "" + snapshot.album = object["album"] as? String ?? "" + snapshot.duration = object["duration"] as? Double ?? 0 + snapshot.elapsed = object["elapsed"] as? Double ?? 0 + snapshot.rate = object["rate"] as? Double ?? 0 + if let base64 = object["artwork"] as? String { + snapshot.artwork = Data(base64Encoded: base64) + } + if let pid = object["pid"] as? Int, pid > 0 { + snapshot.source = NSRunningApplication(processIdentifier: pid_t(pid))?.localizedName + } + onUpdate?(snapshot) } } diff --git a/Sources/VoidBarMediaHelper/helper.m b/Sources/VoidBarMediaHelper/helper.m new file mode 100644 index 0000000..89e3317 --- /dev/null +++ b/Sources/VoidBarMediaHelper/helper.m @@ -0,0 +1,151 @@ +// Now Playing feed, loaded into /usr/bin/perl. +// +// Since macOS 15.4 the mediaremoted daemon answers only clients it trusts, so +// an ordinary app gets an empty dictionary no matter what it asks. Claiming the +// `com.apple.mediaremote.external-access` entitlement does not help either: it +// is restricted, and a process that claims it without Apple's authorization is +// killed at launch. +// +// /usr/bin/perl, however, is a platform binary (Platform identifier=16) that +// the daemon does trust, and it is signed without library validation — so it +// can load this dylib. Running the MediaRemote calls from inside that process +// yields the full record: title, artist, album, duration, position, artwork. +// +// The helper prints one JSON object per line on stdout and takes commands on +// stdin. It exits as soon as stdin closes, so it can never outlive VoidBar. + +#import +#import + +typedef void (*MRGetInfoFn)(dispatch_queue_t, void (^)(CFDictionaryRef)); +typedef void (*MRGetBoolFn)(dispatch_queue_t, void (^)(Boolean)); +typedef void (*MRRegisterFn)(dispatch_queue_t); +typedef Boolean (*MRSendCommandFn)(int, CFDictionaryRef); +typedef void (*MRSetElapsedFn)(double); +typedef void (*MRGetPIDFn)(dispatch_queue_t, void (^)(int)); + +static MRGetInfoFn sGetInfo; +static MRGetBoolFn sGetIsPlaying; +static MRSendCommandFn sSendCommand; +static MRSetElapsedFn sSetElapsed; +static MRGetPIDFn sGetPID; +static int sOwnerPID; +static dispatch_queue_t sQueue; +static NSString *sArtworkID; + +static NSString *const kMediaRemotePath = + @"/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote"; + +static void emit(NSDictionary *payload) { + NSData *json = [NSJSONSerialization dataWithJSONObject:payload options:0 error:NULL]; + if (!json) return; + fwrite(json.bytes, 1, json.length, stdout); + fputc('\n', stdout); + fflush(stdout); +} + +/// Reads the current record and prints it. Artwork is only included when the +/// track changed — it is the bulk of the payload and never changes mid-track. +static void publish(void) { + if (!sGetInfo || !sGetIsPlaying) return; + // Cached rather than nested a call deeper: it only labels the source. + if (sGetPID) sGetPID(sQueue, ^(int pid) { sOwnerPID = pid; }); + sGetIsPlaying(sQueue, ^(Boolean playing) { + sGetInfo(sQueue, ^(CFDictionaryRef raw) { + NSDictionary *info = (__bridge NSDictionary *)raw; + NSString *title = info[@"kMRMediaRemoteNowPlayingInfoTitle"] ?: @""; + + NSMutableDictionary *out = [NSMutableDictionary dictionary]; + out[@"playing"] = @(playing ? YES : NO); + out[@"title"] = title; + out[@"artist"] = info[@"kMRMediaRemoteNowPlayingInfoArtist"] ?: @""; + out[@"album"] = info[@"kMRMediaRemoteNowPlayingInfoAlbum"] ?: @""; + out[@"duration"] = info[@"kMRMediaRemoteNowPlayingInfoDuration"] ?: @0; + out[@"elapsed"] = info[@"kMRMediaRemoteNowPlayingInfoElapsedTime"] ?: @0; + out[@"rate"] = info[@"kMRMediaRemoteNowPlayingInfoPlaybackRate"] ?: @0; + out[@"pid"] = @(sOwnerPID); + + NSString *artworkID = info[@"kMRMediaRemoteNowPlayingInfoArtworkIdentifier"] ?: title; + NSData *artwork = info[@"kMRMediaRemoteNowPlayingInfoArtworkData"]; + if (artwork.length > 0 && ![artworkID isEqualToString:sArtworkID]) { + out[@"artwork"] = [artwork base64EncodedStringWithOptions:0]; + sArtworkID = artworkID; + } + if (title.length == 0) sArtworkID = nil; + + emit(out); + }); + }); +} + +static void handleCommand(NSString *line) { + if ([line isEqualToString:@"get"]) { + publish(); + } else if ([line hasPrefix:@"cmd "]) { + if (sSendCommand) sSendCommand([line substringFromIndex:4].intValue, NULL); + publish(); + } else if ([line hasPrefix:@"seek "]) { + if (sSetElapsed) sSetElapsed([line substringFromIndex:5].doubleValue); + publish(); + } +} + +static void startFeed(void) { + [NSThread detachNewThreadWithBlock:^{ + sQueue = dispatch_queue_create("dev.xand0.voidbar.mediaremote", DISPATCH_QUEUE_SERIAL); + + void *handle = dlopen(kMediaRemotePath.UTF8String, RTLD_NOW); + if (!handle) { + emit(@{@"error": @"mediaremote-unavailable"}); + return; + } + sGetInfo = (MRGetInfoFn)dlsym(handle, "MRMediaRemoteGetNowPlayingInfo"); + sGetIsPlaying = (MRGetBoolFn)dlsym(handle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying"); + sSendCommand = (MRSendCommandFn)dlsym(handle, "MRMediaRemoteSendCommand"); + sSetElapsed = (MRSetElapsedFn)dlsym(handle, "MRMediaRemoteSetElapsedTime"); + sGetPID = (MRGetPIDFn)dlsym(handle, "MRMediaRemoteGetNowPlayingApplicationPID"); + + MRRegisterFn registerNotifications = + (MRRegisterFn)dlsym(handle, "MRMediaRemoteRegisterForNowPlayingNotifications"); + if (registerNotifications) registerNotifications(sQueue); + + NSArray *names = @[ + @"kMRMediaRemoteNowPlayingInfoDidChangeNotification", + @"kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification", + @"kMRMediaRemoteNowPlayingApplicationDidChangeNotification", + ]; + for (NSString *name in names) { + [NSNotificationCenter.defaultCenter addObserverForName:name + object:nil + queue:nil + usingBlock:^(NSNotification *note) { + publish(); + }]; + } + + publish(); + [NSRunLoop.currentRunLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; + [NSRunLoop.currentRunLoop run]; + }]; +} + +static void startCommandReader(void) { + [NSThread detachNewThreadWithBlock:^{ + char buffer[512]; + while (fgets(buffer, sizeof buffer, stdin)) { + @autoreleasepool { + NSString *line = [@(buffer) stringByTrimmingCharactersInSet: + NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (line.length) handleCommand(line); + } + } + // VoidBar closed the pipe or went away. + exit(0); + }]; +} + +__attribute__((constructor)) +static void voidbar_helper_init(void) { + startFeed(); + startCommandReader(); +} From a37bd8d403438fe7992784715c67bbef68c84554 Mon Sep 17 00:00:00 2001 From: xand0dev Date: Thu, 6 Aug 2026 00:21:59 +0300 Subject: [PATCH 03/11] Fix NotchGeometry calculation returning zero width on non-notch macOS 15 setups --- Resources/uk.lproj/Localizable.strings | 1 + Sources/VoidBar/Model/NotchViewModel.swift | 11 ++++++++--- Sources/VoidBar/Notch/NotchGeometry.swift | 14 ++++++++------ Sources/VoidBar/UI/NotchContentView.swift | 9 +++++++++ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/Resources/uk.lproj/Localizable.strings b/Resources/uk.lproj/Localizable.strings index 10b1836..a22df8a 100644 --- a/Resources/uk.lproj/Localizable.strings +++ b/Resources/uk.lproj/Localizable.strings @@ -8,6 +8,7 @@ "Snippets" = "Шаблони"; "Calendar" = "Календар"; "Translate" = "Переклад"; +"Teleprompter" = "Суфлер"; /* Меню-бар */ "Open Panel" = "Відкрити панель"; diff --git a/Sources/VoidBar/Model/NotchViewModel.swift b/Sources/VoidBar/Model/NotchViewModel.swift index 93e5c20..b0dcf58 100644 --- a/Sources/VoidBar/Model/NotchViewModel.swift +++ b/Sources/VoidBar/Model/NotchViewModel.swift @@ -4,7 +4,7 @@ import Combine @MainActor final class NotchViewModel: ObservableObject { enum Tab: String, CaseIterable, Identifiable { - case media, shelf, clipboard, snippets, calendar, translate, notes + case media, shelf, clipboard, snippets, calendar, translate, notes, teleprompter var id: String { rawValue } var symbol: String { @@ -16,6 +16,7 @@ final class NotchViewModel: ObservableObject { case .calendar: return "calendar" case .translate: return "translate" case .notes: return "note.text" + case .teleprompter: return "text.line.first.and.arrowtriangle.forward" } } @@ -28,19 +29,20 @@ final class NotchViewModel: ObservableObject { case .calendar: return localized("Calendar") case .translate: return localized("Translate") case .notes: return localized("Notes") + case .teleprompter: return localized("Teleprompter") } } /// Tabs with a field in them. Landing on one hands it the keyboard, so /// that arriving and typing is a single move. - var needsKeyboard: Bool { self == .translate || self == .snippets || self == .notes } + var needsKeyboard: Bool { self == .translate || self == .snippets || self == .notes || self == .teleprompter } /// Which rail the icon sits on. The left one carries the original six /// and is full — a seventh icon would outgrow the height the panel /// body has — so growth continues in a second column on the right, /// which the scratch notes open. static let leftRail: [Tab] = [.media, .shelf, .clipboard, .snippets, .calendar, .translate] - static let rightRail: [Tab] = [.notes] + static let rightRail: [Tab] = [.notes, .teleprompter] } @Published var isOpen = false @@ -81,6 +83,7 @@ final class NotchViewModel: ObservableObject { let translator: Translator let snippets: SnippetStore let notes: NoteStore + let teleprompter: TeleprompterStore private var cancellables = Set() @@ -93,6 +96,7 @@ final class NotchViewModel: ObservableObject { self.translator = Translator() self.snippets = SnippetStore() self.notes = NoteStore() + self.teleprompter = TeleprompterStore() // The panel header reads through to the stores — counters, the source // name, the equalizer. Nested ObservableObjects do not propagate on @@ -183,6 +187,7 @@ final class NotchViewModel: ObservableObject { calendar.stop() // Whatever was typed makes it to disk even when quitting mid-thought. notes.flush() + teleprompter.flush() } func accept(urls: [URL]) -> Bool { diff --git a/Sources/VoidBar/Notch/NotchGeometry.swift b/Sources/VoidBar/Notch/NotchGeometry.swift index 7ad3d35..6ca8c79 100644 --- a/Sources/VoidBar/Notch/NotchGeometry.swift +++ b/Sources/VoidBar/Notch/NotchGeometry.swift @@ -23,12 +23,14 @@ struct NotchGeometry { let left = screen.auxiliaryTopLeftArea, let right = screen.auxiliaryTopRightArea { let width = screen.frame.width - left.width - right.width - return NotchGeometry( - screen: screen, - notchSize: CGSize(width: width, height: screen.safeAreaInsets.top), - notchCenterX: screen.frame.minX + left.width + width / 2, - isPhysical: true - ) + if width > 0 { + return NotchGeometry( + screen: screen, + notchSize: CGSize(width: width, height: screen.safeAreaInsets.top), + notchCenterX: screen.frame.minX + left.width + width / 2, + isPhysical: true + ) + } } // No notch: pretend there is one the size of a typical MacBook cutout so diff --git a/Sources/VoidBar/UI/NotchContentView.swift b/Sources/VoidBar/UI/NotchContentView.swift index 5114dbf..9f7b6c1 100644 --- a/Sources/VoidBar/UI/NotchContentView.swift +++ b/Sources/VoidBar/UI/NotchContentView.swift @@ -28,6 +28,11 @@ struct NotchContentView: View { } .frame(width: size.width, height: size.height, alignment: .top) .clipped() + + if !isOpen { + DynamicIslandView(vm: vm) + .transition(.opacity) + } } .frame(width: size.width + 2 * topRadius, height: size.height, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -96,6 +101,8 @@ struct NotchContentView: View { EmptyView() case .notes: NotesCounter(notes: vm.notes) + case .teleprompter: + EmptyView() } } @@ -157,6 +164,8 @@ struct NotchContentView: View { TranslatePane(translator: vm.translator, wantsKeyboard: $vm.wantsKeyboard) case .notes: NotesPane(notes: vm.notes, wantsKeyboard: $vm.wantsKeyboard) + case .teleprompter: + TeleprompterPane(store: vm.teleprompter) } } } From abdd842ab2b8997b5e56bf1c2ac70837305814c1 Mon Sep 17 00:00:00 2001 From: xand0dev Date: Thu, 6 Aug 2026 00:27:56 +0300 Subject: [PATCH 04/11] Redesign Dynamic Island for non-notched screens to use a unified floating pill and hide the fake notch background --- Sources/VoidBar/UI/DynamicIslandView.swift | 66 ++++++++++++++-------- Sources/VoidBar/UI/NotchContentView.swift | 1 + 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/Sources/VoidBar/UI/DynamicIslandView.swift b/Sources/VoidBar/UI/DynamicIslandView.swift index 513a08f..89c5548 100644 --- a/Sources/VoidBar/UI/DynamicIslandView.swift +++ b/Sources/VoidBar/UI/DynamicIslandView.swift @@ -18,32 +18,50 @@ struct DynamicIslandView: View { ZStack { if vm.media.isPlaying { - // Left Wing (Equalizer / Icon) - HStack { - Spacer() // push to right edge of the left wing - EqualizerBars(isAnimating: true) - .padding(.trailing, 10) - } - .frame(width: 44, height: notchHeight) - .background(Color.black) - .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) - .offset(x: -notchWidth / 2 - 22 + 8) // Overlap slightly to merge with notch - - // Right Wing (Timer / Source) - HStack { - if vm.media.track != nil { - // Very simple static text for now, ideally an updating timer - Text(formatTime(vm.media.position)) - .font(.system(size: 10, weight: .medium).monospacedDigit()) - .foregroundColor(Color.white) - .padding(.leading, 10) + if vm.geometry.isPhysical { + // Left Wing (Equalizer / Icon) + HStack { + Spacer() // push to right edge of the left wing + EqualizerBars(isAnimating: true) + .padding(.trailing, 10) + } + .frame(width: 44, height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + .offset(x: -notchWidth / 2 - 22 + 8) // Overlap slightly to merge with notch + + // Right Wing (Timer / Source) + HStack { + if vm.media.track != nil { + // Very simple static text for now, ideally an updating timer + Text(formatTime(vm.media.position)) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundColor(Color.white) + .padding(.leading, 10) + } + Spacer() + } + .frame(width: 44, height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + .offset(x: notchWidth / 2 + 22 - 8) + } else { + // Unified Pill for non-notched screens + HStack(spacing: 8) { + EqualizerBars(isAnimating: true) + + if vm.media.track != nil { + Text(formatTime(vm.media.position)) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundColor(Color.white) + } } - Spacer() + .padding(.horizontal, 12) + .frame(height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + // When unified, it just floats in the center } - .frame(width: 44, height: notchHeight) - .background(Color.black) - .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) - .offset(x: notchWidth / 2 + 22 - 8) } } .frame(width: notchWidth, height: notchHeight) // Center aligns with notch diff --git a/Sources/VoidBar/UI/NotchContentView.swift b/Sources/VoidBar/UI/NotchContentView.swift index 9f7b6c1..df2adda 100644 --- a/Sources/VoidBar/UI/NotchContentView.swift +++ b/Sources/VoidBar/UI/NotchContentView.swift @@ -16,6 +16,7 @@ struct NotchContentView: View { bottomRadius: isOpen ? Theme.openBottomRadius : Theme.collapsedBottomRadius ) .fill(Color.black) + .opacity(!isOpen && !vm.geometry.isPhysical ? 0 : 1) .frame(width: size.width + 2 * topRadius, height: size.height) .shadow(color: .black.opacity(isOpen ? 0.5 : 0), radius: 18, y: 8) From 8aa979c30c4327a6411ce17cee491227dbe9f586 Mon Sep 17 00:00:00 2001 From: xand0dev Date: Fri, 7 Aug 2026 10:37:22 +0300 Subject: [PATCH 05/11] Add calendar filtering menu to select which calendars are displayed in VoidBar --- Sources/VoidBar/Services/CalendarStore.swift | 27 +++++++++++- Sources/VoidBar/UI/CalendarPane.swift | 44 ++++++++++++++++++-- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/Sources/VoidBar/Services/CalendarStore.swift b/Sources/VoidBar/Services/CalendarStore.swift index 600eb24..3fb1abe 100644 --- a/Sources/VoidBar/Services/CalendarStore.swift +++ b/Sources/VoidBar/Services/CalendarStore.swift @@ -31,9 +31,23 @@ final class CalendarStore: ObservableObject { @Published private(set) var access: Access = .notRequested @Published private(set) var meetings: [Meeting] = [] + @Published private(set) var availableCalendars: [EKCalendar] = [] /// Recomputed on a timer so the countdown in the header stays honest. @Published private(set) var now = Date() + var disabledCalendarIDs: Set { + get { + if let array = UserDefaults.standard.stringArray(forKey: "disabledCalendarIDs") { + return Set(array) + } + return [] + } + set { + UserDefaults.standard.set(Array(newValue), forKey: "disabledCalendarIDs") + reload() + } + } + private let store = EKEventStore() private var timer: Timer? private var observer: Any? @@ -156,11 +170,22 @@ final class CalendarStore: ObservableObject { func reload() { guard access == .granted else { return } + + availableCalendars = store.calendars(for: .event) + let disabled = disabledCalendarIDs + let activeCalendars = availableCalendars.filter { !disabled.contains($0.calendarIdentifier) } + + if activeCalendars.isEmpty && !availableCalendars.isEmpty { + meetings = [] + now = Date() + return + } + let start = Date() let predicate = store.predicateForEvents( withStart: start, end: start.addingTimeInterval(horizon), - calendars: nil + calendars: activeCalendars ) meetings = store.events(matching: predicate) .filter { !$0.isAllDay && $0.status != .canceled } diff --git a/Sources/VoidBar/UI/CalendarPane.swift b/Sources/VoidBar/UI/CalendarPane.swift index 1d5c5ee..3f206ed 100644 --- a/Sources/VoidBar/UI/CalendarPane.swift +++ b/Sources/VoidBar/UI/CalendarPane.swift @@ -10,10 +10,14 @@ struct CalendarPane: View { case .denied: deniedState case .granted: - if let next = calendar.next { - agenda(next: next) - } else { - emptyState + ZStack(alignment: .topTrailing) { + if let next = calendar.next { + agenda(next: next) + } else { + emptyState + } + + settingsMenu } } } @@ -211,4 +215,36 @@ struct CalendarPane: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } + + private var settingsMenu: some View { + Menu { + ForEach(calendar.availableCalendars, id: \.calendarIdentifier) { cal in + Button { + var disabled = calendar.disabledCalendarIDs + if disabled.contains(cal.calendarIdentifier) { + disabled.remove(cal.calendarIdentifier) + } else { + disabled.insert(cal.calendarIdentifier) + } + calendar.disabledCalendarIDs = disabled + } label: { + HStack { + if !calendar.disabledCalendarIDs.contains(cal.calendarIdentifier) { + Image(systemName: "checkmark") + } + Text(cal.title) + } + } + } + } label: { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 12)) + .foregroundStyle(Theme.secondary) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .frame(width: 24, height: 24) + .padding(.trailing, 0) + .padding(.top, -2) + } } From e0d1c6025018f8b8502162cc8c4a5b7e2ad7b16c Mon Sep 17 00:00:00 2001 From: xand0dev Date: Fri, 7 Aug 2026 21:57:09 +0300 Subject: [PATCH 06/11] Create TimerStore and countdown logic --- Sources/VoidBar/Services/TimerStore.swift | 62 +++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 Sources/VoidBar/Services/TimerStore.swift diff --git a/Sources/VoidBar/Services/TimerStore.swift b/Sources/VoidBar/Services/TimerStore.swift new file mode 100644 index 0000000..7cda381 --- /dev/null +++ b/Sources/VoidBar/Services/TimerStore.swift @@ -0,0 +1,62 @@ +import Foundation +import Combine + +@MainActor +final class TimerStore: ObservableObject { + enum State { + case idle + case running + case paused + } + + @Published var state: State = .idle + @Published var timeRemaining: TimeInterval = 25 * 60 + + let defaultDuration: TimeInterval = 25 * 60 + private var timer: Timer? + + var formattedTime: String { + let minutes = Int(timeRemaining) / 60 + let seconds = Int(timeRemaining) % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + func start() { + if state == .idle { + timeRemaining = defaultDuration + } + state = .running + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.tick() + } + } + } + + func pause() { + state = .paused + timer?.invalidate() + timer = nil + } + + func reset() { + state = .idle + timer?.invalidate() + timer = nil + timeRemaining = defaultDuration + } + + private func tick() { + guard state == .running else { return } + if timeRemaining > 0 { + timeRemaining -= 1 + } else { + finish() + } + } + + private func finish() { + reset() + // TODO: Trigger notification + } +} From 535e67e6510bcb7f87fb99efab843adec267694b Mon Sep 17 00:00:00 2001 From: xand0dev Date: Fri, 7 Aug 2026 21:57:38 +0300 Subject: [PATCH 07/11] Add timer tab --- Sources/VoidBar/Model/NotchViewModel.swift | 9 +++++++-- Sources/VoidBar/UI/NotchContentView.swift | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Sources/VoidBar/Model/NotchViewModel.swift b/Sources/VoidBar/Model/NotchViewModel.swift index b0dcf58..3467538 100644 --- a/Sources/VoidBar/Model/NotchViewModel.swift +++ b/Sources/VoidBar/Model/NotchViewModel.swift @@ -4,7 +4,7 @@ import Combine @MainActor final class NotchViewModel: ObservableObject { enum Tab: String, CaseIterable, Identifiable { - case media, shelf, clipboard, snippets, calendar, translate, notes, teleprompter + case media, shelf, clipboard, snippets, calendar, timer, translate, notes, teleprompter var id: String { rawValue } var symbol: String { @@ -14,6 +14,7 @@ final class NotchViewModel: ObservableObject { case .clipboard: return "list.clipboard.fill" case .snippets: return "pin.fill" case .calendar: return "calendar" + case .timer: return "timer" case .translate: return "translate" case .notes: return "note.text" case .teleprompter: return "text.line.first.and.arrowtriangle.forward" @@ -27,6 +28,7 @@ final class NotchViewModel: ObservableObject { case .clipboard: return localized("Clipboard") case .snippets: return localized("Snippets") case .calendar: return localized("Calendar") + case .timer: return localized("Timer") case .translate: return localized("Translate") case .notes: return localized("Notes") case .teleprompter: return localized("Teleprompter") @@ -41,7 +43,7 @@ final class NotchViewModel: ObservableObject { /// and is full — a seventh icon would outgrow the height the panel /// body has — so growth continues in a second column on the right, /// which the scratch notes open. - static let leftRail: [Tab] = [.media, .shelf, .clipboard, .snippets, .calendar, .translate] + static let leftRail: [Tab] = [.media, .shelf, .clipboard, .snippets, .calendar, .timer, .translate] static let rightRail: [Tab] = [.notes, .teleprompter] } @@ -82,6 +84,7 @@ final class NotchViewModel: ObservableObject { let calendar: CalendarStore let translator: Translator let snippets: SnippetStore + let timer: TimerStore let notes: NoteStore let teleprompter: TeleprompterStore @@ -95,6 +98,7 @@ final class NotchViewModel: ObservableObject { self.calendar = CalendarStore() self.translator = Translator() self.snippets = SnippetStore() + self.timer = TimerStore() self.notes = NoteStore() self.teleprompter = TeleprompterStore() @@ -122,6 +126,7 @@ final class NotchViewModel: ObservableObject { shelf.objectWillChange, clipboard.objectWillChange, calendar.objectWillChange, + timer.objectWillChange, ] { child .sink { [weak self] _ in diff --git a/Sources/VoidBar/UI/NotchContentView.swift b/Sources/VoidBar/UI/NotchContentView.swift index df2adda..f9f6b89 100644 --- a/Sources/VoidBar/UI/NotchContentView.swift +++ b/Sources/VoidBar/UI/NotchContentView.swift @@ -96,6 +96,12 @@ struct NotchContentView: View { .font(.system(size: 10, weight: .medium)) .foregroundStyle(next.isRunning ? Color.white.opacity(0.8) : Theme.tertiary) } + case .timer: + if vm.timer.state != .idle { + Text(vm.timer.formattedTime) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundStyle(vm.timer.state == .running ? Color.white.opacity(0.8) : Theme.tertiary) + } case .translate: // Nothing: the columns name both languages already, and the strip // is the one part of the panel worth not spending on a repeat. @@ -159,6 +165,8 @@ struct NotchContentView: View { ClipboardPane(clipboard: vm.clipboard) case .calendar: CalendarPane(calendar: vm.calendar) + case .timer: + TimerPane(timer: vm.timer) case .snippets: SnippetsPane(snippets: vm.snippets, wantsKeyboard: $vm.wantsKeyboard) case .translate: From d0c4d6f6b3e3d0986df5aeb9112211863c714616 Mon Sep 17 00:00:00 2001 From: xand0dev Date: Fri, 7 Aug 2026 21:57:57 +0300 Subject: [PATCH 08/11] Create TimerPane UI --- Sources/VoidBar/UI/TimerPane.swift | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 Sources/VoidBar/UI/TimerPane.swift diff --git a/Sources/VoidBar/UI/TimerPane.swift b/Sources/VoidBar/UI/TimerPane.swift new file mode 100644 index 0000000..9d50f67 --- /dev/null +++ b/Sources/VoidBar/UI/TimerPane.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct TimerPane: View { + @ObservedObject var timer: TimerStore + + var body: some View { + VStack(spacing: 20) { + Text(timer.formattedTime) + .font(.system(size: 48, weight: .semibold).monospacedDigit()) + .foregroundStyle(.white) + + HStack(spacing: 30) { + Button { + timer.reset() + HapticManager.play(.alignment) + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 20)) + } + .buttonStyle(NotchButtonStyle(size: 40)) + + Button { + if timer.state == .running { + timer.pause() + } else { + timer.start() + } + HapticManager.play(.alignment) + } label: { + Image(systemName: timer.state == .running ? "pause.fill" : "play.fill") + .font(.system(size: 24)) + } + .buttonStyle(NotchButtonStyle(size: 50, prominent: true)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} From 2c9cf88aa562e52631c2ef494b9d849ba2514aec Mon Sep 17 00:00:00 2001 From: xand0dev Date: Fri, 7 Aug 2026 21:58:42 +0300 Subject: [PATCH 09/11] Integrate timer with Dynamic Island --- Sources/VoidBar/UI/DynamicIslandView.swift | 143 ++++++++++++++------- 1 file changed, 93 insertions(+), 50 deletions(-) diff --git a/Sources/VoidBar/UI/DynamicIslandView.swift b/Sources/VoidBar/UI/DynamicIslandView.swift index 89c5548..91f444e 100644 --- a/Sources/VoidBar/UI/DynamicIslandView.swift +++ b/Sources/VoidBar/UI/DynamicIslandView.swift @@ -12,60 +12,103 @@ struct DynamicIslandView: View { @State private var phase: CGFloat = 0 var body: some View { - // We position the islands exactly outside the physical notch width. - // We are drawing inside a frame that is `size.width + 2*topRadius` wide. - // The center of this frame corresponds to the center of the notch. - ZStack { - if vm.media.isPlaying { - if vm.geometry.isPhysical { - // Left Wing (Equalizer / Icon) - HStack { - Spacer() // push to right edge of the left wing - EqualizerBars(isAnimating: true) - .padding(.trailing, 10) - } - .frame(width: 44, height: notchHeight) - .background(Color.black) - .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) - .offset(x: -notchWidth / 2 - 22 + 8) // Overlap slightly to merge with notch - - // Right Wing (Timer / Source) - HStack { - if vm.media.track != nil { - // Very simple static text for now, ideally an updating timer - Text(formatTime(vm.media.position)) - .font(.system(size: 10, weight: .medium).monospacedDigit()) - .foregroundColor(Color.white) - .padding(.leading, 10) - } - Spacer() - } - .frame(width: 44, height: notchHeight) - .background(Color.black) - .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) - .offset(x: notchWidth / 2 + 22 - 8) - } else { - // Unified Pill for non-notched screens - HStack(spacing: 8) { - EqualizerBars(isAnimating: true) - - if vm.media.track != nil { - Text(formatTime(vm.media.position)) - .font(.system(size: 10, weight: .medium).monospacedDigit()) - .foregroundColor(Color.white) - } - } - .padding(.horizontal, 12) - .frame(height: notchHeight) - .background(Color.black) - .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) - // When unified, it just floats in the center + if vm.timer.state == .running { + timerPill + } else if vm.media.isPlaying { + mediaPill + } + } + .frame(width: notchWidth, height: notchHeight) + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: vm.media.isPlaying || vm.timer.state == .running) + } + + @ViewBuilder + private var timerPill: some View { + if vm.geometry.isPhysical { + HStack { + Spacer() + Image(systemName: "timer") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(Theme.tertiary) + .padding(.trailing, 10) + } + .frame(width: 44, height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + .offset(x: -notchWidth / 2 - 22 + 8) + + HStack { + Text(vm.timer.formattedTime) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundColor(Color.white) + .padding(.leading, 10) + Spacer() + } + .frame(width: 44, height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + .offset(x: notchWidth / 2 + 22 - 8) + } else { + HStack(spacing: 8) { + Image(systemName: "timer") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(Theme.tertiary) + Text(vm.timer.formattedTime) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundColor(Color.white) + } + .padding(.horizontal, 12) + .frame(height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + } + } + + @ViewBuilder + private var mediaPill: some View { + if vm.geometry.isPhysical { + // Left Wing (Equalizer / Icon) + HStack { + Spacer() // push to right edge of the left wing + EqualizerBars(isAnimating: true) + .padding(.trailing, 10) + } + .frame(width: 44, height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + .offset(x: -notchWidth / 2 - 22 + 8) // Overlap slightly to merge with notch + + // Right Wing (Timer / Source) + HStack { + if vm.media.track != nil { + Text(formatTime(vm.media.position)) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundColor(Color.white) + .padding(.leading, 10) + } + Spacer() + } + .frame(width: 44, height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) + .offset(x: notchWidth / 2 + 22 - 8) + } else { + // Unified Pill for non-notched screens + HStack(spacing: 8) { + EqualizerBars(isAnimating: true) + + if vm.media.track != nil { + Text(formatTime(vm.media.position)) + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundColor(Color.white) } } + .padding(.horizontal, 12) + .frame(height: notchHeight) + .background(Color.black) + .clipShape(RoundedRectangle(cornerRadius: notchHeight / 2, style: .continuous)) } - .frame(width: notchWidth, height: notchHeight) // Center aligns with notch - .animation(.spring(response: 0.4, dampingFraction: 0.7), value: vm.media.isPlaying) } private func formatTime(_ seconds: Double) -> String { From c94cfcf308d0a709fb6da462e0af182a9c2a6cf1 Mon Sep 17 00:00:00 2001 From: xand0dev Date: Fri, 7 Aug 2026 21:59:00 +0300 Subject: [PATCH 10/11] Add notifications when timer finishes --- Sources/VoidBar/Services/TimerStore.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/VoidBar/Services/TimerStore.swift b/Sources/VoidBar/Services/TimerStore.swift index 7cda381..5aff2d7 100644 --- a/Sources/VoidBar/Services/TimerStore.swift +++ b/Sources/VoidBar/Services/TimerStore.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import AppKit @MainActor final class TimerStore: ObservableObject { @@ -57,6 +58,11 @@ final class TimerStore: ObservableObject { private func finish() { reset() - // TODO: Trigger notification + let notification = NSUserNotification() + notification.title = "Pomodoro Finished" + notification.informativeText = "Time to take a break!" + notification.soundName = NSUserNotificationDefaultSoundName + NSUserNotificationCenter.default.deliverNotification(notification) + HapticManager.play(.generic) } } From 50df1fc08c7f0915c6963df3c84570057bd829f3 Mon Sep 17 00:00:00 2001 From: xand0dev Date: Fri, 7 Aug 2026 22:00:14 +0300 Subject: [PATCH 11/11] Fix compile errors in Timer feature --- Sources/VoidBar/Services/TimerStore.swift | 3 +-- Sources/VoidBar/UI/TimerPane.swift | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Sources/VoidBar/Services/TimerStore.swift b/Sources/VoidBar/Services/TimerStore.swift index 5aff2d7..aeea207 100644 --- a/Sources/VoidBar/Services/TimerStore.swift +++ b/Sources/VoidBar/Services/TimerStore.swift @@ -62,7 +62,6 @@ final class TimerStore: ObservableObject { notification.title = "Pomodoro Finished" notification.informativeText = "Time to take a break!" notification.soundName = NSUserNotificationDefaultSoundName - NSUserNotificationCenter.default.deliverNotification(notification) - HapticManager.play(.generic) + NSUserNotificationCenter.default.deliver(notification) } } diff --git a/Sources/VoidBar/UI/TimerPane.swift b/Sources/VoidBar/UI/TimerPane.swift index 9d50f67..3f44668 100644 --- a/Sources/VoidBar/UI/TimerPane.swift +++ b/Sources/VoidBar/UI/TimerPane.swift @@ -12,7 +12,6 @@ struct TimerPane: View { HStack(spacing: 30) { Button { timer.reset() - HapticManager.play(.alignment) } label: { Image(systemName: "arrow.counterclockwise") .font(.system(size: 20)) @@ -25,7 +24,6 @@ struct TimerPane: View { } else { timer.start() } - HapticManager.play(.alignment) } label: { Image(systemName: timer.state == .running ? "pause.fill" : "play.fill") .font(.system(size: 24))