mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge remote-tracking branch 'origin/main' into pr-360
This commit is contained in:
commit
62f96385d8
22 changed files with 7572 additions and 953 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -20,9 +20,19 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Foundation
|
import AppKit
|
||||||
import Combine
|
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 {
|
class AppleMusicController: MediaControllerProtocol {
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
@ -30,17 +40,23 @@ class AppleMusicController: MediaControllerProtocol {
|
||||||
bundleIdentifier: "com.apple.Music",
|
bundleIdentifier: "com.apple.Music",
|
||||||
playbackRate: 1
|
playbackRate: 1
|
||||||
)
|
)
|
||||||
|
|
||||||
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
|
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
|
||||||
$playbackState.eraseToAnyPublisher()
|
$playbackState.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
var isWorking: Bool {
|
var isWorking: Bool {
|
||||||
return true // AppleMusic controller always works
|
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 notificationTask: Task<Void, Never>?
|
||||||
|
private var lastCatalogArtworkKey: String?
|
||||||
|
private var cachedCatalogArtwork: Data?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init() {
|
init() {
|
||||||
setupPlaybackStateChangeObserver()
|
setupPlaybackStateChangeObserver()
|
||||||
|
|
@ -122,7 +138,7 @@ class AppleMusicController: MediaControllerProtocol {
|
||||||
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
|
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
|
||||||
guard descriptor.numberOfItems >= 8 else { return }
|
guard descriptor.numberOfItems >= 8 else { return }
|
||||||
var updatedState = self.playbackState
|
var updatedState = self.playbackState
|
||||||
|
|
||||||
updatedState.isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
|
updatedState.isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
|
||||||
updatedState.title = descriptor.atIndex(2)?.stringValue ?? "Unknown"
|
updatedState.title = descriptor.atIndex(2)?.stringValue ?? "Unknown"
|
||||||
updatedState.artist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
|
updatedState.artist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
|
||||||
|
|
@ -132,91 +148,108 @@ class AppleMusicController: MediaControllerProtocol {
|
||||||
updatedState.isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
|
updatedState.isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
|
||||||
let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0
|
let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0
|
||||||
updatedState.repeatMode = RepeatMode(rawValue: Int(repeatModeValue)) ?? .off
|
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()
|
updatedState.lastUpdated = Date()
|
||||||
self.playbackState = updatedState
|
self.playbackState = updatedState
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// 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 {
|
private func executeCommand(_ command: String) async {
|
||||||
let script = "tell application \"Music\" to \(command)"
|
let script = "tell application \"Music\" to \(command)"
|
||||||
try? await AppleScriptHelper.executeVoid(script)
|
try? await AppleScriptHelper.executeVoid(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
|
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
|
||||||
if #available(macOS 26.0, *) {
|
let script = """
|
||||||
let script = """
|
tell application "Music"
|
||||||
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
|
try
|
||||||
set playerState to player state is playing
|
set artData to raw data of artwork 1 of current track
|
||||||
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, ""}
|
|
||||||
end try
|
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
|
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData}
|
||||||
set artData to data of artwork 1 of current track
|
on error
|
||||||
on error
|
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""}
|
||||||
set artData to ""
|
end try
|
||||||
end try
|
end tell
|
||||||
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData}
|
"""
|
||||||
on error
|
return try await AppleScriptHelper.execute(script)
|
||||||
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""}
|
|
||||||
end try
|
|
||||||
end tell
|
|
||||||
"""
|
|
||||||
return try await AppleScriptHelper.execute(script)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ class CalendarService: CalendarServiceProviding {
|
||||||
extension CalendarModel {
|
extension CalendarModel {
|
||||||
init(from calendar: EKCalendar) {
|
init(from calendar: EKCalendar) {
|
||||||
self.init(
|
self.init(
|
||||||
|
accountName: calendar.accountTitle,
|
||||||
id: calendar.calendarIdentifier,
|
id: calendar.calendarIdentifier,
|
||||||
title: calendar.title,
|
title: calendar.title,
|
||||||
color: calendar.color,
|
color: calendar.color,
|
||||||
|
|
@ -267,7 +268,7 @@ extension Priority {
|
||||||
|
|
||||||
// MARK: - Helper Extensions
|
// MARK: - Helper Extensions
|
||||||
|
|
||||||
private extension EKCalendar {
|
extension EKCalendar {
|
||||||
var accountTitle: String {
|
var accountTitle: String {
|
||||||
switch source.sourceType {
|
switch source.sourceType {
|
||||||
case .local, .subscribed, .birthdays:
|
case .local, .subscribed, .birthdays:
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ struct LockScreenMusicPanel: View {
|
||||||
@ObservedObject var musicManager = MusicManager.shared
|
@ObservedObject var musicManager = MusicManager.shared
|
||||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||||
|
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
|
||||||
@ObservedObject private var animator: LockScreenPanelAnimator
|
@ObservedObject private var animator: LockScreenPanelAnimator
|
||||||
@State private var sliderValue: Double = 0
|
@State private var sliderValue: Double = 0
|
||||||
@State private var dragging: Bool = false
|
@State private var dragging: Bool = false
|
||||||
|
|
@ -44,6 +45,8 @@ 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 isAirPlayPopoverPresented = false
|
||||||
|
|
||||||
@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
|
||||||
|
|
@ -58,6 +61,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
|
||||||
|
|
@ -134,14 +138,17 @@ struct LockScreenMusicPanel: View {
|
||||||
}
|
}
|
||||||
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
|
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
|
||||||
.contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
|
.contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
|
||||||
.animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded)
|
.animation(.easeInOut(duration: 0.28), value: isExpanded)
|
||||||
.animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.18), value: shouldShowVolumeSlider)
|
.animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
sliderValue = musicManager.elapsedTime
|
sliderValue = musicManager.elapsedTime
|
||||||
isActive = true
|
isActive = true
|
||||||
logPanelAppearance()
|
logPanelAppearance()
|
||||||
updatePanelSize(animated: false)
|
updatePanelSize(animated: false)
|
||||||
routeManager.refreshDevices()
|
routeManager.refreshDevices()
|
||||||
|
if isAppleMusicActive {
|
||||||
|
Task { await airPlayManager.refreshDevices() }
|
||||||
|
}
|
||||||
logGlassState(reason: "Panel appeared")
|
logGlassState(reason: "Panel appeared")
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
|
|
@ -163,6 +170,24 @@ struct LockScreenMusicPanel: View {
|
||||||
}
|
}
|
||||||
updatePanelSize()
|
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
|
.onChange(of: enableLyrics) { _, _ in
|
||||||
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
||||||
updatePanelSize()
|
updatePanelSize()
|
||||||
|
|
@ -311,7 +336,7 @@ struct LockScreenMusicPanel: View {
|
||||||
private func toggleExpanded() {
|
private func toggleExpanded() {
|
||||||
let newState = !isExpanded
|
let newState = !isExpanded
|
||||||
suspendParallaxInteraction()
|
suspendParallaxInteraction()
|
||||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) {
|
withAnimation(.easeInOut(duration: 0.28)) {
|
||||||
isExpanded = newState
|
isExpanded = newState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,7 +355,7 @@ struct LockScreenMusicPanel: View {
|
||||||
|
|
||||||
let workItem = DispatchWorkItem {
|
let workItem = DispatchWorkItem {
|
||||||
suspendParallaxInteraction()
|
suspendParallaxInteraction()
|
||||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) {
|
withAnimation(.easeInOut(duration: 0.28)) {
|
||||||
isExpanded = false
|
isExpanded = false
|
||||||
}
|
}
|
||||||
logPanelAppearance(event: "⏱️ Auto-collapsed")
|
logPanelAppearance(event: "⏱️ Auto-collapsed")
|
||||||
|
|
@ -433,6 +458,16 @@ struct LockScreenMusicPanel: View {
|
||||||
volumeSlider
|
volumeSlider
|
||||||
.frame(maxWidth: .infinity, alignment: alignment)
|
.frame(maxWidth: .infinity, alignment: alignment)
|
||||||
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
|
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
|
||||||
|
|
||||||
|
if 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 {
|
if enableLyrics {
|
||||||
|
|
@ -442,8 +477,10 @@ struct LockScreenMusicPanel: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: alignment)
|
.frame(maxWidth: .infinity, alignment: alignment)
|
||||||
.padding(.top, isExpanded ? 6 : 2)
|
.padding(.top, isExpanded ? 6 : 2)
|
||||||
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
|
.animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
|
||||||
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: enableLyrics)
|
.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 {
|
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
|
||||||
|
|
@ -462,7 +499,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -541,6 +578,12 @@ struct LockScreenMusicPanel: View {
|
||||||
}
|
}
|
||||||
case .mediaOutput:
|
case .mediaOutput:
|
||||||
mediaOutputControlButton
|
mediaOutputControlButton
|
||||||
|
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",
|
||||||
|
|
@ -618,6 +661,35 @@ struct LockScreenMusicPanel: View {
|
||||||
.accessibilityLabel("Media output")
|
.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 {
|
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(
|
||||||
|
|
@ -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 {
|
private var sliderBackgroundFill: Color {
|
||||||
if usesLiquidGlass {
|
if usesLiquidGlass {
|
||||||
return Color.white.opacity(0.05)
|
return Color.white.opacity(0.05)
|
||||||
|
|
@ -699,6 +881,26 @@ struct LockScreenMusicPanel: View {
|
||||||
return Color.white.opacity(0.08)
|
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 {
|
private var shouldShowVolumeSlider: Bool {
|
||||||
showMediaOutputControl && isVolumeSliderVisible
|
showMediaOutputControl && isVolumeSliderVisible
|
||||||
}
|
}
|
||||||
|
|
@ -707,12 +909,31 @@ struct LockScreenMusicPanel: View {
|
||||||
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var accessorySectionScrollMaxHeight: CGFloat {
|
||||||
|
isExpanded ? 170 : 130
|
||||||
|
}
|
||||||
|
|
||||||
private var lyricsExtraHeight: CGFloat {
|
private var lyricsExtraHeight: CGFloat {
|
||||||
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics)
|
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 {
|
private var totalExtraHeight: CGFloat {
|
||||||
sliderExtraHeight + lyricsExtraHeight
|
sliderExtraHeight + accessorySectionExtraHeight + lyricsExtraHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat {
|
private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat {
|
||||||
|
|
@ -722,6 +943,7 @@ struct LockScreenMusicPanel: View {
|
||||||
|
|
||||||
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
|
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
|
||||||
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
|
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
|
||||||
|
accessorySectionExtraHeight +
|
||||||
lyricsHeight(forExpanded: expanded, enabled: enableLyrics)
|
lyricsHeight(forExpanded: expanded, enabled: enableLyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -758,9 +980,12 @@ struct LockScreenMusicPanel: View {
|
||||||
let newState = !isVolumeSliderVisible
|
let newState = !isVolumeSliderVisible
|
||||||
if newState {
|
if newState {
|
||||||
routeManager.refreshDevices()
|
routeManager.refreshDevices()
|
||||||
|
if useMergedAirPlayOutput {
|
||||||
|
Task { await airPlayManager.refreshDevices() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
|
withAnimation(.easeInOut(duration: 0.24)) {
|
||||||
isVolumeSliderVisible = newState
|
isVolumeSliderVisible = newState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -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 {
|
struct MediaOutputSelectorPopover: View {
|
||||||
@ObservedObject var routeManager: AudioRouteManager
|
@ObservedObject var routeManager: AudioRouteManager
|
||||||
@ObservedObject var volumeModel: MediaOutputVolumeViewModel
|
@ObservedObject var volumeModel: MediaOutputVolumeViewModel
|
||||||
|
|
@ -1008,7 +1070,7 @@ struct MediaOutputSelectorPopover: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.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 {
|
final class MediaOutputVolumeViewModel: ObservableObject {
|
||||||
@Published var level: Float
|
@Published var level: Float
|
||||||
@Published var isMuted: Bool
|
@Published var isMuted: Bool
|
||||||
|
|
|
||||||
|
|
@ -615,11 +615,11 @@ private struct DurationInputRow: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 8) {
|
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
|
colon
|
||||||
DurationField(label: "MM", value: $minutes, range: 0...59, width: fieldWidth)
|
DurationField(label: String(localized: "MM"), value: $minutes, range: 0...59, width: fieldWidth)
|
||||||
colon
|
colon
|
||||||
DurationField(label: "SS", value: $seconds, range: 0...59, width: fieldWidth)
|
DurationField(label: String(localized: "SS"), value: $seconds, range: 0...59, width: fieldWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -29,7 +29,7 @@ struct CPUStatsDetailView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
StatsCard(title: "CPU Overview") {
|
StatsCard(title: String(localized: "CPU Overview")) {
|
||||||
CPUUsageDashboard(
|
CPUUsageDashboard(
|
||||||
breakdown: statsManager.cpuBreakdown,
|
breakdown: statsManager.cpuBreakdown,
|
||||||
loadAverage: statsManager.cpuLoadAverage,
|
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)
|
CPUProcessList(processes: topProcesses, accentColor: userColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !statsManager.cpuCoreUsage.isEmpty {
|
if !statsManager.cpuCoreUsage.isEmpty {
|
||||||
StatsCard(title: "Per-Core Usage") {
|
StatsCard(title: String(localized: "Per-Core Usage")) {
|
||||||
CPUCoreUsageGrid(cores: statsManager.cpuCoreUsage, accentColor: userColor)
|
CPUCoreUsageGrid(cores: statsManager.cpuCoreUsage, accentColor: userColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,12 +88,12 @@ struct CPUUsageDashboard: View {
|
||||||
Text("System")
|
Text("System")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
DetailRow(color: nil, label: "Logical cores", value: "\(max(coreCount, 1))")
|
DetailRow(color: nil, label: String(localized: "Logical cores"), value: "\(max(coreCount, 1))")
|
||||||
DetailRow(color: nil, label: "Uptime", value: StatsFormatting.abbreviatedDuration(uptime))
|
DetailRow(color: nil, label: String(localized: "Uptime"), value: StatsFormatting.abbreviatedDuration(uptime))
|
||||||
Divider().padding(.vertical, 4)
|
Divider().padding(.vertical, 4)
|
||||||
DetailRow(color: nil, label: "Load 1m", value: String(format: "%.2f", loadAverage.oneMinute))
|
DetailRow(color: nil, label: String(localized: "Load 1m"), value: String(format: "%.2f", loadAverage.oneMinute))
|
||||||
DetailRow(color: nil, label: "Load 5m", value: String(format: "%.2f", loadAverage.fiveMinutes))
|
DetailRow(color: nil, label: String(localized: "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 15m"), value: String(format: "%.2f", loadAverage.fifteenMinutes))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
|
@ -205,7 +205,7 @@ private struct CPUFrequencyGauge: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
CircularGaugeView(
|
CircularGaugeView(
|
||||||
title: "Frequency",
|
title: String(localized: "Frequency"),
|
||||||
value: normalizedValue,
|
value: normalizedValue,
|
||||||
tint: tint,
|
tint: tint,
|
||||||
centerPrimaryText: centerPrimary,
|
centerPrimaryText: centerPrimary,
|
||||||
|
|
@ -264,7 +264,7 @@ private struct CPUSegmentDonut: View {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text(StatsFormatting.percentage(breakdown.activeUsage))
|
Text(StatsFormatting.percentage(breakdown.activeUsage))
|
||||||
.font(.system(size: 30, weight: .bold, design: .rounded))
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
Text("Active")
|
Text(String(localized: "stats_resource_active", defaultValue: "Active"))
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
@ -335,9 +335,9 @@ struct LoadAverageRow: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
LoadAverageValue(label: "1m", value: loadAverage.oneMinute)
|
LoadAverageValue(label: String(localized: "1m"), value: loadAverage.oneMinute)
|
||||||
LoadAverageValue(label: "5m", value: loadAverage.fiveMinutes)
|
LoadAverageValue(label: String(localized: "5m"), value: loadAverage.fiveMinutes)
|
||||||
LoadAverageValue(label: "15m", value: loadAverage.fifteenMinutes)
|
LoadAverageValue(label: String(localized: "15m"), value: loadAverage.fifteenMinutes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -455,10 +455,10 @@ struct CPUDetailsGrid: View {
|
||||||
Text("Usage Breakdown")
|
Text("Usage Breakdown")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
DetailRow(color: nil, label: "Total", value: StatsFormatting.percentage(breakdown.activeUsage))
|
DetailRow(color: nil, label: String(localized: "Total"), value: StatsFormatting.percentage(breakdown.activeUsage))
|
||||||
DetailRow(color: systemColor, label: "System", value: StatsFormatting.percentage(breakdown.system))
|
DetailRow(color: systemColor, label: String(localized: "System"), value: StatsFormatting.percentage(breakdown.system))
|
||||||
DetailRow(color: userColor, label: "User", value: StatsFormatting.percentage(breakdown.user))
|
DetailRow(color: userColor, label: String(localized: "User"), value: StatsFormatting.percentage(breakdown.user))
|
||||||
DetailRow(color: Color.gray.opacity(0.4), label: "Idle", value: StatsFormatting.percentage(breakdown.idle))
|
DetailRow(color: Color.gray.opacity(0.4), label: String(localized: "Idle"), value: StatsFormatting.percentage(breakdown.idle))
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider().padding(.vertical, 4)
|
Divider().padding(.vertical, 4)
|
||||||
|
|
@ -467,8 +467,8 @@ struct CPUDetailsGrid: View {
|
||||||
Text("System")
|
Text("System")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
DetailRow(color: nil, label: "Logical cores", value: "\(max(coreCount, 1))")
|
DetailRow(color: nil, label: String(localized: "Logical cores"), value: "\(max(coreCount, 1))")
|
||||||
DetailRow(color: nil, label: "Uptime", value: StatsFormatting.abbreviatedDuration(uptime))
|
DetailRow(color: nil, label: String(localized: "Uptime"), value: StatsFormatting.abbreviatedDuration(uptime))
|
||||||
}
|
}
|
||||||
|
|
||||||
if showLoadAverage {
|
if showLoadAverage {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ struct DiskStatsDetailView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
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(
|
DiskOverview(
|
||||||
readSpeed: statsManager.diskRead,
|
readSpeed: statsManager.diskRead,
|
||||||
writeSpeed: statsManager.diskWrite,
|
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)
|
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)
|
DiskTotalsView(totals: statsManager.diskTotals, readColor: readColor, writeColor: writeColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,8 +99,8 @@ private struct DiskTotalsView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
DetailRow(color: readColor.opacity(0.75), label: "Read", value: formattedBytes(totals.readMB))
|
DetailRow(color: readColor.opacity(0.75), label: String(localized: "Read"), value: formattedBytes(totals.readMB))
|
||||||
DetailRow(color: writeColor.opacity(0.75), label: "Written", value: formattedBytes(totals.writtenMB))
|
DetailRow(color: writeColor.opacity(0.75), label: String(localized: "Written"), value: formattedBytes(totals.writtenMB))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ struct GPUStatsDetailView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
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(
|
GPUUsageDashboard(
|
||||||
usage: statsManager.gpuUsage,
|
usage: statsManager.gpuUsage,
|
||||||
breakdown: statsManager.gpuBreakdown,
|
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)
|
CPUProcessList(processes: topProcesses, accentColor: accentColor, displayLimit: processDisplayLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !statsManager.gpuDevices.isEmpty {
|
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)
|
GPUDeviceList(devices: statsManager.gpuDevices, accentColor: accentColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -119,10 +119,10 @@ private struct GPUUsageDashboard: View {
|
||||||
|
|
||||||
private var breakdownSection: some View {
|
private var breakdownSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
DetailRow(color: accentColor.opacity(0.9), label: "Render", value: StatsFormatting.percentage(breakdown.render))
|
DetailRow(color: accentColor.opacity(0.9), label: String(localized: "Render"), value: StatsFormatting.percentage(breakdown.render))
|
||||||
DetailRow(color: accentColor.opacity(0.7), label: "Compute", value: StatsFormatting.percentage(breakdown.compute))
|
DetailRow(color: accentColor.opacity(0.7), label: String(localized: "Compute"), value: StatsFormatting.percentage(breakdown.compute))
|
||||||
DetailRow(color: accentColor.opacity(0.55), label: "Video", value: StatsFormatting.percentage(breakdown.video))
|
DetailRow(color: accentColor.opacity(0.55), label: String(localized: "Video"), value: StatsFormatting.percentage(breakdown.video))
|
||||||
DetailRow(color: accentColor.opacity(0.45), label: "Other", value: StatsFormatting.percentage(breakdown.other))
|
DetailRow(color: accentColor.opacity(0.45), label: String(localized: "Other"), value: StatsFormatting.percentage(breakdown.other))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
@ -132,15 +132,15 @@ private struct GPUUsageDashboard: View {
|
||||||
Text("Averages")
|
Text("Averages")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
DetailRow(color: nil, label: "Session Avg", value: StatsFormatting.percentage(averageUsage))
|
DetailRow(color: nil, label: String(localized: "Session Avg"), value: StatsFormatting.percentage(averageUsage))
|
||||||
DetailRow(color: nil, label: "Last Update", value: formattedTimestamp(lastUpdated))
|
DetailRow(color: nil, label: String("localized: Last Update"), value: formattedTimestamp(lastUpdated))
|
||||||
if let device = primaryDevice {
|
if let device = primaryDevice {
|
||||||
Divider().padding(.vertical, 4)
|
Divider().padding(.vertical, 4)
|
||||||
DetailRow(color: nil, label: "Active GPU", value: device.formattedVendorModel)
|
DetailRow(color: nil, label: String(localized: "Active GPU"), value: device.formattedVendorModel)
|
||||||
DetailRow(color: nil, label: "Status", value: device.isActive ? "Active" : "Idle")
|
DetailRow(color: nil, label: String(localized: "Status"), value: device.isActive ? String(localized: "Active") : String(localized: "Idle"))
|
||||||
DetailRow(color: nil, label: "Temperature", value: device.temperatureText)
|
DetailRow(color: nil, label: String(localized: "Temperature"), value: device.temperatureText)
|
||||||
if let cores = device.cores {
|
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)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
HStack(alignment: .center, spacing: 18) {
|
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)
|
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)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +193,7 @@ private struct GPUUsageRing: View {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text(StatsFormatting.percentage(usage))
|
Text(StatsFormatting.percentage(usage))
|
||||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
Text("Active")
|
Text(String(localized: "stats_resource_active", defaultValue: "Active"))
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
@ -272,7 +272,7 @@ private struct GPUDeviceRow: View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(device.formattedVendorModel)
|
Text(device.formattedVendorModel)
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.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)
|
.font(.caption2)
|
||||||
.foregroundColor(device.isActive ? .green : .secondary)
|
.foregroundColor(device.isActive ? .green : .secondary)
|
||||||
}
|
}
|
||||||
|
|
@ -288,25 +288,25 @@ private struct GPUDeviceRow: View {
|
||||||
}
|
}
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
if let render = device.renderUtilization {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
if let cores = device.cores {
|
||||||
DetailRow(color: nil, label: "Cores", value: "\(cores)")
|
DetailRow(color: nil, label: String(localized: "Cores"), value: "\(cores)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,15 @@ struct MemoryStatsDetailView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
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)
|
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)
|
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)
|
MemoryHealthView(breakdown: statsManager.memoryBreakdown, accentColor: accentColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -64,9 +64,9 @@ private struct MemoryUsageDashboard: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let totalsSection = VStack(alignment: .leading, spacing: 12) {
|
let totalsSection = VStack(alignment: .leading, spacing: 12) {
|
||||||
DetailRow(color: accentColor.opacity(0.8), label: "Used", value: formattedBytes(breakdown.usedBytes))
|
DetailRow(color: accentColor.opacity(0.8), label: String(localized: "Used"), value: formattedBytes(breakdown.usedBytes))
|
||||||
DetailRow(color: freeColor.opacity(0.9), label: "Free", value: formattedBytes(breakdown.freeBytes))
|
DetailRow(color: freeColor.opacity(0.9), label: String(localized: "Free"), value: formattedBytes(breakdown.freeBytes))
|
||||||
DetailRow(color: nil, label: "Total", value: formattedBytes(breakdown.totalBytes))
|
DetailRow(color: nil, label: String(localized: "Total"), value: formattedBytes(breakdown.totalBytes))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
|
@ -74,10 +74,10 @@ private struct MemoryUsageDashboard: View {
|
||||||
Text("Breakdown")
|
Text("Breakdown")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
MemoryBreakdownRow(label: "App", value: breakdown.appBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.85))
|
MemoryBreakdownRow(label: String(localized: "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: String(localized: "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: String(localized: "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: "Compressed"), value: breakdown.compressedBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.35))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
|
@ -170,11 +170,11 @@ private struct MemoryPressureIndicator: View {
|
||||||
private var status: (title: String, color: Color, description: String) {
|
private var status: (title: String, color: Color, description: String) {
|
||||||
switch pressure.level {
|
switch pressure.level {
|
||||||
case .normal:
|
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:
|
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:
|
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)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
if swap.totalBytes > 0 {
|
if swap.totalBytes > 0 {
|
||||||
DetailRow(color: accentColor.opacity(0.85), label: "Used", value: StatsFormatting.bytes(swap.usedBytes))
|
DetailRow(color: accentColor.opacity(0.85), label: String(localized: "Used"), value: StatsFormatting.bytes(swap.usedBytes))
|
||||||
DetailRow(color: accentColor.opacity(0.55), label: "Free", value: StatsFormatting.bytes(swap.freeBytes))
|
DetailRow(color: accentColor.opacity(0.55), label: String(localized: "Free"), value: StatsFormatting.bytes(swap.freeBytes))
|
||||||
DetailRow(color: nil, label: "Total", value: StatsFormatting.bytes(swap.totalBytes))
|
DetailRow(color: nil, label: String(localized: "Total"), value: StatsFormatting.bytes(swap.totalBytes))
|
||||||
SwapUsageBar(usedBytes: swap.usedBytes, totalBytes: swap.totalBytes, tint: accentColor)
|
SwapUsageBar(usedBytes: swap.usedBytes, totalBytes: swap.totalBytes, tint: accentColor)
|
||||||
.frame(height: 8)
|
.frame(height: 8)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ struct NetworkStatsDetailView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
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(
|
NetworkOverview(
|
||||||
download: statsManager.networkDownload,
|
download: statsManager.networkDownload,
|
||||||
upload: statsManager.networkUpload,
|
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(
|
NetworkInterfacesCard(
|
||||||
interfaces: statsManager.networkInterfaces,
|
interfaces: statsManager.networkInterfaces,
|
||||||
downloadColor: downloadColor,
|
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)
|
NetworkTotalsView(totals: statsManager.networkTotals, downloadColor: downloadColor, uploadColor: uploadColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,8 +101,8 @@ private struct NetworkOverview: View {
|
||||||
Text(StatsFormatting.throughput(upload))
|
Text(StatsFormatting.throughput(upload))
|
||||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
DetailRow(color: uploadColor.opacity(0.6), label: "Average", value: StatsFormatting.throughput(avgUpload))
|
DetailRow(color: uploadColor.opacity(0.6), label: String(localized: "Average"), value: StatsFormatting.throughput(avgUpload))
|
||||||
DetailRow(color: uploadColor.opacity(0.45), label: "Peak", value: StatsFormatting.throughput(peakUpload))
|
DetailRow(color: uploadColor.opacity(0.45), label: String(localized: "Peak"), value: StatsFormatting.throughput(peakUpload))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
@ -116,8 +116,8 @@ private struct NetworkTotalsView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
DetailRow(color: downloadColor.opacity(0.75), label: "Downloaded", value: formattedBytes(totals.downloadedMB))
|
DetailRow(color: downloadColor.opacity(0.75), label: String(localized: "Downloaded"), value: formattedBytes(totals.downloadedMB))
|
||||||
DetailRow(color: uploadColor.opacity(0.75), label: "Uploaded", value: formattedBytes(totals.uploadedMB))
|
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))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
if interface.isActive {
|
if interface.isActive {
|
||||||
Text("Active")
|
Text(String(localized: "stats_resource_active", defaultValue: "Active"))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(downloadColor)
|
.foregroundColor(downloadColor)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
|
|
@ -222,8 +222,8 @@ private struct NetworkInterfaceRow: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
metricRow(label: "Down", value: StatsFormatting.throughput(interface.currentDownload), color: downloadColor)
|
metricRow(label: String(localized: "Down"), value: StatsFormatting.throughput(interface.currentDownload), color: downloadColor)
|
||||||
metricRow(label: "Up", value: StatsFormatting.throughput(interface.currentUpload), color: uploadColor)
|
metricRow(label: String(localized: "Up"), value: StatsFormatting.throughput(interface.currentUpload), color: uploadColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
245
DynamicIsland/managers/AppleMusicAirPlayManager.swift
Normal file
245
DynamicIsland/managers/AppleMusicAirPlayManager.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -382,6 +382,36 @@ class CalendarManager: ObservableObject {
|
||||||
lastEventsFetchDate = Date()
|
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 {
|
func setReminderCompleted(reminderID: String, completed: Bool) async {
|
||||||
await calendarService.setReminderCompleted(reminderID: reminderID, completed: completed)
|
await calendarService.setReminderCompleted(reminderID: reminderID, completed: completed)
|
||||||
await updateEvents(force: true)
|
await updateEvents(force: true)
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,8 @@ class LockScreenPanelManager {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resizeDuration: CFTimeInterval = 0.28
|
||||||
|
|
||||||
let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
|
let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
|
||||||
let targetWidth = baseSize.width
|
let targetWidth = baseSize.width
|
||||||
let targetHeight = baseSize.height + additionalHeight
|
let targetHeight = baseSize.height + additionalHeight
|
||||||
|
|
@ -175,7 +177,7 @@ class LockScreenPanelManager {
|
||||||
|
|
||||||
if animated {
|
if animated {
|
||||||
NSAnimationContext.runAnimationGroup { context in
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
context.duration = 0.45
|
context.duration = resizeDuration
|
||||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
window.animator().setFrame(targetFrame, display: true)
|
window.animator().setFrame(targetFrame, display: true)
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +193,8 @@ class LockScreenPanelManager {
|
||||||
let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
|
let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
|
||||||
if animated {
|
if animated {
|
||||||
CATransaction.begin()
|
CATransaction.begin()
|
||||||
CATransaction.setAnimationDuration(0.28)
|
CATransaction.setAnimationDuration(resizeDuration)
|
||||||
|
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut))
|
||||||
window.contentView?.layer?.cornerRadius = targetRadius
|
window.contentView?.layer?.cornerRadius = targetRadius
|
||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,7 @@ final class LockScreenWidgetPreviewManager: ObservableObject {
|
||||||
private func applyCalendarPreviewEvents() {
|
private func applyCalendarPreviewEvents() {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let reminderCalendar = CalendarModel(
|
let reminderCalendar = CalendarModel(
|
||||||
|
accountName: "Preview",
|
||||||
id: "preview.reminders",
|
id: "preview.reminders",
|
||||||
title: "Reminders",
|
title: "Reminders",
|
||||||
color: .systemBlue,
|
color: .systemBlue,
|
||||||
|
|
@ -327,6 +328,7 @@ final class LockScreenWidgetPreviewManager: ObservableObject {
|
||||||
)
|
)
|
||||||
|
|
||||||
let eventCalendar = CalendarModel(
|
let eventCalendar = CalendarModel(
|
||||||
|
accountName: "Preview",
|
||||||
id: "preview.calendar",
|
id: "preview.calendar",
|
||||||
title: "Calendar",
|
title: "Calendar",
|
||||||
color: .systemOrange,
|
color: .systemOrange,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
import Cocoa
|
import Cocoa
|
||||||
|
|
||||||
struct CalendarModel: Identifiable, Hashable {
|
struct CalendarModel: Identifiable, Hashable {
|
||||||
|
let accountName: String
|
||||||
let id: String
|
let id: String
|
||||||
let title: String
|
let title: String
|
||||||
let color: NSColor
|
let color: NSColor
|
||||||
|
|
|
||||||
|
|
@ -836,6 +836,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