From 75bb8ce9bebaebf52d8bbd9268b44f562d8007c4 Mon Sep 17 00:00:00 2001
From: Amir Zarrinkafsh <3339418+nightah@users.noreply.github.com>
Date: Sun, 22 Mar 2026 17:27:00 +1100
Subject: [PATCH 1/5] feat(media): add airplay device selector with volume
control for apple music
Adds an AirPlay device picker popover to the music player controls, allowing users to select and control volume for individual AirPlay devices (HomePod, Apple TV, AirPort Express, etc.) when Apple Music is the active player.
---
.../components/Notch/NotchHomeView.swift | 181 ++++++++++++--
.../managers/AppleMusicAirPlayManager.swift | 235 ++++++++++++++++++
2 files changed, 398 insertions(+), 18 deletions(-)
create mode 100644 DynamicIsland/managers/AppleMusicAirPlayManager.swift
diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift
index 37deed7..462833b 100644
--- a/DynamicIsland/components/Notch/NotchHomeView.swift
+++ b/DynamicIsland/components/Notch/NotchHomeView.swift
@@ -849,35 +849,70 @@ struct CustomSlider: View {
private struct MediaOutputPickerButton: View {
@ObservedObject private var routeManager = AudioRouteManager.shared
+ @ObservedObject private var musicManager = MusicManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
+ @StateObject private var airPlayManager = AppleMusicAirPlayManager()
@State private var isPopoverPresented = false
+ @State private var isAirPlayPopoverPresented = false
@State private var isHoveringPopover = false
@EnvironmentObject private var vm: DynamicIslandViewModel
+ private var isAppleMusicActive: Bool {
+ musicManager.bundleIdentifier == "com.apple.Music"
+ }
+
var body: some View {
- HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
- isPopoverPresented.toggle()
- if isPopoverPresented {
- routeManager.refreshDevices()
+ HStack(spacing: 4) {
+ HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
+ isPopoverPresented.toggle()
+ if isPopoverPresented {
+ routeManager.refreshDevices()
+ }
}
- }
- .accessibilityLabel("Media output")
- .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
- MediaOutputSelectorPopover(
- routeManager: routeManager,
- volumeModel: volumeModel,
- onHoverChanged: { hovering in
- isHoveringPopover = hovering
+ .accessibilityLabel("Media output")
+ .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
+ MediaOutputSelectorPopover(
+ routeManager: routeManager,
+ volumeModel: volumeModel,
+ onHoverChanged: { hovering in
+ isHoveringPopover = hovering
+ updatePopoverActivity()
+ }
+ ) {
+ isPopoverPresented = false
+ isHoveringPopover = false
updatePopoverActivity()
}
- ) {
- isPopoverPresented = false
- isHoveringPopover = false
- updatePopoverActivity()
+ }
+
+ if isAppleMusicActive && !airPlayManager.devices.isEmpty {
+ HoverButton(icon: "airplayaudio", iconColor: .white, scale: .medium) {
+ isAirPlayPopoverPresented.toggle()
+ if isAirPlayPopoverPresented {
+ Task { await airPlayManager.refreshDevices() }
+ }
+ }
+ .accessibilityLabel("AirPlay")
+ .popover(isPresented: $isAirPlayPopoverPresented, arrowEdge: .bottom) {
+ AirPlaySelectorPopover(
+ airPlayManager: airPlayManager,
+ onHoverChanged: { hovering in
+ isHoveringPopover = hovering
+ updatePopoverActivity()
+ }
+ ) {
+ isAirPlayPopoverPresented = false
+ isHoveringPopover = false
+ updatePopoverActivity()
+ }
+ }
}
}
.onAppear {
routeManager.refreshDevices()
+ if isAppleMusicActive {
+ Task { await airPlayManager.refreshDevices() }
+ }
}
.onChange(of: isPopoverPresented) { _, presented in
if !presented {
@@ -885,6 +920,17 @@ private struct MediaOutputPickerButton: View {
}
updatePopoverActivity()
}
+ .onChange(of: isAirPlayPopoverPresented) { _, presented in
+ if !presented {
+ isHoveringPopover = false
+ }
+ updatePopoverActivity()
+ }
+ .onChange(of: musicManager.bundleIdentifier) { _, newBundle in
+ if newBundle == "com.apple.Music" {
+ Task { await airPlayManager.refreshDevices() }
+ }
+ }
.onDisappear {
vm.isMediaOutputPopoverActive = false
}
@@ -895,7 +941,7 @@ private struct MediaOutputPickerButton: View {
}
private func updatePopoverActivity() {
- vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
+ vm.isMediaOutputPopoverActive = (isPopoverPresented || isAirPlayPopoverPresented) && isHoveringPopover
}
}
@@ -1008,7 +1054,7 @@ struct MediaOutputSelectorPopover: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
- .frame(maxHeight: 180)
+ .frame(maxHeight: 240)
}
}
}
@@ -1029,6 +1075,105 @@ struct MediaOutputSelectorPopover: View {
}
}
+struct AirPlaySelectorPopover: View {
+ @ObservedObject var airPlayManager: AppleMusicAirPlayManager
+ var onHoverChanged: (Bool) -> Void
+ var dismiss: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("AirPlay")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ if airPlayManager.devices.isEmpty {
+ Text("No AirPlay devices found")
+ .font(.callout)
+ .foregroundColor(.secondary)
+ .padding(.vertical, 12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ ScrollView {
+ VStack(spacing: 6) {
+ ForEach(airPlayManager.devices) { device in
+ VStack(spacing: 4) {
+ Button {
+ Task { await airPlayManager.toggleDevice(device) }
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: device.iconName)
+ .font(.system(size: 14, weight: .medium))
+ Text(device.name)
+ .foregroundColor(.primary)
+ .lineLimit(1)
+ Spacer()
+ if device.isSelected {
+ Image(systemName: "checkmark")
+ .font(.system(size: 12, weight: .bold))
+ }
+ }
+ .padding(.vertical, 6)
+ .padding(.horizontal, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(device.isSelected ? Color.primary.opacity(0.12) : .clear)
+ )
+ }
+ .buttonStyle(.plain)
+
+ if device.isSelected {
+ AirPlayVolumeSlider(
+ airPlayManager: airPlayManager,
+ deviceID: device.id
+ )
+ .padding(.horizontal, 8)
+ }
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(maxHeight: 300)
+ }
+ }
+ .frame(width: 240)
+ .padding(16)
+ .onHover { hovering in
+ onHoverChanged(hovering)
+ }
+ .onDisappear {
+ onHoverChanged(false)
+ }
+ }
+}
+
+/// Local @State slider decoupled from the manager's @Published state.
+/// This prevents SwiftUI from resetting the slider position when other
+/// published properties on the manager change during a drag.
+private struct AirPlayVolumeSlider: View {
+ @ObservedObject var airPlayManager: AppleMusicAirPlayManager
+ let deviceID: String
+
+ @State private var sliderValue: Double = 0
+ @State private var isSyncing = false
+
+ var body: some View {
+ Slider(value: $sliderValue, in: 0...100)
+ .tint(.accentColor)
+ .onAppear {
+ isSyncing = true
+ let volume = airPlayManager.devices.first { $0.id == deviceID }?.volume ?? 0
+ sliderValue = Double(volume)
+ isSyncing = false
+ }
+ .onChange(of: sliderValue) { _, newValue in
+ guard !isSyncing else { return }
+ airPlayManager.setVolume(Int(newValue), for: deviceID)
+ }
+ }
+}
+
final class MediaOutputVolumeViewModel: ObservableObject {
@Published var level: Float
@Published var isMuted: Bool
diff --git a/DynamicIsland/managers/AppleMusicAirPlayManager.swift b/DynamicIsland/managers/AppleMusicAirPlayManager.swift
new file mode 100644
index 0000000..cc34b89
--- /dev/null
+++ b/DynamicIsland/managers/AppleMusicAirPlayManager.swift
@@ -0,0 +1,235 @@
+/*
+ * Atoll (DynamicIsland)
+ * Copyright (C) 2024-2026 Atoll Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+import Foundation
+
+struct AirPlayDevice: Identifiable, Equatable {
+ let id: String // network address or name as fallback
+ let name: String
+ let kind: String
+ var isSelected: Bool
+ var volume: Int // 0-100
+
+ var iconName: String {
+ switch kind {
+ case "HomePod":
+ return "homepodmini"
+ case "AirPort Express":
+ return "airport.express"
+ case "computer":
+ return "laptopcomputer"
+ case "AirPlay device":
+ let normalizedName = name.lowercased()
+ if normalizedName.contains("tv") || normalizedName.contains("theatre") || normalizedName.contains("theater") {
+ return "appletv"
+ }
+ return "airplayaudio"
+ default:
+ return "airplayaudio"
+ }
+ }
+}
+
+@MainActor
+final class AppleMusicAirPlayManager: ObservableObject {
+ @Published private(set) var devices: [AirPlayDevice] = []
+ @Published private(set) var isLoading = false
+
+ private var isBusy = false
+
+ // MARK: - Volume commit pipeline
+ // Slider values are written to userSetVolume (non-@Published) and drained
+ // at throttled intervals into a serialised AppleScript commit queue.
+ // This avoids triggering SwiftUI re-renders that would reset the Slider.
+
+ private var userSetVolume: [String: Int] = [:]
+ private var throttleActive: [String: Bool] = [:]
+ private var lastCommittedVolume: [String: Int] = [:]
+ private var isCommittingVolume = false
+ private var pendingVolumeCommits: [(volume: Int, deviceName: String, deviceID: String)] = []
+
+ // MARK: - Device management
+
+ func refreshDevices() async {
+ guard !isBusy else { return }
+ isBusy = true
+ defer { isBusy = false }
+
+ isLoading = true
+ defer { isLoading = false }
+
+ let script = """
+ tell application "Music"
+ try
+ set deviceList to {}
+ set allDevices to AirPlay devices
+ repeat with d in allDevices
+ set deviceName to name of d
+ set deviceKind to kind of d as string
+ set deviceActive to selected of d
+ set deviceAddr to network address of d
+ set deviceVol to sound volume of d
+ set end of deviceList to {deviceName, deviceKind, deviceActive, deviceAddr, deviceVol}
+ end repeat
+ return deviceList
+ on error
+ return {}
+ end try
+ end tell
+ """
+
+ guard let result = try? await AppleScriptHelper.execute(script) else { return }
+
+ var fetched: [AirPlayDevice] = []
+ for i in 1...result.numberOfItems {
+ guard let item = result.atIndex(i) else { continue }
+ let name = item.atIndex(1)?.stringValue ?? "Unknown"
+ let kind = item.atIndex(2)?.stringValue ?? "AirPlay device"
+ let selected = item.atIndex(3)?.booleanValue ?? false
+ let addr = item.atIndex(4)?.stringValue ?? ""
+ let volume = Int(item.atIndex(5)?.int32Value ?? 50)
+ let id = addr.isEmpty ? name : addr
+ fetched.append(AirPlayDevice(id: id, name: name, kind: kind, isSelected: selected, volume: volume))
+ }
+ devices = fetched
+ for device in fetched {
+ lastCommittedVolume[device.id] = device.volume
+ }
+ }
+
+ func toggleDevice(_ device: AirPlayDevice) async {
+ guard !isBusy else { return }
+ isBusy = true
+ defer { isBusy = false }
+
+ let newState = !device.isSelected
+ let escapedName = device.name.replacingOccurrences(of: "\"", with: "\\\"")
+ let script = """
+ tell application "Music"
+ try
+ set targetDevice to first AirPlay device whose name is "\(escapedName)"
+ set selected of targetDevice to \(newState)
+ end try
+ end tell
+ """
+ try? await AppleScriptHelper.executeVoid(script)
+ try? await Task.sleep(for: .milliseconds(200))
+
+ isBusy = false
+ await refreshDevices()
+ }
+
+ // MARK: - Volume control
+
+ /// Called by the slider on every drag value change.
+ /// Does NOT mutate any @Published property — this is critical to prevent
+ /// SwiftUI from recreating the slider view and resetting its @State.
+ func setVolume(_ volume: Int, for deviceID: String) {
+ let clamped = min(max(volume, 0), 100)
+ guard let device = devices.first(where: { $0.id == deviceID }) else { return }
+
+ userSetVolume[deviceID] = clamped
+ guard throttleActive[deviceID] != true else { return }
+
+ throttleActive[deviceID] = true
+ Task { [weak self] in
+ guard let self else { return }
+
+ // Commit immediately, then drain at intervals while the user keeps dragging
+ if let first = self.userSetVolume.removeValue(forKey: deviceID) {
+ await self.enqueueVolumeCommit(volume: first, deviceName: device.name, deviceID: deviceID)
+ }
+
+ var alive = true
+ while alive {
+ try? await Task.sleep(for: .milliseconds(150))
+ guard let pending = self.userSetVolume.removeValue(forKey: deviceID) else {
+ alive = false
+ break
+ }
+ await self.enqueueVolumeCommit(volume: pending, deviceName: device.name, deviceID: deviceID)
+ }
+ self.throttleActive[deviceID] = false
+ }
+ }
+
+ // MARK: - Commit queue
+
+ /// Serialises AppleScript calls — only one runs at a time.
+ /// Queued entries are deduplicated per-device (only the latest value is kept).
+ private func enqueueVolumeCommit(volume: Int, deviceName: String, deviceID: String) async {
+ if isCommittingVolume {
+ pendingVolumeCommits.removeAll { $0.deviceID == deviceID }
+ pendingVolumeCommits.append((volume: volume, deviceName: deviceName, deviceID: deviceID))
+ return
+ }
+
+ isCommittingVolume = true
+ await commitVolume(volume: volume, deviceName: deviceName, deviceID: deviceID)
+
+ while !pendingVolumeCommits.isEmpty {
+ var latestByDevice: [String: (volume: Int, deviceName: String, deviceID: String)] = [:]
+ for entry in pendingVolumeCommits {
+ latestByDevice[entry.deviceID] = entry
+ }
+ pendingVolumeCommits.removeAll()
+
+ for (_, entry) in latestByDevice {
+ await commitVolume(volume: entry.volume, deviceName: entry.deviceName, deviceID: entry.deviceID)
+ }
+ }
+ isCommittingVolume = false
+ }
+
+ /// Commits a volume change via AppleScript.
+ ///
+ /// Apple Music internally rebalances other AirPlay devices when one device's
+ /// volume drops past another device's volume in a single call. To prevent this,
+ /// when moving DOWN past the highest other selected device's volume, we first
+ /// set to that device's volume (waypoint), then to the actual target — at most
+ /// two AppleScript calls.
+ private func commitVolume(volume: Int, deviceName: String, deviceID: String) async {
+ let escapedName = deviceName.replacingOccurrences(of: "\"", with: "\\\"")
+ let current = lastCommittedVolume[deviceID] ?? volume
+
+ let otherMaxVolume = devices
+ .filter { $0.id != deviceID && $0.isSelected }
+ .map { lastCommittedVolume[$0.id] ?? $0.volume }
+ .max() ?? 0
+
+ // Waypoint needed when crossing below another device's volume
+ if current > otherMaxVolume && volume < otherMaxVolume && otherMaxVolume > 0 {
+ await sendVolumeAppleScript(escapedName: escapedName, volume: otherMaxVolume)
+ }
+
+ await sendVolumeAppleScript(escapedName: escapedName, volume: volume)
+ lastCommittedVolume[deviceID] = volume
+ }
+
+ private func sendVolumeAppleScript(escapedName: String, volume: Int) async {
+ let script = """
+ tell application "Music"
+ try
+ set targetDevice to first AirPlay device whose name is "\(escapedName)"
+ set sound volume of targetDevice to \(volume)
+ end try
+ end tell
+ """
+ try? await AppleScriptHelper.executeVoid(script)
+ }
+}
From 5e1e8f613ba0783b774055087fe940643b3467ed Mon Sep 17 00:00:00 2001
From: Amir Zarrinkafsh <3339418+nightah@users.noreply.github.com>
Date: Sun, 22 Mar 2026 21:26:27 +1100
Subject: [PATCH 2/5] feat(media): include airplay for apple music on lock
screen widget
---
.../LockScreen/LockScreenMusicPanel.swift | 103 +++++++++++++++++-
.../components/Notch/NotchHomeView.swift | 7 +-
.../managers/AppleMusicAirPlayManager.swift | 10 ++
3 files changed, 115 insertions(+), 5 deletions(-)
diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
index 2b32047..37001be 100644
--- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
+++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
@@ -37,6 +37,7 @@ struct LockScreenMusicPanel: View {
@ObservedObject var musicManager = MusicManager.shared
@ObservedObject private var routeManager = AudioRouteManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
+ @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@ObservedObject private var animator: LockScreenPanelAnimator
@State private var sliderValue: Double = 0
@State private var dragging: Bool = false
@@ -142,6 +143,9 @@ struct LockScreenMusicPanel: View {
logPanelAppearance()
updatePanelSize(animated: false)
routeManager.refreshDevices()
+ if isAppleMusicActive {
+ Task { await airPlayManager.refreshDevices() }
+ }
logGlassState(reason: "Panel appeared")
}
.onDisappear {
@@ -163,6 +167,17 @@ struct LockScreenMusicPanel: View {
}
updatePanelSize()
}
+ .onChange(of: isVolumeSliderVisible) { _, visible in
+ if visible && isAppleMusicActive {
+ Task { await airPlayManager.refreshDevices() }
+ } else {
+ measuredAirPlayHeight = nil
+ }
+ updatePanelSize()
+ }
+ .onChange(of: airPlayManager.devices) { _, _ in
+ updatePanelSize()
+ }
.onChange(of: enableLyrics) { _, _ in
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
updatePanelSize()
@@ -433,6 +448,12 @@ struct LockScreenMusicPanel: View {
volumeSlider
.frame(maxWidth: .infinity, alignment: alignment)
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
+
+ if shouldShowAirPlay {
+ airPlaySection
+ .frame(maxWidth: .infinity, alignment: alignment)
+ .transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
+ }
}
if enableLyrics {
@@ -443,6 +464,7 @@ struct LockScreenMusicPanel: View {
.frame(maxWidth: .infinity, alignment: alignment)
.padding(.top, isExpanded ? 6 : 2)
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
+ .animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowAirPlay)
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: enableLyrics)
}
@@ -692,6 +714,61 @@ struct LockScreenMusicPanel: View {
)
}
+ private var airPlaySection: some View {
+ VStack(spacing: 6) {
+ ForEach(airPlayManager.devices) { device in
+ VStack(spacing: 4) {
+ Button {
+ registerInteraction()
+ Task { await airPlayManager.toggleDevice(device) }
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: device.iconName)
+ .font(.system(size: isExpanded ? 14 : 12, weight: .medium))
+ .foregroundColor(.white.opacity(0.8))
+ Text(device.name)
+ .font(.system(size: isExpanded ? 13 : 11, weight: .medium))
+ .foregroundColor(.white)
+ .lineLimit(1)
+ Spacer()
+ if device.isSelected {
+ Image(systemName: "checkmark")
+ .font(.system(size: isExpanded ? 12 : 10, weight: .bold))
+ .foregroundColor(.accentColor)
+ }
+ }
+ .padding(.vertical, 4)
+ .padding(.horizontal, 12)
+ }
+ .buttonStyle(.plain)
+
+ if device.isSelected {
+ AirPlayVolumeSlider(
+ airPlayManager: airPlayManager,
+ deviceID: device.id
+ )
+ .padding(.horizontal, 12)
+ }
+ }
+ }
+ }
+ .padding(.vertical, 8)
+ .background(
+ RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
+ .fill(sliderBackgroundFill)
+ )
+ .background(
+ GeometryReader { geo in
+ Color.clear
+ .onAppear { measuredAirPlayHeight = geo.size.height }
+ .onChange(of: geo.size.height) { _, newHeight in
+ measuredAirPlayHeight = newHeight
+ updatePanelSize()
+ }
+ }
+ )
+ }
+
private var sliderBackgroundFill: Color {
if usesLiquidGlass {
return Color.white.opacity(0.05)
@@ -699,6 +776,14 @@ struct LockScreenMusicPanel: View {
return Color.white.opacity(0.08)
}
+ private var isAppleMusicActive: Bool {
+ musicManager.bundleIdentifier == "com.apple.Music"
+ }
+
+ private var shouldShowAirPlay: Bool {
+ shouldShowVolumeSlider && isAppleMusicActive && !airPlayManager.devices.isEmpty
+ }
+
private var shouldShowVolumeSlider: Bool {
showMediaOutputControl && isVolumeSliderVisible
}
@@ -711,8 +796,23 @@ struct LockScreenMusicPanel: View {
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics)
}
+ @State private var measuredAirPlayHeight: CGFloat?
+
+ private var airPlayExtraHeight: CGFloat {
+ guard shouldShowAirPlay else { return 0 }
+ if let measured = measuredAirPlayHeight {
+ return measured
+ }
+ // Fallback estimate used only until the first GeometryReader measurement arrives
+ let selectedCount = airPlayManager.devices.filter(\.isSelected).count
+ let totalCount = airPlayManager.devices.count
+ let deviceRows: CGFloat = CGFloat(totalCount) * 22 + CGFloat(max(totalCount - 1, 0)) * 6
+ let sliders: CGFloat = CGFloat(selectedCount) * 24
+ return deviceRows + sliders + 16
+ }
+
private var totalExtraHeight: CGFloat {
- sliderExtraHeight + lyricsExtraHeight
+ sliderExtraHeight + airPlayExtraHeight + lyricsExtraHeight
}
private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat {
@@ -722,6 +822,7 @@ struct LockScreenMusicPanel: View {
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
+ airPlayExtraHeight +
lyricsHeight(forExpanded: expanded, enabled: enableLyrics)
}
diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift
index 462833b..0da7d2e 100644
--- a/DynamicIsland/components/Notch/NotchHomeView.swift
+++ b/DynamicIsland/components/Notch/NotchHomeView.swift
@@ -851,7 +851,7 @@ private struct MediaOutputPickerButton: View {
@ObservedObject private var routeManager = AudioRouteManager.shared
@ObservedObject private var musicManager = MusicManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
- @StateObject private var airPlayManager = AppleMusicAirPlayManager()
+ @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@State private var isPopoverPresented = false
@State private var isAirPlayPopoverPresented = false
@State private var isHoveringPopover = false
@@ -1151,7 +1151,7 @@ struct AirPlaySelectorPopover: View {
/// Local @State slider decoupled from the manager's @Published state.
/// This prevents SwiftUI from resetting the slider position when other
/// published properties on the manager change during a drag.
-private struct AirPlayVolumeSlider: View {
+struct AirPlayVolumeSlider: View {
@ObservedObject var airPlayManager: AppleMusicAirPlayManager
let deviceID: String
@@ -1163,8 +1163,7 @@ private struct AirPlayVolumeSlider: View {
.tint(.accentColor)
.onAppear {
isSyncing = true
- let volume = airPlayManager.devices.first { $0.id == deviceID }?.volume ?? 0
- sliderValue = Double(volume)
+ sliderValue = Double(airPlayManager.currentVolume(for: deviceID))
isSyncing = false
}
.onChange(of: sliderValue) { _, newValue in
diff --git a/DynamicIsland/managers/AppleMusicAirPlayManager.swift b/DynamicIsland/managers/AppleMusicAirPlayManager.swift
index cc34b89..75210bf 100644
--- a/DynamicIsland/managers/AppleMusicAirPlayManager.swift
+++ b/DynamicIsland/managers/AppleMusicAirPlayManager.swift
@@ -47,6 +47,8 @@ struct AirPlayDevice: Identifiable, Equatable {
@MainActor
final class AppleMusicAirPlayManager: ObservableObject {
+ static let shared = AppleMusicAirPlayManager()
+
@Published private(set) var devices: [AirPlayDevice] = []
@Published private(set) var isLoading = false
@@ -136,6 +138,14 @@ final class AppleMusicAirPlayManager: ObservableObject {
// MARK: - Volume control
+ /// Returns the most recent volume the user set for a device, whether it's
+ /// still pending, already committed, or (as fallback) the last fetched value.
+ func currentVolume(for deviceID: String) -> Int {
+ if let pending = userSetVolume[deviceID] { return pending }
+ if let committed = lastCommittedVolume[deviceID] { return committed }
+ return devices.first { $0.id == deviceID }?.volume ?? 0
+ }
+
/// Called by the slider on every drag value change.
/// Does NOT mutate any @Published property — this is critical to prevent
/// SwiftUI from recreating the slider view and resetting its @State.
From 9d17e4aafbbe28aa7139a54597f4066c518ce5bb Mon Sep 17 00:00:00 2001
From: Amir Zarrinkafsh <3339418+nightah@users.noreply.github.com>
Date: Fri, 27 Mar 2026 13:03:59 +1100
Subject: [PATCH 3/5] feat(media): decouple airplay and output, merged output
for lock screen
---
.../LockScreen/LockScreenMusicPanel.swift | 99 +++++++++++--
.../Notch/MinimalisticMusicPlayerView.swift | 72 +++++++++-
.../components/Notch/NotchHomeView.swift | 134 ++++++++++--------
.../Settings/MusicSlotConfigurationView.swift | 33 +++--
.../components/Settings/SettingsView.swift | 21 +++
DynamicIsland/models/Constants.swift | 1 +
DynamicIsland/models/MusicControlButton.swift | 20 ++-
7 files changed, 296 insertions(+), 84 deletions(-)
diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
index 37001be..198a45f 100644
--- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
+++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
@@ -45,6 +45,9 @@ struct LockScreenMusicPanel: View {
@State private var isActive = true
@State private var isExpanded = false
@State private var isVolumeSliderVisible = false
+ @State private var isMediaOutputPopoverPresented = false
+ @State private var isAirPlayPopoverPresented = false
+ @State private var measuredAirPlayHeight: CGFloat?
@State private var collapseWorkItem: DispatchWorkItem?
@State private var parallaxResumeWorkItem: DispatchWorkItem?
@State private var isParallaxSuspended = false
@@ -59,6 +62,7 @@ struct LockScreenMusicPanel: View {
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
@Default(.musicControlSlots) private var slotConfig
@Default(.musicSkipBehavior) private var musicSkipBehavior
+ @Default(.lockScreenMusicMergedAirPlayOutput) private var mergedAirPlayOutput
@Default(.enableLyrics) private var enableLyrics
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
@@ -168,15 +172,19 @@ struct LockScreenMusicPanel: View {
updatePanelSize()
}
.onChange(of: isVolumeSliderVisible) { _, visible in
- if visible && isAppleMusicActive {
- Task { await airPlayManager.refreshDevices() }
- } else {
- measuredAirPlayHeight = nil
+ if useMergedAirPlayOutput {
+ if visible && isAppleMusicActive {
+ Task { await airPlayManager.refreshDevices() }
+ } else {
+ measuredAirPlayHeight = nil
+ }
}
updatePanelSize()
}
.onChange(of: airPlayManager.devices) { _, _ in
- updatePanelSize()
+ if useMergedAirPlayOutput {
+ updatePanelSize()
+ }
}
.onChange(of: enableLyrics) { _, _ in
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
@@ -484,7 +492,7 @@ struct LockScreenMusicPanel: View {
return fallbackSlots
}
- let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl)
+ let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive)
return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout
}
@@ -562,7 +570,17 @@ struct LockScreenMusicPanel: View {
musicManager.toggleRepeat()
}
case .mediaOutput:
- mediaOutputControlButton
+ if useMergedAirPlayOutput {
+ mediaOutputControlButton
+ } else {
+ standaloneMediaOutputButton
+ }
+ case .airPlay:
+ if useMergedAirPlayOutput {
+ mediaOutputControlButton
+ } else {
+ standaloneAirPlayButton
+ }
case .lyrics:
controlButton(
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
@@ -640,6 +658,65 @@ struct LockScreenMusicPanel: View {
.accessibilityLabel("Media output")
}
+ private var standaloneMediaOutputButton: some View {
+ let frameSize: CGFloat = isExpanded ? 56 : 32
+ let iconSize: CGFloat = isExpanded ? 26 : 18
+
+ return PanelControlButton(
+ icon: mediaOutputIcon,
+ frameSize: frameSize,
+ iconSize: iconSize,
+ iconColor: isMediaOutputPopoverPresented ? .accentColor : .white.opacity(0.8),
+ backgroundOpacity: isMediaOutputPopoverPresented ? 0.22 : 0.0,
+ interaction: .none,
+ symbolEffect: .replace
+ ) {
+ registerInteraction()
+ isMediaOutputPopoverPresented.toggle()
+ if isMediaOutputPopoverPresented {
+ routeManager.refreshDevices()
+ }
+ }
+ .accessibilityLabel("Media output")
+ .popover(isPresented: $isMediaOutputPopoverPresented, arrowEdge: .bottom) {
+ MediaOutputSelectorPopover(
+ routeManager: routeManager,
+ volumeModel: volumeModel,
+ onHoverChanged: { _ in },
+ dismiss: { isMediaOutputPopoverPresented = false }
+ )
+ }
+ }
+
+ private var standaloneAirPlayButton: some View {
+ let frameSize: CGFloat = isExpanded ? 56 : 32
+ let iconSize: CGFloat = isExpanded ? 26 : 18
+
+ return PanelControlButton(
+ icon: "airplayaudio",
+ frameSize: frameSize,
+ iconSize: iconSize,
+ iconColor: isAirPlayPopoverPresented ? .accentColor : .white.opacity(0.8),
+ backgroundOpacity: isAirPlayPopoverPresented ? 0.22 : 0.0,
+ interaction: .none,
+ symbolEffect: .replace
+ ) {
+ registerInteraction()
+ isAirPlayPopoverPresented.toggle()
+ if isAirPlayPopoverPresented {
+ Task { await airPlayManager.refreshDevices() }
+ }
+ }
+ .accessibilityLabel("AirPlay")
+ .popover(isPresented: $isAirPlayPopoverPresented, arrowEdge: .bottom) {
+ AirPlaySelectorPopover(
+ airPlayManager: airPlayManager,
+ onHoverChanged: { _ in },
+ dismiss: { isAirPlayPopoverPresented = false }
+ )
+ }
+ }
+
private func lyricsSection(alignment: Alignment) -> some View {
let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
let transition: AnyTransition = .asymmetric(
@@ -780,8 +857,12 @@ struct LockScreenMusicPanel: View {
musicManager.bundleIdentifier == "com.apple.Music"
}
+ private var useMergedAirPlayOutput: Bool {
+ mergedAirPlayOutput && isAppleMusicActive && slotConfig.contains(where: { $0 == .mediaOutput || $0 == .airPlay })
+ }
+
private var shouldShowAirPlay: Bool {
- shouldShowVolumeSlider && isAppleMusicActive && !airPlayManager.devices.isEmpty
+ useMergedAirPlayOutput && shouldShowVolumeSlider && !airPlayManager.devices.isEmpty
}
private var shouldShowVolumeSlider: Bool {
@@ -796,8 +877,6 @@ struct LockScreenMusicPanel: View {
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics)
}
- @State private var measuredAirPlayHeight: CGFloat?
-
private var airPlayExtraHeight: CGFloat {
guard shouldShowAirPlay else { return 0 }
if let measured = measuredAirPlayHeight {
diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift
index c306ce3..fc57c91 100644
--- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift
+++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift
@@ -876,9 +876,13 @@ private struct MinimalisticReminderDetailsView: View {
)
}
+ private var isAppleMusicActive: Bool {
+ musicManager.bundleIdentifier == "com.apple.Music"
+ }
+
private var displayedSlots: [MusicControlButton] {
if showCustomControls {
- let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl)
+ let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive)
return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout
}
@@ -943,6 +947,8 @@ private struct MinimalisticReminderDetailsView: View {
}
case .mediaOutput:
MinimalisticMediaOutputButton()
+ case .airPlay:
+ MinimalisticAirPlayButton()
case .lyrics:
controlButton(
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
@@ -1027,6 +1033,70 @@ private struct MinimalisticReminderDetailsView: View {
}
}
+ private struct MinimalisticAirPlayButton: View {
+ @ObservedObject private var musicManager = MusicManager.shared
+ @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
+ @EnvironmentObject private var vm: DynamicIslandViewModel
+ @State private var isPopoverPresented = false
+ @State private var isHoveringPopover = false
+
+ private var isAppleMusicActive: Bool {
+ musicManager.bundleIdentifier == "com.apple.Music"
+ }
+
+ var body: some View {
+ MinimalisticSquircircleButton(
+ icon: "airplayaudio",
+ fontSize: 18,
+ fontWeight: .medium,
+ frameSize: CGSize(width: 40, height: 40),
+ cornerRadius: 16,
+ foregroundColor: .white.opacity(0.85),
+ symbolEffectStyle: .replace
+ ) {
+ isPopoverPresented.toggle()
+ if isPopoverPresented {
+ Task { await airPlayManager.refreshDevices() }
+ }
+ }
+ .accessibilityLabel("AirPlay")
+ .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
+ AirPlaySelectorPopover(
+ airPlayManager: airPlayManager,
+ onHoverChanged: { hovering in
+ isHoveringPopover = hovering
+ updateActivity()
+ }
+ ) {
+ isPopoverPresented = false
+ isHoveringPopover = false
+ updateActivity()
+ }
+ }
+ .onChange(of: isPopoverPresented) { _, presented in
+ if !presented { isHoveringPopover = false }
+ updateActivity()
+ }
+ .onAppear {
+ if isAppleMusicActive {
+ Task { await airPlayManager.refreshDevices() }
+ }
+ }
+ .onChange(of: musicManager.bundleIdentifier) { _, newBundle in
+ if newBundle == "com.apple.Music" {
+ Task { await airPlayManager.refreshDevices() }
+ }
+ }
+ .onDisappear {
+ vm.isMediaOutputPopoverActive = false
+ }
+ }
+
+ private func updateActivity() {
+ vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
+ }
+ }
+
private var repeatIcon: String {
switch musicManager.repeatMode {
case .off: return "repeat"
diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift
index 0da7d2e..09d60e5 100644
--- a/DynamicIsland/components/Notch/NotchHomeView.swift
+++ b/DynamicIsland/components/Notch/NotchHomeView.swift
@@ -398,9 +398,13 @@ struct MusicControlsView: View {
}
}
+ private var isAppleMusicActive: Bool {
+ musicManager.bundleIdentifier == "com.apple.Music"
+ }
+
private var displayedSlots: [MusicControlButton] {
if showCustomControls {
- let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl)
+ let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive)
return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout
}
@@ -474,6 +478,8 @@ struct MusicControlsView: View {
}
case .mediaOutput:
MediaOutputPickerButton()
+ case .airPlay:
+ AirPlayPickerButton()
case .lyrics:
HoverButton(
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
@@ -849,11 +855,60 @@ struct CustomSlider: View {
private struct MediaOutputPickerButton: View {
@ObservedObject private var routeManager = AudioRouteManager.shared
- @ObservedObject private var musicManager = MusicManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
+ @State private var isPopoverPresented = false
+ @State private var isHoveringPopover = false
+ @EnvironmentObject private var vm: DynamicIslandViewModel
+
+ var body: some View {
+ HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
+ isPopoverPresented.toggle()
+ if isPopoverPresented {
+ routeManager.refreshDevices()
+ }
+ }
+ .accessibilityLabel("Media output")
+ .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
+ MediaOutputSelectorPopover(
+ routeManager: routeManager,
+ volumeModel: volumeModel,
+ onHoverChanged: { hovering in
+ isHoveringPopover = hovering
+ updatePopoverActivity()
+ }
+ ) {
+ isPopoverPresented = false
+ isHoveringPopover = false
+ updatePopoverActivity()
+ }
+ }
+ .onAppear {
+ routeManager.refreshDevices()
+ }
+ .onChange(of: isPopoverPresented) { _, presented in
+ if !presented {
+ isHoveringPopover = false
+ }
+ updatePopoverActivity()
+ }
+ .onDisappear {
+ vm.isMediaOutputPopoverActive = false
+ }
+ }
+
+ private var buttonIcon: String {
+ routeManager.activeDevice?.iconName ?? "speaker.wave.2"
+ }
+
+ private func updatePopoverActivity() {
+ vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
+ }
+}
+
+private struct AirPlayPickerButton: View {
+ @ObservedObject private var musicManager = MusicManager.shared
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@State private var isPopoverPresented = false
- @State private var isAirPlayPopoverPresented = false
@State private var isHoveringPopover = false
@EnvironmentObject private var vm: DynamicIslandViewModel
@@ -862,68 +917,33 @@ private struct MediaOutputPickerButton: View {
}
var body: some View {
- HStack(spacing: 4) {
- HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
- isPopoverPresented.toggle()
- if isPopoverPresented {
- routeManager.refreshDevices()
- }
+ HoverButton(icon: "airplayaudio", iconColor: .white, scale: .medium) {
+ isPopoverPresented.toggle()
+ if isPopoverPresented {
+ Task { await airPlayManager.refreshDevices() }
}
- .accessibilityLabel("Media output")
- .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
- MediaOutputSelectorPopover(
- routeManager: routeManager,
- volumeModel: volumeModel,
- onHoverChanged: { hovering in
- isHoveringPopover = hovering
- updatePopoverActivity()
- }
- ) {
- isPopoverPresented = false
- isHoveringPopover = false
+ }
+ .accessibilityLabel("AirPlay")
+ .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
+ AirPlaySelectorPopover(
+ airPlayManager: airPlayManager,
+ onHoverChanged: { hovering in
+ isHoveringPopover = hovering
updatePopoverActivity()
}
- }
-
- if isAppleMusicActive && !airPlayManager.devices.isEmpty {
- HoverButton(icon: "airplayaudio", iconColor: .white, scale: .medium) {
- isAirPlayPopoverPresented.toggle()
- if isAirPlayPopoverPresented {
- Task { await airPlayManager.refreshDevices() }
- }
- }
- .accessibilityLabel("AirPlay")
- .popover(isPresented: $isAirPlayPopoverPresented, arrowEdge: .bottom) {
- AirPlaySelectorPopover(
- airPlayManager: airPlayManager,
- onHoverChanged: { hovering in
- isHoveringPopover = hovering
- updatePopoverActivity()
- }
- ) {
- isAirPlayPopoverPresented = false
- isHoveringPopover = false
- updatePopoverActivity()
- }
- }
+ ) {
+ isPopoverPresented = false
+ isHoveringPopover = false
+ updatePopoverActivity()
}
}
.onAppear {
- routeManager.refreshDevices()
if isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
}
}
.onChange(of: isPopoverPresented) { _, presented in
- if !presented {
- isHoveringPopover = false
- }
- updatePopoverActivity()
- }
- .onChange(of: isAirPlayPopoverPresented) { _, presented in
- if !presented {
- isHoveringPopover = false
- }
+ if !presented { isHoveringPopover = false }
updatePopoverActivity()
}
.onChange(of: musicManager.bundleIdentifier) { _, newBundle in
@@ -936,12 +956,8 @@ private struct MediaOutputPickerButton: View {
}
}
- private var buttonIcon: String {
- routeManager.activeDevice?.iconName ?? "speaker.wave.2"
- }
-
private func updatePopoverActivity() {
- vm.isMediaOutputPopoverActive = (isPopoverPresented || isAirPlayPopoverPresented) && isHoveringPopover
+ vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
}
}
diff --git a/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift b/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift
index 663f42a..3457604 100644
--- a/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift
+++ b/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift
@@ -34,6 +34,10 @@ struct MusicSlotConfigurationView: View {
private let slotCount = MusicControlButton.slotCount
+ private var isAppleMusicActive: Bool {
+ musicManager.bundleIdentifier == "com.apple.Music"
+ }
+
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
@@ -195,15 +199,23 @@ private struct ScrollHintIndicator: View {
}
}
+ private func isControlDisabled(_ control: MusicControlButton) -> Bool {
+ if control == .mediaOutput && !showMediaOutputControl { return true }
+ if control.isAppleMusicExclusive && !isAppleMusicActive { return true }
+ return false
+ }
+
private func paletteItem(for control: MusicControlButton) -> some View {
- VStack(spacing: 6) {
+ let disabled = isControlDisabled(control)
+
+ return VStack(spacing: 6) {
RoundedRectangle(cornerRadius: 8)
.fill(Color(nsColor: .controlBackgroundColor))
.frame(width: 44, height: 44)
.overlay {
Image(systemName: control.iconName)
.font(.system(size: control.prefersLargeScale ? 18 : 15, weight: .medium))
- .foregroundStyle(control == .mediaOutput && !showMediaOutputControl ? .secondary : .primary)
+ .foregroundStyle(disabled ? .secondary : .primary)
}
.onDrag {
NSItemProvider(object: NSString(string: "control:\(control.rawValue)"))
@@ -220,8 +232,8 @@ private struct ScrollHintIndicator: View {
.minimumScaleFactor(0.8)
.multilineTextAlignment(.center)
}
- .opacity(control == .mediaOutput && !showMediaOutputControl ? 0.4 : 1)
- .disabled(control == .mediaOutput && !showMediaOutputControl)
+ .opacity(disabled ? 0.4 : 1)
+ .disabled(disabled)
}
private func place(_ control: MusicControlButton) {
@@ -233,17 +245,20 @@ private struct ScrollHintIndicator: View {
}
private func slotValue(at index: Int) -> MusicControlButton {
- let normalized = musicControlSlots.normalized(allowingMediaOutput: showMediaOutputControl)
+ let normalized = musicControlSlots.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive)
guard normalized.indices.contains(index) else { return .none }
return normalized[index]
}
private var pickerOptions: [MusicControlButton] {
- let base = MusicControlButton.pickerOptions
- if showMediaOutputControl {
- return base
+ var base = MusicControlButton.pickerOptions
+ if !showMediaOutputControl {
+ base = base.filter { $0 != .mediaOutput }
}
- return base.filter { $0 != .mediaOutput }
+ if !isAppleMusicActive {
+ base = base.filter { !$0.isAppleMusicExclusive }
+ }
+ return base
}
private func previewIconColor(for slot: MusicControlButton) -> Color {
diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift
index ae7a5ec..471b6e1 100644
--- a/DynamicIsland/components/Settings/SettingsView.swift
+++ b/DynamicIsland/components/Settings/SettingsView.swift
@@ -2373,6 +2373,11 @@ struct Media: View {
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled
@Default(.showStandardMediaControls) private var showStandardMediaControls
@Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer
+ @ObservedObject private var musicManager = MusicManager.shared
+
+ private var isAppleMusicActive: Bool {
+ musicManager.bundleIdentifier == "com.apple.Music"
+ }
private func highlightID(_ title: String) -> String {
SettingsTab.media.highlightID(for: title)
@@ -2532,6 +2537,11 @@ struct Media: View {
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon)
.disabled(!enableLockScreenMediaWidget)
+ if isAppleMusicActive {
+ Defaults.Toggle("Show merged AirPlay and output devices", key: .lockScreenMusicMergedAirPlayOutput)
+ .disabled(!enableLockScreenMediaWidget)
+ .settingsHighlight(id: highlightID("Show merged AirPlay and output devices"))
+ }
Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder)
.disabled(!enableLockScreenMediaWidget)
if lockScreenGlassCustomizationMode == .customLiquid {
@@ -4282,6 +4292,12 @@ struct LockScreenSettings: View {
@Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode
@Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs
@Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled
+ @Default(.lockScreenMusicMergedAirPlayOutput) private var lockScreenMusicMergedAirPlayOutput
+ @ObservedObject private var musicManager = MusicManager.shared
+
+ private var isAppleMusicActive: Bool {
+ musicManager.bundleIdentifier == "com.apple.Music"
+ }
private func highlightID(_ title: String) -> String {
SettingsTab.lockScreen.highlightID(for: title)
@@ -4437,6 +4453,11 @@ struct LockScreenSettings: View {
Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon)
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Show media app icon"))
+ if isAppleMusicActive {
+ Defaults.Toggle("Show merged AirPlay and output devices", key: .lockScreenMusicMergedAirPlayOutput)
+ .disabled(!enableLockScreenMediaWidget)
+ .settingsHighlight(id: highlightID("Show merged AirPlay and output devices"))
+ }
Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder)
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Show panel border"))
diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift
index 8ae20b6..628253f 100644
--- a/DynamicIsland/models/Constants.swift
+++ b/DynamicIsland/models/Constants.swift
@@ -834,6 +834,7 @@ extension Defaults.Keys {
static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: false)
static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true)
+ static let lockScreenMusicMergedAirPlayOutput = Key("lockScreenMusicMergedAirPlayOutput", default: true)
static let lockScreenTimerWidgetUsesBlur = Key("lockScreenTimerWidgetUsesBlur", default: false)
static let lockScreenReminderChipStyle = Key("lockScreenReminderChipStyle", default: .eventColor)
static let lockScreenReminderWidgetHorizontalAlignment = Key("lockScreenReminderWidgetHorizontalAlignment", default: "center")
diff --git a/DynamicIsland/models/MusicControlButton.swift b/DynamicIsland/models/MusicControlButton.swift
index c4023a9..3d2bd23 100644
--- a/DynamicIsland/models/MusicControlButton.swift
+++ b/DynamicIsland/models/MusicControlButton.swift
@@ -29,6 +29,7 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
case trackForward
case repeatMode
case mediaOutput
+ case airPlay
case lyrics
case seekBackward
case seekForward
@@ -61,9 +62,15 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
.shuffle,
.repeatMode,
.lyrics,
- .mediaOutput
+ .mediaOutput,
+ .airPlay
]
+ /// Controls that are only available when Apple Music is the active media source.
+ var isAppleMusicExclusive: Bool {
+ self == .airPlay
+ }
+
var id: String { rawValue }
var label: String {
@@ -80,6 +87,8 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
return "Repeat"
case .mediaOutput:
return "Change Media Output"
+ case .airPlay:
+ return "AirPlay"
case .lyrics:
return "Lyrics"
case .seekBackward:
@@ -105,6 +114,8 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
return "repeat"
case .mediaOutput:
return "speaker.wave.2"
+ case .airPlay:
+ return "airplayaudio"
case .lyrics:
return "quote.bubble"
case .seekBackward:
@@ -122,11 +133,10 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
}
extension Array where Element == MusicControlButton {
- func normalized(allowingMediaOutput: Bool) -> [MusicControlButton] {
+ func normalized(allowingMediaOutput: Bool, isAppleMusicActive: Bool = true) -> [MusicControlButton] {
var sanitized = map { button -> MusicControlButton in
- if button == .mediaOutput && !allowingMediaOutput {
- return .none
- }
+ if button == .mediaOutput && !allowingMediaOutput { return .none }
+ if button.isAppleMusicExclusive && !isAppleMusicActive { return .none }
return button
}
From 699b9402dc669495d2bcf8bba092884ce696bcce Mon Sep 17 00:00:00 2001
From: Hariharan Mudaliar
Date: Sat, 28 Mar 2026 12:00:31 +0530
Subject: [PATCH 4/5] Update localizable
---
DynamicIsland/Localizable.xcstrings | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings
index 857d9af..9389307 100644
--- a/DynamicIsland/Localizable.xcstrings
+++ b/DynamicIsland/Localizable.xcstrings
@@ -3714,6 +3714,10 @@
}
}
},
+ "AirPlay" : {
+ "comment" : "A label for the AirPlay button.",
+ "isCommentAutoGenerated" : true
+ },
"Alignment" : {
"localizations" : {
"ko" : {
@@ -20355,6 +20359,10 @@
}
}
},
+ "No AirPlay devices found" : {
+ "comment" : "A message displayed when no AirPlay devices are found.",
+ "isCommentAutoGenerated" : true
+ },
"No audio outputs available" : {
"comment" : "Displayed when no audio output devices can be listed in the media output selector.",
"isCommentAutoGenerated" : true,
From 061564eb8be66660df5d16f5b4ab6bfd6b760fd7 Mon Sep 17 00:00:00 2001
From: Hariharan Mudaliar
Date: Sat, 28 Mar 2026 12:31:15 +0530
Subject: [PATCH 5/5] Update LockScreenMusicPanel for smooth vertical expansion
---
DynamicIsland/Localizable.xcstrings | 4 +
.../LockScreen/LockScreenMusicPanel.swift | 249 +++++++++++-------
.../managers/LockScreenPanelManager.swift | 7 +-
3 files changed, 156 insertions(+), 104 deletions(-)
diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings
index 9389307..a6ab283 100644
--- a/DynamicIsland/Localizable.xcstrings
+++ b/DynamicIsland/Localizable.xcstrings
@@ -20363,6 +20363,10 @@
"comment" : "A message displayed when no AirPlay devices are found.",
"isCommentAutoGenerated" : true
},
+ "No AirPlay outputs available" : {
+ "comment" : "A message displayed when there are no available AirPlay outputs.",
+ "isCommentAutoGenerated" : true
+ },
"No audio outputs available" : {
"comment" : "Displayed when no audio output devices can be listed in the media output selector.",
"isCommentAutoGenerated" : true,
diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
index 198a45f..4dc6eae 100644
--- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
+++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
@@ -45,9 +45,8 @@ struct LockScreenMusicPanel: View {
@State private var isActive = true
@State private var isExpanded = false
@State private var isVolumeSliderVisible = false
- @State private var isMediaOutputPopoverPresented = false
@State private var isAirPlayPopoverPresented = false
- @State private var measuredAirPlayHeight: CGFloat?
+
@State private var collapseWorkItem: DispatchWorkItem?
@State private var parallaxResumeWorkItem: DispatchWorkItem?
@State private var isParallaxSuspended = false
@@ -139,8 +138,8 @@ struct LockScreenMusicPanel: View {
}
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
.contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
- .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)
+ .animation(.easeInOut(duration: 0.28), value: isExpanded)
+ .animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
.onAppear {
sliderValue = musicManager.elapsedTime
isActive = true
@@ -175,8 +174,6 @@ struct LockScreenMusicPanel: View {
if useMergedAirPlayOutput {
if visible && isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
- } else {
- measuredAirPlayHeight = nil
}
}
updatePanelSize()
@@ -186,6 +183,11 @@ struct LockScreenMusicPanel: View {
updatePanelSize()
}
}
+ .onChange(of: routeManager.devices) { _, _ in
+ if !useMergedAirPlayOutput {
+ updatePanelSize()
+ }
+ }
.onChange(of: enableLyrics) { _, _ in
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
updatePanelSize()
@@ -334,7 +336,7 @@ struct LockScreenMusicPanel: View {
private func toggleExpanded() {
let newState = !isExpanded
suspendParallaxInteraction()
- withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) {
+ withAnimation(.easeInOut(duration: 0.28)) {
isExpanded = newState
}
@@ -353,7 +355,7 @@ struct LockScreenMusicPanel: View {
let workItem = DispatchWorkItem {
suspendParallaxInteraction()
- withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) {
+ withAnimation(.easeInOut(duration: 0.28)) {
isExpanded = false
}
logPanelAppearance(event: "⏱️ Auto-collapsed")
@@ -461,6 +463,10 @@ struct LockScreenMusicPanel: View {
airPlaySection
.frame(maxWidth: .infinity, alignment: alignment)
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
+ } else if shouldShowRouteSelector {
+ mediaOutputDevicesSection
+ .frame(maxWidth: .infinity, alignment: alignment)
+ .transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
}
}
@@ -471,9 +477,10 @@ struct LockScreenMusicPanel: View {
}
.frame(maxWidth: .infinity, alignment: alignment)
.padding(.top, isExpanded ? 6 : 2)
- .animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
- .animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowAirPlay)
- .animation(.spring(response: 0.45, dampingFraction: 0.82), value: enableLyrics)
+ .animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
+ .animation(.easeInOut(duration: 0.24), value: shouldShowAirPlay)
+ .animation(.easeInOut(duration: 0.24), value: shouldShowRouteSelector)
+ .animation(.easeInOut(duration: 0.24), value: enableLyrics)
}
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
@@ -570,11 +577,7 @@ struct LockScreenMusicPanel: View {
musicManager.toggleRepeat()
}
case .mediaOutput:
- if useMergedAirPlayOutput {
- mediaOutputControlButton
- } else {
- standaloneMediaOutputButton
- }
+ mediaOutputControlButton
case .airPlay:
if useMergedAirPlayOutput {
mediaOutputControlButton
@@ -658,36 +661,6 @@ struct LockScreenMusicPanel: View {
.accessibilityLabel("Media output")
}
- private var standaloneMediaOutputButton: some View {
- let frameSize: CGFloat = isExpanded ? 56 : 32
- let iconSize: CGFloat = isExpanded ? 26 : 18
-
- return PanelControlButton(
- icon: mediaOutputIcon,
- frameSize: frameSize,
- iconSize: iconSize,
- iconColor: isMediaOutputPopoverPresented ? .accentColor : .white.opacity(0.8),
- backgroundOpacity: isMediaOutputPopoverPresented ? 0.22 : 0.0,
- interaction: .none,
- symbolEffect: .replace
- ) {
- registerInteraction()
- isMediaOutputPopoverPresented.toggle()
- if isMediaOutputPopoverPresented {
- routeManager.refreshDevices()
- }
- }
- .accessibilityLabel("Media output")
- .popover(isPresented: $isMediaOutputPopoverPresented, arrowEdge: .bottom) {
- MediaOutputSelectorPopover(
- routeManager: routeManager,
- volumeModel: volumeModel,
- onHoverChanged: { _ in },
- dismiss: { isMediaOutputPopoverPresented = false }
- )
- }
- }
-
private var standaloneAirPlayButton: some View {
let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? 26 : 18
@@ -792,58 +765,113 @@ struct LockScreenMusicPanel: View {
}
private var airPlaySection: some View {
- VStack(spacing: 6) {
- ForEach(airPlayManager.devices) { device in
- VStack(spacing: 4) {
- Button {
- registerInteraction()
- Task { await airPlayManager.toggleDevice(device) }
- } label: {
- HStack(spacing: 8) {
- Image(systemName: device.iconName)
- .font(.system(size: isExpanded ? 14 : 12, weight: .medium))
- .foregroundColor(.white.opacity(0.8))
- Text(device.name)
- .font(.system(size: isExpanded ? 13 : 11, weight: .medium))
- .foregroundColor(.white)
- .lineLimit(1)
- Spacer()
- if device.isSelected {
- Image(systemName: "checkmark")
- .font(.system(size: isExpanded ? 12 : 10, weight: .bold))
- .foregroundColor(.accentColor)
+ accessorySectionContainer {
+ if airPlayManager.devices.isEmpty {
+ Text("No AirPlay outputs available")
+ .font(.system(size: isExpanded ? 13 : 11, weight: .medium))
+ .foregroundColor(.white.opacity(0.68))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 8)
+ .padding(.horizontal, 12)
+ } else {
+ ScrollView(.vertical, showsIndicators: true) {
+ VStack(spacing: 6) {
+ ForEach(airPlayManager.devices) { device in
+ VStack(spacing: 4) {
+ Button {
+ registerInteraction()
+ Task { await airPlayManager.toggleDevice(device) }
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: device.iconName)
+ .font(.system(size: isExpanded ? 14 : 12, weight: .medium))
+ .foregroundColor(.white.opacity(0.8))
+ Text(device.name)
+ .font(.system(size: isExpanded ? 13 : 11, weight: .medium))
+ .foregroundColor(.white)
+ .lineLimit(1)
+ Spacer()
+ if device.isSelected {
+ Image(systemName: "checkmark")
+ .font(.system(size: isExpanded ? 12 : 10, weight: .bold))
+ .foregroundColor(.accentColor)
+ }
+ }
+ .padding(.vertical, 4)
+ .padding(.horizontal, 12)
+ }
+ .buttonStyle(.plain)
+
+ if device.isSelected {
+ AirPlayVolumeSlider(
+ airPlayManager: airPlayManager,
+ deviceID: device.id
+ )
+ .padding(.horizontal, 12)
+ }
}
}
- .padding(.vertical, 4)
- .padding(.horizontal, 12)
- }
- .buttonStyle(.plain)
-
- if device.isSelected {
- AirPlayVolumeSlider(
- airPlayManager: airPlayManager,
- deviceID: device.id
- )
- .padding(.horizontal, 12)
}
+ .frame(maxWidth: .infinity, alignment: .leading)
}
+ .frame(maxHeight: accessorySectionScrollMaxHeight)
}
}
.padding(.vertical, 8)
- .background(
- RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
- .fill(sliderBackgroundFill)
- )
- .background(
- GeometryReader { geo in
- Color.clear
- .onAppear { measuredAirPlayHeight = geo.size.height }
- .onChange(of: geo.size.height) { _, newHeight in
- measuredAirPlayHeight = newHeight
- updatePanelSize()
+ }
+
+ private var mediaOutputDevicesSection: some View {
+ accessorySectionContainer {
+ if routeManager.devices.isEmpty {
+ Text("No audio outputs available")
+ .font(.system(size: isExpanded ? 13 : 11, weight: .medium))
+ .foregroundColor(.white.opacity(0.68))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 8)
+ .padding(.horizontal, 12)
+ } else {
+ ScrollView(.vertical, showsIndicators: true) {
+ VStack(alignment: .leading, spacing: 6) {
+ ForEach(routeManager.devices) { device in
+ Button {
+ registerInteraction()
+ routeManager.select(device: device)
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: device.iconName)
+ .font(.system(size: isExpanded ? 14 : 12, weight: .medium))
+ .foregroundColor(.white.opacity(0.82))
+ Text(device.name)
+ .font(.system(size: isExpanded ? 13 : 11, weight: .medium))
+ .foregroundColor(.white)
+ .lineLimit(1)
+ Spacer()
+ if device.id == routeManager.activeDeviceID {
+ Image(systemName: "checkmark")
+ .font(.system(size: isExpanded ? 12 : 10, weight: .bold))
+ .foregroundColor(.accentColor)
+ }
+ }
+ .padding(.vertical, 5)
+ .padding(.horizontal, 12)
+ }
+ .buttonStyle(.plain)
+ }
}
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(maxHeight: accessorySectionScrollMaxHeight)
}
- )
+ }
+ .padding(.vertical, 8)
+ }
+
+ private func accessorySectionContainer(@ViewBuilder content: () -> Content) -> some View {
+ content()
+ .background(
+ RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
+ .fill(sliderBackgroundFill)
+ )
}
private var sliderBackgroundFill: Color {
@@ -865,6 +893,14 @@ struct LockScreenMusicPanel: View {
useMergedAirPlayOutput && shouldShowVolumeSlider && !airPlayManager.devices.isEmpty
}
+ private var shouldShowRouteSelector: Bool {
+ !useMergedAirPlayOutput && shouldShowVolumeSlider
+ }
+
+ private var shouldShowAccessorySection: Bool {
+ shouldShowAirPlay || shouldShowRouteSelector
+ }
+
private var shouldShowVolumeSlider: Bool {
showMediaOutputControl && isVolumeSliderVisible
}
@@ -873,25 +909,31 @@ struct LockScreenMusicPanel: View {
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
}
+ private var accessorySectionScrollMaxHeight: CGFloat {
+ isExpanded ? 170 : 130
+ }
+
private var lyricsExtraHeight: CGFloat {
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics)
}
- private var airPlayExtraHeight: CGFloat {
- guard shouldShowAirPlay else { return 0 }
- if let measured = measuredAirPlayHeight {
- return measured
+ private var accessorySectionExtraHeight: CGFloat {
+ guard shouldShowAccessorySection else { return 0 }
+ if shouldShowAirPlay {
+ let selectedCount = airPlayManager.devices.filter(\.isSelected).count
+ let totalCount = airPlayManager.devices.count
+ let deviceRows: CGFloat = CGFloat(totalCount) * 30 + CGFloat(max(totalCount - 1, 0)) * 6
+ let sliders: CGFloat = CGFloat(selectedCount) * 34
+ return min(deviceRows + sliders + 24, accessorySectionScrollMaxHeight + 16)
}
- // Fallback estimate used only until the first GeometryReader measurement arrives
- let selectedCount = airPlayManager.devices.filter(\.isSelected).count
- let totalCount = airPlayManager.devices.count
- let deviceRows: CGFloat = CGFloat(totalCount) * 22 + CGFloat(max(totalCount - 1, 0)) * 6
- let sliders: CGFloat = CGFloat(selectedCount) * 24
- return deviceRows + sliders + 16
+
+ let totalCount = max(routeManager.devices.count, 1)
+ let deviceRows: CGFloat = CGFloat(totalCount) * 30 + CGFloat(max(totalCount - 1, 0)) * 6
+ return min(deviceRows + 24, accessorySectionScrollMaxHeight + 16)
}
private var totalExtraHeight: CGFloat {
- sliderExtraHeight + airPlayExtraHeight + lyricsExtraHeight
+ sliderExtraHeight + accessorySectionExtraHeight + lyricsExtraHeight
}
private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat {
@@ -901,7 +943,7 @@ struct LockScreenMusicPanel: View {
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
- airPlayExtraHeight +
+ accessorySectionExtraHeight +
lyricsHeight(forExpanded: expanded, enabled: enableLyrics)
}
@@ -938,9 +980,12 @@ struct LockScreenMusicPanel: View {
let newState = !isVolumeSliderVisible
if newState {
routeManager.refreshDevices()
+ if useMergedAirPlayOutput {
+ Task { await airPlayManager.refreshDevices() }
+ }
}
- withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
+ withAnimation(.easeInOut(duration: 0.24)) {
isVolumeSliderVisible = newState
}
diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift
index 5c5dfa7..c259971 100644
--- a/DynamicIsland/managers/LockScreenPanelManager.swift
+++ b/DynamicIsland/managers/LockScreenPanelManager.swift
@@ -166,6 +166,8 @@ class LockScreenPanelManager {
return
}
+ let resizeDuration: CFTimeInterval = 0.28
+
let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
let targetWidth = baseSize.width
let targetHeight = baseSize.height + additionalHeight
@@ -175,7 +177,7 @@ class LockScreenPanelManager {
if animated {
NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.45
+ context.duration = resizeDuration
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrame(targetFrame, display: true)
}
@@ -191,7 +193,8 @@ class LockScreenPanelManager {
let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
if animated {
CATransaction.begin()
- CATransaction.setAnimationDuration(0.28)
+ CATransaction.setAnimationDuration(resizeDuration)
+ CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut))
window.contentView?.layer?.cornerRadius = targetRadius
CATransaction.commit()
} else {