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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions boringNotch/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -387,6 +390,7 @@ struct ContentView: View {
ShelfView()
}
}
.frame(maxHeight: .infinity, alignment: .top)
.transition(
.scale(scale: 0.8, anchor: .top)
.combined(with: .opacity)
Expand Down
6 changes: 6 additions & 0 deletions boringNotch/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -7394,6 +7394,9 @@
},
"Normalize gesture direction" : {

},
"No music app active" : {

},
"Not charging" : {
"localizations" : {
Expand Down Expand Up @@ -7690,6 +7693,9 @@
}
}
}
},
"Open a music app to see playback controls" : {

},
"Open Calendar Settings" : {
"localizations" : {
Expand Down
7 changes: 6 additions & 1 deletion boringNotch/MediaControllers/NowPlayingController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
54 changes: 52 additions & 2 deletions boringNotch/components/Notch/NotchHomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
Expand Down
118 changes: 118 additions & 0 deletions boringNotch/managers/MusicManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

// Store last values at the time artwork was changed
private var lastArtworkTitle: String = "I'm Handsome"
Expand All @@ -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
Expand All @@ -92,6 +133,9 @@ class MusicManager: ObservableObject {

// Initialize the active controller after deprecation check
self.setActiveControllerBasedOnPreference()

// Initial check for music app activity
self.updateMusicAppActiveState()
}
}

Expand All @@ -101,6 +145,7 @@ class MusicManager: ObservableObject {

public func destroy() {
debounceIdleTask?.cancel()
musicAppMonitorTask?.cancel()
cancellables.removeAll()
controllerCancellables.removeAll()
flipWorkItem?.cancel()
Expand Down Expand Up @@ -587,4 +632,77 @@ class MusicManager: ObservableObject {
}
}
}

// MARK: - Music App Activity Detection

/// Dedicated music app bundle identifiers (not browsers)
private static let dedicatedMusicAppBundleIdentifiers: Set<String> = [
"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
}
}
}
}
}