mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Expanding Lock Screen Music player
This commit is contained in:
parent
070f88df6f
commit
7af1d1bc27
2 changed files with 218 additions and 79 deletions
|
|
@ -9,17 +9,23 @@ import SwiftUI
|
|||
import Defaults
|
||||
|
||||
struct LockScreenMusicPanel: View {
|
||||
static let defaultSize = CGSize(width: 420, height: 180)
|
||||
static let collapsedSize = CGSize(width: 420, height: 180)
|
||||
static let expandedSize = CGSize(width: 660, height: 310)
|
||||
|
||||
@ObservedObject var musicManager = MusicManager.shared
|
||||
@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 collapseWorkItem: DispatchWorkItem?
|
||||
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
|
||||
|
||||
private let cornerRadius: CGFloat = 28
|
||||
private var panelSize: CGSize { Self.defaultSize }
|
||||
private let collapseTimeout: TimeInterval = 5
|
||||
|
||||
private var currentSize: CGSize {
|
||||
isExpanded ? Self.expandedSize : Self.collapsedSize
|
||||
}
|
||||
|
||||
private var usesLiquidGlass: Bool {
|
||||
if #available(macOS 26.0, *) {
|
||||
|
|
@ -31,84 +37,185 @@ struct LockScreenMusicPanel: View {
|
|||
var body: some View {
|
||||
if isActive {
|
||||
panelContent
|
||||
.onDisappear {
|
||||
isActive = false
|
||||
}
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(width: panelSize.width, height: panelSize.height)
|
||||
.frame(width: Self.collapsedSize.width, height: Self.collapsedSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
private var panelContent: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Header area with album art
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
// Album art
|
||||
Button {
|
||||
musicManager.openMusicApp()
|
||||
} label: {
|
||||
Image(nsImage: musicManager.albumArt)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 60, height: 60)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.opacity(musicManager.isPlaying ? 1 : 0.4)
|
||||
.scaleEffect(musicManager.isPlaying ? 1 : 0.85)
|
||||
|
||||
// Song info
|
||||
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
|
||||
if Defaults[.useMusicVisualizer] {
|
||||
Rectangle()
|
||||
.fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient)
|
||||
.mask {
|
||||
AudioSpectrumView(isPlaying: .constant(musicManager.isPlaying))
|
||||
.frame(width: 20, height: 16)
|
||||
}
|
||||
.frame(width: 20, height: 16)
|
||||
}
|
||||
panelCore
|
||||
.frame(width: currentSize.width, height: currentSize.height)
|
||||
.animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded)
|
||||
.onAppear {
|
||||
sliderValue = musicManager.elapsedTime
|
||||
isActive = true
|
||||
logPanelAppearance()
|
||||
LockScreenPanelManager.shared.updatePanelSize(expanded: false, animated: false)
|
||||
}
|
||||
.onDisappear {
|
||||
isActive = false
|
||||
cancelCollapseTimer()
|
||||
}
|
||||
.onChange(of: isExpanded) { expanded in
|
||||
LockScreenPanelManager.shared.updatePanelSize(expanded: expanded)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var panelCore: some View {
|
||||
Group {
|
||||
if isExpanded {
|
||||
expandedLayout
|
||||
} else {
|
||||
collapsedLayout
|
||||
}
|
||||
.frame(height: 60)
|
||||
|
||||
// Progress bar
|
||||
progressBar
|
||||
.padding(.top, 4)
|
||||
|
||||
// Playback controls
|
||||
playbackControls
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
.frame(width: panelSize.width, height: panelSize.height)
|
||||
.padding(.horizontal, isExpanded ? 24 : 20)
|
||||
.padding(.vertical, isExpanded ? 22 : 16)
|
||||
.frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading)
|
||||
.background(panelBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.stroke(Color.white.opacity(0.35), lineWidth: 1.4)
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
|
||||
.onAppear {
|
||||
sliderValue = musicManager.elapsedTime
|
||||
isActive = true
|
||||
logPanelAppearance()
|
||||
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
}
|
||||
|
||||
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: 28) {
|
||||
albumArtButton(size: 220, cornerRadius: cornerRadius)
|
||||
.frame(width: 220, height: 220)
|
||||
|
||||
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: 16)
|
||||
|
||||
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) {
|
||||
Image(nsImage: musicManager.albumArt)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
}
|
||||
.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
|
||||
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 {
|
||||
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 cancelCollapseTimer() {
|
||||
collapseWorkItem?.cancel()
|
||||
collapseWorkItem = nil
|
||||
}
|
||||
|
||||
// MARK: - Progress Bar
|
||||
|
||||
|
|
@ -139,12 +246,13 @@ struct LockScreenMusicPanel: View {
|
|||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
registerInteraction()
|
||||
dragging = true
|
||||
let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration)
|
||||
sliderValue = newValue
|
||||
lastDragged = Date()
|
||||
}
|
||||
.onEnded { _ in
|
||||
registerInteraction()
|
||||
musicManager.seek(to: sliderValue)
|
||||
dragging = false
|
||||
}
|
||||
|
|
@ -197,8 +305,10 @@ struct LockScreenMusicPanel: View {
|
|||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
private var playbackControls: some View {
|
||||
HStack(spacing: 20) {
|
||||
private func playbackControls(alignment: Alignment) -> some View {
|
||||
let spacing: CGFloat = isExpanded ? 24 : 20
|
||||
|
||||
return HStack(spacing: spacing) {
|
||||
// Always show shuffle on lock screen
|
||||
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
||||
Task { await musicManager.toggleShuffle() }
|
||||
|
|
@ -219,29 +329,38 @@ struct LockScreenMusicPanel: View {
|
|||
Task { await musicManager.toggleRepeat() }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 2)
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
.padding(.top, isExpanded ? 6 : 2)
|
||||
}
|
||||
|
||||
private var playPauseButton: some View {
|
||||
Button(action: {
|
||||
let frameSize: CGFloat = isExpanded ? 60 : 48
|
||||
|
||||
return Button(action: {
|
||||
registerInteraction()
|
||||
Task { await musicManager.togglePlay() }
|
||||
}) {
|
||||
Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 48, height: 48)
|
||||
.frame(width: frameSize, height: frameSize)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
private func controlButton(icon: String, size: CGFloat = 18, isActive: Bool = false, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
let frameSize: CGFloat = isExpanded ? 44 : 32
|
||||
let iconSize: CGFloat = isExpanded ? max(size, 20) : size
|
||||
|
||||
return Button(action: {
|
||||
registerInteraction()
|
||||
action()
|
||||
}) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: size, weight: .medium))
|
||||
.font(.system(size: iconSize, weight: .medium))
|
||||
.foregroundColor(isActive ? .red : .white.opacity(0.8))
|
||||
.frame(width: 32, height: 32)
|
||||
.frame(width: frameSize, height: frameSize)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class LockScreenPanelManager {
|
|||
|
||||
private var panelWindow: NSWindow?
|
||||
private var hasDelegated = false
|
||||
private var collapsedFrame: NSRect?
|
||||
|
||||
private init() {
|
||||
print("[\(timestamp())] LockScreenPanelManager: initialized")
|
||||
|
|
@ -41,11 +42,13 @@ class LockScreenPanelManager {
|
|||
return
|
||||
}
|
||||
|
||||
let panelSize = LockScreenMusicPanel.defaultSize
|
||||
let collapsedSize = LockScreenMusicPanel.collapsedSize
|
||||
let screenFrame = screen.frame
|
||||
let originX = screenFrame.origin.x + (screenFrame.width / 2) - (panelSize.width / 2)
|
||||
let originY = screenFrame.origin.y + (screenFrame.height / 2) - panelSize.height - 32
|
||||
let targetFrame = NSRect(x: originX, y: originY, width: panelSize.width, height: panelSize.height)
|
||||
let centerX = screenFrame.origin.x + (screenFrame.width / 2)
|
||||
let originX = centerX - (collapsedSize.width / 2)
|
||||
let originY = screenFrame.origin.y + (screenFrame.height / 2) - collapsedSize.height - 32
|
||||
let targetFrame = NSRect(x: originX, y: originY, width: collapsedSize.width, height: collapsedSize.height)
|
||||
collapsedFrame = targetFrame
|
||||
|
||||
let window: NSWindow
|
||||
|
||||
|
|
@ -86,6 +89,23 @@ class LockScreenPanelManager {
|
|||
print("[\(timestamp())] LockScreenPanelManager: panel visible")
|
||||
}
|
||||
|
||||
func updatePanelSize(expanded: Bool, animated: Bool = true) {
|
||||
guard let window = panelWindow, let baseFrame = collapsedFrame else {
|
||||
return
|
||||
}
|
||||
|
||||
let targetSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
|
||||
let originX = baseFrame.midX - (targetSize.width / 2)
|
||||
let originY = baseFrame.origin.y
|
||||
let targetFrame = NSRect(x: originX, y: originY, width: targetSize.width, height: targetSize.height)
|
||||
|
||||
if animated {
|
||||
window.animator().setFrame(targetFrame, display: true)
|
||||
} else {
|
||||
window.setFrame(targetFrame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
func hidePanel() {
|
||||
print("[\(timestamp())] LockScreenPanelManager: hidePanel")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue