diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index 500c7cfe6..ae0e67bdc 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 1153BD982D9881F900979FB0 /* AppleScriptHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD972D9881F900979FB0 /* AppleScriptHelper.swift */; }; 1153BD9A2D98824300979FB0 /* SpotifyController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD992D98824300979FB0 /* SpotifyController.swift */; }; 1153BD9C2D98853B00979FB0 /* NowPlayingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BD9B2D98853B00979FB0 /* NowPlayingController.swift */; }; + A1B2C3D4E5F60718293A4B5D /* NowPlayingStreamSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* NowPlayingStreamSupport.swift */; }; 1153BDA72D99B22200979FB0 /* YouTubeMusicController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1153BDA62D99B22200979FB0 /* YouTubeMusicController.swift */; }; 115C12EC2ED3D003009754CA /* OpenNotchOSD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 115C12EB2ED3D003009754CA /* OpenNotchOSD.swift */; }; 1160F8D82DD98230006FBB94 /* NotchShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1160F8D72DD98230006FBB94 /* NotchShape.swift */; }; @@ -235,6 +236,7 @@ 1153BD972D9881F900979FB0 /* AppleScriptHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptHelper.swift; sourceTree = ""; }; 1153BD992D98824300979FB0 /* SpotifyController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyController.swift; sourceTree = ""; }; 1153BD9B2D98853B00979FB0 /* NowPlayingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingController.swift; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5C /* NowPlayingStreamSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingStreamSupport.swift; sourceTree = ""; }; 1153BDA62D99B22200979FB0 /* YouTubeMusicController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeMusicController.swift; sourceTree = ""; }; 115C12EB2ED3D003009754CA /* OpenNotchOSD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenNotchOSD.swift; sourceTree = ""; }; 1160F8D72DD98230006FBB94 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = ""; }; @@ -486,6 +488,7 @@ 1153BD922D986E4300979FB0 /* AppleMusicController.swift */, 1153BD992D98824300979FB0 /* SpotifyController.swift */, 1153BD9B2D98853B00979FB0 /* NowPlayingController.swift */, + A1B2C3D4E5F60718293A4B5C /* NowPlayingStreamSupport.swift */, ); path = MediaControllers; sourceTree = ""; @@ -1073,6 +1076,7 @@ 14D570CB2C5F4B2C0011E668 /* BatteryStatusViewModel.swift in Sources */, 9A0887322C7A693000C160EA /* TabButton.swift in Sources */, 1153BD9C2D98853B00979FB0 /* NowPlayingController.swift in Sources */, + A1B2C3D4E5F60718293A4B5D /* NowPlayingStreamSupport.swift in Sources */, 11985BEF2F37E48900F81585 /* OSDIconView.swift in Sources */, 1194E9402EACC652009C82D6 /* Color+AccentColor.swift in Sources */, B141C2412CA5F53F00AC8CC8 /* SparkleView.swift in Sources */, diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 07896651c..8d0e75b8e 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -43,6 +43,7 @@ struct ContentView: View { private let extendedHoverPadding: CGFloat = 30 private let zeroHeightHoverPadding: CGFloat = 10 + private let nowPlayingFallbackNoticeWidth: CGFloat = 330 // MARK: - Corner Radius Scaling private var cornerRadiusScaleFactor: CGFloat? { @@ -88,7 +89,9 @@ struct ContentView: View { private var computedChinWidth: CGFloat { var chinWidth: CGFloat = vm.closedNotchSize.width - if coordinator.expandingView.type == .battery && coordinator.expandingView.show + if shouldDisplayNowPlayingFallbackNotice { + chinWidth = nowPlayingFallbackNoticeWidth + } else if coordinator.expandingView.type == .battery && coordinator.expandingView.show && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { chinWidth = 640 @@ -107,6 +110,21 @@ struct ContentView: View { return chinWidth } + private var shouldDisplayNowPlayingFallbackNotice: Bool { + guard musicManager.nowPlayingNotice != nil else { return false } + + let selectedScreen = NSScreen.screen(withUUID: coordinator.selectedScreenUUID) + let targetScreenUUID = selectedScreen?.displayUUID ?? NSScreen.main?.displayUUID + let currentScreen = vm.screenUUID.flatMap { NSScreen.screen(withUUID: $0) } + let isConnected = vm.screenUUID == nil || currentScreen != nil + let isTargetDisplay = vm.screenUUID == nil || vm.screenUUID == targetScreenUUID + + return isConnected + && isTargetDisplay + && vm.notchState == .closed + && !isNotchHeightZero + } + // If the closed notch height is 0 (any display/setting), display a 10pt nearly-invisible notch // instead of fully hiding it. This preserves layout while avoiding visual artifacts. private var isNotchHeightZero: Bool { vm.effectiveClosedNotchHeight == 0 } @@ -160,23 +178,23 @@ struct ContentView: View { handleHover(hovering) } .onTapGesture { - if vm.notchState == .closed { + if vm.notchState == .closed && !shouldDisplayNowPlayingFallbackNotice { doOpen() } } - .conditionalModifier(Defaults[.enableGestures]) { view in + .conditionalModifier(Defaults[.enableGestures] && !shouldDisplayNowPlayingFallbackNotice) { view in view .panGesture(direction: .down) { translation, phase in handleDownGesture(translation: translation, phase: phase) } } - .conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures]) { view in + .conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures] && !shouldDisplayNowPlayingFallbackNotice) { view in view .panGesture(direction: .up) { translation, phase in handleUpGesture(translation: translation, phase: phase) } } - .conditionalModifier(Defaults[.enableHorizontalMediaGestures] && Defaults[.enableGestures]) { view in + .conditionalModifier(Defaults[.enableHorizontalMediaGestures] && Defaults[.enableGestures] && !shouldDisplayNowPlayingFallbackNotice) { view in view .panGesture(direction: .left) { translation, phase in handleNextTrackGesture(translation: translation, phase: phase) @@ -300,7 +318,11 @@ struct ContentView: View { .padding(.top, 40) Spacer() } else { - if coordinator.expandingView.type == .battery && coordinator.expandingView.show + if shouldDisplayNowPlayingFallbackNotice, + let notice = musicManager.nowPlayingNotice { + nowPlayingFallbackNotice(notice) + .transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .top))) + } else if coordinator.expandingView.type == .battery && coordinator.expandingView.show && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { HStack(spacing: 0) { @@ -427,6 +449,51 @@ struct ContentView: View { .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], delegate: GeneralDropTargetDelegate(isTargeted: $dropInteraction.generalDropTargeting)) } + private func nowPlayingFallbackNotice(_ notice: NowPlayingFallbackNotice) -> some View { + HStack(spacing: 11) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color.orange) + .frame(width: 24, height: 24) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(notice.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + + Text(notice.subtitle) + .font(.caption) + .foregroundStyle(.white.opacity(0.62)) + } + .lineLimit(2) + + Spacer(minLength: 5) + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(width: nowPlayingFallbackNoticeWidth) + .frame(minHeight: 58) + .accessibilityElement(children: .combine) + .onAppear { + if musicManager.markNowPlayingNoticePresented(notice.id) { + announceNowPlayingFallbackNotice(notice) + } + } + } + + private func announceNowPlayingFallbackNotice(_ notice: NowPlayingFallbackNotice) { + let announcement = "\(String(localized: notice.title)). \(String(localized: notice.subtitle))." + NSAccessibility.post( + element: NSApplication.shared, + notification: .announcementRequested, + userInfo: [ + .announcement: announcement, + .priority: NSAccessibilityPriorityLevel.high.rawValue, + ] + ) + } + @ViewBuilder func BoringFaceAnimation() -> some View { HStack { @@ -552,7 +619,7 @@ struct ContentView: View { var dragDetector: some View { @Bindable var dropInteraction = vm.dropInteraction - if Defaults[.boringShelf] && vm.notchState == .closed { + if Defaults[.boringShelf] && vm.notchState == .closed && !shouldDisplayNowPlayingFallbackNotice { Color.clear .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) @@ -591,6 +658,7 @@ struct ContentView: View { } guard vm.notchState == .closed, + !shouldDisplayNowPlayingFallbackNotice, !coordinator.shouldShowSneakPeek(on: vm.screenUUID), Defaults[.openNotchOnHover] else { return } @@ -601,6 +669,7 @@ struct ContentView: View { await MainActor.run { guard self.vm.notchState == .closed, self.isHovering, + !self.shouldDisplayNowPlayingFallbackNotice, !self.coordinator.shouldShowSneakPeek(on: self.vm.screenUUID) else { return } self.doOpen() diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index 21c0e7f6e..ccafcaa23 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -145,9 +145,6 @@ } } }, - "%@x" : { - "comment" : "Animation speed multiplier." - }, "%@" : { "localizations" : { "en" : { @@ -164,6 +161,9 @@ } } }, + "%@x" : { + "comment" : "Animation speed multiplier." + }, "%lld" : { "localizations" : { "en" : { @@ -1850,6 +1850,18 @@ } } }, + "Apple Music" : { + "comment" : "Apple Music service name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Music" + } + } + }, + "shouldTranslate" : false + }, "Auto-scroll to next event" : { "localizations" : { "de" : { @@ -3106,6 +3118,50 @@ } } }, + "Boring Notch could not verify Now Playing. Try again, or reopen the app if it keeps happening." : { + "comment" : "Recoverable Now Playing probe failure message.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boring Notch could not verify Now Playing. Try again, or reopen the app if it keeps happening." + } + } + } + }, + "Boring Notch lost its Now Playing connection. Reconnecting automatically..." : { + "comment" : "Now Playing runtime failure message shown before an automatic recovery attempt.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boring Notch lost its Now Playing connection. Reconnecting automatically..." + } + } + } + }, + "Boring Notch's Now Playing components are unavailable. Reopen or reinstall the app." : { + "comment" : "Now Playing setup failure message shown when required bundled components cannot be used.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boring Notch's Now Playing components are unavailable. Reopen or reinstall the app." + } + } + } + }, + "Could not verify Now Playing" : { + "comment" : "Title of the passive notice shown when the Now Playing availability probe fails.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not verify Now Playing" + } + } + } + }, "boring.notch" : { "localizations" : { "cs" : { @@ -3709,8 +3765,19 @@ "Camera" : { "comment" : "A label for the camera picker." }, + "Camera Access Required" : { + "comment" : "Camera permission alert title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera Access Required" + } + } + } + }, "Cancel" : { - "extractionState" : "stale", + "comment" : "Cancel button title", "localizations" : { "cs" : { "stringUnit" : { @@ -4316,6 +4383,10 @@ } } }, + "Check Again" : { + "comment" : "A button that refreshes the availability of the 'Now Playing' media source.", + "isCommentAutoGenerated" : true + }, "Check for updates automatically" : { "localizations" : { "de" : { @@ -4486,6 +4557,10 @@ } } }, + "Checking Now Playing availability..." : { + "comment" : "A message displayed when the app is checking if Now Playing is available.", + "isCommentAutoGenerated" : true + }, "Choose a Music Source" : { "localizations" : { "cs" : { @@ -5861,6 +5936,28 @@ } } }, + "Connects directly to the Apple Music app." : { + "comment" : "Onboarding description of the Apple Music source.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connects directly to the Apple Music app." + } + } + } + }, + "Connects directly to the Spotify app." : { + "comment" : "Onboarding description of the Spotify music source.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connects directly to the Spotify app." + } + } + } + }, "Continue" : { "localizations" : { "cs" : { @@ -11525,6 +11622,7 @@ } }, "https://github.com/pear-devs/pear-desktop" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -16162,6 +16260,28 @@ } } }, + "Now Playing components unavailable" : { + "comment" : "Title of the passive notice shown when required Now Playing components cannot be used.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Now Playing components unavailable" + } + } + } + }, + "Now Playing connection lost" : { + "comment" : "Title of the passive notice shown when an active Now Playing stream fails.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Now Playing connection lost" + } + } + } + }, "Open Calendar Settings" : { "localizations" : { "cs" : { @@ -16608,6 +16728,17 @@ } } }, + "Open Settings" : { + "comment" : "Button title that opens app or system settings", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + } + } + }, "Open shelf by default if items are present" : { "localizations" : { "de" : { @@ -16720,6 +16851,17 @@ } } }, + "Open System Settings" : { + "comment" : "Button title that opens System Settings", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open System Settings" + } + } + } + }, "Option (⌥) Key Behavior" : { "localizations" : { "cs" : { @@ -17505,6 +17647,28 @@ } } }, + "Please allow camera access in System Settings to use the mirror feature." : { + "comment" : "Mirror camera permission alert message", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please allow camera access in System Settings to use the mirror feature." + } + } + } + }, + "Please allow camera access in System Settings." : { + "comment" : "Camera permission alert message", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please allow camera access in System Settings." + } + } + } + }, "Plugged In" : { "localizations" : { "cs" : { @@ -19073,6 +19237,17 @@ } } }, + "Requires a third-party client with API plugin enabled." : { + "comment" : "Onboarding description of the YouTube Music source.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requires a third-party client with API plugin enabled." + } + } + } + }, "Requires macOS 14.2 or later. Update macOS to enable real-time audio waveform." : { "localizations" : { "de" : { @@ -21690,6 +21865,10 @@ } } }, + "Show charging wattage" : { + "comment" : "A checkbox to show the wattage of the charging port.", + "isCommentAutoGenerated" : true + }, "Show cool face animation while inactive" : { "localizations" : { "de" : { @@ -24107,6 +24286,18 @@ } } }, + "Spotify" : { + "comment" : "Spotify service name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spotify" + } + } + }, + "shouldTranslate" : false + }, "Square" : { "localizations" : { "cs" : { @@ -24634,7 +24825,7 @@ } }, "System Setting" : { - "comment" : "Week starts on: follow the macOS system setting (shown with the resolved day in parentheses)", + "comment" : "Week starts on: follow the macOS system setting", "localizations" : { "de" : { "stringUnit" : { @@ -26135,6 +26326,50 @@ } } }, + "Using %@ instead" : { + "comment" : "Now Playing setup failure notice. The placeholder is the fallback music source name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Using %@ instead" + } + } + } + }, + "Using %@ instead. Your Now Playing preference is preserved." : { + "comment" : "Media settings footer for a non-recoverable Now Playing setup failure. The placeholder is the active fallback source.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Using %@ instead. Your Now Playing preference is preserved." + } + } + } + }, + "Using %@ temporarily" : { + "comment" : "Temporary Now Playing fallback notice. The placeholder is the fallback music source name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Using %@ temporarily" + } + } + } + }, + "Using %@ temporarily. Your Now Playing preference is preserved." : { + "comment" : "Media settings footer for a temporary Now Playing fallback. The placeholder is the active fallback source.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Using %@ temporarily. Your Now Playing preference is preserved." + } + } + } + }, "Using System Accent" : { "localizations" : { "de" : { @@ -27211,6 +27446,17 @@ } } }, + "Works with most media apps, including browsers, to detect what's playing. Note: This may be removed in a future macOS version." : { + "comment" : "Onboarding description of the universal macOS Now Playing music source.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Works with most media apps, including browsers, to detect what's playing. Note: This may be removed in a future macOS version." + } + } + } + }, "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." : { "localizations" : { "de" : { @@ -27547,7 +27793,20 @@ } } }, + "YouTube Music" : { + "comment" : "YouTube Music service name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "YouTube Music" + } + } + }, + "shouldTranslate" : false + }, "YouTube Music requires this third-party app to be installed: " : { + "extractionState" : "stale", "localizations" : { "cs" : { "stringUnit" : { @@ -27667,4 +27926,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/boringNotch/MediaControllers/AppleMusicController.swift b/boringNotch/MediaControllers/AppleMusicController.swift index bd5903354..9b6a58de0 100644 --- a/boringNotch/MediaControllers/AppleMusicController.swift +++ b/boringNotch/MediaControllers/AppleMusicController.swift @@ -9,6 +9,7 @@ import Foundation import Combine import SwiftUI +@MainActor class AppleMusicController: MediaControllerProtocol { // MARK: - Properties @Published private var playbackState: PlaybackState = PlaybackState( diff --git a/boringNotch/MediaControllers/MediaControllerProtocol.swift b/boringNotch/MediaControllers/MediaControllerProtocol.swift index 423c0583b..b7258419c 100644 --- a/boringNotch/MediaControllers/MediaControllerProtocol.swift +++ b/boringNotch/MediaControllers/MediaControllerProtocol.swift @@ -6,10 +6,10 @@ // import Foundation -import AppKit import Combine -protocol MediaControllerProtocol: ObservableObject { +@MainActor +protocol MediaControllerProtocol: AnyObject { var playbackStatePublisher: AnyPublisher { get } var supportsVolumeControl: Bool { get } var supportsFavorite: Bool { get } @@ -27,3 +27,11 @@ protocol MediaControllerProtocol: ObservableObject { func isActive() -> Bool func updatePlaybackInfo() async } + +@MainActor +protocol NowPlayingRuntimeControlling: MediaControllerProtocol { + var runtimeFailures: AsyncStream { get } + + func startRuntimeStream() + func stopRuntimeStream() +} diff --git a/boringNotch/MediaControllers/NowPlayingController.swift b/boringNotch/MediaControllers/NowPlayingController.swift index 739899078..fca18afc0 100644 --- a/boringNotch/MediaControllers/NowPlayingController.swift +++ b/boringNotch/MediaControllers/NowPlayingController.swift @@ -9,7 +9,8 @@ import AppKit import Combine import Foundation -final class NowPlayingController: ObservableObject, MediaControllerProtocol { +@MainActor +final class NowPlayingController: NowPlayingRuntimeControlling { func updatePlaybackInfo() async { await fetchFavoriteStateIfSupported() } @@ -55,22 +56,24 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { await updatePlaybackInfo() } - private var lastMusicItem: - (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)? - // MARK: - Media Remote Functions private let mediaRemoteBundle: CFBundle private let MRMediaRemoteSendCommandFunction: @convention(c) (Int, AnyObject?) -> Void private let MRMediaRemoteSetElapsedTimeFunction: @convention(c) (Double) -> Void private let MRMediaRemoteSetShuffleModeFunction: @convention(c) (Int) -> Void private let MRMediaRemoteSetRepeatModeFunction: @convention(c) (Int) -> Void + private let adapterScriptURL: URL + private let adapterFrameworkPath: String + + let runtimeFailures: AsyncStream + private let runtimeFailureContinuation: AsyncStream.Continuation - private var process: Process? - private var pipeHandler: JSONLinesPipeHandler? - private var streamTask: Task? + private var streamSession: NowPlayingStreamSession? // MARK: - Initialization - init?() { + init() throws { + let resources = try NowPlayingResources.load() + guard let bundle = CFBundleCreate( kCFAllocatorDefault, @@ -83,8 +86,9 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { bundle, "MRMediaRemoteSetShuffleMode" as CFString), let MRMediaRemoteSetRepeatModePointer = CFBundleGetFunctionPointerForName( bundle, "MRMediaRemoteSetRepeatMode" as CFString) - - else { return nil } + else { + throw NowPlayingError.unavailable + } mediaRemoteBundle = bundle MRMediaRemoteSendCommandFunction = unsafeBitCast( @@ -95,27 +99,21 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { MRMediaRemoteSetShuffleModePointer, to: (@convention(c) (Int) -> Void).self) MRMediaRemoteSetRepeatModeFunction = unsafeBitCast( MRMediaRemoteSetRepeatModePointer, to: (@convention(c) (Int) -> Void).self) + adapterScriptURL = resources.adapterScriptURL + adapterFrameworkPath = resources.adapterFrameworkPath - Task { await setupNowPlayingObserver() } + let runtimeFailureChannel = AsyncStream.makeStream(of: Void.self) + runtimeFailures = runtimeFailureChannel.stream + runtimeFailureContinuation = runtimeFailureChannel.continuation } deinit { - streamTask?.cancel() - - if let pipeHandler = self.pipeHandler { - Task { await pipeHandler.close() + if let streamSession { + Task { @MainActor in + streamSession.stop() } } - - if let process = self.process { - if process.isRunning { - process.terminate() - process.waitUntilExit() - } - } - - self.process = nil - self.pipeHandler = nil + runtimeFailureContinuation.finish() } // MARK: - Protocol Implementation @@ -186,43 +184,33 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { playbackState.volume = clampedLevel } - // MARK: - Setup Methods - private func setupNowPlayingObserver() async { + // MARK: - Runtime Stream Lifecycle + func startRuntimeStream() { + guard streamSession == nil else { return } + let process = Process() - guard - let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"), - let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework") - else { - assertionFailure("Could not find mediaremote-adapter.pl script or framework path") - return - } - process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") - process.arguments = [scriptURL.path, frameworkPath, "stream"] - - let pipeHandler = JSONLinesPipeHandler() - process.standardOutput = await pipeHandler.getPipe() - - self.process = process - self.pipeHandler = pipeHandler - - do { - try process.run() - streamTask = Task { [weak self] in - await self?.processJSONStream() + process.arguments = [adapterScriptURL.path, adapterFrameworkPath, "stream"] + + let session = NowPlayingStreamSession( + process: process, + onUpdate: { [weak self] update in + await self?.handleAdapterUpdate(update) + }, + onFailure: { [weak self] in + guard let self else { return } + self.streamSession = nil + self.runtimeFailureContinuation.yield() } - } catch { - assertionFailure("Failed to launch mediaremote-adapter.pl: \(error)") - } + ) + streamSession = session + session.start() } - // MARK: - Async Stream Processing - private func processJSONStream() async { - guard let pipeHandler = self.pipeHandler else { return } - - await pipeHandler.readJSONLines(as: NowPlayingUpdate.self) { [weak self] update in - await self?.handleAdapterUpdate(update) - } + func stopRuntimeStream() { + let session = streamSession + streamSession = nil + session?.stop() } // MARK: - Update Methods @@ -312,34 +300,29 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { self.playbackState = newPlaybackState - // Fetch favorite state for supported apps asynchronously - // await fetchFavoriteStateIfSupported() } - - private func fetchFavoriteStateIfSupported() async { - let bundleID = playbackState.bundleIdentifier - - if bundleID == "com.apple.Music" { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") - guard !runningApps.isEmpty else { return } - - let script = """ - tell application "Music" - try - return favorited of current track - on error - return false - end try - end tell - """ - if let result = try? await AppleScriptHelper.execute(script) { - var updated = self.playbackState - updated.isFavorite = result.booleanValue - self.playbackState = updated - } - } - } - + + private func fetchFavoriteStateIfSupported() async { + guard playbackState.bundleIdentifier == "com.apple.Music" else { return } + + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + guard !runningApps.isEmpty else { return } + + let script = """ + tell application "Music" + try + return favorited of current track + on error + return false + end try + end tell + """ + if let result = try? await AppleScriptHelper.execute(script) { + var updated = playbackState + updated.isFavorite = result.booleanValue + playbackState = updated + } + } } private extension NowPlayingController { @@ -351,103 +334,3 @@ private extension NowPlayingController { return preferred.normalizedBundleIdentifiers } } - -struct NowPlayingUpdate: Codable { - let payload: NowPlayingPayload - let diff: Bool? -} - -struct NowPlayingPayload: Codable { - let title: String? - let artist: String? - let album: String? - let duration: Double? - let elapsedTime: Double? - let shuffleMode: Int? - let repeatMode: Int? - let artworkData: String? - let timestamp: String? - let playbackRate: Double? - let playing: Bool? - let parentApplicationBundleIdentifier: String? - let bundleIdentifier: String? - let volume: Double? -} - -actor JSONLinesPipeHandler { - private let pipe: Pipe - private let fileHandle: FileHandle - private var buffer = "" - - init() { - self.pipe = Pipe() - self.fileHandle = pipe.fileHandleForReading - } - - func getPipe() -> Pipe { - return pipe - } - - func readJSONLines(as type: T.Type, onLine: @escaping (T) async -> Void) async { - do { - try await self.processLines(as: type) { decodedObject in - await onLine(decodedObject) - } - } catch { - print("Error processing JSON stream: \(error)") - } - } - - private func processLines(as type: T.Type, onLine: @escaping (T) async -> Void) async throws { - while true { - let data = try await readData() - guard !data.isEmpty else { break } - - if let chunk = String(data: data, encoding: .utf8) { - buffer.append(chunk) - - while let range = buffer.range(of: "\n") { - let line = String(buffer[..(_ line: String, as type: T.Type, onLine: @escaping (T) async -> Void) async { - guard let data = line.data(using: .utf8) else { - return - } - do { - let decodedObject = try JSONDecoder().decode(T.self, from: data) - await onLine(decodedObject) - } catch { - // Ignore lines that can't be decoded - } - } - - private func readData() async throws -> Data { - return try await withCheckedThrowingContinuation { continuation in - - fileHandle.readabilityHandler = { handle in - let data = handle.availableData - handle.readabilityHandler = nil - continuation.resume(returning: data) - } - } - } - - func close() async { - do { - fileHandle.readabilityHandler = nil - try fileHandle.close() - try pipe.fileHandleForWriting.close() - } catch { - print("Error closing pipe handler: \(error)") - } - } -} diff --git a/boringNotch/MediaControllers/NowPlayingStreamSupport.swift b/boringNotch/MediaControllers/NowPlayingStreamSupport.swift new file mode 100644 index 000000000..0b9e17382 --- /dev/null +++ b/boringNotch/MediaControllers/NowPlayingStreamSupport.swift @@ -0,0 +1,307 @@ +import Foundation + +enum TimedProcessRunner { + static func exitsSuccessfully( + _ process: Process, + timeout: Duration + ) async throws -> Bool { + try Task.checkCancellation() + + let terminations = AsyncStream { continuation in + process.terminationHandler = { process in + continuation.yield( + process.terminationReason == .exit && process.terminationStatus == 0 + ) + continuation.finish() + } + } + + do { + try process.run() + } catch { + process.terminationHandler = nil + throw error + } + + defer { process.terminationHandler = nil } + + return try await withTaskCancellationHandler { + let succeeded = await withTaskGroup(of: Bool.self) { group in + group.addTask { + for await succeeded in terminations { + return succeeded + } + return false + } + group.addTask { + try? await Task.sleep(for: timeout) + return false + } + + let result = await group.next() ?? false + group.cancelAll() + return result + } + + try Task.checkCancellation() + stop(process) + return succeeded + } onCancel: { + stop(process) + } + } + + static func stop(_ process: Process) { + guard process.isRunning else { return } + _ = kill(process.processIdentifier, SIGKILL) + } +} + +enum NowPlayingError: Error, Sendable { case unavailable } + +struct NowPlayingResources: Sendable { + let adapterScriptURL: URL + let adapterFrameworkPath: String + let testClientURL: URL + + static func load(from bundle: Bundle = .main) throws -> Self { + let fileManager = FileManager.default + + guard let scriptURL = bundle.url( + forResource: "mediaremote-adapter", + withExtension: "pl" + ), fileManager.isReadableFile(atPath: scriptURL.path) else { + throw NowPlayingError.unavailable + } + + guard let privateFrameworksPath = bundle.privateFrameworksPath else { + throw NowPlayingError.unavailable + } + let frameworkPath = privateFrameworksPath.appending("/MediaRemoteAdapter.framework") + let frameworkExecutable = frameworkPath.appending("/MediaRemoteAdapter") + guard fileManager.isExecutableFile(atPath: frameworkExecutable) else { + throw NowPlayingError.unavailable + } + + guard let testClientURL = bundle.url( + forResource: "MediaRemoteAdapterTestClient", + withExtension: nil + ), fileManager.isExecutableFile(atPath: testClientURL.path) else { + throw NowPlayingError.unavailable + } + + return Self( + adapterScriptURL: scriptURL, + adapterFrameworkPath: frameworkPath, + testClientURL: testClientURL + ) + } +} + +enum NowPlayingFailure: Equatable, Sendable { + case setup + case probe + case runtime +} + +enum NowPlayingAvailability: Equatable, Sendable { + case unchecked + case checking + case available + case unavailable(NowPlayingFailure) + + var isSelectable: Bool { self == .available } + + var failure: NowPlayingFailure? { + guard case let .unavailable(failure) = self else { return nil } + return failure + } + + var offersManualRetry: Bool { failure == .probe } + + var usesTemporaryFallback: Bool { + guard let failure else { return false } + return failure != .setup + } + + var settingsMessage: LocalizedStringResource? { + switch self { + case .unchecked, .checking, .available: + nil + case .unavailable(.setup): + LocalizedStringResource( + "Boring Notch's Now Playing components are unavailable. Reopen or reinstall the app.", + comment: "Now Playing setup failure message shown when required bundled components cannot be used." + ) + case .unavailable(.probe): + LocalizedStringResource( + "Boring Notch could not verify Now Playing. Try again, or reopen the app if it keeps happening.", + comment: "Recoverable Now Playing probe failure message." + ) + case .unavailable(.runtime): + LocalizedStringResource( + "Boring Notch lost its Now Playing connection. Reconnecting automatically...", + comment: "Now Playing runtime failure message shown before an automatic recovery attempt." + ) + } + } +} + +actor JSONLinesPipeHandler { + nonisolated let outputPipe: Pipe + nonisolated let fileHandle: FileHandle + private var byteIterator: FileHandle.AsyncBytes.Iterator + private var consecutiveMalformedLines = 0 + + init(pipe: Pipe = Pipe()) { + outputPipe = pipe + fileHandle = pipe.fileHandleForReading + byteIterator = fileHandle.bytes.makeAsyncIterator() + } + + func readJSONLines( + as type: Value.Type, + onValue: @escaping @Sendable (Value) async -> Void + ) async { + var line = Data() + var iterator = byteIterator + + do { + while let byte = try await iterator.next() { + guard !Task.isCancelled else { return } + + guard byte == UInt8(ascii: "\n") else { + line.append(byte) + continue + } + + if line.last == UInt8(ascii: "\r") { + line.removeLast() + } + + guard !line.isEmpty, + let decoded = try? JSONDecoder().decode(Value.self, from: line) + else { + consecutiveMalformedLines += 1 + line.removeAll(keepingCapacity: true) + if consecutiveMalformedLines >= 3 { + return + } + continue + } + + consecutiveMalformedLines = 0 + line.removeAll(keepingCapacity: true) + await onValue(decoded) + } + } catch {} + } + + nonisolated func close() { + try? fileHandle.close() + try? outputPipe.fileHandleForWriting.close() + } +} + +@MainActor +final class NowPlayingStreamSession { + private let process: Process + private let reader: JSONLinesPipeHandler + private let onUpdate: @MainActor (NowPlayingUpdate) async -> Void + private let onFailure: @MainActor () -> Void + + private var readTask: Task? + private var isStopped = false + + init( + process: Process, + reader: JSONLinesPipeHandler = JSONLinesPipeHandler(), + onUpdate: @escaping @MainActor (NowPlayingUpdate) async -> Void, + onFailure: @escaping @MainActor () -> Void + ) { + self.process = process + self.reader = reader + self.onUpdate = onUpdate + self.onFailure = onFailure + } + + func start() { + guard readTask == nil, !isStopped else { return } + + process.standardOutput = reader.outputPipe + process.terminationHandler = { [weak self] _ in + Task { @MainActor [weak self] in + self?.finish() + } + } + + do { + try process.run() + } catch { + process.terminationHandler = nil + finish() + return + } + + let session = self + readTask = Task { @MainActor [reader, session] in + await reader.readJSONLines(as: NowPlayingUpdate.self) { update in + await session.receive(update) + } + session.finish() + } + } + + func stop() { + guard !isStopped else { return } + cleanup() + } + + private func receive(_ update: NowPlayingUpdate) async { + guard !isStopped else { return } + await onUpdate(update) + } + + private func finish() { + guard !isStopped else { return } + cleanup() + onFailure() + } + + private func cleanup() { + isStopped = true + process.terminationHandler = nil + readTask?.cancel() + readTask = nil + reader.close() + TimedProcessRunner.stop(process) + } + + deinit { + process.terminationHandler = nil + reader.close() + TimedProcessRunner.stop(process) + } +} + +struct NowPlayingUpdate: Codable, Sendable { + let payload: NowPlayingPayload + let diff: Bool? +} + +struct NowPlayingPayload: Codable, Sendable { + let title: String? + let artist: String? + let album: String? + let duration: Double? + let elapsedTime: Double? + let shuffleMode: Int? + let repeatMode: Int? + let artworkData: String? + let timestamp: String? + let playbackRate: Double? + let playing: Bool? + let parentApplicationBundleIdentifier: String? + let bundleIdentifier: String? + let volume: Double? +} diff --git a/boringNotch/MediaControllers/SpotifyController.swift b/boringNotch/MediaControllers/SpotifyController.swift index f1a2867c5..9bf5d38c7 100644 --- a/boringNotch/MediaControllers/SpotifyController.swift +++ b/boringNotch/MediaControllers/SpotifyController.swift @@ -9,6 +9,7 @@ import Foundation import Combine import SwiftUI +@MainActor class SpotifyController: MediaControllerProtocol { func setFavorite(_ favorite: Bool) async { //Placeholder diff --git a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift b/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift index c5a3b87bd..ddfbce9ae 100644 --- a/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift +++ b/boringNotch/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift @@ -10,6 +10,7 @@ import Foundation import Combine import SwiftUI +@MainActor final class YouTubeMusicController: MediaControllerProtocol { // MARK: - Published Properties @Published var playbackState = PlaybackState( @@ -69,10 +70,15 @@ final class YouTubeMusicController: MediaControllerProtocol { deinit { artworkFetchTask?.cancel() - cancelReconnect(resetDelay: false) + reconnectTask?.cancel() appStateObserver?.cancel() - stopPeriodicUpdates() - disconnectClient(takeWebSocketClient()) + updateTimer?.invalidate() + + if let webSocketClient { + Task { + await webSocketClient.disconnect() + } + } } // MARK: - MediaControllerProtocol Implementation @@ -106,7 +112,7 @@ final class YouTubeMusicController: MediaControllerProtocol { func toggleShuffle() async { await sendCommand(endpoint: "/shuffle", method: "POST") } func toggleRepeat() async { await sendCommand(endpoint: "/switch-repeat", method: "POST") } - nonisolated func isActive() -> Bool { + func isActive() -> Bool { NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == configuration.bundleIdentifier } diff --git a/boringNotch/boringNotchApp.swift b/boringNotch/boringNotchApp.swift index c3b78a6b6..489a9ee23 100644 --- a/boringNotch/boringNotchApp.swift +++ b/boringNotch/boringNotchApp.swift @@ -6,7 +6,6 @@ // import AVFoundation -import Combine import Defaults import KeyboardShortcuts import Sparkle @@ -470,12 +469,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { self.showOnboardingWindow() } playWelcomeSound() - } else if MusicManager.shared.isNowPlayingDeprecated - && Defaults[.mediaController] == .nowPlaying - { - DispatchQueue.main.async { - self.showOnboardingWindow(step: .musicPermission) - } } previousScreens = NSScreen.screens diff --git a/boringNotch/components/Onboarding/MusicControllerSelectionView.swift b/boringNotch/components/Onboarding/MusicControllerSelectionView.swift index 9ae147ea5..29dd15e92 100644 --- a/boringNotch/components/Onboarding/MusicControllerSelectionView.swift +++ b/boringNotch/components/Onboarding/MusicControllerSelectionView.swift @@ -6,23 +6,13 @@ // import SwiftUI -import Defaults - +@MainActor struct MusicControllerSelectionView: View { let onContinue: () -> Void - @Default(.mediaController) var mediaController - - private var availableMediaControllers: [MediaControllerType] { - if MusicManager.shared.isNowPlayingDeprecated { - return MediaControllerType.allCases.filter { $0 != .nowPlaying } - } else { - return MediaControllerType.allCases - } - } - - @State private var selectedMediaController: MediaControllerType = Defaults[.mediaController] + @ObservedObject private var musicManager = MusicManager.shared + @State private var selectedMediaController = MusicManager.shared.preferredMediaController var body: some View { VStack(spacing: 20) { @@ -39,46 +29,94 @@ struct MusicControllerSelectionView: View { ScrollView { VStack(spacing: 12) { - ForEach(availableMediaControllers) { controller in - ControllerOptionView( - controller: controller, - isSelected: self.selectedMediaController == controller - ) - .onTapGesture { - self.selectedMediaController = controller + ForEach(MediaControllerType.allCases) { controller in + let isEnabled = controller != .nowPlaying + || isNowPlayingSelectionEnabled + + Button { + selectedMediaController = controller + } label: { + ControllerOptionView( + controller: controller, + isSelected: selectedMediaController == controller, + isEnabled: isEnabled + ) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + + if controller == .youtubeMusic, + let url = URL(string: "https://github.com/pear-devs/pear-desktop") { + Link("View on GitHub: pear-devs/pear-desktop", destination: url) + .font(.subheadline) } } } .padding() } - //Disable scroll if there are 4 or fewer to avoid unnecessary scroll behavior - .scrollDisabled(availableMediaControllers.count <= 4) + // Disable scroll if there are 4 or fewer to avoid unnecessary scroll behavior + .scrollDisabled(MediaControllerType.allCases.count <= 4) -// Spacer() + nowPlayingAvailabilityStatus Button("Continue", action: { - self.mediaController = self.selectedMediaController - NotificationCenter.default.post( - name: Notification.Name.mediaControllerChanged, - object: nil - ) + guard canContinue else { return } + musicManager.selectMediaController(selectedMediaController) onContinue() }) .buttonStyle(.borderedProminent) .controlSize(.large) + .disabled(!canContinue) .padding(.bottom, 24) } + .task { + musicManager.ensureNowPlayingAvailabilityChecked() + } .frame(maxWidth: .infinity, maxHeight: .infinity) .background( VisualEffectView(material: .underWindowBackground, blendingMode: .behindWindow) .ignoresSafeArea() ) } + + private var canContinue: Bool { + selectedMediaController != .nowPlaying + || isNowPlayingSelectionEnabled + } + + private var isNowPlayingSelectionEnabled: Bool { + musicManager.nowPlayingAvailability.isSelectable + } + + @ViewBuilder + private var nowPlayingAvailabilityStatus: some View { + if musicManager.nowPlayingAvailability == .checking { + Text("Checking Now Playing availability...") + .font(.caption) + .foregroundStyle(.secondary) + } else if let message = musicManager.nowPlayingAvailability.settingsMessage { + VStack(spacing: 8) { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if musicManager.nowPlayingAvailability.offersManualRetry { + Button("Check Again") { + musicManager.refreshNowPlayingAvailability() + } + .font(.caption) + } + } + .padding(.horizontal, 24) + } + } } struct ControllerOptionView: View { let controller: MediaControllerType let isSelected: Bool + let isEnabled: Bool var body: some View { HStack(spacing: 16) { @@ -92,15 +130,10 @@ struct ControllerOptionView: View { .font(.headline) .fontWeight(.semibold) - Text(controller.description) + Text(controller.descriptionResource) .font(.subheadline) .foregroundColor(.secondary) - if controller == .youtubeMusic, let url = URL(string: "https://github.com/pear-devs/pear-desktop") { - Link("View on GitHub: pear-devs/pear-desktop", destination: url) - .font(.subheadline) - .padding(.top, 2) - } } Spacer() @@ -115,21 +148,34 @@ struct ControllerOptionView: View { .stroke(isSelected ? Color.effectiveAccent : Color.secondary.opacity(0.3), lineWidth: 1.5) ) .contentShape(Rectangle()) + .opacity(isEnabled ? 1 : 0.5) } } extension MediaControllerType { - var description: String { + var descriptionResource: LocalizedStringResource { switch self { case .nowPlaying: - return "Works with most media apps, including browsers, to detect what's playing. Note: This may be removed in a future macOS version." + LocalizedStringResource( + "Works with most media apps, including browsers, to detect what's playing. Note: This may be removed in a future macOS version.", + comment: "Onboarding description of the universal macOS Now Playing music source." + ) case .spotify: - return "Connects directly to the Spotify app." + LocalizedStringResource( + "Connects directly to the Spotify app.", + comment: "Onboarding description of the Spotify music source." + ) case .appleMusic: - return "Connects directly to the Apple Music app." + LocalizedStringResource( + "Connects directly to the Apple Music app.", + comment: "Onboarding description of the Apple Music source." + ) case .youtubeMusic: - return "Requires a third-party client with API plugin enabled." + LocalizedStringResource( + "Requires a third-party client with API plugin enabled.", + comment: "Onboarding description of the YouTube Music source." + ) } } } diff --git a/boringNotch/components/Settings/Views/MediaSettingsView.swift b/boringNotch/components/Settings/Views/MediaSettingsView.swift index 55410274d..3ba4674c7 100644 --- a/boringNotch/components/Settings/Views/MediaSettingsView.swift +++ b/boringNotch/components/Settings/Views/MediaSettingsView.swift @@ -10,50 +10,31 @@ import SwiftUI struct Media: View { @Default(.waitInterval) var waitInterval - @Default(.mediaController) var mediaController @ObservedObject var coordinator = BoringViewCoordinator.shared @Default(.hideNotchOption) var hideNotchOption @Default(.enableSneakPeek) private var enableSneakPeek @Default(.sneakPeekStyles) var sneakPeekStyles @Default(.enableLyrics) var enableLyrics + @ObservedObject private var musicManager = MusicManager.shared var body: some View { Form { Section { - Picker("Music Source", selection: $mediaController) { - ForEach(availableMediaControllers) { controller in - Text(controller.localizedString).tag(controller) + Picker("Music Source", selection: mediaControllerSelection) { + ForEach(MediaControllerType.allCases) { controller in + Text(controller.localizedResource) + .tag(controller) + .disabled( + controller == .nowPlaying + && !musicManager.nowPlayingAvailability.isSelectable + ) } } - .onChange(of: mediaController) { _, _ in - NotificationCenter.default.post( - name: Notification.Name.mediaControllerChanged, - object: nil - ) - } } header: { Text("Media Source") } footer: { - if MusicManager.shared.isNowPlayingDeprecated { - HStack { - Text("YouTube Music requires this third-party app to be installed: ") - .foregroundStyle(.secondary) - .font(.caption) - Link( - "https://github.com/pear-devs/pear-desktop", - destination: URL(string: "https://github.com/pear-devs/pear-desktop")! - ) - .font(.caption) - .foregroundColor(.blue) // Ensures it's visibly a link - } - } else { - Text( - "'Now Playing' was the only option on previous versions and works with all media apps." - ) - .foregroundStyle(.secondary) - .font(.caption) - } + mediaSourceFooter } Section { @@ -124,14 +105,68 @@ struct Media: View { } .accentColor(.effectiveAccent) .navigationTitle("Media") + .task { + musicManager.ensureNowPlayingAvailabilityChecked() + } } - // Only show controller options that are available on this macOS version - private var availableMediaControllers: [MediaControllerType] { - if MusicManager.shared.isNowPlayingDeprecated { - return MediaControllerType.allCases.filter { $0 != .nowPlaying } + private var mediaControllerSelection: Binding { + Binding( + get: { musicManager.preferredMediaController }, + set: { selectedController in + guard selectedController != musicManager.preferredMediaController else { return } + musicManager.selectMediaController(selectedController) + } + ) + } + + @ViewBuilder + private var mediaSourceFooter: some View { + let availability = musicManager.nowPlayingAvailability + + if availability == .checking { + footerText("Checking Now Playing availability...") + } else if let message = availability.settingsMessage { + VStack(alignment: .leading, spacing: 6) { + footerText(message) + + if musicManager.preferredMediaController == .nowPlaying, + let effectiveController = musicManager.effectiveMediaController, + effectiveController != .nowPlaying { + if availability.usesTemporaryFallback { + footerText( + LocalizedStringResource( + "Using \(effectiveController.localizedString) temporarily. Your Now Playing preference is preserved.", + comment: "Media settings footer for a temporary Now Playing fallback. The placeholder is the active fallback source." + ) + ) + } else { + footerText( + LocalizedStringResource( + "Using \(effectiveController.localizedString) instead. Your Now Playing preference is preserved.", + comment: "Media settings footer for a non-recoverable Now Playing setup failure. The placeholder is the active fallback source." + ) + ) + } + } + + if availability.offersManualRetry { + Button("Check Again") { + musicManager.refreshNowPlayingAvailability() + } + .font(.caption) + } + } } else { - return MediaControllerType.allCases + footerText( + "'Now Playing' was the only option on previous versions and works with all media apps." + ) } } + + private func footerText(_ text: LocalizedStringResource) -> some View { + Text(text) + .foregroundStyle(.secondary) + .font(.caption) + } } diff --git a/boringNotch/components/Webcam/WebcamView.swift b/boringNotch/components/Webcam/WebcamView.swift index e506b55fe..71900dad6 100644 --- a/boringNotch/components/Webcam/WebcamView.swift +++ b/boringNotch/components/Webcam/WebcamView.swift @@ -70,10 +70,10 @@ struct CameraPreviewView: View { case .denied, .restricted: DispatchQueue.main.async { let alert = NSAlert() - alert.messageText = "Camera Access Required" - alert.informativeText = "Please allow camera access in System Settings to use the mirror feature." - alert.addButton(withTitle: "Open System Settings") - alert.addButton(withTitle: "Cancel") + alert.messageText = NSLocalizedString("Camera Access Required", comment: "Camera permission alert title") + alert.informativeText = NSLocalizedString("Please allow camera access in System Settings to use the mirror feature.", comment: "Mirror camera permission alert message") + alert.addButton(withTitle: NSLocalizedString("Open System Settings", comment: "Button title that opens System Settings")) + alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Cancel button title")) if alert.runModal() == .alertFirstButtonReturn { if let settingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera") { diff --git a/boringNotch/helpers/MediaChecker.swift b/boringNotch/helpers/MediaChecker.swift index 50494e59a..ae8d644b8 100644 --- a/boringNotch/helpers/MediaChecker.swift +++ b/boringNotch/helpers/MediaChecker.swift @@ -7,61 +7,50 @@ import Foundation -final class MediaChecker: Sendable { - - enum MediaCheckerError: Error { - case missingResources - case processExecutionFailed - case timeout - } - - func checkDeprecationStatus() async throws -> Bool { - try await Task.detached(priority: .userInitiated) { - guard let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"), - let nowPlayingTestClientPath = Bundle.main.url(forResource: "MediaRemoteAdapterTestClient", withExtension: nil)?.path, - let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework") - else { - throw MediaCheckerError.missingResources - } - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") - process.arguments = [scriptURL.path, frameworkPath, nowPlayingTestClientPath, "test"] - +struct MediaChecker: Sendable { + func checkAvailability(maxAttempts: Int = 3) async throws -> NowPlayingAvailability { + let attempts = max(1, maxAttempts) + let resources: NowPlayingResources + + do { + resources = try NowPlayingResources.load() + } catch { + return .unavailable(.setup) + } + + for attempt in 1...attempts { + try Task.checkCancellation() do { - try process.run() + if try await runAvailabilityCheck(resources: resources) { + return .available + } + } catch is CancellationError { + throw CancellationError() } catch { - throw MediaCheckerError.processExecutionFailed + // A failed launch can be as transient as a failed probe, so retry it too. } - // Timeout after 10 seconds - let didExit: Bool = try await withThrowingTaskGroup(of: Bool.self) { group in - group.addTask { - process.waitUntilExit() - return true - } - group.addTask { - try await Task.sleep(for: .seconds(10)) - if process.isRunning { - process.terminate() - } - return false // Timed out - } - for try await exited in group { - if exited { - group.cancelAll() - return true - } - } - throw MediaCheckerError.timeout + if attempt < attempts { + try await Task.sleep(for: .milliseconds(350 * attempt)) } + } - if !didExit { - throw MediaCheckerError.timeout - } + return .unavailable(.probe) + } - let isDeprecated = process.terminationStatus == 1 - return isDeprecated - }.value + private func runAvailabilityCheck(resources: NowPlayingResources) async throws -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") + process.arguments = [ + resources.adapterScriptURL.path, + resources.adapterFrameworkPath, + resources.testClientURL.path, + "test", + ] + + return try await TimedProcessRunner.exitsSuccessfully( + process, + timeout: .seconds(10) + ) } } diff --git a/boringNotch/managers/AudioCaptureManager.swift b/boringNotch/managers/AudioCaptureManager.swift index 52331efad..417ea38e9 100644 --- a/boringNotch/managers/AudioCaptureManager.swift +++ b/boringNotch/managers/AudioCaptureManager.swift @@ -108,7 +108,9 @@ final class AudioCaptureManager: ObservableObject { fftQueue.setSpecific(key: Self.fftQueueKey, value: ()) lifecycleQueue.setSpecific(key: Self.lifecycleQueueKey, value: ()) computeBandRanges(sampleRate: sampleRate) - observeState() + Task { @MainActor [weak self] in + self?.observeState() + } } deinit { @@ -154,6 +156,7 @@ final class AudioCaptureManager: ObservableObject { // MARK: - State observation + @MainActor private func observeState() { let music = MusicManager.shared let enabledPublisher = Defaults.publisher(.realtimeAudioWaveform) diff --git a/boringNotch/managers/MusicManager.swift b/boringNotch/managers/MusicManager.swift index 275dbdcab..e32446267 100644 --- a/boringNotch/managers/MusicManager.swift +++ b/boringNotch/managers/MusicManager.swift @@ -14,26 +14,77 @@ let defaultImage: NSImage = .init( accessibilityDescription: "Album Art" )! -class MusicManager: ObservableObject { +struct NowPlayingFallbackNotice: Identifiable, Equatable { + let id = UUID() + let fallbackSource: MediaControllerType + let failure: NowPlayingFailure + + var title: LocalizedStringResource { + switch failure { + case .setup: + LocalizedStringResource( + "Now Playing components unavailable", + comment: "Title of the passive notice shown when required Now Playing components cannot be used." + ) + case .probe: + LocalizedStringResource( + "Could not verify Now Playing", + comment: "Title of the passive notice shown when the Now Playing availability probe fails." + ) + case .runtime: + LocalizedStringResource( + "Now Playing connection lost", + comment: "Title of the passive notice shown when an active Now Playing stream fails." + ) + } + } + + var subtitle: LocalizedStringResource { + if failure == .setup { + LocalizedStringResource( + "Using \(fallbackSource.localizedString) instead", + comment: "Now Playing setup failure notice. The placeholder is the fallback music source name." + ) + } else { + LocalizedStringResource( + "Using \(fallbackSource.localizedString) temporarily", + comment: "Temporary Now Playing fallback notice. The placeholder is the fallback music source name." + ) + } + } +} + +@MainActor +final class MusicManager: ObservableObject { // MARK: - Properties static let shared = MusicManager() - private var cancellables = Set() + private static let noticeDuration: Duration = .seconds(6) + private static let runtimeRecoveryDelay: Duration = .seconds(1) + private var controllerCancellables = Set() private var debounceIdleTask: Task? - - // Helper to check if macOS has removed support for NowPlayingController - public private(set) var isNowPlayingDeprecated: Bool = false - private let mediaChecker = MediaChecker() + private var availabilityTask: Task? + private var runtimeFailureTask: Task? + private var runtimeRecoveryTask: Task? + private var noticeDismissalTask: Task? + private var isDestroyed = false + private var lastNoticedFailure: NowPlayingFailure? + + // Helper to check if macOS can use NowPlayingController + @Published private(set) var preferredMediaController: MediaControllerType + @Published private(set) var nowPlayingAvailability: NowPlayingAvailability = .unchecked + @Published private(set) var effectiveMediaController: MediaControllerType? + @Published private(set) var nowPlayingNotice: NowPlayingFallbackNotice? // Active controller private var activeController: (any MediaControllerProtocol)? // Published properties for UI - @Published var songTitle: String = "I'm Handsome" - @Published var artistName: String = "Me" + @Published var songTitle: String = "" + @Published var artistName: String = "" @Published var albumArt: NSImage = defaultImage @Published var isPlaying = false - @Published var album: String = "Self Love" + @Published var album: String = "" @Published var isPlayerIdle: Bool = true @Published var animations: BoringAnimations = .init() @Published var avgColor: NSColor = .white @@ -47,7 +98,7 @@ class MusicManager: ObservableObject { @Published var repeatMode: RepeatMode = .off @Published var volume: Double = 0.5 @Published var volumeControlSupported: Bool = true - @ObservedObject var coordinator = BoringViewCoordinator.shared + private let coordinator = BoringViewCoordinator.shared @Published var usingAppIconForArtwork: Bool = false @Published var canFavoriteTrack: Bool = false @@ -61,9 +112,9 @@ class MusicManager: ObservableObject { private var artworkData: Data? = nil // Store last values at the time artwork was changed - private var lastArtworkTitle: String = "I'm Handsome" - private var lastArtworkArtist: String = "Me" - private var lastArtworkAlbum: String = "Self Love" + private var lastArtworkTitle: String = "" + private var lastArtworkArtist: String = "" + private var lastArtworkAlbum: String = "" private var lastArtworkBundleIdentifier: String? = nil @Published var isFlipping: Bool = false @@ -74,117 +125,356 @@ class MusicManager: ObservableObject { // MARK: - Initialization init() { - // Listen for changes to the default controller preference - NotificationCenter.default.publisher(for: Notification.Name.mediaControllerChanged) - .sink { [weak self] _ in - self?.setActiveControllerBasedOnPreference() - } - .store(in: &cancellables) + Self.migrateMediaControllerPreferenceIfNeeded() + preferredMediaController = Defaults[.mediaController] - // Initialize deprecation check asynchronously - Task { @MainActor in - do { - self.isNowPlayingDeprecated = try await self.mediaChecker.checkDeprecationStatus() - print("Deprecation check completed: \(self.isNowPlayingDeprecated)") - } catch { - print("Failed to check deprecation status: \(error). Defaulting to false.") - self.isNowPlayingDeprecated = false - } - - // Initialize the active controller after deprecation check - self.setActiveControllerBasedOnPreference() + if preferredMediaController == .nowPlaying { + activateFallback() + ensureNowPlayingAvailabilityChecked() + } else { + activateControllerIfNeeded(preferredMediaController) } } + private static func migrateMediaControllerPreferenceIfNeeded() { + guard !Defaults[.didMigrateMediaControllerChoice] else { return } + + let firstLaunch = UserDefaults.standard.object(forKey: "firstLaunch") as? Bool ?? true + let hasStoredController = UserDefaults.standard.object(forKey: "mediaController") != nil + Defaults[.didChooseMediaController] = Defaults[.didChooseMediaController] + || hasStoredController + || !firstLaunch + Defaults[.didMigrateMediaControllerChoice] = true + } + deinit { - destroy() + debounceIdleTask?.cancel() + availabilityTask?.cancel() + runtimeFailureTask?.cancel() + runtimeRecoveryTask?.cancel() + noticeDismissalTask?.cancel() + controllerCancellables.removeAll() + flipWorkItem?.cancel() + transitionWorkItem?.cancel() } - - public func destroy() { + + func destroy() { + guard !isDestroyed else { return } + isDestroyed = true + debounceIdleTask?.cancel() - cancellables.removeAll() + availabilityTask?.cancel() + runtimeFailureTask?.cancel() + runtimeRecoveryTask?.cancel() + noticeDismissalTask?.cancel() + availabilityTask = nil + runtimeFailureTask = nil + runtimeRecoveryTask = nil + noticeDismissalTask = nil controllerCancellables.removeAll() flipWorkItem?.cancel() transitionWorkItem?.cancel() + (activeController as? any NowPlayingRuntimeControlling)?.stopRuntimeStream() - // Release active controller activeController = nil + effectiveMediaController = nil + nowPlayingNotice = nil } - // MARK: - Setup Methods - private func createController(for type: MediaControllerType) -> (any MediaControllerProtocol)? { - // Cleanup previous controller - if activeController != nil { - controllerCancellables.removeAll() - activeController = nil - } + func selectMediaController(_ type: MediaControllerType) { + guard !isDestroyed else { return } - let newController: (any MediaControllerProtocol)? + Defaults[.mediaController] = type + Defaults[.didChooseMediaController] = true + preferredMediaController = type + cancelRuntimeRecovery() + clearNotice() - switch type { - case .nowPlaying: - // Only create NowPlayingController if not deprecated on this macOS version - if !self.isNowPlayingDeprecated { - newController = NowPlayingController() + if type == .nowPlaying { + if nowPlayingAvailability == .available { + activateControllerIfNeeded(.nowPlaying) } else { - return nil + activateFallback() + refreshNowPlayingAvailability() } - case .appleMusic: - newController = AppleMusicController() - case .spotify: - newController = SpotifyController() - case .youtubeMusic: - newController = YouTubeMusicController() + } else { + activateControllerIfNeeded(type) } + } + + private func resolvedNowPlayingFallback() -> MediaControllerType { + guard let bundleIdentifier = Defaults[.lastSupportedNowPlayingBundleIdentifier], + let controller = MediaControllerType(nowPlayingBundleIdentifier: bundleIdentifier) + else { + return .appleMusic + } + + return controller + } + + func ensureNowPlayingAvailabilityChecked() { + guard nowPlayingAvailability == .unchecked else { return } + startAvailabilityCheck() + } + + func refreshNowPlayingAvailability() { + cancelRuntimeRecovery() + startAvailabilityCheck() + } + + private func startAvailabilityCheck() { + guard !isDestroyed, availabilityTask == nil else { return } + + nowPlayingAvailability = .checking + availabilityTask = Task { @MainActor [weak self] in + let availability: NowPlayingAvailability + do { + availability = try await MediaChecker().checkAvailability(maxAttempts: 3) + } catch is CancellationError { + return + } catch { + availability = .unavailable(.probe) + } - // Set up state observation for the new controller - if let controller = newController { - controller.playbackStatePublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] state in - guard let self = self, - self.activeController === controller else { return } - self.updateFromPlaybackState(state) + guard let self, !self.isDestroyed else { return } + self.availabilityTask = nil + self.nowPlayingAvailability = availability + + if availability == .available { + self.clearNotice() + if self.preferredMediaController == .nowPlaying { + self.activateControllerIfNeeded(.nowPlaying) } - .store(in: &controllerCancellables) + } else if self.preferredMediaController == .nowPlaying, + let failure = availability.failure { + let noticeFailure = Defaults[.didChooseMediaController] ? failure : nil + self.activateFallback(noticeFailure: noticeFailure) + } + } + } + + private func activateFallback(noticeFailure: NowPlayingFailure? = nil) { + let fallbackController = resolvedNowPlayingFallback() + + if let noticeFailure { + requestNotice(fallbackController: fallbackController, failure: noticeFailure) } - return newController + activateControllerIfNeeded(fallbackController) } - private func setActiveControllerBasedOnPreference() { - let preferredType = Defaults[.mediaController] - print("Preferred Media Controller: \(preferredType)") + private func requestNotice( + fallbackController: MediaControllerType, + failure: NowPlayingFailure + ) { + guard lastNoticedFailure != failure else { return } + lastNoticedFailure = failure + + noticeDismissalTask?.cancel() + noticeDismissalTask = nil + nowPlayingNotice = NowPlayingFallbackNotice( + fallbackSource: fallbackController, + failure: failure + ) + } - // If NowPlaying is deprecated but that's the preference, use Apple Music instead - let controllerType = (self.isNowPlayingDeprecated && preferredType == .nowPlaying) - ? .appleMusic - : preferredType + @discardableResult + func markNowPlayingNoticePresented(_ noticeID: UUID) -> Bool { + guard nowPlayingNotice?.id == noticeID, + noticeDismissalTask == nil + else { + return false + } - if let controller = createController(for: controllerType) { - setActiveController(controller) - } else if controllerType != .appleMusic, let fallbackController = createController(for: .appleMusic) { - // Fallback to Apple Music if preferred controller couldn't be created - setActiveController(fallbackController) + noticeDismissalTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await Task.sleep(for: Self.noticeDuration) + } catch { + return + } + guard self.nowPlayingNotice?.id == noticeID else { return } + self.nowPlayingNotice = nil + self.noticeDismissalTask = nil } + return true } - private func setActiveController(_ controller: any MediaControllerProtocol) { - // Cancel any existing flip animation - flipWorkItem?.cancel() + private func clearNotice() { + noticeDismissalTask?.cancel() + noticeDismissalTask = nil + nowPlayingNotice = nil + lastNoticedFailure = nil + } + + private func activateControllerIfNeeded(_ type: MediaControllerType) { + guard !isDestroyed, + activeController == nil || effectiveMediaController != type + else { + return + } + + do { + let controller = try makeController(for: type) + activateController(controller, type: type) + } catch { + if type == .nowPlaying { + let failure = NowPlayingFailure.setup + nowPlayingAvailability = .unavailable(failure) + let noticeFailure = Defaults[.didChooseMediaController] ? failure : nil + activateFallback(noticeFailure: noticeFailure) + return + } + + guard type != .appleMusic else { return } + activateController(AppleMusicController(), type: .appleMusic) + } + } + + private func makeController( + for type: MediaControllerType + ) throws -> any MediaControllerProtocol { + switch type { + case .nowPlaying: + try NowPlayingController() + case .appleMusic: + AppleMusicController() + case .spotify: + SpotifyController() + case .youtubeMusic: + YouTubeMusicController() + } + } + + private func activateController( + _ controller: any MediaControllerProtocol, + type: MediaControllerType + ) { + let isReplacingController = activeController != nil + + runtimeFailureTask?.cancel() + runtimeFailureTask = nil + (activeController as? any NowPlayingRuntimeControlling)?.stopRuntimeStream() + controllerCancellables.removeAll() - // Set new active controller + flipWorkItem?.cancel() + if isReplacingController { + resetPublishedPlaybackState() + } activeController = controller - - self.canFavoriteTrack = controller.supportsFavorite + effectiveMediaController = type + canFavoriteTrack = controller.supportsFavorite + volumeControlSupported = controller.supportsVolumeControl + + controller.playbackStatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self, controller] state in + guard let self, + self.activeController === controller, + state.lastUpdated != .distantPast + else { + return + } + self.updateFromPlaybackState(state) + } + .store(in: &controllerCancellables) + + if let runtimeController = controller as? any NowPlayingRuntimeControlling { + runtimeFailureTask = Task { @MainActor [weak self, runtimeController] in + for await _ in runtimeController.runtimeFailures { + guard let self else { return } + self.handleRuntimeFailure(from: runtimeController) + } + } + } - // Get current state from active controller forceUpdate() + (controller as? any NowPlayingRuntimeControlling)?.startRuntimeStream() + } + + private func resetPublishedPlaybackState() { + debounceIdleTask?.cancel() + debounceIdleTask = nil + + songTitle = "" + artistName = "" + album = "" + albumArt = defaultImage + isPlaying = false + isPlayerIdle = true + avgColor = .white + bundleIdentifier = nil + audioCaptureBundleIdentifiers = [] + songDuration = 0 + elapsedTime = 0 + timestampDate = Date() + playbackRate = 1 + isShuffled = false + repeatMode = .off + volume = 0.5 + usingAppIconForArtwork = false + isFavoriteTrack = false + + artworkData = nil + lastArtworkTitle = "" + lastArtworkArtist = "" + lastArtworkAlbum = "" + lastArtworkBundleIdentifier = nil + lyricsService.clearLyrics() + } + + private func handleRuntimeFailure(from controller: any NowPlayingRuntimeControlling) { + guard activeController === controller, + effectiveMediaController == .nowPlaying, + preferredMediaController == .nowPlaying + else { + return + } + + NSLog("Now Playing runtime stream failed; switching to fallback") + let failure = NowPlayingFailure.runtime + nowPlayingAvailability = .unavailable(failure) + let noticeFailure = Defaults[.didChooseMediaController] ? failure : nil + activateFallback(noticeFailure: noticeFailure) + scheduleRuntimeRecovery() + } + + private func scheduleRuntimeRecovery() { + cancelRuntimeRecovery() + runtimeRecoveryTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: Self.runtimeRecoveryDelay) + } catch { + return + } + + guard let self, + !self.isDestroyed, + self.preferredMediaController == .nowPlaying + else { + return + } + + self.runtimeRecoveryTask = nil + self.startAvailabilityCheck() + } + } + + private func cancelRuntimeRecovery() { + runtimeRecoveryTask?.cancel() + runtimeRecoveryTask = nil } // MARK: - Update Methods - @MainActor private func updateFromPlaybackState(_ state: PlaybackState) { + guard state.lastUpdated != .distantPast else { return } + + if effectiveMediaController == .nowPlaying, + MediaControllerType(nowPlayingBundleIdentifier: state.bundleIdentifier) != nil, + Defaults[.lastSupportedNowPlayingBundleIdentifier] != state.bundleIdentifier { + Defaults[.lastSupportedNowPlayingBundleIdentifier] = state.bundleIdentifier + } + // Check for playback state changes (playing/paused) if state.isPlaying != self.isPlaying { NSLog("Playback state changed: \(state.isPlaying ? "Playing" : "Paused")") @@ -309,33 +599,6 @@ class MusicManager: ObservableObject { setFavorite(!isFavoriteTrack) } - @MainActor - private func toggleAppleMusicFavorite() async { - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") - guard !runningApps.isEmpty else { return } - - let script = """ - tell application \"Music\" - if it is running then - try - set loved of current track to (not loved of current track) - return loved of current track - on error - return false - end try - else - return false - end if - end tell - """ - - if let result = try? await AppleScriptHelper.execute(script) { - let loved = result.booleanValue - self.isFavoriteTrack = loved - self.forceUpdate() - } - } - func setFavorite(_ favorite: Bool) { guard canFavoriteTrack else { return } guard let controller = activeController else { return } @@ -388,8 +651,9 @@ class MusicManager: ObservableObject { if let artworkImage = NSImage(data: artworkData) { DispatchQueue.main.async { [weak self] in - self?.usingAppIconForArtwork = false - self?.updateAlbumArt(newAlbumArt: artworkImage) + guard let self, self.artworkData == artworkData else { return } + self.usingAppIconForArtwork = false + self.updateAlbumArt(newAlbumArt: artworkImage) } } } diff --git a/boringNotch/models/BoringViewModel.swift b/boringNotch/models/BoringViewModel.swift index 77d5366d8..cb4f2e6d2 100644 --- a/boringNotch/models/BoringViewModel.swift +++ b/boringNotch/models/BoringViewModel.swift @@ -142,10 +142,10 @@ class BoringViewModel: NSObject, ObservableObject { NSApp.activate(ignoringOtherApps: true) let alert = NSAlert() - alert.messageText = "Camera Access Required" - alert.informativeText = "Please allow camera access in System Settings." - alert.addButton(withTitle: "Open Settings") - alert.addButton(withTitle: "Cancel") + alert.messageText = NSLocalizedString("Camera Access Required", comment: "Camera permission alert title") + alert.informativeText = NSLocalizedString("Please allow camera access in System Settings.", comment: "Camera permission alert message") + alert.addButton(withTitle: NSLocalizedString("Open Settings", comment: "Button title that opens app or system settings")) + alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Cancel button title")) if alert.runModal() == .alertFirstButtonReturn { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera") { diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index fc2847421..580708f9a 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -102,9 +102,6 @@ struct AppLanguage: RawRepresentable, Hashable, Identifiable, Defaults.Serializa // Define notification names at file scope extension Notification.Name { - // MARK: - Media - static let mediaControllerChanged = Notification.Name("mediaControllerChanged") - // MARK: - Display static let selectedScreenChanged = Notification.Name("SelectedScreenChanged") static let notchHeightChanged = Notification.Name("NotchHeightChanged") @@ -133,18 +130,35 @@ enum MediaControllerType: String, CaseIterable, Identifiable, Defaults.Serializa var id: String { self.rawValue } - var localizedString: String { + init?(nowPlayingBundleIdentifier bundleIdentifier: String) { + switch bundleIdentifier { + case "com.apple.Music": + self = .appleMusic + case "com.spotify.client": + self = .spotify + case YouTubeMusicConfiguration.default.bundleIdentifier: + self = .youtubeMusic + default: + return nil + } + } + + var localizedResource: LocalizedStringResource { switch self { case .nowPlaying: - return NSLocalizedString("Now Playing", comment: "") + "Now Playing" case .appleMusic: - return "Apple Music" + "Apple Music" case .spotify: - return "Spotify" + "Spotify" case .youtubeMusic: - return "YouTube Music" + "YouTube Music" } } + + var localizedString: String { + String(localized: localizedResource) + } } // Sneak peek styles for selection in settings @@ -329,6 +343,12 @@ extension Defaults.Keys { // MARK: Media Controller static let mediaController = Key("mediaController", default: defaultMediaController) + static let didChooseMediaController = Key("didChooseMediaController", default: false) + static let didMigrateMediaControllerChoice = Key("didMigrateMediaControllerChoice", default: false) + static let lastSupportedNowPlayingBundleIdentifier = Key( + "lastSupportedNowPlayingBundleIdentifier", + default: nil + ) // MARK: Advanced Settings static let useCustomAccentColor = Key("useCustomAccentColor", default: false) @@ -339,13 +359,9 @@ extension Defaults.Keys { // Normalize scroll/gesture direction so when macOS "Natural scrolling" is disabled, it doesn't invert gestures static let normalizeGestureDirection = Key("normalizeGestureDirection", default: true) - // Helper to determine the default media controller based on NowPlaying deprecation status + // Keep the default stable. Runtime availability is handled by MusicManager. static var defaultMediaController: MediaControllerType { - if MusicManager.shared.isNowPlayingDeprecated { - return .appleMusic - } else { - return .nowPlaying - } + .nowPlaying } static let didClearLegacyURLCacheV1 = Key("didClearLegacyURLCache_v1", default: false) diff --git a/boringNotch/models/PlaybackState.swift b/boringNotch/models/PlaybackState.swift index 7ad4c47f0..59781b7ba 100644 --- a/boringNotch/models/PlaybackState.swift +++ b/boringNotch/models/PlaybackState.swift @@ -17,9 +17,9 @@ struct PlaybackState { var bundleIdentifier: String var audioCaptureBundleIdentifiers: [String] = [] var isPlaying: Bool = false - var title: String = "I'm Handsome" - var artist: String = "Me" - var album: String = "Self Love" + var title: String = "" + var artist: String = "" + var album: String = "" var currentTime: Double = 0 var duration: Double = 0 var playbackRate: Double = 1 diff --git a/boringNotchTests/NowPlayingAvailabilityTests.swift b/boringNotchTests/NowPlayingAvailabilityTests.swift new file mode 100644 index 000000000..1af665244 --- /dev/null +++ b/boringNotchTests/NowPlayingAvailabilityTests.swift @@ -0,0 +1,39 @@ +// +// NowPlayingAvailabilityTests.swift +// boringNotchTests +// + +import XCTest + +@testable import boringNotch + +final class NowPlayingAvailabilityTests: XCTestCase { + func testAvailableIsSelectable() { + XCTAssertTrue(NowPlayingAvailability.available.isSelectable) + XCTAssertFalse(NowPlayingAvailability.available.usesTemporaryFallback) + } + + func testSetupFailureIsNotRetriable() { + let availability = NowPlayingAvailability.unavailable(.setup) + + XCTAssertEqual(availability.failure, .setup) + XCTAssertFalse(availability.offersManualRetry) + XCTAssertFalse(availability.usesTemporaryFallback) + } + + func testProbeFailureOffersManualRetry() { + let availability = NowPlayingAvailability.unavailable(.probe) + + XCTAssertEqual(availability.failure, .probe) + XCTAssertTrue(availability.offersManualRetry) + XCTAssertTrue(availability.usesTemporaryFallback) + } + + func testRuntimeFailureDoesNotOfferManualRetry() { + let availability = NowPlayingAvailability.unavailable(.runtime) + + XCTAssertEqual(availability.failure, .runtime) + XCTAssertFalse(availability.offersManualRetry) + XCTAssertTrue(availability.usesTemporaryFallback) + } +} diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter index b717eb00e..ff51776c9 100755 Binary files a/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter and b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter differ diff --git a/mediaremote-adapter/MediaRemoteAdapterTestClient b/mediaremote-adapter/MediaRemoteAdapterTestClient index 5cf79eb07..68af13305 100755 Binary files a/mediaremote-adapter/MediaRemoteAdapterTestClient and b/mediaremote-adapter/MediaRemoteAdapterTestClient differ diff --git a/mediaremote-adapter/mediaremote-adapter.pl b/mediaremote-adapter/mediaremote-adapter.pl index 7ff9017b2..0c39684c5 100755 --- a/mediaremote-adapter/mediaremote-adapter.pl +++ b/mediaremote-adapter/mediaremote-adapter.pl @@ -56,11 +56,14 @@ () --no-diff: Disable diffing and always dump all metadata --debounce=N: Delay in milliseconds to prevent spam (0 by default) get, stream - --micros: Replaces the following time keys with microsecond equivalents + --micros: Replaces the following time keys with microsecond equivalents: "duration" -> "durationMicros" "elapsedTime" -> "elapsedTimeMicros" "elapsedTimeNow" -> "elapsedTimeNowMicros" "timestamp" -> "timestampEpochMicros" (converted to epoch time) + --no-artwork: Omits "artworkData" and "artworkMimeType" from the payload. + Useful for consumers that do not render artwork, since this avoids + emitting several hundred kilobytes of base64 data per update. --human-readable, -h: Makes values human-readable. Use only for debugging. The JSON output is pretty-printed and the following keys are adapted: "artworkData" -> Binary data is truncated to a shorter representation @@ -81,7 +84,8 @@ () sub fail { my ($error) = @_; print STDERR "$error\n"; - exit 1; + # Keep wrapper failures disjoint from adapter_test's documented 0...4 statuses. + exit 64; } fail "Framework path not provided" unless @ARGV >= 1; @@ -125,7 +129,7 @@ sub parse_options { my $i = $start_index; while ($i <= $#ARGV) { my $arg = $ARGV[$i]; - if ($arg =~ /^--([a-z\\-]+)(?:=(.*))?$/) { + if ($arg =~ /^--([a-z:\.\\-]+)(?:=(.*))?$/) { my $key = $1; my $value = defined $2 ? $2 : undef; $arg_map{$key} = $value; @@ -196,9 +200,15 @@ sub set_env_option_value { elsif ($key eq "micros") { set_env_option($options, $key); } + elsif ($key eq "no-artwork") { + set_env_option($options, $key); + } elsif ($key eq "human-readable" || $key eq "h") { set_env_option($options, "human-readable"); } + elsif ($key eq "experimental-peculiar-debounce:com.tidal.desktop") { + set_env_option_value($options, $key); + } else { fail "Unrecognized option '$key'"; } @@ -211,6 +221,9 @@ sub set_env_option_value { if ($key eq "micros") { set_env_option($options, $key); } + elsif ($key eq "no-artwork") { + set_env_option($options, $key); + } elsif ($key eq "human-readable" || $key eq "h") { set_env_option($options, "human-readable"); }