diff --git a/.gitignore b/.gitignore index 9082292..3bf470c 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,4 @@ mew-notch/ /References/ /builds/ /appdmg.json +COMPREHENSIVE_DOCUMENTATION.md \ No newline at end of file diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index d88afb6..02e0162 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -454,6 +454,7 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 12; diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 0d413f8..1a40e0d 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -234,6 +234,64 @@ struct ContentView: View { private var closedMusicContentEnabled: Bool { 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 private var activeCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) { @@ -334,335 +392,348 @@ struct ContentView: View { } var body: some View { - let interactionsEnabled = !lockScreenManager.isLocked - 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 + installRootLifecycleHandlers(on: rootBodyView) + } - ZStack(alignment: .top) { - let mainLayout = NotchLayout() - .frame(alignment: .top) - .padding(.horizontal, notchHorizontalPadding) - .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) - .background(.black) - .clipShape(resolvedClipShape) - .compositingGroup() - .shadow( - color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) - ? .black.opacity(0.6) - : .clear, - radius: Defaults[.cornerRadiusScaling] ? 10 : 5 - ) - // Extra horizontal inset for Dynamic Island mode so the shadow - // is not clipped by the outer frame constraint - .padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0) - .padding(.top, pillTopOffset) + private var mainLayoutBase: some View { + NotchLayout() + .frame(alignment: .top) + .padding(.horizontal, notchHorizontalPadding) + .padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0) + .background(.black) + .clipShape(resolvedClipShape) + .compositingGroup() + .shadow( + color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) + ? .black.opacity(0.6) + : .clear, + radius: Defaults[.cornerRadiusScaling] ? 10 : 5 + ) + // Extra horizontal inset for Dynamic Island mode so the shadow + // is not clipped by the outer frame constraint + .padding(.horizontal, isIslandMode ? dynamicIslandShadowInset : 0) + .padding(.top, pillTopOffset) + } - mainLayout - .conditionalModifier(!useModernCloseAnimation) { view in - let hoverAnimation = Animation.bouncy.speed(1.2) - let notchStateAnimation = Animation.spring.speed(1.2) - return view - .animation(hoverAnimation, value: isHovering) - .animation(notchStateAnimation, value: vm.notchState) - .animation(.smooth, value: gestureProgress) - .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) - } - .conditionalModifier(useModernCloseAnimation) { view in - let hoverAnimation = Animation.bouncy.speed(1.2) - let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0) - let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0) - let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation - return view - .animation(hoverAnimation, value: isHovering) - .animation(notchAnimation, value: vm.notchState) - .animation(.smooth, value: gestureProgress) - } - .conditionalModifier(interactionsEnabled) { view in - view - .contentShape(resolvedClipShape) - .onHover { hovering in - handleHover(hovering) + private var configuredMainLayout: some View { + mainLayoutBase + .conditionalModifier(!useModernCloseAnimation) { view in + let hoverAnimation = Animation.bouncy.speed(1.2) + let notchStateAnimation = Animation.spring.speed(1.2) + return view + .animation(hoverAnimation, value: isHovering) + .animation(notchStateAnimation, value: vm.notchState) + .animation(.smooth, value: gestureProgress) + .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) + } + .conditionalModifier(useModernCloseAnimation) { view in + let hoverAnimation = Animation.bouncy.speed(1.2) + let openAnimation = Animation.spring(response: 0.42, dampingFraction: 0.8, blendDuration: 0) + let closeAnimation = Animation.spring(response: 0.45, dampingFraction: 1.0, blendDuration: 0) + let notchAnimation = vm.notchState == .open ? openAnimation : closeAnimation + return view + .animation(hoverAnimation, value: isHovering) + .animation(notchAnimation, value: vm.notchState) + .animation(.smooth, value: gestureProgress) + } + .conditionalModifier(interactionsEnabled) { view in + view + .contentShape(resolvedClipShape) + .onHover { hovering in + handleHover(hovering) + } + .onTapGesture { + if handleClosedMusicWaveformTapIfNeeded() { + return } - .onTapGesture { - if handleClosedMusicWaveformTapIfNeeded() { - return + if vm.notchState == .closed && Defaults[.enableHaptics] { + triggerHapticIfAllowed() + } + openNotch() + } + .conditionalModifier(Defaults[.enableGestures]) { view in + view + .panGesture(direction: .down) { translation, phase in + handleDownGesture(translation: translation, phase: phase) } - if vm.notchState == .closed && Defaults[.enableHaptics] { - triggerHapticIfAllowed() + .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() } - .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) { - 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) + }) + .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) { + 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( maxWidth: dynamicNotchSize.width + (isDynamicIslandMode ? dynamicIslandShadowInset * 2 : 0), maxHeight: dynamicNotchSize.height + currentShadowPadding + (isDynamicIslandMode ? dynamicIslandTopOffset : 0), alignment: .top ) - .frame(maxHeight: .infinity, alignment: .top) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .environmentObject(privacyManager) .background(dragDetector) .environmentObject(vm) .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) - } + } - } - .onChange(of: musicControlWindowEnabled) { _, enabled in - if enabled { + private func installRootLifecycleHandlers(on view: Content) -> some View { + installSecondaryRootLifecycleHandlers( + on: installPrimaryRootLifecycleHandlers(on: view) + ) + } + + private func installPrimaryRootLifecycleHandlers(on view: Content) -> some View { + view + .onAppear { + isMusicControlWindowSuppressed = vm.notchState != .closed + || lockScreenManager.isLocked + || isMusicHUDDeferredAfterUnlock if musicManager.isPlaying || !musicManager.isPlayerIdle { clearMusicControlVisibilityDeadline() } + if let deadline = musicControlVisibilityDeadline, Date() > deadline { + clearMusicControlVisibilityDeadline() + } 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(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() hideMusicControlWindow() + cancelMusicControlVisibilityTimer() clearMusicControlVisibilityDeadline() - hasPendingMusicControlSync = false - pendingMusicControlForceRefresh = false + musicControlSuppressionTask?.cancel() + 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 @@ -683,12 +754,7 @@ struct ContentView: View { if musicManager.isPlaying { return true } return !musicManager.isPlayerIdle && hasMusicMetadata }() - let musicPairingEligible = vm.notchState == .closed - && hasActiveMusicSnapshot - && coordinator.musicLiveActivityEnabled - && closedMusicContentEnabled - && !vm.hideOnClosed - && !lockScreenManager.isLocked + let musicPairingEligible = closedMusicPairingEligible(hasActiveMusicSnapshot: hasActiveMusicSnapshot) let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible) let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary) let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID) @@ -750,6 +816,8 @@ struct ContentView: View { .transition(AnyTransition.move(edge: .trailing).combined(with: .opacity)) } else if canShowMusicDuringExpansion && musicPairingEligible { 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 { TimerLiveActivity() } 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() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { 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 { PrivacyLiveActivity() } else if let extensionPayload = extensionStandalonePayload { @@ -970,15 +1040,17 @@ struct ContentView: View { @ViewBuilder func DynamicIslandFaceAnimation() -> some View { + let sideSize = max(0, vm.effectiveClosedNotchHeight - 12) HStack { HStack { Rectangle() .fill(.clear) - .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) + .frame(width: sideSize, height: sideSize) Rectangle() .fill(.black) .frame(width: vm.closedNotchSize.width - 20) IdleAnimationView() + .frame(width: sideSize, height: sideSize) } }.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) } @@ -1034,11 +1106,13 @@ struct ContentView: View { .overlay( HStack(alignment: .top) { if(coordinator.expandingView.show && coordinator.expandingView.type == .music) { - MarqueeText( - .constant(musicManager.songTitle), + MusicTitleMarqueeView( + text: musicManager.songTitle, + isExplicit: musicManager.isCurrentTrackExplicit, textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, 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) .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) @@ -1434,6 +1508,15 @@ struct ContentView: View { 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 { ExtensionRoutingDiagnostics.shared.logSuppression( .music, @@ -1645,6 +1728,7 @@ struct ContentView: View { && closedMusicContentEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked + && !isMusicHUDDeferredAfterUnlock && !coordinator.expandingView.show && (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil) && !coordinator.firstLaunch @@ -2202,7 +2286,7 @@ struct ContentView: View { guard !Task.isCancelled else { return } await MainActor.run { - if vm.notchState == .closed && !lockScreenManager.isLocked { + if vm.notchState == .closed && !lockScreenManager.isLocked && !isMusicHUDDeferredAfterUnlock { isMusicControlWindowSuppressed = false triggerPendingMusicControlSyncIfNeeded() } else { @@ -2225,7 +2309,10 @@ struct ContentView: View { } 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) { @@ -2249,6 +2336,7 @@ struct ContentView: View { vm.notchState == .closed, !vm.hideOnClosed, !lockScreenManager.isLocked, + !isMusicHUDDeferredAfterUnlock, !isMusicControlWindowSuppressed else { return false } @@ -2267,7 +2355,7 @@ struct ContentView: View { return } - if !bypassSuppression && (isMusicControlWindowSuppressed || lockScreenManager.isLocked) { + if !bypassSuppression && (isMusicControlWindowSuppressed || lockScreenManager.isLocked || isMusicHUDDeferredAfterUnlock) { hasPendingMusicControlSync = true if forceRefresh { pendingMusicControlForceRefresh = true diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 2d2e3bb..7d726a5 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -2314,6 +2314,7 @@ }, "±10 Seconds" : { "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3137,6 +3138,7 @@ }, "Accent" : { "comment" : "Display name for the caps lock indicator tint mode.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3694,6 +3696,7 @@ }, "Adaptive gradient" : { "comment" : "Display name for the timer icon color mode: adaptive gradient.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -6016,6 +6019,7 @@ }, "Apple Music" : { "comment" : "Media controller type for Apple Music", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -6919,6 +6923,7 @@ }, "Bar" : { "comment" : "Name of the timer progress style that shows a horizontal bar.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -14519,6 +14524,7 @@ }, "Digital" : { "comment" : "The display name for the \"Digital\" reminder presentation style.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -20826,6 +20832,7 @@ }, "Green" : { "comment" : "Display name for the green caps lock indicator tint mode.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -29940,6 +29947,7 @@ }, "Now Playing" : { "comment" : "Name of the media controller type for the \"Now Playing\" option.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -30685,6 +30693,7 @@ }, "Panel" : { "comment" : "Display name for the clipboard display mode \"Panel\".", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -34227,6 +34236,7 @@ }, "Ring" : { "comment" : "The name of the timer progress style that shows a ring shape.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -35748,6 +35758,7 @@ }, "Separate Tab" : { "comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38180,6 +38191,7 @@ }, "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.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38204,6 +38216,7 @@ }, "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.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38431,6 +38444,7 @@ }, "Skip forward or backward by ten seconds" : { "comment" : "Description of a music skip behavior option.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39154,6 +39168,7 @@ }, "Spotify" : { "comment" : "Spotify media controller type.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39390,6 +39405,7 @@ }, "Standard previous/next track controls" : { "comment" : "Description of the music skip behavior option to use standard previous/next track controls.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -42220,6 +42236,7 @@ }, "Track Skip" : { "comment" : "Display name for the \"Track Skip\" music skip behavior.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -43410,6 +43427,9 @@ } } } + }, + "Use enhanced liquid border" : { + }, "Use MacBook icon when on battery" : { "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." : { - + "comment" : "Description of the Amazon Music media controller.", + "isCommentAutoGenerated" : true }, "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 블러 효과가 적용됩니다.'", @@ -45580,6 +45601,7 @@ }, "Youtube Music" : { "comment" : "Spotify music service.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { diff --git a/DynamicIsland/MediaControllers/SpotifyController.swift b/DynamicIsland/MediaControllers/SpotifyController.swift index d2b5538..0380d96 100644 --- a/DynamicIsland/MediaControllers/SpotifyController.swift +++ b/DynamicIsland/MediaControllers/SpotifyController.swift @@ -25,9 +25,11 @@ import Combine import SwiftUI class SpotifyController: MediaControllerProtocol { + static let bundleIdentifier = "com.spotify.client" + // MARK: - Properties @Published private var playbackState: PlaybackState = PlaybackState( - bundleIdentifier: "com.spotify.client" + bundleIdentifier: SpotifyController.bundleIdentifier ) var playbackStatePublisher: AnyPublisher { @@ -100,7 +102,7 @@ class SpotifyController: MediaControllerProtocol { func updatePlaybackInfo() async { 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 currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown" @@ -111,9 +113,11 @@ class SpotifyController: MediaControllerProtocol { let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false 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( - bundleIdentifier: "com.spotify.client", + bundleIdentifier: Self.bundleIdentifier, isPlaying: isPlaying, title: currentTrack, artist: currentTrackArtist, @@ -125,6 +129,8 @@ class SpotifyController: MediaControllerProtocol { repeatMode: isRepeating ? .all : .off, lastUpdated: Date() ) + state.contentIdentifier = trackIdentifier.isEmpty ? nil : trackIdentifier + state.contentURL = trackSpotifyURL.isEmpty ? nil : trackSpotifyURL if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork { state.artwork = existingArtwork @@ -186,9 +192,17 @@ class SpotifyController: MediaControllerProtocol { set shuffleState to shuffling set repeatState to repeating 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 - return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, ""} + return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""} end try end tell """ diff --git a/DynamicIsland/components/IdleAnimationView.swift b/DynamicIsland/components/IdleAnimationView.swift index 41015a9..01382cb 100644 --- a/DynamicIsland/components/IdleAnimationView.swift +++ b/DynamicIsland/components/IdleAnimationView.swift @@ -65,7 +65,6 @@ private struct AnimationContentView: View { .rotationEffect(.degrees(config.rotation)) .opacity(config.opacity) .padding(.bottom, config.paddingBottom) - .frame(width: config.expandWithAnimation ? nil : 30, height: 20) .clipped() case .lottieURL(let url): @@ -83,7 +82,6 @@ private struct AnimationContentView: View { .rotationEffect(.degrees(config.rotation)) .opacity(config.opacity) .padding(.bottom, config.paddingBottom) - .frame(width: config.expandWithAnimation ? nil : 30, height: 20) .clipped() } } diff --git a/DynamicIsland/components/Live activities/MarqueeTextView.swift b/DynamicIsland/components/Live activities/MarqueeTextView.swift index b04a0fb..2a39706 100644 --- a/DynamicIsland/components/Live activities/MarqueeTextView.swift +++ b/DynamicIsland/components/Live activities/MarqueeTextView.swift @@ -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) + } +} diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift index f103790..da41805 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift @@ -42,18 +42,42 @@ struct LockScreenLiveActivity: View { private var indicatorDimension: CGFloat { 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 { HStack(spacing: 0) { // Left - Lock icon with subtle glow Color.clear .overlay(alignment: .leading) { - if isExpanded { - LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor) - .frame(width: indicatorDimension, height: indicatorDimension) - } + LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor) + .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 Rectangle() @@ -62,7 +86,7 @@ struct LockScreenLiveActivity: View { // Right - Empty for symmetry with animation 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)) .onAppear { @@ -100,6 +124,5 @@ struct LockScreenLiveActivity: View { } } .animation(.spring(response: 0.5, dampingFraction: 0.85), value: lockScreenManager.isLocked) - .animation(.easeOut(duration: 0.25), value: isExpanded) } } diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 2cc0227..17716a4 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -56,6 +56,7 @@ struct LockScreenMusicPanel: View { @Default(.lockScreenMusicLiquidGlassVariant) private var musicGlassVariant @Default(.lockScreenShowAppIcon) var showAppIcon @Default(.lockScreenPanelShowsBorder) var showPanelBorder + @Default(.lockScreenMusicUsesEnhancedLiquidBorder) private var useEnhancedLiquidBorder @Default(.lockScreenPanelUsesBlur) var enableBlur @Default(.showMediaOutputControl) private var showMediaOutputControl @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat @@ -113,6 +114,10 @@ struct LockScreenMusicPanel: View { private var usesLiquidGlass: Bool { usesCustomLiquidGlass || usesStandardLiquidGlass } + + private var usesEnhancedCustomLiquidBorder: Bool { + usesCustomLiquidGlass && useEnhancedLiquidBorder + } var body: some View { if isActive && musicManager.hasActiveSession { @@ -131,9 +136,8 @@ struct LockScreenMusicPanel: View { .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading) .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .overlay { - if showPanelBorder && !usesLiquidGlass { - RoundedRectangle(cornerRadius: panelCornerRadius) - .stroke(Color.white.opacity(0.35), lineWidth: 1.4) + if showPanelBorder { + panelBorderOverlay } } .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) { albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius) - VStack(alignment: .leading, spacing: 1) { - Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.white) - .lineLimit(1) + GeometryReader { geo in + VStack(alignment: .leading, spacing: 1) { + MusicTitleMarqueeView( + text: musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle, + 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) - .font(.system(size: 10, weight: .regular)) - .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray) - .lineLimit(1) + Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray) + .lineLimit(1) + } + .frame(width: geo.size.width, height: geo.size.height, alignment: .leading) } - - Spacer() + .frame(maxWidth: .infinity, minHeight: 30, maxHeight: 30, alignment: .leading) visualizer(width: 20, height: 16) } @@ -276,19 +295,34 @@ struct LockScreenMusicPanel: View { private var expandedHeader: some View { HStack(alignment: .top, spacing: 16) { - VStack(alignment: .leading, spacing: 6) { - Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle) - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.white) - .lineLimit(2) + GeometryReader { geo in + VStack(alignment: .leading, spacing: 6) { + MusicTitleMarqueeView( + text: musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle, + 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) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.7) : .gray) - .lineLimit(2) + Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.7) : .gray) + .lineLimit(2) + } + .frame(width: geo.size.width, height: geo.size.height, alignment: .leading) } - - Spacer() + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) visualizer(width: 24, height: 20) } @@ -1047,6 +1081,53 @@ struct LockScreenMusicPanel: View { .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 { Image(nsImage: musicManager.albumArt) .resizable() diff --git a/DynamicIsland/components/MinimalisticMusicView.swift b/DynamicIsland/components/MinimalisticMusicView.swift index e830e19..165dea8 100644 --- a/DynamicIsland/components/MinimalisticMusicView.swift +++ b/DynamicIsland/components/MinimalisticMusicView.swift @@ -41,13 +41,16 @@ struct MinimalisticMusicView: View { GeometryReader { geo in VStack(alignment: .center, spacing: 2) { if !musicManager.songTitle.isEmpty { - MarqueeText( - $musicManager.songTitle, + MusicTitleMarqueeView( + text: musicManager.songTitle, + isExplicit: musicManager.isCurrentTrackExplicit, font: .system(size: 12, weight: .semibold), nsFont: .subheadline, textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray, minDuration: 0.4, - frameWidth: max(0, geo.size.width - 8) + frameWidth: max(0, geo.size.width - 8), + alignment: .center, + badgeHeight: 13 ) } diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index b17abc2..5b020a9 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -83,12 +83,14 @@ struct MinimalisticMusicPlayerView: View { VStack(alignment: .leading, spacing: 1) { if !musicManager.songTitle.isEmpty { - MarqueeText( - $musicManager.songTitle, + MusicTitleMarqueeView( + text: musicManager.songTitle, + isExplicit: musicManager.isCurrentTrackExplicit, font: .system(size: 12, weight: .semibold), nsFont: .subheadline, textColor: .white, - frameWidth: textWidth + frameWidth: textWidth, + badgeHeight: 13 ) } diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 3041026..9d10cd5 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -187,7 +187,15 @@ struct MusicControlsView: View { private func songInfo(width: CGFloat) -> some View { 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( $musicManager.artistName, font: .headline, diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 6cb4300..2a4464b 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2670,6 +2670,12 @@ struct Media: View { Text("Show panel border") } .disabled(!enableLockScreenMediaWidget) + if lockScreenGlassCustomizationMode == .customLiquid { + Defaults.Toggle(key: .lockScreenMusicUsesEnhancedLiquidBorder) { + Text("Use enhanced liquid border") + } + .disabled(!enableLockScreenMediaWidget) + } if lockScreenGlassCustomizationMode == .customLiquid { customLiquidBlurRow .opacity(enableLockScreenMediaWidget ? 1 : 0.5) @@ -4772,6 +4778,13 @@ struct LockScreenSettings: View { } .disabled(!enableLockScreenMediaWidget) .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 { variantSlider( title: "Music panel variant", diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift index 867893a..ca57d62 100644 --- a/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift @@ -113,7 +113,7 @@ final class ShelfItemViewModel: ObservableObject { // MARK: - Actions func handleClick(event: NSEvent, view: NSView) { - let flags = event.modifierFlags + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if flags.contains(.shift) { selection.shiftSelect(to: item, in: ShelfStateViewModel.shared.items) } else if flags.contains(.command) { diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfSelectionModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfSelectionModel.swift index 219c6c7..815029c 100644 --- a/DynamicIsland/components/Shelf/ViewModels/ShelfSelectionModel.swift +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfSelectionModel.swift @@ -83,6 +83,19 @@ final class ShelfSelectionModel: ObservableObject { 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) func ensureValidAnchor(in allItems: [ShelfItem]) { if let anchor = lastAnchorID, !allItems.contains(where: { $0.id == anchor }) { diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift index e1972f8..8c69de0 100644 --- a/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift @@ -28,7 +28,10 @@ final class ShelfStateViewModel: ObservableObject { static let shared = ShelfStateViewModel() @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 diff --git a/DynamicIsland/components/Shelf/Views/ShelfItemView.swift b/DynamicIsland/components/Shelf/Views/ShelfItemView.swift index ef9fcd9..e266e08 100644 --- a/DynamicIsland/components/Shelf/Views/ShelfItemView.swift +++ b/DynamicIsland/components/Shelf/Views/ShelfItemView.swift @@ -237,6 +237,7 @@ private struct DraggableClickHandler: NSViewRepresentable { private let dragThreshold: CGFloat = 3.0 private var draggedURLs: [URL] = [] private var draggedItems: [ShelfItem] = [] + private var didStartDragSession = false override func rightMouseDown(with event: NSEvent) { onRightClick?(event, self) @@ -244,7 +245,21 @@ private struct DraggableClickHandler: NSViewRepresentable { override func mouseDown(with event: NSEvent) { 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) { @@ -259,12 +274,26 @@ private struct DraggableClickHandler: NSViewRepresentable { ) if dragDistance > dragThreshold { + didStartDragSession = true + prepareSelectionForDrag(using: mouseDownEvent) startDragSession(with: event) self.mouseDownEvent = nil } else { 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) { // Prepare dragging items diff --git a/DynamicIsland/components/Shelf/Views/ShelfView.swift b/DynamicIsland/components/Shelf/Views/ShelfView.swift index f495c8d..f53cb3a 100644 --- a/DynamicIsland/components/Shelf/Views/ShelfView.swift +++ b/DynamicIsland/components/Shelf/Views/ShelfView.swift @@ -23,6 +23,28 @@ import SwiftUI 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 { @EnvironmentObject var vm: DynamicIslandViewModel @StateObject var tvm = ShelfStateViewModel.shared @@ -82,14 +104,20 @@ struct ShelfView: View { style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10]) ) .overlay { - content - .padding() + ZStack { + ShelfBackgroundClickCatcher { + guard !selection.isDragging else { return } + selection.clear() + } + + content + .padding() + } } .transaction { transaction in transaction.animation = vm.animation } .contentShape(Rectangle()) - .onTapGesture { selection.clear() } } var content: some View { diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index e1c7a6e..6e98688 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -26,6 +26,8 @@ import AVFoundation enum LockScreenAnimationTimings { static let lockExpand: TimeInterval = 0.45 static let unlockCollapse: TimeInterval = 0.82 + static let postUnlockMusicHUDPause: TimeInterval = 1.0 + static let postUnlockMusicHUDReveal: TimeInterval = 0.34 } @MainActor @@ -39,11 +41,13 @@ class LockScreenManager: ObservableObject { // MARK: - Published Properties @Published var isLocked: Bool = false @Published var isLockIdle: Bool = true + @Published var shouldDelayPostUnlockMusicHUD: Bool = false @Published var lastUpdated: Date = .distantPast // MARK: - Private Properties private var debounceIdleTask: Task? private var collapseTask: Task? + private var postUnlockMusicHUDTask: Task? // MARK: - Helpers @@ -63,6 +67,7 @@ class LockScreenManager: ObservableObject { DistributedNotificationCenter.default().removeObserver(self) debounceIdleTask?.cancel() collapseTask?.cancel() + postUnlockMusicHUDTask?.cancel() } // MARK: - Setup @@ -102,6 +107,8 @@ class LockScreenManager: ObservableObject { // Update state SYNCHRONOUSLY without Task/await to avoid any delay lastUpdated = Date() updateIdleState(locked: true) + postUnlockMusicHUDTask?.cancel() + shouldDelayPostUnlockMusicHUD = false // Set locked state immediately without animation wrapper isLocked = true @@ -149,6 +156,33 @@ class LockScreenManager: ObservableObject { lastUpdated = Date() updateIdleState(locked: 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 print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") @@ -182,8 +216,9 @@ class LockScreenManager: ObservableObject { } else { debounceIdleTask?.cancel() debounceIdleTask = Task { [weak self] in - let configuredInterval = max(Defaults[.waitInterval], 0) - let idleDelay = min(max(configuredInterval, 0.2), LockScreenAnimationTimings.unlockCollapse) + // Keep the lock live activity mounted until the collapse animation finishes, + // otherwise the content disappears before the island fully closes. + let idleDelay = LockScreenAnimationTimings.unlockCollapse try? await Task.sleep(for: .seconds(idleDelay)) guard let self = self, !Task.isCancelled else { return } await MainActor.run { diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 0eee6de..d3f723d 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -43,6 +43,354 @@ let defaultImage: NSImage = .init( 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] = [:] + + 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 { [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] = [:] + + 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 { [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 = #"]*id="__NEXT_DATA__"[^>]*>(.*?)"# + guard let regex = try? NSRegularExpression( + pattern: pattern, + options: [.dotMatchesLineSeparators, .caseInsensitive] + ) else { + return nil + } + + let range = NSRange(html.startIndex.. 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.. 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(_ 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 { enum SkipDirection: Equatable { case backward @@ -82,6 +430,7 @@ class MusicManager: ObservableObject { @Published var isPlaying = false @Published var album: String = "Self Love" @Published var isPlayerIdle: Bool = true + @Published var isCurrentTrackExplicit: Bool = false /// Whether there is an active music session with real metadata. /// 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 private var lyricSyncTask: Task? + private var explicitLookupTask: Task? + private var explicitLookupKey: String? private var artworkData: Data? = nil @@ -139,6 +490,8 @@ class MusicManager: ObservableObject { private var lastArtworkArtist: String = "Me" private var lastArtworkAlbum: String = "Self Love" private var lastArtworkBundleIdentifier: String? = nil + private var lastArtworkContentIdentifier: String? = nil + private var lastArtworkContentURL: String? = nil @Published var flipAngle: Double = 0 @Published var lastFlipDirection: SkipDirection = .forward @@ -227,6 +580,7 @@ class MusicManager: ObservableObject { public func destroy() { debounceIdleTask?.cancel() + explicitLookupTask?.cancel() cancellables.removeAll() controllerCancellables.removeAll() transitionWorkItem?.cancel() @@ -342,10 +696,19 @@ class MusicManager: ObservableObject { let artistChanged = state.artist != self.lastArtworkArtist let albumChanged = state.album != self.lastArtworkAlbum let bundleChanged = state.bundleIdentifier != self.lastArtworkBundleIdentifier + let contentIdentifierChanged = state.contentIdentifier != self.lastArtworkContentIdentifier + let contentURLChanged = state.contentURL != self.lastArtworkContentURL // Check for artwork changes 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 let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange] @@ -369,14 +732,19 @@ class MusicManager: ObservableObject { self.lastArtworkArtist = state.artist self.lastArtworkAlbum = state.album self.lastArtworkBundleIdentifier = state.bundleIdentifier + self.lastArtworkContentIdentifier = state.contentIdentifier + self.lastArtworkContentURL = state.contentURL // Fetch lyrics for new track whenever content changes self.fetchLyrics() + self.refreshExplicitFlag(for: state) // Only update sneak peek if there's actual content and something changed if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { self.updateSneakPeek() } + } else if state.isExplicit != nil { + self.refreshExplicitFlag(for: state) } 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() { // Debounce: rapid metadata updates (title, artwork, bundle arriving // separately for one track change) should only produce a single flip. diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 083b913..198cd85 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -904,6 +904,10 @@ extension Defaults.Keys { ) static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: false) + static let lockScreenMusicUsesEnhancedLiquidBorder = Key( + "lockScreenMusicUsesEnhancedLiquidBorder", + default: true + ) static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) static let lockScreenMusicMergedAirPlayOutput = Key("lockScreenMusicMergedAirPlayOutput", default: true) static let lockScreenTimerWidgetUsesBlur = Key("lockScreenTimerWidgetUsesBlur", default: false) diff --git a/DynamicIsland/models/PlaybackState.swift b/DynamicIsland/models/PlaybackState.swift index 0eeea5c..516e6c2 100644 --- a/DynamicIsland/models/PlaybackState.swift +++ b/DynamicIsland/models/PlaybackState.swift @@ -34,6 +34,9 @@ struct PlaybackState { var title: String = "I'm Handsome" var artist: String = "Me" var album: String = "Self Love" + var contentIdentifier: String? + var contentURL: String? + var isExplicit: Bool? var currentTime: Double = 0 var duration: Double = 0 var playbackRate: Double = 1 @@ -50,6 +53,9 @@ extension PlaybackState: Equatable { && lhs.title == rhs.title && lhs.artist == rhs.artist && lhs.album == rhs.album + && lhs.contentIdentifier == rhs.contentIdentifier + && lhs.contentURL == rhs.contentURL + && lhs.isExplicit == rhs.isExplicit && lhs.currentTime == rhs.currentTime && lhs.duration == rhs.duration && lhs.isShuffled == rhs.isShuffled