mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
961 lines
33 KiB
Swift
961 lines
33 KiB
Swift
//
|
||
// MinimalisticMusicPlayerView.swift
|
||
// DynamicIsland
|
||
//
|
||
// Created for minimalistic UI mode - Open state music player
|
||
//
|
||
|
||
import SwiftUI
|
||
import Defaults
|
||
|
||
#if canImport(AppKit)
|
||
import AppKit
|
||
#endif
|
||
|
||
// Lyrics are shown/hidden only via Defaults[.enableLyrics] in settings. Inline display is used in the player views.
|
||
|
||
struct MinimalisticMusicPlayerView: View {
|
||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||
let albumArtNamespace: Namespace.ID
|
||
@Default(.musicAuxLeftControl) private var leftAuxControl
|
||
@Default(.musicAuxRightControl) private var rightAuxControl
|
||
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||
@ObservedObject private var reminderManager = ReminderLiveActivityManager.shared
|
||
@Default(.enableReminderLiveActivity) private var enableReminderLiveActivity
|
||
@Default(.enableLyrics) private var enableLyrics
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// Header area with album art (matching DynamicIslandHeader height of 24pt)
|
||
GeometryReader { headerGeo in
|
||
let albumArtWidth: CGFloat = 50
|
||
let spacing: CGFloat = 10
|
||
let visualizerWidth: CGFloat = useMusicVisualizer ? 24 : 0
|
||
let textWidth = max(0, headerGeo.size.width - albumArtWidth - spacing - (useMusicVisualizer ? (visualizerWidth + spacing) : 0))
|
||
HStack(alignment: .center, spacing: spacing) {
|
||
MinimalisticAlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace)
|
||
.frame(width: albumArtWidth, height: albumArtWidth)
|
||
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
if !musicManager.songTitle.isEmpty {
|
||
MarqueeText(
|
||
$musicManager.songTitle,
|
||
font: .system(size: 12, weight: .semibold),
|
||
nsFont: .subheadline,
|
||
textColor: .white,
|
||
frameWidth: textWidth
|
||
)
|
||
}
|
||
|
||
Text(musicManager.artistName)
|
||
.font(.system(size: 10, weight: .regular))
|
||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
||
.lineLimit(1)
|
||
|
||
}
|
||
.frame(width: textWidth, alignment: .leading)
|
||
|
||
if useMusicVisualizer {
|
||
visualizer
|
||
.frame(width: visualizerWidth)
|
||
}
|
||
}
|
||
}
|
||
.frame(height: 50)
|
||
|
||
// Compact progress bar
|
||
progressBar
|
||
.padding(.top, 6)
|
||
|
||
// Compact playback controls
|
||
playbackControls
|
||
.padding(.top, 4)
|
||
|
||
if enableLyrics {
|
||
lyricsView
|
||
.padding(.top, 10)
|
||
}
|
||
|
||
reminderList
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.top, -15)
|
||
.padding(.bottom, ReminderLiveActivityManager.baselineMinimalisticBottomPadding)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: calculateDynamicHeight(), alignment: .top)
|
||
}
|
||
|
||
// MARK: - TypingLyricView
|
||
|
||
struct TypingLyricView: View {
|
||
let text: String
|
||
let color: Color
|
||
let id: Int
|
||
let playbackRate: Double
|
||
let isPlaying: Bool
|
||
@State private var displayed: String = ""
|
||
@State private var lastText: String = ""
|
||
@State private var animationTask: Task<Void, Never>?
|
||
|
||
var body: some View {
|
||
Text(displayed)
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundColor(color)
|
||
.lineLimit(2)
|
||
.multilineTextAlignment(.leading)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 4)
|
||
.padding(.top, 4)
|
||
.id(id)
|
||
.onChange(of: text) { _, newText in
|
||
animateTyping(newText)
|
||
}
|
||
.onChange(of: isPlaying) { _, playing in
|
||
if !playing {
|
||
animationTask?.cancel()
|
||
} else if displayed != text {
|
||
animateTyping(text)
|
||
}
|
||
}
|
||
.onAppear {
|
||
animateTyping(text)
|
||
}
|
||
.onDisappear {
|
||
animationTask?.cancel()
|
||
}
|
||
}
|
||
|
||
private func animateTyping(_ newText: String) {
|
||
animationTask?.cancel()
|
||
displayed = ""
|
||
lastText = newText
|
||
let chars = Array(newText)
|
||
|
||
animationTask = Task {
|
||
for (i, c) in chars.enumerated() {
|
||
if Task.isCancelled { return }
|
||
try? await Task.sleep(for: .milliseconds(Int(30 / max(playbackRate, 0.1))))
|
||
if Task.isCancelled { return }
|
||
if lastText == newText {
|
||
displayed += String(c)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var reminderEntries: [ReminderLiveActivityManager.ReminderEntry] {
|
||
reminderManager.activeWindowReminders
|
||
}
|
||
|
||
private var shouldShowReminderList: Bool {
|
||
enableReminderLiveActivity && !reminderEntries.isEmpty
|
||
}
|
||
|
||
private var reminderListHeight: CGFloat {
|
||
ReminderLiveActivityManager.additionalHeight(forRowCount: reminderEntries.count)
|
||
}
|
||
|
||
private func calculateDynamicHeight() -> CGFloat {
|
||
var height: CGFloat = 50 // Base height for header
|
||
|
||
// Add progress bar height
|
||
height += 6 + 4 // progress bar + top padding
|
||
|
||
// Add playback controls height
|
||
height += 40 + 2 // controls + top padding
|
||
|
||
// Add lyrics height if enabled in settings (reserve space even while loading)
|
||
if enableLyrics {
|
||
let lyricsTopPadding: CGFloat = 10
|
||
let lyricsEstimatedHeight: CGFloat = 34
|
||
height += lyricsTopPadding + lyricsEstimatedHeight
|
||
}
|
||
|
||
// Add reminder list height if showing
|
||
if shouldShowReminderList {
|
||
height += reminderListHeight
|
||
}
|
||
|
||
// Add padding
|
||
height += 15 // top padding
|
||
height += ReminderLiveActivityManager.baselineMinimalisticBottomPadding
|
||
|
||
return height
|
||
}
|
||
|
||
private var reminderList: some View {
|
||
MinimalisticReminderEventListView(reminders: reminderEntries)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.frame(height: reminderListHeight, alignment: .top)
|
||
.opacity(shouldShowReminderList ? 1 : 0)
|
||
.animation(.easeInOut(duration: 0.18), value: shouldShowReminderList)
|
||
.environmentObject(vm)
|
||
}
|
||
|
||
private var lyricsView: some View {
|
||
let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let transition: AnyTransition = .asymmetric(
|
||
insertion: .move(edge: .bottom).combined(with: .opacity),
|
||
removal: .move(edge: .top).combined(with: .opacity)
|
||
)
|
||
|
||
return HStack(spacing: 6) {
|
||
if !line.isEmpty {
|
||
Image(systemName: "music.note")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(.white.opacity(0.7))
|
||
.symbolRenderingMode(.monochrome)
|
||
|
||
Text(line)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(.white.opacity(0.88))
|
||
.lineLimit(2)
|
||
.multilineTextAlignment(.leading)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.trailing, 6)
|
||
.id(line)
|
||
.transition(transition)
|
||
}
|
||
}
|
||
.padding(.horizontal, 6)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.animation(.smooth(duration: 0.32), value: line)
|
||
}
|
||
|
||
|
||
private struct MinimalisticReminderEventListView: View {
|
||
let reminders: [ReminderLiveActivityManager.ReminderEntry]
|
||
|
||
private let textFont = Font.system(size: 13, weight: .semibold)
|
||
private let separatorSpacing: CGFloat = 10
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: ReminderLiveActivityManager.listRowSpacing) {
|
||
ForEach(reminders) { entry in
|
||
MinimalisticReminderEventRow(entry: entry, textFont: textFont, separatorSpacing: separatorSpacing)
|
||
}
|
||
}
|
||
.padding(.top, ReminderLiveActivityManager.listTopPadding)
|
||
}
|
||
}
|
||
|
||
private struct MinimalisticReminderEventRow: View {
|
||
let entry: ReminderLiveActivityManager.ReminderEntry
|
||
let textFont: Font
|
||
let separatorSpacing: CGFloat
|
||
|
||
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||
@State private var didCopyLink = false
|
||
@State private var copyResetToken: UUID?
|
||
@State private var isDetailsPopoverPresented = false
|
||
@State private var isHoveringDetailsPopover = false
|
||
|
||
private let indicatorHeight: CGFloat = 20
|
||
|
||
private static let timeFormatter: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.dateStyle = .none
|
||
formatter.timeStyle = .short
|
||
return formatter
|
||
}()
|
||
|
||
var body: some View {
|
||
HStack(spacing: separatorSpacing) {
|
||
RoundedRectangle(cornerRadius: 3)
|
||
.fill(eventColor)
|
||
.frame(width: 8, height: indicatorHeight)
|
||
|
||
HStack(spacing: 6) {
|
||
Text(entry.event.title)
|
||
.font(textFont)
|
||
.foregroundStyle(Color.white)
|
||
.lineLimit(1)
|
||
|
||
if let timeText {
|
||
Text(timeText)
|
||
.font(.system(size: 12, weight: .regular))
|
||
.foregroundStyle(Color.white.opacity(0.6))
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.layoutPriority(1)
|
||
|
||
Spacer(minLength: 12)
|
||
|
||
HStack(spacing: separatorSpacing) {
|
||
if let url = linkURL {
|
||
Button {
|
||
copyToClipboard(url: url)
|
||
triggerCopyFeedback()
|
||
} label: {
|
||
Image(systemName: didCopyLink ? "checkmark.circle.fill" : "link")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(didCopyLink ? Color.green : Color.white.opacity(0.85))
|
||
.symbolRenderingMode(.monochrome)
|
||
.animation(.easeInOut(duration: 0.2), value: didCopyLink)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("Copy event link")
|
||
}
|
||
|
||
if hasDetails {
|
||
Button {
|
||
isDetailsPopoverPresented.toggle()
|
||
} label: {
|
||
Image(systemName: "info.circle")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(Color.white.opacity(0.85))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.popover(isPresented: $isDetailsPopoverPresented, arrowEdge: .top) {
|
||
MinimalisticReminderDetailsView(
|
||
entry: entry,
|
||
linkURL: linkURL,
|
||
onHoverChanged: { hovering in
|
||
isHoveringDetailsPopover = hovering
|
||
updatePopoverActivity()
|
||
}
|
||
)
|
||
.onDisappear {
|
||
isHoveringDetailsPopover = false
|
||
updatePopoverActivity()
|
||
}
|
||
}
|
||
.onChange(of: isDetailsPopoverPresented) { _, presented in
|
||
if !presented {
|
||
isHoveringDetailsPopover = false
|
||
updatePopoverActivity()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(height: ReminderLiveActivityManager.listRowHeight)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
openInCalendar()
|
||
}
|
||
.onDisappear {
|
||
copyResetToken = nil
|
||
didCopyLink = false
|
||
vm.isReminderPopoverActive = false
|
||
}
|
||
}
|
||
|
||
private var eventColor: Color {
|
||
Color(nsColor: entry.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
|
||
}
|
||
|
||
private var hasDetails: Bool {
|
||
let location = entry.event.location?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
let notes = entry.event.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
return !location.isEmpty || !notes.isEmpty
|
||
}
|
||
|
||
private var linkURL: URL? {
|
||
entry.event.url ?? entry.event.calendarAppURL()
|
||
}
|
||
|
||
private var timeText: String? {
|
||
Self.timeFormatter.string(from: entry.event.start)
|
||
}
|
||
|
||
private func updatePopoverActivity() {
|
||
vm.isReminderPopoverActive = isDetailsPopoverPresented && isHoveringDetailsPopover
|
||
}
|
||
|
||
private func triggerCopyFeedback() {
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) {
|
||
didCopyLink = true
|
||
}
|
||
|
||
let token = UUID()
|
||
copyResetToken = token
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [token] in
|
||
guard copyResetToken == token else { return }
|
||
|
||
withAnimation(.easeInOut(duration: 0.2)) {
|
||
didCopyLink = false
|
||
}
|
||
|
||
if copyResetToken == token {
|
||
copyResetToken = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
private func copyToClipboard(url: URL) {
|
||
#if canImport(AppKit)
|
||
let pasteboard = NSPasteboard.general
|
||
pasteboard.clearContents()
|
||
pasteboard.setString(url.absoluteString, forType: .string)
|
||
#endif
|
||
}
|
||
|
||
private func openInCalendar() {
|
||
#if canImport(AppKit)
|
||
guard let url = linkURL else { return }
|
||
NSWorkspace.shared.open(url)
|
||
#endif
|
||
}
|
||
}
|
||
|
||
private struct MinimalisticReminderDetailsView: View {
|
||
let entry: ReminderLiveActivityManager.ReminderEntry
|
||
let linkURL: URL?
|
||
var onHoverChanged: (Bool) -> Void = { _ in }
|
||
|
||
private let detailFont = Font.system(size: 13, weight: .regular)
|
||
private let smallLabelFont = Font.system(size: 12, weight: .semibold)
|
||
private static let timeFormatter: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.dateStyle = .none
|
||
formatter.timeStyle = .short
|
||
return formatter
|
||
}()
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text(entry.event.title)
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.foregroundStyle(Color.white)
|
||
.lineLimit(2)
|
||
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
if let timeRange = timeRangeText {
|
||
detailRow(icon: "clock", label: "Time", value: timeRange)
|
||
}
|
||
|
||
if let location = entry.event.location, !location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
detailRow(icon: "mappin.and.ellipse", label: "Location", value: location)
|
||
}
|
||
|
||
if let notes = entry.event.notes, !notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
detailRow(icon: "note.text", label: "Notes", value: notes)
|
||
}
|
||
}
|
||
|
||
if let url = linkURL {
|
||
Button {
|
||
open(url: url)
|
||
} label: {
|
||
Label("Open in Calendar", systemImage: "arrow.up.right.square")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
}
|
||
.buttonStyle(.link)
|
||
.foregroundStyle(Color.accentColor)
|
||
}
|
||
}
|
||
.padding(16)
|
||
.frame(minWidth: 220)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||
.fill(Color.black.opacity(0.92))
|
||
)
|
||
.onHover { hovering in
|
||
onHoverChanged(hovering)
|
||
}
|
||
.onDisappear {
|
||
onHoverChanged(false)
|
||
}
|
||
}
|
||
|
||
private func detailRow(icon: String, label: String, value: String) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Label(label, systemImage: icon)
|
||
.font(smallLabelFont)
|
||
.foregroundStyle(Color.white.opacity(0.8))
|
||
Text(value)
|
||
.font(detailFont)
|
||
.foregroundStyle(Color.white)
|
||
}
|
||
}
|
||
|
||
private var timeRangeText: String? {
|
||
let startText = Self.timeFormatter.string(from: entry.event.start)
|
||
let endText = Self.timeFormatter.string(from: entry.event.end)
|
||
return startText == endText ? startText : "\(startText) – \(endText)"
|
||
}
|
||
|
||
private func open(url: URL) {
|
||
#if canImport(AppKit)
|
||
NSWorkspace.shared.open(url)
|
||
#endif
|
||
}
|
||
}
|
||
// MARK: - Visualizer
|
||
|
||
@Default(.useMusicVisualizer) var useMusicVisualizer
|
||
|
||
private var visualizer: some View {
|
||
Rectangle()
|
||
.fill(Defaults[.coloredSpectrogram] ? Color(nsColor: MusicManager.shared.avgColor).gradient : Color.gray.gradient)
|
||
.mask {
|
||
AudioSpectrumView(isPlaying: .constant(MusicManager.shared.isPlaying))
|
||
.frame(width: 20, height: 16)
|
||
}
|
||
.frame(width: 20, height: 16)
|
||
.matchedGeometryEffect(id: "spectrum", in: albumArtNamespace)
|
||
}
|
||
|
||
// MARK: - Progress Bar (Full Width)
|
||
|
||
@ObservedObject var musicManager = MusicManager.shared
|
||
@State private var sliderValue: Double = 0
|
||
@State private var dragging: Bool = false
|
||
@State private var lastDragged: Date = .distantPast
|
||
|
||
private var progressBar: some View {
|
||
TimelineView(
|
||
.animation(
|
||
minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil
|
||
)
|
||
) { timeline in
|
||
MusicSliderView(
|
||
sliderValue: $sliderValue,
|
||
duration: Binding(
|
||
get: { musicManager.songDuration },
|
||
set: { musicManager.songDuration = $0 }
|
||
),
|
||
lastDragged: $lastDragged,
|
||
color: musicManager.avgColor,
|
||
dragging: $dragging,
|
||
currentDate: timeline.date,
|
||
timestampDate: musicManager.timestampDate,
|
||
elapsedTime: musicManager.elapsedTime,
|
||
playbackRate: musicManager.playbackRate,
|
||
isPlaying: musicManager.isPlaying,
|
||
isLiveStream: musicManager.isLiveStream,
|
||
onValueChange: { newValue in
|
||
musicManager.seek(to: newValue)
|
||
},
|
||
labelLayout: .inline,
|
||
trailingLabel: .remaining,
|
||
restingTrackHeight: 7,
|
||
draggingTrackHeight: 11
|
||
)
|
||
}
|
||
.onAppear {
|
||
sliderValue = musicManager.elapsedTime
|
||
}
|
||
.onChange(of: musicManager.isLiveStream) { _, isLive in
|
||
if isLive {
|
||
dragging = false
|
||
sliderValue = 0
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Playback Controls (Larger)
|
||
|
||
private var playbackControls: some View {
|
||
let controls = resolvedAuxControls
|
||
let seekInterval: TimeInterval = 10
|
||
let skipPressMagnitude: CGFloat = 8
|
||
|
||
let backwardConfig: (icon: String, press: MinimalisticSquircircleButton.PressEffect, symbol: MinimalisticSquircircleButton.SymbolEffectStyle, action: () -> Void)
|
||
let forwardConfig: (icon: String, press: MinimalisticSquircircleButton.PressEffect, symbol: MinimalisticSquircircleButton.SymbolEffectStyle, action: () -> Void)
|
||
|
||
switch musicSkipBehavior {
|
||
case .track:
|
||
backwardConfig = (
|
||
icon: "backward.fill",
|
||
press: .nudge(-skipPressMagnitude),
|
||
symbol: .replace,
|
||
action: { musicManager.previousTrack() }
|
||
)
|
||
forwardConfig = (
|
||
icon: "forward.fill",
|
||
press: .nudge(skipPressMagnitude),
|
||
symbol: .replace,
|
||
action: { musicManager.nextTrack() }
|
||
)
|
||
case .tenSecond:
|
||
backwardConfig = (
|
||
icon: "gobackward.10",
|
||
press: .wiggle(.counterClockwise),
|
||
symbol: .wiggle,
|
||
action: { musicManager.seek(by: -seekInterval) }
|
||
)
|
||
forwardConfig = (
|
||
icon: "goforward.10",
|
||
press: .wiggle(.clockwise),
|
||
symbol: .wiggle,
|
||
action: { musicManager.seek(by: seekInterval) }
|
||
)
|
||
}
|
||
|
||
return HStack(spacing: 16) {
|
||
if Defaults[.showShuffleAndRepeat] {
|
||
auxButton(for: controls.left)
|
||
}
|
||
|
||
controlButton(
|
||
icon: backwardConfig.icon,
|
||
size: 18,
|
||
pressEffect: backwardConfig.press,
|
||
symbolEffect: backwardConfig.symbol,
|
||
action: backwardConfig.action
|
||
)
|
||
|
||
playPauseButton
|
||
|
||
controlButton(
|
||
icon: forwardConfig.icon,
|
||
size: 18,
|
||
pressEffect: forwardConfig.press,
|
||
symbolEffect: forwardConfig.symbol,
|
||
action: forwardConfig.action
|
||
)
|
||
|
||
if Defaults[.showShuffleAndRepeat] {
|
||
auxButton(for: controls.right)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
.padding(.top, 2)
|
||
}
|
||
|
||
private var playPauseButton: some View {
|
||
MinimalisticSquircircleButton(
|
||
icon: musicManager.isPlaying ? "pause.fill" : "play.fill",
|
||
fontSize: 28,
|
||
fontWeight: .semibold,
|
||
frameSize: CGSize(width: 60, height: 60),
|
||
cornerRadius: 24,
|
||
foregroundColor: .white,
|
||
pressEffect: .none,
|
||
symbolEffectStyle: .replace,
|
||
action: {
|
||
musicManager.togglePlay()
|
||
}
|
||
)
|
||
}
|
||
|
||
private func controlButton(
|
||
icon: String,
|
||
size: CGFloat = 18,
|
||
isActive: Bool = false,
|
||
pressEffect: MinimalisticSquircircleButton.PressEffect = .none,
|
||
symbolEffect: MinimalisticSquircircleButton.SymbolEffectStyle = .none,
|
||
action: @escaping () -> Void
|
||
) -> some View {
|
||
MinimalisticSquircircleButton(
|
||
icon: icon,
|
||
fontSize: size,
|
||
fontWeight: .medium,
|
||
frameSize: CGSize(width: 40, height: 40),
|
||
cornerRadius: 16,
|
||
foregroundColor: isActive ? .red : .white.opacity(0.85),
|
||
pressEffect: pressEffect,
|
||
symbolEffectStyle: symbolEffect,
|
||
action: action
|
||
)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func auxButton(for control: MusicAuxiliaryControl) -> some View {
|
||
switch control {
|
||
case .shuffle:
|
||
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
||
Task { await musicManager.toggleShuffle() }
|
||
}
|
||
case .repeatMode:
|
||
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off, symbolEffect: .replace) {
|
||
Task { await musicManager.toggleRepeat() }
|
||
}
|
||
case .mediaOutput:
|
||
MinimalisticMediaOutputButton()
|
||
case .lyrics:
|
||
controlButton(
|
||
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
|
||
isActive: enableLyrics,
|
||
symbolEffect: .replace
|
||
) {
|
||
enableLyrics.toggle()
|
||
}
|
||
}
|
||
}
|
||
|
||
private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) {
|
||
guard leftAuxControl == rightAuxControl else {
|
||
return (leftAuxControl, rightAuxControl)
|
||
}
|
||
return (leftAuxControl, MusicAuxiliaryControl.alternative(excluding: leftAuxControl))
|
||
}
|
||
private struct MinimalisticMediaOutputButton: View {
|
||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||
@State private var isPopoverPresented = false
|
||
@State private var isHoveringPopover = false
|
||
|
||
var body: some View {
|
||
MinimalisticSquircircleButton(
|
||
icon: routeManager.activeDevice?.iconName ?? "speaker.wave.2",
|
||
fontSize: 18,
|
||
fontWeight: .medium,
|
||
frameSize: CGSize(width: 40, height: 40),
|
||
cornerRadius: 16,
|
||
foregroundColor: .white.opacity(0.85),
|
||
symbolEffectStyle: .replace
|
||
) {
|
||
isPopoverPresented.toggle()
|
||
if isPopoverPresented {
|
||
routeManager.refreshDevices()
|
||
}
|
||
}
|
||
.accessibilityLabel("Media output")
|
||
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
||
MediaOutputSelectorPopover(
|
||
routeManager: routeManager,
|
||
volumeModel: volumeModel,
|
||
onHoverChanged: { hovering in
|
||
isHoveringPopover = hovering
|
||
updateActivity()
|
||
}
|
||
) {
|
||
isPopoverPresented = false
|
||
isHoveringPopover = false
|
||
updateActivity()
|
||
}
|
||
}
|
||
.onChange(of: isPopoverPresented) { _, presented in
|
||
if !presented {
|
||
isHoveringPopover = false
|
||
}
|
||
updateActivity()
|
||
}
|
||
.onAppear {
|
||
routeManager.refreshDevices()
|
||
}
|
||
.onDisappear {
|
||
vm.isMediaOutputPopoverActive = false
|
||
}
|
||
}
|
||
|
||
private func updateActivity() {
|
||
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
|
||
}
|
||
}
|
||
|
||
private var repeatIcon: String {
|
||
switch musicManager.repeatMode {
|
||
case .off: return "repeat"
|
||
case .all: return "repeat"
|
||
case .one: return "repeat.1"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Minimalistic Album Art
|
||
|
||
struct MinimalisticAlbumArtView: View {
|
||
@ObservedObject var musicManager = MusicManager.shared
|
||
@ObservedObject var vm: DynamicIslandViewModel
|
||
let albumArtNamespace: Namespace.ID
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .bottomTrailing) {
|
||
if Defaults[.lightingEffect] {
|
||
albumArtBackground
|
||
}
|
||
albumArtButton
|
||
}
|
||
}
|
||
|
||
private var albumArtBackground: some View {
|
||
Color.clear
|
||
.aspectRatio(1, contentMode: .fit)
|
||
.background(
|
||
Image(nsImage: musicManager.albumArt)
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
)
|
||
.clipped()
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
.scaleEffect(x: 1.3, y: 1.4)
|
||
.rotationEffect(.degrees(92))
|
||
.blur(radius: 35)
|
||
.opacity(min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3)))
|
||
}
|
||
|
||
private var albumArtButton: some View {
|
||
Button {
|
||
musicManager.openMusicApp()
|
||
} label: {
|
||
Color.clear
|
||
.aspectRatio(1, contentMode: .fit)
|
||
.background(
|
||
Image(nsImage: musicManager.albumArt)
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
)
|
||
.clipped()
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
|
||
.albumArtFlip(angle: musicManager.flipAngle)
|
||
}
|
||
.buttonStyle(PlainButtonStyle())
|
||
.opacity(musicManager.isPlaying ? 1 : 0.4)
|
||
.scaleEffect(musicManager.isPlaying ? 1 : 0.85)
|
||
}
|
||
}
|
||
|
||
// MARK: - Hover-highlighted control button
|
||
|
||
private struct MinimalisticSquircircleButton: View {
|
||
let icon: String
|
||
let fontSize: CGFloat
|
||
let fontWeight: Font.Weight
|
||
let frameSize: CGSize
|
||
let cornerRadius: CGFloat
|
||
let foregroundColor: Color
|
||
let pressEffect: PressEffect
|
||
let symbolEffectStyle: SymbolEffectStyle
|
||
let action: () -> Void
|
||
|
||
@State private var isHovering = false
|
||
@State private var pressOffset: CGFloat = 0
|
||
@State private var rotationAngle: Double = 0
|
||
@State private var wiggleToken: Int = 0
|
||
|
||
init(
|
||
icon: String,
|
||
fontSize: CGFloat,
|
||
fontWeight: Font.Weight,
|
||
frameSize: CGSize,
|
||
cornerRadius: CGFloat,
|
||
foregroundColor: Color,
|
||
pressEffect: PressEffect = .none,
|
||
symbolEffectStyle: SymbolEffectStyle = .none,
|
||
action: @escaping () -> Void
|
||
) {
|
||
self.icon = icon
|
||
self.fontSize = fontSize
|
||
self.fontWeight = fontWeight
|
||
self.frameSize = frameSize
|
||
self.cornerRadius = cornerRadius
|
||
self.foregroundColor = foregroundColor
|
||
self.pressEffect = pressEffect
|
||
self.symbolEffectStyle = symbolEffectStyle
|
||
self.action = action
|
||
}
|
||
|
||
var body: some View {
|
||
Button {
|
||
triggerPressEffect()
|
||
action()
|
||
} label: {
|
||
iconView()
|
||
.frame(width: frameSize.width, height: frameSize.height)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||
.fill(isHovering ? Color.white.opacity(0.18) : .clear)
|
||
)
|
||
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||
}
|
||
.buttonStyle(PlainButtonStyle())
|
||
.offset(x: pressOffset)
|
||
.rotationEffect(.degrees(rotationAngle))
|
||
.onHover { hovering in
|
||
withAnimation(.easeOut(duration: 0.18)) {
|
||
isHovering = hovering
|
||
}
|
||
}
|
||
}
|
||
|
||
private func triggerPressEffect() {
|
||
switch pressEffect {
|
||
case .none:
|
||
return
|
||
case .nudge(let amount):
|
||
withAnimation(.spring(response: 0.16, dampingFraction: 0.72)) {
|
||
pressOffset = amount
|
||
}
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
|
||
withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) {
|
||
pressOffset = 0
|
||
}
|
||
}
|
||
case .wiggle(let direction):
|
||
guard #available(macOS 14.0, *) else { return }
|
||
wiggleToken += 1
|
||
let angle: Double = direction == .clockwise ? 11 : -11
|
||
|
||
withAnimation(.spring(response: 0.18, dampingFraction: 0.52)) {
|
||
rotationAngle = angle
|
||
}
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
||
withAnimation(.spring(response: 0.32, dampingFraction: 0.76)) {
|
||
rotationAngle = 0
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func iconView() -> some View {
|
||
let image = Image(systemName: icon)
|
||
.font(.system(size: fontSize, weight: fontWeight))
|
||
.foregroundColor(foregroundColor)
|
||
|
||
switch symbolEffectStyle {
|
||
case .none:
|
||
image
|
||
case .replace:
|
||
if #available(macOS 14.0, *) {
|
||
image.contentTransition(.symbolEffect(.replace))
|
||
} else {
|
||
image
|
||
}
|
||
case .bounce:
|
||
if #available(macOS 14.0, *) {
|
||
image.symbolEffect(.bounce, value: icon)
|
||
} else {
|
||
image
|
||
}
|
||
case .replaceAndBounce:
|
||
if #available(macOS 14.0, *) {
|
||
image
|
||
.contentTransition(.symbolEffect(.replace))
|
||
.symbolEffect(.bounce, value: icon)
|
||
} else {
|
||
image
|
||
}
|
||
case .wiggle:
|
||
if #available(macOS 14.0, *) {
|
||
image.symbolEffect(
|
||
.wiggle.byLayer,
|
||
options: .nonRepeating,
|
||
value: wiggleToken
|
||
)
|
||
} else {
|
||
image
|
||
}
|
||
}
|
||
}
|
||
|
||
enum PressEffect {
|
||
case none
|
||
case nudge(CGFloat)
|
||
case wiggle(WiggleDirection)
|
||
}
|
||
|
||
enum SymbolEffectStyle {
|
||
case none
|
||
case replace
|
||
case bounce
|
||
case replaceAndBounce
|
||
case wiggle
|
||
}
|
||
|
||
enum WiggleDirection {
|
||
case clockwise
|
||
case counterClockwise
|
||
}
|
||
}
|