mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
1266 lines
44 KiB
Swift
1266 lines
44 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)
|
|
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)
|
|
.clipShape(
|
|
RoundedRectangle(
|
|
cornerRadius: Defaults[.cornerRadiusScaling]
|
|
? musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.opened/3 : MusicPlayerImageSizes.cornerRadiusInset.opened
|
|
: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.closed/3 : MusicPlayerImageSizes.cornerRadiusInset.closed
|
|
|
|
|
|
)
|
|
)
|
|
)
|
|
.clipped()
|
|
.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()
|
|
.padding(.bottom, -5)
|
|
|
|
}
|
|
.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)
|
|
.clipShape(
|
|
RoundedRectangle(
|
|
cornerRadius: Defaults[.cornerRadiusScaling]
|
|
? musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.opened/3 : MusicPlayerImageSizes.cornerRadiusInset.opened
|
|
: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.closed/3 : MusicPlayerImageSizes.cornerRadiusInset.closed
|
|
|
|
|
|
)
|
|
) // ensures that even the rectagular album cover is rounded just like the MacOS media player
|
|
}
|
|
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
|
|
.clipped()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var appIconOverlay: some View {
|
|
if vm.notchState == .open && !musicManager.usingAppIconForArtwork {
|
|
AppIcon(for: musicManager.bundleIdentifier ?? "com.apple.Music")
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: 36, height: 36)
|
|
.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) {
|
|
MusicTitleMarqueeView(
|
|
text: musicManager.songTitle,
|
|
isExplicit: musicManager.isCurrentTrackExplicit,
|
|
font: .headline,
|
|
nsFont: .headline,
|
|
textColor: .white,
|
|
frameWidth: width,
|
|
badgeHeight: 14
|
|
)
|
|
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: .system(size: 12, weight: .regular),
|
|
nsFont: .headline,
|
|
textColor: .white.opacity(0.7),
|
|
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 isAppleMusicActive: Bool {
|
|
musicManager.bundleIdentifier == "com.apple.Music"
|
|
}
|
|
|
|
private var displayedSlots: [MusicControlButton] {
|
|
if showCustomControls {
|
|
let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive)
|
|
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 .airPlay:
|
|
AirPlayPickerButton()
|
|
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)
|
|
.padding(Defaults[.enableMinimalisticUI] ? 0 : 8) //Putting the main padding for home view here for consistency
|
|
}
|
|
|
|
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.6)
|
|
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
|
|
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
|
@State private var isPopoverPresented = false
|
|
@State private var isHoveringPopover = false
|
|
@EnvironmentObject private var vm: DynamicIslandViewModel
|
|
|
|
var body: some View {
|
|
HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
|
|
isPopoverPresented.toggle()
|
|
if isPopoverPresented {
|
|
routeManager.refreshDevices()
|
|
}
|
|
}
|
|
.accessibilityLabel("Media output")
|
|
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
|
MediaOutputSelectorPopover(
|
|
routeManager: routeManager,
|
|
volumeModel: volumeModel,
|
|
onHoverChanged: { hovering in
|
|
isHoveringPopover = hovering
|
|
updatePopoverActivity()
|
|
}
|
|
) {
|
|
isPopoverPresented = false
|
|
isHoveringPopover = false
|
|
updatePopoverActivity()
|
|
}
|
|
}
|
|
.onAppear {
|
|
routeManager.refreshDevices()
|
|
}
|
|
.onChange(of: isPopoverPresented) { _, presented in
|
|
if !presented {
|
|
isHoveringPopover = false
|
|
}
|
|
updatePopoverActivity()
|
|
}
|
|
.onDisappear {
|
|
vm.isMediaOutputPopoverActive = false
|
|
}
|
|
}
|
|
|
|
private var buttonIcon: String {
|
|
routeManager.activeDevice?.iconName ?? "speaker.wave.2"
|
|
}
|
|
|
|
private func updatePopoverActivity() {
|
|
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
|
|
}
|
|
}
|
|
|
|
private struct AirPlayPickerButton: View {
|
|
@ObservedObject private var musicManager = MusicManager.shared
|
|
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
|
|
@State private var isPopoverPresented = false
|
|
@State private var isHoveringPopover = false
|
|
@EnvironmentObject private var vm: DynamicIslandViewModel
|
|
|
|
private var isAppleMusicActive: Bool {
|
|
musicManager.bundleIdentifier == "com.apple.Music"
|
|
}
|
|
|
|
var body: some View {
|
|
HoverButton(icon: "airplayaudio", iconColor: .white, scale: .medium) {
|
|
isPopoverPresented.toggle()
|
|
if isPopoverPresented {
|
|
Task { await airPlayManager.refreshDevices() }
|
|
}
|
|
}
|
|
.accessibilityLabel("AirPlay")
|
|
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
|
AirPlaySelectorPopover(
|
|
airPlayManager: airPlayManager,
|
|
onHoverChanged: { hovering in
|
|
isHoveringPopover = hovering
|
|
updatePopoverActivity()
|
|
}
|
|
) {
|
|
isPopoverPresented = false
|
|
isHoveringPopover = false
|
|
updatePopoverActivity()
|
|
}
|
|
}
|
|
.onAppear {
|
|
if isAppleMusicActive {
|
|
Task { await airPlayManager.refreshDevices() }
|
|
}
|
|
}
|
|
.onChange(of: isPopoverPresented) { _, presented in
|
|
if !presented { isHoveringPopover = false }
|
|
updatePopoverActivity()
|
|
}
|
|
.onChange(of: musicManager.bundleIdentifier) { _, newBundle in
|
|
if newBundle == "com.apple.Music" {
|
|
Task { await airPlayManager.refreshDevices() }
|
|
}
|
|
}
|
|
.onDisappear {
|
|
vm.isMediaOutputPopoverActive = false
|
|
}
|
|
}
|
|
|
|
private func updatePopoverActivity() {
|
|
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
|
|
}
|
|
}
|
|
|
|
struct MediaOutputSelectorPopover: View {
|
|
@ObservedObject var routeManager: AudioRouteManager
|
|
@ObservedObject var volumeModel: MediaOutputVolumeViewModel
|
|
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.
|
|
struct AirPlayVolumeSlider: View {
|
|
@ObservedObject var airPlayManager: AppleMusicAirPlayManager
|
|
let deviceID: String
|
|
|
|
@State private var sliderValue: Double = 0
|
|
@State private var isSyncing = false
|
|
|
|
var body: some View {
|
|
Slider(value: $sliderValue, in: 0...100)
|
|
.tint(.accentColor)
|
|
.onAppear {
|
|
isSyncing = true
|
|
sliderValue = Double(airPlayManager.currentVolume(for: deviceID))
|
|
isSyncing = false
|
|
}
|
|
.onChange(of: sliderValue) { _, newValue in
|
|
guard !isSyncing else { return }
|
|
airPlayManager.setVolume(Int(newValue), for: deviceID)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class MediaOutputVolumeViewModel: ObservableObject {
|
|
@Published var level: Float
|
|
@Published var isMuted: Bool
|
|
|
|
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())
|
|
}
|