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
|
import Defaults
|
||||||
|
|
||||||
struct LockScreenMusicPanel: View {
|
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
|
@ObservedObject var musicManager = MusicManager.shared
|
||||||
@State private var sliderValue: Double = 0
|
@State private var sliderValue: Double = 0
|
||||||
@State private var dragging: Bool = false
|
@State private var dragging: Bool = false
|
||||||
@State private var lastDragged: Date = .distantPast
|
|
||||||
@State private var isActive = true
|
@State private var isActive = true
|
||||||
|
@State private var isExpanded = false
|
||||||
|
@State private var collapseWorkItem: DispatchWorkItem?
|
||||||
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
|
@Default(.lockScreenGlassStyle) var lockScreenGlassStyle
|
||||||
|
|
||||||
private let cornerRadius: CGFloat = 28
|
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 {
|
private var usesLiquidGlass: Bool {
|
||||||
if #available(macOS 26.0, *) {
|
if #available(macOS 26.0, *) {
|
||||||
|
|
@ -31,84 +37,185 @@ struct LockScreenMusicPanel: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if isActive {
|
if isActive {
|
||||||
panelContent
|
panelContent
|
||||||
.onDisappear {
|
|
||||||
isActive = false
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: panelSize.width, height: panelSize.height)
|
.frame(width: Self.collapsedSize.width, height: Self.collapsedSize.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var panelContent: some View {
|
private var panelContent: some View {
|
||||||
VStack(spacing: 12) {
|
panelCore
|
||||||
// Header area with album art
|
.frame(width: currentSize.width, height: currentSize.height)
|
||||||
HStack(alignment: .center, spacing: 16) {
|
.animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded)
|
||||||
// Album art
|
.onAppear {
|
||||||
Button {
|
sliderValue = musicManager.elapsedTime
|
||||||
musicManager.openMusicApp()
|
isActive = true
|
||||||
} label: {
|
logPanelAppearance()
|
||||||
Image(nsImage: musicManager.albumArt)
|
LockScreenPanelManager.shared.updatePanelSize(expanded: false, animated: false)
|
||||||
.resizable()
|
}
|
||||||
.aspectRatio(contentMode: .fill)
|
.onDisappear {
|
||||||
.frame(width: 60, height: 60)
|
isActive = false
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
cancelCollapseTimer()
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.onChange(of: isExpanded) { expanded in
|
||||||
.opacity(musicManager.isPlaying ? 1 : 0.4)
|
LockScreenPanelManager.shared.updatePanelSize(expanded: expanded)
|
||||||
.scaleEffect(musicManager.isPlaying ? 1 : 0.85)
|
}
|
||||||
|
}
|
||||||
// Song info
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
@ViewBuilder
|
||||||
Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle)
|
private var panelCore: some View {
|
||||||
.font(.system(size: 12, weight: .semibold))
|
Group {
|
||||||
.foregroundColor(.white)
|
if isExpanded {
|
||||||
.lineLimit(1)
|
expandedLayout
|
||||||
|
} else {
|
||||||
Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName)
|
collapsedLayout
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(height: 60)
|
|
||||||
|
|
||||||
// Progress bar
|
|
||||||
progressBar
|
|
||||||
.padding(.top, 4)
|
|
||||||
|
|
||||||
// Playback controls
|
|
||||||
playbackControls
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, isExpanded ? 24 : 20)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, isExpanded ? 22 : 16)
|
||||||
.frame(width: panelSize.width, height: panelSize.height)
|
.frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading)
|
||||||
.background(panelBackground)
|
.background(panelBackground)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: cornerRadius)
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
.stroke(Color.white.opacity(0.35), lineWidth: 1.4)
|
.stroke(Color.white.opacity(0.35), lineWidth: 1.4)
|
||||||
}
|
}
|
||||||
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
|
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
|
||||||
.onAppear {
|
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||||
sliderValue = musicManager.elapsedTime
|
}
|
||||||
isActive = true
|
|
||||||
logPanelAppearance()
|
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
|
// MARK: - Progress Bar
|
||||||
|
|
||||||
|
|
@ -139,12 +246,13 @@ struct LockScreenMusicPanel: View {
|
||||||
.gesture(
|
.gesture(
|
||||||
DragGesture(minimumDistance: 0)
|
DragGesture(minimumDistance: 0)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
|
registerInteraction()
|
||||||
dragging = true
|
dragging = true
|
||||||
let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration)
|
let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration)
|
||||||
sliderValue = newValue
|
sliderValue = newValue
|
||||||
lastDragged = Date()
|
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
|
registerInteraction()
|
||||||
musicManager.seek(to: sliderValue)
|
musicManager.seek(to: sliderValue)
|
||||||
dragging = false
|
dragging = false
|
||||||
}
|
}
|
||||||
|
|
@ -197,8 +305,10 @@ struct LockScreenMusicPanel: View {
|
||||||
|
|
||||||
// MARK: - Playback Controls
|
// MARK: - Playback Controls
|
||||||
|
|
||||||
private var playbackControls: some View {
|
private func playbackControls(alignment: Alignment) -> some View {
|
||||||
HStack(spacing: 20) {
|
let spacing: CGFloat = isExpanded ? 24 : 20
|
||||||
|
|
||||||
|
return HStack(spacing: spacing) {
|
||||||
// Always show shuffle on lock screen
|
// Always show shuffle on lock screen
|
||||||
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
||||||
Task { await musicManager.toggleShuffle() }
|
Task { await musicManager.toggleShuffle() }
|
||||||
|
|
@ -219,29 +329,38 @@ struct LockScreenMusicPanel: View {
|
||||||
Task { await musicManager.toggleRepeat() }
|
Task { await musicManager.toggleRepeat() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: alignment)
|
||||||
.padding(.top, 2)
|
.padding(.top, isExpanded ? 6 : 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var playPauseButton: some View {
|
private var playPauseButton: some View {
|
||||||
Button(action: {
|
let frameSize: CGFloat = isExpanded ? 60 : 48
|
||||||
|
|
||||||
|
return Button(action: {
|
||||||
|
registerInteraction()
|
||||||
Task { await musicManager.togglePlay() }
|
Task { await musicManager.togglePlay() }
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill")
|
Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill")
|
||||||
.font(.system(size: 24, weight: .semibold))
|
.font(.system(size: 24, weight: .semibold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(width: 48, height: 48)
|
.frame(width: frameSize, height: frameSize)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func controlButton(icon: String, size: CGFloat = 18, isActive: Bool = false, action: @escaping () -> Void) -> some View {
|
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)
|
Image(systemName: icon)
|
||||||
.font(.system(size: size, weight: .medium))
|
.font(.system(size: iconSize, weight: .medium))
|
||||||
.foregroundColor(isActive ? .red : .white.opacity(0.8))
|
.foregroundColor(isActive ? .red : .white.opacity(0.8))
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: frameSize, height: frameSize)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class LockScreenPanelManager {
|
||||||
|
|
||||||
private var panelWindow: NSWindow?
|
private var panelWindow: NSWindow?
|
||||||
private var hasDelegated = false
|
private var hasDelegated = false
|
||||||
|
private var collapsedFrame: NSRect?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
print("[\(timestamp())] LockScreenPanelManager: initialized")
|
print("[\(timestamp())] LockScreenPanelManager: initialized")
|
||||||
|
|
@ -41,11 +42,13 @@ class LockScreenPanelManager {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let panelSize = LockScreenMusicPanel.defaultSize
|
let collapsedSize = LockScreenMusicPanel.collapsedSize
|
||||||
let screenFrame = screen.frame
|
let screenFrame = screen.frame
|
||||||
let originX = screenFrame.origin.x + (screenFrame.width / 2) - (panelSize.width / 2)
|
let centerX = screenFrame.origin.x + (screenFrame.width / 2)
|
||||||
let originY = screenFrame.origin.y + (screenFrame.height / 2) - panelSize.height - 32
|
let originX = centerX - (collapsedSize.width / 2)
|
||||||
let targetFrame = NSRect(x: originX, y: originY, width: panelSize.width, height: panelSize.height)
|
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
|
let window: NSWindow
|
||||||
|
|
||||||
|
|
@ -86,6 +89,23 @@ class LockScreenPanelManager {
|
||||||
print("[\(timestamp())] LockScreenPanelManager: panel visible")
|
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() {
|
func hidePanel() {
|
||||||
print("[\(timestamp())] LockScreenPanelManager: hidePanel")
|
print("[\(timestamp())] LockScreenPanelManager: hidePanel")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue