diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index 2df2959da..e8e101258 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -90,12 +90,13 @@ struct ContentView: View { { chinWidth = 640 } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) - && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) + && vm.notchState == .closed && musicManager.isMusicAppActive + && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { chinWidth += (2 * max(0, displayClosedNotchHeight - 12) + 20) } else if !coordinator.expandingView.show && vm.notchState == .closed - && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] + && !musicManager.isMusicAppActive && Defaults[.showNotHumanFace] && !vm.hideOnClosed { chinWidth += (2 * max(0, displayClosedNotchHeight - 12) + 20) @@ -318,11 +319,13 @@ struct ContentView: View { gestureProgress: $gestureProgress ) .transition(.opacity) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && musicManager.isMusicAppActive && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { MusicLiveActivity() .frame(alignment: .center) - } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { + .transition(.opacity.combined(with: .scale(scale: 0.8))) + } else if !coordinator.expandingView.show && vm.notchState == .closed && !musicManager.isMusicAppActive && Defaults[.showNotHumanFace] && !vm.hideOnClosed { BoringFaceAnimation() + .transition(.opacity.combined(with: .scale(scale: 0.8))) } else if vm.notchState == .open { BoringHeader() .frame(height: max(24, displayClosedNotchHeight)) @@ -387,6 +390,7 @@ struct ContentView: View { ShelfView() } } + .frame(maxHeight: .infinity, alignment: .top) .transition( .scale(scale: 0.8, anchor: .top) .combined(with: .opacity) diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index d041f6e52..c8d8d636f 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -7394,6 +7394,9 @@ }, "Normalize gesture direction" : { + }, + "No music app active" : { + }, "Not charging" : { "localizations" : { @@ -7690,6 +7693,9 @@ } } } + }, + "Open a music app to see playback controls" : { + }, "Open Calendar Settings" : { "localizations" : { diff --git a/boringNotch/MediaControllers/NowPlayingController.swift b/boringNotch/MediaControllers/NowPlayingController.swift index 09d24d71d..d11f83199 100644 --- a/boringNotch/MediaControllers/NowPlayingController.swift +++ b/boringNotch/MediaControllers/NowPlayingController.swift @@ -144,7 +144,12 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { } func isActive() -> Bool { - return true + // Check if the currently tracked app (from playback state) is running + let bundleID = playbackState.bundleIdentifier + if bundleID.isEmpty { + return false + } + return NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == bundleID } } func toggleShuffle() async { diff --git a/boringNotch/components/Notch/NotchHomeView.swift b/boringNotch/components/Notch/NotchHomeView.swift index 65aaa1280..6887c9219 100644 --- a/boringNotch/components/Notch/NotchHomeView.swift +++ b/boringNotch/components/Notch/NotchHomeView.swift @@ -399,6 +399,7 @@ struct NotchHomeView: View { @ObservedObject var webcamManager = WebcamManager.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var coordinator = BoringViewCoordinator.shared + @ObservedObject var musicManager = MusicManager.shared let albumArtNamespace: Namespace.ID var body: some View { @@ -414,14 +415,33 @@ struct NotchHomeView: View { private var shouldShowCamera: Bool { Defaults[.showMirror] && webcamManager.cameraAvailable && vm.isCameraExpanded } + + /// Determines if the music player should be visible based on whether a music app is active + private var shouldShowMusicPlayer: Bool { + musicManager.isMusicAppActive + } + + /// Calculate the width for calendar when music player is hidden + private var calendarWidthWhenMusicHidden: CGFloat { + shouldShowCamera ? 280 : 400 + } private var mainContent: some View { HStack(alignment: .top, spacing: (shouldShowCamera && Defaults[.showCalendar]) ? 10 : 15) { - MusicPlayerView(albumArtNamespace: albumArtNamespace) + // Music Player - only show when a music app is active + if shouldShowMusicPlayer { + MusicPlayerView(albumArtNamespace: albumArtNamespace) + .transition(.asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.9, anchor: .leading)).combined(with: .move(edge: .leading)), + removal: .opacity.combined(with: .scale(scale: 0.9, anchor: .leading)).combined(with: .move(edge: .leading)) + )) + } if Defaults[.showCalendar] { CalendarView() - .frame(width: shouldShowCamera ? 170 : 215) + .frame(width: shouldShowMusicPlayer + ? (shouldShowCamera ? 170 : 215) + : calendarWidthWhenMusicHidden) .onHover { isHovering in vm.isHoveringCalendar = isHovering } @@ -436,12 +456,42 @@ struct NotchHomeView: View { .blur(radius: vm.notchState == .closed ? 20 : 0) .animation(.interactiveSpring(response: 0.32, dampingFraction: 0.76, blendDuration: 0), value: shouldShowCamera) } + + // When no music app is active and neither calendar nor camera is shown, + // show an empty state or placeholder + if !shouldShowMusicPlayer && !Defaults[.showCalendar] && !shouldShowCamera { + EmptyMusicPlaceholder() + .transition(.opacity) + } } + .animation(.smooth(duration: 0.35), value: shouldShowMusicPlayer) .transition(.asymmetric(insertion: .opacity.combined(with: .move(edge: .top)), removal: .opacity)) .blur(radius: vm.notchState == .closed ? 30 : 0) } } +/// A placeholder view shown when no music app is active and no other widgets are enabled +private struct EmptyMusicPlaceholder: View { + var body: some View { + VStack(spacing: 12) { + Image(systemName: "music.note") + .font(.system(size: 32, weight: .light)) + .foregroundStyle(.secondary) + + Text("No music app active") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text("Open a music app to see playback controls") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} + struct MusicSliderView: View { @Binding var sliderValue: Double @Binding var duration: Double diff --git a/boringNotch/managers/MusicManager.swift b/boringNotch/managers/MusicManager.swift index 93ec90754..b49819927 100644 --- a/boringNotch/managers/MusicManager.swift +++ b/boringNotch/managers/MusicManager.swift @@ -56,8 +56,13 @@ class MusicManager: ObservableObject { var isFetchingLyrics: Bool { lyricsService.isFetchingLyrics } var syncedLyrics: [(time: Double, text: String)] { lyricsService.syncedLyrics } @Published var isFavoriteTrack: Bool = false + + /// Indicates whether a music app is currently running (not just playing) + /// Used to determine if the music widget should be visible + @Published var isMusicAppActive: Bool = false private var artworkData: Data? = nil + private var musicAppMonitorTask: Task? // Store last values at the time artwork was changed private var lastArtworkTitle: String = "I'm Handsome" @@ -79,6 +84,42 @@ class MusicManager: ObservableObject { self?.setActiveControllerBasedOnPreference() } .store(in: &cancellables) + + // Listen for app launch to track music app activity + NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didLaunchApplicationNotification) + .sink { [weak self] notification in + guard let self = self else { return } + // Check if the launched app might be a music app + if let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let bundleID = app.bundleIdentifier { + // Update if it's a dedicated music app or matches current playback + if MusicManager.dedicatedMusicAppBundleIdentifiers.contains(bundleID) || + bundleID == self.bundleIdentifier { + self.updateMusicAppActiveState() + } + } + } + .store(in: &cancellables) + + // Listen for app termination to track music app activity + NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification) + .sink { [weak self] notification in + guard let self = self else { return } + // Check if the terminated app might be a music app + if let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + let bundleID = app.bundleIdentifier { + // Update if it's a dedicated music app or matches current playback + if MusicManager.dedicatedMusicAppBundleIdentifiers.contains(bundleID) || + bundleID == self.bundleIdentifier { + // Add a small delay to ensure the app is fully removed from running apps list + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(150)) + self.updateMusicAppActiveState() + } + } + } + } + .store(in: &cancellables) // Initialize deprecation check asynchronously Task { @MainActor in @@ -92,6 +133,9 @@ class MusicManager: ObservableObject { // Initialize the active controller after deprecation check self.setActiveControllerBasedOnPreference() + + // Initial check for music app activity + self.updateMusicAppActiveState() } } @@ -101,6 +145,7 @@ class MusicManager: ObservableObject { public func destroy() { debounceIdleTask?.cancel() + musicAppMonitorTask?.cancel() cancellables.removeAll() controllerCancellables.removeAll() flipWorkItem?.cancel() @@ -587,4 +632,77 @@ class MusicManager: ObservableObject { } } } + + // MARK: - Music App Activity Detection + + /// Dedicated music app bundle identifiers (not browsers) + private static let dedicatedMusicAppBundleIdentifiers: Set = [ + "com.apple.Music", + "com.spotify.client", + "com.tidal.desktop", + "com.amazon.music", + "com.pandora.desktop", + "com.deezer.deezer-desktop", + "com.soundcloud.desktop", + "tv.plex.plexamp", + "com.roon.Roon", + "com.vox.vox", + "com.coppertino.Vox", + "com.clementine-player.clementine", + "org.videolan.vlc", + "com.colliderli.iina", + "io.mpv" + ] + + /// Checks if any music app is currently running based on the active controller + /// This is smarter than just checking for running apps - it checks if the + /// currently configured media controller's app is active + private func checkIfAnyMusicAppIsRunning() -> Bool { + // First check: is the currently active controller's app running? + // This is the primary check - if user selected Spotify, check if Spotify is running + if let controller = activeController, controller.isActive() { + return true + } + + // Second check: if we have a bundle identifier from current playback, + // check if that app is running (handles NowPlaying controller case) + if let currentBundleID = bundleIdentifier, + !currentBundleID.isEmpty { + let runningApps = NSWorkspace.shared.runningApplications + if runningApps.contains(where: { $0.bundleIdentifier == currentBundleID }) { + return true + } + } + + // Third check: check if any dedicated music app is running + // This catches cases where user has a music app open but no playback yet + let runningApps = NSWorkspace.shared.runningApplications + for app in runningApps { + if let bundleID = app.bundleIdentifier, + Self.dedicatedMusicAppBundleIdentifiers.contains(bundleID) { + return true + } + } + + return false + } + + /// Updates the `isMusicAppActive` state based on running applications + /// Called when apps launch/terminate + private func updateMusicAppActiveState() { + let isActive = checkIfAnyMusicAppIsRunning() + + if isActive != isMusicAppActive { + withAnimation(.smooth(duration: 0.3)) { + isMusicAppActive = isActive + } + + // If music app became inactive, also mark player as idle + if !isActive { + withAnimation(.smooth(duration: 0.3)) { + isPlayerIdle = true + } + } + } + } }