mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Slider is working
This commit is contained in:
parent
5c169b14ce
commit
decf7a677a
6 changed files with 327 additions and 304 deletions
|
|
@ -2,12 +2,22 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<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>
|
||||
<array>
|
||||
<string>com.spotify.client</string>
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -20,14 +20,14 @@ class MusicManager: ObservableObject {
|
|||
private var cancellables = Set<AnyCancellable>()
|
||||
private var controllerCancellables = Set<AnyCancellable>()
|
||||
private var debounceIdleTask: Task<Void, Never>?
|
||||
|
||||
|
||||
// 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()
|
||||
|
|
|
|||
|
|
@ -236,6 +236,8 @@ extension Defaults.Keys {
|
|||
static let showNotHumanFace = Key<Bool>("showNotHumanFace", default: false)
|
||||
static let tileShowLabels = Key<Bool>("tileShowLabels", 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>(
|
||||
"sliderUseAlbumArtColor",
|
||||
default: SliderColorEnum.white
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue