mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 23:54:32 +00:00
Merge pull request #111 from Ebullioscopic/feature/lock-screen
LIVE Indicator and Animations for Lock Screen music Player
This commit is contained in:
commit
e3a6b6167b
3 changed files with 267 additions and 81 deletions
|
|
@ -412,6 +412,7 @@
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Atoll;
|
INFOPLIST_KEY_CFBundleDisplayName = Atoll;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_LSUIElement = YES;
|
INFOPLIST_KEY_LSUIElement = YES;
|
||||||
|
INFOPLIST_KEY_MetalCaptureEnabled = YES;
|
||||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
|
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.";
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.";
|
||||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
||||||
|
|
@ -479,6 +480,7 @@
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Atoll;
|
INFOPLIST_KEY_CFBundleDisplayName = Atoll;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_LSUIElement = YES;
|
INFOPLIST_KEY_LSUIElement = YES;
|
||||||
|
INFOPLIST_KEY_MetalCaptureEnabled = YES;
|
||||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
|
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.";
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.";
|
||||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ struct LockScreenMusicPanel: View {
|
||||||
@ObservedObject var musicManager = MusicManager.shared
|
@ObservedObject var musicManager = MusicManager.shared
|
||||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||||
|
@ObservedObject private var animator: LockScreenPanelAnimator
|
||||||
@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 isActive = true
|
@State private var isActive = true
|
||||||
|
|
@ -26,6 +27,14 @@ struct LockScreenMusicPanel: View {
|
||||||
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
|
@Default(.lockScreenPanelShowsBorder) var showPanelBorder
|
||||||
@Default(.lockScreenPanelUsesBlur) var enableBlur
|
@Default(.lockScreenPanelUsesBlur) var enableBlur
|
||||||
@Default(.showMediaOutputControl) var showMediaOutputControl
|
@Default(.showMediaOutputControl) var showMediaOutputControl
|
||||||
|
@Default(.showShuffleAndRepeat) var showShuffleAndRepeat
|
||||||
|
@Default(.musicAuxLeftControl) private var leftAuxControl
|
||||||
|
@Default(.musicAuxRightControl) private var rightAuxControl
|
||||||
|
@Default(.enableLyrics) private var enableLyrics
|
||||||
|
|
||||||
|
init(animator: LockScreenPanelAnimator) {
|
||||||
|
_animator = ObservedObject(wrappedValue: animator)
|
||||||
|
}
|
||||||
|
|
||||||
private let collapsedPanelCornerRadius: CGFloat = 28
|
private let collapsedPanelCornerRadius: CGFloat = 28
|
||||||
private let expandedPanelCornerRadius: CGFloat = 52
|
private let expandedPanelCornerRadius: CGFloat = 52
|
||||||
|
|
@ -35,10 +44,12 @@ struct LockScreenMusicPanel: View {
|
||||||
private let collapseTimeout: TimeInterval = 5
|
private let collapseTimeout: TimeInterval = 5
|
||||||
private let collapsedSliderExtraHeight: CGFloat = 72
|
private let collapsedSliderExtraHeight: CGFloat = 72
|
||||||
private let expandedSliderExtraHeight: CGFloat = 88
|
private let expandedSliderExtraHeight: CGFloat = 88
|
||||||
|
private let collapsedLyricsExtraHeight: CGFloat = 64
|
||||||
|
private let expandedLyricsExtraHeight: CGFloat = 96
|
||||||
|
|
||||||
private var currentSize: CGSize {
|
private var currentSize: CGSize {
|
||||||
let base = isExpanded ? Self.expandedSize : Self.collapsedSize
|
let base = isExpanded ? Self.expandedSize : Self.collapsedSize
|
||||||
return CGSize(width: base.width, height: base.height + sliderExtraHeight)
|
return CGSize(width: base.width, height: base.height + totalExtraHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var panelCornerRadius: CGFloat {
|
private var panelCornerRadius: CGFloat {
|
||||||
|
|
@ -70,11 +81,7 @@ struct LockScreenMusicPanel: View {
|
||||||
sliderValue = musicManager.elapsedTime
|
sliderValue = musicManager.elapsedTime
|
||||||
isActive = true
|
isActive = true
|
||||||
logPanelAppearance()
|
logPanelAppearance()
|
||||||
LockScreenPanelManager.shared.updatePanelSize(
|
updatePanelSize(animated: false)
|
||||||
expanded: isExpanded,
|
|
||||||
additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider),
|
|
||||||
animated: false
|
|
||||||
)
|
|
||||||
routeManager.refreshDevices()
|
routeManager.refreshDevices()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
|
|
@ -83,10 +90,7 @@ struct LockScreenMusicPanel: View {
|
||||||
isVolumeSliderVisible = false
|
isVolumeSliderVisible = false
|
||||||
}
|
}
|
||||||
.onChange(of: isExpanded) { _, expanded in
|
.onChange(of: isExpanded) { _, expanded in
|
||||||
LockScreenPanelManager.shared.updatePanelSize(
|
updatePanelSize()
|
||||||
expanded: expanded,
|
|
||||||
additionalHeight: sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.onChange(of: showMediaOutputControl) { _, enabled in
|
.onChange(of: showMediaOutputControl) { _, enabled in
|
||||||
if !enabled {
|
if !enabled {
|
||||||
|
|
@ -94,11 +98,16 @@ struct LockScreenMusicPanel: View {
|
||||||
isVolumeSliderVisible = false
|
isVolumeSliderVisible = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LockScreenPanelManager.shared.updatePanelSize(
|
updatePanelSize()
|
||||||
expanded: isExpanded,
|
|
||||||
additionalHeight: sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.onChange(of: enableLyrics) { _, _ in
|
||||||
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
||||||
|
updatePanelSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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
|
@ViewBuilder
|
||||||
|
|
@ -272,62 +281,89 @@ struct LockScreenMusicPanel: View {
|
||||||
// MARK: - Progress Bar
|
// MARK: - Progress Bar
|
||||||
|
|
||||||
private var progressBar: some View {
|
private var progressBar: some View {
|
||||||
TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in
|
TimelineView(
|
||||||
let currentElapsed = currentSliderValue(timeline.date)
|
.animation(
|
||||||
|
minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil
|
||||||
HStack(spacing: 8) {
|
)
|
||||||
// Elapsed time
|
) { timeline in
|
||||||
Text(formatTime(dragging ? sliderValue : currentElapsed))
|
let progressValue = dragging ? sliderValue : currentSliderValue(timeline.date)
|
||||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
|
||||||
.foregroundColor(.white.opacity(0.7))
|
if musicManager.isLiveStream {
|
||||||
.frame(width: 42, alignment: .leading)
|
LiveStreamProgressIndicator(tint: sliderColor)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
// Progress bar
|
} else {
|
||||||
GeometryReader { geometry in
|
HStack(spacing: 8) {
|
||||||
ZStack(alignment: .leading) {
|
// Elapsed time
|
||||||
// Background track
|
Text(formatTime(progressValue))
|
||||||
RoundedRectangle(cornerRadius: 3)
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||||
.fill(Color.white.opacity(0.2))
|
.foregroundColor(.white.opacity(0.7))
|
||||||
.frame(height: 6)
|
.frame(width: 42, alignment: .leading)
|
||||||
|
|
||||||
// Filled portion
|
// Progress bar
|
||||||
RoundedRectangle(cornerRadius: 3)
|
GeometryReader { geometry in
|
||||||
.fill(sliderColor)
|
ZStack(alignment: .leading) {
|
||||||
.frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6)
|
// Background track
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(Color.white.opacity(0.2))
|
||||||
|
.frame(height: 6)
|
||||||
|
|
||||||
|
// Filled portion
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(sliderColor)
|
||||||
|
.frame(
|
||||||
|
width: max(
|
||||||
|
0,
|
||||||
|
geometry.size.width * (progressValue / max(musicManager.songDuration, 1))
|
||||||
|
),
|
||||||
|
height: 6
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { value in
|
||||||
|
registerInteraction()
|
||||||
|
dragging = true
|
||||||
|
let duration = max(musicManager.songDuration, 0.0001)
|
||||||
|
let clampedX = min(max(value.location.x, 0), geometry.size.width)
|
||||||
|
let newValue = Double(clampedX / geometry.size.width) * duration
|
||||||
|
sliderValue = min(max(0, newValue), musicManager.songDuration)
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
registerInteraction()
|
||||||
|
musicManager.seek(to: sliderValue)
|
||||||
|
dragging = false
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.gesture(
|
.frame(height: 6)
|
||||||
DragGesture(minimumDistance: 0)
|
|
||||||
.onChanged { value in
|
// Time remaining
|
||||||
registerInteraction()
|
Text("-\(formatTime(max(musicManager.songDuration - progressValue, 0)))")
|
||||||
dragging = true
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||||
let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration)
|
.foregroundColor(.white.opacity(0.7))
|
||||||
sliderValue = newValue
|
.frame(width: 48, alignment: .trailing)
|
||||||
}
|
|
||||||
.onEnded { _ in
|
|
||||||
registerInteraction()
|
|
||||||
musicManager.seek(to: sliderValue)
|
|
||||||
dragging = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.frame(height: 6)
|
|
||||||
|
|
||||||
// Time remaining
|
|
||||||
Text("-\(formatTime(musicManager.songDuration - (dragging ? sliderValue : currentElapsed)))")
|
|
||||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
|
||||||
.foregroundColor(.white.opacity(0.7))
|
|
||||||
.frame(width: 48, alignment: .trailing)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
sliderValue = musicManager.elapsedTime
|
sliderValue = musicManager.elapsedTime
|
||||||
}
|
}
|
||||||
|
.onChange(of: musicManager.isLiveStream) { _, isLive in
|
||||||
|
if isLive {
|
||||||
|
dragging = false
|
||||||
|
sliderValue = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func currentSliderValue(_ date: Date) -> Double {
|
private func currentSliderValue(_ date: Date) -> Double {
|
||||||
if dragging {
|
if dragging {
|
||||||
return sliderValue
|
return sliderValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if musicManager.isLiveStream {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
if musicManager.isPlaying {
|
if musicManager.isPlaying {
|
||||||
let timeSinceLastUpdate = date.timeIntervalSince(musicManager.timestampDate)
|
let timeSinceLastUpdate = date.timeIntervalSince(musicManager.timestampDate)
|
||||||
|
|
@ -359,8 +395,9 @@ struct LockScreenMusicPanel: View {
|
||||||
|
|
||||||
private func playbackControls(alignment: Alignment) -> some View {
|
private func playbackControls(alignment: Alignment) -> some View {
|
||||||
let spacing: CGFloat = isExpanded ? 24 : 20
|
let spacing: CGFloat = isExpanded ? 24 : 20
|
||||||
|
let verticalSpacing: CGFloat = (shouldShowVolumeSlider || enableLyrics) ? 14 : 10
|
||||||
|
|
||||||
return VStack(spacing: shouldShowVolumeSlider ? 14 : 10) {
|
return VStack(spacing: verticalSpacing) {
|
||||||
controlsRow(alignment: alignment, spacing: spacing)
|
controlsRow(alignment: alignment, spacing: spacing)
|
||||||
|
|
||||||
if shouldShowVolumeSlider {
|
if shouldShowVolumeSlider {
|
||||||
|
|
@ -368,16 +405,24 @@ struct LockScreenMusicPanel: View {
|
||||||
.frame(maxWidth: .infinity, alignment: alignment)
|
.frame(maxWidth: .infinity, alignment: alignment)
|
||||||
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
|
.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)
|
.frame(maxWidth: .infinity, alignment: alignment)
|
||||||
.padding(.top, isExpanded ? 6 : 2)
|
.padding(.top, isExpanded ? 6 : 2)
|
||||||
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
|
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
|
||||||
|
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: enableLyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
|
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
|
||||||
HStack(spacing: spacing) {
|
let controls = resolvedAuxControls
|
||||||
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
|
||||||
musicManager.toggleShuffle()
|
return HStack(spacing: spacing) {
|
||||||
|
if let leftControl = controls.left {
|
||||||
|
auxButton(for: leftControl)
|
||||||
}
|
}
|
||||||
|
|
||||||
controlButton(icon: "backward.fill", size: 18) {
|
controlButton(icon: "backward.fill", size: 18) {
|
||||||
|
|
@ -390,12 +435,8 @@ struct LockScreenMusicPanel: View {
|
||||||
musicManager.nextTrack()
|
musicManager.nextTrack()
|
||||||
}
|
}
|
||||||
|
|
||||||
if showMediaOutputControl {
|
if let rightControl = controls.right {
|
||||||
mediaOutputControlButton
|
auxButton(for: rightControl)
|
||||||
} else {
|
|
||||||
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
|
||||||
musicManager.toggleRepeat()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: alignment)
|
.frame(maxWidth: .infinity, alignment: alignment)
|
||||||
|
|
@ -418,7 +459,13 @@ struct LockScreenMusicPanel: View {
|
||||||
.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,
|
||||||
|
activeColor: Color = .red,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
let frameSize: CGFloat = isExpanded ? 56 : 32
|
let frameSize: CGFloat = isExpanded ? 56 : 32
|
||||||
let iconSize: CGFloat = isExpanded ? max(size, 24) : size
|
let iconSize: CGFloat = isExpanded ? max(size, 24) : size
|
||||||
|
|
||||||
|
|
@ -428,7 +475,7 @@ struct LockScreenMusicPanel: View {
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: iconSize, weight: .medium))
|
.font(.system(size: iconSize, weight: .medium))
|
||||||
.foregroundColor(isActive ? .red : .white.opacity(0.8))
|
.foregroundColor(isActive ? activeColor : .white.opacity(0.8))
|
||||||
.frame(width: frameSize, height: frameSize)
|
.frame(width: frameSize, height: frameSize)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
|
@ -450,6 +497,93 @@ struct LockScreenMusicPanel: View {
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 availableAuxControls: [MusicAuxiliaryControl] {
|
||||||
|
MusicAuxiliaryControl.allCases.filter { control in
|
||||||
|
control != .mediaOutput || showMediaOutputControl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resolvedAuxControls: (left: MusicAuxiliaryControl?, right: MusicAuxiliaryControl?) {
|
||||||
|
guard showShuffleAndRepeat else { return (nil, nil) }
|
||||||
|
|
||||||
|
let options = availableAuxControls
|
||||||
|
guard !options.isEmpty else { return (nil, nil) }
|
||||||
|
|
||||||
|
let left = options.contains(leftAuxControl) ? leftAuxControl : options.first!
|
||||||
|
var rightCandidate = options.contains(rightAuxControl) ? rightAuxControl : options.first!
|
||||||
|
|
||||||
|
if options.count == 1 {
|
||||||
|
return (left, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rightCandidate == left, let alternative = options.first(where: { $0 != left }) {
|
||||||
|
rightCandidate = alternative
|
||||||
|
}
|
||||||
|
|
||||||
|
if rightCandidate == left {
|
||||||
|
return (left, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (left, rightCandidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func auxButton(for control: MusicAuxiliaryControl) -> some View {
|
||||||
|
switch control {
|
||||||
|
case .shuffle:
|
||||||
|
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
||||||
|
musicManager.toggleShuffle()
|
||||||
|
}
|
||||||
|
case .repeatMode:
|
||||||
|
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
||||||
|
musicManager.toggleRepeat()
|
||||||
|
}
|
||||||
|
case .mediaOutput:
|
||||||
|
mediaOutputControlButton
|
||||||
|
case .lyrics:
|
||||||
|
controlButton(
|
||||||
|
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
|
||||||
|
isActive: enableLyrics,
|
||||||
|
activeColor: .accentColor
|
||||||
|
) {
|
||||||
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) {
|
||||||
|
enableLyrics.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var repeatIcon: String {
|
private var repeatIcon: String {
|
||||||
switch musicManager.repeatMode {
|
switch musicManager.repeatMode {
|
||||||
case .off: return "repeat"
|
case .off: return "repeat"
|
||||||
|
|
@ -501,6 +635,32 @@ struct LockScreenMusicPanel: View {
|
||||||
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var lyricsExtraHeight: CGFloat {
|
||||||
|
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalExtraHeight: CGFloat {
|
||||||
|
sliderExtraHeight + 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) +
|
||||||
|
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 {
|
private var volumeIconName: String {
|
||||||
if volumeModel.isMuted || volumeModel.level <= 0.001 {
|
if volumeModel.isMuted || volumeModel.level <= 0.001 {
|
||||||
return "speaker.slash.fill"
|
return "speaker.slash.fill"
|
||||||
|
|
@ -517,6 +677,11 @@ struct LockScreenMusicPanel: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleVolumeSlider() {
|
private func toggleVolumeSlider() {
|
||||||
|
guard showMediaOutputControl else {
|
||||||
|
isVolumeSliderVisible = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
registerInteraction()
|
registerInteraction()
|
||||||
let newState = !isVolumeSliderVisible
|
let newState = !isVolumeSliderVisible
|
||||||
if newState {
|
if newState {
|
||||||
|
|
@ -527,11 +692,7 @@ struct LockScreenMusicPanel: View {
|
||||||
isVolumeSliderVisible = newState
|
isVolumeSliderVisible = newState
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetHeight = sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
updatePanelSize()
|
||||||
LockScreenPanelManager.shared.updatePanelSize(
|
|
||||||
expanded: isExpanded,
|
|
||||||
additionalHeight: targetHeight
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sliderHeight(forExpanded expanded: Bool, visible: Bool) -> CGFloat {
|
private func sliderHeight(forExpanded expanded: Bool, visible: Bool) -> CGFloat {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ import SkyLightWindow
|
||||||
import Defaults
|
import Defaults
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LockScreenPanelAnimator: ObservableObject {
|
||||||
|
@Published var isPresented: Bool = false
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class LockScreenPanelManager {
|
class LockScreenPanelManager {
|
||||||
static let shared = LockScreenPanelManager()
|
static let shared = LockScreenPanelManager()
|
||||||
|
|
@ -21,6 +26,8 @@ class LockScreenPanelManager {
|
||||||
private let collapsedPanelCornerRadius: CGFloat = 28
|
private let collapsedPanelCornerRadius: CGFloat = 28
|
||||||
private let expandedPanelCornerRadius: CGFloat = 52
|
private let expandedPanelCornerRadius: CGFloat = 52
|
||||||
private(set) var latestFrame: NSRect?
|
private(set) var latestFrame: NSRect?
|
||||||
|
private let panelAnimator = LockScreenPanelAnimator()
|
||||||
|
private var hideTask: Task<Void, Never>?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
print("[\(timestamp())] LockScreenPanelManager: initialized")
|
print("[\(timestamp())] LockScreenPanelManager: initialized")
|
||||||
|
|
@ -81,7 +88,10 @@ class LockScreenPanelManager {
|
||||||
|
|
||||||
window.setFrame(targetFrame, display: true)
|
window.setFrame(targetFrame, display: true)
|
||||||
latestFrame = targetFrame
|
latestFrame = targetFrame
|
||||||
let hosting = NSHostingView(rootView: LockScreenMusicPanel())
|
hideTask?.cancel()
|
||||||
|
panelAnimator.isPresented = false
|
||||||
|
|
||||||
|
let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator))
|
||||||
hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
|
hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
|
||||||
window.contentView = hosting
|
window.contentView = hosting
|
||||||
|
|
||||||
|
|
@ -100,6 +110,11 @@ class LockScreenPanelManager {
|
||||||
// Keep the window alive and simply order it out on unlock to avoid SkyLight crashes.
|
// Keep the window alive and simply order it out on unlock to avoid SkyLight crashes.
|
||||||
window.orderFrontRegardless()
|
window.orderFrontRegardless()
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.panelAnimator.isPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
print("[\(timestamp())] LockScreenPanelManager: panel visible")
|
print("[\(timestamp())] LockScreenPanelManager: panel visible")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,16 +157,24 @@ class LockScreenPanelManager {
|
||||||
func hidePanel() {
|
func hidePanel() {
|
||||||
print("[\(timestamp())] LockScreenPanelManager: hidePanel")
|
print("[\(timestamp())] LockScreenPanelManager: hidePanel")
|
||||||
|
|
||||||
|
panelAnimator.isPresented = false
|
||||||
|
hideTask?.cancel()
|
||||||
|
|
||||||
guard let window = panelWindow else {
|
guard let window = panelWindow else {
|
||||||
print("LockScreenPanelManager: no panel to hide")
|
print("LockScreenPanelManager: no panel to hide")
|
||||||
|
latestFrame = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.orderOut(nil)
|
hideTask = Task { [weak self, weak window] in
|
||||||
window.contentView = nil
|
try? await Task.sleep(for: .milliseconds(360))
|
||||||
|
guard let self else { return }
|
||||||
latestFrame = nil
|
await MainActor.run {
|
||||||
|
window?.orderOut(nil)
|
||||||
print("[\(timestamp())] LockScreenPanelManager: panel hidden")
|
window?.contentView = nil
|
||||||
|
self.latestFrame = nil
|
||||||
|
print("[\(self.timestamp())] LockScreenPanelManager: panel hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue