mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
feat(media): decouple airplay and output, merged output for lock screen
This commit is contained in:
parent
5e1e8f613b
commit
9d17e4aafb
7 changed files with 296 additions and 84 deletions
|
|
@ -45,6 +45,9 @@ struct LockScreenMusicPanel: View {
|
||||||
@State private var isActive = true
|
@State private var isActive = true
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
@State private var isVolumeSliderVisible = 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 collapseWorkItem: DispatchWorkItem?
|
||||||
@State private var parallaxResumeWorkItem: DispatchWorkItem?
|
@State private var parallaxResumeWorkItem: DispatchWorkItem?
|
||||||
@State private var isParallaxSuspended = false
|
@State private var isParallaxSuspended = false
|
||||||
|
|
@ -59,6 +62,7 @@ struct LockScreenMusicPanel: View {
|
||||||
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
||||||
@Default(.musicControlSlots) private var slotConfig
|
@Default(.musicControlSlots) private var slotConfig
|
||||||
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
||||||
|
@Default(.lockScreenMusicMergedAirPlayOutput) private var mergedAirPlayOutput
|
||||||
@Default(.enableLyrics) private var enableLyrics
|
@Default(.enableLyrics) private var enableLyrics
|
||||||
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
|
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
|
||||||
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
|
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
|
||||||
|
|
@ -168,16 +172,20 @@ struct LockScreenMusicPanel: View {
|
||||||
updatePanelSize()
|
updatePanelSize()
|
||||||
}
|
}
|
||||||
.onChange(of: isVolumeSliderVisible) { _, visible in
|
.onChange(of: isVolumeSliderVisible) { _, visible in
|
||||||
|
if useMergedAirPlayOutput {
|
||||||
if visible && isAppleMusicActive {
|
if visible && isAppleMusicActive {
|
||||||
Task { await airPlayManager.refreshDevices() }
|
Task { await airPlayManager.refreshDevices() }
|
||||||
} else {
|
} else {
|
||||||
measuredAirPlayHeight = nil
|
measuredAirPlayHeight = nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
updatePanelSize()
|
updatePanelSize()
|
||||||
}
|
}
|
||||||
.onChange(of: airPlayManager.devices) { _, _ in
|
.onChange(of: airPlayManager.devices) { _, _ in
|
||||||
|
if useMergedAirPlayOutput {
|
||||||
updatePanelSize()
|
updatePanelSize()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.onChange(of: enableLyrics) { _, _ in
|
.onChange(of: enableLyrics) { _, _ in
|
||||||
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
||||||
updatePanelSize()
|
updatePanelSize()
|
||||||
|
|
@ -484,7 +492,7 @@ struct LockScreenMusicPanel: View {
|
||||||
return fallbackSlots
|
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
|
return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -562,7 +570,17 @@ struct LockScreenMusicPanel: View {
|
||||||
musicManager.toggleRepeat()
|
musicManager.toggleRepeat()
|
||||||
}
|
}
|
||||||
case .mediaOutput:
|
case .mediaOutput:
|
||||||
|
if useMergedAirPlayOutput {
|
||||||
mediaOutputControlButton
|
mediaOutputControlButton
|
||||||
|
} else {
|
||||||
|
standaloneMediaOutputButton
|
||||||
|
}
|
||||||
|
case .airPlay:
|
||||||
|
if useMergedAirPlayOutput {
|
||||||
|
mediaOutputControlButton
|
||||||
|
} else {
|
||||||
|
standaloneAirPlayButton
|
||||||
|
}
|
||||||
case .lyrics:
|
case .lyrics:
|
||||||
controlButton(
|
controlButton(
|
||||||
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
|
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
|
||||||
|
|
@ -640,6 +658,65 @@ struct LockScreenMusicPanel: View {
|
||||||
.accessibilityLabel("Media output")
|
.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 {
|
private func lyricsSection(alignment: Alignment) -> some View {
|
||||||
let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
|
let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let transition: AnyTransition = .asymmetric(
|
let transition: AnyTransition = .asymmetric(
|
||||||
|
|
@ -780,8 +857,12 @@ struct LockScreenMusicPanel: View {
|
||||||
musicManager.bundleIdentifier == "com.apple.Music"
|
musicManager.bundleIdentifier == "com.apple.Music"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var useMergedAirPlayOutput: Bool {
|
||||||
|
mergedAirPlayOutput && isAppleMusicActive && slotConfig.contains(where: { $0 == .mediaOutput || $0 == .airPlay })
|
||||||
|
}
|
||||||
|
|
||||||
private var shouldShowAirPlay: Bool {
|
private var shouldShowAirPlay: Bool {
|
||||||
shouldShowVolumeSlider && isAppleMusicActive && !airPlayManager.devices.isEmpty
|
useMergedAirPlayOutput && shouldShowVolumeSlider && !airPlayManager.devices.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
private var shouldShowVolumeSlider: Bool {
|
private var shouldShowVolumeSlider: Bool {
|
||||||
|
|
@ -796,8 +877,6 @@ struct LockScreenMusicPanel: View {
|
||||||
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics)
|
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
@State private var measuredAirPlayHeight: CGFloat?
|
|
||||||
|
|
||||||
private var airPlayExtraHeight: CGFloat {
|
private var airPlayExtraHeight: CGFloat {
|
||||||
guard shouldShowAirPlay else { return 0 }
|
guard shouldShowAirPlay else { return 0 }
|
||||||
if let measured = measuredAirPlayHeight {
|
if let measured = measuredAirPlayHeight {
|
||||||
|
|
|
||||||
|
|
@ -876,9 +876,13 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isAppleMusicActive: Bool {
|
||||||
|
musicManager.bundleIdentifier == "com.apple.Music"
|
||||||
|
}
|
||||||
|
|
||||||
private var displayedSlots: [MusicControlButton] {
|
private var displayedSlots: [MusicControlButton] {
|
||||||
if showCustomControls {
|
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
|
return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -943,6 +947,8 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
}
|
}
|
||||||
case .mediaOutput:
|
case .mediaOutput:
|
||||||
MinimalisticMediaOutputButton()
|
MinimalisticMediaOutputButton()
|
||||||
|
case .airPlay:
|
||||||
|
MinimalisticAirPlayButton()
|
||||||
case .lyrics:
|
case .lyrics:
|
||||||
controlButton(
|
controlButton(
|
||||||
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
|
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 {
|
private var repeatIcon: String {
|
||||||
switch musicManager.repeatMode {
|
switch musicManager.repeatMode {
|
||||||
case .off: return "repeat"
|
case .off: return "repeat"
|
||||||
|
|
|
||||||
|
|
@ -398,9 +398,13 @@ struct MusicControlsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isAppleMusicActive: Bool {
|
||||||
|
musicManager.bundleIdentifier == "com.apple.Music"
|
||||||
|
}
|
||||||
|
|
||||||
private var displayedSlots: [MusicControlButton] {
|
private var displayedSlots: [MusicControlButton] {
|
||||||
if showCustomControls {
|
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
|
return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,6 +478,8 @@ struct MusicControlsView: View {
|
||||||
}
|
}
|
||||||
case .mediaOutput:
|
case .mediaOutput:
|
||||||
MediaOutputPickerButton()
|
MediaOutputPickerButton()
|
||||||
|
case .airPlay:
|
||||||
|
AirPlayPickerButton()
|
||||||
case .lyrics:
|
case .lyrics:
|
||||||
HoverButton(
|
HoverButton(
|
||||||
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
|
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
|
||||||
|
|
@ -849,20 +855,12 @@ struct CustomSlider: View {
|
||||||
|
|
||||||
private struct MediaOutputPickerButton: View {
|
private struct MediaOutputPickerButton: View {
|
||||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||||
@ObservedObject private var musicManager = MusicManager.shared
|
|
||||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||||
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
|
|
||||||
@State private var isPopoverPresented = false
|
@State private var isPopoverPresented = false
|
||||||
@State private var isAirPlayPopoverPresented = false
|
|
||||||
@State private var isHoveringPopover = false
|
@State private var isHoveringPopover = false
|
||||||
@EnvironmentObject private var vm: DynamicIslandViewModel
|
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||||||
|
|
||||||
private var isAppleMusicActive: Bool {
|
|
||||||
musicManager.bundleIdentifier == "com.apple.Music"
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 4) {
|
|
||||||
HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
|
HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
|
||||||
isPopoverPresented.toggle()
|
isPopoverPresented.toggle()
|
||||||
if isPopoverPresented {
|
if isPopoverPresented {
|
||||||
|
|
@ -884,35 +882,8 @@ private struct MediaOutputPickerButton: View {
|
||||||
updatePopoverActivity()
|
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 {
|
.onAppear {
|
||||||
routeManager.refreshDevices()
|
routeManager.refreshDevices()
|
||||||
if isAppleMusicActive {
|
|
||||||
Task { await airPlayManager.refreshDevices() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: isPopoverPresented) { _, presented in
|
.onChange(of: isPopoverPresented) { _, presented in
|
||||||
if !presented {
|
if !presented {
|
||||||
|
|
@ -920,10 +891,59 @@ private struct MediaOutputPickerButton: View {
|
||||||
}
|
}
|
||||||
updatePopoverActivity()
|
updatePopoverActivity()
|
||||||
}
|
}
|
||||||
.onChange(of: isAirPlayPopoverPresented) { _, presented in
|
.onDisappear {
|
||||||
if !presented {
|
vm.isMediaOutputPopoverActive = false
|
||||||
isHoveringPopover = 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 isHoveringPopover = false
|
||||||
|
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||||||
|
|
||||||
|
private var isAppleMusicActive: Bool {
|
||||||
|
musicManager.bundleIdentifier == "com.apple.Music"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HoverButton(icon: "airplayaudio", iconColor: .white, scale: .medium) {
|
||||||
|
isPopoverPresented.toggle()
|
||||||
|
if isPopoverPresented {
|
||||||
|
Task { await airPlayManager.refreshDevices() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("AirPlay")
|
||||||
|
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
||||||
|
AirPlaySelectorPopover(
|
||||||
|
airPlayManager: airPlayManager,
|
||||||
|
onHoverChanged: { hovering in
|
||||||
|
isHoveringPopover = hovering
|
||||||
|
updatePopoverActivity()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
isPopoverPresented = false
|
||||||
|
isHoveringPopover = false
|
||||||
|
updatePopoverActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if isAppleMusicActive {
|
||||||
|
Task { await airPlayManager.refreshDevices() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: isPopoverPresented) { _, presented in
|
||||||
|
if !presented { isHoveringPopover = false }
|
||||||
updatePopoverActivity()
|
updatePopoverActivity()
|
||||||
}
|
}
|
||||||
.onChange(of: musicManager.bundleIdentifier) { _, newBundle in
|
.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() {
|
private func updatePopoverActivity() {
|
||||||
vm.isMediaOutputPopoverActive = (isPopoverPresented || isAirPlayPopoverPresented) && isHoveringPopover
|
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ struct MusicSlotConfigurationView: View {
|
||||||
|
|
||||||
private let slotCount = MusicControlButton.slotCount
|
private let slotCount = MusicControlButton.slotCount
|
||||||
|
|
||||||
|
private var isAppleMusicActive: Bool {
|
||||||
|
musicManager.bundleIdentifier == "com.apple.Music"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
header
|
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 {
|
private func paletteItem(for control: MusicControlButton) -> some View {
|
||||||
VStack(spacing: 6) {
|
let disabled = isControlDisabled(control)
|
||||||
|
|
||||||
|
return VStack(spacing: 6) {
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: 8)
|
||||||
.fill(Color(nsColor: .controlBackgroundColor))
|
.fill(Color(nsColor: .controlBackgroundColor))
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
.overlay {
|
.overlay {
|
||||||
Image(systemName: control.iconName)
|
Image(systemName: control.iconName)
|
||||||
.font(.system(size: control.prefersLargeScale ? 18 : 15, weight: .medium))
|
.font(.system(size: control.prefersLargeScale ? 18 : 15, weight: .medium))
|
||||||
.foregroundStyle(control == .mediaOutput && !showMediaOutputControl ? .secondary : .primary)
|
.foregroundStyle(disabled ? .secondary : .primary)
|
||||||
}
|
}
|
||||||
.onDrag {
|
.onDrag {
|
||||||
NSItemProvider(object: NSString(string: "control:\(control.rawValue)"))
|
NSItemProvider(object: NSString(string: "control:\(control.rawValue)"))
|
||||||
|
|
@ -220,8 +232,8 @@ private struct ScrollHintIndicator: View {
|
||||||
.minimumScaleFactor(0.8)
|
.minimumScaleFactor(0.8)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
.opacity(control == .mediaOutput && !showMediaOutputControl ? 0.4 : 1)
|
.opacity(disabled ? 0.4 : 1)
|
||||||
.disabled(control == .mediaOutput && !showMediaOutputControl)
|
.disabled(disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func place(_ control: MusicControlButton) {
|
private func place(_ control: MusicControlButton) {
|
||||||
|
|
@ -233,17 +245,20 @@ private struct ScrollHintIndicator: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func slotValue(at index: Int) -> MusicControlButton {
|
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 }
|
guard normalized.indices.contains(index) else { return .none }
|
||||||
return normalized[index]
|
return normalized[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
private var pickerOptions: [MusicControlButton] {
|
private var pickerOptions: [MusicControlButton] {
|
||||||
let base = MusicControlButton.pickerOptions
|
var base = MusicControlButton.pickerOptions
|
||||||
if showMediaOutputControl {
|
if !showMediaOutputControl {
|
||||||
return base
|
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 {
|
private func previewIconColor(for slot: MusicControlButton) -> Color {
|
||||||
|
|
|
||||||
|
|
@ -2373,6 +2373,11 @@ struct Media: View {
|
||||||
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled
|
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled
|
||||||
@Default(.showStandardMediaControls) private var showStandardMediaControls
|
@Default(.showStandardMediaControls) private var showStandardMediaControls
|
||||||
@Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer
|
@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 {
|
private func highlightID(_ title: String) -> String {
|
||||||
SettingsTab.media.highlightID(for: title)
|
SettingsTab.media.highlightID(for: title)
|
||||||
|
|
@ -2532,6 +2537,11 @@ struct Media: View {
|
||||||
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
|
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
|
||||||
Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon)
|
Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon)
|
||||||
.disabled(!enableLockScreenMediaWidget)
|
.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)
|
Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder)
|
||||||
.disabled(!enableLockScreenMediaWidget)
|
.disabled(!enableLockScreenMediaWidget)
|
||||||
if lockScreenGlassCustomizationMode == .customLiquid {
|
if lockScreenGlassCustomizationMode == .customLiquid {
|
||||||
|
|
@ -4282,6 +4292,12 @@ struct LockScreenSettings: View {
|
||||||
@Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode
|
@Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode
|
||||||
@Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs
|
@Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs
|
||||||
@Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled
|
@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 {
|
private func highlightID(_ title: String) -> String {
|
||||||
SettingsTab.lockScreen.highlightID(for: title)
|
SettingsTab.lockScreen.highlightID(for: title)
|
||||||
|
|
@ -4437,6 +4453,11 @@ struct LockScreenSettings: View {
|
||||||
Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon)
|
Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon)
|
||||||
.disabled(!enableLockScreenMediaWidget)
|
.disabled(!enableLockScreenMediaWidget)
|
||||||
.settingsHighlight(id: highlightID("Show media app icon"))
|
.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)
|
Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder)
|
||||||
.disabled(!enableLockScreenMediaWidget)
|
.disabled(!enableLockScreenMediaWidget)
|
||||||
.settingsHighlight(id: highlightID("Show panel border"))
|
.settingsHighlight(id: highlightID("Show panel border"))
|
||||||
|
|
|
||||||
|
|
@ -834,6 +834,7 @@ extension Defaults.Keys {
|
||||||
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
|
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
|
||||||
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: false)
|
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: false)
|
||||||
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
|
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
|
||||||
|
static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true)
|
||||||
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)
|
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)
|
||||||
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
|
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
|
||||||
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")
|
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
|
||||||
case trackForward
|
case trackForward
|
||||||
case repeatMode
|
case repeatMode
|
||||||
case mediaOutput
|
case mediaOutput
|
||||||
|
case airPlay
|
||||||
case lyrics
|
case lyrics
|
||||||
case seekBackward
|
case seekBackward
|
||||||
case seekForward
|
case seekForward
|
||||||
|
|
@ -61,9 +62,15 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
|
||||||
.shuffle,
|
.shuffle,
|
||||||
.repeatMode,
|
.repeatMode,
|
||||||
.lyrics,
|
.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 id: String { rawValue }
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
|
|
@ -80,6 +87,8 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
|
||||||
return "Repeat"
|
return "Repeat"
|
||||||
case .mediaOutput:
|
case .mediaOutput:
|
||||||
return "Change Media Output"
|
return "Change Media Output"
|
||||||
|
case .airPlay:
|
||||||
|
return "AirPlay"
|
||||||
case .lyrics:
|
case .lyrics:
|
||||||
return "Lyrics"
|
return "Lyrics"
|
||||||
case .seekBackward:
|
case .seekBackward:
|
||||||
|
|
@ -105,6 +114,8 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
|
||||||
return "repeat"
|
return "repeat"
|
||||||
case .mediaOutput:
|
case .mediaOutput:
|
||||||
return "speaker.wave.2"
|
return "speaker.wave.2"
|
||||||
|
case .airPlay:
|
||||||
|
return "airplayaudio"
|
||||||
case .lyrics:
|
case .lyrics:
|
||||||
return "quote.bubble"
|
return "quote.bubble"
|
||||||
case .seekBackward:
|
case .seekBackward:
|
||||||
|
|
@ -122,11 +133,10 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == MusicControlButton {
|
extension Array where Element == MusicControlButton {
|
||||||
func normalized(allowingMediaOutput: Bool) -> [MusicControlButton] {
|
func normalized(allowingMediaOutput: Bool, isAppleMusicActive: Bool = true) -> [MusicControlButton] {
|
||||||
var sanitized = map { button -> MusicControlButton in
|
var sanitized = map { button -> MusicControlButton in
|
||||||
if button == .mediaOutput && !allowingMediaOutput {
|
if button == .mediaOutput && !allowingMediaOutput { return .none }
|
||||||
return .none
|
if button.isAppleMusicExclusive && !isAppleMusicActive { return .none }
|
||||||
}
|
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue