From 13e60c52111613228180e5fbd7c304d0aa013d0b Mon Sep 17 00:00:00 2001 From: delli Date: Sat, 15 Nov 2025 14:21:31 +0100 Subject: [PATCH 1/7] added lyrics feature --- .../components/MinimalisticMusicView.swift | 85 ++++-- .../Notch/MinimalisticMusicPlayerView.swift | 119 ++++++++- .../components/Notch/NotchHomeView.swift | 12 + .../components/Settings/SettingsView.swift | 1 + DynamicIsland/managers/MusicManager.swift | 245 +++++++++++++++++- DynamicIsland/models/Constants.swift | 3 + .../models/DynamicIslandViewModel.swift | 44 +++- DynamicIsland/sizing/matters.swift | 2 +- 8 files changed, 464 insertions(+), 47 deletions(-) diff --git a/DynamicIsland/components/MinimalisticMusicView.swift b/DynamicIsland/components/MinimalisticMusicView.swift index 22af312..38655ec 100644 --- a/DynamicIsland/components/MinimalisticMusicView.swift +++ b/DynamicIsland/components/MinimalisticMusicView.swift @@ -9,42 +9,73 @@ import SwiftUI import Defaults +// Note: lyrics display is inlined into the main minimalistic view below and is controlled by Defaults[.enableLyrics] + struct MinimalisticMusicView: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var musicManager = MusicManager.shared + @Default(.enableLyrics) var enableLyrics @State private var isHovering: Bool = false var body: some View { - HStack(spacing: 0) { - // Left: Album Art - albumArtView - - // Middle: Song Title (scrolling if needed) - Rectangle() - .fill(.black) - .overlay( - GeometryReader { geo in - HStack(alignment: .center) { - if !musicManager.songTitle.isEmpty { - MarqueeText( - $musicManager.songTitle, - font: .system(size: 12, weight: .semibold), - nsFont: .subheadline, - textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, - minDuration: 0.4, - frameWidth: max(0, geo.size.width - 8) - ) + VStack(spacing: 2) { + // Main content row + HStack(spacing: 0) { + // Left: Album Art + albumArtView + + // Middle: Song Title, Artist and Lyrics (lyrics shown under artist when enabled) + Rectangle() + .fill(.black) + .overlay( + GeometryReader { geo in + VStack(alignment: .center, spacing: 2) { + if !musicManager.songTitle.isEmpty { + MarqueeText( + $musicManager.songTitle, + font: .system(size: 12, weight: .semibold), + nsFont: .subheadline, + textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, + minDuration: 0.4, + frameWidth: max(0, geo.size.width - 8) + ) + } + + // Artist name + if !musicManager.artistName.isEmpty { + Text(musicManager.artistName) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .center) + } + + // Lyrics under the author name (same font size as author) + if enableLyrics { + Text(musicManager.currentLyrics) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(.white.opacity(0.8)) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 4) + .transition(.opacity) + .animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics) + } } + .frame(width: geo.size.width, height: geo.size.height, alignment: .center) } - .frame(width: geo.size.width, height: geo.size.height, alignment: .center) - } - ) - .frame(width: vm.closedNotchSize.width) - - // Right: Music Visualizer - visualizerView + ) + .frame(width: vm.closedNotchSize.width) + + // Right: Music Visualizer + visualizerView + } + + // (lyrics are displayed inline under the artist name) } - .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + // reserve extra height when lyrics are enabled + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0) + (enableLyrics ? 22 : 0), alignment: .center) .onHover { hovering in isHovering = hovering } diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 7e00c6d..9b1dff8 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -12,12 +12,15 @@ import Defaults import AppKit #endif +// Lyrics are shown/hidden only via Defaults[.enableLyrics] in settings. Inline display is used in the player views. + struct MinimalisticMusicPlayerView: View { @EnvironmentObject var vm: DynamicIslandViewModel let albumArtNamespace: Namespace.ID @Default(.showMediaOutputControl) var showMediaOutputControl @ObservedObject private var reminderManager = ReminderLiveActivityManager.shared @Default(.enableReminderLiveActivity) private var enableReminderLiveActivity + @Default(.enableLyrics) private var enableLyrics var body: some View { VStack(spacing: 0) { @@ -46,6 +49,19 @@ struct MinimalisticMusicPlayerView: View { .font(.system(size: 10, weight: .regular)) .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray) .lineLimit(1) + + // Lyrics shown under the author name, same font size as author + if enableLyrics { + Text(musicManager.currentLyrics) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(.white.opacity(0.8)) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + .transition(.opacity) + .animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics) + } } .frame(width: textWidth, alignment: .leading) @@ -65,12 +81,75 @@ struct MinimalisticMusicPlayerView: View { playbackControls .padding(.top, 4) + // Lyrics are displayed inline under the artist name (see header area) + reminderList } .padding(.horizontal, 12) .padding(.top, -15) - .padding(.bottom, shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding) + .padding(.bottom, shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding) .frame(maxWidth: .infinity) + .frame(height: calculateDynamicHeight(), alignment: .center) + .animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics) + } + + // MARK: - TypingLyricView + + struct TypingLyricView: View { + let text: String + let color: Color + let id: Int + let playbackRate: Double + let isPlaying: Bool + @State private var displayed: String = "" + @State private var lastText: String = "" + @State private var animationTask: Task? + + var body: some View { + Text(displayed) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(color) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + .padding(.top, 4) + .id(id) + .onChange(of: text) { _, newText in + animateTyping(newText) + } + .onChange(of: isPlaying) { _, playing in + if !playing { + animationTask?.cancel() + } else if displayed != text { + animateTyping(text) + } + } + .onAppear { + animateTyping(text) + } + .onDisappear { + animationTask?.cancel() + } + } + + private func animateTyping(_ newText: String) { + animationTask?.cancel() + displayed = "" + lastText = newText + let chars = Array(newText) + + animationTask = Task { + for (i, c) in chars.enumerated() { + if Task.isCancelled { return } + try? await Task.sleep(for: .milliseconds(Int(30 / max(playbackRate, 0.1)))) + if Task.isCancelled { return } + if lastText == newText { + displayed += String(c) + } + } + } + } } private var reminderEntries: [ReminderLiveActivityManager.ReminderEntry] { @@ -85,6 +164,32 @@ struct MinimalisticMusicPlayerView: View { ReminderLiveActivityManager.additionalHeight(forRowCount: reminderEntries.count) } + private func calculateDynamicHeight() -> CGFloat { + var height: CGFloat = 50 // Base height for header + + // Add progress bar height + height += 6 + 4 // progress bar + top padding + + // Add playback controls height + height += 40 + 2 // controls + top padding + + // Add lyrics height if enabled in settings (reserve space even while loading) + if Defaults[.enableLyrics] { + height += 20 + 4 // lyrics + top padding + } + + // Add reminder list height if showing + if shouldShowReminderList { + height += reminderListHeight + ReminderLiveActivityManager.listTopPadding + } + + // Add padding + height += 15 // top padding + height += shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding + + return height + } + private var reminderList: some View { MinimalisticReminderEventListView(reminders: reminderEntries) .frame(maxWidth: .infinity, alignment: .leading) @@ -472,23 +577,25 @@ private struct MinimalisticReminderDetailsView: View { // MARK: - Playback Controls (Larger) private var playbackControls: some View { - HStack(spacing: 20) { + HStack(spacing: 16) { if Defaults[.showShuffleAndRepeat] { controlButton(icon: "shuffle", isActive: musicManager.isShuffled) { Task { await musicManager.toggleShuffle() } } } - + controlButton(icon: "backward.fill", size: 18) { Task { await musicManager.previousTrack() } } - + playPauseButton - + controlButton(icon: "forward.fill", size: 18) { Task { await musicManager.nextTrack() } } - + + // (lyrics toggle removed from UI; controlled via Settings -> Media -> Enable Lyrics) + if Defaults[.showShuffleAndRepeat] { if showMediaOutputControl { MinimalisticMediaOutputButton() diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 40eb0d8..c148f95 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -155,6 +155,18 @@ struct MusicControlsView: View { frameWidth: width ) .fontWeight(.medium) + // Lyrics shown under the author name (same font size as author) when enabled in settings + if Defaults[.enableLyrics] { + Text(musicManager.currentLyrics) + .font(.headline) + .foregroundColor(.white.opacity(0.8)) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + .transition(.opacity) + .animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics) + } } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 726a0d4..80b4a7c 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -908,6 +908,7 @@ struct Media: View { isOn: $coordinator.musicLiveActivityEnabled.animation() ) Toggle("Enable sneak peek", isOn: $enableSneakPeek) + Defaults.Toggle("Enable lyrics", key: .enableLyrics) Picker("Sneak Peek Style", selection: $sneakPeekStyles){ ForEach(SneakPeekStyle.allCases) { style in Text(style.rawValue).tag(style) diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 0604315..f9d0906 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -7,8 +7,21 @@ import AppKit import Combine import Defaults +import Foundation import SwiftUI +// MARK: - Lyric Data Structures +struct LyricLine: Identifiable, Codable { + let id = UUID() + let timestamp: TimeInterval + let text: String + + init(timestamp: TimeInterval, text: String) { + self.timestamp = timestamp + self.text = text + } +} + let defaultImage: NSImage = .init( systemSymbolName: "heart.fill", accessibilityDescription: "Album Art" @@ -48,6 +61,15 @@ class MusicManager: ObservableObject { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @Published var usingAppIconForArtwork: Bool = false + // MARK: - Lyrics Properties + @Published var currentLyrics: String = "" + @Published var syncedLyrics: [LyricLine] = [] + @Published var showLyrics: Bool = false + @Published var currentLyricIndex: Int = -1 + + // Task used to periodically sync displayed lyric with playback position + private var lyricSyncTask: Task? + private var artworkData: Data? = nil private var liveStreamUnknownDurationCount: Int = 0 @@ -212,13 +234,14 @@ class MusicManager: ObservableObject { } self.artworkData = state.artwork - if artworkChanged || state.artwork == nil { - // Update last artwork change values - self.lastArtworkTitle = state.title - self.lastArtworkArtist = state.artist - self.lastArtworkAlbum = state.album - self.lastArtworkBundleIdentifier = state.bundleIdentifier - } + // Update last artwork change values + self.lastArtworkTitle = state.title + self.lastArtworkArtist = state.artist + self.lastArtworkAlbum = state.album + self.lastArtworkBundleIdentifier = state.bundleIdentifier + + // Fetch lyrics for new track whenever content changes + self.fetchLyrics() // Only update sneak peek if there's actual content and something changed if !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { @@ -246,6 +269,8 @@ class MusicManager: ObservableObject { if timeChanged { self.elapsedTime = state.currentTime + // Update current lyric based on elapsed time + self.updateCurrentLyric(for: state.currentTime) } if durationChanged { @@ -270,6 +295,14 @@ class MusicManager: ObservableObject { updateLiveStreamState(with: state) self.timestampDate = state.lastUpdated + + // Manage lyric sync task based on playback/lyrics availability + if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty { + // Ensure syncing runs while lyrics are enabled + startLyricSync() + } else { + stopLyricSync() + } } private func triggerFlipAnimation() { @@ -509,6 +542,204 @@ class MusicManager: ObservableObject { } } } + + // MARK: - Lyrics Methods + func fetchLyrics() { + guard Defaults[.enableLyrics] else { return } + // If the lyrics panel is visible already, provide immediate feedback + if showLyrics { + Task { @MainActor in + self.currentLyrics = "Loading lyrics..." + self.syncedLyrics = [] + self.currentLyricIndex = -1 + } + } + + Task { + do { + let lyrics = try await fetchLyricsFromAPI(artist: artistName, title: songTitle) + await MainActor.run { + self.syncedLyrics = lyrics + self.currentLyricIndex = -1 + if !lyrics.isEmpty { + self.currentLyrics = lyrics[0].text + } else { + self.currentLyrics = "" + } + + // If lyrics are enabled, start syncing them to playback position + if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty { + self.startLyricSync() + } else if self.syncedLyrics.isEmpty { + self.stopLyricSync() + } + } + } catch { + print("Failed to fetch lyrics: \(error)") + await MainActor.run { + self.syncedLyrics = [] + self.currentLyrics = "" + self.currentLyricIndex = -1 + self.stopLyricSync() + } + } + } + } + + private func fetchLyricsFromAPI(artist: String, title: String) async throws -> [LyricLine] { + guard !artist.isEmpty, !title.isEmpty else { return [] } + + // Normalize input and percent-encode + let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current) + let cleanTitle = title.folding(options: .diacriticInsensitive, locale: .current) + guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + return [] + } + + // Use LRCLIB search endpoint which returns an array JSON with `plainLyrics` and/or `syncedLyrics`. + let urlString = "https://lrclib.net/api/search?track_name=\(encodedTitle)&artist_name=\(encodedArtist)" + guard let url = URL(string: urlString) else { return [] } + + let (data, response) = try await URLSession.shared.data(from: url) + if let http = response as? HTTPURLResponse, http.statusCode == 200 { + // Try parse as array JSON (preferred) + if let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let first = jsonArray.first { + let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if !synced.isEmpty { + return parseLRC(synced) + } else if !plain.isEmpty { + return [LyricLine(timestamp: 0, text: plain)] + } else { + return [] + } + } else { + // Fallback: try to decode as UTF8 and handle as LRC or plain text + if let lrcString = String(data: data, encoding: .utf8), !lrcString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + // If it contains a syncedLyrics key in an object, try that + if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let synced = json["syncedLyrics"] as? String { + return parseLRC(synced) + } + // Otherwise treat as plain lyrics blob + return [LyricLine(timestamp: 0, text: lrcString.trimmingCharacters(in: .whitespacesAndNewlines))] + } + return [] + } + } else { + return [] + } + } + + private func parseLRC(_ lrc: String) -> [LyricLine] { + let lines = lrc.components(separatedBy: .newlines) + var lyrics: [LyricLine] = [] + + // Accept patterns like [m:ss], [mm:ss], [mm:ss.xx] where centiseconds are optional + let pattern = "\\[(\\d{1,2}):(\\d{2})(?:\\.(\\d{1,2}))?\\]" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } + + for line in lines { + let nsLine = line as NSString + let fullRange = NSRange(location: 0, length: nsLine.length) + if let match = regex.firstMatch(in: line, options: [], range: fullRange) { + let minRange = match.range(at: 1) + let secRange = match.range(at: 2) + let centiRange = match.range(at: 3) + + let minStr = minRange.location != NSNotFound ? nsLine.substring(with: minRange) : "0" + let secStr = secRange.location != NSNotFound ? nsLine.substring(with: secRange) : "0" + let centiStr = (centiRange.location != NSNotFound) ? nsLine.substring(with: centiRange) : "0" + + let minutes = Double(minStr) ?? 0 + let seconds = Double(secStr) ?? 0 + let centis = Double(centiStr) ?? 0 + let timestamp = minutes * 60 + seconds + centis / 100.0 + + let textStart = match.range.location + match.range.length + if textStart <= nsLine.length { + let text = nsLine.substring(from: textStart).trimmingCharacters(in: .whitespaces) + if !text.isEmpty { + lyrics.append(LyricLine(timestamp: timestamp, text: text)) + } + } + } + } + + return lyrics.sorted(by: { $0.timestamp < $1.timestamp }) + } + + func updateCurrentLyric(for elapsedTime: TimeInterval) { + guard !syncedLyrics.isEmpty else { return } + + // Find the current lyric based on elapsed time + var newIndex = -1 + for (index, lyric) in syncedLyrics.enumerated() { + if elapsedTime >= lyric.timestamp { + newIndex = index + } else { + break + } + } + + if newIndex != currentLyricIndex { + currentLyricIndex = newIndex + if newIndex >= 0 && newIndex < syncedLyrics.count { + currentLyrics = syncedLyrics[newIndex].text + } + } + } + + // Start a background task that periodically updates the displayed lyric + private func startLyricSync() { + // If already running, keep it + if lyricSyncTask != nil { return } + + lyricSyncTask = Task { [weak self] in + guard let self = self else { return } + while !Task.isCancelled { + // Compute estimated playback position and update lyric + let position = self.estimatedPlaybackPosition() + await MainActor.run { + self.updateCurrentLyric(for: position) + } + + // Sleep ~300ms between updates + try? await Task.sleep(nanoseconds: 300_000_000) + } + } + } + + private func stopLyricSync() { + lyricSyncTask?.cancel() + lyricSyncTask = nil + } + + func toggleLyrics() { + // Toggle the UI state first so the views can react immediately. + showLyrics.toggle() + + // If lyrics are requested to be shown but we don't have any yet, + // show a loading placeholder and start fetching asynchronously. + if showLyrics && syncedLyrics.isEmpty { + // Provide immediate feedback so the UI can show a loading state. + currentLyrics = "Loading lyrics..." + + Task { + await fetchLyrics() + + // If fetch completed but no lyrics were found, show a friendly message. + await MainActor.run { + if self.syncedLyrics.isEmpty && self.currentLyrics.isEmpty { + self.currentLyrics = "No lyrics found" + } + } + } + } + } } // MARK: - Album Art Flip Helper diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 1564258..4039c46 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -528,6 +528,9 @@ extension Defaults.Keys { // MARK: Minimalistic UI Mode static let enableMinimalisticUI = Key("enableMinimalisticUI", default: false) + // MARK: Lyrics Feature + static let enableLyrics = Key("enableLyrics", default: true) + // Helper to determine the default media controller based on macOS version static var defaultMediaController: MediaControllerType { if #available(macOS 15.4, *) { diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index c536240..803f3e0 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -83,7 +83,34 @@ class DynamicIslandViewModel: NSObject, ObservableObject { let updatedTarget = self.calculateDynamicNotchSize() guard self.notchState == .open else { return } guard self.notchSize != updatedTarget else { return } - self.notchSize = updatedTarget + withAnimation(.smooth) { + self.notchSize = updatedTarget + } + if let delegate = AppDelegate.shared { + delegate.ensureWindowSize(updatedTarget, animated: true, force: false) + } + } + .store(in: &cancellables) + + // Observe settings + lyrics changes to dynamically resize the notch + let enableLyricsPublisher = Defaults.publisher(.enableLyrics).map { $0.newValue } + + enableLyricsPublisher + .combineLatest(MusicManager.shared.$currentLyrics) + .removeDuplicates { $0.0 == $1.0 && $0.1 == $1.1 } + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self else { return } + guard Defaults[.enableMinimalisticUI] else { return } + let updatedTarget = self.calculateDynamicNotchSize() + guard self.notchState == .open else { return } + guard self.notchSize != updatedTarget else { return } + withAnimation(.smooth) { + self.notchSize = updatedTarget + } + if let delegate = AppDelegate.shared { + delegate.ensureWindowSize(updatedTarget, animated: true, force: false) + } } .store(in: &cancellables) } @@ -171,27 +198,32 @@ class DynamicIslandViewModel: NSObject, ObservableObject { if Defaults[.enableMinimalisticUI] { let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: minimalisticReminderRowCount) baseSize.height += extraHeight + + // Add extra height for lyrics if enabled in settings + if Defaults[.enableLyrics] { + baseSize.height += 24 // Additional height for lyrics display + } } - + // Only apply dynamic sizing when on stats tab and stats are enabled guard DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else { return baseSize } - + let enabledGraphsCount = [ Defaults[.showCpuGraph], - Defaults[.showMemoryGraph], + Defaults[.showMemoryGraph], Defaults[.showGpuGraph], Defaults[.showNetworkGraph], Defaults[.showDiskGraph] ].filter { $0 }.count - + // If 4+ graphs are enabled, increase width if enabledGraphsCount >= 4 { let extraWidth: CGFloat = CGFloat(enabledGraphsCount - 3) * 120 return CGSize(width: baseSize.width + extraWidth, height: baseSize.height) } - + return baseSize } diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index 2eff7fe..5a70013 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -15,7 +15,7 @@ let downloadSneakSize: CGSize = .init(width: 65, height: 1) let batterySneakSize: CGSize = .init(width: 160, height: 1) let openNotchSize: CGSize = .init(width: 640, height: 190) -let minimalisticOpenNotchSize: CGSize = .init(width: 420, height: 180) +let minimalisticOpenNotchSize: CGSize = .init(width: 420, height: 200) let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14)) let minimalisticCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 35, bottom: 35), closed: cornerRadiusInsets.closed) From 4f82ce417ed55249037187a1d9390433edb157fc Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 18 Nov 2025 00:03:59 +0530 Subject: [PATCH 2/7] Working Lyrics and adjusted height --- DynamicIsland.xcodeproj/project.pbxproj | 26 +++++++++ .../components/MinimalisticMusicView.swift | 42 +++++++++++---- .../Notch/MinimalisticMusicPlayerView.swift | 54 +++++++++++++------ .../components/Notch/NotchHomeView.swift | 10 +++- .../models/DynamicIslandViewModel.swift | 5 -- DynamicIsland/sizing/matters.swift | 13 ++++- 6 files changed, 116 insertions(+), 34 deletions(-) diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index cdd49d3..ea84660 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -383,6 +383,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; @@ -394,6 +395,12 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = YES; + ENABLE_RESOURCE_ACCESS_CAMERA = YES; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = YES; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)", @@ -423,6 +430,12 @@ PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll; PRODUCT_NAME = Atoll; PROVISIONING_PROFILE_SPECIFIER = ""; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; SYSTEM_FRAMEWORK_SEARCH_PATHS = ( @@ -437,6 +450,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; @@ -448,6 +462,12 @@ ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = YES; + ENABLE_RESOURCE_ACCESS_CAMERA = YES; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = YES; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)", @@ -477,6 +497,12 @@ PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll; PRODUCT_NAME = Atoll; PROVISIONING_PROFILE_SPECIFIER = ""; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; SYSTEM_FRAMEWORK_SEARCH_PATHS = ( diff --git a/DynamicIsland/components/MinimalisticMusicView.swift b/DynamicIsland/components/MinimalisticMusicView.swift index 38655ec..af5cd40 100644 --- a/DynamicIsland/components/MinimalisticMusicView.swift +++ b/DynamicIsland/components/MinimalisticMusicView.swift @@ -52,15 +52,7 @@ struct MinimalisticMusicView: View { // Lyrics under the author name (same font size as author) if enableLyrics { - Text(musicManager.currentLyrics) - .font(.system(size: 10, weight: .regular)) - .foregroundColor(.white.opacity(0.8)) - .lineLimit(2) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.horizontal, 4) - .transition(.opacity) - .animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics) + lyricsLineView } } .frame(width: geo.size.width, height: geo.size.height, alignment: .center) @@ -75,7 +67,7 @@ struct MinimalisticMusicView: View { // (lyrics are displayed inline under the artist name) } // reserve extra height when lyrics are enabled - .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0) + (enableLyrics ? 22 : 0), alignment: .center) + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) .onHover { hovering in isHovering = hovering } @@ -118,3 +110,33 @@ struct MinimalisticMusicView: View { height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center) } } + +private extension MinimalisticMusicView { + var lyricsLineView: some View { + let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) + + return HStack(spacing: 6) { + if !line.isEmpty { + Image(systemName: "music.note") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white.opacity(0.7)) + .symbolRenderingMode(.monochrome) + + Text(line) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white.opacity(0.88)) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 6) + .id(line) + .transition(.asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + )) + } + } + .frame(maxWidth: .infinity, alignment: .center) + .animation(.smooth(duration: 0.32), value: line) + } +} diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 9b1dff8..b1dcbb0 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -50,18 +50,6 @@ struct MinimalisticMusicPlayerView: View { .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray) .lineLimit(1) - // Lyrics shown under the author name, same font size as author - if enableLyrics { - Text(musicManager.currentLyrics) - .font(.system(size: 10, weight: .regular)) - .foregroundColor(.white.opacity(0.8)) - .lineLimit(2) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - .transition(.opacity) - .animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics) - } } .frame(width: textWidth, alignment: .leading) @@ -81,7 +69,10 @@ struct MinimalisticMusicPlayerView: View { playbackControls .padding(.top, 4) - // Lyrics are displayed inline under the artist name (see header area) + if enableLyrics { + lyricsView + .padding(.top, 10) + } reminderList } @@ -90,7 +81,6 @@ struct MinimalisticMusicPlayerView: View { .padding(.bottom, shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding) .frame(maxWidth: .infinity) .frame(height: calculateDynamicHeight(), alignment: .center) - .animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics) } // MARK: - TypingLyricView @@ -174,8 +164,10 @@ struct MinimalisticMusicPlayerView: View { height += 40 + 2 // controls + top padding // Add lyrics height if enabled in settings (reserve space even while loading) - if Defaults[.enableLyrics] { - height += 20 + 4 // lyrics + top padding + if enableLyrics { + let lyricsTopPadding: CGFloat = 10 + let lyricsEstimatedHeight: CGFloat = 30 + height += lyricsTopPadding + lyricsEstimatedHeight } // Add reminder list height if showing @@ -198,6 +190,36 @@ struct MinimalisticMusicPlayerView: View { .animation(.easeInOut(duration: 0.18), value: shouldShowReminderList) .environmentObject(vm) } + + private var lyricsView: some View { + let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) + let transition: AnyTransition = .asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + ) + + return HStack(spacing: 6) { + if !line.isEmpty { + Image(systemName: "music.note") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white.opacity(0.7)) + .symbolRenderingMode(.monochrome) + + Text(line) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white.opacity(0.88)) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.trailing, 6) + .id(line) + .transition(transition) + } + } + .padding(.horizontal, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .animation(.smooth(duration: 0.32), value: line) + } private struct MinimalisticReminderEventListView: View { diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index c148f95..f072590 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -157,6 +157,11 @@ struct MusicControlsView: View { .fontWeight(.medium) // Lyrics shown under the author name (same font size as author) when enabled in settings if Defaults[.enableLyrics] { + let transition = AnyTransition.asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + ) + Text(musicManager.currentLyrics) .font(.headline) .foregroundColor(.white.opacity(0.8)) @@ -164,8 +169,9 @@ struct MusicControlsView: View { .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 2) - .transition(.opacity) - .animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics) + .id(musicManager.currentLyrics) + .transition(transition) + .animation(.easeInOut(duration: 0.32), value: musicManager.currentLyrics) } } } diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index 803f3e0..811bb7d 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -198,11 +198,6 @@ class DynamicIslandViewModel: NSObject, ObservableObject { if Defaults[.enableMinimalisticUI] { let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: minimalisticReminderRowCount) baseSize.height += extraHeight - - // Add extra height for lyrics if enabled in settings - if Defaults[.enableLyrics] { - baseSize.height += 24 // Additional height for lyrics display - } } // Only apply dynamic sizing when on stats tab and stats are enabled diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index 5a70013..f48ba08 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -15,7 +15,18 @@ let downloadSneakSize: CGSize = .init(width: 65, height: 1) let batterySneakSize: CGSize = .init(width: 160, height: 1) let openNotchSize: CGSize = .init(width: 640, height: 190) -let minimalisticOpenNotchSize: CGSize = .init(width: 420, height: 200) +private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180) +private let minimalisticLyricsExtraHeight: CGFloat = 30 + +var minimalisticOpenNotchSize: CGSize { + var size = minimalisticBaseOpenNotchSize + + if Defaults[.enableLyrics] { + size.height += minimalisticLyricsExtraHeight + } + + return size +} let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14)) let minimalisticCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 35, bottom: 35), closed: cornerRadiusInsets.closed) From c34cdfc864c1ca957cd9fb969b3dae61dd9e9e6d Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 18 Nov 2025 00:46:20 +0530 Subject: [PATCH 3/7] BugFix: Janky Animation in the calendar view --- DynamicIsland/ContentView.swift | 6 ----- DynamicIsland/DynamicIslandApp.swift | 6 ----- .../Notch/MinimalisticMusicPlayerView.swift | 2 +- .../components/Notch/NotchHomeView.swift | 27 +++++++++++++------ .../models/DynamicIslandViewModel.swift | 9 +------ DynamicIsland/sizing/matters.swift | 11 +++++++- 6 files changed, 31 insertions(+), 30 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index c0b9a6f..08a65c4 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -40,12 +40,6 @@ struct ContentView: View { // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize - - if Defaults[.enableMinimalisticUI] && vm.notchState == .open { - let reminderCount = reminderManager.activeWindowReminders.count - let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: reminderCount) - baseSize.height += extraHeight - } guard coordinator.currentView == .stats else { return baseSize diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 1e08434..65aa46d 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -278,12 +278,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Use minimalistic or normal size based on settings var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize - - if Defaults[.enableMinimalisticUI] && vm.notchState == .open { - let reminderCount = ReminderLiveActivityManager.shared.activeWindowReminders.count - let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: reminderCount) - baseSize.height += extraHeight - } // Only apply dynamic sizing when on stats tab and stats are enabled guard coordinator.currentView == .stats && Defaults[.enableStatsFeature] else { diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index b1dcbb0..1b13fb6 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -166,7 +166,7 @@ struct MinimalisticMusicPlayerView: View { // Add lyrics height if enabled in settings (reserve space even while loading) if enableLyrics { let lyricsTopPadding: CGFloat = 10 - let lyricsEstimatedHeight: CGFloat = 30 + let lyricsEstimatedHeight: CGFloat = 34 height += lyricsTopPadding + lyricsEstimatedHeight } diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index f072590..ad5859e 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -162,16 +162,27 @@ struct MusicControlsView: View { removal: .move(edge: .top).combined(with: .opacity) ) - Text(musicManager.currentLyrics) - .font(.headline) - .foregroundColor(.white.opacity(0.8)) - .lineLimit(2) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) + let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) + + if !line.isEmpty { + let lyricsBinding = Binding( + get: { musicManager.currentLyrics }, + set: { _ in } + ) + + MarqueeText( + lyricsBinding, + font: .headline, + nsFont: .headline, + textColor: .white.opacity(0.8), + minDuration: 0.35, + frameWidth: width + ) .padding(.top, 2) - .id(musicManager.currentLyrics) + .id(line) .transition(transition) - .animation(.easeInOut(duration: 0.32), value: musicManager.currentLyrics) + .animation(.easeInOut(duration: 0.32), value: line) + } } } } diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index 811bb7d..cbfc80c 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -45,7 +45,6 @@ class DynamicIslandViewModel: NSObject, ObservableObject { @Published var notchSize: CGSize = getClosedNotchSize() @Published var closedNotchSize: CGSize = getClosedNotchSize() - private var minimalisticReminderRowCount: Int = 0 deinit { destroy() @@ -77,9 +76,8 @@ class DynamicIslandViewModel: NSObject, ObservableObject { ReminderLiveActivityManager.shared.$activeWindowReminders .removeDuplicates() .receive(on: RunLoop.main) - .sink { [weak self] entries in + .sink { [weak self] _ in guard let self else { return } - self.minimalisticReminderRowCount = entries.count let updatedTarget = self.calculateDynamicNotchSize() guard self.notchState == .open else { return } guard self.notchSize != updatedTarget else { return } @@ -195,11 +193,6 @@ class DynamicIslandViewModel: NSObject, ObservableObject { // Use minimalistic size if minimalistic UI is enabled var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize - if Defaults[.enableMinimalisticUI] { - let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: minimalisticReminderRowCount) - baseSize.height += extraHeight - } - // Only apply dynamic sizing when on stats tab and stats are enabled guard DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else { return baseSize diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index f48ba08..4326a60 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -16,14 +16,23 @@ let batterySneakSize: CGSize = .init(width: 160, height: 1) let openNotchSize: CGSize = .init(width: 640, height: 190) private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180) -private let minimalisticLyricsExtraHeight: CGFloat = 30 +private let minimalisticLyricsExtraHeight: CGFloat = 40 +@MainActor var minimalisticOpenNotchSize: CGSize { var size = minimalisticBaseOpenNotchSize if Defaults[.enableLyrics] { size.height += minimalisticLyricsExtraHeight } + + let reminderCount = ReminderLiveActivityManager.shared.activeWindowReminders.count + if reminderCount > 0 { + let reminderHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: reminderCount) + size.height += reminderHeight + size.height += ReminderLiveActivityManager.listTopPadding + size.height += ReminderLiveActivityManager.listBottomPadding - ReminderLiveActivityManager.baselineMinimalisticBottomPadding + } return size } From 9f57cc0a38d40f22855cc72cbfc36181050c53be Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 18 Nov 2025 00:58:59 +0530 Subject: [PATCH 4/7] Fix: Padding --- .../components/Notch/MinimalisticMusicPlayerView.swift | 8 ++++---- DynamicIsland/sizing/matters.swift | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 1b13fb6..32cc877 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -78,9 +78,9 @@ struct MinimalisticMusicPlayerView: View { } .padding(.horizontal, 12) .padding(.top, -15) - .padding(.bottom, shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding) + .padding(.bottom, ReminderLiveActivityManager.baselineMinimalisticBottomPadding) .frame(maxWidth: .infinity) - .frame(height: calculateDynamicHeight(), alignment: .center) + .frame(height: calculateDynamicHeight(), alignment: .top) } // MARK: - TypingLyricView @@ -172,12 +172,12 @@ struct MinimalisticMusicPlayerView: View { // Add reminder list height if showing if shouldShowReminderList { - height += reminderListHeight + ReminderLiveActivityManager.listTopPadding + height += reminderListHeight } // Add padding height += 15 // top padding - height += shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding + height += ReminderLiveActivityManager.baselineMinimalisticBottomPadding return height } diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index 4326a60..2e1ff7b 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -30,8 +30,6 @@ var minimalisticOpenNotchSize: CGSize { if reminderCount > 0 { let reminderHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: reminderCount) size.height += reminderHeight - size.height += ReminderLiveActivityManager.listTopPadding - size.height += ReminderLiveActivityManager.listBottomPadding - ReminderLiveActivityManager.baselineMinimalisticBottomPadding } return size From 40bcbb1c7f8aae323e25ec9fe3b7650b02889966 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 18 Nov 2025 01:50:49 +0530 Subject: [PATCH 5/7] Working button remapping --- DynamicIsland/DynamicIslandApp.swift | 1 + DynamicIsland/Localizable.xcstrings | 16 ++++-- .../Notch/MinimalisticMusicPlayerView.swift | 37 +++++++++----- .../components/Notch/NotchHomeView.swift | 51 ++++++++++++++----- .../components/Settings/SettingsView.swift | 16 +++++- DynamicIsland/models/Constants.swift | 35 +++++++++++++ 6 files changed, 124 insertions(+), 32 deletions(-) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 65aa46d..7d3e437 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -361,6 +361,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Migrate legacy progress bar settings Defaults.Keys.migrateProgressBarStyle() + Defaults.Keys.migrateMusicAuxControls() // Initialize idle animations (load bundled + built-in face) idleAnimationManager.initializeDefaultAnimations() diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 90670fb..ac86707 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -10497,6 +10497,10 @@ } } }, + "Left button" : { + "comment" : "A label for a picker that lets the user select a control for the left button on a media player.", + "isCommentAutoGenerated" : true + }, "License key" : { "localizations" : { "fr" : { @@ -15403,10 +15407,6 @@ } } }, - "Replace repeat button with media output control" : { - "comment" : "A toggle that allows users to replace the repeat button with a media output control.", - "isCommentAutoGenerated" : true - }, "Replaces system HUD notifications with Dynamic Island displays." : { }, @@ -15582,6 +15582,10 @@ } } }, + "Right button" : { + "comment" : "A label for the right button in the media controls picker.", + "isCommentAutoGenerated" : true + }, "Rotation:" : { "localizations" : { "fr" : { @@ -16791,6 +16795,10 @@ }, "Show floating pause/stop controls" : { + }, + "Show media output control in other layouts" : { + "comment" : "A toggle that lets the user enable or disable the media output control in layouts other than the home screen.", + "isCommentAutoGenerated" : true }, "Show on a specific display" : { "localizations" : { diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 32cc877..34aa282 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -17,7 +17,8 @@ import AppKit struct MinimalisticMusicPlayerView: View { @EnvironmentObject var vm: DynamicIslandViewModel let albumArtNamespace: Namespace.ID - @Default(.showMediaOutputControl) var showMediaOutputControl + @Default(.musicAuxLeftControl) private var leftAuxControl + @Default(.musicAuxRightControl) private var rightAuxControl @ObservedObject private var reminderManager = ReminderLiveActivityManager.shared @Default(.enableReminderLiveActivity) private var enableReminderLiveActivity @Default(.enableLyrics) private var enableLyrics @@ -601,9 +602,7 @@ private struct MinimalisticReminderDetailsView: View { private var playbackControls: some View { HStack(spacing: 16) { if Defaults[.showShuffleAndRepeat] { - controlButton(icon: "shuffle", isActive: musicManager.isShuffled) { - Task { await musicManager.toggleShuffle() } - } + auxButton(for: leftAuxControl) } controlButton(icon: "backward.fill", size: 18) { @@ -616,16 +615,8 @@ private struct MinimalisticReminderDetailsView: View { Task { await musicManager.nextTrack() } } - // (lyrics toggle removed from UI; controlled via Settings -> Media -> Enable Lyrics) - if Defaults[.showShuffleAndRepeat] { - if showMediaOutputControl { - MinimalisticMediaOutputButton() - } else { - controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) { - Task { await musicManager.toggleRepeat() } - } - } + auxButton(for: rightAuxControl) } } .frame(maxWidth: .infinity, alignment: .center) @@ -657,6 +648,26 @@ private struct MinimalisticReminderDetailsView: View { action: action ) } + + @ViewBuilder + private func auxButton(for control: MusicAuxiliaryControl) -> some View { + switch control { + case .shuffle: + controlButton(icon: "shuffle", isActive: musicManager.isShuffled) { + Task { await musicManager.toggleShuffle() } + } + case .repeatMode: + controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) { + Task { await musicManager.toggleRepeat() } + } + case .mediaOutput: + MinimalisticMediaOutputButton() + case .lyrics: + controlButton(icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", isActive: enableLyrics) { + enableLyrics.toggle() + } + } + } private struct MinimalisticMediaOutputButton: View { @ObservedObject private var routeManager = AudioRouteManager.shared @StateObject private var volumeModel = MediaOutputVolumeViewModel() diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index ad5859e..61a8568 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -120,7 +120,9 @@ struct MusicControlsView: View { @State private var dragging: Bool = false @State private var lastDragged: Date = .distantPast let showShuffleAndRepeat: Bool - @Default(.showMediaOutputControl) private var showMediaOutputControl + @Default(.musicAuxLeftControl) private var leftAuxControl + @Default(.musicAuxRightControl) private var rightAuxControl + @Default(.enableLyrics) private var enableLyrics var body: some View { VStack(alignment: .leading) { @@ -213,12 +215,7 @@ struct MusicControlsView: View { private var playbackControls: some View { HStack(spacing: 8) { if showShuffleAndRepeat { - HoverButton( - icon: "shuffle", iconColor: musicManager.isShuffled ? .red : .white, - scale: .medium - ) { - MusicManager.shared.toggleShuffle() - } + auxButton(for: leftAuxControl) } HoverButton(icon: "backward.fill", scale: .medium) { MusicManager.shared.previousTrack() @@ -230,13 +227,7 @@ struct MusicControlsView: View { MusicManager.shared.nextTrack() } if showShuffleAndRepeat { - if showMediaOutputControl { - MediaOutputPickerButton() - } else { - HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) { - MusicManager.shared.toggleRepeat() - } - } + auxButton(for: rightAuxControl) } } .frame(maxWidth: .infinity, alignment: .center) @@ -261,6 +252,38 @@ struct MusicControlsView: View { return .red } } + + @ViewBuilder + private func auxButton(for control: MusicAuxiliaryControl) -> some View { + switch control { + case .shuffle: + HoverButton( + icon: "shuffle", + iconColor: musicManager.isShuffled ? .red : .white, + scale: .medium + ) { + MusicManager.shared.toggleShuffle() + } + case .repeatMode: + HoverButton( + icon: repeatIcon, + iconColor: repeatIconColor, + scale: .medium + ) { + MusicManager.shared.toggleRepeat() + } + case .mediaOutput: + MediaOutputPickerButton() + case .lyrics: + HoverButton( + icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", + iconColor: enableLyrics ? .accentColor : .white, + scale: .medium + ) { + enableLyrics.toggle() + } + } + } } // MARK: - Main View diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 8f3139b..ea4cf75 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -855,6 +855,8 @@ struct Media: View { @Default(.sneakPeekStyles) var sneakPeekStyles @Default(.enableMinimalisticUI) var enableMinimalisticUI @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat + @Default(.musicAuxLeftControl) private var musicAuxLeftControl + @Default(.musicAuxRightControl) private var musicAuxRightControl var body: some View { Form { @@ -896,9 +898,21 @@ struct Media: View { } } Defaults.Toggle(key: .showMediaOutputControl) { - Text("Replace repeat button with media output control") + Text("Show media output control in other layouts") } .disabled(!showShuffleAndRepeat) + if showShuffleAndRepeat { + Picker("Left button", selection: $musicAuxLeftControl) { + ForEach(MusicAuxiliaryControl.allCases) { control in + Text(control.displayName).tag(control) + } + } + Picker("Right button", selection: $musicAuxRightControl) { + ForEach(MusicAuxiliaryControl.allCases) { control in + Text(control.displayName).tag(control) + } + } + } } header: { Text("Media controls") } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 843dc57..eeeec99 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -196,6 +196,28 @@ enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable var id: String { self.rawValue } } +enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Serializable { + case shuffle + case repeatMode + case mediaOutput + case lyrics + + var id: String { rawValue } + + var displayName: String { + switch self { + case .shuffle: + return "Shuffle" + case .repeatMode: + return "Repeat" + case .mediaOutput: + return "Media Output" + case .lyrics: + return "Lyrics Toggle" + } + } +} + enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable { case adaptive = "Adaptive" case solid = "Solid" @@ -372,6 +394,9 @@ extension Defaults.Keys { static let waitInterval = Key("waitInterval", default: 3) static let showShuffleAndRepeat = Key("showShuffleAndRepeat", default: false) static let showMediaOutputControl = Key("showMediaOutputControl", default: false) + static let musicAuxLeftControl = Key("musicAuxLeftControl", default: .shuffle) + static let musicAuxRightControl = Key("musicAuxRightControl", default: .repeatMode) + static let didMigrateMusicAuxControls = Key("didMigrateMusicAuxControls", default: false) // Enable lock screen media widget (shows the standalone panel when screen is locked) static let enableLockScreenMediaWidget = Key("enableLockScreenMediaWidget", default: true) static let enableLockScreenWeatherWidget = Key("enableLockScreenWeatherWidget", default: true) @@ -552,4 +577,14 @@ extension Defaults.Keys { Defaults[.progressBarStyle] = .gradient } } + + static func migrateMusicAuxControls() { + guard Defaults[.didMigrateMusicAuxControls] == false else { return } + + if Defaults[.showMediaOutputControl] { + Defaults[.musicAuxRightControl] = .mediaOutput + } + + Defaults[.didMigrateMusicAuxControls] = true + } } From 4aa481ecb151fba36f0a70ad480136d739fab270 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 18 Nov 2025 02:28:03 +0530 Subject: [PATCH 6/7] Add animations --- DynamicIsland/components/HoverButton.swift | 28 +++- .../Notch/MinimalisticMusicPlayerView.swift | 124 ++++++++++++++++-- .../components/Notch/NotchHomeView.swift | 21 ++- DynamicIsland/models/Constants.swift | 19 ++- ReadMe.md | 4 +- 5 files changed, 175 insertions(+), 21 deletions(-) diff --git a/DynamicIsland/components/HoverButton.swift b/DynamicIsland/components/HoverButton.swift index d1f6d0e..1b96caf 100644 --- a/DynamicIsland/components/HoverButton.swift +++ b/DynamicIsland/components/HoverButton.swift @@ -11,15 +11,20 @@ struct HoverButton: View { var icon: String var iconColor: Color = .white; var scale: Image.Scale = .medium + var pressEffect: PressEffect? = nil var action: () -> Void var contentTransition: ContentTransition = .symbolEffect; @State private var isHovering = false + @State private var pressOffset: CGFloat = 0 var body: some View { let size = CGFloat(scale == .large ? 40 : 30) - Button(action: action) { + Button(action: { + triggerPressEffect() + action() + }) { Rectangle() .fill(.clear) .contentShape(Rectangle()) @@ -37,10 +42,31 @@ struct HoverButton: View { } } .buttonStyle(PlainButtonStyle()) + .offset(x: pressOffset) .onHover { hovering in withAnimation(.smooth(duration: 0.3)) { isHovering = hovering } } } + + private func triggerPressEffect() { + guard let pressEffect else { return } + + switch pressEffect { + case .nudge(let amount): + withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) { + pressOffset = amount + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { + withAnimation(.spring(response: 0.28, dampingFraction: 0.72)) { + pressOffset = 0 + } + } + } + } + + enum PressEffect { + case nudge(CGFloat) + } } diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 34aa282..2ecdb29 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -600,23 +600,25 @@ private struct MinimalisticReminderDetailsView: View { // MARK: - Playback Controls (Larger) private var playbackControls: some View { - HStack(spacing: 16) { + let controls = resolvedAuxControls + + return HStack(spacing: 16) { if Defaults[.showShuffleAndRepeat] { - auxButton(for: leftAuxControl) + auxButton(for: controls.left) } - controlButton(icon: "backward.fill", size: 18) { + controlButton(icon: "backward.fill", size: 18, pressEffect: .nudge(-8)) { Task { await musicManager.previousTrack() } } playPauseButton - controlButton(icon: "forward.fill", size: 18) { + controlButton(icon: "forward.fill", size: 18, pressEffect: .nudge(8)) { Task { await musicManager.nextTrack() } } if Defaults[.showShuffleAndRepeat] { - auxButton(for: rightAuxControl) + auxButton(for: controls.right) } } .frame(maxWidth: .infinity, alignment: .center) @@ -631,13 +633,22 @@ private struct MinimalisticReminderDetailsView: View { frameSize: CGSize(width: 54, height: 54), cornerRadius: 20, foregroundColor: .white, + pressEffect: .none, + symbolEffectStyle: .replace, action: { Task { await musicManager.togglePlay() } } ) } - private func controlButton(icon: String, size: CGFloat = 18, isActive: Bool = false, action: @escaping () -> Void) -> some View { + private func controlButton( + icon: String, + size: CGFloat = 18, + isActive: Bool = false, + pressEffect: MinimalisticSquircircleButton.PressEffect = .none, + symbolEffect: MinimalisticSquircircleButton.SymbolEffectStyle = .none, + action: @escaping () -> Void + ) -> some View { MinimalisticSquircircleButton( icon: icon, fontSize: size, @@ -645,6 +656,8 @@ private struct MinimalisticReminderDetailsView: View { frameSize: CGSize(width: 40, height: 40), cornerRadius: 16, foregroundColor: isActive ? .red : .white.opacity(0.85), + pressEffect: pressEffect, + symbolEffectStyle: symbolEffect, action: action ) } @@ -663,11 +676,22 @@ private struct MinimalisticReminderDetailsView: View { case .mediaOutput: MinimalisticMediaOutputButton() case .lyrics: - controlButton(icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", isActive: enableLyrics) { + controlButton( + icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", + isActive: enableLyrics, + symbolEffect: .replace + ) { enableLyrics.toggle() } } } + + private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) { + guard leftAuxControl == rightAuxControl else { + return (leftAuxControl, rightAuxControl) + } + return (leftAuxControl, MusicAuxiliaryControl.alternative(excluding: leftAuxControl)) + } private struct MinimalisticMediaOutputButton: View { @ObservedObject private var routeManager = AudioRouteManager.shared @StateObject private var volumeModel = MediaOutputVolumeViewModel() @@ -795,15 +819,41 @@ private struct MinimalisticSquircircleButton: View { let frameSize: CGSize let cornerRadius: CGFloat let foregroundColor: Color + let pressEffect: PressEffect + let symbolEffectStyle: SymbolEffectStyle let action: () -> Void @State private var isHovering = false + @State private var pressOffset: CGFloat = 0 + + init( + icon: String, + fontSize: CGFloat, + fontWeight: Font.Weight, + frameSize: CGSize, + cornerRadius: CGFloat, + foregroundColor: Color, + pressEffect: PressEffect = .none, + symbolEffectStyle: SymbolEffectStyle = .none, + action: @escaping () -> Void + ) { + self.icon = icon + self.fontSize = fontSize + self.fontWeight = fontWeight + self.frameSize = frameSize + self.cornerRadius = cornerRadius + self.foregroundColor = foregroundColor + self.pressEffect = pressEffect + self.symbolEffectStyle = symbolEffectStyle + self.action = action + } var body: some View { - Button(action: action) { - Image(systemName: icon) - .font(.system(size: fontSize, weight: fontWeight)) - .foregroundColor(foregroundColor) + Button { + triggerPressEffect() + action() + } label: { + iconView() .frame(width: frameSize.width, height: frameSize.height) .background( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) @@ -812,10 +862,62 @@ private struct MinimalisticSquircircleButton: View { .contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) } .buttonStyle(PlainButtonStyle()) + .offset(x: pressOffset) .onHover { hovering in withAnimation(.easeOut(duration: 0.18)) { isHovering = hovering } } } + + private func triggerPressEffect() { + guard case let .nudge(amount) = pressEffect else { return } + + withAnimation(.spring(response: 0.16, dampingFraction: 0.72)) { + pressOffset = amount + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { + pressOffset = 0 + } + } + } + + @ViewBuilder + private func iconView() -> some View { + let image = Image(systemName: icon) + .font(.system(size: fontSize, weight: fontWeight)) + .foregroundColor(foregroundColor) + + switch symbolEffectStyle { + case .none: + image + case .replace: + if #available(macOS 14.0, *) { + image + .contentTransition(.symbolEffect(.replace)) + .id(icon) + } else { + image + } + case .bounce: + if #available(macOS 14.0, *) { + image.symbolEffect(.bounce, value: icon) + } else { + image + } + } + } + + enum PressEffect { + case none + case nudge(CGFloat) + } + + enum SymbolEffectStyle { + case none + case replace + case bounce + } } diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 61a8568..56b74cd 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -158,7 +158,7 @@ struct MusicControlsView: View { ) .fontWeight(.medium) // Lyrics shown under the author name (same font size as author) when enabled in settings - if Defaults[.enableLyrics] { + if enableLyrics { let transition = AnyTransition.asymmetric( insertion: .move(edge: .bottom).combined(with: .opacity), removal: .move(edge: .top).combined(with: .opacity) @@ -213,21 +213,23 @@ struct MusicControlsView: View { } private var playbackControls: some View { - HStack(spacing: 8) { + let controls = resolvedAuxControls + + return HStack(spacing: 8) { if showShuffleAndRepeat { - auxButton(for: leftAuxControl) + auxButton(for: controls.left) } - HoverButton(icon: "backward.fill", scale: .medium) { + HoverButton(icon: "backward.fill", scale: .medium, pressEffect: .nudge(-6)) { MusicManager.shared.previousTrack() } HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) { MusicManager.shared.togglePlay() } - HoverButton(icon: "forward.fill", scale: .medium) { + HoverButton(icon: "forward.fill", scale: .medium, pressEffect: .nudge(6)) { MusicManager.shared.nextTrack() } if showShuffleAndRepeat { - auxButton(for: rightAuxControl) + auxButton(for: controls.right) } } .frame(maxWidth: .infinity, alignment: .center) @@ -253,6 +255,13 @@ struct MusicControlsView: View { } } + private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) { + guard leftAuxControl == rightAuxControl else { + return (leftAuxControl, rightAuxControl) + } + return (leftAuxControl, MusicAuxiliaryControl.alternative(excluding: leftAuxControl)) + } + @ViewBuilder private func auxButton(for control: MusicAuxiliaryControl) -> some View { switch control { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index eeeec99..33d96fd 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -213,9 +213,26 @@ enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Seriali case .mediaOutput: return "Media Output" case .lyrics: - return "Lyrics Toggle" + return "Lyrics" } } + + var symbolName: String { + switch self { + case .shuffle: + return "shuffle" + case .repeatMode: + return "repeat" + case .mediaOutput: + return "speaker.wave.2" + case .lyrics: + return "quote.bubble" + } + } + + static func alternative(excluding control: MusicAuxiliaryControl) -> MusicAuxiliaryControl { + allCases.first { $0 != control } ?? .shuffle + } } enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable { diff --git a/ReadMe.md b/ReadMe.md index 0ae4746..dc1fdc7 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -12,7 +12,7 @@ GitHub downloads - + Discord server

