From b77de94b62664eb67d46a44ddd2174fa3834e1c3 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 27 Oct 2025 01:15:27 +0530 Subject: [PATCH] Working widget --- .../LockScreen/LockScreenMusicPanel.swift | 163 +++++++++++++++--- .../managers/LockScreenPanelManager.swift | 10 +- 2 files changed, 147 insertions(+), 26 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index b539d8b..a1c8a84 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -13,15 +13,19 @@ struct LockScreenMusicPanel: View { static let expandedSize = CGSize(width: 720, height: 340) @ObservedObject var musicManager = MusicManager.shared + @ObservedObject private var routeManager = AudioRouteManager.shared + @StateObject private var volumeModel = MediaOutputVolumeViewModel() @State private var sliderValue: Double = 0 @State private var dragging: Bool = false @State private var isActive = true @State private var isExpanded = false + @State private var isVolumeSliderVisible = false @State private var collapseWorkItem: DispatchWorkItem? @Default(.lockScreenGlassStyle) var lockScreenGlassStyle @Default(.lockScreenShowAppIcon) var showAppIcon @Default(.lockScreenPanelShowsBorder) var showPanelBorder @Default(.lockScreenPanelUsesBlur) var enableBlur + @Default(.showMediaOutputControl) var showMediaOutputControl private let collapsedPanelCornerRadius: CGFloat = 28 private let expandedPanelCornerRadius: CGFloat = 52 @@ -29,9 +33,12 @@ struct LockScreenMusicPanel: View { 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 var currentSize: CGSize { - isExpanded ? Self.expandedSize : Self.collapsedSize + let base = isExpanded ? Self.expandedSize : Self.collapsedSize + return CGSize(width: base.width, height: base.height + sliderExtraHeight) } private var panelCornerRadius: CGFloat { @@ -58,18 +65,32 @@ struct LockScreenMusicPanel: View { panelCore .frame(width: currentSize.width, height: currentSize.height) .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() - LockScreenPanelManager.shared.updatePanelSize(expanded: false, animated: false) + LockScreenPanelManager.shared.updatePanelSize(expanded: false, additionalHeight: sliderExtraHeight, animated: false) + routeManager.refreshDevices() } .onDisappear { isActive = false cancelCollapseTimer() + isVolumeSliderVisible = false } .onChange(of: isExpanded) { _, expanded in - LockScreenPanelManager.shared.updatePanelSize(expanded: expanded) + LockScreenPanelManager.shared.updatePanelSize(expanded: expanded, additionalHeight: sliderExtraHeight) + } + .onChange(of: isVolumeSliderVisible) { _, _ in + LockScreenPanelManager.shared.updatePanelSize(expanded: isExpanded, additionalHeight: sliderExtraHeight) + } + .onChange(of: showMediaOutputControl) { _, enabled in + if !enabled { + withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { + isVolumeSliderVisible = false + } + } + LockScreenPanelManager.shared.updatePanelSize(expanded: isExpanded, additionalHeight: sliderExtraHeight) } } @@ -332,29 +353,45 @@ struct LockScreenMusicPanel: View { 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) { - musicManager.toggleShuffle() - } - - controlButton(icon: "backward.fill", size: 18) { - musicManager.previousTrack() - } - - playPauseButton - - controlButton(icon: "forward.fill", size: 18) { - musicManager.nextTrack() - } - - // Always show repeat on lock screen - controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) { - musicManager.toggleRepeat() + return VStack(spacing: shouldShowVolumeSlider ? 14 : 10) { + controlsRow(alignment: alignment, spacing: spacing) + + if shouldShowVolumeSlider { + volumeSlider + .frame(maxWidth: .infinity, alignment: alignment) + .transition(.move(edge: .bottom).combined(with: .opacity)) } } .frame(maxWidth: .infinity, alignment: alignment) .padding(.top, isExpanded ? 6 : 2) + .animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider) + } + + private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View { + HStack(spacing: spacing) { + controlButton(icon: "shuffle", isActive: musicManager.isShuffled) { + musicManager.toggleShuffle() + } + + controlButton(icon: "backward.fill", size: 18) { + musicManager.previousTrack() + } + + playPauseButton + + controlButton(icon: "forward.fill", size: 18) { + musicManager.nextTrack() + } + + if showMediaOutputControl { + mediaOutputControlButton + } else { + controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) { + musicManager.toggleRepeat() + } + } + } + .frame(maxWidth: .infinity, alignment: alignment) } private var playPauseButton: some View { @@ -391,6 +428,29 @@ struct LockScreenMusicPanel: View { .buttonStyle(PlainButtonStyle()) } + private var mediaOutputControlButton: some View { + let frameSize: CGFloat = isExpanded ? 56 : 32 + let iconSize: CGFloat = isExpanded ? 26 : 18 + + return Button(action: { + registerInteraction() + withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { + isVolumeSliderVisible.toggle() + } + if isVolumeSliderVisible { + routeManager.refreshDevices() + } + }) { + Image(systemName: mediaOutputIcon) + .font(.system(size: iconSize, weight: .medium)) + .foregroundColor(isVolumeSliderVisible ? .accentColor : .white.opacity(0.8)) + .frame(width: frameSize, height: frameSize) + .contentShape(Rectangle()) + } + .accessibilityLabel("Media output") + .buttonStyle(PlainButtonStyle()) + } + private var repeatIcon: String { switch musicManager.repeatMode { case .off: return "repeat" @@ -399,6 +459,65 @@ struct LockScreenMusicPanel: View { } } + 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(Color.white.opacity(0.08)) + ) + } + + private var shouldShowVolumeSlider: Bool { + showMediaOutputControl && isVolumeSliderVisible + } + + private var sliderExtraHeight: CGFloat { + guard shouldShowVolumeSlider else { return 0 } + return isExpanded ? expandedSliderExtraHeight : collapsedSliderExtraHeight + } + + 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)))%" + } + @ViewBuilder private var panelBackground: some View { if enableBlur { diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index ad54397..edde46e 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -89,15 +89,17 @@ class LockScreenPanelManager { print("[\(timestamp())] LockScreenPanelManager: panel visible") } - func updatePanelSize(expanded: Bool, animated: Bool = true) { + func updatePanelSize(expanded: Bool, additionalHeight: CGFloat = 0, 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 baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize + let targetWidth = baseSize.width + let targetHeight = baseSize.height + additionalHeight + let originX = baseFrame.midX - (targetWidth / 2) let originY = baseFrame.origin.y - let targetFrame = NSRect(x: originX, y: originY, width: targetSize.width, height: targetSize.height) + let targetFrame = NSRect(x: originX, y: originY, width: targetWidth, height: targetHeight) if animated { window.animator().setFrame(targetFrame, display: true)