Expanding Lock Screen Music player

This commit is contained in:
Hariharan Mudaliar 2025-10-17 22:09:05 +05:30
commit 7af1d1bc27
2 changed files with 218 additions and 79 deletions

View file

@ -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())

View file

@ -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")