Merge origin/main into bug/terminal-bugs

This commit is contained in:
Hariharan Mudaliar 2026-04-27 10:35:39 +05:30
commit cdf0785e29
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
22 changed files with 1363 additions and 353 deletions

1
.gitignore vendored
View file

@ -158,3 +158,4 @@ mew-notch/
/References/ /References/
/builds/ /builds/
/appdmg.json /appdmg.json
COMPREHENSIVE_DOCUMENTATION.md

View file

@ -454,6 +454,7 @@
AUTOMATION_APPLE_EVENTS = NO; AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 12; CURRENT_PROJECT_VERSION = 12;

View file

@ -234,6 +234,64 @@ struct ContentView: View {
private var closedMusicContentEnabled: Bool { private var closedMusicContentEnabled: Bool {
enableMinimalisticUI || showStandardMediaControls enableMinimalisticUI || showStandardMediaControls
} }
private var isMusicHUDDeferredAfterUnlock: Bool {
lockScreenManager.shouldDelayPostUnlockMusicHUD
}
private var interactionsEnabled: Bool {
!lockScreenManager.isLocked
}
private var isIslandMode: Bool {
isDynamicIslandMode
}
private var notchHorizontalPadding: CGFloat {
guard vm.notchState == .open else {
return activeCornerRadiusInsets.closed.bottom
}
if Defaults[.cornerRadiusScaling] {
return activeCornerRadiusInsets.opened.top - 5
}
return activeCornerRadiusInsets.opened.bottom - 5
}
private var bodyHoverAreaPadding: CGFloat {
if vm.notchState == .open && Defaults[.extendHoverArea] {
return 0
}
return vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0
}
private var notchBottomPadding: CGFloat {
currentShadowPadding + bodyHoverAreaPadding
}
private var pillTopOffset: CGFloat {
isIslandMode ? dynamicIslandTopOffset : 0
}
private func closedMusicPairingEligible(hasActiveMusicSnapshot: Bool) -> Bool {
vm.notchState == .closed
&& hasActiveMusicSnapshot
&& coordinator.musicLiveActivityEnabled
&& closedMusicContentEnabled
&& !vm.hideOnClosed
&& !lockScreenManager.isLocked
&& !isMusicHUDDeferredAfterUnlock
}
private var closedLiveActivitySwapTransition: AnyTransition {
.asymmetric(
insertion: .opacity
.combined(with: .scale(scale: 0.965, anchor: .center))
.animation(.spring(response: 0.34, dampingFraction: 0.88)),
removal: .opacity
.combined(with: .scale(scale: 0.92, anchor: .center))
.animation(.smooth(duration: 0.22))
)
}
// 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)) {
@ -334,335 +392,348 @@ struct ContentView: View {
} }
var body: some View { var body: some View {
let interactionsEnabled = !lockScreenManager.isLocked installRootLifecycleHandlers(on: rootBodyView)
let isIslandMode = isDynamicIslandMode }
let notchHorizontalPadding: CGFloat = {
guard vm.notchState == .open else {
return activeCornerRadiusInsets.closed.bottom
}
if Defaults[.cornerRadiusScaling] {
return activeCornerRadiusInsets.opened.top - 5
}
return activeCornerRadiusInsets.opened.bottom - 5
}()
let hoverAreaPadding: CGFloat = {
if vm.notchState == .open && Defaults[.extendHoverArea] {
return 0
}
return vm.effectiveClosedNotchHeight == 0 ? zeroHeightHoverPadding : 0
}()
let notchBottomPadding = currentShadowPadding + hoverAreaPadding
// Extra top padding to detach pill from screen edge in Dynamic Island mode
let pillTopOffset: CGFloat = isIslandMode ? dynamicIslandTopOffset : 0
ZStack(alignment: .top) { private var mainLayoutBase: some View {
let mainLayout = NotchLayout() NotchLayout()
.frame(alignment: .top) .frame(alignment: .top)
.padding(.horizontal, notchHorizontalPadding) .padding(.horizontal, notchHorizontalPadding)
.padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0)
.background(.black) .background(.black)
.clipShape(resolvedClipShape) .clipShape(resolvedClipShape)
.compositingGroup() .compositingGroup()
.shadow( .shadow(
color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow])
? .black.opacity(0.6) ? .black.opacity(0.6)
: .clear, : .clear,
radius: Defaults[.cornerRadiusScaling] ? 10 : 5 radius: Defaults[.cornerRadiusScaling] ? 10 : 5
) )
// Extra horizontal inset for Dynamic Island mode so the shadow // Extra horizontal inset for Dynamic Island mode so the shadow
// is not clipped by the outer frame constraint // is not clipped by the outer frame constraint
.padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0) .padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0)
.padding(.top, pillTopOffset) .padding(.top, pillTopOffset)
}
mainLayout private var configuredMainLayout: some View {
.conditionalModifier(!useModernCloseAnimation) { view in mainLayoutBase
let hoverAnimation = Animation.bouncy.speed(1.2) .conditionalModifier(!useModernCloseAnimation) { view in
let notchStateAnimation = Animation.spring.speed(1.2) let hoverAnimation = Animation.bouncy.speed(1.2)
return view let notchStateAnimation = Animation.spring.speed(1.2)
.animation(hoverAnimation, value: isHovering) return view
.animation(notchStateAnimation, value: vm.notchState) .animation(hoverAnimation, value: isHovering)
.animation(.smooth, value: gestureProgress) .animation(notchStateAnimation, value: vm.notchState)
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) .animation(.smooth, value: gestureProgress)
} .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
.conditionalModifier(useModernCloseAnimation) { view in }
let hoverAnimation = Animation.bouncy.speed(1.2) .conditionalModifier(useModernCloseAnimation) { view in
let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0) let hoverAnimation = Animation.bouncy.speed(1.2)
let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0) let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0)
let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0)
return view let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation
.animation(hoverAnimation, value: isHovering) return view
.animation(notchAnimation, value: vm.notchState) .animation(hoverAnimation, value: isHovering)
.animation(.smooth, value: gestureProgress) .animation(notchAnimation, value: vm.notchState)
} .animation(.smooth, value: gestureProgress)
.conditionalModifier(interactionsEnabled) { view in }
view .conditionalModifier(interactionsEnabled) { view in
.contentShape(resolvedClipShape) view
.onHover { hovering in .contentShape(resolvedClipShape)
handleHover(hovering) .onHover { hovering in
handleHover(hovering)
}
.onTapGesture {
if handleClosedMusicWaveformTapIfNeeded() {
return
} }
.onTapGesture { if vm.notchState == .closed && Defaults[.enableHaptics] {
if handleClosedMusicWaveformTapIfNeeded() { triggerHapticIfAllowed()
return }
openNotch()
}
.conditionalModifier(Defaults[.enableGestures]) { view in
view
.panGesture(direction: .down) { translation, phase in
handleDownGesture(translation: translation, phase: phase)
} }
if vm.notchState == .closed && Defaults[.enableHaptics] { .panGesture(direction: .left) { translation, phase in
triggerHapticIfAllowed() handleSkipGesture(direction: .forward, translation: translation, phase: phase)
} }
.panGesture(direction: .right) { translation, phase in
handleSkipGesture(direction: .backward, translation: translation, phase: phase)
}
}
}
.conditionalModifier((Defaults[.closeGestureEnabled] || Defaults[.reverseScrollGestures]) && Defaults[.enableGestures] && interactionsEnabled) { view in
view
.panGesture(direction: .up) { translation, phase in
handleUpGesture(translation: translation, phase: phase)
}
}
// Shadow bottom padding and hide-until-hover offset applied AFTER
// interaction modifiers so .contentShape / .onHover only covers
// the actual notch content, not the shadow clearance below it.
.padding(.bottom, notchBottomPadding)
.offset(y: shouldHideUntilHover && !isHovering
? -(vm.closedNotchSize.height + pillTopOffset + currentShadowPadding + 10)
: 0
)
.onAppear(perform: {
if coordinator.firstLaunch {
// Single open during first launch; closeHello() handles the timed close.
runAfter(1) {
withAnimation(vm.animation) {
openNotch() openNotch()
} }
.conditionalModifier(Defaults[.enableGestures]) { view in
view
.panGesture(direction: .down) { translation, phase in
handleDownGesture(translation: translation, phase: phase)
}
.panGesture(direction: .left) { translation, phase in
handleSkipGesture(direction: .forward, translation: translation, phase: phase)
}
.panGesture(direction: .right) { translation, phase in
handleSkipGesture(direction: .backward, translation: translation, phase: phase)
}
}
}
.conditionalModifier((Defaults[.closeGestureEnabled] || Defaults[.reverseScrollGestures]) && Defaults[.enableGestures] && interactionsEnabled) { view in
view
.panGesture(direction: .up) { translation, phase in
handleUpGesture(translation: translation, phase: phase)
}
}
// Shadow bottom padding and hide-until-hover offset applied AFTER
// interaction modifiers so .contentShape / .onHover only covers
// the actual notch content, not the shadow clearance below it.
.padding(.bottom, notchBottomPadding)
.offset(y: shouldHideUntilHover && !isHovering
? -(vm.closedNotchSize.height + pillTopOffset + currentShadowPadding + 10)
: 0
)
.onAppear(perform: {
if coordinator.firstLaunch {
// Single open during first launch; closeHello() handles the timed close.
runAfter(1) {
withAnimation(vm.animation) {
openNotch()
}
}
}
})
.onChange(of: vm.notchState) { _, newState in
// Update smart monitoring based on notch state
if enableStatsFeature {
let currentViewString = coordinator.currentView == .stats ? "stats" : "other"
statsManager.updateMonitoringState(
notchIsOpen: newState == .open,
currentView: currentViewString
)
}
// Reset hover state when notch state changes
if newState == .closed && isHovering {
withAnimation {
isHovering = false
}
}
if newState != .closed {
isHoveringClosedMusicWaveformControl = false
}
if newState == .closed {
removeStickyTerminalClickMonitor()
}
#if os(macOS)
if newState == .open {
TimerControlWindowManager.shared.hide()
}
#endif
}
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
runAfter(0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() {
vm.close()
}
} }
} }
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in })
runAfter(0.1) { .onChange(of: vm.notchState) { _, newState in
if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() { // Update smart monitoring based on notch state
vm.close() if enableStatsFeature {
} let currentViewString = coordinator.currentView == .stats ? "stats" : "other"
} statsManager.updateMonitoringState(
} notchIsOpen: newState == .open,
.onChange(of: vm.shouldRecheckHover) { _, _ in currentView: currentViewString
// Recheck hover state when popovers are closed )
runAfter(0.1) {
if vm.notchState == .open && !shouldPreventAutoClose() && !isHovering {
vm.close()
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .sharingDidFinish)) { _ in
runAfter(0.1) {
if vm.notchState == .open && !isHovering && !shouldPreventAutoClose() {
vm.close()
}
}
}
.onChange(of: coordinator.sneakPeek.show) { _, sneakPeekShowing in
// When sneak peek finishes, check if user is still hovering and open notch if needed
if !sneakPeekShowing {
runAfter(0.2) {
if isHovering && vm.notchState == .closed && !coordinator.isHoverOpenSuppressed {
openNotch()
}
}
}
}
.onChange(of: coordinator.currentView) { _, newValue in
if enableStatsFeature {
let currentViewString = newValue == .stats ? "stats" : "other"
statsManager.updateMonitoringState(
notchIsOpen: vm.notchState == .open,
currentView: currentViewString
)
}
syncStickyTerminalOutsideClickMonitor()
}
.sensoryFeedback(.alignment, trigger: haptics)
.contextMenu {
Button("Settings") {
SettingsWindowController.shared.showWindow()
}
// Button("Edit") { // Doesnt work....
// let dn = DynamicNotch(content: EditPanelView())
// dn.toggle()
// }
// #if DEBUG
// .disabled(false)
// #else
// .disabled(true)
// #endif
// .keyboardShortcut("E", modifiers: .command)
} }
// Reset hover state when notch state changes
if newState == .closed && isHovering {
withAnimation {
isHovering = false
}
}
if newState != .closed {
isHoveringClosedMusicWaveformControl = false
}
if newState == .closed {
removeStickyTerminalClickMonitor()
}
#if os(macOS)
if newState == .open {
TimerControlWindowManager.shared.hide()
}
#endif
}
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
runAfter(0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() {
vm.close()
}
}
}
.onChange(of: vm.isStatsPopoverActive) { _, newPopoverState in
runAfter(0.1) {
if !newPopoverState && !isHovering && vm.notchState == .open && !shouldPreventAutoClose() {
vm.close()
}
}
}
.onChange(of: vm.shouldRecheckHover) { _, _ in
// Recheck hover state when popovers are closed
runAfter(0.1) {
if vm.notchState == .open && !shouldPreventAutoClose() && !isHovering {
vm.close()
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .sharingDidFinish)) { _ in
runAfter(0.1) {
if vm.notchState == .open && !isHovering && !shouldPreventAutoClose() {
vm.close()
}
}
}
.onChange(of: coordinator.sneakPeek.show) { _, sneakPeekShowing in
// When sneak peek finishes, check if user is still hovering and open notch if needed
if !sneakPeekShowing {
runAfter(0.2) {
if isHovering && vm.notchState == .closed && !coordinator.isHoverOpenSuppressed {
openNotch()
}
}
}
}
.onChange(of: coordinator.currentView) { _, newValue in
if enableStatsFeature {
let currentViewString = newValue == .stats ? "stats" : "other"
statsManager.updateMonitoringState(
notchIsOpen: vm.notchState == .open,
currentView: currentViewString
)
}
syncStickyTerminalOutsideClickMonitor()
}
.sensoryFeedback(.alignment, trigger: haptics)
.contextMenu {
Button("Settings") {
SettingsWindowController.shared.showWindow()
}
// Button("Edit") { // Doesnt work....
// let dn = DynamicNotch(content: EditPanelView())
// dn.toggle()
// }
// #if DEBUG
// .disabled(false)
// #else
// .disabled(true)
// #endif
// .keyboardShortcut("E", modifiers: .command)
}
}
private var rootBodyView: some View {
ZStack(alignment: .top) {
configuredMainLayout
} }
.frame( .frame(
maxWidth: dynamicNotchSize.width + (isDynamicIslandMode ? dynamicIslandShadowInset * 2 : 0), maxWidth: dynamicNotchSize.width + (isDynamicIslandMode ? dynamicIslandShadowInset * 2 : 0),
maxHeight: dynamicNotchSize.height + currentShadowPadding + (isDynamicIslandMode ? dynamicIslandTopOffset : 0), maxHeight: dynamicNotchSize.height + currentShadowPadding + (isDynamicIslandMode ? dynamicIslandTopOffset : 0),
alignment: .top alignment: .top
) )
.frame(maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.environmentObject(privacyManager) .environmentObject(privacyManager)
.background(dragDetector) .background(dragDetector)
.environmentObject(vm) .environmentObject(vm)
.environmentObject(webcamManager) .environmentObject(webcamManager)
.onAppear { }
isMusicControlWindowSuppressed = vm.notchState != .closed || lockScreenManager.isLocked
if musicManager.isPlaying || !musicManager.isPlayerIdle {
clearMusicControlVisibilityDeadline()
}
if let deadline = musicControlVisibilityDeadline, Date() > deadline {
clearMusicControlVisibilityDeadline()
}
enqueueMusicControlWindowSync(forceRefresh: true)
startHiddenEdgeHoverPolling()
}
.onChange(of: terminalStickyMode) { _, _ in
syncStickyTerminalOutsideClickMonitor()
}
.onChange(of: vm.notchState) { _, state in
if state == .open {
suppressMusicControlWindowUpdates()
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
releaseMusicControlWindowUpdates(after: musicControlResumeDelay)
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
} private func installRootLifecycleHandlers<Content: View>(on view: Content) -> some View {
.onChange(of: musicControlWindowEnabled) { _, enabled in installSecondaryRootLifecycleHandlers(
if enabled { on: installPrimaryRootLifecycleHandlers(on: view)
)
}
private func installPrimaryRootLifecycleHandlers<Content: View>(on view: Content) -> some View {
view
.onAppear {
isMusicControlWindowSuppressed = vm.notchState != .closed
|| lockScreenManager.isLocked
|| isMusicHUDDeferredAfterUnlock
if musicManager.isPlaying || !musicManager.isPlayerIdle { if musicManager.isPlaying || !musicManager.isPlayerIdle {
clearMusicControlVisibilityDeadline() clearMusicControlVisibilityDeadline()
} }
if let deadline = musicControlVisibilityDeadline, Date() > deadline {
clearMusicControlVisibilityDeadline()
}
enqueueMusicControlWindowSync(forceRefresh: true) enqueueMusicControlWindowSync(forceRefresh: true)
} else { startHiddenEdgeHoverPolling()
}
.onChange(of: terminalStickyMode) { _, _ in
syncStickyTerminalOutsideClickMonitor()
}
.onChange(of: vm.notchState) { _, state in
if state == .open {
suppressMusicControlWindowUpdates()
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
releaseMusicControlWindowUpdates(after: musicControlResumeDelay)
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: musicControlWindowEnabled) { _, enabled in
if enabled {
if musicManager.isPlaying || !musicManager.isPlayerIdle {
clearMusicControlVisibilityDeadline()
}
enqueueMusicControlWindowSync(forceRefresh: true)
} else {
cancelMusicControlWindowSync()
hideMusicControlWindow()
clearMusicControlVisibilityDeadline()
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
}
}
.onChange(of: coordinator.musicLiveActivityEnabled) { _, enabled in
if enabled {
enqueueMusicControlWindowSync(forceRefresh: true)
} else {
cancelMusicControlWindowSync()
hideMusicControlWindow()
clearMusicControlVisibilityDeadline()
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
}
}
.onChange(of: vm.hideOnClosed) { _, hidden in
if hidden {
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: lockScreenManager.isLocked) { _, locked in
if locked {
suppressMusicControlWindowUpdates()
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
releaseMusicControlWindowUpdates(after: musicControlResumeDelay)
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: lockScreenManager.shouldDelayPostUnlockMusicHUD) { _, deferred in
if deferred {
suppressMusicControlWindowUpdates()
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
releaseMusicControlWindowUpdates(after: 0)
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
}
private func installSecondaryRootLifecycleHandlers<Content: View>(on view: Content) -> some View {
view
.onChange(of: showStandardMediaControls) { _, _ in
handleStandardMediaControlsAvailabilityChange()
}
.onChange(of: enableMinimalisticUI) { _, _ in
handleStandardMediaControlsAvailabilityChange()
}
.onChange(of: gestureProgress) { _, _ in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: isHovering) { _, hovering in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(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() {
enqueueMusicControlWindowSync(forceRefresh: true)
}
}
.onChange(of: vm.effectiveClosedNotchHeight) { _, _ in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(forceRefresh: true)
}
}
.onDisappear {
hoverTask?.cancel()
stopHoverClickMonitor()
removeStickyTerminalClickMonitor()
stopHiddenEdgeHoverPolling()
cancelMusicControlWindowSync() cancelMusicControlWindowSync()
hideMusicControlWindow() hideMusicControlWindow()
cancelMusicControlVisibilityTimer()
clearMusicControlVisibilityDeadline() clearMusicControlVisibilityDeadline()
hasPendingMusicControlSync = false musicControlSuppressionTask?.cancel()
pendingMusicControlForceRefresh = false isHoveringClosedMusicWaveformControl = false
} }
}
.onChange(of: coordinator.musicLiveActivityEnabled) { _, enabled in
if enabled {
enqueueMusicControlWindowSync(forceRefresh: true)
} else {
cancelMusicControlWindowSync()
hideMusicControlWindow()
clearMusicControlVisibilityDeadline()
hasPendingMusicControlSync = false
pendingMusicControlForceRefresh = false
}
}
.onChange(of: vm.hideOnClosed) { _, hidden in
if hidden {
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: lockScreenManager.isLocked) { _, locked in
if locked {
suppressMusicControlWindowUpdates()
cancelMusicControlWindowSync()
hideMusicControlWindow()
} else {
releaseMusicControlWindowUpdates(after: musicControlResumeDelay)
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: showStandardMediaControls) { _, _ in
handleStandardMediaControlsAvailabilityChange()
}
.onChange(of: enableMinimalisticUI) { _, _ in
handleStandardMediaControlsAvailabilityChange()
}
.onChange(of: gestureProgress) { _, _ in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(forceRefresh: true, delay: 0.05)
}
}
.onChange(of: isHovering) { _, hovering in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(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() {
enqueueMusicControlWindowSync(forceRefresh: true)
}
}
.onChange(of: vm.effectiveClosedNotchHeight) { _, _ in
if shouldShowMusicControlWindow() {
enqueueMusicControlWindowSync(forceRefresh: true)
}
}
.onDisappear {
hoverTask?.cancel()
stopHoverClickMonitor()
removeStickyTerminalClickMonitor()
stopHiddenEdgeHoverPolling()
cancelMusicControlWindowSync()
hideMusicControlWindow()
cancelMusicControlVisibilityTimer()
clearMusicControlVisibilityDeadline()
musicControlSuppressionTask?.cancel()
isHoveringClosedMusicWaveformControl = false
}
} }
@ViewBuilder @ViewBuilder
@ -683,12 +754,7 @@ struct ContentView: View {
if musicManager.isPlaying { return true } if musicManager.isPlaying { return true }
return !musicManager.isPlayerIdle && hasMusicMetadata return !musicManager.isPlayerIdle && hasMusicMetadata
}() }()
let musicPairingEligible = vm.notchState == .closed let musicPairingEligible = closedMusicPairingEligible(hasActiveMusicSnapshot: hasActiveMusicSnapshot)
&& hasActiveMusicSnapshot
&& coordinator.musicLiveActivityEnabled
&& closedMusicContentEnabled
&& !vm.hideOnClosed
&& !lockScreenManager.isLocked
let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible) let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible)
let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary) let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary)
let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID) let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID)
@ -750,6 +816,8 @@ struct ContentView: View {
.transition(AnyTransition.move(edge: .trailing).combined(with: .opacity)) .transition(AnyTransition.move(edge: .trailing).combined(with: .opacity))
} else if canShowMusicDuringExpansion && musicPairingEligible { } else if canShowMusicDuringExpansion && musicPairingEligible {
MusicLiveActivity(secondary: musicSecondary) MusicLiveActivity(secondary: musicSecondary)
.id("closed-music-live-activity")
.transition(closedLiveActivitySwapTransition)
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed {
TimerLiveActivity() TimerLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed {
@ -766,6 +834,8 @@ struct ContentView: View {
DoNotDisturbLiveActivity() DoNotDisturbLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed {
LockScreenLiveActivity() LockScreenLiveActivity()
.id("lock-screen-live-activity")
.transition(closedLiveActivitySwapTransition)
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed {
PrivacyLiveActivity() PrivacyLiveActivity()
} else if let extensionPayload = extensionStandalonePayload { } else if let extensionPayload = extensionStandalonePayload {
@ -970,15 +1040,17 @@ struct ContentView: View {
@ViewBuilder @ViewBuilder
func DynamicIslandFaceAnimation() -> some View { func DynamicIslandFaceAnimation() -> some View {
let sideSize = max(0, vm.effectiveClosedNotchHeight - 12)
HStack { HStack {
HStack { HStack {
Rectangle() Rectangle()
.fill(.clear) .fill(.clear)
.frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) .frame(width: sideSize, height: sideSize)
Rectangle() Rectangle()
.fill(.black) .fill(.black)
.frame(width: vm.closedNotchSize.width - 20) .frame(width: vm.closedNotchSize.width - 20)
IdleAnimationView() IdleAnimationView()
.frame(width: sideSize, height: sideSize)
} }
}.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) }.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
} }
@ -1034,11 +1106,13 @@ struct ContentView: View {
.overlay( .overlay(
HStack(alignment: .top) { HStack(alignment: .top) {
if(coordinator.expandingView.show && coordinator.expandingView.type == .music) { if(coordinator.expandingView.show && coordinator.expandingView.type == .music) {
MarqueeText( MusicTitleMarqueeView(
.constant(musicManager.songTitle), text: musicManager.songTitle,
isExplicit: musicManager.isCurrentTrackExplicit,
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
minDuration: 0.4, minDuration: 0.4,
frameWidth: max(0, (effectiveCenterWidth - vm.closedNotchSize.width) / 2 - 12) frameWidth: max(0, (effectiveCenterWidth - vm.closedNotchSize.width) / 2 - 12),
badgeHeight: 13
) )
.padding(.leading, 8) .padding(.leading, 8)
.opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
@ -1434,6 +1508,15 @@ struct ContentView: View {
return nil return nil
} }
guard !isMusicHUDDeferredAfterUnlock else {
ExtensionRoutingDiagnostics.shared.logSuppression(
.music,
reason: "waiting for lock screen unlock animation to finish",
pendingCount: candidates.count
)
return nil
}
guard coordinator.musicLiveActivityEnabled else { guard coordinator.musicLiveActivityEnabled else {
ExtensionRoutingDiagnostics.shared.logSuppression( ExtensionRoutingDiagnostics.shared.logSuppression(
.music, .music,
@ -1645,6 +1728,7 @@ struct ContentView: View {
&& closedMusicContentEnabled && closedMusicContentEnabled
&& !vm.hideOnClosed && !vm.hideOnClosed
&& !lockScreenManager.isLocked && !lockScreenManager.isLocked
&& !isMusicHUDDeferredAfterUnlock
&& !coordinator.expandingView.show && !coordinator.expandingView.show
&& (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil) && (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil)
&& !coordinator.firstLaunch && !coordinator.firstLaunch
@ -2202,7 +2286,7 @@ struct ContentView: View {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
if vm.notchState == .closed && !lockScreenManager.isLocked { if vm.notchState == .closed && !lockScreenManager.isLocked && !isMusicHUDDeferredAfterUnlock {
isMusicControlWindowSuppressed = false isMusicControlWindowSuppressed = false
triggerPendingMusicControlSyncIfNeeded() triggerPendingMusicControlSyncIfNeeded()
} else { } else {
@ -2225,7 +2309,10 @@ struct ContentView: View {
} }
private func shouldDeferMusicControlSync() -> Bool { private func shouldDeferMusicControlSync() -> Bool {
vm.notchState != .closed || lockScreenManager.isLocked || isMusicControlWindowSuppressed vm.notchState != .closed
|| lockScreenManager.isLocked
|| isMusicHUDDeferredAfterUnlock
|| isMusicControlWindowSuppressed
} }
private func enqueueMusicControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0) { private func enqueueMusicControlWindowSync(forceRefresh: Bool, delay: TimeInterval = 0) {
@ -2249,6 +2336,7 @@ struct ContentView: View {
vm.notchState == .closed, vm.notchState == .closed,
!vm.hideOnClosed, !vm.hideOnClosed,
!lockScreenManager.isLocked, !lockScreenManager.isLocked,
!isMusicHUDDeferredAfterUnlock,
!isMusicControlWindowSuppressed else { !isMusicControlWindowSuppressed else {
return false return false
} }
@ -2267,7 +2355,7 @@ struct ContentView: View {
return return
} }
if !bypassSuppression && (isMusicControlWindowSuppressed || lockScreenManager.isLocked) { if !bypassSuppression && (isMusicControlWindowSuppressed || lockScreenManager.isLocked || isMusicHUDDeferredAfterUnlock) {
hasPendingMusicControlSync = true hasPendingMusicControlSync = true
if forceRefresh { if forceRefresh {
pendingMusicControlForceRefresh = true pendingMusicControlForceRefresh = true

View file

@ -2314,6 +2314,7 @@
}, },
"±10 Seconds" : { "±10 Seconds" : {
"comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -3137,6 +3138,7 @@
}, },
"Accent" : { "Accent" : {
"comment" : "Display name for the caps lock indicator tint mode.", "comment" : "Display name for the caps lock indicator tint mode.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -3694,6 +3696,7 @@
}, },
"Adaptive gradient" : { "Adaptive gradient" : {
"comment" : "Display name for the timer icon color mode: adaptive gradient.", "comment" : "Display name for the timer icon color mode: adaptive gradient.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -6016,6 +6019,7 @@
}, },
"Apple Music" : { "Apple Music" : {
"comment" : "Media controller type for Apple Music", "comment" : "Media controller type for Apple Music",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -6919,6 +6923,7 @@
}, },
"Bar" : { "Bar" : {
"comment" : "Name of the timer progress style that shows a horizontal bar.", "comment" : "Name of the timer progress style that shows a horizontal bar.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -14519,6 +14524,7 @@
}, },
"Digital" : { "Digital" : {
"comment" : "The display name for the \"Digital\" reminder presentation style.", "comment" : "The display name for the \"Digital\" reminder presentation style.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -20826,6 +20832,7 @@
}, },
"Green" : { "Green" : {
"comment" : "Display name for the green caps lock indicator tint mode.", "comment" : "Display name for the green caps lock indicator tint mode.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -29940,6 +29947,7 @@
}, },
"Now Playing" : { "Now Playing" : {
"comment" : "Name of the media controller type for the \"Now Playing\" option.", "comment" : "Name of the media controller type for the \"Now Playing\" option.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -30685,6 +30693,7 @@
}, },
"Panel" : { "Panel" : {
"comment" : "Display name for the clipboard display mode \"Panel\".", "comment" : "Display name for the clipboard display mode \"Panel\".",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -34227,6 +34236,7 @@
}, },
"Ring" : { "Ring" : {
"comment" : "The name of the timer progress style that shows a ring shape.", "comment" : "The name of the timer progress style that shows a ring shape.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -35748,6 +35758,7 @@
}, },
"Separate Tab" : { "Separate Tab" : {
"comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.", "comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -38180,6 +38191,7 @@
}, },
"Shows screen assistant as a dropdown attached to the AI button" : { "Shows screen assistant as a dropdown attached to the AI button" : {
"comment" : "Description of the screen assistant display mode option that shows the screen assistant as a dropdown attached to the AI button.", "comment" : "Description of the screen assistant display mode option that shows the screen assistant as a dropdown attached to the AI button.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -38204,6 +38216,7 @@
}, },
"Shows screen assistant in a floating panel near the notch" : { "Shows screen assistant in a floating panel near the notch" : {
"comment" : "Description of the screen assistant display mode option that shows the screen assistant in a floating panel near the notch.", "comment" : "Description of the screen assistant display mode option that shows the screen assistant in a floating panel near the notch.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -38431,6 +38444,7 @@
}, },
"Skip forward or backward by ten seconds" : { "Skip forward or backward by ten seconds" : {
"comment" : "Description of a music skip behavior option.", "comment" : "Description of a music skip behavior option.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -39154,6 +39168,7 @@
}, },
"Spotify" : { "Spotify" : {
"comment" : "Spotify media controller type.", "comment" : "Spotify media controller type.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -39390,6 +39405,7 @@
}, },
"Standard previous/next track controls" : { "Standard previous/next track controls" : {
"comment" : "Description of the music skip behavior option to use standard previous/next track controls.", "comment" : "Description of the music skip behavior option to use standard previous/next track controls.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -42220,6 +42236,7 @@
}, },
"Track Skip" : { "Track Skip" : {
"comment" : "Display name for the \"Track Skip\" music skip behavior.", "comment" : "Display name for the \"Track Skip\" music skip behavior.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {
@ -43410,6 +43427,9 @@
} }
} }
} }
},
"Use enhanced liquid border" : {
}, },
"Use MacBook icon when on battery" : { "Use MacBook icon when on battery" : {
"localizations" : { "localizations" : {
@ -43762,7 +43782,8 @@
} }
}, },
"Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek." : { "Uses macOS Now Playing when the Amazon Music app is the active media source. Playback controls follow the system Now Playing target. Scrubbing the timeline may not work if the Amazon Music app does not support remote seek." : {
"comment" : "Description of the Amazon Music media controller.",
"isCommentAutoGenerated" : true
}, },
"Uses the frosted blur treatment while glass mode is enabled." : { "Uses the frosted blur treatment while glass mode is enabled." : {
"comment" : "A description of the frosted blur treatment when glass mode is enabled. | A description of the frosted blur treatment when glass mode is enabled. | merge:ko:alt='Glass 모드가 활성화되어 있는 동안에는 Frosted 블러 효과가 적용됩니다.'", "comment" : "A description of the frosted blur treatment when glass mode is enabled. | A description of the frosted blur treatment when glass mode is enabled. | merge:ko:alt='Glass 모드가 활성화되어 있는 동안에는 Frosted 블러 효과가 적용됩니다.'",
@ -45580,6 +45601,7 @@
}, },
"Youtube Music" : { "Youtube Music" : {
"comment" : "Spotify music service.", "comment" : "Spotify music service.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"ko" : { "ko" : {

View file

@ -25,9 +25,11 @@ import Combine
import SwiftUI import SwiftUI
class SpotifyController: MediaControllerProtocol { class SpotifyController: MediaControllerProtocol {
static let bundleIdentifier = "com.spotify.client"
// MARK: - Properties // MARK: - Properties
@Published private var playbackState: PlaybackState = PlaybackState( @Published private var playbackState: PlaybackState = PlaybackState(
bundleIdentifier: "com.spotify.client" bundleIdentifier: SpotifyController.bundleIdentifier
) )
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> { var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
@ -100,7 +102,7 @@ class SpotifyController: MediaControllerProtocol {
func updatePlaybackInfo() async { func updatePlaybackInfo() async {
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return } guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
guard descriptor.numberOfItems >= 9 else { return } guard descriptor.numberOfItems >= 11 else { return }
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown" let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown"
@ -111,9 +113,11 @@ class SpotifyController: MediaControllerProtocol {
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
let artworkURL = descriptor.atIndex(9)?.stringValue ?? "" let artworkURL = descriptor.atIndex(9)?.stringValue ?? ""
let trackIdentifier = descriptor.atIndex(10)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let trackSpotifyURL = descriptor.atIndex(11)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
var state = PlaybackState( var state = PlaybackState(
bundleIdentifier: "com.spotify.client", bundleIdentifier: Self.bundleIdentifier,
isPlaying: isPlaying, isPlaying: isPlaying,
title: currentTrack, title: currentTrack,
artist: currentTrackArtist, artist: currentTrackArtist,
@ -125,6 +129,8 @@ class SpotifyController: MediaControllerProtocol {
repeatMode: isRepeating ? .all : .off, repeatMode: isRepeating ? .all : .off,
lastUpdated: Date() lastUpdated: Date()
) )
state.contentIdentifier = trackIdentifier.isEmpty ? nil : trackIdentifier
state.contentURL = trackSpotifyURL.isEmpty ? nil : trackSpotifyURL
if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork { if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork {
state.artwork = existingArtwork state.artwork = existingArtwork
@ -186,9 +192,17 @@ class SpotifyController: MediaControllerProtocol {
set shuffleState to shuffling set shuffleState to shuffling
set repeatState to repeating set repeatState to repeating
set artworkURL to artwork url of current track set artworkURL to artwork url of current track
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL} set trackIdentifier to ""
try
set trackIdentifier to id of current track
end try
set trackSpotifyURL to ""
try
set trackSpotifyURL to spotify url of current track
end try
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackIdentifier, trackSpotifyURL}
on error on error
return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, ""} return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""}
end try end try
end tell end tell
""" """

View file

@ -65,7 +65,6 @@ private struct AnimationContentView: View {
.rotationEffect(.degrees(config.rotation)) .rotationEffect(.degrees(config.rotation))
.opacity(config.opacity) .opacity(config.opacity)
.padding(.bottom, config.paddingBottom) .padding(.bottom, config.paddingBottom)
.frame(width: config.expandWithAnimation ? nil : 30, height: 20)
.clipped() .clipped()
case .lottieURL(let url): case .lottieURL(let url):
@ -83,7 +82,6 @@ private struct AnimationContentView: View {
.rotationEffect(.degrees(config.rotation)) .rotationEffect(.degrees(config.rotation))
.opacity(config.opacity) .opacity(config.opacity)
.padding(.bottom, config.paddingBottom) .padding(.bottom, config.paddingBottom)
.frame(width: config.expandWithAnimation ? nil : 30, height: 20)
.clipped() .clipped()
} }
} }

View file

@ -116,3 +116,157 @@ struct MarqueeText: View {
} }
} }
struct MusicExplicitBadge: View {
var label: String = "E"
var fontSize: CGFloat = 9
var height: CGFloat = 14
var horizontalPadding: CGFloat = 4
var minWidth: CGFloat? = nil
var foregroundColor: Color = .white.opacity(0.92)
var backgroundColor: Color = .white.opacity(0.18)
var cornerRadius: CGFloat? = nil
var body: some View {
Text(label)
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundColor(foregroundColor)
.padding(.horizontal, max(horizontalPadding, height * 0.2))
.frame(minWidth: minWidth, minHeight: height)
.background(
RoundedRectangle(
cornerRadius: cornerRadius ?? max(4, height * 0.28),
style: .continuous
)
.fill(backgroundColor)
)
.fixedSize()
}
}
struct MusicTitleMarqueeView: View {
let text: String
let isExplicit: Bool
let font: Font
let nsFont: NSFont.TextStyle
let textColor: Color
let backgroundColor: Color
let minDuration: Double
let frameWidth: CGFloat
let alignment: Alignment
let badgeSpacing: CGFloat
let badgeLabel: String
let badgeFontSize: CGFloat?
let badgeHeight: CGFloat?
let badgeForegroundColor: Color
let badgeBackgroundColor: Color
let badgeHorizontalPadding: CGFloat
let badgeMinWidth: CGFloat?
let badgeCornerRadius: CGFloat?
init(
text: String,
isExplicit: Bool,
font: Font = .body,
nsFont: NSFont.TextStyle = .body,
textColor: Color = .primary,
backgroundColor: Color = .clear,
minDuration: Double = 3.0,
frameWidth: CGFloat = 200,
alignment: Alignment = .leading,
badgeSpacing: CGFloat = 6,
badgeLabel: String = "E",
badgeFontSize: CGFloat? = nil,
badgeHeight: CGFloat? = nil,
badgeForegroundColor: Color = .white.opacity(0.92),
badgeBackgroundColor: Color = .white.opacity(0.18),
badgeHorizontalPadding: CGFloat = 4,
badgeMinWidth: CGFloat? = nil,
badgeCornerRadius: CGFloat? = nil
) {
self.text = text
self.isExplicit = isExplicit
self.font = font
self.nsFont = nsFont
self.textColor = textColor
self.backgroundColor = backgroundColor
self.minDuration = minDuration
self.frameWidth = frameWidth
self.alignment = alignment
self.badgeSpacing = badgeSpacing
self.badgeLabel = badgeLabel
self.badgeFontSize = badgeFontSize
self.badgeHeight = badgeHeight
self.badgeForegroundColor = badgeForegroundColor
self.badgeBackgroundColor = badgeBackgroundColor
self.badgeHorizontalPadding = badgeHorizontalPadding
self.badgeMinWidth = badgeMinWidth
self.badgeCornerRadius = badgeCornerRadius
}
private var resolvedBadgeHeight: CGFloat {
badgeHeight ?? max(12, NSFont.preferredFont(forTextStyle: nsFont).pointSize * 1.12)
}
private var resolvedBadgeFontSize: CGFloat {
badgeFontSize ?? max(8, resolvedBadgeHeight * 0.6)
}
private var reservedBadgeWidth: CGFloat {
guard isExplicit else { return 0 }
return resolvedBadgeHeight + (resolvedBadgeHeight * 0.7) + badgeSpacing
}
private var titleFrameWidth: CGFloat {
max(0, frameWidth - reservedBadgeWidth)
}
private var measurementFont: NSFont {
NSFont.preferredFont(forTextStyle: nsFont)
}
private var measuredTextWidth: CGFloat {
let attributes: [NSAttributedString.Key: Any] = [.font: measurementFont]
return ceil((text as NSString).size(withAttributes: attributes).width)
}
private var needsScrolling: Bool {
measuredTextWidth > titleFrameWidth
}
var body: some View {
HStack(spacing: badgeSpacing) {
if needsScrolling {
MarqueeText(
.constant(text),
font: font,
nsFont: nsFont,
textColor: textColor,
backgroundColor: backgroundColor,
minDuration: minDuration,
frameWidth: titleFrameWidth
)
} else {
Text(text)
.font(font)
.foregroundColor(textColor)
.lineLimit(1)
.truncationMode(.tail)
.fixedSize(horizontal: true, vertical: false)
}
if isExplicit {
MusicExplicitBadge(
label: badgeLabel,
fontSize: resolvedBadgeFontSize,
height: resolvedBadgeHeight,
horizontalPadding: badgeHorizontalPadding,
minWidth: badgeMinWidth,
foregroundColor: badgeForegroundColor,
backgroundColor: badgeBackgroundColor,
cornerRadius: badgeCornerRadius
)
}
}
.frame(width: frameWidth, alignment: alignment)
}
}

View file

@ -42,18 +42,42 @@ struct LockScreenLiveActivity: View {
private var indicatorDimension: CGFloat { private var indicatorDimension: CGFloat {
max(0, vm.effectiveClosedNotchHeight - 12) max(0, vm.effectiveClosedNotchHeight - 12)
} }
private var indicatorSideHeight: CGFloat {
max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
}
private var expandedIndicatorSideWidth: CGFloat {
max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2)
}
private var indicatorSideWidth: CGFloat {
isExpanded ? expandedIndicatorSideWidth : 0
}
private var indicatorVisibilityProgress: CGFloat {
guard expandedIndicatorSideWidth > 0 else { return 0 }
return min(max(indicatorSideWidth / expandedIndicatorSideWidth, 0), 1)
}
private var indicatorOpacity: Double {
let fadeThreshold: CGFloat = 0.16
guard indicatorVisibilityProgress < fadeThreshold else { return 1 }
return Double(max(0, indicatorVisibilityProgress / fadeThreshold))
}
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Left - Lock icon with subtle glow // Left - Lock icon with subtle glow
Color.clear Color.clear
.overlay(alignment: .leading) { .overlay(alignment: .leading) {
if isExpanded { LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor)
LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor) .frame(width: indicatorDimension, height: indicatorDimension)
.frame(width: indicatorDimension, height: indicatorDimension) .opacity(indicatorOpacity)
} .scaleEffect(0.96 + (indicatorVisibilityProgress * 0.04))
} }
.frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) .frame(width: indicatorSideWidth, height: indicatorSideHeight, alignment: .leading)
.clipped()
// Center - Black fill // Center - Black fill
Rectangle() Rectangle()
@ -62,7 +86,7 @@ struct LockScreenLiveActivity: View {
// Right - Empty for symmetry with animation // Right - Empty for symmetry with animation
Color.clear Color.clear
.frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) .frame(width: indicatorSideWidth, height: indicatorSideHeight)
} }
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0))
.onAppear { .onAppear {
@ -100,6 +124,5 @@ struct LockScreenLiveActivity: View {
} }
} }
.animation(.spring(response: 0.5, dampingFraction: 0.85), value: lockScreenManager.isLocked) .animation(.spring(response: 0.5, dampingFraction: 0.85), value: lockScreenManager.isLocked)
.animation(.easeOut(duration: 0.25), value: isExpanded)
} }
} }

View file

@ -56,6 +56,7 @@ struct LockScreenMusicPanel: View {
@Default(.lockScreenMusicLiquidGlassVariant) private var musicGlassVariant @Default(.lockScreenMusicLiquidGlassVariant) private var musicGlassVariant
@Default(.lockScreenShowAppIcon) var showAppIcon @Default(.lockScreenShowAppIcon) var showAppIcon
@Default(.lockScreenPanelShowsBorder) var showPanelBorder @Default(.lockScreenPanelShowsBorder) var showPanelBorder
@Default(.lockScreenMusicUsesEnhancedLiquidBorder) private var useEnhancedLiquidBorder
@Default(.lockScreenPanelUsesBlur) var enableBlur @Default(.lockScreenPanelUsesBlur) var enableBlur
@Default(.showMediaOutputControl) private var showMediaOutputControl @Default(.showMediaOutputControl) private var showMediaOutputControl
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
@ -113,6 +114,10 @@ struct LockScreenMusicPanel: View {
private var usesLiquidGlass: Bool { private var usesLiquidGlass: Bool {
usesCustomLiquidGlass || usesStandardLiquidGlass usesCustomLiquidGlass || usesStandardLiquidGlass
} }
private var usesEnhancedCustomLiquidBorder: Bool {
usesCustomLiquidGlass && useEnhancedLiquidBorder
}
var body: some View { var body: some View {
if isActive && musicManager.hasActiveSession { if isActive && musicManager.hasActiveSession {
@ -131,9 +136,8 @@ struct LockScreenMusicPanel: View {
.frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading) .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading)
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
.overlay { .overlay {
if showPanelBorder && !usesLiquidGlass { if showPanelBorder {
RoundedRectangle(cornerRadius: panelCornerRadius) panelBorderOverlay
.stroke(Color.white.opacity(0.35), lineWidth: 1.4)
} }
} }
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
@ -255,19 +259,34 @@ struct LockScreenMusicPanel: View {
HStack(alignment: .center, spacing: 16) { HStack(alignment: .center, spacing: 16) {
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius) albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius)
VStack(alignment: .leading, spacing: 1) { GeometryReader { geo in
Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle) VStack(alignment: .leading, spacing: 1) {
.font(.system(size: 12, weight: .semibold)) MusicTitleMarqueeView(
.foregroundColor(.white) text: musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle,
.lineLimit(1) isExplicit: !musicManager.songTitle.isEmpty && musicManager.isCurrentTrackExplicit,
font: .system(size: 12, weight: .semibold),
nsFont: .subheadline,
textColor: .white,
minDuration: 0.45,
frameWidth: geo.size.width,
badgeSpacing: 5,
badgeLabel: "E",
badgeHeight: 13,
badgeForegroundColor: Color.black.opacity(0.72),
badgeBackgroundColor: Color.white.opacity(0.46),
badgeHorizontalPadding: 4,
badgeMinWidth: 14,
badgeCornerRadius: 4
)
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName) Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
.font(.system(size: 10, weight: .regular)) .font(.system(size: 10, weight: .regular))
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray) .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
.lineLimit(1) .lineLimit(1)
}
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
} }
.frame(maxWidth: .infinity, minHeight: 30, maxHeight: 30, alignment: .leading)
Spacer()
visualizer(width: 20, height: 16) visualizer(width: 20, height: 16)
} }
@ -276,19 +295,34 @@ struct LockScreenMusicPanel: View {
private var expandedHeader: some View { private var expandedHeader: some View {
HStack(alignment: .top, spacing: 16) { HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 6) { GeometryReader { geo in
Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle) VStack(alignment: .leading, spacing: 6) {
.font(.system(size: 18, weight: .semibold)) MusicTitleMarqueeView(
.foregroundColor(.white) text: musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle,
.lineLimit(2) isExplicit: !musicManager.songTitle.isEmpty && musicManager.isCurrentTrackExplicit,
font: .system(size: 18, weight: .semibold),
nsFont: .title3,
textColor: .white,
minDuration: 0.55,
frameWidth: geo.size.width,
badgeSpacing: 6,
badgeLabel: "E",
badgeHeight: 16,
badgeForegroundColor: Color.black.opacity(0.74),
badgeBackgroundColor: Color.white.opacity(0.5),
badgeHorizontalPadding: 5,
badgeMinWidth: 17,
badgeCornerRadius: 5
)
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName) Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.7) : .gray) .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.7) : .gray)
.lineLimit(2) .lineLimit(2)
}
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
} }
.frame(maxWidth: .infinity, minHeight: 42, alignment: .leading)
Spacer()
visualizer(width: 24, height: 20) visualizer(width: 24, height: 20)
} }
@ -1047,6 +1081,53 @@ struct LockScreenMusicPanel: View {
.accessibilityHidden(true) .accessibilityHidden(true)
} }
@ViewBuilder
private var panelBorderOverlay: some View {
if usesEnhancedCustomLiquidBorder {
ZStack {
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.stroke(
Color.white.opacity(0.22),
lineWidth: 1.05
)
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.inset(by: 0.85)
.stroke(
LinearGradient(
colors: [
Color.white.opacity(0.28),
Color.white.opacity(0.1),
Color.black.opacity(0.18)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 0.9
)
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.inset(by: 1.55)
.stroke(
Color.black.opacity(0.16),
lineWidth: 0.55
)
}
.allowsHitTesting(false)
} else if usesLiquidGlass {
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.stroke(
Color.white.opacity(usesCustomLiquidGlass ? 0.15 : 0.14),
lineWidth: usesCustomLiquidGlass ? 0.95 : 0.9
)
.allowsHitTesting(false)
} else {
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.stroke(Color.white.opacity(0.35), lineWidth: 1.4)
.allowsHitTesting(false)
}
}
private func albumArtImage(size: CGFloat, cornerRadius: CGFloat) -> some View { private func albumArtImage(size: CGFloat, cornerRadius: CGFloat) -> some View {
Image(nsImage: musicManager.albumArt) Image(nsImage: musicManager.albumArt)
.resizable() .resizable()

View file

@ -41,13 +41,16 @@ struct MinimalisticMusicView: View {
GeometryReader { geo in GeometryReader { geo in
VStack(alignment: .center, spacing: 2) { VStack(alignment: .center, spacing: 2) {
if !musicManager.songTitle.isEmpty { if !musicManager.songTitle.isEmpty {
MarqueeText( MusicTitleMarqueeView(
$musicManager.songTitle, text: musicManager.songTitle,
isExplicit: musicManager.isCurrentTrackExplicit,
font: .system(size: 12, weight: .semibold), font: .system(size: 12, weight: .semibold),
nsFont: .subheadline, nsFont: .subheadline,
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
minDuration: 0.4, minDuration: 0.4,
frameWidth: max(0, geo.size.width - 8) frameWidth: max(0, geo.size.width - 8),
alignment: .center,
badgeHeight: 13
) )
} }

View file

@ -83,12 +83,14 @@ struct MinimalisticMusicPlayerView: View {
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
if !musicManager.songTitle.isEmpty { if !musicManager.songTitle.isEmpty {
MarqueeText( MusicTitleMarqueeView(
$musicManager.songTitle, text: musicManager.songTitle,
isExplicit: musicManager.isCurrentTrackExplicit,
font: .system(size: 12, weight: .semibold), font: .system(size: 12, weight: .semibold),
nsFont: .subheadline, nsFont: .subheadline,
textColor: .white, textColor: .white,
frameWidth: textWidth frameWidth: textWidth,
badgeHeight: 13
) )
} }

View file

@ -187,7 +187,15 @@ struct MusicControlsView: View {
private func songInfo(width: CGFloat) -> some View { private func songInfo(width: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
MarqueeText($musicManager.songTitle, font: .headline, nsFont: .headline, textColor: .white, frameWidth: width) MusicTitleMarqueeView(
text: musicManager.songTitle,
isExplicit: musicManager.isCurrentTrackExplicit,
font: .headline,
nsFont: .headline,
textColor: .white,
frameWidth: width,
badgeHeight: 14
)
MarqueeText( MarqueeText(
$musicManager.artistName, $musicManager.artistName,
font: .headline, font: .headline,

View file

@ -2670,6 +2670,12 @@ struct Media: View {
Text("Show panel border") Text("Show panel border")
} }
.disabled(!enableLockScreenMediaWidget) .disabled(!enableLockScreenMediaWidget)
if lockScreenGlassCustomizationMode == .customLiquid {
Defaults.Toggle(key: .lockScreenMusicUsesEnhancedLiquidBorder) {
Text("Use enhanced liquid border")
}
.disabled(!enableLockScreenMediaWidget)
}
if lockScreenGlassCustomizationMode == .customLiquid { if lockScreenGlassCustomizationMode == .customLiquid {
customLiquidBlurRow customLiquidBlurRow
.opacity(enableLockScreenMediaWidget ? 1 : 0.5) .opacity(enableLockScreenMediaWidget ? 1 : 0.5)
@ -4772,6 +4778,13 @@ struct LockScreenSettings: View {
} }
.disabled(!enableLockScreenMediaWidget) .disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Show panel border")) .settingsHighlight(id: highlightID("Show panel border"))
if lockScreenGlassCustomizationMode == .customLiquid {
Defaults.Toggle(key: .lockScreenMusicUsesEnhancedLiquidBorder) {
Text("Use enhanced liquid border")
}
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Use enhanced liquid border"))
}
if lockScreenGlassCustomizationMode == .customLiquid { if lockScreenGlassCustomizationMode == .customLiquid {
variantSlider( variantSlider(
title: "Music panel variant", title: "Music panel variant",

View file

@ -113,7 +113,7 @@ final class ShelfItemViewModel: ObservableObject {
// MARK: - Actions // MARK: - Actions
func handleClick(event: NSEvent, view: NSView) { func handleClick(event: NSEvent, view: NSView) {
let flags = event.modifierFlags let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.shift) { if flags.contains(.shift) {
selection.shiftSelect(to: item, in: ShelfStateViewModel.shared.items) selection.shiftSelect(to: item, in: ShelfStateViewModel.shared.items)
} else if flags.contains(.command) { } else if flags.contains(.command) {

View file

@ -83,6 +83,19 @@ final class ShelfSelectionModel: ObservableObject {
lastAnchorID = nil lastAnchorID = nil
} }
func reconcileSelection(with allItems: [ShelfItem]) {
let validIDs = Set(allItems.map(\.id))
let filteredSelection = selectedIDs.intersection(validIDs)
if filteredSelection != selectedIDs {
selectedIDs = filteredSelection
}
if let anchor = lastAnchorID, !validIDs.contains(anchor) {
lastAnchorID = selectedIDs.first
}
}
// Keep anchor sane if items array changed drastically (optional helper) // Keep anchor sane if items array changed drastically (optional helper)
func ensureValidAnchor(in allItems: [ShelfItem]) { func ensureValidAnchor(in allItems: [ShelfItem]) {
if let anchor = lastAnchorID, !allItems.contains(where: { $0.id == anchor }) { if let anchor = lastAnchorID, !allItems.contains(where: { $0.id == anchor }) {

View file

@ -28,7 +28,10 @@ final class ShelfStateViewModel: ObservableObject {
static let shared = ShelfStateViewModel() static let shared = ShelfStateViewModel()
@Published private(set) var items: [ShelfItem] = [] { @Published private(set) var items: [ShelfItem] = [] {
didSet { ShelfPersistenceService.shared.save(items) } didSet {
ShelfPersistenceService.shared.save(items)
ShelfSelectionModel.shared.reconcileSelection(with: items)
}
} }
@Published var isLoading: Bool = false @Published var isLoading: Bool = false

View file

@ -237,6 +237,7 @@ private struct DraggableClickHandler<Content: View>: NSViewRepresentable {
private let dragThreshold: CGFloat = 3.0 private let dragThreshold: CGFloat = 3.0
private var draggedURLs: [URL] = [] private var draggedURLs: [URL] = []
private var draggedItems: [ShelfItem] = [] private var draggedItems: [ShelfItem] = []
private var didStartDragSession = false
override func rightMouseDown(with event: NSEvent) { override func rightMouseDown(with event: NSEvent) {
onRightClick?(event, self) onRightClick?(event, self)
@ -244,7 +245,21 @@ private struct DraggableClickHandler<Content: View>: NSViewRepresentable {
override func mouseDown(with event: NSEvent) { override func mouseDown(with event: NSEvent) {
mouseDownEvent = event mouseDownEvent = event
onClick?(event, self) didStartDragSession = false
}
override func mouseUp(with event: NSEvent) {
defer {
mouseDownEvent = nil
didStartDragSession = false
}
guard let mouseDownEvent, !didStartDragSession else {
super.mouseUp(with: event)
return
}
onClick?(mouseDownEvent, self)
} }
override func mouseDragged(with event: NSEvent) { override func mouseDragged(with event: NSEvent) {
@ -259,12 +274,26 @@ private struct DraggableClickHandler<Content: View>: NSViewRepresentable {
) )
if dragDistance > dragThreshold { if dragDistance > dragThreshold {
didStartDragSession = true
prepareSelectionForDrag(using: mouseDownEvent)
startDragSession(with: event) startDragSession(with: event)
self.mouseDownEvent = nil self.mouseDownEvent = nil
} else { } else {
super.mouseDragged(with: event) super.mouseDragged(with: event)
} }
} }
private func prepareSelectionForDrag(using initialEvent: NSEvent) {
let flags = initialEvent.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.shift) || flags.contains(.command) || flags.contains(.control) {
return
}
if !ShelfSelectionModel.shared.isSelected(item.id) {
ShelfSelectionModel.shared.selectSingle(item)
}
}
private func startDragSession(with event: NSEvent) { private func startDragSession(with event: NSEvent) {
// Prepare dragging items // Prepare dragging items

View file

@ -23,6 +23,28 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
private struct ShelfBackgroundClickCatcher: NSViewRepresentable {
let onClick: () -> Void
func makeNSView(context: Context) -> BackgroundClickView {
let view = BackgroundClickView()
view.onClick = onClick
return view
}
func updateNSView(_ nsView: BackgroundClickView, context: Context) {
nsView.onClick = onClick
}
final class BackgroundClickView: NSView {
var onClick: (() -> Void)?
override func mouseUp(with event: NSEvent) {
onClick?()
}
}
}
struct ShelfView: View { struct ShelfView: View {
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
@StateObject var tvm = ShelfStateViewModel.shared @StateObject var tvm = ShelfStateViewModel.shared
@ -82,14 +104,20 @@ struct ShelfView: View {
style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10]) style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10])
) )
.overlay { .overlay {
content ZStack {
.padding() ShelfBackgroundClickCatcher {
guard !selection.isDragging else { return }
selection.clear()
}
content
.padding()
}
} }
.transaction { transaction in .transaction { transaction in
transaction.animation = vm.animation transaction.animation = vm.animation
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { selection.clear() }
} }
var content: some View { var content: some View {

View file

@ -26,6 +26,8 @@ import AVFoundation
enum LockScreenAnimationTimings { enum LockScreenAnimationTimings {
static let lockExpand: TimeInterval = 0.45 static let lockExpand: TimeInterval = 0.45
static let unlockCollapse: TimeInterval = 0.82 static let unlockCollapse: TimeInterval = 0.82
static let postUnlockMusicHUDPause: TimeInterval = 1.0
static let postUnlockMusicHUDReveal: TimeInterval = 0.34
} }
@MainActor @MainActor
@ -39,11 +41,13 @@ class LockScreenManager: ObservableObject {
// MARK: - Published Properties // MARK: - Published Properties
@Published var isLocked: Bool = false @Published var isLocked: Bool = false
@Published var isLockIdle: Bool = true @Published var isLockIdle: Bool = true
@Published var shouldDelayPostUnlockMusicHUD: Bool = false
@Published var lastUpdated: Date = .distantPast @Published var lastUpdated: Date = .distantPast
// MARK: - Private Properties // MARK: - Private Properties
private var debounceIdleTask: Task<Void, Never>? private var debounceIdleTask: Task<Void, Never>?
private var collapseTask: Task<Void, Never>? private var collapseTask: Task<Void, Never>?
private var postUnlockMusicHUDTask: Task<Void, Never>?
// MARK: - Helpers // MARK: - Helpers
@ -63,6 +67,7 @@ class LockScreenManager: ObservableObject {
DistributedNotificationCenter.default().removeObserver(self) DistributedNotificationCenter.default().removeObserver(self)
debounceIdleTask?.cancel() debounceIdleTask?.cancel()
collapseTask?.cancel() collapseTask?.cancel()
postUnlockMusicHUDTask?.cancel()
} }
// MARK: - Setup // MARK: - Setup
@ -102,6 +107,8 @@ class LockScreenManager: ObservableObject {
// Update state SYNCHRONOUSLY without Task/await to avoid any delay // Update state SYNCHRONOUSLY without Task/await to avoid any delay
lastUpdated = Date() lastUpdated = Date()
updateIdleState(locked: true) updateIdleState(locked: true)
postUnlockMusicHUDTask?.cancel()
shouldDelayPostUnlockMusicHUD = false
// Set locked state immediately without animation wrapper // Set locked state immediately without animation wrapper
isLocked = true isLocked = true
@ -149,6 +156,33 @@ class LockScreenManager: ObservableObject {
lastUpdated = Date() lastUpdated = Date()
updateIdleState(locked: false) updateIdleState(locked: false)
isLocked = false isLocked = false
postUnlockMusicHUDTask?.cancel()
shouldDelayPostUnlockMusicHUD = Defaults[.enableLockScreenLiveActivity]
if shouldDelayPostUnlockMusicHUD {
postUnlockMusicHUDTask = Task { [weak self] in
try? await Task.sleep(
for: .seconds(
LockScreenAnimationTimings.unlockCollapse
+ LockScreenAnimationTimings.postUnlockMusicHUDPause
)
)
guard let self = self, !Task.isCancelled else { return }
await MainActor.run {
if !self.isLocked {
withAnimation(
.spring(
response: LockScreenAnimationTimings.postUnlockMusicHUDReveal,
dampingFraction: 0.88,
blendDuration: 0.08
)
) {
self.shouldDelayPostUnlockMusicHUD = false
}
}
}
}
}
// Hide panel window immediately and synchronously // Hide panel window immediately and synchronously
print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window")
@ -182,8 +216,9 @@ class LockScreenManager: ObservableObject {
} else { } else {
debounceIdleTask?.cancel() debounceIdleTask?.cancel()
debounceIdleTask = Task { [weak self] in debounceIdleTask = Task { [weak self] in
let configuredInterval = max(Defaults[.waitInterval], 0) // Keep the lock live activity mounted until the collapse animation finishes,
let idleDelay = min(max(configuredInterval, 0.2), LockScreenAnimationTimings.unlockCollapse) // otherwise the content disappears before the island fully closes.
let idleDelay = LockScreenAnimationTimings.unlockCollapse
try? await Task.sleep(for: .seconds(idleDelay)) try? await Task.sleep(for: .seconds(idleDelay))
guard let self = self, !Task.isCancelled else { return } guard let self = self, !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {

View file

@ -43,6 +43,354 @@ let defaultImage: NSImage = .init(
accessibilityDescription: "Album Art" accessibilityDescription: "Album Art"
)! )!
private struct ITunesExplicitnessSearchResponse: Decodable {
let results: [ITunesExplicitnessTrack]
}
private struct ITunesExplicitnessTrack: Decodable {
let trackName: String?
let artistName: String?
let collectionName: String?
let trackExplicitness: String?
}
private actor MusicExplicitnessResolver {
struct LookupKey: Hashable, Sendable {
let title: String
let artist: String
let album: String
init(title: String, artist: String, album: String) {
self.title = MusicExplicitnessResolver.normalize(title)
self.artist = MusicExplicitnessResolver.normalize(artist)
self.album = MusicExplicitnessResolver.normalize(album)
}
var canResolve: Bool {
!title.isEmpty && !artist.isEmpty
}
}
static let shared = MusicExplicitnessResolver()
private let session = URLSession(configuration: .ephemeral)
private var cache: [LookupKey: Bool] = [:]
private var inFlightTasks: [LookupKey: Task<Bool, Never>] = [:]
func resolve(title: String, artist: String, album: String) async -> Bool {
let key = LookupKey(title: title, artist: artist, album: album)
guard key.canResolve else { return false }
if let cached = cache[key] {
return cached
}
if let inFlightTask = inFlightTasks[key] {
return await inFlightTask.value
}
let task = Task<Bool, Never> { [session] in
await Self.fetchExplicitness(for: key, using: session)
}
inFlightTasks[key] = task
let result = await task.value
cache[key] = result
inFlightTasks[key] = nil
return result
}
private static func fetchExplicitness(for key: LookupKey, using session: URLSession) async -> Bool {
let query = "\(key.title) \(key.artist)"
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(
string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=song&limit=15")
else {
return false
}
do {
let (data, _) = try await session.data(from: url)
let response = try JSONDecoder().decode(ITunesExplicitnessSearchResponse.self, from: data)
let bestMatch = response.results
.map { track in (track, matchScore(for: track, key: key)) }
.max { lhs, rhs in lhs.1 < rhs.1 }
guard let bestMatch,
bestMatch.1 >= 8
else {
return false
}
return bestMatch.0.trackExplicitness?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
== "explicit"
} catch {
return false
}
}
private static func matchScore(for track: ITunesExplicitnessTrack, key: LookupKey) -> Int {
let trackTitle = canonicalTitle(track.trackName ?? "")
guard !trackTitle.isEmpty else { return Int.min }
let keyTitle = canonicalTitle(key.title)
let trackArtist = normalize(track.artistName ?? "")
let trackAlbum = normalize(track.collectionName ?? "")
var score = 0
if trackTitle == keyTitle {
score += 6
} else if trackTitle.contains(keyTitle) || keyTitle.contains(trackTitle) {
score += 4
} else {
return Int.min
}
if !key.artist.isEmpty {
if trackArtist == key.artist {
score += 4
} else if trackArtist.contains(key.artist) || key.artist.contains(trackArtist) {
score += 2
}
}
if !key.album.isEmpty {
if trackAlbum == key.album {
score += 2
} else if !trackAlbum.isEmpty && (trackAlbum.contains(key.album) || key.album.contains(trackAlbum)) {
score += 1
}
}
return score
}
private static func canonicalTitle(_ value: String) -> String {
var title = value.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
title = title.replacingOccurrences(
of: #"\([^)]*\)|\[[^\]]*\]"#,
with: " ",
options: .regularExpression
)
title = title.replacingOccurrences(
of: #"\s-\s(?:\d{4}\s)?(?:remaster(?:ed)?|live|edit|mix|version|mono|stereo).*$"#,
with: " ",
options: .regularExpression
)
return normalize(title)
}
private static func normalize(_ value: String) -> String {
let folded = value.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
let stripped = folded.replacingOccurrences(
of: #"[^a-z0-9]+"#,
with: " ",
options: .regularExpression
)
return stripped
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
private actor SpotifyExplicitnessResolver {
struct LookupKey: Hashable, Sendable {
let trackID: String
init?(contentIdentifier: String?, contentURL: String?) {
guard let trackID = SpotifyExplicitnessResolver.extractTrackID(from: contentIdentifier)
?? SpotifyExplicitnessResolver.extractTrackID(from: contentURL)
else {
return nil
}
self.trackID = trackID
}
}
static let shared = SpotifyExplicitnessResolver()
private let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.httpAdditionalHeaders = [
"User-Agent": "Mozilla/5.0",
"Accept-Language": "en-US,en;q=0.9"
]
return URLSession(configuration: configuration)
}()
private var cache: [LookupKey: Bool] = [:]
private var inFlightTasks: [LookupKey: Task<Bool?, Never>] = [:]
func resolve(key: LookupKey) async -> Bool? {
if let cached = cache[key] {
return cached
}
if let inFlightTask = inFlightTasks[key] {
return await inFlightTask.value
}
let task = Task<Bool?, Never> { [session] in
await Self.fetchExplicitness(for: key, using: session)
}
inFlightTasks[key] = task
let result = await task.value
if let result {
cache[key] = result
}
inFlightTasks[key] = nil
return result
}
private static func fetchExplicitness(for key: LookupKey, using session: URLSession) async -> Bool? {
guard let url = URL(string: "https://open.spotify.com/embed/track/\(key.trackID)") else {
return nil
}
do {
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let html = String(data: data, encoding: .utf8)
else {
return nil
}
if let isExplicit = parseIsExplicitDirectly(from: html) {
return isExplicit
}
if let nextDataJSON = extractNextDataJSON(from: html),
let isExplicit = parseIsExplicit(from: nextDataJSON) {
return isExplicit
}
return nil
} catch {
return nil
}
}
private static func extractNextDataJSON(from html: String) -> String? {
let pattern = #"<script[^>]*id="__NEXT_DATA__"[^>]*>(.*?)</script>"#
guard let regex = try? NSRegularExpression(
pattern: pattern,
options: [.dotMatchesLineSeparators, .caseInsensitive]
) else {
return nil
}
let range = NSRange(html.startIndex..<html.endIndex, in: html)
guard let match = regex.firstMatch(in: html, options: [], range: range),
match.numberOfRanges > 1,
let jsonRange = Range(match.range(at: 1), in: html)
else {
return nil
}
return String(html[jsonRange])
}
private static func parseIsExplicitDirectly(from html: String) -> Bool? {
if let value = captureFirstMatch(
in: html,
pattern: #""isExplicit"\s*:\s*(true|false)"#
) {
return value == "true"
}
if let label = captureFirstMatch(
in: html,
pattern: #""label"\s*:\s*"([A-Z_]+)""#
) {
switch label {
case "EXPLICIT":
return true
case "NON_EXPLICIT":
return false
default:
break
}
}
return nil
}
private static func captureFirstMatch(in source: String, pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return nil
}
let range = NSRange(source.startIndex..<source.endIndex, in: source)
guard let match = regex.firstMatch(in: source, options: [], range: range),
match.numberOfRanges > 1,
let valueRange = Range(match.range(at: 1), in: source)
else {
return nil
}
return String(source[valueRange]).lowercased()
}
private static func parseIsExplicit(from nextDataJSON: String) -> Bool? {
guard let data = nextDataJSON.data(using: .utf8),
let rootObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let props = rootObject["props"] as? [String: Any],
let pageProps = props["pageProps"] as? [String: Any],
let state = pageProps["state"] as? [String: Any],
let dataObject = state["data"] as? [String: Any],
let entity = dataObject["entity"] as? [String: Any]
else {
return nil
}
if let isExplicit = entity["isExplicit"] as? Bool {
return isExplicit
}
if let contentRating = entity["contentRating"] as? [String: Any],
let label = contentRating["label"] as? String {
return label.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() == "EXPLICIT"
}
return nil
}
private static func extractTrackID(from rawValue: String?) -> String? {
guard let rawValue else { return nil }
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("spotify:track:") {
return validatedTrackID(String(trimmed.split(separator: ":").last ?? ""))
}
if let url = URL(string: trimmed) {
let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty }
if let trackIndex = pathComponents.firstIndex(of: "track"),
trackIndex + 1 < pathComponents.count {
return validatedTrackID(pathComponents[trackIndex + 1])
}
}
return validatedTrackID(trimmed)
}
private static func validatedTrackID<S: StringProtocol>(_ candidate: S) -> String? {
let value = String(candidate).trimmingCharacters(in: .whitespacesAndNewlines)
guard value.range(of: #"^[A-Za-z0-9]{22}$"#, options: .regularExpression) != nil else {
return nil
}
return value
}
}
class MusicManager: ObservableObject { class MusicManager: ObservableObject {
enum SkipDirection: Equatable { enum SkipDirection: Equatable {
case backward case backward
@ -82,6 +430,7 @@ class MusicManager: ObservableObject {
@Published var isPlaying = false @Published var isPlaying = false
@Published var album: String = "Self Love" @Published var album: String = "Self Love"
@Published var isPlayerIdle: Bool = true @Published var isPlayerIdle: Bool = true
@Published var isCurrentTrackExplicit: Bool = false
/// Whether there is an active music session with real metadata. /// Whether there is an active music session with real metadata.
/// Returns `false` only when the metadata is still placeholder/fallback defaults /// Returns `false` only when the metadata is still placeholder/fallback defaults
@ -126,6 +475,8 @@ class MusicManager: ObservableObject {
// Task used to periodically sync displayed lyric with playback position // Task used to periodically sync displayed lyric with playback position
private var lyricSyncTask: Task<Void, Never>? private var lyricSyncTask: Task<Void, Never>?
private var explicitLookupTask: Task<Void, Never>?
private var explicitLookupKey: String?
private var artworkData: Data? = nil private var artworkData: Data? = nil
@ -139,6 +490,8 @@ class MusicManager: ObservableObject {
private var lastArtworkArtist: String = "Me" private var lastArtworkArtist: String = "Me"
private var lastArtworkAlbum: String = "Self Love" private var lastArtworkAlbum: String = "Self Love"
private var lastArtworkBundleIdentifier: String? = nil private var lastArtworkBundleIdentifier: String? = nil
private var lastArtworkContentIdentifier: String? = nil
private var lastArtworkContentURL: String? = nil
@Published var flipAngle: Double = 0 @Published var flipAngle: Double = 0
@Published var lastFlipDirection: SkipDirection = .forward @Published var lastFlipDirection: SkipDirection = .forward
@ -227,6 +580,7 @@ class MusicManager: ObservableObject {
public func destroy() { public func destroy() {
debounceIdleTask?.cancel() debounceIdleTask?.cancel()
explicitLookupTask?.cancel()
cancellables.removeAll() cancellables.removeAll()
controllerCancellables.removeAll() controllerCancellables.removeAll()
transitionWorkItem?.cancel() transitionWorkItem?.cancel()
@ -342,10 +696,19 @@ class MusicManager: ObservableObject {
let artistChanged = state.artist != self.lastArtworkArtist let artistChanged = state.artist != self.lastArtworkArtist
let albumChanged = state.album != self.lastArtworkAlbum let albumChanged = state.album != self.lastArtworkAlbum
let bundleChanged = state.bundleIdentifier != self.lastArtworkBundleIdentifier let bundleChanged = state.bundleIdentifier != self.lastArtworkBundleIdentifier
let contentIdentifierChanged = state.contentIdentifier != self.lastArtworkContentIdentifier
let contentURLChanged = state.contentURL != self.lastArtworkContentURL
// Check for artwork changes // Check for artwork changes
let artworkChanged = state.artwork != nil && state.artwork != self.artworkData let artworkChanged = state.artwork != nil && state.artwork != self.artworkData
let hasContentChange = titleChanged || artistChanged || albumChanged || artworkChanged || bundleChanged let hasContentChange =
titleChanged
|| artistChanged
|| albumChanged
|| artworkChanged
|| bundleChanged
|| contentIdentifierChanged
|| contentURLChanged
// Handle artwork and visual transitions for changed content // Handle artwork and visual transitions for changed content
let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange] let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange]
@ -369,14 +732,19 @@ class MusicManager: ObservableObject {
self.lastArtworkArtist = state.artist self.lastArtworkArtist = state.artist
self.lastArtworkAlbum = state.album self.lastArtworkAlbum = state.album
self.lastArtworkBundleIdentifier = state.bundleIdentifier self.lastArtworkBundleIdentifier = state.bundleIdentifier
self.lastArtworkContentIdentifier = state.contentIdentifier
self.lastArtworkContentURL = state.contentURL
// Fetch lyrics for new track whenever content changes // Fetch lyrics for new track whenever content changes
self.fetchLyrics() self.fetchLyrics()
self.refreshExplicitFlag(for: state)
// Only update sneak peek if there's actual content and something changed // Only update sneak peek if there's actual content and something changed
if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
self.updateSneakPeek() self.updateSneakPeek()
} }
} else if state.isExplicit != nil {
self.refreshExplicitFlag(for: state)
} }
let timeChanged = state.currentTime != self.elapsedTime let timeChanged = state.currentTime != self.elapsedTime
@ -435,6 +803,122 @@ class MusicManager: ObservableObject {
} }
} }
@MainActor
private func refreshExplicitFlag(for state: PlaybackState) {
if let explicitValue = state.isExplicit {
explicitLookupTask?.cancel()
explicitLookupTask = nil
explicitLookupKey = nil
if isCurrentTrackExplicit != explicitValue {
isCurrentTrackExplicit = explicitValue
}
return
}
if state.bundleIdentifier == SpotifyController.bundleIdentifier,
let spotifyLookupKey = SpotifyExplicitnessResolver.LookupKey(
contentIdentifier: state.contentIdentifier,
contentURL: state.contentURL
) {
let lookupIdentifier = "spotify|\(spotifyLookupKey.trackID)"
let fallbackTitle = state.title
let fallbackArtist = state.artist
let fallbackAlbum = state.album
guard explicitLookupKey != lookupIdentifier else { return }
explicitLookupTask?.cancel()
explicitLookupKey = lookupIdentifier
if isCurrentTrackExplicit {
isCurrentTrackExplicit = false
}
explicitLookupTask = Task { [weak self] in
let isExplicit = await SpotifyExplicitnessResolver.shared.resolve(key: spotifyLookupKey)
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self,
self.explicitLookupKey == lookupIdentifier
else {
return
}
self.explicitLookupTask = nil
if let isExplicit {
self.isCurrentTrackExplicit = isExplicit
} else {
self.explicitLookupKey = nil
self.refreshGenericExplicitFlag(
title: fallbackTitle,
artist: fallbackArtist,
album: fallbackAlbum
)
}
}
}
return
}
refreshGenericExplicitFlag(title: state.title, artist: state.artist, album: state.album)
}
@MainActor
private func refreshGenericExplicitFlag(title: String, artist: String, album: String) {
let lookupKey = MusicExplicitnessResolver.LookupKey(
title: title,
artist: artist,
album: album
)
let lookupIdentifier = "generic|\(lookupKey.title)|\(lookupKey.artist)|\(lookupKey.album)"
guard lookupKey.canResolve,
!Self.placeholderTitles.contains(lookupKey.title),
!Self.placeholderArtists.contains(lookupKey.artist)
else {
explicitLookupTask?.cancel()
explicitLookupTask = nil
explicitLookupKey = nil
if isCurrentTrackExplicit {
isCurrentTrackExplicit = false
}
return
}
guard explicitLookupKey != lookupIdentifier else { return }
explicitLookupTask?.cancel()
explicitLookupKey = lookupIdentifier
if isCurrentTrackExplicit {
isCurrentTrackExplicit = false
}
explicitLookupTask = Task { [weak self] in
let isExplicit = await MusicExplicitnessResolver.shared.resolve(
title: title,
artist: artist,
album: album
)
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self,
self.explicitLookupKey == lookupIdentifier
else {
return
}
self.isCurrentTrackExplicit = isExplicit
self.explicitLookupTask = nil
}
}
}
private func triggerFlipAnimation() { private func triggerFlipAnimation() {
// Debounce: rapid metadata updates (title, artwork, bundle arriving // Debounce: rapid metadata updates (title, artwork, bundle arriving
// separately for one track change) should only produce a single flip. // separately for one track change) should only produce a single flip.

View file

@ -904,6 +904,10 @@ extension Defaults.Keys {
) )
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false) static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: false) static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: false)
static let lockScreenMusicUsesEnhancedLiquidBorder = Key<Bool>(
"lockScreenMusicUsesEnhancedLiquidBorder",
default: true
)
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true) static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true) static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true)
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)

View file

@ -34,6 +34,9 @@ struct PlaybackState {
var title: String = "I'm Handsome" var title: String = "I'm Handsome"
var artist: String = "Me" var artist: String = "Me"
var album: String = "Self Love" var album: String = "Self Love"
var contentIdentifier: String?
var contentURL: String?
var isExplicit: Bool?
var currentTime: Double = 0 var currentTime: Double = 0
var duration: Double = 0 var duration: Double = 0
var playbackRate: Double = 1 var playbackRate: Double = 1
@ -50,6 +53,9 @@ extension PlaybackState: Equatable {
&& lhs.title == rhs.title && lhs.title == rhs.title
&& lhs.artist == rhs.artist && lhs.artist == rhs.artist
&& lhs.album == rhs.album && lhs.album == rhs.album
&& lhs.contentIdentifier == rhs.contentIdentifier
&& lhs.contentURL == rhs.contentURL
&& lhs.isExplicit == rhs.isExplicit
&& lhs.currentTime == rhs.currentTime && lhs.currentTime == rhs.currentTime
&& lhs.duration == rhs.duration && lhs.duration == rhs.duration
&& lhs.isShuffled == rhs.isShuffled && lhs.isShuffled == rhs.isShuffled