mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Adds an AirPlay device picker popover to the music player controls, allowing users to select and control volume for individual AirPlay devices (HomePod, Apple TV, AirPort Express, etc.) when Apple Music is the active player.
1233 lines
43 KiB
Swift
1233 lines
43 KiB
Swift
/*
|
|
* Atoll (DynamicIsland)
|
|
* Copyright (C) 2024-2026 Atoll Contributors
|
|
*
|
|
* Originally from boring.notch project
|
|
* Modified and adapted for Atoll (DynamicIsland)
|
|
* See NOTICE for details.
|
|
*
|
|
* 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 Combine
|
|
import Defaults
|
|
import SwiftUI
|
|
|
|
// MARK: - Music Player Components
|
|
|
|
struct MusicPlayerView: View {
|
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
|
let albumArtNamespace: Namespace.ID
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace)
|
|
.padding(.all, 5)
|
|
MusicControlsView()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
struct AlbumArtView: View {
|
|
@ObservedObject var musicManager = MusicManager.shared
|
|
@ObservedObject var vm: DynamicIslandViewModel
|
|
let albumArtNamespace: Namespace.ID
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
if Defaults[.lightingEffect] {
|
|
albumArtBackground
|
|
}
|
|
albumArtButton
|
|
}
|
|
}
|
|
|
|
private var albumArtBackground: some View {
|
|
Color.clear
|
|
.aspectRatio(1, contentMode: .fit)
|
|
.background(
|
|
Image(nsImage: musicManager.albumArt)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
)
|
|
.clipped()
|
|
.clipShape(
|
|
RoundedRectangle(
|
|
cornerRadius: Defaults[.cornerRadiusScaling]
|
|
? MusicPlayerImageSizes.cornerRadiusInset.opened
|
|
: MusicPlayerImageSizes.cornerRadiusInset.closed)
|
|
)
|
|
.scaleEffect(x: 1.3, y: 1.4)
|
|
.rotationEffect(.degrees(92))
|
|
.blur(radius: 40)
|
|
.opacity(musicManager.isPlaying ? 0.5 : 0)
|
|
}
|
|
|
|
private var albumArtButton: some View {
|
|
ZStack {
|
|
Button {
|
|
musicManager.openMusicApp()
|
|
} label: {
|
|
ZStack(alignment:.bottomTrailing) {
|
|
albumArtImage
|
|
appIconOverlay
|
|
}
|
|
.albumArtFlip(angle: musicManager.flipAngle)
|
|
.parallax3D(magnitude: 12)
|
|
}
|
|
.buttonStyle(PlainButtonStyle())
|
|
.scaleEffect(musicManager.isPlaying ? 1 : 0.85)
|
|
|
|
albumArtDarkOverlay
|
|
}
|
|
}
|
|
|
|
private var albumArtDarkOverlay: some View {
|
|
Rectangle()
|
|
.aspectRatio(1, contentMode: .fit)
|
|
.foregroundColor(Color.black)
|
|
.opacity(musicManager.isPlaying ? 0 : 0.8)
|
|
.blur(radius: 50)
|
|
}
|
|
|
|
private var albumArtImage: some View {
|
|
Color.clear
|
|
.aspectRatio(1, contentMode: .fit)
|
|
.overlay {
|
|
Image(nsImage: musicManager.albumArt)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
}
|
|
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
|
|
.clipped()
|
|
.clipShape(
|
|
RoundedRectangle(
|
|
cornerRadius: Defaults[.cornerRadiusScaling]
|
|
? MusicPlayerImageSizes.cornerRadiusInset.opened
|
|
: MusicPlayerImageSizes.cornerRadiusInset.closed)
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var appIconOverlay: some View {
|
|
if vm.notchState == .open && !musicManager.usingAppIconForArtwork {
|
|
AppIcon(for: musicManager.bundleIdentifier ?? "com.apple.Music")
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(width: 30, height: 30)
|
|
.offset(x: 10, y: 10)
|
|
.transition(.scale.combined(with: .opacity).animation(.bouncy.delay(0.3)))
|
|
.zIndex(2)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MusicControlsView: View {
|
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
|
@ObservedObject var musicManager = MusicManager.shared
|
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
|
@State private var sliderValue: Double = MusicManager.shared.estimatedPlaybackPosition()
|
|
@State private var dragging: Bool = false
|
|
@State private var lastDragged: Date = .distantPast
|
|
@State private var hudValue: Double = 0
|
|
@State private var hudDragging: Bool = false
|
|
@State private var hudLastDragged: Date = .distantPast
|
|
@Default(.showShuffleAndRepeat) private var showCustomControls
|
|
@Default(.musicControlSlots) private var slotConfig
|
|
@Default(.showMediaOutputControl) private var showMediaOutputControl
|
|
@Default(.musicSkipBehavior) private var musicSkipBehavior
|
|
@Default(.enableLyrics) private var enableLyrics
|
|
private let seekInterval: TimeInterval = 10
|
|
private let skipMagnitude: CGFloat = 6
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading) {
|
|
songInfoAndSlider
|
|
if shouldShowControlHUDRow {
|
|
controlHUDRow
|
|
} else {
|
|
playbackControls
|
|
}
|
|
}
|
|
.buttonStyle(PlainButtonStyle())
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private var songInfoAndSlider: some View {
|
|
GeometryReader { geo in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
songInfo(width: geo.size.width)
|
|
musicSlider
|
|
}
|
|
}
|
|
.padding(.top, 10)
|
|
.padding(.leading, 5)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private func songInfo(width: CGFloat) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
MarqueeText($musicManager.songTitle, font: .headline, nsFont: .headline, textColor: .white, frameWidth: width)
|
|
MarqueeText(
|
|
$musicManager.artistName,
|
|
font: .headline,
|
|
nsFont: .headline,
|
|
textColor: Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor)
|
|
.ensureMinimumBrightness(factor: 0.6) : .gray,
|
|
frameWidth: width
|
|
)
|
|
.fontWeight(.medium)
|
|
// Lyrics shown under the author name (same font size as author) when enabled in settings
|
|
if enableLyrics {
|
|
let transition = AnyTransition.asymmetric(
|
|
insertion: .move(edge: .bottom).combined(with: .opacity),
|
|
removal: .move(edge: .top).combined(with: .opacity)
|
|
)
|
|
|
|
let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if !line.isEmpty {
|
|
let lyricsBinding = Binding<String>(
|
|
get: { musicManager.currentLyrics },
|
|
set: { _ in }
|
|
)
|
|
|
|
MarqueeText(
|
|
lyricsBinding,
|
|
font: .headline,
|
|
nsFont: .headline,
|
|
textColor: .white.opacity(0.8),
|
|
minDuration: 0.35,
|
|
frameWidth: width
|
|
)
|
|
.padding(.top, 2)
|
|
.id(line)
|
|
.transition(transition)
|
|
.animation(.easeInOut(duration: 0.32), value: line)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether the progress timeline should be paused (no ticks).
|
|
private var isProgressTimelinePaused: Bool {
|
|
!musicManager.isPlaying || musicManager.isLiveStream || musicManager.playbackRate <= 0
|
|
}
|
|
|
|
private var musicSlider: some View {
|
|
TimelineView(.animation(minimumInterval: 1.0, paused: isProgressTimelinePaused)) { timeline in
|
|
MusicSliderView(
|
|
sliderValue: $sliderValue,
|
|
duration: $musicManager.songDuration,
|
|
lastDragged: $lastDragged,
|
|
color: musicManager.avgColor,
|
|
dragging: $dragging,
|
|
currentDate: timeline.date,
|
|
timestampDate: musicManager.timestampDate,
|
|
elapsedTime: musicManager.elapsedTime,
|
|
playbackRate: musicManager.playbackRate,
|
|
isPlaying: musicManager.isPlaying,
|
|
isLiveStream: musicManager.isLiveStream
|
|
) { newValue in
|
|
guard !musicManager.isLiveStream else { return }
|
|
MusicManager.shared.seek(to: newValue)
|
|
}
|
|
.padding(.top, 5)
|
|
.frame(height: 36)
|
|
}
|
|
}
|
|
|
|
private var playbackControls: some View {
|
|
HStack(spacing: 8) {
|
|
ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in
|
|
slotView(for: slot)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
}
|
|
|
|
private var shouldShowControlHUDRow: Bool {
|
|
guard vm.notchState == .open else { return false }
|
|
guard coordinator.sneakPeek.show else { return false }
|
|
guard Defaults[.enableSystemHUD] else { return false }
|
|
guard !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] else { return false }
|
|
|
|
switch coordinator.sneakPeek.type {
|
|
case .volume:
|
|
return Defaults[.enableVolumeHUD]
|
|
case .brightness:
|
|
return Defaults[.enableBrightnessHUD]
|
|
case .backlight:
|
|
return Defaults[.enableKeyboardBacklightHUD]
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var controlHUDRow: some View {
|
|
HStack(alignment: .center, spacing: 10) {
|
|
if !controlLeftIconName.isEmpty {
|
|
Image(systemName: controlLeftIconName)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.frame(width: 22, height: 22, alignment: .center)
|
|
}
|
|
|
|
controlHUDSlider
|
|
|
|
if !controlRightIconName.isEmpty {
|
|
Image(systemName: controlRightIconName)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.frame(width: 22, height: 22, alignment: .center)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.onAppear { syncHUDValueIfNeeded(force: true) }
|
|
.onChange(of: coordinator.sneakPeek.value) { _, _ in
|
|
syncHUDValueIfNeeded(force: false)
|
|
}
|
|
}
|
|
|
|
private var controlHUDSlider: some View {
|
|
VStack(spacing: 0) {
|
|
Spacer(minLength: 0)
|
|
CustomSlider(
|
|
value: Binding(
|
|
get: { hudValue },
|
|
set: { newValue in
|
|
hudValue = newValue
|
|
updateControlHUDValue(newValue)
|
|
}
|
|
),
|
|
range: 0...1,
|
|
color: .white,
|
|
dragging: $hudDragging,
|
|
lastDragged: $hudLastDragged,
|
|
onValueChange: { newValue in
|
|
updateControlHUDValue(newValue)
|
|
},
|
|
thumbSize: 10,
|
|
restingTrackHeight: 4,
|
|
draggingTrackHeight: 7
|
|
)
|
|
.frame(height: 7)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(height: 22)
|
|
}
|
|
|
|
private func syncHUDValueIfNeeded(force: Bool) {
|
|
guard shouldShowControlHUDRow else { return }
|
|
guard force || !hudDragging else { return }
|
|
hudValue = Double(coordinator.sneakPeek.value)
|
|
}
|
|
|
|
private func updateControlHUDValue(_ newValue: Double) {
|
|
let clamped = max(0, min(1, newValue))
|
|
switch coordinator.sneakPeek.type {
|
|
case .volume:
|
|
SystemVolumeController.shared.setVolume(Float(clamped))
|
|
case .brightness:
|
|
SystemBrightnessController.shared.setBrightness(Float(clamped))
|
|
case .backlight:
|
|
SystemKeyboardBacklightController.shared.setLevel(Float(clamped))
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private var controlLeftIconName: String {
|
|
switch coordinator.sneakPeek.type {
|
|
case .volume:
|
|
return SystemVolumeController.shared.isMuted ? "speaker.slash" : "speaker.wave.1"
|
|
case .brightness:
|
|
return "sun.min.fill"
|
|
case .backlight:
|
|
return "light.min"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
private var controlRightIconName: String {
|
|
switch coordinator.sneakPeek.type {
|
|
case .volume:
|
|
return SystemVolumeController.shared.isMuted ? "" : "speaker.wave.3"
|
|
case .brightness:
|
|
return "sun.max.fill"
|
|
case .backlight:
|
|
return "light.max"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
private var brandAccentColor: Color {
|
|
musicManager.brandAccentColor
|
|
}
|
|
|
|
private var repeatIcon: String {
|
|
switch musicManager.repeatMode {
|
|
case .off:
|
|
return "repeat"
|
|
case .all:
|
|
return "repeat"
|
|
case .one:
|
|
return "repeat.1"
|
|
}
|
|
}
|
|
|
|
private var repeatIconColor: Color {
|
|
switch musicManager.repeatMode {
|
|
case .off:
|
|
return .white
|
|
case .all, .one:
|
|
return brandAccentColor
|
|
}
|
|
}
|
|
|
|
private var displayedSlots: [MusicControlButton] {
|
|
if showCustomControls {
|
|
let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl)
|
|
return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout
|
|
}
|
|
|
|
switch musicSkipBehavior {
|
|
case .track:
|
|
return MusicControlButton.minimalLayout
|
|
case .tenSecond:
|
|
return [.none, .seekBackward, .playPause, .seekForward, .none]
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func slotView(for control: MusicControlButton) -> some View {
|
|
switch control {
|
|
case .none:
|
|
Spacer(minLength: 0)
|
|
case .playPause:
|
|
HoverButton(
|
|
icon: musicManager.isPlaying ? (musicManager.isLiveStream ? "stop.fill" : "pause.fill") : "play.fill",
|
|
scale: .large
|
|
) {
|
|
MusicManager.shared.togglePlay()
|
|
}
|
|
case .trackBackward:
|
|
playbackButton(
|
|
icon: "backward.fill",
|
|
press: .nudge(-skipMagnitude),
|
|
trigger: skipGestureTrigger(for: .trackBackward)
|
|
) {
|
|
musicManager.previousTrack()
|
|
}
|
|
case .trackForward:
|
|
playbackButton(
|
|
icon: "forward.fill",
|
|
press: .nudge(skipMagnitude),
|
|
trigger: skipGestureTrigger(for: .trackForward)
|
|
) {
|
|
musicManager.nextTrack()
|
|
}
|
|
case .seekBackward:
|
|
playbackButton(
|
|
icon: "gobackward.10",
|
|
press: .wiggle(.counterClockwise),
|
|
trigger: skipGestureTrigger(for: .seekBackward)
|
|
) {
|
|
musicManager.seek(by: -seekInterval)
|
|
}
|
|
case .seekForward:
|
|
playbackButton(
|
|
icon: "goforward.10",
|
|
press: .wiggle(.clockwise),
|
|
trigger: skipGestureTrigger(for: .seekForward)
|
|
) {
|
|
musicManager.seek(by: seekInterval)
|
|
}
|
|
case .shuffle:
|
|
HoverButton(
|
|
icon: "shuffle",
|
|
iconColor: musicManager.isShuffled ? brandAccentColor : .white,
|
|
scale: .medium
|
|
) {
|
|
MusicManager.shared.toggleShuffle()
|
|
}
|
|
case .repeatMode:
|
|
HoverButton(
|
|
icon: repeatIcon,
|
|
iconColor: repeatIconColor,
|
|
scale: .medium
|
|
) {
|
|
MusicManager.shared.toggleRepeat()
|
|
}
|
|
case .mediaOutput:
|
|
MediaOutputPickerButton()
|
|
case .lyrics:
|
|
HoverButton(
|
|
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
|
|
iconColor: enableLyrics ? brandAccentColor : .white,
|
|
scale: .medium
|
|
) {
|
|
enableLyrics.toggle()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SkipTrigger {
|
|
let token: Int
|
|
let pressEffect: HoverButton.PressEffect
|
|
}
|
|
|
|
private func playbackButton(
|
|
icon: String,
|
|
press: HoverButton.PressEffect?,
|
|
trigger: SkipTrigger?,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
HoverButton(
|
|
icon: icon,
|
|
scale: .medium,
|
|
pressEffect: press,
|
|
externalTriggerToken: trigger?.token,
|
|
externalTriggerEffect: trigger?.pressEffect
|
|
) {
|
|
action()
|
|
}
|
|
}
|
|
|
|
private func skipGestureTrigger(for control: MusicControlButton) -> SkipTrigger? {
|
|
guard let pulse = musicManager.skipGesturePulse else { return nil }
|
|
|
|
switch control {
|
|
case .trackBackward where pulse.behavior == .track && pulse.direction == .backward:
|
|
return SkipTrigger(token: pulse.token, pressEffect: .nudge(-skipMagnitude))
|
|
case .trackForward where pulse.behavior == .track && pulse.direction == .forward:
|
|
return SkipTrigger(token: pulse.token, pressEffect: .nudge(skipMagnitude))
|
|
case .seekBackward where pulse.behavior == .tenSecond && pulse.direction == .backward:
|
|
return SkipTrigger(token: pulse.token, pressEffect: .wiggle(.counterClockwise))
|
|
case .seekForward where pulse.behavior == .tenSecond && pulse.direction == .forward:
|
|
return SkipTrigger(token: pulse.token, pressEffect: .wiggle(.clockwise))
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Main View
|
|
|
|
struct NotchHomeView: View {
|
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
|
@ObservedObject var webcamManager = WebcamManager.shared
|
|
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
|
@ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
|
|
@ObservedObject private var musicManager = MusicManager.shared
|
|
@Default(.showStandardMediaControls) private var showStandardMediaControls
|
|
@Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer
|
|
let albumArtNamespace: Namespace.ID
|
|
|
|
/// Whether the music player should actively display (enabled AND has real content).
|
|
private var shouldShowMusicPlayer: Bool {
|
|
showStandardMediaControls && (!autoHideInactiveNotchMediaPlayer || musicManager.hasActiveSession)
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if !coordinator.firstLaunch {
|
|
mainContent
|
|
}
|
|
}
|
|
.transition(.opacity.combined(with: .blurReplace))
|
|
}
|
|
|
|
private var mainContent: some View {
|
|
HStack(alignment: .top, spacing: 20) {
|
|
if Defaults[.enableMinimalisticUI] {
|
|
if let overridePayload = minimalisticOverridePayload {
|
|
ExtensionMinimalisticExperienceView(
|
|
payload: overridePayload,
|
|
albumArtNamespace: albumArtNamespace
|
|
)
|
|
} else {
|
|
MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace)
|
|
}
|
|
} else {
|
|
// Normal mode: Show full music player with optional calendar and webcam
|
|
if shouldShowMusicPlayer {
|
|
MusicPlayerView(albumArtNamespace: albumArtNamespace)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
if Defaults[.showCalendar] {
|
|
Group {
|
|
if shouldShowMusicPlayer {
|
|
CalendarView()
|
|
} else {
|
|
StandaloneCalendarView()
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.onHover { isHovering in
|
|
vm.isHoveringCalendar = isHovering
|
|
}
|
|
.environmentObject(vm)
|
|
}
|
|
|
|
if Defaults[.showMirror],
|
|
webcamManager.cameraAvailable,
|
|
vm.notchState == .open {
|
|
CameraPreviewView(webcamManager: webcamManager)
|
|
.scaledToFit()
|
|
.opacity(vm.notchState == .closed ? 0 : 1)
|
|
.blur(radius: vm.notchState == .closed ? 20 : 0)
|
|
}
|
|
}
|
|
}
|
|
.transition(.opacity.animation(.smooth.speed(0.9))
|
|
.combined(with: .blurReplace.animation(.smooth.speed(0.9)))
|
|
.combined(with: .move(edge: .top)))
|
|
.blur(radius: vm.notchState == .closed ? 30 : 0)
|
|
}
|
|
|
|
private var minimalisticOverridePayload: ExtensionNotchExperiencePayload? {
|
|
extensionNotchExperienceManager.minimalisticReplacementPayload()
|
|
}
|
|
}
|
|
|
|
struct MusicSliderView: View {
|
|
@Binding var sliderValue: Double
|
|
@Binding var duration: Double
|
|
@Binding var lastDragged: Date
|
|
var color: NSColor
|
|
@Binding var dragging: Bool
|
|
let currentDate: Date
|
|
let timestampDate: Date
|
|
let elapsedTime: Double
|
|
let playbackRate: Double
|
|
let isPlaying: Bool
|
|
let isLiveStream: Bool
|
|
var onValueChange: (Double) -> Void
|
|
var labelLayout: TimeLabelLayout = .stacked
|
|
var trailingLabel: TrailingLabel = .duration
|
|
var restingTrackHeight: CGFloat = 5
|
|
var draggingTrackHeight: CGFloat = 9
|
|
|
|
enum TimeLabelLayout {
|
|
case stacked
|
|
case inline
|
|
}
|
|
|
|
enum TrailingLabel {
|
|
case duration
|
|
case remaining
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isLiveStream {
|
|
liveStreamView
|
|
} else {
|
|
switch labelLayout {
|
|
case .stacked:
|
|
stackedContent
|
|
case .inline:
|
|
inlineContent
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: currentDate) { newDate in
|
|
guard !isLiveStream else { return }
|
|
guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return }
|
|
sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate)
|
|
}
|
|
.onChange(of: isPlaying) { _, playing in
|
|
// Snap slider to the exact position when music pauses so
|
|
// the in-flight animation doesn't coast past the true value.
|
|
if !playing {
|
|
sliderValue = MusicManager.shared.estimatedPlaybackPosition()
|
|
}
|
|
}
|
|
.onChange(of: isLiveStream) { isLive in
|
|
if isLive {
|
|
sliderValue = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
private var stackedContent: some View {
|
|
VStack(spacing: 6) {
|
|
sliderCore
|
|
.frame(height: sliderFrameHeight)
|
|
|
|
HStack {
|
|
Text(timeString(from: sliderValue))
|
|
Spacer()
|
|
Text(trailingTimeText)
|
|
}
|
|
.fontWeight(.medium)
|
|
.foregroundColor(timeLabelColor)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
|
|
private var inlineContent: some View {
|
|
HStack(spacing: 10) {
|
|
Text(timeString(from: sliderValue))
|
|
.font(inlineLabelFont)
|
|
.foregroundColor(timeLabelColor)
|
|
.frame(width: 42, alignment: .leading)
|
|
|
|
sliderCore
|
|
.frame(height: sliderFrameHeight)
|
|
.frame(maxWidth: .infinity)
|
|
|
|
Text(trailingTimeText)
|
|
.font(inlineLabelFont)
|
|
.foregroundColor(timeLabelColor)
|
|
.frame(width: 48, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var liveStreamView: some View {
|
|
switch labelLayout {
|
|
case .stacked:
|
|
LiveStreamProgressIndicator(tint: sliderTint)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: sliderFrameHeight)
|
|
case .inline:
|
|
HStack(spacing: 10) {
|
|
Spacer()
|
|
.frame(width: 42)
|
|
LiveStreamProgressIndicator(tint: sliderTint)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: sliderFrameHeight)
|
|
Spacer()
|
|
.frame(width: 48)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var sliderCore: some View {
|
|
CustomSlider(
|
|
value: $sliderValue,
|
|
range: 0 ... duration,
|
|
color: sliderTint,
|
|
dragging: $dragging,
|
|
lastDragged: $lastDragged,
|
|
onValueChange: onValueChange,
|
|
restingTrackHeight: restingTrackHeight,
|
|
draggingTrackHeight: draggingTrackHeight
|
|
)
|
|
// Smoothly interpolate the filled track between 1-second ticks using
|
|
// Core Animation — runs on the GPU with zero CPU polling cost.
|
|
// Disabled while dragging or paused so the bar responds instantly.
|
|
.animation(
|
|
!dragging && isPlaying && !isLiveStream
|
|
? .linear(duration: 1.0)
|
|
: nil,
|
|
value: sliderValue
|
|
)
|
|
}
|
|
|
|
private var sliderTint: Color {
|
|
switch Defaults[.sliderColor] {
|
|
case .albumArt:
|
|
return Color(nsColor: color).ensureMinimumBrightness(factor: 0.8)
|
|
case .accent:
|
|
return .accentColor
|
|
case .white:
|
|
return .white
|
|
}
|
|
}
|
|
|
|
private var timeLabelColor: Color {
|
|
Defaults[.playerColorTinting]
|
|
? Color(nsColor: color).ensureMinimumBrightness(factor: 0.6)
|
|
: .gray
|
|
}
|
|
|
|
private var trailingTimeText: String {
|
|
switch trailingLabel {
|
|
case .duration:
|
|
return timeString(from: duration)
|
|
case .remaining:
|
|
let remaining = max(duration - sliderValue, 0)
|
|
return "-" + timeString(from: remaining)
|
|
}
|
|
}
|
|
|
|
private var inlineLabelFont: Font {
|
|
.system(size: 11, weight: .medium, design: .monospaced)
|
|
}
|
|
|
|
private var sliderFrameHeight: CGFloat {
|
|
max(restingTrackHeight, draggingTrackHeight)
|
|
}
|
|
|
|
func timeString(from seconds: Double) -> String {
|
|
let totalMinutes = Int(seconds) / 60
|
|
let remainingSeconds = Int(seconds) % 60
|
|
let hours = totalMinutes / 60
|
|
let minutes = totalMinutes % 60
|
|
|
|
if hours > 0 {
|
|
return String(format: "%d:%02d:%02d", hours, minutes, remainingSeconds)
|
|
} else {
|
|
return String(format: "%d:%02d", minutes, remainingSeconds)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
struct CustomSlider: View {
|
|
@Binding var value: Double
|
|
var range: ClosedRange<Double>
|
|
var color: Color = .white
|
|
@Binding var dragging: Bool
|
|
@Binding var lastDragged: Date
|
|
var onValueChange: ((Double) -> Void)?
|
|
var thumbSize: CGFloat = 12
|
|
var restingTrackHeight: CGFloat = 5
|
|
var draggingTrackHeight: CGFloat = 9
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let width = geometry.size.width
|
|
let trackHeight = CGFloat(dragging ? draggingTrackHeight : restingTrackHeight)
|
|
let rangeSpan = range.upperBound - range.lowerBound
|
|
|
|
let progress = rangeSpan == .zero ? 0 : (value - range.lowerBound) / rangeSpan
|
|
let filledTrackWidth = min(max(progress, 0), 1) * width
|
|
|
|
ZStack(alignment: .leading) {
|
|
// Background track
|
|
Rectangle()
|
|
.fill(.gray.opacity(0.3))
|
|
.frame(height: trackHeight)
|
|
|
|
// Filled track
|
|
Rectangle()
|
|
.fill(color)
|
|
.frame(width: filledTrackWidth, height: trackHeight)
|
|
}
|
|
.cornerRadius(trackHeight / 2)
|
|
.frame(height: max(restingTrackHeight, draggingTrackHeight))
|
|
.contentShape(Rectangle())
|
|
.highPriorityGesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { gesture in
|
|
withAnimation {
|
|
dragging = true
|
|
}
|
|
let newValue = range.lowerBound + Double(gesture.location.x / width) * rangeSpan
|
|
value = min(max(newValue, range.lowerBound), range.upperBound)
|
|
}
|
|
.onEnded { _ in
|
|
onValueChange?(value)
|
|
dragging = false
|
|
lastDragged = Date()
|
|
}
|
|
)
|
|
.animation(.bouncy.speed(1.4), value: dragging)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MediaOutputPickerButton: View {
|
|
@ObservedObject private var routeManager = AudioRouteManager.shared
|
|
@ObservedObject private var musicManager = MusicManager.shared
|
|
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
|
@StateObject private var airPlayManager = AppleMusicAirPlayManager()
|
|
@State private var isPopoverPresented = false
|
|
@State private var isAirPlayPopoverPresented = false
|
|
@State private var isHoveringPopover = false
|
|
@EnvironmentObject private var vm: DynamicIslandViewModel
|
|
|
|
private var isAppleMusicActive: Bool {
|
|
musicManager.bundleIdentifier == "com.apple.Music"
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
|
|
isPopoverPresented.toggle()
|
|
if isPopoverPresented {
|
|
routeManager.refreshDevices()
|
|
}
|
|
}
|
|
.accessibilityLabel("Media output")
|
|
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
|
MediaOutputSelectorPopover(
|
|
routeManager: routeManager,
|
|
volumeModel: volumeModel,
|
|
onHoverChanged: { hovering in
|
|
isHoveringPopover = hovering
|
|
updatePopoverActivity()
|
|
}
|
|
) {
|
|
isPopoverPresented = false
|
|
isHoveringPopover = false
|
|
updatePopoverActivity()
|
|
}
|
|
}
|
|
|
|
if isAppleMusicActive && !airPlayManager.devices.isEmpty {
|
|
HoverButton(icon: "airplayaudio", iconColor: .white, scale: .medium) {
|
|
isAirPlayPopoverPresented.toggle()
|
|
if isAirPlayPopoverPresented {
|
|
Task { await airPlayManager.refreshDevices() }
|
|
}
|
|
}
|
|
.accessibilityLabel("AirPlay")
|
|
.popover(isPresented: $isAirPlayPopoverPresented, arrowEdge: .bottom) {
|
|
AirPlaySelectorPopover(
|
|
airPlayManager: airPlayManager,
|
|
onHoverChanged: { hovering in
|
|
isHoveringPopover = hovering
|
|
updatePopoverActivity()
|
|
}
|
|
) {
|
|
isAirPlayPopoverPresented = false
|
|
isHoveringPopover = false
|
|
updatePopoverActivity()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
routeManager.refreshDevices()
|
|
if isAppleMusicActive {
|
|
Task { await airPlayManager.refreshDevices() }
|
|
}
|
|
}
|
|
.onChange(of: isPopoverPresented) { _, presented in
|
|
if !presented {
|
|
isHoveringPopover = false
|
|
}
|
|
updatePopoverActivity()
|
|
}
|
|
.onChange(of: isAirPlayPopoverPresented) { _, presented in
|
|
if !presented {
|
|
isHoveringPopover = false
|
|
}
|
|
updatePopoverActivity()
|
|
}
|
|
.onChange(of: musicManager.bundleIdentifier) { _, newBundle in
|
|
if newBundle == "com.apple.Music" {
|
|
Task { await airPlayManager.refreshDevices() }
|
|
}
|
|
}
|
|
.onDisappear {
|
|
vm.isMediaOutputPopoverActive = false
|
|
}
|
|
}
|
|
|
|
private var buttonIcon: String {
|
|
routeManager.activeDevice?.iconName ?? "speaker.wave.2"
|
|
}
|
|
|
|
private func updatePopoverActivity() {
|
|
vm.isMediaOutputPopoverActive = (isPopoverPresented || isAirPlayPopoverPresented) && isHoveringPopover
|
|
}
|
|
}
|
|
|
|
struct MediaOutputSelectorPopover: View {
|
|
@ObservedObject var routeManager: AudioRouteManager
|
|
@ObservedObject var volumeModel: MediaOutputVolumeViewModel
|
|
var onHoverChanged: (Bool) -> Void
|
|
var dismiss: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 16) {
|
|
volumeSection
|
|
Divider()
|
|
devicesSection
|
|
}
|
|
.frame(width: 240)
|
|
.padding(16)
|
|
.onHover { hovering in
|
|
onHoverChanged(hovering)
|
|
}
|
|
.onDisappear {
|
|
onHoverChanged(false)
|
|
}
|
|
}
|
|
|
|
private var volumeSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
volumeModel.toggleMute()
|
|
} label: {
|
|
Image(systemName: volumeIconName)
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundColor(.primary)
|
|
.frame(width: 28, height: 28)
|
|
.background(
|
|
Circle()
|
|
.fill(Color.secondary.opacity(0.18))
|
|
)
|
|
.contentShape(Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Slider(
|
|
value: Binding(
|
|
get: { Double(volumeModel.level) },
|
|
set: { newValue in
|
|
volumeModel.setVolume(Float(newValue))
|
|
}
|
|
),
|
|
in: 0 ... 1
|
|
)
|
|
.tint(.accentColor)
|
|
}
|
|
|
|
HStack {
|
|
Text("Output volume")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Spacer()
|
|
Text(volumePercentage)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var devicesSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Output devices")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
if routeManager.devices.isEmpty {
|
|
Text("No audio outputs available")
|
|
.font(.callout)
|
|
.foregroundColor(.secondary)
|
|
.padding(.vertical, 12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
} else {
|
|
ScrollView {
|
|
VStack(spacing: 6) {
|
|
ForEach(routeManager.devices) { device in
|
|
Button {
|
|
routeManager.select(device: device)
|
|
dismiss()
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: device.iconName)
|
|
.font(.system(size: 14, weight: .medium))
|
|
Text(device.name)
|
|
.foregroundColor(.primary)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
if device.id == routeManager.activeDeviceID {
|
|
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.id == routeManager.activeDeviceID ? Color.primary.opacity(0.12) : .clear)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.frame(maxHeight: 240)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var volumeIconName: String {
|
|
if volumeModel.isMuted || volumeModel.level <= 0.001 {
|
|
return "speaker.slash.fill"
|
|
} else if volumeModel.level < 0.33 {
|
|
return "speaker.wave.1.fill"
|
|
} else if volumeModel.level < 0.66 {
|
|
return "speaker.wave.2.fill"
|
|
}
|
|
return "speaker.wave.3.fill"
|
|
}
|
|
|
|
private var volumePercentage: String {
|
|
"\(Int(round(volumeModel.level * 100)))%"
|
|
}
|
|
}
|
|
|
|
struct AirPlaySelectorPopover: View {
|
|
@ObservedObject var airPlayManager: AppleMusicAirPlayManager
|
|
var onHoverChanged: (Bool) -> Void
|
|
var dismiss: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("AirPlay")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
if airPlayManager.devices.isEmpty {
|
|
Text("No AirPlay devices found")
|
|
.font(.callout)
|
|
.foregroundColor(.secondary)
|
|
.padding(.vertical, 12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
} else {
|
|
ScrollView {
|
|
VStack(spacing: 6) {
|
|
ForEach(airPlayManager.devices) { device in
|
|
VStack(spacing: 4) {
|
|
Button {
|
|
Task { await airPlayManager.toggleDevice(device) }
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: device.iconName)
|
|
.font(.system(size: 14, weight: .medium))
|
|
Text(device.name)
|
|
.foregroundColor(.primary)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
if device.isSelected {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 12, weight: .bold))
|
|
}
|
|
}
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(device.isSelected ? Color.primary.opacity(0.12) : .clear)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if device.isSelected {
|
|
AirPlayVolumeSlider(
|
|
airPlayManager: airPlayManager,
|
|
deviceID: device.id
|
|
)
|
|
.padding(.horizontal, 8)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.frame(maxHeight: 300)
|
|
}
|
|
}
|
|
.frame(width: 240)
|
|
.padding(16)
|
|
.onHover { hovering in
|
|
onHoverChanged(hovering)
|
|
}
|
|
.onDisappear {
|
|
onHoverChanged(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Local @State slider decoupled from the manager's @Published state.
|
|
/// This prevents SwiftUI from resetting the slider position when other
|
|
/// published properties on the manager change during a drag.
|
|
private struct AirPlayVolumeSlider: View {
|
|
@ObservedObject var airPlayManager: AppleMusicAirPlayManager
|
|
let deviceID: String
|
|
|
|
@State private var sliderValue: Double = 0
|
|
@State private var isSyncing = false
|
|
|
|
var body: some View {
|
|
Slider(value: $sliderValue, in: 0...100)
|
|
.tint(.accentColor)
|
|
.onAppear {
|
|
isSyncing = true
|
|
let volume = airPlayManager.devices.first { $0.id == deviceID }?.volume ?? 0
|
|
sliderValue = Double(volume)
|
|
isSyncing = false
|
|
}
|
|
.onChange(of: sliderValue) { _, newValue in
|
|
guard !isSyncing else { return }
|
|
airPlayManager.setVolume(Int(newValue), for: deviceID)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class MediaOutputVolumeViewModel: ObservableObject {
|
|
@Published var level: Float
|
|
@Published var isMuted: Bool
|
|
|
|
private let controller: SystemVolumeController
|
|
private var cancellables: Set<AnyCancellable> = []
|
|
|
|
init(controller: SystemVolumeController = .shared) {
|
|
self.controller = controller
|
|
controller.start()
|
|
level = controller.currentVolume
|
|
isMuted = controller.isMuted
|
|
|
|
NotificationCenter.default.publisher(for: .systemVolumeDidChange)
|
|
.receive(on: RunLoop.main)
|
|
.sink { [weak self] notification in
|
|
guard let self,
|
|
let value = notification.userInfo?["value"] as? Float,
|
|
let muted = notification.userInfo?["muted"] as? Bool else { return }
|
|
self.level = value
|
|
self.isMuted = muted
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
NotificationCenter.default.publisher(for: .systemAudioRouteDidChange)
|
|
.receive(on: RunLoop.main)
|
|
.sink { [weak self] _ in
|
|
self?.syncFromController()
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
func setVolume(_ value: Float) {
|
|
level = value
|
|
if value > 0 {
|
|
isMuted = false
|
|
}
|
|
controller.setVolume(value)
|
|
}
|
|
|
|
func toggleMute() {
|
|
isMuted.toggle()
|
|
controller.toggleMute()
|
|
}
|
|
|
|
private func syncFromController() {
|
|
level = controller.currentVolume
|
|
isMuted = controller.isMuted
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NotchHomeView(
|
|
albumArtNamespace: Namespace().wrappedValue
|
|
)
|
|
.environmentObject(DynamicIslandViewModel())
|
|
}
|