diff --git a/DynamicIsland/DynamicIsland.entitlements b/DynamicIsland/DynamicIsland.entitlements index ab90afd..23129cf 100644 --- a/DynamicIsland/DynamicIsland.entitlements +++ b/DynamicIsland/DynamicIsland.entitlements @@ -2,12 +2,22 @@ + com.apple.security.app-sandbox + com.apple.security.automation.apple-events com.apple.security.device.camera com.apple.security.device.audio-input + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.personal-information.calendars + com.apple.security.temporary-exception.apple-events com.spotify.client diff --git a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift index 9f3f750..fc0b8f4 100644 --- a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift +++ b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift @@ -152,48 +152,64 @@ struct CalendarView: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject private var calendarManager = CalendarManager.shared @State private var selectedDate = Date() - + var body: some View { - VStack(spacing: 8) { - HStack { - Text("\(selectedDate, format: .dateTime.month())") - .font(.system(size: 18)) - .fontWeight(.semibold) - ZStack { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading) { + Text(selectedDate.formatted(.dateTime.month(.abbreviated))) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.white) + Text(selectedDate.formatted(.dateTime.year())) + .font(.title3) + .fontWeight(.light) + .foregroundColor(Color(white: 0.65)) + } + + ZStack(alignment: .top) { WheelPicker(selectedDate: $selectedDate, config: Config()) - HStack { + HStack(alignment: .top) { LinearGradient( - colors: [.black, .clear], startPoint: .leading, endPoint: .trailing + colors: [Color.black, .clear], startPoint: .leading, endPoint: .trailing ) .frame(width: 20) Spacer() LinearGradient( - colors: [.clear, .black], startPoint: .leading, endPoint: .trailing + colors: [.clear, Color.black], startPoint: .leading, endPoint: .trailing ) .frame(width: 20) } } } - if calendarManager.events.isEmpty { + + let filteredEvents = EventListView.filteredEvents( + events: calendarManager.events + ) + if filteredEvents.isEmpty { EmptyEventsView() + Spacer(minLength: 0) } else { EventListView(events: calendarManager.events) } } .listRowBackground(Color.clear) - .onChange(of: selectedDate) { _, newDate in + .frame(height: 120) + .onChange(of: selectedDate) { Task { - await calendarManager.updateCurrentDate(newDate) + await calendarManager.updateCurrentDate(selectedDate) } } .onChange(of: vm.notchState) { _, _ in Task { await calendarManager.updateCurrentDate(Date.now) + selectedDate = Date.now } } .onAppear { Task { await calendarManager.updateCurrentDate(Date.now) + selectedDate = Date.now } } } @@ -201,77 +217,100 @@ struct CalendarView: View { struct EmptyEventsView: View { var body: some View { - ScrollView { + VStack { + Image(systemName: "calendar.badge.checkmark") + .font(.title) + .foregroundColor(Color(white: 0.65)) Text("No events today") - .font(.headline) - .foregroundStyle(.white) - Text("Enjoy your free time!") .font(.subheadline) - .foregroundColor(.gray) + .foregroundColor(.white) + Text("Enjoy your free time!") + .font(.caption) + .foregroundColor(Color(white: 0.65)) } } } struct EventListView: View { @Environment(\.openURL) private var openURL + @ObservedObject private var calendarManager = CalendarManager.shared let events: [EventModel] + + static func filteredEvents(events: [EventModel]) -> [EventModel] { + events.filter { event in + // Filter out all-day events if setting is enabled + if event.isAllDay && Defaults[.hideAllDayEvents] { + return false + } + return true + } + } + + private var filteredEvents: [EventModel] { + Self.filteredEvents(events: events) + } + var body: some View { - ScrollView(showsIndicators: false) { - HStack(alignment: .top) { - VStack(alignment: .trailing, spacing: 5) { - ForEach(events.indices, id: \.self) { index in - VStack(alignment: .trailing) { - if events[index].isAllDay { - Text("All-day") - } else { - Text("\(events[index].start, style: .time)") - Text("\(events[index].end, style: .time)") - } - } - .multilineTextAlignment(.trailing) - .padding(.bottom, 8) - .font(.caption2) - } - } - - VStack(alignment: .leading, spacing: 5) { - ForEach(events.indices, id: \.self) { index in - HStack(alignment: .top) { - VStack(spacing: 5) { - Image( - systemName: events[index].eventStatus == .ended - ? "checkmark.circle" : "circle" - ) - .foregroundColor(events[index].eventStatus == .ended ? .green : .gray) - .font(.footnote) - Rectangle() - .frame(width: 1) - .foregroundStyle(.gray.opacity(0.5)) - .opacity(index == events.count - 1 ? 0 : 1) - } - .padding(.top, 1) - - Button { - if let url = events[index].calendarAppURL() { - openURL(url) - } - } label: { - Text(events[index].title) - .font(.footnote) - .foregroundStyle(.gray) - } - .buttonStyle(.plain) - - Spacer(minLength: 0) - } - .opacity((events[index].eventStatus == .ended) ? 0.6 : 1) + List { + ForEach(filteredEvents) { event in + Button(action: { + if let url = event.calendarAppURL() { + openURL(url) } + }) { + eventRow(event) } + .padding(.leading, -5) + .buttonStyle(PlainButtonStyle()) + .listRowSeparator(.automatic) + .listRowSeparatorTint(.gray.opacity(0.2)) + .listRowBackground(Color.clear) } } + .listStyle(.plain) .scrollIndicators(.never) - .scrollTargetBehavior(.viewAligned) + .scrollContentBackground(.hidden) + .background(Color.clear) + Spacer(minLength: 0) + } + + private func eventRow(_ event: EventModel) -> some View { + // Regular event row + return AnyView( + HStack(spacing: 8) { + Circle() + .fill(Color(event.calendar.color)) + .frame(width: 8, height: 8) + HStack { + Text(event.title) + .font(.callout) + .foregroundColor(.white) + .lineLimit(1) + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: 4) { + if event.isAllDay { + Text("All-day") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(1) + } else { + Text("\(event.start, style: .time)") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.white) + .lineLimit(1) + Text("\(event.end, style: .time)") + .font(.caption2) + .foregroundColor(Color(white: 0.65)) + .lineLimit(1) + } + } + } + .contentShape(Rectangle()) + } + ) } } diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 7cec2a4..5a7e1dd 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -79,15 +79,17 @@ struct MinimalisticMusicPlayerView: View { @State private var lastDragged: Date = .distantPast private var progressBar: some View { - HStack(spacing: 8) { - // Elapsed time - left - Text(formatTime(dragging ? sliderValue : musicManager.elapsedTime)) - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundColor(.white.opacity(0.7)) - .frame(width: 42, alignment: .leading) + TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in + let currentElapsed = currentSliderValue(timeline.date) - // Progress bar - TimelineView(.animation(minimumInterval: 0.05)) { timeline in + HStack(spacing: 8) { + // Elapsed time - left + Text(formatTime(dragging ? sliderValue : currentElapsed)) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .frame(width: 42, alignment: .leading) + + // Progress bar GeometryReader { geometry in ZStack(alignment: .leading) { // Background track @@ -114,14 +116,14 @@ struct MinimalisticMusicPlayerView: View { } ) } + .frame(height: 6) + + // Time remaining - right + Text("-\(formatTime(musicManager.songDuration - (dragging ? sliderValue : currentElapsed)))") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .frame(width: 48, alignment: .trailing) } - .frame(height: 6) - - // Time remaining - right - Text("-\(formatTime(musicManager.songDuration - (dragging ? sliderValue : musicManager.elapsedTime)))") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundColor(.white.opacity(0.7)) - .frame(width: 48, alignment: .trailing) } .onAppear { sliderValue = musicManager.elapsedTime diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 6291f67..dbbcf18 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -15,11 +15,12 @@ import SwiftUI struct MusicPlayerView: View { @EnvironmentObject var vm: DynamicIslandViewModel let albumArtNamespace: Namespace.ID + let showShuffleAndRepeat: Bool var body: some View { HStack { - AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) - MusicControlsView().drawingGroup().compositingGroup() + AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).padding(.all, 5) + MusicControlsView(showShuffleAndRepeat: showShuffleAndRepeat).drawingGroup().compositingGroup() } } } @@ -39,47 +40,59 @@ struct AlbumArtView: View { } private var albumArtBackground: some View { - Color.clear - .aspectRatio(1, contentMode: .fit) - .background( - Image(nsImage: musicManager.albumArt) - .resizable() - .aspectRatio(contentMode: .fill) - ) + Image(nsImage: musicManager.albumArt) + .resizable() .clipped() - .clipShape(RoundedRectangle(cornerRadius: Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.opened : MusicPlayerImageSizes.cornerRadiusInset.closed)) + .clipShape( + RoundedRectangle( + cornerRadius: Defaults[.cornerRadiusScaling] + ? MusicPlayerImageSizes.cornerRadiusInset.opened + : MusicPlayerImageSizes.cornerRadiusInset.closed) + ) + .aspectRatio(1, contentMode: .fit) .scaleEffect(x: 1.3, y: 1.4) .rotationEffect(.degrees(92)) - .blur(radius: 35) - .opacity(min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3))) + .blur(radius: 40) + .opacity(musicManager.isPlaying ? 0.5 : 0) } private var albumArtButton: some View { - Button { - musicManager.openMusicApp() - } label: { - ZStack(alignment: .bottomTrailing) { - albumArtImage - appIconOverlay + ZStack { + Button { + musicManager.openMusicApp() + } label: { + ZStack(alignment:.bottomTrailing) { + albumArtImage + appIconOverlay + } } + .buttonStyle(PlainButtonStyle()) + .scaleEffect(musicManager.isPlaying ? 1 : 0.85) + + albumArtDarkOverlay } - .buttonStyle(PlainButtonStyle()) - .opacity(musicManager.isPlaying ? 1 : 0.4) - .scaleEffect(musicManager.isPlaying ? 1 : 0.85) + } + + private var albumArtDarkOverlay: some View { + Rectangle() + .aspectRatio(1, contentMode: .fit) + .foregroundColor(Color.black) + .opacity(musicManager.isPlaying ? 0 : 0.8) + .blur(radius: 50) } private var albumArtImage: some View { - Color.clear + Image(nsImage: musicManager.albumArt) + .resizable() .aspectRatio(1, contentMode: .fit) - .background( - Image(nsImage: musicManager.albumArt) - .resizable() - .aspectRatio(contentMode: .fill) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: musicManager.isFlipping) - ) - .clipped() - .clipShape(RoundedRectangle(cornerRadius: Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.opened : MusicPlayerImageSizes.cornerRadiusInset.closed)) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) + .clipped() + .clipShape( + RoundedRectangle( + cornerRadius: Defaults[.cornerRadiusScaling] + ? MusicPlayerImageSizes.cornerRadiusInset.opened + : MusicPlayerImageSizes.cornerRadiusInset.closed) + ) } @ViewBuilder @@ -91,34 +104,24 @@ struct AlbumArtView: View { .frame(width: 30, height: 30) .offset(x: 10, y: 10) .transition(.scale.combined(with: .opacity).animation(.bouncy.delay(0.3))) + .zIndex(2) } } } struct MusicControlsView: View { @ObservedObject var musicManager = MusicManager.shared - @EnvironmentObject var vm: DynamicIslandViewModel @State private var sliderValue: Double = 0 @State private var dragging: Bool = false @State private var lastDragged: Date = .distantPast - + let showShuffleAndRepeat: Bool + var body: some View { VStack(alignment: .leading) { songInfoAndSlider playbackControls } .buttonStyle(PlainButtonStyle()) - .frame(minWidth: Defaults[.showMirror] && Defaults[.showCalendar] ? 140 : 180) - .onAppear { - // Initialize slider value when view appears - sliderValue = musicManager.elapsedTime - } - .onChange(of: vm.notchState) { _, newState in - // Reset slider value when notch opens to prevent stuck state - if newState == .open && !dragging { - sliderValue = musicManager.elapsedTime - } - } } private var songInfoAndSlider: some View { @@ -148,7 +151,7 @@ struct MusicControlsView: View { } private var musicSlider: some View { - TimelineView(.animation(minimumInterval: 0.05)) { timeline in + TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in MusicSliderView( sliderValue: $sliderValue, duration: $musicManager.songDuration, @@ -170,33 +173,26 @@ struct MusicControlsView: View { private var playbackControls: some View { HStack(spacing: 8) { - if Defaults[.showShuffleAndRepeat] { - HoverButton(icon: "shuffle", iconColor: musicManager.isShuffled ? .red : .white, scale: .medium) { - Task { - await MusicManager.shared.toggleShuffle() - } + if showShuffleAndRepeat { + HoverButton( + icon: "shuffle", iconColor: musicManager.isShuffled ? .red : .white, + scale: .medium + ) { + MusicManager.shared.toggleShuffle() } } HoverButton(icon: "backward.fill", scale: .medium) { - Task { - await MusicManager.shared.previousTrack() - } + MusicManager.shared.previousTrack() } HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) { - Task { - await MusicManager.shared.togglePlay() - } + MusicManager.shared.togglePlay() } HoverButton(icon: "forward.fill", scale: .medium) { - Task { - await MusicManager.shared.nextTrack() - } + MusicManager.shared.nextTrack() } - if Defaults[.showShuffleAndRepeat] { + if showShuffleAndRepeat { HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) { - Task { - await MusicManager.shared.toggleRepeat() - } + MusicManager.shared.toggleRepeat() } } } @@ -249,7 +245,7 @@ struct NotchHomeView: View { MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) } else { // Normal mode: Show full music player with optional calendar and webcam - MusicPlayerView(albumArtNamespace: albumArtNamespace) + MusicPlayerView(albumArtNamespace: albumArtNamespace, showShuffleAndRepeat: Defaults[.showShuffleAndRepeat]) if Defaults[.showCalendar] { CalendarView() @@ -289,13 +285,6 @@ struct MusicSliderView: View { let isPlaying: Bool var onValueChange: (Double) -> Void - var currentElapsedTime: Double { - // A small buffer is needed to ensure a meaningful difference between the two dates - guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return sliderValue } - let timeDifference = isPlaying ? currentDate.timeIntervalSince(timestampDate) : 0 - let elapsed = elapsedTime + (timeDifference * playbackRate) - return min(elapsed, duration) - } var body: some View { VStack { @@ -320,8 +309,9 @@ struct MusicSliderView: View { .ensureMinimumBrightness(factor: 0.6) : .gray) .font(.caption) } - .onChange(of: currentDate) { - sliderValue = currentElapsedTime + .onChange(of: currentDate) { newDate in + guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return } + sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate) } } @@ -354,7 +344,8 @@ struct CustomSlider: View { let height = CGFloat(dragging ? 9 : 5) let rangeSpan = range.upperBound - range.lowerBound - let filledTrackWidth = min(rangeSpan == .zero ? 0 : ((value - range.lowerBound) / rangeSpan) * width, width) + let progress = rangeSpan == .zero ? 0 : (value - range.lowerBound) / rangeSpan + let filledTrackWidth = min(max(progress, 0), 1) * width ZStack(alignment: .leading) { // Background track diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 48b04e6..d51b228 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -20,14 +20,14 @@ class MusicManager: ObservableObject { private var cancellables = Set() private var controllerCancellables = Set() private var debounceIdleTask: Task? - + // Helper to check if macOS has removed support for NowPlayingController public private(set) var isNowPlayingDeprecated: Bool = false private let mediaChecker = MediaChecker() // Active controller private var activeController: (any MediaControllerProtocol)? - + // Published properties for UI @Published var songTitle: String = "I'm Handsome" @Published var artistName: String = "Me" @@ -103,13 +103,13 @@ class MusicManager: ObservableObject { // MARK: - Setup Methods private func createController(for type: MediaControllerType) -> (any MediaControllerProtocol)? { // Cleanup previous controller - if let _ = activeController { + if activeController != nil { controllerCancellables.removeAll() activeController = nil } - + let newController: (any MediaControllerProtocol)? - + switch type { case .nowPlaying: // Only create NowPlayingController if not deprecated on this macOS version @@ -125,7 +125,7 @@ class MusicManager: ObservableObject { case .youtubeMusic: newController = YouTubeMusicController() } - + // Set up state observation for the new controller if let controller = newController { controller.playbackStatePublisher @@ -137,19 +137,19 @@ class MusicManager: ObservableObject { } .store(in: &controllerCancellables) } - + return newController } - + private func setActiveControllerBasedOnPreference() { let preferredType = Defaults[.mediaController] print("Preferred Media Controller: \(preferredType)") - + // If NowPlaying is deprecated but that's the preference, use Apple Music instead let controllerType = (self.isNowPlayingDeprecated && preferredType == .nowPlaying) ? .appleMusic : preferredType - + if let controller = createController(for: controllerType) { setActiveController(controller) } else if controllerType != .appleMusic, let fallbackController = createController(for: .appleMusic) { @@ -157,126 +157,122 @@ class MusicManager: ObservableObject { setActiveController(fallbackController) } } - + private func setActiveController(_ controller: any MediaControllerProtocol) { - // Set the active controller - self.activeController = controller - + // Cancel any existing flip animation + flipWorkItem?.cancel() + + // Set new active controller + activeController = controller + // Get current state from active controller - if let state = Mirror(reflecting: controller).children.first(where: { $0.label == "playbackState" })?.value as? PlaybackState { - updateFromPlaybackState(state) - } + forceUpdate() } // MARK: - Update Methods + @MainActor private func updateFromPlaybackState(_ state: PlaybackState) { - // Create a batch of updates to apply together - let updateBatch = DispatchWorkItem { [weak self] in - guard let self = self else { return } - - // Check for playback state changes (playing/paused) - if state.isPlaying != self.isPlaying { - withAnimation(.smooth) { - self.isPlaying = state.isPlaying - self.updateIdleState(state: state.isPlaying) - } - - if state.isPlaying && !state.title.isEmpty && !state.artist.isEmpty { - self.updateSneakPeek() + // Check for playback state changes (playing/paused) + if state.isPlaying != self.isPlaying { + NSLog("Playback state changed: \(state.isPlaying ? "Playing" : "Paused")") + withAnimation(.smooth) { + self.isPlaying = state.isPlaying + self.updateIdleState(state: state.isPlaying) + } + + if state.isPlaying && !state.title.isEmpty && !state.artist.isEmpty { + self.updateSneakPeek() + } + } + + // Check for changes in track metadata using last artwork change values + let titleChanged = state.title != self.lastArtworkTitle + let artistChanged = state.artist != self.lastArtworkArtist + let albumChanged = state.album != self.lastArtworkAlbum + let bundleChanged = state.bundleIdentifier != self.lastArtworkBundleIdentifier + + // Check for artwork changes + let artworkChanged = state.artwork != nil && state.artwork != self.artworkData + let hasContentChange = titleChanged || artistChanged || albumChanged || artworkChanged || bundleChanged + + // Handle artwork and visual transitions for changed content + if hasContentChange { + self.triggerFlipAnimation() + + if artworkChanged, let artwork = state.artwork { + self.updateArtwork(artwork) + } else if state.artwork == nil { + // Try to use app icon if no artwork but track changed + if let appIconImage = AppIconAsNSImage(for: state.bundleIdentifier) { + self.usingAppIconForArtwork = true + self.updateAlbumArt(newAlbumArt: appIconImage) } } - - // Check for changes in track metadata - let titleChanged = state.title != self.songTitle - let artistChanged = state.artist != self.artistName - let albumChanged = state.album != self.album - - // Check for artwork changes - let artworkChanged = state.artwork != nil && state.artwork != self.artworkData - let hasContentChange = titleChanged || artistChanged || albumChanged || artworkChanged - - // Handle artwork and visual transitions for changed content - if hasContentChange { - self.triggerFlipAnimation() - - if artworkChanged, let artwork = state.artwork { - self.updateArtwork(artwork) - } else if state.artwork == nil { - // Try to use app icon if no artwork but track changed - if let appIconImage = AppIconAsNSImage(for: state.bundleIdentifier) { - self.usingAppIconForArtwork = true - self.updateAlbumArt(newAlbumArt: appIconImage) - } - } - 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 - } - - // Only update sneak peek if there's actual content and something changed - if !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { - self.updateSneakPeek() - } + 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 } - - let timeChanged = state.currentTime != self.elapsedTime - let durationChanged = state.duration != self.songDuration - let playbackRateChanged = state.playbackRate != self.playbackRate - let shuffleChanged = state.isShuffled != self.isShuffled - let repeatModeChanged = state.repeatMode != self.repeatMode - - if state.title != self.songTitle { - self.songTitle = state.title + + // Only update sneak peek if there's actual content and something changed + if !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { + self.updateSneakPeek() } - - if state.artist != self.artistName { - self.artistName = state.artist - } - - if state.album != self.album { - self.album = state.album - } - - if timeChanged { - self.elapsedTime = state.currentTime - } - - if durationChanged { - self.songDuration = state.duration - } - - if playbackRateChanged { - self.playbackRate = state.playbackRate - } - - if shuffleChanged { - self.isShuffled = state.isShuffled - } - - if state.bundleIdentifier != self.bundleIdentifier { - self.bundleIdentifier = state.bundleIdentifier - } - - if repeatModeChanged { - self.repeatMode = state.repeatMode - } - - self.timestampDate = state.lastUpdated + } + + let timeChanged = state.currentTime != self.elapsedTime + let durationChanged = state.duration != self.songDuration + let playbackRateChanged = state.playbackRate != self.playbackRate + let shuffleChanged = state.isShuffled != self.isShuffled + let repeatModeChanged = state.repeatMode != self.repeatMode + + if state.title != self.songTitle { + self.songTitle = state.title + } + + if state.artist != self.artistName { + self.artistName = state.artist + } + + if state.album != self.album { + self.album = state.album + } + + if timeChanged { + self.elapsedTime = state.currentTime + } + + if durationChanged { + self.songDuration = state.duration + } + + if playbackRateChanged { + self.playbackRate = state.playbackRate } - // Execute the batch update on the main thread - DispatchQueue.main.async(execute: updateBatch) + if shuffleChanged { + self.isShuffled = state.isShuffled + } + + if state.bundleIdentifier != self.bundleIdentifier { + self.bundleIdentifier = state.bundleIdentifier + } + + if repeatModeChanged { + self.repeatMode = state.repeatMode + } + + self.timestampDate = state.lastUpdated } - + private func triggerFlipAnimation() { // Cancel any existing animation flipWorkItem?.cancel() - + // Create a new animation let workItem = DispatchWorkItem { [weak self] in self?.isFlipping = true @@ -284,7 +280,7 @@ class MusicManager: ObservableObject { self?.isFlipping = false } } - + flipWorkItem = workItem DispatchQueue.main.async(execute: workItem) } @@ -333,6 +329,15 @@ class MusicManager: ObservableObject { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem!) } + // MARK: - Playback Position Estimation + public func estimatedPlaybackPosition(at date: Date = Date()) -> TimeInterval { + guard isPlaying else { return min(elapsedTime, songDuration) } + + let timeDifference = date.timeIntervalSince(timestampDate) + let estimated = elapsedTime + (timeDifference * playbackRate) + return min(max(0, estimated), songDuration) + } + func calculateAverageColor() { albumArt.averageColor { [weak self] color in DispatchQueue.main.async { @@ -342,7 +347,7 @@ class MusicManager: ObservableObject { } } } - + private func updateSneakPeek() { if isPlaying && Defaults[.enableSneakPeek] { if Defaults[.sneakPeekStyles] == .standard { @@ -359,53 +364,28 @@ class MusicManager: ObservableObject { await activeController?.togglePlay() } } - + func play() { Task { await activeController?.play() } } - + func pause() { Task { await activeController?.pause() } } - + func toggleShuffle() { Task { - // Provide immediate UI feedback - await MainActor.run { - self.isShuffled.toggle() - } - - // Execute the actual command await activeController?.toggleShuffle() - - // Note: The actual state will be updated when the controller reports back - // via updateFromPlaybackState if the command provides status feedback } } func toggleRepeat() { Task { - // Provide immediate UI feedback - await MainActor.run { - switch self.repeatMode { - case .off: - self.repeatMode = .all - case .all: - self.repeatMode = .one - case .one: - self.repeatMode = .off - } - } - - // Execute the actual command await activeController?.toggleRepeat() - - // Note: The actual state will be updated when the controller reports back - // via updateFromPlaybackState if the command provides status feedback } } @@ -414,25 +394,25 @@ class MusicManager: ObservableObject { await activeController?.togglePlay() } } - + func nextTrack() { Task { await activeController?.nextTrack() } } - + func previousTrack() { Task { await activeController?.previousTrack() } } - + func seek(to position: TimeInterval) { Task { await activeController?.seek(to: position) } } - + func openMusicApp() { guard let bundleID = bundleIdentifier else { print("Error: appBundleIdentifier is nil") @@ -453,13 +433,12 @@ class MusicManager: ObservableObject { print("Failed to find app with bundle ID: \(bundleID)") } } - + func forceUpdate() { // Request immediate update from the active controller Task { [weak self] in if self?.activeController?.isActive() == true { - if type(of: self?.activeController) == YouTubeMusicController.self, - let youtubeController = self?.activeController as? YouTubeMusicController { + if let youtubeController = self?.activeController as? YouTubeMusicController { await youtubeController.pollPlaybackState() } else { await self?.activeController?.updatePlaybackInfo() diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index fd62f4f..0df18ab 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -236,6 +236,8 @@ extension Defaults.Keys { static let showNotHumanFace = Key("showNotHumanFace", default: false) static let tileShowLabels = Key("tileShowLabels", default: false) static let showCalendar = Key("showCalendar", default: false) + static let hideCompletedReminders = Key("hideCompletedReminders", default: true) + static let hideAllDayEvents = Key("hideAllDayEvents", default: false) static let sliderColor = Key( "sliderUseAlbumArtColor", default: SliderColorEnum.white