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">
|
<!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>
|
||||||
|
|
|
||||||
|
|
@ -154,46 +154,62 @@ struct CalendarView: View {
|
||||||
@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())
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
.frame(height: 6)
|
|
||||||
|
|
||||||
// Time remaining - right
|
// Time remaining - right
|
||||||
Text("-\(formatTime(musicManager.songDuration - (dragging ? sliderValue : musicManager.elapsedTime)))")
|
Text("-\(formatTime(musicManager.songDuration - (dragging ? sliderValue : currentElapsed)))")
|
||||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(.white.opacity(0.7))
|
.foregroundColor(.white.opacity(0.7))
|
||||||
.frame(width: 48, alignment: .trailing)
|
.frame(width: 48, alignment: .trailing)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
sliderValue = musicManager.elapsedTime
|
sliderValue = musicManager.elapsedTime
|
||||||
|
|
|
||||||
|
|
@ -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,16 +104,17 @@ 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) {
|
||||||
|
|
@ -108,17 +122,6 @@ struct MusicControlsView: View {
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -159,118 +159,114 @@ class MusicManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for changes in track metadata
|
if state.isPlaying && !state.title.isEmpty && !state.artist.isEmpty {
|
||||||
let titleChanged = state.title != self.songTitle
|
self.updateSneakPeek()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute the batch update on the main thread
|
// Check for changes in track metadata using last artwork change values
|
||||||
DispatchQueue.main.async(execute: updateBatch)
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
private func triggerFlipAnimation() {
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -374,38 +379,13 @@ class MusicManager: ObservableObject {
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,8 +438,7 @@ class MusicManager: ObservableObject {
|
||||||
// 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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue