Slider is working

This commit is contained in:
Hariharan Mudaliar 2025-10-07 00:18:47 +05:30
commit decf7a677a
6 changed files with 327 additions and 304 deletions

View file

@ -2,12 +2,22 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.automation.apple-events</key> <key>com.apple.security.automation.apple-events</key>
<true/> <true/>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
<key>com.apple.security.device.audio-input</key> <key>com.apple.security.device.audio-input</key>
<true/> <true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.personal-information.calendars</key>
<true/>
<key>com.apple.security.temporary-exception.apple-events</key> <key>com.apple.security.temporary-exception.apple-events</key>
<array> <array>
<string>com.spotify.client</string> <string>com.spotify.client</string>

View file

@ -152,48 +152,64 @@ struct CalendarView: View {
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject private var calendarManager = CalendarManager.shared @ObservedObject private var calendarManager = CalendarManager.shared
@State private var selectedDate = Date() @State private var selectedDate = Date()
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: 0) {
HStack { HStack(alignment: .top, spacing: 8) {
Text("\(selectedDate, format: .dateTime.month())") VStack(alignment: .leading) {
.font(.system(size: 18)) Text(selectedDate.formatted(.dateTime.month(.abbreviated)))
.fontWeight(.semibold) .font(.title3)
ZStack { .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()) WheelPicker(selectedDate: $selectedDate, config: Config())
HStack { HStack(alignment: .top) {
LinearGradient( LinearGradient(
colors: [.black, .clear], startPoint: .leading, endPoint: .trailing colors: [Color.black, .clear], startPoint: .leading, endPoint: .trailing
) )
.frame(width: 20) .frame(width: 20)
Spacer() Spacer()
LinearGradient( LinearGradient(
colors: [.clear, .black], startPoint: .leading, endPoint: .trailing colors: [.clear, Color.black], startPoint: .leading, endPoint: .trailing
) )
.frame(width: 20) .frame(width: 20)
} }
} }
} }
if calendarManager.events.isEmpty {
let filteredEvents = EventListView.filteredEvents(
events: calendarManager.events
)
if filteredEvents.isEmpty {
EmptyEventsView() EmptyEventsView()
Spacer(minLength: 0)
} else { } else {
EventListView(events: calendarManager.events) EventListView(events: calendarManager.events)
} }
} }
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.onChange(of: selectedDate) { _, newDate in .frame(height: 120)
.onChange(of: selectedDate) {
Task { Task {
await calendarManager.updateCurrentDate(newDate) await calendarManager.updateCurrentDate(selectedDate)
} }
} }
.onChange(of: vm.notchState) { _, _ in .onChange(of: vm.notchState) { _, _ in
Task { Task {
await calendarManager.updateCurrentDate(Date.now) await calendarManager.updateCurrentDate(Date.now)
selectedDate = Date.now
} }
} }
.onAppear { .onAppear {
Task { Task {
await calendarManager.updateCurrentDate(Date.now) await calendarManager.updateCurrentDate(Date.now)
selectedDate = Date.now
} }
} }
} }
@ -201,77 +217,100 @@ struct CalendarView: View {
struct EmptyEventsView: View { struct EmptyEventsView: View {
var body: some View { var body: some View {
ScrollView { VStack {
Image(systemName: "calendar.badge.checkmark")
.font(.title)
.foregroundColor(Color(white: 0.65))
Text("No events today") Text("No events today")
.font(.headline)
.foregroundStyle(.white)
Text("Enjoy your free time!")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.gray) .foregroundColor(.white)
Text("Enjoy your free time!")
.font(.caption)
.foregroundColor(Color(white: 0.65))
} }
} }
} }
struct EventListView: View { struct EventListView: View {
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@ObservedObject private var calendarManager = CalendarManager.shared
let events: [EventModel] 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 { var body: some View {
ScrollView(showsIndicators: false) { List {
HStack(alignment: .top) { ForEach(filteredEvents) { event in
VStack(alignment: .trailing, spacing: 5) { Button(action: {
ForEach(events.indices, id: \.self) { index in if let url = event.calendarAppURL() {
VStack(alignment: .trailing) { openURL(url)
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)
} }
}) {
eventRow(event)
} }
.padding(.leading, -5)
.buttonStyle(PlainButtonStyle())
.listRowSeparator(.automatic)
.listRowSeparatorTint(.gray.opacity(0.2))
.listRowBackground(Color.clear)
} }
} }
.listStyle(.plain)
.scrollIndicators(.never) .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())
}
)
} }
} }

View file

@ -79,15 +79,17 @@ struct MinimalisticMusicPlayerView: View {
@State private var lastDragged: Date = .distantPast @State private var lastDragged: Date = .distantPast
private var progressBar: some View { private var progressBar: some View {
HStack(spacing: 8) { TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in
// Elapsed time - left let currentElapsed = currentSliderValue(timeline.date)
Text(formatTime(dragging ? sliderValue : musicManager.elapsedTime))
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.frame(width: 42, alignment: .leading)
// Progress bar HStack(spacing: 8) {
TimelineView(.animation(minimumInterval: 0.05)) { timeline in // 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 GeometryReader { geometry in
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
// Background track // 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 { .onAppear {
sliderValue = musicManager.elapsedTime sliderValue = musicManager.elapsedTime

View file

@ -15,11 +15,12 @@ import SwiftUI
struct MusicPlayerView: View { struct MusicPlayerView: View {
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
let albumArtNamespace: Namespace.ID let albumArtNamespace: Namespace.ID
let showShuffleAndRepeat: Bool
var body: some View { var body: some View {
HStack { HStack {
AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).padding(.all, 5)
MusicControlsView().drawingGroup().compositingGroup() MusicControlsView(showShuffleAndRepeat: showShuffleAndRepeat).drawingGroup().compositingGroup()
} }
} }
} }
@ -39,47 +40,59 @@ struct AlbumArtView: View {
} }
private var albumArtBackground: some View { private var albumArtBackground: some View {
Color.clear Image(nsImage: musicManager.albumArt)
.aspectRatio(1, contentMode: .fit) .resizable()
.background(
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: .fill)
)
.clipped() .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) .scaleEffect(x: 1.3, y: 1.4)
.rotationEffect(.degrees(92)) .rotationEffect(.degrees(92))
.blur(radius: 35) .blur(radius: 40)
.opacity(min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3))) .opacity(musicManager.isPlaying ? 0.5 : 0)
} }
private var albumArtButton: some View { private var albumArtButton: some View {
Button { ZStack {
musicManager.openMusicApp() Button {
} label: { musicManager.openMusicApp()
ZStack(alignment: .bottomTrailing) { } label: {
albumArtImage ZStack(alignment:.bottomTrailing) {
appIconOverlay 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 { private var albumArtImage: some View {
Color.clear Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(1, contentMode: .fit) .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) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
.clipped()
.clipShape(
RoundedRectangle(
cornerRadius: Defaults[.cornerRadiusScaling]
? MusicPlayerImageSizes.cornerRadiusInset.opened
: MusicPlayerImageSizes.cornerRadiusInset.closed)
)
} }
@ViewBuilder @ViewBuilder
@ -91,34 +104,24 @@ struct AlbumArtView: View {
.frame(width: 30, height: 30) .frame(width: 30, height: 30)
.offset(x: 10, y: 10) .offset(x: 10, y: 10)
.transition(.scale.combined(with: .opacity).animation(.bouncy.delay(0.3))) .transition(.scale.combined(with: .opacity).animation(.bouncy.delay(0.3)))
.zIndex(2)
} }
} }
} }
struct MusicControlsView: View { struct MusicControlsView: View {
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@EnvironmentObject var vm: DynamicIslandViewModel
@State private var sliderValue: Double = 0 @State private var sliderValue: Double = 0
@State private var dragging: Bool = false @State private var dragging: Bool = false
@State private var lastDragged: Date = .distantPast @State private var lastDragged: Date = .distantPast
let showShuffleAndRepeat: Bool
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
songInfoAndSlider songInfoAndSlider
playbackControls playbackControls
} }
.buttonStyle(PlainButtonStyle()) .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 { private var songInfoAndSlider: some View {
@ -148,7 +151,7 @@ struct MusicControlsView: View {
} }
private var musicSlider: some 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( MusicSliderView(
sliderValue: $sliderValue, sliderValue: $sliderValue,
duration: $musicManager.songDuration, duration: $musicManager.songDuration,
@ -170,33 +173,26 @@ struct MusicControlsView: View {
private var playbackControls: some View { private var playbackControls: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
if Defaults[.showShuffleAndRepeat] { if showShuffleAndRepeat {
HoverButton(icon: "shuffle", iconColor: musicManager.isShuffled ? .red : .white, scale: .medium) { HoverButton(
Task { icon: "shuffle", iconColor: musicManager.isShuffled ? .red : .white,
await MusicManager.shared.toggleShuffle() scale: .medium
} ) {
MusicManager.shared.toggleShuffle()
} }
} }
HoverButton(icon: "backward.fill", scale: .medium) { HoverButton(icon: "backward.fill", scale: .medium) {
Task { MusicManager.shared.previousTrack()
await MusicManager.shared.previousTrack()
}
} }
HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) { HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) {
Task { MusicManager.shared.togglePlay()
await MusicManager.shared.togglePlay()
}
} }
HoverButton(icon: "forward.fill", scale: .medium) { HoverButton(icon: "forward.fill", scale: .medium) {
Task { MusicManager.shared.nextTrack()
await MusicManager.shared.nextTrack()
}
} }
if Defaults[.showShuffleAndRepeat] { if showShuffleAndRepeat {
HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) { HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
Task { MusicManager.shared.toggleRepeat()
await MusicManager.shared.toggleRepeat()
}
} }
} }
} }
@ -249,7 +245,7 @@ struct NotchHomeView: View {
MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace)
} else { } else {
// Normal mode: Show full music player with optional calendar and webcam // Normal mode: Show full music player with optional calendar and webcam
MusicPlayerView(albumArtNamespace: albumArtNamespace) MusicPlayerView(albumArtNamespace: albumArtNamespace, showShuffleAndRepeat: Defaults[.showShuffleAndRepeat])
if Defaults[.showCalendar] { if Defaults[.showCalendar] {
CalendarView() CalendarView()
@ -289,13 +285,6 @@ struct MusicSliderView: View {
let isPlaying: Bool let isPlaying: Bool
var onValueChange: (Double) -> Void 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 { var body: some View {
VStack { VStack {
@ -320,8 +309,9 @@ struct MusicSliderView: View {
.ensureMinimumBrightness(factor: 0.6) : .gray) .ensureMinimumBrightness(factor: 0.6) : .gray)
.font(.caption) .font(.caption)
} }
.onChange(of: currentDate) { .onChange(of: currentDate) { newDate in
sliderValue = currentElapsedTime 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 height = CGFloat(dragging ? 9 : 5)
let rangeSpan = range.upperBound - range.lowerBound 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) { ZStack(alignment: .leading) {
// Background track // Background track

View file

@ -20,14 +20,14 @@ class MusicManager: ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var controllerCancellables = Set<AnyCancellable>() private var controllerCancellables = Set<AnyCancellable>()
private var debounceIdleTask: Task<Void, Never>? private var debounceIdleTask: Task<Void, Never>?
// Helper to check if macOS has removed support for NowPlayingController // Helper to check if macOS has removed support for NowPlayingController
public private(set) var isNowPlayingDeprecated: Bool = false public private(set) var isNowPlayingDeprecated: Bool = false
private let mediaChecker = MediaChecker() private let mediaChecker = MediaChecker()
// Active controller // Active controller
private var activeController: (any MediaControllerProtocol)? private var activeController: (any MediaControllerProtocol)?
// Published properties for UI // Published properties for UI
@Published var songTitle: String = "I'm Handsome" @Published var songTitle: String = "I'm Handsome"
@Published var artistName: String = "Me" @Published var artistName: String = "Me"
@ -103,13 +103,13 @@ class MusicManager: ObservableObject {
// MARK: - Setup Methods // MARK: - Setup Methods
private func createController(for type: MediaControllerType) -> (any MediaControllerProtocol)? { private func createController(for type: MediaControllerType) -> (any MediaControllerProtocol)? {
// Cleanup previous controller // Cleanup previous controller
if let _ = activeController { if activeController != nil {
controllerCancellables.removeAll() controllerCancellables.removeAll()
activeController = nil activeController = nil
} }
let newController: (any MediaControllerProtocol)? let newController: (any MediaControllerProtocol)?
switch type { switch type {
case .nowPlaying: case .nowPlaying:
// Only create NowPlayingController if not deprecated on this macOS version // Only create NowPlayingController if not deprecated on this macOS version
@ -125,7 +125,7 @@ class MusicManager: ObservableObject {
case .youtubeMusic: case .youtubeMusic:
newController = YouTubeMusicController() newController = YouTubeMusicController()
} }
// Set up state observation for the new controller // Set up state observation for the new controller
if let controller = newController { if let controller = newController {
controller.playbackStatePublisher controller.playbackStatePublisher
@ -137,19 +137,19 @@ class MusicManager: ObservableObject {
} }
.store(in: &controllerCancellables) .store(in: &controllerCancellables)
} }
return newController return newController
} }
private func setActiveControllerBasedOnPreference() { private func setActiveControllerBasedOnPreference() {
let preferredType = Defaults[.mediaController] let preferredType = Defaults[.mediaController]
print("Preferred Media Controller: \(preferredType)") print("Preferred Media Controller: \(preferredType)")
// If NowPlaying is deprecated but that's the preference, use Apple Music instead // If NowPlaying is deprecated but that's the preference, use Apple Music instead
let controllerType = (self.isNowPlayingDeprecated && preferredType == .nowPlaying) let controllerType = (self.isNowPlayingDeprecated && preferredType == .nowPlaying)
? .appleMusic ? .appleMusic
: preferredType : preferredType
if let controller = createController(for: controllerType) { if let controller = createController(for: controllerType) {
setActiveController(controller) setActiveController(controller)
} else if controllerType != .appleMusic, let fallbackController = createController(for: .appleMusic) { } else if controllerType != .appleMusic, let fallbackController = createController(for: .appleMusic) {
@ -157,126 +157,122 @@ class MusicManager: ObservableObject {
setActiveController(fallbackController) setActiveController(fallbackController)
} }
} }
private func setActiveController(_ controller: any MediaControllerProtocol) { private func setActiveController(_ controller: any MediaControllerProtocol) {
// Set the active controller // Cancel any existing flip animation
self.activeController = controller flipWorkItem?.cancel()
// Set new active controller
activeController = controller
// Get current state from active controller // Get current state from active controller
if let state = Mirror(reflecting: controller).children.first(where: { $0.label == "playbackState" })?.value as? PlaybackState { forceUpdate()
updateFromPlaybackState(state)
}
} }
// MARK: - Update Methods // MARK: - Update Methods
@MainActor
private func updateFromPlaybackState(_ state: PlaybackState) { private func updateFromPlaybackState(_ state: PlaybackState) {
// Create a batch of updates to apply together // Check for playback state changes (playing/paused)
let updateBatch = DispatchWorkItem { [weak self] in if state.isPlaying != self.isPlaying {
guard let self = self else { return } NSLog("Playback state changed: \(state.isPlaying ? "Playing" : "Paused")")
withAnimation(.smooth) {
// Check for playback state changes (playing/paused) self.isPlaying = state.isPlaying
if state.isPlaying != self.isPlaying { self.updateIdleState(state: state.isPlaying)
withAnimation(.smooth) { }
self.isPlaying = state.isPlaying
self.updateIdleState(state: state.isPlaying) if state.isPlaying && !state.title.isEmpty && !state.artist.isEmpty {
} self.updateSneakPeek()
}
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)
} }
} }
self.artworkData = state.artwork
// Check for changes in track metadata
let titleChanged = state.title != self.songTitle if artworkChanged || state.artwork == nil {
let artistChanged = state.artist != self.artistName // Update last artwork change values
let albumChanged = state.album != self.album self.lastArtworkTitle = state.title
self.lastArtworkArtist = state.artist
// Check for artwork changes self.lastArtworkAlbum = state.album
let artworkChanged = state.artwork != nil && state.artwork != self.artworkData self.lastArtworkBundleIdentifier = state.bundleIdentifier
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()
}
} }
let timeChanged = state.currentTime != self.elapsedTime // Only update sneak peek if there's actual content and something changed
let durationChanged = state.duration != self.songDuration if !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
let playbackRateChanged = state.playbackRate != self.playbackRate self.updateSneakPeek()
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 let timeChanged = state.currentTime != self.elapsedTime
} let durationChanged = state.duration != self.songDuration
let playbackRateChanged = state.playbackRate != self.playbackRate
if state.album != self.album { let shuffleChanged = state.isShuffled != self.isShuffled
self.album = state.album let repeatModeChanged = state.repeatMode != self.repeatMode
}
if state.title != self.songTitle {
if timeChanged { self.songTitle = state.title
self.elapsedTime = state.currentTime }
}
if state.artist != self.artistName {
if durationChanged { self.artistName = state.artist
self.songDuration = state.duration }
}
if state.album != self.album {
if playbackRateChanged { self.album = state.album
self.playbackRate = state.playbackRate }
}
if timeChanged {
if shuffleChanged { self.elapsedTime = state.currentTime
self.isShuffled = state.isShuffled }
}
if durationChanged {
if state.bundleIdentifier != self.bundleIdentifier { self.songDuration = state.duration
self.bundleIdentifier = state.bundleIdentifier }
}
if playbackRateChanged {
if repeatModeChanged { self.playbackRate = state.playbackRate
self.repeatMode = state.repeatMode
}
self.timestampDate = state.lastUpdated
} }
// Execute the batch update on the main thread if shuffleChanged {
DispatchQueue.main.async(execute: updateBatch) 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() { private func triggerFlipAnimation() {
// Cancel any existing animation // Cancel any existing animation
flipWorkItem?.cancel() flipWorkItem?.cancel()
// Create a new animation // Create a new animation
let workItem = DispatchWorkItem { [weak self] in let workItem = DispatchWorkItem { [weak self] in
self?.isFlipping = true self?.isFlipping = true
@ -284,7 +280,7 @@ class MusicManager: ObservableObject {
self?.isFlipping = false self?.isFlipping = false
} }
} }
flipWorkItem = workItem flipWorkItem = workItem
DispatchQueue.main.async(execute: workItem) DispatchQueue.main.async(execute: workItem)
} }
@ -333,6 +329,15 @@ class MusicManager: ObservableObject {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem!) 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() { func calculateAverageColor() {
albumArt.averageColor { [weak self] color in albumArt.averageColor { [weak self] color in
DispatchQueue.main.async { DispatchQueue.main.async {
@ -342,7 +347,7 @@ class MusicManager: ObservableObject {
} }
} }
} }
private func updateSneakPeek() { private func updateSneakPeek() {
if isPlaying && Defaults[.enableSneakPeek] { if isPlaying && Defaults[.enableSneakPeek] {
if Defaults[.sneakPeekStyles] == .standard { if Defaults[.sneakPeekStyles] == .standard {
@ -359,53 +364,28 @@ class MusicManager: ObservableObject {
await activeController?.togglePlay() await activeController?.togglePlay()
} }
} }
func play() { func play() {
Task { Task {
await activeController?.play() await activeController?.play()
} }
} }
func pause() { func pause() {
Task { Task {
await activeController?.pause() await activeController?.pause()
} }
} }
func toggleShuffle() { func toggleShuffle() {
Task { Task {
// Provide immediate UI feedback
await MainActor.run {
self.isShuffled.toggle()
}
// Execute the actual command
await activeController?.toggleShuffle() 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() { func toggleRepeat() {
Task { 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() 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() await activeController?.togglePlay()
} }
} }
func nextTrack() { func nextTrack() {
Task { Task {
await activeController?.nextTrack() await activeController?.nextTrack()
} }
} }
func previousTrack() { func previousTrack() {
Task { Task {
await activeController?.previousTrack() await activeController?.previousTrack()
} }
} }
func seek(to position: TimeInterval) { func seek(to position: TimeInterval) {
Task { Task {
await activeController?.seek(to: position) await activeController?.seek(to: position)
} }
} }
func openMusicApp() { func openMusicApp() {
guard let bundleID = bundleIdentifier else { guard let bundleID = bundleIdentifier else {
print("Error: appBundleIdentifier is nil") print("Error: appBundleIdentifier is nil")
@ -453,13 +433,12 @@ class MusicManager: ObservableObject {
print("Failed to find app with bundle ID: \(bundleID)") print("Failed to find app with bundle ID: \(bundleID)")
} }
} }
func forceUpdate() { func forceUpdate() {
// Request immediate update from the active controller // Request immediate update from the active controller
Task { [weak self] in Task { [weak self] in
if self?.activeController?.isActive() == true { if self?.activeController?.isActive() == true {
if type(of: self?.activeController) == YouTubeMusicController.self, if let youtubeController = self?.activeController as? YouTubeMusicController {
let youtubeController = self?.activeController as? YouTubeMusicController {
await youtubeController.pollPlaybackState() await youtubeController.pollPlaybackState()
} else { } else {
await self?.activeController?.updatePlaybackInfo() await self?.activeController?.updatePlaybackInfo()

View file

@ -236,6 +236,8 @@ extension Defaults.Keys {
static let showNotHumanFace = Key<Bool>("showNotHumanFace", default: false) static let showNotHumanFace = Key<Bool>("showNotHumanFace", default: false)
static let tileShowLabels = Key<Bool>("tileShowLabels", default: false) static let tileShowLabels = Key<Bool>("tileShowLabels", default: false)
static let showCalendar = Key<Bool>("showCalendar", default: false) static let showCalendar = Key<Bool>("showCalendar", default: false)
static let hideCompletedReminders = Key<Bool>("hideCompletedReminders", default: true)
static let hideAllDayEvents = Key<Bool>("hideAllDayEvents", default: false)
static let sliderColor = Key<SliderColorEnum>( static let sliderColor = Key<SliderColorEnum>(
"sliderUseAlbumArtColor", "sliderUseAlbumArtColor",
default: SliderColorEnum.white default: SliderColorEnum.white