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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue