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 @@
-
+
@@ -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
}
}