mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 15:44:32 +00:00
Working on Media controls
This commit is contained in:
parent
ef18157857
commit
e6fcddae67
8 changed files with 744 additions and 6 deletions
|
|
@ -71,13 +71,17 @@ struct ContentView: View {
|
||||||
@State private var lastHapticTime: Date = Date()
|
@State private var lastHapticTime: Date = Date()
|
||||||
|
|
||||||
@State private var gestureProgress: CGFloat = .zero
|
@State private var gestureProgress: CGFloat = .zero
|
||||||
|
@State private var isMusicControlWindowVisible = false
|
||||||
|
@State private var pendingMusicControlSync: DispatchWorkItem?
|
||||||
|
@State private var musicControlHideWorkItem: DispatchWorkItem?
|
||||||
|
@State private var musicControlVisibilityDeadline: Date?
|
||||||
|
|
||||||
@State private var haptics: Bool = false
|
@State private var haptics: Bool = false
|
||||||
|
|
||||||
@Namespace var albumArtNamespace
|
@Namespace var albumArtNamespace
|
||||||
|
|
||||||
@Default(.useMusicVisualizer) var useMusicVisualizer
|
@Default(.useMusicVisualizer) var useMusicVisualizer
|
||||||
|
@Default(.musicControlWindowEnabled) var musicControlWindowEnabled
|
||||||
@Default(.showNotHumanFace) var showNotHumanFace
|
@Default(.showNotHumanFace) var showNotHumanFace
|
||||||
@Default(.useModernCloseAnimation) var useModernCloseAnimation
|
@Default(.useModernCloseAnimation) var useModernCloseAnimation
|
||||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||||
|
|
@ -91,6 +95,7 @@ struct ContentView: View {
|
||||||
private let extendedHoverPadding: CGFloat = 30
|
private let extendedHoverPadding: CGFloat = 30
|
||||||
private let zeroHeightHoverPadding: CGFloat = 10
|
private let zeroHeightHoverPadding: CGFloat = 10
|
||||||
private let statsAdditionalRowHeight: CGFloat = 110
|
private let statsAdditionalRowHeight: CGFloat = 110
|
||||||
|
private let musicControlPauseGrace: TimeInterval = 10
|
||||||
|
|
||||||
// Use minimalistic corner radius ONLY when opened, keep normal when closed
|
// Use minimalistic corner radius ONLY when opened, keep normal when closed
|
||||||
private var activeCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) {
|
private var activeCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) {
|
||||||
|
|
@ -368,6 +373,86 @@ struct ContentView: View {
|
||||||
.background(dragDetector)
|
.background(dragDetector)
|
||||||
.environmentObject(vm)
|
.environmentObject(vm)
|
||||||
.environmentObject(webcamManager)
|
.environmentObject(webcamManager)
|
||||||
|
.onAppear {
|
||||||
|
if musicManager.isPlaying || !musicManager.isPlayerIdle {
|
||||||
|
clearMusicControlVisibilityDeadline()
|
||||||
|
}
|
||||||
|
if let deadline = musicControlVisibilityDeadline, Date() > deadline {
|
||||||
|
clearMusicControlVisibilityDeadline()
|
||||||
|
}
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true)
|
||||||
|
}
|
||||||
|
.onChange(of: vm.notchState) { _, state in
|
||||||
|
if state == .open {
|
||||||
|
cancelMusicControlWindowSync()
|
||||||
|
hideMusicControlWindow()
|
||||||
|
} else {
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true, delay: 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: musicControlWindowEnabled) { _, enabled in
|
||||||
|
if enabled {
|
||||||
|
if musicManager.isPlaying || !musicManager.isPlayerIdle {
|
||||||
|
clearMusicControlVisibilityDeadline()
|
||||||
|
}
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true)
|
||||||
|
} else {
|
||||||
|
cancelMusicControlWindowSync()
|
||||||
|
hideMusicControlWindow()
|
||||||
|
clearMusicControlVisibilityDeadline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: coordinator.musicLiveActivityEnabled) { _, enabled in
|
||||||
|
if enabled {
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true)
|
||||||
|
} else {
|
||||||
|
cancelMusicControlWindowSync()
|
||||||
|
hideMusicControlWindow()
|
||||||
|
clearMusicControlVisibilityDeadline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: vm.hideOnClosed) { _, hidden in
|
||||||
|
if hidden {
|
||||||
|
cancelMusicControlWindowSync()
|
||||||
|
hideMusicControlWindow()
|
||||||
|
} else {
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true, delay: 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: lockScreenManager.isLocked) { _, locked in
|
||||||
|
if locked {
|
||||||
|
cancelMusicControlWindowSync()
|
||||||
|
hideMusicControlWindow()
|
||||||
|
} else {
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true, delay: 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: gestureProgress) { _, _ in
|
||||||
|
if shouldShowMusicControlWindow() {
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true, delay: 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: isHovering) { _, hovering in
|
||||||
|
if shouldShowMusicControlWindow() {
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true, delay: hovering ? 0.05 : 0.12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: musicManager.isPlaying) { _, isPlaying in
|
||||||
|
handleMusicControlPlaybackChange(isPlaying: isPlaying)
|
||||||
|
}
|
||||||
|
.onChange(of: musicManager.isPlayerIdle) { _, isIdle in
|
||||||
|
handleMusicControlIdleChange(isIdle: isIdle)
|
||||||
|
}
|
||||||
|
.onChange(of: vm.closedNotchSize) { _, _ in
|
||||||
|
if shouldShowMusicControlWindow() {
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: vm.effectiveClosedNotchHeight) { _, _ in
|
||||||
|
if shouldShowMusicControlWindow() {
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
// Clean up all timer work items to prevent memory leaks and conflicts
|
// Clean up all timer work items to prevent memory leaks and conflicts
|
||||||
hoverWorkItem?.cancel()
|
hoverWorkItem?.cancel()
|
||||||
|
|
@ -375,6 +460,10 @@ struct ContentView: View {
|
||||||
statsTransitionWorkItem?.cancel()
|
statsTransitionWorkItem?.cancel()
|
||||||
viewTransitionWorkItem?.cancel()
|
viewTransitionWorkItem?.cancel()
|
||||||
sizeChangeWorkItem?.cancel()
|
sizeChangeWorkItem?.cancel()
|
||||||
|
cancelMusicControlWindowSync()
|
||||||
|
hideMusicControlWindow()
|
||||||
|
cancelMusicControlVisibilityTimer()
|
||||||
|
clearMusicControlVisibilityDeadline()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -910,6 +999,160 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleMusicControlPlaybackChange(isPlaying: Bool) {
|
||||||
|
guard musicControlWindowEnabled else { return }
|
||||||
|
|
||||||
|
if isPlaying {
|
||||||
|
clearMusicControlVisibilityDeadline()
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true)
|
||||||
|
} else {
|
||||||
|
extendMusicControlVisibilityAfterPause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleMusicControlIdleChange(isIdle: Bool) {
|
||||||
|
guard musicControlWindowEnabled else { return }
|
||||||
|
|
||||||
|
if isIdle {
|
||||||
|
if musicControlVisibilityDeadline == nil {
|
||||||
|
extendMusicControlVisibilityAfterPause()
|
||||||
|
}
|
||||||
|
} else if musicManager.isPlaying {
|
||||||
|
clearMusicControlVisibilityDeadline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extendMusicControlVisibilityAfterPause() {
|
||||||
|
let deadline = Date().addingTimeInterval(musicControlPauseGrace)
|
||||||
|
musicControlVisibilityDeadline = deadline
|
||||||
|
scheduleMusicControlVisibilityCheck(deadline: deadline)
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearMusicControlVisibilityDeadline() {
|
||||||
|
musicControlVisibilityDeadline = nil
|
||||||
|
cancelMusicControlVisibilityTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleMusicControlVisibilityCheck(deadline: Date) {
|
||||||
|
cancelMusicControlVisibilityTimer()
|
||||||
|
|
||||||
|
let workItem = DispatchWorkItem {
|
||||||
|
musicControlHideWorkItem = nil
|
||||||
|
|
||||||
|
if let currentDeadline = musicControlVisibilityDeadline, currentDeadline <= Date() {
|
||||||
|
musicControlVisibilityDeadline = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleMusicControlWindowSync(forceRefresh: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
musicControlHideWorkItem = workItem
|
||||||
|
let interval = max(0, deadline.timeIntervalSinceNow)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + interval, execute: workItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelMusicControlVisibilityTimer() {
|
||||||
|
musicControlHideWorkItem?.cancel()
|
||||||
|
musicControlHideWorkItem = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func musicControlVisibilityIsActive() -> Bool {
|
||||||
|
if musicManager.isPlaying {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let deadline = musicControlVisibilityDeadline else { return false }
|
||||||
|
return Date() <= deadline
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldShowMusicControlWindow() -> Bool {
|
||||||
|
guard musicControlWindowEnabled,
|
||||||
|
coordinator.musicLiveActivityEnabled,
|
||||||
|
vm.notchState == .closed,
|
||||||
|
!vm.hideOnClosed,
|
||||||
|
!lockScreenManager.isLocked else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return musicControlVisibilityIsActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleMusicControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0) {
|
||||||
|
#if os(macOS)
|
||||||
|
cancelMusicControlWindowSync()
|
||||||
|
|
||||||
|
guard shouldShowMusicControlWindow() else {
|
||||||
|
hideMusicControlWindow()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let syncDelay = max(0, delay)
|
||||||
|
let workItem = DispatchWorkItem { [forceRefresh] in
|
||||||
|
pendingMusicControlSync = nil
|
||||||
|
|
||||||
|
if shouldShowMusicControlWindow() {
|
||||||
|
syncMusicControlWindow(forceRefresh: forceRefresh)
|
||||||
|
} else {
|
||||||
|
hideMusicControlWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingMusicControlSync = workItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + syncDelay, execute: workItem)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelMusicControlWindowSync() {
|
||||||
|
pendingMusicControlSync?.cancel()
|
||||||
|
pendingMusicControlSync = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
private func currentMusicControlWindowMetrics() -> MusicControlWindowMetrics {
|
||||||
|
MusicControlWindowMetrics(
|
||||||
|
notchHeight: max(vm.closedNotchSize.height, vm.effectiveClosedNotchHeight),
|
||||||
|
notchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0),
|
||||||
|
rightWingWidth: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
|
||||||
|
cornerRadius: activeCornerRadiusInsets.closed.bottom,
|
||||||
|
spacing: 36
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncMusicControlWindow(forceRefresh: Bool = false) {
|
||||||
|
let notchAvailable = vm.effectiveClosedNotchHeight > 0 && vm.closedNotchSize.width > 0
|
||||||
|
let targetVisible = shouldShowMusicControlWindow() && notchAvailable
|
||||||
|
|
||||||
|
if targetVisible {
|
||||||
|
let metrics = currentMusicControlWindowMetrics()
|
||||||
|
if !isMusicControlWindowVisible {
|
||||||
|
let didPresent = MusicControlWindowManager.shared.present(using: vm, metrics: metrics)
|
||||||
|
isMusicControlWindowVisible = didPresent
|
||||||
|
} else if forceRefresh {
|
||||||
|
let didRefresh = MusicControlWindowManager.shared.refresh(using: vm, metrics: metrics)
|
||||||
|
if !didRefresh {
|
||||||
|
MusicControlWindowManager.shared.hide()
|
||||||
|
isMusicControlWindowVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if isMusicControlWindowVisible {
|
||||||
|
MusicControlWindowManager.shared.hide()
|
||||||
|
isMusicControlWindowVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideMusicControlWindow() {
|
||||||
|
if isMusicControlWindowVisible {
|
||||||
|
MusicControlWindowManager.shared.hide()
|
||||||
|
isMusicControlWindowVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
private func syncMusicControlWindow(forceRefresh: Bool = false) {}
|
||||||
|
|
||||||
|
private func hideMusicControlWindow() {}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FullScreenDropDelegate: DropDelegate {
|
struct FullScreenDropDelegate: DropDelegate {
|
||||||
|
|
|
||||||
|
|
@ -6816,6 +6816,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Displays play/pause and skip buttons beside the notch while music is active. Disabled by default." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Do Not Disturb" : {
|
"Do Not Disturb" : {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -711,8 +711,8 @@ struct LockScreenMusicPanel: View {
|
||||||
if #available(macOS 26.0, *) {
|
if #available(macOS 26.0, *) {
|
||||||
RoundedRectangle(cornerRadius: panelCornerRadius)
|
RoundedRectangle(cornerRadius: panelCornerRadius)
|
||||||
.glassEffect(
|
.glassEffect(
|
||||||
.regular
|
.clear
|
||||||
.tint(Color.white.opacity(0.12))
|
//.tint(Color.white.opacity(0.12))
|
||||||
.interactive(),
|
.interactive(),
|
||||||
in: .rect(cornerRadius: panelCornerRadius)
|
in: .rect(cornerRadius: panelCornerRadius)
|
||||||
)
|
)
|
||||||
|
|
@ -739,8 +739,8 @@ struct LockScreenMusicPanel: View {
|
||||||
if #available(macOS 26.0, *) {
|
if #available(macOS 26.0, *) {
|
||||||
RoundedRectangle(cornerRadius: cornerRadius)
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
.glassEffect(
|
.glassEffect(
|
||||||
.regular
|
.clear
|
||||||
.tint(Color.white.opacity(0.16))
|
//.tint(Color.white.opacity(0.16))
|
||||||
.interactive(),
|
.interactive(),
|
||||||
in: .rect(cornerRadius: cornerRadius)
|
in: .rect(cornerRadius: cornerRadius)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
273
DynamicIsland/components/Music/MusicControlOverlay.swift
Normal file
273
DynamicIsland/components/Music/MusicControlOverlay.swift
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Defaults
|
||||||
|
|
||||||
|
struct MusicControlOverlay: View {
|
||||||
|
let notchHeight: CGFloat
|
||||||
|
let cornerRadius: CGFloat
|
||||||
|
|
||||||
|
@ObservedObject private var musicManager = MusicManager.shared
|
||||||
|
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||||||
|
|
||||||
|
private let seekInterval: TimeInterval = 10
|
||||||
|
private let skipPressMagnitude: CGFloat = 8
|
||||||
|
|
||||||
|
private var controlsEnabled: Bool {
|
||||||
|
!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttonSide: CGFloat {
|
||||||
|
let preferred = notchHeight * 0.94
|
||||||
|
let lowerBound = max(30, notchHeight * 0.8)
|
||||||
|
let upperBound = max(notchHeight + 2, 32)
|
||||||
|
return min(max(preferred, lowerBound), upperBound)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var playPauseSide: CGFloat {
|
||||||
|
min(buttonSide + 8, notchHeight + 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttonCornerRadius: CGFloat {
|
||||||
|
max(cornerRadius - 8, buttonSide * 0.28)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var windowCornerRadius: CGFloat {
|
||||||
|
max(cornerRadius - 6, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backwardConfig: ButtonConfig {
|
||||||
|
switch musicSkipBehavior {
|
||||||
|
case .track:
|
||||||
|
return ButtonConfig(
|
||||||
|
icon: "backward.fill",
|
||||||
|
pressEffect: .nudge(-skipPressMagnitude),
|
||||||
|
symbolEffect: .replace,
|
||||||
|
action: { MusicManager.shared.previousTrack() }
|
||||||
|
)
|
||||||
|
case .tenSecond:
|
||||||
|
return ButtonConfig(
|
||||||
|
icon: "gobackward.10",
|
||||||
|
pressEffect: .wiggle(.counterClockwise),
|
||||||
|
symbolEffect: .wiggle,
|
||||||
|
action: { MusicManager.shared.seek(by: -seekInterval) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var forwardConfig: ButtonConfig {
|
||||||
|
switch musicSkipBehavior {
|
||||||
|
case .track:
|
||||||
|
return ButtonConfig(
|
||||||
|
icon: "forward.fill",
|
||||||
|
pressEffect: .nudge(skipPressMagnitude),
|
||||||
|
symbolEffect: .replace,
|
||||||
|
action: { MusicManager.shared.nextTrack() }
|
||||||
|
)
|
||||||
|
case .tenSecond:
|
||||||
|
return ButtonConfig(
|
||||||
|
icon: "goforward.10",
|
||||||
|
pressEffect: .wiggle(.clockwise),
|
||||||
|
symbolEffect: .wiggle,
|
||||||
|
action: { MusicManager.shared.seek(by: seekInterval) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var playPauseConfig: ButtonConfig {
|
||||||
|
ButtonConfig(
|
||||||
|
icon: musicManager.isPlaying ? "pause.fill" : "play.fill",
|
||||||
|
pressEffect: .none,
|
||||||
|
symbolEffect: .replace,
|
||||||
|
action: { MusicManager.shared.togglePlay() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 18) {
|
||||||
|
FloatingMediaButton(
|
||||||
|
icon: backwardConfig.icon,
|
||||||
|
frameSize: CGSize(width: buttonSide, height: buttonSide),
|
||||||
|
cornerRadius: buttonCornerRadius,
|
||||||
|
foregroundColor: .white.opacity(controlsEnabled ? 0.9 : 0.35),
|
||||||
|
pressEffect: backwardConfig.pressEffect,
|
||||||
|
symbolEffectStyle: backwardConfig.symbolEffect,
|
||||||
|
isEnabled: controlsEnabled,
|
||||||
|
action: backwardConfig.action
|
||||||
|
)
|
||||||
|
|
||||||
|
FloatingMediaButton(
|
||||||
|
icon: playPauseConfig.icon,
|
||||||
|
frameSize: CGSize(width: playPauseSide, height: playPauseSide),
|
||||||
|
cornerRadius: max(buttonCornerRadius + 2, playPauseSide * 0.32),
|
||||||
|
foregroundColor: .white,
|
||||||
|
pressEffect: playPauseConfig.pressEffect,
|
||||||
|
symbolEffectStyle: playPauseConfig.symbolEffect,
|
||||||
|
isEnabled: controlsEnabled,
|
||||||
|
action: playPauseConfig.action
|
||||||
|
)
|
||||||
|
|
||||||
|
FloatingMediaButton(
|
||||||
|
icon: forwardConfig.icon,
|
||||||
|
frameSize: CGSize(width: buttonSide, height: buttonSide),
|
||||||
|
cornerRadius: buttonCornerRadius,
|
||||||
|
foregroundColor: .white.opacity(controlsEnabled ? 0.9 : 0.35),
|
||||||
|
pressEffect: forwardConfig.pressEffect,
|
||||||
|
symbolEffectStyle: forwardConfig.symbolEffect,
|
||||||
|
isEnabled: controlsEnabled,
|
||||||
|
action: forwardConfig.action
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.frame(height: notchHeight)
|
||||||
|
.frame(minWidth: buttonSide * 3.2)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: windowCornerRadius, style: .continuous)
|
||||||
|
.fill(Color.black.opacity(0.88))
|
||||||
|
}
|
||||||
|
.compositingGroup()
|
||||||
|
.animation(.smooth(duration: 0.2), value: musicManager.isPlaying)
|
||||||
|
.animation(.smooth(duration: 0.2), value: musicSkipBehavior)
|
||||||
|
.animation(.smooth(duration: 0.2), value: controlsEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ButtonConfig {
|
||||||
|
let icon: String
|
||||||
|
let pressEffect: FloatingMediaButton.PressEffect
|
||||||
|
let symbolEffect: FloatingMediaButton.SymbolEffectStyle
|
||||||
|
let action: () -> Void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FloatingMediaButton: View {
|
||||||
|
let icon: String
|
||||||
|
let frameSize: CGSize
|
||||||
|
let cornerRadius: CGFloat
|
||||||
|
let foregroundColor: Color
|
||||||
|
let pressEffect: PressEffect
|
||||||
|
let symbolEffectStyle: SymbolEffectStyle
|
||||||
|
let isEnabled: Bool
|
||||||
|
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
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
guard isEnabled else { return }
|
||||||
|
triggerPressEffect()
|
||||||
|
action()
|
||||||
|
} label: {
|
||||||
|
iconView
|
||||||
|
.frame(width: frameSize.width, height: frameSize.height)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||||
|
.fill(isHovering && isEnabled ? Color.white.opacity(0.18) : .clear)
|
||||||
|
)
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.offset(x: pressOffset)
|
||||||
|
.rotationEffect(.degrees(rotationAngle))
|
||||||
|
.disabled(!isEnabled)
|
||||||
|
.onHover { hovering in
|
||||||
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
|
isHovering = hovering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconView: some View {
|
||||||
|
let image = Image(systemName: icon)
|
||||||
|
.font(.system(size: min(frameSize.width, frameSize.height) * 0.42, weight: .semibold))
|
||||||
|
.foregroundColor(foregroundColor)
|
||||||
|
|
||||||
|
switch symbolEffectStyle {
|
||||||
|
case .none:
|
||||||
|
return AnyView(image)
|
||||||
|
case .replace:
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
return AnyView(image.contentTransition(.symbolEffect(.replace)))
|
||||||
|
} else {
|
||||||
|
return AnyView(image)
|
||||||
|
}
|
||||||
|
case .replaceAndBounce:
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
return AnyView(
|
||||||
|
image
|
||||||
|
.contentTransition(.symbolEffect(.replace))
|
||||||
|
.symbolEffect(.bounce, value: icon)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return AnyView(image)
|
||||||
|
}
|
||||||
|
case .wiggle:
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
return AnyView(
|
||||||
|
image.symbolEffect(
|
||||||
|
.wiggle.byLayer,
|
||||||
|
options: .nonRepeating,
|
||||||
|
value: wiggleToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return AnyView(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PressEffect {
|
||||||
|
case none
|
||||||
|
case nudge(CGFloat)
|
||||||
|
case wiggle(WiggleDirection)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SymbolEffectStyle {
|
||||||
|
case none
|
||||||
|
case replace
|
||||||
|
case replaceAndBounce
|
||||||
|
case wiggle
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WiggleDirection {
|
||||||
|
case clockwise
|
||||||
|
case counterClockwise
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
MusicControlOverlay(notchHeight: 34, cornerRadius: 14)
|
||||||
|
.padding()
|
||||||
|
.background(Color.gray.opacity(0.2))
|
||||||
|
}
|
||||||
|
|
@ -858,6 +858,7 @@ struct Media: View {
|
||||||
@Default(.musicAuxLeftControl) private var musicAuxLeftControl
|
@Default(.musicAuxLeftControl) private var musicAuxLeftControl
|
||||||
@Default(.musicAuxRightControl) private var musicAuxRightControl
|
@Default(.musicAuxRightControl) private var musicAuxRightControl
|
||||||
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||||||
|
@Default(.musicControlWindowEnabled) private var musicControlWindowEnabled
|
||||||
@State private var previousLeftAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxLeftControl]
|
@State private var previousLeftAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxLeftControl]
|
||||||
@State private var previousRightAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxRightControl]
|
@State private var previousRightAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxRightControl]
|
||||||
|
|
||||||
|
|
@ -938,6 +939,12 @@ struct Media: View {
|
||||||
"Enable music live activity",
|
"Enable music live activity",
|
||||||
isOn: $coordinator.musicLiveActivityEnabled.animation()
|
isOn: $coordinator.musicLiveActivityEnabled.animation()
|
||||||
)
|
)
|
||||||
|
Defaults.Toggle(
|
||||||
|
"Show floating media controls",
|
||||||
|
key: .musicControlWindowEnabled
|
||||||
|
)
|
||||||
|
.disabled(!coordinator.musicLiveActivityEnabled)
|
||||||
|
.help("Displays play/pause and skip buttons beside the notch while music is active. Disabled by default.")
|
||||||
Toggle("Enable sneak peek", isOn: $enableSneakPeek)
|
Toggle("Enable sneak peek", isOn: $enableSneakPeek)
|
||||||
Defaults.Toggle("Enable lyrics", key: .enableLyrics)
|
Defaults.Toggle("Enable lyrics", key: .enableLyrics)
|
||||||
Picker("Sneak Peek Style", selection: $sneakPeekStyles){
|
Picker("Sneak Peek Style", selection: $sneakPeekStyles){
|
||||||
|
|
|
||||||
199
DynamicIsland/managers/MusicControlWindowManager.swift
Normal file
199
DynamicIsland/managers/MusicControlWindowManager.swift
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
import SkyLightWindow
|
||||||
|
import QuartzCore
|
||||||
|
|
||||||
|
struct MusicControlWindowMetrics {
|
||||||
|
let notchHeight: CGFloat
|
||||||
|
let notchWidth: CGFloat
|
||||||
|
let rightWingWidth: CGFloat
|
||||||
|
let cornerRadius: CGFloat
|
||||||
|
let spacing: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MusicControlWindowManager {
|
||||||
|
static let shared = MusicControlWindowManager()
|
||||||
|
|
||||||
|
private var window: NSWindow?
|
||||||
|
private var hostingView: NSHostingView<MusicControlOverlay>?
|
||||||
|
private var hasDelegated = false
|
||||||
|
private var lastMetrics: MusicControlWindowMetrics?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func present(using viewModel: DynamicIslandViewModel, metrics: MusicControlWindowMetrics) -> Bool {
|
||||||
|
guard let screen = resolveScreen(from: viewModel) else { return false }
|
||||||
|
guard viewModel.effectiveClosedNotchHeight > 0, viewModel.closedNotchSize.width > 0 else {
|
||||||
|
hide()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let overlay = MusicControlOverlay(
|
||||||
|
notchHeight: metrics.notchHeight,
|
||||||
|
cornerRadius: metrics.cornerRadius
|
||||||
|
)
|
||||||
|
let hosting = ensureHostingView(with: overlay)
|
||||||
|
let fittingSize = measuredSize(for: hosting)
|
||||||
|
hosting.frame = NSRect(origin: .zero, size: fittingSize)
|
||||||
|
|
||||||
|
let window = ensureWindow(on: screen)
|
||||||
|
if window.contentView !== hosting {
|
||||||
|
window.contentView = hosting
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetFrame = frame(for: fittingSize, viewModel: viewModel, screen: screen, metrics: metrics)
|
||||||
|
lastMetrics = metrics
|
||||||
|
|
||||||
|
if !hasDelegated {
|
||||||
|
SkyLightOperator.shared.delegateWindow(window)
|
||||||
|
hasDelegated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if window.alphaValue <= 0.01 {
|
||||||
|
let startFrame = initialFrame(for: targetFrame, metrics: metrics)
|
||||||
|
window.setFrame(startFrame, display: true)
|
||||||
|
window.alphaValue = 0
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.24
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||||
|
window.animator().setFrame(targetFrame, display: true)
|
||||||
|
window.animator().alphaValue = 1
|
||||||
|
} completionHandler: {
|
||||||
|
window.setFrame(targetFrame, display: false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.16
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
|
window.animator().setFrame(targetFrame, display: true)
|
||||||
|
} completionHandler: {
|
||||||
|
window.setFrame(targetFrame, display: false)
|
||||||
|
}
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
window.alphaValue = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func refresh(using viewModel: DynamicIslandViewModel, metrics: MusicControlWindowMetrics) -> Bool {
|
||||||
|
guard window != nil else {
|
||||||
|
return present(using: viewModel, metrics: metrics)
|
||||||
|
}
|
||||||
|
return present(using: viewModel, metrics: metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hide(animated: Bool = true) {
|
||||||
|
guard let window else { return }
|
||||||
|
|
||||||
|
guard animated, window.alphaValue > 0.01 else {
|
||||||
|
window.orderOut(nil)
|
||||||
|
window.alphaValue = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let retreatFrame = notchRetreatFrame(from: window.frame)
|
||||||
|
let originalFrame = window.frame
|
||||||
|
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.18
|
||||||
|
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||||||
|
window.animator().setFrame(retreatFrame, display: true)
|
||||||
|
window.animator().alphaValue = 0
|
||||||
|
} completionHandler: {
|
||||||
|
window.setFrame(originalFrame, display: false)
|
||||||
|
window.orderOut(nil)
|
||||||
|
window.alphaValue = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureHostingView(with overlay: MusicControlOverlay) -> NSHostingView<MusicControlOverlay> {
|
||||||
|
if let hostingView {
|
||||||
|
hostingView.rootView = overlay
|
||||||
|
return hostingView
|
||||||
|
}
|
||||||
|
let view = NSHostingView(rootView: overlay)
|
||||||
|
hostingView = view
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureWindow(on screen: NSScreen) -> NSWindow {
|
||||||
|
if let window {
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: screen.frame.midX, y: screen.frame.midY, width: 240, height: 68),
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.isOpaque = false
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.hasShadow = false
|
||||||
|
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
|
||||||
|
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
||||||
|
window.ignoresMouseEvents = false
|
||||||
|
window.isMovable = false
|
||||||
|
window.alphaValue = 0
|
||||||
|
|
||||||
|
self.window = window
|
||||||
|
self.hasDelegated = false
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
private func measuredSize(for hosting: NSHostingView<MusicControlOverlay>) -> CGSize {
|
||||||
|
hosting.layoutSubtreeIfNeeded()
|
||||||
|
let size = hosting.fittingSize
|
||||||
|
return CGSize(width: ceil(size.width), height: ceil(size.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveScreen(from viewModel: DynamicIslandViewModel) -> NSScreen? {
|
||||||
|
if let screenName = viewModel.screen,
|
||||||
|
let targetScreen = NSScreen.screens.first(where: { $0.localizedName == screenName }) {
|
||||||
|
return targetScreen
|
||||||
|
}
|
||||||
|
return NSScreen.main
|
||||||
|
}
|
||||||
|
|
||||||
|
private func frame(for size: CGSize, viewModel: DynamicIslandViewModel, screen: NSScreen, metrics: MusicControlWindowMetrics) -> NSRect {
|
||||||
|
let screenFrame = screen.frame
|
||||||
|
let notchOriginX = screenFrame.midX - (metrics.notchWidth / 2)
|
||||||
|
let originY = screenFrame.maxY - size.height
|
||||||
|
|
||||||
|
let rightEdge = notchOriginX + metrics.notchWidth + metrics.rightWingWidth
|
||||||
|
let rawOriginX = rightEdge + metrics.spacing
|
||||||
|
|
||||||
|
let clampedOriginX = max(screenFrame.minX + 8, min(rawOriginX, screenFrame.maxX - size.width - 8))
|
||||||
|
|
||||||
|
return NSRect(x: clampedOriginX, y: originY, width: size.width, height: size.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initialFrame(for targetFrame: NSRect, metrics: MusicControlWindowMetrics) -> NSRect {
|
||||||
|
var frame = targetFrame
|
||||||
|
let notchRightEdge = targetFrame.minX - metrics.spacing
|
||||||
|
frame.origin.x = notchRightEdge - targetFrame.width
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notchRetreatFrame(from currentFrame: NSRect) -> NSRect {
|
||||||
|
guard let metrics = lastMetrics else {
|
||||||
|
return currentFrame.offsetBy(dx: -currentFrame.width * 0.75, dy: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let notchRightEdge = currentFrame.minX - metrics.spacing
|
||||||
|
var frame = currentFrame
|
||||||
|
frame.origin.x = notchRightEdge - currentFrame.width
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -486,8 +486,20 @@ class MusicManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func togglePlay() {
|
func togglePlay() {
|
||||||
|
let controller = activeController
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await activeController?.togglePlay()
|
await MainActor.run {
|
||||||
|
let newState = !isPlaying
|
||||||
|
withAnimation(.smooth(duration: 0.18)) {
|
||||||
|
isPlaying = newState
|
||||||
|
if newState {
|
||||||
|
isPlayerIdle = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await controller?.togglePlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,7 @@ extension Defaults.Keys {
|
||||||
static let musicAuxRightControl = Key<MusicAuxiliaryControl>("musicAuxRightControl", default: .repeatMode)
|
static let musicAuxRightControl = Key<MusicAuxiliaryControl>("musicAuxRightControl", default: .repeatMode)
|
||||||
static let didMigrateMusicAuxControls = Key<Bool>("didMigrateMusicAuxControls", default: false)
|
static let didMigrateMusicAuxControls = Key<Bool>("didMigrateMusicAuxControls", default: false)
|
||||||
static let musicSkipBehavior = Key<MusicSkipBehavior>("musicSkipBehavior", default: .track)
|
static let musicSkipBehavior = Key<MusicSkipBehavior>("musicSkipBehavior", default: .track)
|
||||||
|
static let musicControlWindowEnabled = Key<Bool>("musicControlWindowEnabled", default: false)
|
||||||
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
||||||
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
||||||
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue