Merge remote-tracking branch 'origin/main' into pr-360

This commit is contained in:
Hariharan Mudaliar 2026-03-28 14:31:28 +05:30
commit 62f96385d8
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
22 changed files with 7572 additions and 953 deletions

File diff suppressed because it is too large Load diff

View file

@ -20,9 +20,19 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Foundation
import AppKit
import Combine
import SwiftUI
import Foundation
private struct ITunesSearchResponse: Decodable {
let results: [ITunesTrack]
}
private struct ITunesTrack: Decodable {
let trackName: String?
let collectionName: String?
let artworkUrl100: String?
}
class AppleMusicController: MediaControllerProtocol {
// MARK: - Properties
@ -30,17 +40,23 @@ class AppleMusicController: MediaControllerProtocol {
bundleIdentifier: "com.apple.Music",
playbackRate: 1
)
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var isWorking: Bool {
return true // AppleMusic controller always works
}
/// Minimum byte count for artwork data to be considered valid. Anything
/// smaller is likely an empty descriptor or error string, not image data.
private static let minimumArtworkSize = 16
private var notificationTask: Task<Void, Never>?
private var lastCatalogArtworkKey: String?
private var cachedCatalogArtwork: Data?
// MARK: - Initialization
init() {
setupPlaybackStateChangeObserver()
@ -122,7 +138,7 @@ class AppleMusicController: MediaControllerProtocol {
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
guard descriptor.numberOfItems >= 8 else { return }
var updatedState = self.playbackState
updatedState.isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
updatedState.title = descriptor.atIndex(2)?.stringValue ?? "Unknown"
updatedState.artist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
@ -132,91 +148,108 @@ class AppleMusicController: MediaControllerProtocol {
updatedState.isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0
updatedState.repeatMode = RepeatMode(rawValue: Int(repeatModeValue)) ?? .off
updatedState.artwork = descriptor.atIndex(9)?.data as Data?
// AppleScript returns artwork data for library tracks. For streamed
// content not in the library it returns an empty descriptor, so we
// fall back to the iTunes Search API to fetch artwork by metadata.
if let artworkData = descriptor.atIndex(9)?.data as Data?,
artworkData.count > Self.minimumArtworkSize {
updatedState.artwork = artworkData
} else {
updatedState.artwork = await fetchArtworkFromCatalog(
title: updatedState.title, artist: updatedState.artist, album: updatedState.album
)
}
updatedState.lastUpdated = Date()
self.playbackState = updatedState
}
// MARK: - Private Methods
private func fetchArtworkFromCatalog(title: String, artist: String, album: String) async -> Data? {
let key = "\(title)|\(artist)|\(album)"
if key == lastCatalogArtworkKey, let cached = cachedCatalogArtwork {
return cached
}
// Search with title + artist only; including album in the query can
// confuse the free-text search when names overlap. We validate the
// album from the structured response fields instead.
let query = "\(title) \(artist)"
guard !query.trimmingCharacters(in: .whitespaces).isEmpty,
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=song&limit=10")
else { return nil }
do {
let (data, _) = try await URLSession.shared.data(from: url)
let response = try JSONDecoder().decode(ITunesSearchResponse.self, from: data)
guard !response.results.isEmpty else { return nil }
let normalizedAlbum = album.lowercased()
let normalizedTitle = title.lowercased()
let match = response.results.first(where: {
$0.trackName?.lowercased() == normalizedTitle &&
$0.collectionName?.lowercased() == normalizedAlbum
}) ?? response.results.first(where: {
$0.collectionName?.lowercased() == normalizedAlbum
}) ?? response.results.first(where: {
$0.trackName?.lowercased() == normalizedTitle
}) ?? response.results.first
guard let artworkURLString = match?.artworkUrl100 else { return nil }
let highResURL = artworkURLString.replacingOccurrences(of: "100x100", with: "600x600")
guard let imageURL = URL(string: highResURL) else { return nil }
let (imageData, _) = try await URLSession.shared.data(from: imageURL)
lastCatalogArtworkKey = key
cachedCatalogArtwork = imageData
return imageData
} catch {
return nil
}
}
private func executeCommand(_ command: String) async {
let script = "tell application \"Music\" to \(command)"
try? await AppleScriptHelper.executeVoid(script)
}
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
if #available(macOS 26.0, *) {
let script = """
tell application "Music"
let script = """
tell application "Music"
try
set playerState to player state is playing
set currentTrackName to name of current track
set currentTrackArtist to artist of current track
set currentTrackAlbum to album of current track
set trackPosition to player position
set trackDuration to duration of current track
set shuffleState to shuffle enabled
set repeatState to song repeat
if repeatState is off then
set repeatValue to 1
else if repeatState is one then
set repeatValue to 2
else if repeatState is all then
set repeatValue to 3
end if
set artData to ""
try
set playerState to player state is playing
set currentTrackName to name of current track
set currentTrackArtist to artist of current track
set currentTrackAlbum to album of current track
set trackPosition to player position
set trackDuration to duration of current track
set shuffleState to shuffle enabled
set repeatState to song repeat
if repeatState is off then
set repeatValue to 1
else if repeatState is one then
set repeatValue to 2
else if repeatState is all then
set repeatValue to 3
end if
set artData to ""
try
set libraryTrack to first track of playlist "Library" whose name is currentTrackName and artist is currentTrackArtist
set artData to data of artwork 1 of libraryTrack
on error
try
set artData to data of artwork 1 of current track
on error
set artData to ""
end try
end try
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData}
on error
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""}
set artData to raw data of artwork 1 of current track
end try
end tell
"""
return try await AppleScriptHelper.execute(script)
} else {
let script = """
tell application "Music"
set isRunning to true
try
set playerState to player state is playing
set currentTrackName to name of current track
set currentTrackArtist to artist of current track
set currentTrackAlbum to album of current track
set trackPosition to player position
set trackDuration to duration of current track
set shuffleState to shuffle enabled
set repeatState to song repeat
if repeatState is off then
set repeatValue to 1
else if repeatState is one then
set repeatValue to 2
else if repeatState is all then
set repeatValue to 3
end if
try
set artData to data of artwork 1 of current track
on error
set artData to ""
end try
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData}
on error
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""}
end try
end tell
"""
return try await AppleScriptHelper.execute(script)
}
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData}
on error
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""}
end try
end tell
"""
return try await AppleScriptHelper.execute(script)
}
}

View file

@ -146,6 +146,7 @@ class CalendarService: CalendarServiceProviding {
extension CalendarModel {
init(from calendar: EKCalendar) {
self.init(
accountName: calendar.accountTitle,
id: calendar.calendarIdentifier,
title: calendar.title,
color: calendar.color,
@ -267,7 +268,7 @@ extension Priority {
// MARK: - Helper Extensions
private extension EKCalendar {
extension EKCalendar {
var accountTitle: String {
switch source.sourceType {
case .local, .subscribed, .birthdays:

View file

@ -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
@ -44,6 +45,8 @@ struct LockScreenMusicPanel: View {
@State private var isActive = true
@State private var isExpanded = false
@State private var isVolumeSliderVisible = false
@State private var isAirPlayPopoverPresented = false
@State private var collapseWorkItem: DispatchWorkItem?
@State private var parallaxResumeWorkItem: DispatchWorkItem?
@State private var isParallaxSuspended = false
@ -58,6 +61,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
@ -134,14 +138,17 @@ 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
logPanelAppearance()
updatePanelSize(animated: false)
routeManager.refreshDevices()
if isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
}
logGlassState(reason: "Panel appeared")
}
.onDisappear {
@ -163,6 +170,24 @@ struct LockScreenMusicPanel: View {
}
updatePanelSize()
}
.onChange(of: isVolumeSliderVisible) { _, visible in
if useMergedAirPlayOutput {
if visible && isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
}
}
updatePanelSize()
}
.onChange(of: airPlayManager.devices) { _, _ in
if useMergedAirPlayOutput {
updatePanelSize()
}
}
.onChange(of: routeManager.devices) { _, _ in
if !useMergedAirPlayOutput {
updatePanelSize()
}
}
.onChange(of: enableLyrics) { _, _ in
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
updatePanelSize()
@ -311,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
}
@ -330,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")
@ -433,6 +458,16 @@ 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))
} else if shouldShowRouteSelector {
mediaOutputDevicesSection
.frame(maxWidth: .infinity, alignment: alignment)
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
}
}
if enableLyrics {
@ -442,8 +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: 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 {
@ -462,7 +499,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
}
@ -541,6 +578,12 @@ struct LockScreenMusicPanel: View {
}
case .mediaOutput:
mediaOutputControlButton
case .airPlay:
if useMergedAirPlayOutput {
mediaOutputControlButton
} else {
standaloneAirPlayButton
}
case .lyrics:
controlButton(
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
@ -618,6 +661,35 @@ struct LockScreenMusicPanel: View {
.accessibilityLabel("Media output")
}
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(
@ -692,6 +764,116 @@ struct LockScreenMusicPanel: View {
)
}
private var airPlaySection: some View {
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)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: accessorySectionScrollMaxHeight)
}
}
.padding(.vertical, 8)
}
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<Content: View>(@ViewBuilder content: () -> Content) -> some View {
content()
.background(
RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
.fill(sliderBackgroundFill)
)
}
private var sliderBackgroundFill: Color {
if usesLiquidGlass {
return Color.white.opacity(0.05)
@ -699,6 +881,26 @@ struct LockScreenMusicPanel: View {
return Color.white.opacity(0.08)
}
private var isAppleMusicActive: Bool {
musicManager.bundleIdentifier == "com.apple.Music"
}
private var useMergedAirPlayOutput: Bool {
mergedAirPlayOutput && isAppleMusicActive && slotConfig.contains(where: { $0 == .mediaOutput || $0 == .airPlay })
}
private var shouldShowAirPlay: Bool {
useMergedAirPlayOutput && shouldShowVolumeSlider && !airPlayManager.devices.isEmpty
}
private var shouldShowRouteSelector: Bool {
!useMergedAirPlayOutput && shouldShowVolumeSlider
}
private var shouldShowAccessorySection: Bool {
shouldShowAirPlay || shouldShowRouteSelector
}
private var shouldShowVolumeSlider: Bool {
showMediaOutputControl && isVolumeSliderVisible
}
@ -707,12 +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 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)
}
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 + lyricsExtraHeight
sliderExtraHeight + accessorySectionExtraHeight + lyricsExtraHeight
}
private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat {
@ -722,6 +943,7 @@ struct LockScreenMusicPanel: View {
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
accessorySectionExtraHeight +
lyricsHeight(forExpanded: expanded, enabled: enableLyrics)
}
@ -758,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
}

View file

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

View file

@ -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",
@ -899,6 +905,62 @@ private struct MediaOutputPickerButton: View {
}
}
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()
}
.onChange(of: musicManager.bundleIdentifier) { _, newBundle in
if newBundle == "com.apple.Music" {
Task { await airPlayManager.refreshDevices() }
}
}
.onDisappear {
vm.isMediaOutputPopoverActive = false
}
}
private func updatePopoverActivity() {
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
}
}
struct MediaOutputSelectorPopover: View {
@ObservedObject var routeManager: AudioRouteManager
@ObservedObject var volumeModel: MediaOutputVolumeViewModel
@ -1008,7 +1070,7 @@ struct MediaOutputSelectorPopover: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 180)
.frame(maxHeight: 240)
}
}
}
@ -1029,6 +1091,104 @@ 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.
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
sliderValue = Double(airPlayManager.currentVolume(for: deviceID))
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

View file

@ -615,11 +615,11 @@ private struct DurationInputRow: View {
var body: some View {
HStack(alignment: .center, spacing: 8) {
DurationField(label: "HH", value: $hours, range: 0...23, width: fieldWidth)
DurationField(label: String(localized: "HH"), value: $hours, range: 0...23, width: fieldWidth)
colon
DurationField(label: "MM", value: $minutes, range: 0...59, width: fieldWidth)
DurationField(label: String(localized: "MM"), value: $minutes, range: 0...59, width: fieldWidth)
colon
DurationField(label: "SS", value: $seconds, range: 0...59, width: fieldWidth)
DurationField(label: String(localized: "SS"), value: $seconds, range: 0...59, width: fieldWidth)
}
}

View file

@ -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 {

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,7 @@ struct CPUStatsDetailView: View {
var body: some View {
ScrollView {
VStack(spacing: 16) {
StatsCard(title: "CPU Overview") {
StatsCard(title: String(localized: "CPU Overview")) {
CPUUsageDashboard(
breakdown: statsManager.cpuBreakdown,
loadAverage: statsManager.cpuLoadAverage,
@ -43,12 +43,12 @@ struct CPUStatsDetailView: View {
)
}
StatsCard(title: "Top Processes") {
StatsCard(title: String(localized: "Top Processes")) {
CPUProcessList(processes: topProcesses, accentColor: userColor)
}
if !statsManager.cpuCoreUsage.isEmpty {
StatsCard(title: "Per-Core Usage") {
StatsCard(title: String(localized: "Per-Core Usage")) {
CPUCoreUsageGrid(cores: statsManager.cpuCoreUsage, accentColor: userColor)
}
}

View file

@ -88,12 +88,12 @@ struct CPUUsageDashboard: View {
Text("System")
.font(.caption)
.foregroundColor(.secondary)
DetailRow(color: nil, label: "Logical cores", value: "\(max(coreCount, 1))")
DetailRow(color: nil, label: "Uptime", value: StatsFormatting.abbreviatedDuration(uptime))
DetailRow(color: nil, label: String(localized: "Logical cores"), value: "\(max(coreCount, 1))")
DetailRow(color: nil, label: String(localized: "Uptime"), value: StatsFormatting.abbreviatedDuration(uptime))
Divider().padding(.vertical, 4)
DetailRow(color: nil, label: "Load 1m", value: String(format: "%.2f", loadAverage.oneMinute))
DetailRow(color: nil, label: "Load 5m", value: String(format: "%.2f", loadAverage.fiveMinutes))
DetailRow(color: nil, label: "Load 15m", value: String(format: "%.2f", loadAverage.fifteenMinutes))
DetailRow(color: nil, label: String(localized: "Load 1m"), value: String(format: "%.2f", loadAverage.oneMinute))
DetailRow(color: nil, label: String(localized: "Load 5m"), value: String(format: "%.2f", loadAverage.fiveMinutes))
DetailRow(color: nil, label: String(localized: "Load 15m"), value: String(format: "%.2f", loadAverage.fifteenMinutes))
}
.frame(maxWidth: .infinity, alignment: .leading)
@ -205,7 +205,7 @@ private struct CPUFrequencyGauge: View {
var body: some View {
CircularGaugeView(
title: "Frequency",
title: String(localized: "Frequency"),
value: normalizedValue,
tint: tint,
centerPrimaryText: centerPrimary,
@ -264,7 +264,7 @@ private struct CPUSegmentDonut: View {
VStack(spacing: 4) {
Text(StatsFormatting.percentage(breakdown.activeUsage))
.font(.system(size: 30, weight: .bold, design: .rounded))
Text("Active")
Text(String(localized: "stats_resource_active", defaultValue: "Active"))
.font(.footnote)
.foregroundColor(.secondary)
}
@ -335,9 +335,9 @@ struct LoadAverageRow: View {
.font(.caption)
.foregroundColor(.secondary)
HStack(spacing: 12) {
LoadAverageValue(label: "1m", value: loadAverage.oneMinute)
LoadAverageValue(label: "5m", value: loadAverage.fiveMinutes)
LoadAverageValue(label: "15m", value: loadAverage.fifteenMinutes)
LoadAverageValue(label: String(localized: "1m"), value: loadAverage.oneMinute)
LoadAverageValue(label: String(localized: "5m"), value: loadAverage.fiveMinutes)
LoadAverageValue(label: String(localized: "15m"), value: loadAverage.fifteenMinutes)
}
}
}
@ -455,10 +455,10 @@ struct CPUDetailsGrid: View {
Text("Usage Breakdown")
.font(.caption)
.foregroundColor(.secondary)
DetailRow(color: nil, label: "Total", value: StatsFormatting.percentage(breakdown.activeUsage))
DetailRow(color: systemColor, label: "System", value: StatsFormatting.percentage(breakdown.system))
DetailRow(color: userColor, label: "User", value: StatsFormatting.percentage(breakdown.user))
DetailRow(color: Color.gray.opacity(0.4), label: "Idle", value: StatsFormatting.percentage(breakdown.idle))
DetailRow(color: nil, label: String(localized: "Total"), value: StatsFormatting.percentage(breakdown.activeUsage))
DetailRow(color: systemColor, label: String(localized: "System"), value: StatsFormatting.percentage(breakdown.system))
DetailRow(color: userColor, label: String(localized: "User"), value: StatsFormatting.percentage(breakdown.user))
DetailRow(color: Color.gray.opacity(0.4), label: String(localized: "Idle"), value: StatsFormatting.percentage(breakdown.idle))
}
Divider().padding(.vertical, 4)
@ -467,8 +467,8 @@ struct CPUDetailsGrid: View {
Text("System")
.font(.caption)
.foregroundColor(.secondary)
DetailRow(color: nil, label: "Logical cores", value: "\(max(coreCount, 1))")
DetailRow(color: nil, label: "Uptime", value: StatsFormatting.abbreviatedDuration(uptime))
DetailRow(color: nil, label: String(localized: "Logical cores"), value: "\(max(coreCount, 1))")
DetailRow(color: nil, label: String(localized: "Uptime"), value: StatsFormatting.abbreviatedDuration(uptime))
}
if showLoadAverage {

View file

@ -28,7 +28,7 @@ struct DiskStatsDetailView: View {
var body: some View {
ScrollView {
VStack(spacing: 16) {
StatsCard(title: "Disk Overview", padding: 16, background: cardBackground, cornerRadius: 12) {
StatsCard(title: String(localized: "Disk Overview"), padding: 16, background: cardBackground, cornerRadius: 12) {
DiskOverview(
readSpeed: statsManager.diskRead,
writeSpeed: statsManager.diskWrite,
@ -39,11 +39,11 @@ struct DiskStatsDetailView: View {
)
}
StatsCard(title: "Storage Devices", padding: 16, background: cardBackground, cornerRadius: 12) {
StatsCard(title: String(localized: "Storage Devices"), padding: 16, background: cardBackground, cornerRadius: 12) {
DiskDevicesCard(devices: statsManager.diskDevices, accentColor: readColor)
}
StatsCard(title: "Session Totals", padding: 16, background: cardBackground, cornerRadius: 12) {
StatsCard(title: String(localized: "Session Totals"), padding: 16, background: cardBackground, cornerRadius: 12) {
DiskTotalsView(totals: statsManager.diskTotals, readColor: readColor, writeColor: writeColor)
}
}
@ -99,8 +99,8 @@ private struct DiskTotalsView: View {
var body: some View {
VStack(spacing: 10) {
DetailRow(color: readColor.opacity(0.75), label: "Read", value: formattedBytes(totals.readMB))
DetailRow(color: writeColor.opacity(0.75), label: "Written", value: formattedBytes(totals.writtenMB))
DetailRow(color: readColor.opacity(0.75), label: String(localized: "Read"), value: formattedBytes(totals.readMB))
DetailRow(color: writeColor.opacity(0.75), label: String(localized: "Written"), value: formattedBytes(totals.writtenMB))
}
}

View file

@ -29,7 +29,7 @@ struct GPUStatsDetailView: View {
var body: some View {
ScrollView {
VStack(spacing: 16) {
StatsCard(title: "GPU Overview", padding: 16, background: cardBackground, cornerRadius: 12) {
StatsCard(title: String(localized: "GPU Overview"), padding: 16, background: cardBackground, cornerRadius: 12) {
GPUUsageDashboard(
usage: statsManager.gpuUsage,
breakdown: statsManager.gpuBreakdown,
@ -40,12 +40,12 @@ struct GPUStatsDetailView: View {
)
}
StatsCard(title: "Top Processes", padding: 16, background: cardBackground, cornerRadius: 12) {
StatsCard(title: String(localized: "Top Processes"), padding: 16, background: cardBackground, cornerRadius: 12) {
CPUProcessList(processes: topProcesses, accentColor: accentColor, displayLimit: processDisplayLimit)
}
if !statsManager.gpuDevices.isEmpty {
StatsCard(title: "Devices", padding: 16, background: cardBackground, cornerRadius: 12) {
StatsCard(title: String(localized: "Devices"), padding: 16, background: cardBackground, cornerRadius: 12) {
GPUDeviceList(devices: statsManager.gpuDevices, accentColor: accentColor)
}
}
@ -119,10 +119,10 @@ private struct GPUUsageDashboard: View {
private var breakdownSection: some View {
VStack(alignment: .leading, spacing: 12) {
DetailRow(color: accentColor.opacity(0.9), label: "Render", value: StatsFormatting.percentage(breakdown.render))
DetailRow(color: accentColor.opacity(0.7), label: "Compute", value: StatsFormatting.percentage(breakdown.compute))
DetailRow(color: accentColor.opacity(0.55), label: "Video", value: StatsFormatting.percentage(breakdown.video))
DetailRow(color: accentColor.opacity(0.45), label: "Other", value: StatsFormatting.percentage(breakdown.other))
DetailRow(color: accentColor.opacity(0.9), label: String(localized: "Render"), value: StatsFormatting.percentage(breakdown.render))
DetailRow(color: accentColor.opacity(0.7), label: String(localized: "Compute"), value: StatsFormatting.percentage(breakdown.compute))
DetailRow(color: accentColor.opacity(0.55), label: String(localized: "Video"), value: StatsFormatting.percentage(breakdown.video))
DetailRow(color: accentColor.opacity(0.45), label: String(localized: "Other"), value: StatsFormatting.percentage(breakdown.other))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ -132,15 +132,15 @@ private struct GPUUsageDashboard: View {
Text("Averages")
.font(.caption)
.foregroundColor(.secondary)
DetailRow(color: nil, label: "Session Avg", value: StatsFormatting.percentage(averageUsage))
DetailRow(color: nil, label: "Last Update", value: formattedTimestamp(lastUpdated))
DetailRow(color: nil, label: String(localized: "Session Avg"), value: StatsFormatting.percentage(averageUsage))
DetailRow(color: nil, label: String("localized: Last Update"), value: formattedTimestamp(lastUpdated))
if let device = primaryDevice {
Divider().padding(.vertical, 4)
DetailRow(color: nil, label: "Active GPU", value: device.formattedVendorModel)
DetailRow(color: nil, label: "Status", value: device.isActive ? "Active" : "Idle")
DetailRow(color: nil, label: "Temperature", value: device.temperatureText)
DetailRow(color: nil, label: String(localized: "Active GPU"), value: device.formattedVendorModel)
DetailRow(color: nil, label: String(localized: "Status"), value: device.isActive ? String(localized: "Active") : String(localized: "Idle"))
DetailRow(color: nil, label: String(localized: "Temperature"), value: device.temperatureText)
if let cores = device.cores {
DetailRow(color: nil, label: "Cores", value: "\(cores)")
DetailRow(color: nil, label: String(localized: "Cores"), value: "\(cores)")
}
}
}
@ -170,9 +170,9 @@ private struct GPUEngineGauges: View {
.font(.caption)
.foregroundColor(.secondary)
HStack(alignment: .center, spacing: 18) {
EngineGaugeView(title: "Render", value: render, tint: accentColor, size: 72)
EngineGaugeView(title: String(localized: "Render"), value: render, tint: accentColor, size: 72)
GPUUsageRing(usage: usage, accentColor: accentColor, diameter: ringDiameter, lineWidth: ringLineWidth)
EngineGaugeView(title: "Tiler", value: tiler, tint: accentColor.opacity(0.75), size: 72)
EngineGaugeView(title: String(localized: "Tiler"), value: tiler, tint: accentColor.opacity(0.75), size: 72)
}
.frame(maxWidth: .infinity, alignment: .center)
}
@ -193,7 +193,7 @@ private struct GPUUsageRing: View {
VStack(spacing: 4) {
Text(StatsFormatting.percentage(usage))
.font(.system(size: 26, weight: .bold, design: .rounded))
Text("Active")
Text(String(localized: "stats_resource_active", defaultValue: "Active"))
.font(.footnote)
.foregroundColor(.secondary)
}
@ -272,7 +272,7 @@ private struct GPUDeviceRow: View {
VStack(alignment: .leading, spacing: 2) {
Text(device.formattedVendorModel)
.font(.system(size: 13, weight: .semibold))
Text(device.isActive ? "Active" : "Idle")
Text(device.isActive ? String(localized: "stats_resource_active", defaultValue: "Active") : String(localized: "Idle"))
.font(.caption2)
.foregroundColor(device.isActive ? .green : .secondary)
}
@ -288,25 +288,25 @@ private struct GPUDeviceRow: View {
}
VStack(spacing: 8) {
if let render = device.renderUtilization {
DetailRow(color: accentColor.opacity(0.9), label: "Renderer", value: StatsFormatting.percentage(render))
DetailRow(color: accentColor.opacity(0.9), label: String(localized: "Renderer"), value: StatsFormatting.percentage(render))
}
if let tiler = device.tilerUtilization {
DetailRow(color: accentColor.opacity(0.6), label: "Tiler", value: StatsFormatting.percentage(tiler))
DetailRow(color: accentColor.opacity(0.6), label: String(localized: "Tiler"), value: StatsFormatting.percentage(tiler))
}
if let temp = device.temperature {
DetailRow(color: nil, label: "Temperature", value: String(format: "%.0f°C", temp))
DetailRow(color: nil, label: String(localized: "Temperature"), value: String(format: "%.0f°C", temp))
}
if let fan = device.fanSpeed {
DetailRow(color: nil, label: "Fan Speed", value: "\(fan)%")
DetailRow(color: nil, label: String(localized: "Fan Speed"), value: "\(fan)%")
}
if let coreClock = device.coreClock {
DetailRow(color: nil, label: "Core Clock", value: "\(coreClock) MHz")
DetailRow(color: nil, label: String(localized: "Core Clock"), value: "\(coreClock) MHz")
}
if let memoryClock = device.memoryClock {
DetailRow(color: nil, label: "Memory Clock", value: "\(memoryClock) MHz")
DetailRow(color: nil, label: String(localized: "Memory Clock"), value: "\(memoryClock) MHz")
}
if let cores = device.cores {
DetailRow(color: nil, label: "Cores", value: "\(cores)")
DetailRow(color: nil, label: String(localized: "Cores"), value: "\(cores)")
}
}
}

View file

@ -29,15 +29,15 @@ struct MemoryStatsDetailView: View {
var body: some View {
ScrollView {
VStack(spacing: 16) {
StatsCard(title: "Memory Overview", padding: 16, background: cardBackground, cornerRadius: 12) {
StatsCard(title: String(localized: "Memory Overview"), padding: 16, background: cardBackground, cornerRadius: 12) {
MemoryUsageDashboard(breakdown: statsManager.memoryBreakdown, accentColor: accentColor)
}
StatsCard(title: "Top Processes", padding: 16, background: cardBackground, cornerRadius: 12) {
StatsCard(title: String(localized: "Top Processes"), padding: 16, background: cardBackground, cornerRadius: 12) {
CPUProcessList(processes: topProcesses, accentColor: accentColor, displayLimit: processDisplayLimit)
}
StatsCard(title: "Pressure & Swap", padding: 16, background: cardBackground, cornerRadius: 12) {
StatsCard(title: String(localized: "Pressure & Swap"), padding: 16, background: cardBackground, cornerRadius: 12) {
MemoryHealthView(breakdown: statsManager.memoryBreakdown, accentColor: accentColor)
}
}
@ -64,9 +64,9 @@ private struct MemoryUsageDashboard: View {
var body: some View {
let totalsSection = VStack(alignment: .leading, spacing: 12) {
DetailRow(color: accentColor.opacity(0.8), label: "Used", value: formattedBytes(breakdown.usedBytes))
DetailRow(color: freeColor.opacity(0.9), label: "Free", value: formattedBytes(breakdown.freeBytes))
DetailRow(color: nil, label: "Total", value: formattedBytes(breakdown.totalBytes))
DetailRow(color: accentColor.opacity(0.8), label: String(localized: "Used"), value: formattedBytes(breakdown.usedBytes))
DetailRow(color: freeColor.opacity(0.9), label: String(localized: "Free"), value: formattedBytes(breakdown.freeBytes))
DetailRow(color: nil, label: String(localized: "Total"), value: formattedBytes(breakdown.totalBytes))
}
.frame(maxWidth: .infinity, alignment: .leading)
@ -74,10 +74,10 @@ private struct MemoryUsageDashboard: View {
Text("Breakdown")
.font(.caption)
.foregroundColor(.secondary)
MemoryBreakdownRow(label: "App", value: breakdown.appBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.85))
MemoryBreakdownRow(label: "Cached", value: breakdown.cacheBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.65))
MemoryBreakdownRow(label: "Wired", value: breakdown.wiredBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.45))
MemoryBreakdownRow(label: "Compressed", value: breakdown.compressedBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.35))
MemoryBreakdownRow(label: String(localized: "App"), value: breakdown.appBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.85))
MemoryBreakdownRow(label: String(localized: "Cached"), value: breakdown.cacheBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.65))
MemoryBreakdownRow(label: String(localized: "Wired"), value: breakdown.wiredBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.45))
MemoryBreakdownRow(label: String(localized: "Compressed"), value: breakdown.compressedBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.35))
}
.frame(maxWidth: .infinity, alignment: .leading)
@ -170,11 +170,11 @@ private struct MemoryPressureIndicator: View {
private var status: (title: String, color: Color, description: String) {
switch pressure.level {
case .normal:
return ("Normal", Color.green, "System memory pressure is nominal.")
return (String(localized: "Normal"), Color.green, String(localized: "System memory pressure is nominal."))
case .warning:
return ("Warning", Color.orange, "Memory pressure is elevated. Closing apps may help.")
return (String(localized: "Warning"), Color.orange, String(localized: "Memory pressure is elevated. Closing apps may help."))
case .critical:
return ("Critical", Color.red, "Memory pressure is critical; the system may purge aggressively.")
return (String(localized: "Critical"), Color.red, String(localized: "Memory pressure is critical; the system may purge aggressively."))
}
}
@ -220,9 +220,9 @@ private struct SwapUsageView: View {
.foregroundColor(.secondary)
if swap.totalBytes > 0 {
DetailRow(color: accentColor.opacity(0.85), label: "Used", value: StatsFormatting.bytes(swap.usedBytes))
DetailRow(color: accentColor.opacity(0.55), label: "Free", value: StatsFormatting.bytes(swap.freeBytes))
DetailRow(color: nil, label: "Total", value: StatsFormatting.bytes(swap.totalBytes))
DetailRow(color: accentColor.opacity(0.85), label: String(localized: "Used"), value: StatsFormatting.bytes(swap.usedBytes))
DetailRow(color: accentColor.opacity(0.55), label: String(localized: "Free"), value: StatsFormatting.bytes(swap.freeBytes))
DetailRow(color: nil, label: String(localized: "Total"), value: StatsFormatting.bytes(swap.totalBytes))
SwapUsageBar(usedBytes: swap.usedBytes, totalBytes: swap.totalBytes, tint: accentColor)
.frame(height: 8)
} else {

View file

@ -28,7 +28,7 @@ struct NetworkStatsDetailView: View {
var body: some View {
ScrollView {
VStack(spacing: 16) {
StatsCard(title: "Network Overview", padding: 16, background: cardBackground, cornerRadius: 12) {
StatsCard(title: String(localized: "Network Overview"), padding: 16, background: cardBackground, cornerRadius: 12) {
NetworkOverview(
download: statsManager.networkDownload,
upload: statsManager.networkUpload,
@ -41,7 +41,7 @@ struct NetworkStatsDetailView: View {
)
}
StatsCard(title: "Interfaces", padding: 16, background: cardBackground, cornerRadius: 12) {
StatsCard(title: String(localized: "Interfaces"), padding: 16, background: cardBackground, cornerRadius: 12) {
NetworkInterfacesCard(
interfaces: statsManager.networkInterfaces,
downloadColor: downloadColor,
@ -49,7 +49,7 @@ struct NetworkStatsDetailView: View {
)
}
StatsCard(title: "Session Totals", padding: 16, background: cardBackground, cornerRadius: 12) {
StatsCard(title: String(localized: "Session Totals"), padding: 16, background: cardBackground, cornerRadius: 12) {
NetworkTotalsView(totals: statsManager.networkTotals, downloadColor: downloadColor, uploadColor: uploadColor)
}
}
@ -101,8 +101,8 @@ private struct NetworkOverview: View {
Text(StatsFormatting.throughput(upload))
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(.primary)
DetailRow(color: uploadColor.opacity(0.6), label: "Average", value: StatsFormatting.throughput(avgUpload))
DetailRow(color: uploadColor.opacity(0.45), label: "Peak", value: StatsFormatting.throughput(peakUpload))
DetailRow(color: uploadColor.opacity(0.6), label: String(localized: "Average"), value: StatsFormatting.throughput(avgUpload))
DetailRow(color: uploadColor.opacity(0.45), label: String(localized: "Peak"), value: StatsFormatting.throughput(peakUpload))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ -116,8 +116,8 @@ private struct NetworkTotalsView: View {
var body: some View {
VStack(spacing: 10) {
DetailRow(color: downloadColor.opacity(0.75), label: "Downloaded", value: formattedBytes(totals.downloadedMB))
DetailRow(color: uploadColor.opacity(0.75), label: "Uploaded", value: formattedBytes(totals.uploadedMB))
DetailRow(color: downloadColor.opacity(0.75), label: String(localized: "Downloaded"), value: formattedBytes(totals.downloadedMB))
DetailRow(color: uploadColor.opacity(0.75), label: String(localized: "Uploaded"), value: formattedBytes(totals.uploadedMB))
}
}
@ -193,7 +193,7 @@ private struct NetworkInterfaceRow: View {
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.primary)
if interface.isActive {
Text("Active")
Text(String(localized: "stats_resource_active", defaultValue: "Active"))
.font(.caption2)
.foregroundColor(downloadColor)
.padding(.horizontal, 6)
@ -222,8 +222,8 @@ private struct NetworkInterfaceRow: View {
Spacer()
VStack(alignment: .trailing, spacing: 4) {
metricRow(label: "Down", value: StatsFormatting.throughput(interface.currentDownload), color: downloadColor)
metricRow(label: "Up", value: StatsFormatting.throughput(interface.currentUpload), color: uploadColor)
metricRow(label: String(localized: "Down"), value: StatsFormatting.throughput(interface.currentDownload), color: downloadColor)
metricRow(label: String(localized: "Up"), value: StatsFormatting.throughput(interface.currentUpload), color: uploadColor)
}
}
}

View file

@ -0,0 +1,245 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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 {
static let shared = AppleMusicAirPlayManager()
@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
/// 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.
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)
}
}

View file

@ -382,6 +382,36 @@ class CalendarManager: ObservableObject {
lastEventsFetchDate = Date()
}
func setCalendarsSelected(_ calendars: [CalendarModel], isSelected: Bool) async {
var selectionState = Defaults[.calendarSelectionState]
let ids = Set(calendars.map { $0.id })
switch selectionState {
case .all:
if !isSelected {
let identifiers = Set(allCalendars.map { $0.id }).subtracting(ids)
selectionState = .selected(identifiers)
}
case .selected(var identifiers):
if isSelected {
identifiers.formUnion(ids)
} else {
identifiers.subtract(ids)
}
if identifiers.isEmpty || identifiers.count == allCalendars.count {
selectionState = .all
} else {
selectionState = .selected(identifiers)
}
}
Defaults[.calendarSelectionState] = selectionState
updateSelectedCalendars()
await updateEvents(force: true)
await updateLockScreenEvents(force: true)
}
func setReminderCompleted(reminderID: String, completed: Bool) async {
await calendarService.setReminderCompleted(reminderID: reminderID, completed: completed)
await updateEvents(force: true)

View file

@ -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 {

View file

@ -302,6 +302,7 @@ final class LockScreenWidgetPreviewManager: ObservableObject {
private func applyCalendarPreviewEvents() {
let now = Date()
let reminderCalendar = CalendarModel(
accountName: "Preview",
id: "preview.reminders",
title: "Reminders",
color: .systemBlue,
@ -327,6 +328,7 @@ final class LockScreenWidgetPreviewManager: ObservableObject {
)
let eventCalendar = CalendarModel(
accountName: "Preview",
id: "preview.calendar",
title: "Calendar",
color: .systemOrange,

View file

@ -23,6 +23,7 @@
import Cocoa
struct CalendarModel: Identifiable, Hashable {
let accountName: String
let id: String
let title: String
let color: NSColor

View file

@ -836,6 +836,7 @@ extension Defaults.Keys {
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: false)
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 lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")

View file

@ -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
}