mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
1181 lines
42 KiB
Swift
1181 lines
42 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 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(.lockScreenPanelUsesBlur) var enableBlur
|
||
@Default(.showMediaOutputControl) private var showMediaOutputControl
|
||
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
||
@Default(.musicControlSlots) private var slotConfig
|
||
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||
@Default(.enableLyrics) private var enableLyrics
|
||
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
|
||
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
|
||
|
||
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
|
||
}
|
||
|
||
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 && !usesLiquidGlass {
|
||
RoundedRectangle(cornerRadius: panelCornerRadius)
|
||
.stroke(Color.white.opacity(0.35), lineWidth: 1.4)
|
||
}
|
||
}
|
||
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
|
||
.contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
|
||
.animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded)
|
||
.animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.18), 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
|
||
}
|
||
.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 visible && isAppleMusicActive {
|
||
Task { await airPlayManager.refreshDevices() }
|
||
} else {
|
||
measuredAirPlayHeight = nil
|
||
}
|
||
updatePanelSize()
|
||
}
|
||
.onChange(of: airPlayManager.devices) { _, _ in
|
||
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)
|
||
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle)
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(.white)
|
||
.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)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
visualizer(width: 20, height: 16)
|
||
}
|
||
.frame(height: 60)
|
||
}
|
||
|
||
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)
|
||
|
||
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)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
visualizer(width: 24, height: 20)
|
||
}
|
||
}
|
||
|
||
private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View {
|
||
Button(action: toggleExpanded) {
|
||
ZStack(alignment: .bottomTrailing) {
|
||
albumArtImage(size: size, cornerRadius: cornerRadius)
|
||
if showAppIcon, let icon = lockScreenAppIcon {
|
||
icon
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
.frame(width: appIconSize, height: appIconSize)
|
||
.clipShape(RoundedRectangle(cornerRadius: appIconCornerRadius, style: .continuous))
|
||
.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: musicManager.flipAngle)
|
||
.parallax3D(magnitude: 12, enableOverride: lockScreenParallaxEnabled, suspended: isParallaxSuspended)
|
||
.frame(width: size, height: size)
|
||
.background(albumArtBackground(cornerRadius: cornerRadius))
|
||
}
|
||
.buttonStyle(PlainButtonStyle())
|
||
.opacity(musicManager.isPlaying ? 1 : 0.4)
|
||
.scaleEffect(musicManager.isPlaying ? 1 : 0.85)
|
||
.animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying)
|
||
}
|
||
|
||
@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 {
|
||
AudioSpectrumView(isPlaying: .constant(musicManager.isPlaying))
|
||
.frame(width: width, height: height)
|
||
}
|
||
.frame(width: width, height: height)
|
||
}
|
||
}
|
||
|
||
private func toggleExpanded() {
|
||
let newState = !isExpanded
|
||
suspendParallaxInteraction()
|
||
withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) {
|
||
isExpanded = newState
|
||
}
|
||
|
||
if newState {
|
||
registerInteraction()
|
||
logPanelAppearance(event: "🔍 Expanded")
|
||
} else {
|
||
logPanelAppearance(event: "⬇️ Collapsed")
|
||
cancelCollapseTimer()
|
||
}
|
||
}
|
||
|
||
private func registerInteraction() {
|
||
cancelCollapseTimer()
|
||
guard isExpanded else { return }
|
||
|
||
let workItem = DispatchWorkItem {
|
||
suspendParallaxInteraction()
|
||
withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) {
|
||
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))
|
||
}
|
||
}
|
||
|
||
if enableLyrics {
|
||
lyricsSection(alignment: alignment)
|
||
.transition(.move(edge: .top).combined(with: .opacity))
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: alignment)
|
||
.padding(.top, isExpanded ? 6 : 2)
|
||
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
|
||
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowAirPlay)
|
||
.animation(.spring(response: 0.45, dampingFraction: 0.82), 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)
|
||
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 .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 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 {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.vertical, 8)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
|
||
.fill(sliderBackgroundFill)
|
||
)
|
||
.background(
|
||
GeometryReader { geo in
|
||
Color.clear
|
||
.onAppear { measuredAirPlayHeight = geo.size.height }
|
||
.onChange(of: geo.size.height) { _, newHeight in
|
||
measuredAirPlayHeight = newHeight
|
||
updatePanelSize()
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
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 shouldShowAirPlay: Bool {
|
||
shouldShowVolumeSlider && isAppleMusicActive && !airPlayManager.devices.isEmpty
|
||
}
|
||
|
||
private var shouldShowVolumeSlider: Bool {
|
||
showMediaOutputControl && isVolumeSliderVisible
|
||
}
|
||
|
||
private var sliderExtraHeight: CGFloat {
|
||
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
||
}
|
||
|
||
private var lyricsExtraHeight: CGFloat {
|
||
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics)
|
||
}
|
||
|
||
@State private var measuredAirPlayHeight: CGFloat?
|
||
|
||
private var airPlayExtraHeight: CGFloat {
|
||
guard shouldShowAirPlay else { return 0 }
|
||
if let measured = measuredAirPlayHeight {
|
||
return measured
|
||
}
|
||
// Fallback estimate used only until the first GeometryReader measurement arrives
|
||
let selectedCount = airPlayManager.devices.filter(\.isSelected).count
|
||
let totalCount = airPlayManager.devices.count
|
||
let deviceRows: CGFloat = CGFloat(totalCount) * 22 + CGFloat(max(totalCount - 1, 0)) * 6
|
||
let sliders: CGFloat = CGFloat(selectedCount) * 24
|
||
return deviceRows + sliders + 16
|
||
}
|
||
|
||
private var totalExtraHeight: CGFloat {
|
||
sliderExtraHeight + airPlayExtraHeight + 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) +
|
||
airPlayExtraHeight +
|
||
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()
|
||
}
|
||
|
||
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
||
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)
|
||
}
|
||
|
||
private func albumArtImage(size: CGFloat, cornerRadius: CGFloat) -> some View {
|
||
Image(nsImage: musicManager.albumArt)
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fit)
|
||
.frame(width: size, height: size)
|
||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||
}
|
||
|
||
@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 : 10
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|