@@ -30,7 +30,7 @@

- Join our Discord community + Join our Discord community

**Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll). From 9d4c99e713b40868a6207a7d571df40aa4ad8043 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 18 Nov 2025 02:56:07 +0530 Subject: [PATCH 7/7] Remap buttons mutual exclusivity --- .../Notch/MinimalisticMusicPlayerView.swift | 5 +- .../components/Settings/SettingsView.swift | 47 +++++++++++++++++++ DynamicIsland/models/Constants.swift | 29 +++++++++--- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 2ecdb29..0a505cd 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -670,7 +670,7 @@ private struct MinimalisticReminderDetailsView: View { Task { await musicManager.toggleShuffle() } } case .repeatMode: - controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) { + controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off, symbolEffect: .replace) { Task { await musicManager.toggleRepeat() } } case .mediaOutput: @@ -706,7 +706,8 @@ private struct MinimalisticReminderDetailsView: View { fontWeight: .medium, frameSize: CGSize(width: 40, height: 40), cornerRadius: 16, - foregroundColor: .white.opacity(0.85) + foregroundColor: .white.opacity(0.85), + symbolEffectStyle: .replace ) { isPopoverPresented.toggle() if isPopoverPresented { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index ea4cf75..d7aa82f 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -857,6 +857,8 @@ struct Media: View { @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat @Default(.musicAuxLeftControl) private var musicAuxLeftControl @Default(.musicAuxRightControl) private var musicAuxRightControl + @State private var previousLeftAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxLeftControl] + @State private var previousRightAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxRightControl] var body: some View { Form { @@ -969,6 +971,42 @@ struct Media: View { Defaults[.enableFullscreenMediaDetection] = hideNotchOption != .never } } + .onAppear { + ensureAuxControlsUnique() + previousLeftAuxControl = musicAuxLeftControl + previousRightAuxControl = musicAuxRightControl + } + .onChange(of: musicAuxLeftControl) { newValue in + if newValue == musicAuxRightControl { + let fallback = MusicAuxiliaryControl.alternative( + excluding: newValue, + preferring: previousLeftAuxControl + ) + if fallback != musicAuxRightControl { + musicAuxRightControl = fallback + } + } + + previousLeftAuxControl = newValue + } + .onChange(of: musicAuxRightControl) { newValue in + if newValue == musicAuxLeftControl { + let fallback = MusicAuxiliaryControl.alternative( + excluding: newValue, + preferring: previousRightAuxControl + ) + if fallback != musicAuxLeftControl { + musicAuxLeftControl = fallback + } + } + + previousRightAuxControl = newValue + } + .onChange(of: showShuffleAndRepeat) { isEnabled in + if isEnabled { + ensureAuxControlsUnique() + } + } .navigationTitle("Media") } @@ -980,6 +1018,15 @@ struct Media: View { return MediaControllerType.allCases } } + + private func ensureAuxControlsUnique() { + guard showShuffleAndRepeat, musicAuxLeftControl == musicAuxRightControl else { return } + + let fallback = MusicAuxiliaryControl.alternative(excluding: musicAuxLeftControl) + if fallback != musicAuxRightControl { + musicAuxRightControl = fallback + } + } } struct CalendarSettings: View { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 33d96fd..55cf754 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -230,8 +230,15 @@ enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Seriali } } - static func alternative(excluding control: MusicAuxiliaryControl) -> MusicAuxiliaryControl { - allCases.first { $0 != control } ?? .shuffle + static func alternative( + excluding control: MusicAuxiliaryControl, + preferring candidate: MusicAuxiliaryControl? = nil + ) -> MusicAuxiliaryControl { + if let candidate, candidate != control { + return candidate + } + + return allCases.first { $0 != control } ?? .shuffle } } @@ -596,12 +603,22 @@ extension Defaults.Keys { } static func migrateMusicAuxControls() { - guard Defaults[.didMigrateMusicAuxControls] == false else { return } + if Defaults[.didMigrateMusicAuxControls] == false { + if Defaults[.showMediaOutputControl] { + Defaults[.musicAuxRightControl] = .mediaOutput + } - if Defaults[.showMediaOutputControl] { - Defaults[.musicAuxRightControl] = .mediaOutput + Defaults[.didMigrateMusicAuxControls] = true } - Defaults[.didMigrateMusicAuxControls] = true + normalizeMusicAuxControls() + } + + private static func normalizeMusicAuxControls() { + guard Defaults[.musicAuxLeftControl] == Defaults[.musicAuxRightControl] else { return } + + let current = Defaults[.musicAuxLeftControl] + let fallback = MusicAuxiliaryControl.alternative(excluding: current) + Defaults[.musicAuxRightControl] = fallback } }