mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Right-click on the album art in the lock screen music panel to set it as the desktop wallpaper. Right-click again or click anywhere on the background to restore the original wallpaper. Wallpaper also restores automatically on track change or screen unlock. Uses direct plist manipulation of the wallpaper config to avoid leaving traces in the wallpaper picker. Album art PNG is pre-cached in the background on track change for instant application. Adds video artwork URL fetching from iTunes catalog for Apple Music tracks (musicVideo entity search). Signed-off-by: Jis G Jacob <studiokeys@blissroms.org>
1487 lines
55 KiB
Swift
1487 lines
55 KiB
Swift
/*
|
||
* Atoll (DynamicIsland)
|
||
* Copyright (C) 2024-2026 Atoll Contributors
|
||
*
|
||
* This program is free software: you can redistribute it and/or modify
|
||
* it under the terms of the GNU General Public License as published by
|
||
* the Free Software Foundation, either version 3 of the License, or
|
||
* (at your option) any later version.
|
||
*
|
||
* This program is distributed in the hope that it will be useful,
|
||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
* GNU General Public License for more details.
|
||
*
|
||
* You should have received a copy of the GNU General Public License
|
||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
*/
|
||
|
||
import SwiftUI
|
||
import Defaults
|
||
|
||
struct LockScreenMusicPanel: View {
|
||
private struct GlassLogSnapshot: Equatable {
|
||
let style: LockScreenGlassStyle
|
||
let customizationMode: LockScreenGlassCustomizationMode
|
||
let variantRawValue: Int
|
||
let usesLiquidGlass: Bool
|
||
}
|
||
|
||
static let collapsedHeight: CGFloat = 180
|
||
static let defaultCollapsedWidth: CGFloat = 420
|
||
static var collapsedSize: CGSize {
|
||
CGSize(width: CGFloat(Defaults[.lockScreenMusicPanelWidth]), height: collapsedHeight)
|
||
}
|
||
static let expandedSize = CGSize(width: 720, height: 340)
|
||
|
||
@ObservedObject var musicManager = MusicManager.shared
|
||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
|
||
@ObservedObject private var animator: LockScreenPanelAnimator
|
||
@State private var sliderValue: Double = 0
|
||
@State private var dragging: Bool = false
|
||
@State private var lastDragged: Date = .distantPast
|
||
@State private var isActive = true
|
||
@State private var isExpanded = false
|
||
@State private var isVolumeSliderVisible = false
|
||
@State private var isAirPlayPopoverPresented = false
|
||
@State private var isArtworkFullscreen = false
|
||
|
||
@State private var collapseWorkItem: DispatchWorkItem?
|
||
@State private var parallaxResumeWorkItem: DispatchWorkItem?
|
||
@State private var isParallaxSuspended = false
|
||
@State private var lastLoggedGlassSnapshot: GlassLogSnapshot?
|
||
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
|
||
@Default(.lockScreenGlassCustomizationMode) private var glassCustomizationMode
|
||
@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
|
||
@Default(.musicControlSlots) private var slotConfig
|
||
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||
@Default(.lockScreenMusicMergedAirPlayOutput) private var mergedAirPlayOutput
|
||
@Default(.enableLyrics) private var enableLyrics
|
||
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
|
||
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
|
||
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var fullscreenArtworkEnabled
|
||
@Default(.lockScreenMusicFullscreenVideoArtwork) private var fullscreenVideoArtwork
|
||
|
||
init(animator: LockScreenPanelAnimator) {
|
||
_animator = ObservedObject(wrappedValue: animator)
|
||
}
|
||
|
||
private let collapsedPanelCornerRadius: CGFloat = 28
|
||
private let expandedPanelCornerRadius: CGFloat = 52
|
||
private let collapsedAlbumArtCornerRadius: CGFloat = 16
|
||
private let expandedAlbumArtCornerRadius: CGFloat = 60
|
||
private let expandedContentSpacing: CGFloat = 40
|
||
private let collapseTimeout: TimeInterval = 5
|
||
private let collapsedSliderExtraHeight: CGFloat = 72
|
||
private let expandedSliderExtraHeight: CGFloat = 88
|
||
private let collapsedLyricsExtraHeight: CGFloat = 64
|
||
private let expandedLyricsExtraHeight: CGFloat = 96
|
||
|
||
private var shouldUseFrostedBlur: Bool {
|
||
enableBlur && !usesLiquidGlass
|
||
}
|
||
|
||
private var currentSize: CGSize {
|
||
let base = isExpanded ? Self.expandedSize : collapsedPanelSize
|
||
return CGSize(width: base.width, height: base.height + totalExtraHeight)
|
||
}
|
||
|
||
private var collapsedPanelSize: CGSize {
|
||
CGSize(width: CGFloat(collapsedPanelWidth), height: Self.collapsedHeight)
|
||
}
|
||
|
||
private var panelCornerRadius: CGFloat {
|
||
isExpanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
|
||
}
|
||
|
||
private var usesCustomLiquidGlass: Bool {
|
||
glassCustomizationMode == .customLiquid
|
||
}
|
||
|
||
private var usesStandardLiquidGlass: Bool {
|
||
guard glassCustomizationMode == .standard else { return false }
|
||
if #available(macOS 26.0, *) {
|
||
return lockScreenGlassStyle == .liquid
|
||
}
|
||
return false
|
||
}
|
||
|
||
private var usesLiquidGlass: Bool {
|
||
usesCustomLiquidGlass || usesStandardLiquidGlass
|
||
}
|
||
|
||
private var usesEnhancedCustomLiquidBorder: Bool {
|
||
usesCustomLiquidGlass && useEnhancedLiquidBorder
|
||
}
|
||
|
||
var body: some View {
|
||
if isActive && musicManager.hasActiveSession {
|
||
panelContent
|
||
} else {
|
||
Color.clear
|
||
.frame(width: collapsedPanelSize.width, height: collapsedPanelSize.height)
|
||
}
|
||
}
|
||
|
||
private var panelContent: some View {
|
||
ZStack(alignment: .topLeading) {
|
||
panelBackgroundLayer
|
||
panelForeground
|
||
}
|
||
.frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading)
|
||
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
|
||
.overlay {
|
||
if showPanelBorder {
|
||
panelBorderOverlay
|
||
}
|
||
}
|
||
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
|
||
.contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
|
||
.animation(.easeInOut(duration: 0.28), value: isExpanded)
|
||
.animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
|
||
.onAppear {
|
||
sliderValue = musicManager.elapsedTime
|
||
isActive = true
|
||
logPanelAppearance()
|
||
updatePanelSize(animated: false)
|
||
routeManager.refreshDevices()
|
||
if isAppleMusicActive {
|
||
Task { await airPlayManager.refreshDevices() }
|
||
}
|
||
logGlassState(reason: "Panel appeared")
|
||
}
|
||
.onDisappear {
|
||
isActive = false
|
||
cancelCollapseTimer()
|
||
isVolumeSliderVisible = false
|
||
parallaxResumeWorkItem?.cancel()
|
||
parallaxResumeWorkItem = nil
|
||
isParallaxSuspended = false
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .atollArtworkWallpaperDismissed)) { _ in
|
||
withAnimation(.easeInOut(duration: 0.28)) {
|
||
isArtworkFullscreen = false
|
||
}
|
||
}
|
||
.onChange(of: isExpanded) { _, expanded in
|
||
updatePanelSize()
|
||
}
|
||
.onChange(of: showMediaOutputControl) { _, enabled in
|
||
if !enabled {
|
||
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
||
isVolumeSliderVisible = false
|
||
}
|
||
}
|
||
updatePanelSize()
|
||
}
|
||
.onChange(of: isVolumeSliderVisible) { _, visible in
|
||
if useMergedAirPlayOutput {
|
||
if visible && isAppleMusicActive {
|
||
Task { await airPlayManager.refreshDevices() }
|
||
}
|
||
}
|
||
updatePanelSize()
|
||
}
|
||
.onChange(of: airPlayManager.devices) { _, _ in
|
||
if useMergedAirPlayOutput {
|
||
updatePanelSize()
|
||
}
|
||
}
|
||
.onChange(of: routeManager.devices) { _, _ in
|
||
if !useMergedAirPlayOutput {
|
||
updatePanelSize()
|
||
}
|
||
}
|
||
.onChange(of: enableLyrics) { _, _ in
|
||
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
||
updatePanelSize()
|
||
}
|
||
}
|
||
.onChange(of: lockScreenGlassStyle) { _, _ in
|
||
logGlassState(reason: "Glass style updated")
|
||
}
|
||
.onChange(of: glassCustomizationMode) { _, _ in
|
||
logGlassState(reason: "Glass mode updated")
|
||
}
|
||
.onChange(of: musicGlassVariant) { _, _ in
|
||
if usesCustomLiquidGlass {
|
||
logGlassState(reason: "Liquid variant updated")
|
||
}
|
||
}
|
||
.scaleEffect(animator.isPresented ? 1 : 0.9, anchor: .center)
|
||
.opacity(animator.isPresented ? 1 : 0)
|
||
.animation(.spring(response: 0.52, dampingFraction: 0.8), value: animator.isPresented)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var panelForeground: some View {
|
||
Group {
|
||
if isExpanded {
|
||
expandedLayout
|
||
} else {
|
||
collapsedLayout
|
||
}
|
||
}
|
||
.padding(.horizontal, isExpanded ? 24 : 20)
|
||
.padding(.vertical, isExpanded ? 22 : 16)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
}
|
||
|
||
private var collapsedLayout: some View {
|
||
VStack(spacing: 12) {
|
||
collapsedHeader
|
||
progressBar
|
||
.padding(.top, 4)
|
||
.frame(maxWidth: .infinity)
|
||
playbackControls(alignment: .center)
|
||
.padding(.top, 4)
|
||
}
|
||
}
|
||
|
||
private var expandedLayout: some View {
|
||
HStack(alignment: .center, spacing: expandedContentSpacing) {
|
||
albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius)
|
||
.frame(width: 230, height: 230)
|
||
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
expandedHeader
|
||
progressBar
|
||
.padding(.top, 10)
|
||
.frame(maxWidth: .infinity)
|
||
playbackControls(alignment: .leading)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(maxHeight: .infinity, alignment: .center)
|
||
}
|
||
|
||
private var collapsedHeader: some View {
|
||
HStack(alignment: .center, spacing: 16) {
|
||
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius)
|
||
|
||
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)
|
||
}
|
||
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: 30, maxHeight: 30, alignment: .leading)
|
||
|
||
visualizer(width: 20, height: 16)
|
||
}
|
||
.frame(height: 60)
|
||
}
|
||
|
||
private var expandedHeader: some View {
|
||
HStack(alignment: .top, spacing: 16) {
|
||
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)
|
||
}
|
||
.frame(width: geo.size.width, height: geo.size.height, alignment: .leading)
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: 42, alignment: .leading)
|
||
|
||
visualizer(width: 24, height: 20)
|
||
}
|
||
}
|
||
|
||
private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View {
|
||
ZStack(alignment: .bottomTrailing) {
|
||
if isArtworkFullscreen {
|
||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||
.fill(Color.white.opacity(0.06))
|
||
.overlay(
|
||
Image(systemName: "photo.fill")
|
||
.font(.system(size: size * 0.3, weight: .light))
|
||
.foregroundColor(.white.opacity(0.2))
|
||
)
|
||
} else {
|
||
albumArtImage(size: size, cornerRadius: cornerRadius)
|
||
if showAppIcon, let icon = lockScreenAppIcon {
|
||
icon
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fit)
|
||
.frame(width: appIconSize, height: appIconSize)
|
||
.shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4)
|
||
.offset(x: appIconOffset, y: appIconOffset)
|
||
.transition(.scale.combined(with: .opacity))
|
||
}
|
||
}
|
||
}
|
||
.albumArtFlip(angle: isArtworkFullscreen ? 0 : musicManager.flipAngle)
|
||
.parallax3D(enableOverride: lockScreenParallaxEnabled && !isArtworkFullscreen, suspended: isParallaxSuspended)
|
||
.frame(width: size)
|
||
.background(albumArtBackground(cornerRadius: cornerRadius))
|
||
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? appIconCornerRadius/3 : appIconCornerRadius, style: .continuous))
|
||
.opacity(musicManager.isPlaying ? 1 : 0.4)
|
||
.scaleEffect(musicManager.isPlaying ? 1 : 0.85)
|
||
.animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying)
|
||
.animation(.easeInOut(duration: 0.28), value: isArtworkFullscreen)
|
||
.onTapGesture {
|
||
toggleExpanded()
|
||
}
|
||
.onRightClick {
|
||
expandArtworkToFullscreen()
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func visualizer(width: CGFloat, height: CGFloat) -> some View {
|
||
if Defaults[.useMusicVisualizer] {
|
||
Rectangle()
|
||
.fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient)
|
||
.mask {
|
||
AudioVisualizerView(isPlaying: .constant(musicManager.isPlaying))
|
||
.frame(width: width, height: height)
|
||
}
|
||
.frame(width: width, height: height)
|
||
}
|
||
}
|
||
|
||
private func toggleExpanded() {
|
||
let newState = !isExpanded
|
||
suspendParallaxInteraction()
|
||
withAnimation(.easeInOut(duration: 0.28)) {
|
||
isExpanded = newState
|
||
}
|
||
|
||
if newState {
|
||
registerInteraction()
|
||
logPanelAppearance(event: "🔍 Expanded")
|
||
} else {
|
||
logPanelAppearance(event: "⬇️ Collapsed")
|
||
cancelCollapseTimer()
|
||
}
|
||
}
|
||
|
||
private func expandArtworkToFullscreen() {
|
||
guard fullscreenArtworkEnabled else { return }
|
||
|
||
if FullScreenArtworkWindowManager.shared.isShowing {
|
||
FullScreenArtworkWindowManager.shared.hide()
|
||
withAnimation(.easeInOut(duration: 0.28)) {
|
||
isArtworkFullscreen = false
|
||
}
|
||
return
|
||
}
|
||
|
||
guard musicManager.hasActiveSession else { return }
|
||
|
||
let artwork = musicManager.albumArt
|
||
let videoURL = musicManager.videoArtworkURL
|
||
|
||
withAnimation(.easeInOut(duration: 0.28)) {
|
||
isArtworkFullscreen = true
|
||
}
|
||
|
||
FullScreenArtworkWindowManager.shared.onDismiss = {
|
||
Task { @MainActor in
|
||
withAnimation(.easeInOut(duration: 0.28)) {
|
||
NotificationCenter.default.post(name: .atollArtworkWallpaperDismissed, object: nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
FullScreenArtworkWindowManager.shared.show(
|
||
artwork: artwork,
|
||
videoURL: fullscreenVideoArtwork ? videoURL : nil,
|
||
allowLiveWallpaper: fullscreenVideoArtwork
|
||
)
|
||
}
|
||
|
||
private func registerInteraction() {
|
||
cancelCollapseTimer()
|
||
guard isExpanded else { return }
|
||
|
||
let workItem = DispatchWorkItem {
|
||
suspendParallaxInteraction()
|
||
withAnimation(.easeInOut(duration: 0.28)) {
|
||
isExpanded = false
|
||
}
|
||
logPanelAppearance(event: "⏱️ Auto-collapsed")
|
||
}
|
||
|
||
collapseWorkItem = workItem
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + collapseTimeout, execute: workItem)
|
||
}
|
||
|
||
private func suspendParallaxInteraction(for duration: TimeInterval = 0.65) {
|
||
parallaxResumeWorkItem?.cancel()
|
||
isParallaxSuspended = true
|
||
|
||
let workItem = DispatchWorkItem {
|
||
isParallaxSuspended = false
|
||
}
|
||
parallaxResumeWorkItem = workItem
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: workItem)
|
||
}
|
||
|
||
private func cancelCollapseTimer() {
|
||
collapseWorkItem?.cancel()
|
||
collapseWorkItem = nil
|
||
}
|
||
|
||
private var isProgressTimelinePaused: Bool {
|
||
!musicManager.isPlaying || musicManager.isLiveStream || musicManager.playbackRate <= 0
|
||
}
|
||
|
||
// MARK: - Progress Bar
|
||
|
||
private var progressBar: some View {
|
||
TimelineView(
|
||
.animation(
|
||
minimumInterval: 1.0,
|
||
paused: isProgressTimelinePaused
|
||
)
|
||
) { timeline in
|
||
MusicSliderView(
|
||
sliderValue: $sliderValue,
|
||
duration: Binding(
|
||
get: { musicManager.songDuration },
|
||
set: { musicManager.songDuration = $0 }
|
||
),
|
||
lastDragged: $lastDragged,
|
||
color: musicManager.avgColor,
|
||
dragging: $dragging,
|
||
currentDate: timeline.date,
|
||
timestampDate: musicManager.timestampDate,
|
||
elapsedTime: musicManager.elapsedTime,
|
||
playbackRate: musicManager.playbackRate,
|
||
isPlaying: musicManager.isPlaying,
|
||
isLiveStream: musicManager.isLiveStream,
|
||
onValueChange: { newValue in
|
||
registerInteraction()
|
||
musicManager.seek(to: newValue)
|
||
},
|
||
labelLayout: .inline,
|
||
trailingLabel: .remaining,
|
||
restingTrackHeight: 7,
|
||
draggingTrackHeight: 11
|
||
)
|
||
}
|
||
.onAppear {
|
||
sliderValue = musicManager.elapsedTime
|
||
}
|
||
.onChange(of: musicManager.isLiveStream) { _, isLive in
|
||
if isLive {
|
||
dragging = false
|
||
sliderValue = 0
|
||
}
|
||
}
|
||
}
|
||
|
||
private var sliderColor: Color {
|
||
switch Defaults[.sliderColor] {
|
||
case .white:
|
||
return .white
|
||
case .albumArt:
|
||
return Color(nsColor: musicManager.avgColor)
|
||
case .accent:
|
||
return .accentColor
|
||
}
|
||
}
|
||
|
||
private var brandAccentColor: Color {
|
||
musicManager.brandAccentColor
|
||
}
|
||
|
||
// MARK: - Playback Controls
|
||
|
||
private func playbackControls(alignment: Alignment) -> some View {
|
||
let spacing: CGFloat = isExpanded ? 24 : 20
|
||
let verticalSpacing: CGFloat = (shouldShowVolumeSlider || enableLyrics) ? 14 : 10
|
||
|
||
return VStack(spacing: verticalSpacing) {
|
||
controlsRow(alignment: alignment, spacing: spacing)
|
||
|
||
if shouldShowVolumeSlider {
|
||
volumeSlider
|
||
.frame(maxWidth: .infinity, alignment: alignment)
|
||
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
|
||
|
||
if shouldShowAirPlay {
|
||
airPlaySection
|
||
.frame(maxWidth: .infinity, alignment: alignment)
|
||
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
|
||
} else if shouldShowRouteSelector {
|
||
mediaOutputDevicesSection
|
||
.frame(maxWidth: .infinity, alignment: alignment)
|
||
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
|
||
}
|
||
}
|
||
|
||
if enableLyrics {
|
||
lyricsSection(alignment: alignment)
|
||
.transition(.move(edge: .top).combined(with: .opacity))
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: alignment)
|
||
.padding(.top, isExpanded ? 6 : 2)
|
||
.animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
|
||
.animation(.easeInOut(duration: 0.24), value: shouldShowAirPlay)
|
||
.animation(.easeInOut(duration: 0.24), value: shouldShowRouteSelector)
|
||
.animation(.easeInOut(duration: 0.24), value: enableLyrics)
|
||
}
|
||
|
||
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
|
||
let skipNudge: CGFloat = isExpanded ? 14 : 9
|
||
|
||
return HStack(spacing: spacing) {
|
||
ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in
|
||
slotView(for: slot, skipNudge: skipNudge)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: alignment)
|
||
}
|
||
|
||
private var displayedSlots: [MusicControlButton] {
|
||
guard showShuffleAndRepeat else {
|
||
return fallbackSlots
|
||
}
|
||
|
||
let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive)
|
||
return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout
|
||
}
|
||
|
||
private var fallbackSlots: [MusicControlButton] {
|
||
switch musicSkipBehavior {
|
||
case .track:
|
||
return MusicControlButton.minimalLayout
|
||
case .tenSecond:
|
||
return [.none, .seekBackward, .playPause, .seekForward, .none]
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func slotView(for control: MusicControlButton, skipNudge: CGFloat) -> some View {
|
||
let seekInterval: TimeInterval = 10
|
||
|
||
switch control {
|
||
case .none:
|
||
Spacer(minLength: 0)
|
||
case .playPause:
|
||
playPauseButton
|
||
case .trackBackward:
|
||
controlButton(
|
||
icon: "backward.fill",
|
||
size: 18,
|
||
interaction: .nudge(-skipNudge),
|
||
symbolEffect: .replace
|
||
) {
|
||
musicManager.previousTrack()
|
||
}
|
||
case .trackForward:
|
||
controlButton(
|
||
icon: "forward.fill",
|
||
size: 18,
|
||
interaction: .nudge(skipNudge),
|
||
symbolEffect: .replace
|
||
) {
|
||
musicManager.nextTrack()
|
||
}
|
||
case .seekBackward:
|
||
controlButton(
|
||
icon: "gobackward.10",
|
||
size: 18,
|
||
interaction: .wiggle(.counterClockwise),
|
||
symbolEffect: .wiggle
|
||
) {
|
||
musicManager.seek(by: -seekInterval)
|
||
}
|
||
case .seekForward:
|
||
controlButton(
|
||
icon: "goforward.10",
|
||
size: 18,
|
||
interaction: .wiggle(.clockwise),
|
||
symbolEffect: .wiggle
|
||
) {
|
||
musicManager.seek(by: seekInterval)
|
||
}
|
||
case .shuffle:
|
||
controlButton(
|
||
icon: "shuffle",
|
||
size: 18,
|
||
isActive: musicManager.isShuffled,
|
||
activeColor: brandAccentColor
|
||
) {
|
||
musicManager.toggleShuffle()
|
||
}
|
||
case .repeatMode:
|
||
controlButton(
|
||
icon: repeatIcon,
|
||
size: 18,
|
||
isActive: musicManager.repeatMode != .off,
|
||
activeColor: brandAccentColor,
|
||
symbolEffect: .replace
|
||
) {
|
||
musicManager.toggleRepeat()
|
||
}
|
||
case .mediaOutput:
|
||
mediaOutputControlButton
|
||
case .airPlay:
|
||
if useMergedAirPlayOutput {
|
||
mediaOutputControlButton
|
||
} else {
|
||
standaloneAirPlayButton
|
||
}
|
||
case .lyrics:
|
||
controlButton(
|
||
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
|
||
size: 18,
|
||
isActive: enableLyrics,
|
||
activeColor: brandAccentColor,
|
||
symbolEffect: .replace
|
||
) {
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) {
|
||
enableLyrics.toggle()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var playPauseButton: some View {
|
||
let frameSize: CGFloat = isExpanded ? 80 : 54
|
||
let iconName = musicManager.isPlaying ? "pause.fill" : "play.fill"
|
||
|
||
return HoverButton(
|
||
icon: iconName,
|
||
iconColor: .white,
|
||
scale: .large,
|
||
pressEffect: nil
|
||
) {
|
||
registerInteraction()
|
||
musicManager.togglePlay()
|
||
}
|
||
.frame(width: frameSize, height: frameSize)
|
||
}
|
||
|
||
private func controlButton(
|
||
icon: String,
|
||
size: CGFloat = 18,
|
||
isActive: Bool = false,
|
||
activeColor: Color? = nil,
|
||
interaction: PanelControlButton.Interaction = .none,
|
||
symbolEffect: PanelControlButton.SymbolEffectStyle = .replace,
|
||
action: @escaping () -> Void
|
||
) -> some View {
|
||
let resolvedActiveColor = activeColor ?? brandAccentColor
|
||
let frameSize: CGFloat = isExpanded ? 56 : 32
|
||
let iconSize: CGFloat = isExpanded ? max(size, 24) : size
|
||
let iconColor = isActive ? resolvedActiveColor : .white.opacity(0.8)
|
||
let backgroundOpacity: Double = isActive ? 0.22 : 0.0
|
||
|
||
return PanelControlButton(
|
||
icon: icon,
|
||
frameSize: frameSize,
|
||
iconSize: iconSize,
|
||
iconColor: iconColor,
|
||
backgroundOpacity: backgroundOpacity,
|
||
interaction: interaction,
|
||
symbolEffect: symbolEffect
|
||
) {
|
||
registerInteraction()
|
||
action()
|
||
}
|
||
}
|
||
|
||
private var mediaOutputControlButton: some View {
|
||
let frameSize: CGFloat = isExpanded ? 56 : 32
|
||
let iconSize: CGFloat = isExpanded ? 26 : 18
|
||
|
||
return PanelControlButton(
|
||
icon: mediaOutputIcon,
|
||
frameSize: frameSize,
|
||
iconSize: iconSize,
|
||
iconColor: shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8),
|
||
backgroundOpacity: shouldShowVolumeSlider ? 0.22 : 0.0,
|
||
interaction: .none,
|
||
symbolEffect: .replace,
|
||
action: toggleVolumeSlider
|
||
)
|
||
.accessibilityLabel("Media output")
|
||
}
|
||
|
||
private var standaloneAirPlayButton: some View {
|
||
let frameSize: CGFloat = isExpanded ? 56 : 32
|
||
let iconSize: CGFloat = isExpanded ? 26 : 18
|
||
|
||
return PanelControlButton(
|
||
icon: "airplayaudio",
|
||
frameSize: frameSize,
|
||
iconSize: iconSize,
|
||
iconColor: isAirPlayPopoverPresented ? .accentColor : .white.opacity(0.8),
|
||
backgroundOpacity: isAirPlayPopoverPresented ? 0.22 : 0.0,
|
||
interaction: .none,
|
||
symbolEffect: .replace
|
||
) {
|
||
registerInteraction()
|
||
isAirPlayPopoverPresented.toggle()
|
||
if isAirPlayPopoverPresented {
|
||
Task { await airPlayManager.refreshDevices() }
|
||
}
|
||
}
|
||
.accessibilityLabel("AirPlay")
|
||
.popover(isPresented: $isAirPlayPopoverPresented, arrowEdge: .bottom) {
|
||
AirPlaySelectorPopover(
|
||
airPlayManager: airPlayManager,
|
||
onHoverChanged: { _ in },
|
||
dismiss: { isAirPlayPopoverPresented = false }
|
||
)
|
||
}
|
||
}
|
||
|
||
private func lyricsSection(alignment: Alignment) -> some View {
|
||
let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let transition: AnyTransition = .asymmetric(
|
||
insertion: .move(edge: .bottom).combined(with: .opacity),
|
||
removal: .move(edge: .top).combined(with: .opacity)
|
||
)
|
||
|
||
return HStack(spacing: 8) {
|
||
if !line.isEmpty {
|
||
Image(systemName: "music.note")
|
||
.font(.system(size: isExpanded ? 14 : 12, weight: .semibold))
|
||
.foregroundColor(.white.opacity(0.7))
|
||
.symbolRenderingMode(.monochrome)
|
||
|
||
Text(line)
|
||
.font(.system(size: isExpanded ? 14 : 12, weight: .semibold))
|
||
.foregroundColor(.white.opacity(0.88))
|
||
.lineLimit(2)
|
||
.multilineTextAlignment(.leading)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.trailing, 6)
|
||
.id(line)
|
||
.transition(transition)
|
||
}
|
||
}
|
||
.padding(.horizontal, isExpanded ? 10 : 8)
|
||
.padding(.top, isExpanded ? 12 : 8)
|
||
.frame(maxWidth: .infinity, alignment: alignment)
|
||
.animation(.smooth(duration: 0.32), value: line)
|
||
}
|
||
|
||
private var repeatIcon: String {
|
||
switch musicManager.repeatMode {
|
||
case .off: return "repeat"
|
||
case .all: return "repeat"
|
||
case .one: return "repeat.1"
|
||
}
|
||
}
|
||
|
||
private var mediaOutputIcon: String {
|
||
routeManager.activeDevice?.iconName ?? "speaker.wave.2"
|
||
}
|
||
|
||
private var volumeSlider: some View {
|
||
HStack(spacing: 14) {
|
||
Image(systemName: volumeIconName)
|
||
.font(.system(size: isExpanded ? 16 : 14, weight: .semibold))
|
||
.foregroundColor(.white.opacity(0.8))
|
||
|
||
Slider(
|
||
value: Binding(
|
||
get: { Double(volumeModel.level) },
|
||
set: { newValue in
|
||
registerInteraction()
|
||
volumeModel.setVolume(Float(newValue))
|
||
}
|
||
),
|
||
in: 0 ... 1
|
||
)
|
||
.tint(sliderColor)
|
||
|
||
Text(volumePercentage)
|
||
.font(.system(size: isExpanded ? 12 : 11, weight: .medium, design: .monospaced))
|
||
.foregroundColor(.white.opacity(0.7))
|
||
.frame(width: 48, alignment: .trailing)
|
||
}
|
||
.padding(.vertical, 8)
|
||
.padding(.horizontal, 12)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
|
||
.fill(sliderBackgroundFill)
|
||
)
|
||
}
|
||
|
||
private var airPlaySection: some View {
|
||
accessorySectionContainer {
|
||
if airPlayManager.devices.isEmpty {
|
||
Text("No AirPlay outputs available")
|
||
.font(.system(size: isExpanded ? 13 : 11, weight: .medium))
|
||
.foregroundColor(.white.opacity(0.68))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.vertical, 8)
|
||
.padding(.horizontal, 12)
|
||
} else {
|
||
ScrollView(.vertical, showsIndicators: true) {
|
||
VStack(spacing: 6) {
|
||
ForEach(airPlayManager.devices) { device in
|
||
VStack(spacing: 4) {
|
||
Button {
|
||
registerInteraction()
|
||
Task { await airPlayManager.toggleDevice(device) }
|
||
} label: {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: device.iconName)
|
||
.font(.system(size: isExpanded ? 14 : 12, weight: .medium))
|
||
.foregroundColor(.white.opacity(0.8))
|
||
Text(device.name)
|
||
.font(.system(size: isExpanded ? 13 : 11, weight: .medium))
|
||
.foregroundColor(.white)
|
||
.lineLimit(1)
|
||
Spacer()
|
||
if device.isSelected {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: isExpanded ? 12 : 10, weight: .bold))
|
||
.foregroundColor(.accentColor)
|
||
}
|
||
}
|
||
.padding(.vertical, 4)
|
||
.padding(.horizontal, 12)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
if device.isSelected {
|
||
AirPlayVolumeSlider(
|
||
airPlayManager: airPlayManager,
|
||
deviceID: device.id
|
||
)
|
||
.padding(.horizontal, 12)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(maxHeight: accessorySectionScrollMaxHeight)
|
||
}
|
||
}
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
private var mediaOutputDevicesSection: some View {
|
||
accessorySectionContainer {
|
||
if routeManager.devices.isEmpty {
|
||
Text("No audio outputs available")
|
||
.font(.system(size: isExpanded ? 13 : 11, weight: .medium))
|
||
.foregroundColor(.white.opacity(0.68))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.vertical, 8)
|
||
.padding(.horizontal, 12)
|
||
} else {
|
||
ScrollView(.vertical, showsIndicators: true) {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
ForEach(routeManager.devices) { device in
|
||
Button {
|
||
registerInteraction()
|
||
routeManager.select(device: device)
|
||
} label: {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: device.iconName)
|
||
.font(.system(size: isExpanded ? 14 : 12, weight: .medium))
|
||
.foregroundColor(.white.opacity(0.82))
|
||
Text(device.name)
|
||
.font(.system(size: isExpanded ? 13 : 11, weight: .medium))
|
||
.foregroundColor(.white)
|
||
.lineLimit(1)
|
||
Spacer()
|
||
if device.id == routeManager.activeDeviceID {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: isExpanded ? 12 : 10, weight: .bold))
|
||
.foregroundColor(.accentColor)
|
||
}
|
||
}
|
||
.padding(.vertical, 5)
|
||
.padding(.horizontal, 12)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(maxHeight: accessorySectionScrollMaxHeight)
|
||
}
|
||
}
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
private func accessorySectionContainer<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||
content()
|
||
.background(
|
||
RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
|
||
.fill(sliderBackgroundFill)
|
||
)
|
||
}
|
||
|
||
private var sliderBackgroundFill: Color {
|
||
if usesLiquidGlass {
|
||
return Color.white.opacity(0.05)
|
||
}
|
||
return Color.white.opacity(0.08)
|
||
}
|
||
|
||
private var isAppleMusicActive: Bool {
|
||
musicManager.bundleIdentifier == "com.apple.Music"
|
||
}
|
||
|
||
private var useMergedAirPlayOutput: Bool {
|
||
mergedAirPlayOutput && isAppleMusicActive && slotConfig.contains(where: { $0 == .mediaOutput || $0 == .airPlay })
|
||
}
|
||
|
||
private var shouldShowAirPlay: Bool {
|
||
useMergedAirPlayOutput && shouldShowVolumeSlider && !airPlayManager.devices.isEmpty
|
||
}
|
||
|
||
private var shouldShowRouteSelector: Bool {
|
||
!useMergedAirPlayOutput && shouldShowVolumeSlider
|
||
}
|
||
|
||
private var shouldShowAccessorySection: Bool {
|
||
shouldShowAirPlay || shouldShowRouteSelector
|
||
}
|
||
|
||
private var shouldShowVolumeSlider: Bool {
|
||
showMediaOutputControl && isVolumeSliderVisible
|
||
}
|
||
|
||
private var sliderExtraHeight: CGFloat {
|
||
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
||
}
|
||
|
||
private var accessorySectionScrollMaxHeight: CGFloat {
|
||
isExpanded ? 170 : 130
|
||
}
|
||
|
||
private var lyricsExtraHeight: CGFloat {
|
||
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics)
|
||
}
|
||
|
||
private var accessorySectionExtraHeight: CGFloat {
|
||
guard shouldShowAccessorySection else { return 0 }
|
||
if shouldShowAirPlay {
|
||
let selectedCount = airPlayManager.devices.filter(\.isSelected).count
|
||
let totalCount = airPlayManager.devices.count
|
||
let deviceRows: CGFloat = CGFloat(totalCount) * 30 + CGFloat(max(totalCount - 1, 0)) * 6
|
||
let sliders: CGFloat = CGFloat(selectedCount) * 34
|
||
return min(deviceRows + sliders + 24, accessorySectionScrollMaxHeight + 16)
|
||
}
|
||
|
||
let totalCount = max(routeManager.devices.count, 1)
|
||
let deviceRows: CGFloat = CGFloat(totalCount) * 30 + CGFloat(max(totalCount - 1, 0)) * 6
|
||
return min(deviceRows + 24, accessorySectionScrollMaxHeight + 16)
|
||
}
|
||
|
||
private var totalExtraHeight: CGFloat {
|
||
sliderExtraHeight + accessorySectionExtraHeight + lyricsExtraHeight
|
||
}
|
||
|
||
private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat {
|
||
guard enabled else { return 0 }
|
||
return expanded ? expandedLyricsExtraHeight : collapsedLyricsExtraHeight
|
||
}
|
||
|
||
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
|
||
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
|
||
accessorySectionExtraHeight +
|
||
lyricsHeight(forExpanded: expanded, enabled: enableLyrics)
|
||
}
|
||
|
||
private func updatePanelSize(animated: Bool = true) {
|
||
LockScreenPanelManager.shared.updatePanelSize(
|
||
expanded: isExpanded,
|
||
additionalHeight: panelAdditionalHeight(forExpanded: isExpanded),
|
||
animated: animated
|
||
)
|
||
}
|
||
|
||
private var volumeIconName: String {
|
||
if volumeModel.isMuted || volumeModel.level <= 0.001 {
|
||
return "speaker.slash.fill"
|
||
} else if volumeModel.level < 0.33 {
|
||
return "speaker.wave.1.fill"
|
||
} else if volumeModel.level < 0.66 {
|
||
return "speaker.wave.2.fill"
|
||
}
|
||
return "speaker.wave.3.fill"
|
||
}
|
||
|
||
private var volumePercentage: String {
|
||
"\(Int(round(volumeModel.level * 100)))%"
|
||
}
|
||
|
||
private func toggleVolumeSlider() {
|
||
guard showMediaOutputControl else {
|
||
isVolumeSliderVisible = false
|
||
return
|
||
}
|
||
|
||
registerInteraction()
|
||
let newState = !isVolumeSliderVisible
|
||
if newState {
|
||
routeManager.refreshDevices()
|
||
if useMergedAirPlayOutput {
|
||
Task { await airPlayManager.refreshDevices() }
|
||
}
|
||
}
|
||
|
||
withAnimation(.easeInOut(duration: 0.24)) {
|
||
isVolumeSliderVisible = newState
|
||
}
|
||
|
||
updatePanelSize()
|
||
}
|
||
|
||
private func sliderHeight(forExpanded expanded: Bool, visible: Bool) -> CGFloat {
|
||
guard visible else { return 0 }
|
||
return expanded ? expandedSliderExtraHeight : collapsedSliderExtraHeight
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var panelBackgroundLayer: some View {
|
||
if usesCustomLiquidGlass {
|
||
customLiquidPanelBackdrop
|
||
} else if usesStandardLiquidGlass {
|
||
standardLiquidPanelBackdrop
|
||
} else if shouldUseFrostedBlur {
|
||
frostedPanelBackground
|
||
} else {
|
||
opaquePanelBackground
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var standardLiquidPanelBackdrop: some View {
|
||
if #available(macOS 26.0, *) {
|
||
GlassTextBackdrop(cornerRadius: panelCornerRadius)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.allowsHitTesting(false)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var customLiquidPanelBackdrop: some View {
|
||
LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: panelCornerRadius) {
|
||
Color.white.opacity(0.04)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.allowsHitTesting(false)
|
||
.accessibilityHidden(true)
|
||
}
|
||
|
||
private var frostedPanelBackground: some View {
|
||
RoundedRectangle(cornerRadius: panelCornerRadius)
|
||
.fill(.ultraThinMaterial)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.allowsHitTesting(false)
|
||
.accessibilityHidden(true)
|
||
}
|
||
|
||
private var opaquePanelBackground: some View {
|
||
RoundedRectangle(cornerRadius: panelCornerRadius)
|
||
.fill(Color.black.opacity(0.45))
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.allowsHitTesting(false)
|
||
.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()
|
||
.aspectRatio(contentMode: .fit)
|
||
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? cornerRadius/3 : cornerRadius))
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func albumArtBackground(cornerRadius: CGFloat) -> some View {
|
||
if usesCustomLiquidGlass {
|
||
customLiquidAlbumArtBackground(cornerRadius: cornerRadius)
|
||
} else if usesStandardLiquidGlass {
|
||
if #available(macOS 26.0, *) {
|
||
clearLiquidGlassSurface(cornerRadius: cornerRadius)
|
||
}
|
||
} else if shouldUseFrostedBlur {
|
||
RoundedRectangle(cornerRadius: cornerRadius)
|
||
.fill(.ultraThinMaterial)
|
||
} else {
|
||
RoundedRectangle(cornerRadius: cornerRadius)
|
||
.fill(Color.black.opacity(0.35))
|
||
}
|
||
}
|
||
|
||
private var lockScreenAppIcon: Image? {
|
||
guard showAppIcon, !musicManager.usingAppIconForArtwork else { return nil }
|
||
let bundleIdentifier = musicManager.bundleIdentifier ?? "com.apple.Music"
|
||
return AppIcon(for: bundleIdentifier)
|
||
}
|
||
|
||
private var appIconSize: CGFloat {
|
||
isExpanded ? 58 : 34
|
||
}
|
||
|
||
private var appIconCornerRadius: CGFloat {
|
||
isExpanded ? 18 : 12
|
||
}
|
||
|
||
private var appIconOffset: CGFloat {
|
||
isExpanded ? 18 : 12
|
||
}
|
||
|
||
@available(macOS 26.0, *)
|
||
private func clearLiquidGlassSurface(cornerRadius: CGFloat) -> some View {
|
||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||
.fill(.clear)
|
||
.glassEffect(
|
||
.clear.interactive(),
|
||
in: .rect(cornerRadius: cornerRadius)
|
||
)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func customLiquidAlbumArtBackground(cornerRadius: CGFloat) -> some View {
|
||
LiquidGlassBackground(variant: musicGlassVariant, cornerRadius: cornerRadius) {
|
||
Color.clear
|
||
}
|
||
}
|
||
|
||
private func logGlassState(reason: String) {
|
||
let snapshot = GlassLogSnapshot(
|
||
style: lockScreenGlassStyle,
|
||
customizationMode: glassCustomizationMode,
|
||
variantRawValue: musicGlassVariant.rawValue,
|
||
usesLiquidGlass: usesLiquidGlass
|
||
)
|
||
guard snapshot != lastLoggedGlassSnapshot else { return }
|
||
lastLoggedGlassSnapshot = snapshot
|
||
|
||
struct ComponentState {
|
||
let name: String
|
||
let isLiquid: Bool
|
||
}
|
||
|
||
let states = [
|
||
ComponentState(name: "Panel Shell", isLiquid: usesLiquidGlass),
|
||
ComponentState(name: "Control Capsules", isLiquid: usesLiquidGlass),
|
||
ComponentState(name: "Volume Slider", isLiquid: usesLiquidGlass),
|
||
ComponentState(name: "Album Art Plate", isLiquid: usesLiquidGlass)
|
||
]
|
||
|
||
let componentSummary = states.map { entry -> String in
|
||
let mode = entry.isLiquid ? "Liquid" : "Frosted"
|
||
return "\(entry.name)=\(mode)"
|
||
}.joined(separator: ", ")
|
||
|
||
let modeDescription: String
|
||
if usesCustomLiquidGlass {
|
||
modeDescription = "Custom Liquid (variant \(musicGlassVariant.rawValue))"
|
||
} else if usesStandardLiquidGlass {
|
||
modeDescription = "Standard Liquid"
|
||
} else {
|
||
modeDescription = lockScreenGlassStyle.rawValue
|
||
}
|
||
|
||
print("[LockScreenMusicPanel] \(reason) – customization=\(glassCustomizationMode.rawValue), mode=\(modeDescription), components[\(componentSummary)], macOS \(currentOSVersionDescription())")
|
||
|
||
if glassCustomizationMode == .standard && lockScreenGlassStyle == .liquid && !usesStandardLiquidGlass {
|
||
print("[LockScreenMusicPanel] Liquid Glass requested but unavailable on this macOS build. Falling back to frosted visuals.")
|
||
}
|
||
}
|
||
|
||
private func currentOSVersionDescription() -> String {
|
||
let version = ProcessInfo.processInfo.operatingSystemVersion
|
||
return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
|
||
}
|
||
|
||
private func logPanelAppearance(event: String = "✅ View appeared") {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "HH:mm:ss.SSS"
|
||
let styleDescriptor = usesLiquidGlass ? "Liquid Glass" : "Frosted"
|
||
print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: \(event) – \(styleDescriptor)")
|
||
}
|
||
}
|
||
|
||
private struct PanelControlButton: View {
|
||
let icon: String
|
||
let frameSize: CGFloat
|
||
let iconSize: CGFloat
|
||
let iconColor: Color
|
||
let backgroundOpacity: Double
|
||
let interaction: Interaction
|
||
let symbolEffect: SymbolEffectStyle
|
||
let action: () -> Void
|
||
|
||
@State private var isHovering = false
|
||
@State private var pressOffset: CGFloat = 0
|
||
@State private var rotationAngle: Double = 0
|
||
@State private var wiggleToken: Int = 0
|
||
|
||
var body: some View {
|
||
Button(action: {
|
||
triggerPressEffect()
|
||
action()
|
||
}) {
|
||
RoundedRectangle(cornerRadius: frameSize / 2, style: .continuous)
|
||
.fill(backgroundColor)
|
||
.overlay(
|
||
iconView
|
||
)
|
||
}
|
||
.frame(width: frameSize, height: frameSize)
|
||
.buttonStyle(PlainButtonStyle())
|
||
.offset(x: pressOffset)
|
||
.rotationEffect(.degrees(rotationAngle))
|
||
.onHover { hovering in
|
||
withAnimation(.smooth(duration: 0.24)) {
|
||
isHovering = hovering
|
||
}
|
||
}
|
||
}
|
||
|
||
private var backgroundColor: Color {
|
||
let hoveredOpacity = max(backgroundOpacity + 0.08, 0.18)
|
||
let appliedOpacity = isHovering ? hoveredOpacity : backgroundOpacity
|
||
return Color.white.opacity(min(appliedOpacity, 0.32))
|
||
}
|
||
|
||
private func triggerPressEffect() {
|
||
switch interaction {
|
||
case .none:
|
||
return
|
||
case .nudge(let amount):
|
||
withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) {
|
||
pressOffset = amount
|
||
}
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
|
||
withAnimation(.spring(response: 0.28, dampingFraction: 0.72)) {
|
||
pressOffset = 0
|
||
}
|
||
}
|
||
case .wiggle(let direction):
|
||
guard #available(macOS 14.0, *) else { return }
|
||
wiggleToken += 1
|
||
let angle: Double = direction == .clockwise ? 10 : -10
|
||
|
||
withAnimation(.spring(response: 0.18, dampingFraction: 0.5)) {
|
||
rotationAngle = angle
|
||
}
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||
withAnimation(.spring(response: 0.32, dampingFraction: 0.75)) {
|
||
rotationAngle = 0
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var iconView: some View {
|
||
let base = Image(systemName: icon)
|
||
.font(.system(size: iconSize, weight: .medium))
|
||
.foregroundStyle(iconColor)
|
||
|
||
switch symbolEffect {
|
||
case .replace:
|
||
base.contentTransition(.symbolEffect(.replace))
|
||
case .replaceAndBounce:
|
||
if #available(macOS 14.0, *) {
|
||
base
|
||
.contentTransition(.symbolEffect(.replace))
|
||
.symbolEffect(.bounce, value: icon)
|
||
} else {
|
||
base.contentTransition(.symbolEffect(.replace))
|
||
}
|
||
case .wiggle:
|
||
if #available(macOS 15.0, *) {
|
||
base
|
||
.contentTransition(.symbolEffect(.replace))
|
||
.symbolEffect(.wiggle.byLayer, options: .nonRepeating, value: wiggleToken)
|
||
} else {
|
||
base.contentTransition(.symbolEffect(.replace))
|
||
}
|
||
}
|
||
}
|
||
|
||
enum Interaction {
|
||
case none
|
||
case nudge(CGFloat)
|
||
case wiggle(WiggleDirection)
|
||
}
|
||
|
||
enum SymbolEffectStyle {
|
||
case replace
|
||
case replaceAndBounce
|
||
case wiggle
|
||
}
|
||
|
||
enum WiggleDirection {
|
||
case clockwise
|
||
case counterClockwise
|
||
}
|
||
}
|
||
|
||
@available(macOS 26.0, *)
|
||
private struct GlassTextBackdrop: View {
|
||
let cornerRadius: CGFloat
|
||
|
||
var body: some View {
|
||
GeometryReader { proxy in
|
||
let dynamicFontSize = max(min(proxy.size.width, proxy.size.height) / 8, 42)
|
||
|
||
Text("Lock Screen Liquid Glass")
|
||
.font(.system(size: dynamicFontSize, weight: .semibold, design: .rounded))
|
||
.foregroundStyle(Color.clear)
|
||
.frame(width: proxy.size.width, height: proxy.size.height)
|
||
.glassEffect(
|
||
.clear.interactive(),
|
||
in: .rect(cornerRadius: cornerRadius)
|
||
)
|
||
}
|
||
.allowsHitTesting(false)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
|
||
// MARK: - Right Click Gesture
|
||
|
||
private struct RightClickOverlay: ViewModifier {
|
||
let action: () -> Void
|
||
|
||
func body(content: Content) -> some View {
|
||
content.overlay(
|
||
RightClickReceiverView(action: action)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
)
|
||
}
|
||
}
|
||
|
||
private struct RightClickReceiverView: NSViewRepresentable {
|
||
let action: () -> Void
|
||
|
||
func makeNSView(context: Context) -> RightClickNSView {
|
||
let view = RightClickNSView()
|
||
view.onRightClick = action
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: RightClickNSView, context: Context) {
|
||
nsView.onRightClick = action
|
||
}
|
||
}
|
||
|
||
private extension View {
|
||
func onRightClick(perform action: @escaping () -> Void) -> some View {
|
||
modifier(RightClickOverlay(action: action))
|
||
}
|
||
}
|
||
|
||
final class RightClickNSView: NSView {
|
||
var onRightClick: (() -> Void)?
|
||
|
||
override func rightMouseDown(with event: NSEvent) {
|
||
onRightClick?()
|
||
}
|
||
}
|
||
|
||
extension Notification.Name {
|
||
static let atollArtworkWallpaperDismissed = Notification.Name("atollArtworkWallpaperDismissed")
|
||
}
|