Atoll/DynamicIsland/managers/MusicManager.swift
2026-01-28 03:03:25 +05:30

922 lines
32 KiB
Swift

//
// MusicManager.swift
// DynamicIsland
//
// Created by Harsh Vardhan Goswami on 03/08/24.
//
import AppKit
import Combine
import Defaults
import Foundation
import SwiftUI
// MARK: - Lyric Data Structures
struct LyricLine: Identifiable, Codable {
let id = UUID()
let timestamp: TimeInterval
let text: String
init(timestamp: TimeInterval, text: String) {
self.timestamp = timestamp
self.text = text
}
}
let defaultImage: NSImage = .init(
systemSymbolName: "heart.fill",
accessibilityDescription: "Album Art"
)!
class MusicManager: ObservableObject {
enum SkipDirection: Equatable {
case backward
case forward
}
struct SkipGesturePulse: Equatable {
let token: Int
let direction: SkipDirection
let behavior: MusicSkipBehavior
}
static let skipGestureSeekInterval: TimeInterval = 10
// MARK: - Properties
static let shared = MusicManager()
private var cancellables = Set<AnyCancellable>()
private var controllerCancellables = Set<AnyCancellable>()
private var debounceIdleTask: Task<Void, Never>?
@MainActor private var pendingOptimisticPlayState: Bool?
// Helper to check if macOS has removed support for NowPlayingController
public private(set) var isNowPlayingDeprecated: Bool = false
private let mediaChecker = MediaChecker()
// Active controller
private var activeController: (any MediaControllerProtocol)?
// Published properties for UI
@Published var songTitle: String = "I'm Handsome"
@Published var artistName: String = "Me"
@Published var albumArt: NSImage = defaultImage
@Published var isPlaying = false
@Published var album: String = "Self Love"
@Published var isPlayerIdle: Bool = true
@Published var animations: DynamicIslandAnimations = .init()
@Published var avgColor: NSColor = .white
@Published var bundleIdentifier: String? = nil
@Published var songDuration: TimeInterval = 0
@Published var elapsedTime: TimeInterval = 0
@Published var timestampDate: Date = .init()
@Published var playbackRate: Double = 1
@Published var isShuffled: Bool = false
@Published var repeatMode: RepeatMode = .off
@Published var isLiveStream: Bool = false
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Published var usingAppIconForArtwork: Bool = false
@Published private(set) var skipGesturePulse: SkipGesturePulse?
// MARK: - Lyrics Properties
@Published var currentLyrics: String = ""
@Published var syncedLyrics: [LyricLine] = []
@Published var showLyrics: Bool = false
@Published var currentLyricIndex: Int = -1
// Task used to periodically sync displayed lyric with playback position
private var lyricSyncTask: Task<Void, Never>?
private var artworkData: Data? = nil
private var liveStreamUnknownDurationCount: Int = 0
private var liveStreamEdgeObservationCount: Int = 0
private var liveStreamCompletionObservationCount: Int = 0
private var liveStreamCompletionReleaseCount: Int = 0
// Store last values at the time artwork was changed
private var lastArtworkTitle: String = "I'm Handsome"
private var lastArtworkArtist: String = "Me"
private var lastArtworkAlbum: String = "Self Love"
private var lastArtworkBundleIdentifier: String? = nil
@Published var flipAngle: Double = 0
private let flipAnimationDuration: TimeInterval = 0.45
@Published var isTransitioning: Bool = false
private var transitionWorkItem: DispatchWorkItem?
private var skipGestureToken: Int = 0
// MARK: - Initialization
init() {
// Listen for changes to the default controller preference
NotificationCenter.default.publisher(for: Notification.Name.mediaControllerChanged)
.sink { [weak self] _ in
self?.setActiveControllerBasedOnPreference()
}
.store(in: &cancellables)
// Initialize deprecation check asynchronously
Task { @MainActor in
do {
self.isNowPlayingDeprecated = try await self.mediaChecker.checkDeprecationStatus()
print("Deprecation check completed: \(self.isNowPlayingDeprecated)")
} catch {
print("Failed to check deprecation status: \(error). Defaulting to false.")
self.isNowPlayingDeprecated = false
}
// Initialize the active controller after deprecation check
self.setActiveControllerBasedOnPreference()
}
}
deinit {
destroy()
}
public func destroy() {
debounceIdleTask?.cancel()
cancellables.removeAll()
controllerCancellables.removeAll()
transitionWorkItem?.cancel()
// Release active controller
activeController = nil
}
// MARK: - Setup Methods
private func createController(for type: MediaControllerType) -> (any MediaControllerProtocol)? {
// Cleanup previous controller
if activeController != nil {
controllerCancellables.removeAll()
activeController = nil
}
let newController: (any MediaControllerProtocol)?
switch type {
case .nowPlaying:
// Only create NowPlayingController if not deprecated on this macOS version
if !self.isNowPlayingDeprecated {
newController = NowPlayingController()
} else {
return nil
}
case .appleMusic:
newController = AppleMusicController()
case .spotify:
newController = SpotifyController()
case .youtubeMusic:
newController = YouTubeMusicController()
}
// Set up state observation for the new controller
if let controller = newController {
controller.playbackStatePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self,
self.activeController === controller else { return }
self.updateFromPlaybackState(state)
}
.store(in: &controllerCancellables)
}
return newController
}
private func setActiveControllerBasedOnPreference() {
let preferredType = Defaults[.mediaController]
print("Preferred Media Controller: \(preferredType)")
// If NowPlaying is deprecated but that's the preference, use Apple Music instead
let controllerType = (self.isNowPlayingDeprecated && preferredType == .nowPlaying)
? .appleMusic
: preferredType
if let controller = createController(for: controllerType) {
setActiveController(controller)
} else if controllerType != .appleMusic, let fallbackController = createController(for: .appleMusic) {
// Fallback to Apple Music if preferred controller couldn't be created
setActiveController(fallbackController)
}
}
private func setActiveController(_ controller: any MediaControllerProtocol) {
// Set new active controller
activeController = controller
// Get current state from active controller
forceUpdate()
}
@MainActor
private func applyPlayState(_ state: Bool, animation: Animation?) {
if let animation {
var transaction = Transaction()
transaction.animation = animation
withTransaction(transaction) {
self.isPlaying = state
}
} else {
self.isPlaying = state
}
self.updateIdleState(state: state)
}
// MARK: - Update Methods
@MainActor
private func updateFromPlaybackState(_ state: PlaybackState) {
// Check for playback state changes (playing/paused)
let eventIsPlaying = state.isPlaying
let expectedState = pendingOptimisticPlayState
pendingOptimisticPlayState = nil
if eventIsPlaying != self.isPlaying {
let animation: Animation? = (expectedState == eventIsPlaying) ? .smooth(duration: 0.18) : .smooth
applyPlayState(eventIsPlaying, animation: animation)
if eventIsPlaying && !state.title.isEmpty && !state.artist.isEmpty {
self.updateSneakPeek()
}
} else {
self.updateIdleState(state: eventIsPlaying)
}
// Check for changes in track metadata using last artwork change values
let titleChanged = state.title != self.lastArtworkTitle
let artistChanged = state.artist != self.lastArtworkArtist
let albumChanged = state.album != self.lastArtworkAlbum
let bundleChanged = state.bundleIdentifier != self.lastArtworkBundleIdentifier
// Check for artwork changes
let artworkChanged = state.artwork != nil && state.artwork != self.artworkData
let hasContentChange = titleChanged || artistChanged || albumChanged || artworkChanged || bundleChanged
// Handle artwork and visual transitions for changed content
let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange]
if hasContentChange {
self.triggerFlipAnimation()
if artworkChanged, let artwork = state.artwork {
self.updateArtwork(artwork)
} else if state.artwork == nil {
// Try to use app icon if no artwork but track changed
if let appIconImage = AppIconAsNSImage(for: state.bundleIdentifier) {
self.usingAppIconForArtwork = true
self.updateAlbumArt(newAlbumArt: appIconImage)
}
}
self.artworkData = state.artwork
// Update last artwork change values
self.lastArtworkTitle = state.title
self.lastArtworkArtist = state.artist
self.lastArtworkAlbum = state.album
self.lastArtworkBundleIdentifier = state.bundleIdentifier
// Fetch lyrics for new track whenever content changes
self.fetchLyrics()
// Only update sneak peek if there's actual content and something changed
if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
self.updateSneakPeek()
}
}
let timeChanged = state.currentTime != self.elapsedTime
let durationChanged = state.duration != self.songDuration
let playbackRateChanged = state.playbackRate != self.playbackRate
let shuffleChanged = state.isShuffled != self.isShuffled
let repeatModeChanged = state.repeatMode != self.repeatMode
if state.title != self.songTitle {
self.songTitle = state.title
}
if state.artist != self.artistName {
self.artistName = state.artist
}
if state.album != self.album {
self.album = state.album
}
if timeChanged {
self.elapsedTime = state.currentTime
// Update current lyric based on elapsed time
self.updateCurrentLyric(for: state.currentTime)
}
if durationChanged {
self.songDuration = state.duration
}
if playbackRateChanged {
self.playbackRate = state.playbackRate
}
if shuffleChanged {
self.isShuffled = state.isShuffled
}
if state.bundleIdentifier != self.bundleIdentifier {
self.bundleIdentifier = state.bundleIdentifier
}
if repeatModeChanged {
self.repeatMode = state.repeatMode
}
updateLiveStreamState(with: state)
self.timestampDate = state.lastUpdated
// Manage lyric sync task based on playback/lyrics availability
if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty {
// Ensure syncing runs while lyrics are enabled
startLyricSync()
} else {
stopLyricSync()
}
}
private func triggerFlipAnimation() {
withAnimation(.easeInOut(duration: flipAnimationDuration)) {
flipAngle += 180
}
}
private func updateLiveStreamState(with state: PlaybackState) {
let duration = state.duration
let current = max(state.currentTime, elapsedTime)
let hasKnownDuration = duration.isFinite && duration > 0
let isPlaying = state.isPlaying
if hasKnownDuration {
liveStreamUnknownDurationCount = 0
let remaining = duration - current
let clampedDuration = max(duration, 0)
let clampedCurrent = clampedDuration > 0
? max(0, min(current, clampedDuration))
: max(0, current)
let progress = clampedDuration > 0 ? clampedCurrent / clampedDuration : 0
let sliderAppearsComplete = isPlaying && clampedDuration > 0 && progress >= 0.999
let nearDurationEdge = isPlaying && remaining.isFinite && remaining <= 1.0 && clampedCurrent >= 10
if sliderAppearsComplete {
liveStreamCompletionObservationCount = min(liveStreamCompletionObservationCount + 1, 8)
liveStreamCompletionReleaseCount = 0
} else {
liveStreamCompletionReleaseCount = min(liveStreamCompletionReleaseCount + 1, 8)
if liveStreamCompletionObservationCount > 0 {
liveStreamCompletionObservationCount = max(liveStreamCompletionObservationCount - 1, 0)
}
}
if nearDurationEdge || sliderAppearsComplete {
liveStreamEdgeObservationCount = min(liveStreamEdgeObservationCount + 1, 12)
} else if liveStreamEdgeObservationCount > 0 {
liveStreamEdgeObservationCount = max(liveStreamEdgeObservationCount - 1, 0)
}
if !isLiveStream {
if liveStreamCompletionObservationCount >= 3 || liveStreamEdgeObservationCount >= 5 {
isLiveStream = true
}
} else {
let shouldClearForKnownDuration =
(duration > 10 && remaining > 5)
|| (liveStreamCompletionObservationCount == 0
&& liveStreamEdgeObservationCount == 0
&& liveStreamCompletionReleaseCount >= 4)
if shouldClearForKnownDuration {
isLiveStream = false
}
}
} else if isPlaying {
liveStreamEdgeObservationCount = max(liveStreamEdgeObservationCount - 1, 0)
liveStreamCompletionObservationCount = max(liveStreamCompletionObservationCount - 1, 0)
liveStreamCompletionReleaseCount = 0
liveStreamUnknownDurationCount = min(liveStreamUnknownDurationCount + 1, 8)
if liveStreamUnknownDurationCount >= 3 && !isLiveStream {
isLiveStream = true
}
} else {
liveStreamUnknownDurationCount = 0
liveStreamEdgeObservationCount = 0
liveStreamCompletionObservationCount = 0
liveStreamCompletionReleaseCount = 0
}
}
private func updateArtwork(_ artworkData: Data) {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
if let artworkImage = NSImage(data: artworkData) {
DispatchQueue.main.async { [weak self] in
self?.usingAppIconForArtwork = false
self?.updateAlbumArt(newAlbumArt: artworkImage)
}
}
}
}
private func updateIdleState(state: Bool) {
if state {
isPlayerIdle = false
debounceIdleTask?.cancel()
} else {
debounceIdleTask?.cancel()
debounceIdleTask = Task { [weak self] in
guard let self = self else { return }
try? await Task.sleep(for: .seconds(Defaults[.waitInterval]))
withAnimation {
self.isPlayerIdle = !self.isPlaying
}
}
}
}
private var workItem: DispatchWorkItem?
func updateAlbumArt(newAlbumArt: NSImage) {
workItem?.cancel()
workItem = DispatchWorkItem { [weak self] in
withAnimation(.smooth) {
self?.albumArt = newAlbumArt
if Defaults[.coloredSpectrogram] {
self?.calculateAverageColor()
}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem!)
}
// MARK: - Playback Position Estimation
public func estimatedPlaybackPosition(at date: Date = Date()) -> TimeInterval {
guard isPlaying else { return min(elapsedTime, songDuration) }
let timeDifference = date.timeIntervalSince(timestampDate)
let estimated = elapsedTime + (timeDifference * playbackRate)
return min(max(0, estimated), songDuration)
}
func calculateAverageColor() {
albumArt.averageColor { [weak self] color in
DispatchQueue.main.async {
withAnimation(.smooth) {
self?.avgColor = color ?? .white
}
}
}
}
private func updateSneakPeek() {
let standardControlsEnabled = Defaults[.showStandardMediaControls]
let minimalisticEnabled = Defaults[.enableMinimalisticUI]
guard standardControlsEnabled || minimalisticEnabled else { return }
if isPlaying && Defaults[.enableSneakPeek] {
if Defaults[.sneakPeekStyles] == .standard {
coordinator.toggleSneakPeek(status: true, type: .music)
} else {
coordinator.toggleExpandingView(status: true, type: .music)
}
}
}
// MARK: - Public Methods for controlling playback
func playPause() {
Task {
await activeController?.togglePlay()
}
}
func play() {
Task {
await activeController?.play()
}
}
func pause() {
Task {
await activeController?.pause()
}
}
func toggleShuffle() {
Task {
await activeController?.toggleShuffle()
}
}
func toggleRepeat() {
Task {
await activeController?.toggleRepeat()
}
}
func togglePlay() {
guard let controller = activeController else { return }
Task {
await MainActor.run {
let newState = !isPlaying
pendingOptimisticPlayState = newState
applyPlayState(newState, animation: .smooth(duration: 0.18))
}
await controller.togglePlay()
}
}
func nextTrack() {
Task {
await activeController?.nextTrack()
}
}
func previousTrack() {
Task {
await activeController?.previousTrack()
}
}
func seek(to position: TimeInterval) {
Task {
await activeController?.seek(to: position)
}
}
func seek(by offset: TimeInterval) {
guard !isLiveStream else { return }
let duration = songDuration
guard duration > 0 else { return }
let current = estimatedPlaybackPosition()
let magnitude = abs(offset)
if offset < 0, current <= magnitude {
previousTrack()
return
}
if offset > 0, (duration - current) <= magnitude {
nextTrack()
return
}
let target = min(max(0, current + offset), duration)
seek(to: target)
}
@MainActor
func handleSkipGesture(direction: SkipDirection) {
guard Defaults[.enableHorizontalMusicGestures] else { return }
guard !isPlayerIdle || bundleIdentifier != nil else { return }
let behavior = Defaults[.musicGestureBehavior]
switch behavior {
case .track:
if direction == .forward {
nextTrack()
} else {
previousTrack()
}
case .tenSecond:
let interval = Self.skipGestureSeekInterval
let offset = direction == .forward ? interval : -interval
seek(by: offset)
}
skipGestureToken = skipGestureToken &+ 1
skipGesturePulse = SkipGesturePulse(
token: skipGestureToken,
direction: direction,
behavior: behavior
)
}
func openMusicApp() {
guard let bundleID = bundleIdentifier else {
print("Error: appBundleIdentifier is nil")
return
}
let workspace = NSWorkspace.shared
if let appURL = workspace.urlForApplication(withBundleIdentifier: bundleID) {
let configuration = NSWorkspace.OpenConfiguration()
workspace.openApplication(at: appURL, configuration: configuration) { (app, error) in
if let error = error {
print("Failed to launch app with bundle ID: \(bundleID), error: \(error)")
} else {
print("Launched app with bundle ID: \(bundleID)")
}
}
} else {
print("Failed to find app with bundle ID: \(bundleID)")
}
}
func forceUpdate() {
// Request immediate update from the active controller
Task { [weak self] in
if self?.activeController?.isActive() == true {
if let youtubeController = self?.activeController as? YouTubeMusicController {
await youtubeController.pollPlaybackState()
} else {
await self?.activeController?.updatePlaybackInfo()
}
}
}
}
// MARK: - Lyrics Methods
func fetchLyrics() {
guard Defaults[.enableLyrics] else { return }
// If the lyrics panel is visible already, provide immediate feedback
if showLyrics {
Task { @MainActor in
self.currentLyrics = "Loading lyrics..."
self.syncedLyrics = []
self.currentLyricIndex = -1
}
}
Task {
do {
let lyrics = try await fetchLyricsFromAPI(artist: artistName, title: songTitle)
await MainActor.run {
self.syncedLyrics = lyrics
self.currentLyricIndex = -1
if !lyrics.isEmpty {
self.currentLyrics = lyrics[0].text
} else {
self.currentLyrics = ""
}
// If lyrics are enabled, start syncing them to playback position
if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty {
self.startLyricSync()
} else if self.syncedLyrics.isEmpty {
self.stopLyricSync()
}
}
} catch {
print("Failed to fetch lyrics: \(error)")
await MainActor.run {
self.syncedLyrics = []
self.currentLyrics = ""
self.currentLyricIndex = -1
self.stopLyricSync()
}
}
}
}
private func fetchLyricsFromAPI(artist: String, title: String) async throws -> [LyricLine] {
guard !artist.isEmpty, !title.isEmpty else { return [] }
// Normalize input and percent-encode
let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current)
let cleanTitle = title.folding(options: .diacriticInsensitive, locale: .current)
guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
return []
}
// Use LRCLIB search endpoint which returns an array JSON with `plainLyrics` and/or `syncedLyrics`.
let urlString = "https://lrclib.net/api/search?track_name=\(encodedTitle)&artist_name=\(encodedArtist)"
guard let url = URL(string: urlString) else { return [] }
let (data, response) = try await URLSession.shared.data(from: url)
if let http = response as? HTTPURLResponse, http.statusCode == 200 {
// Try parse as array JSON (preferred)
if let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
let first = jsonArray.first {
let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !synced.isEmpty {
return parseLRC(synced)
} else if !plain.isEmpty {
return [LyricLine(timestamp: 0, text: plain)]
} else {
return []
}
} else {
// Fallback: try to decode as UTF8 and handle as LRC or plain text
if let lrcString = String(data: data, encoding: .utf8) {
let trimmed = lrcString.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return []
}
// If it contains a syncedLyrics key in an object, try that
if let json = try? JSONSerialization.jsonObject(with: data, options: []) {
if let dict = json as? [String: Any],
let synced = dict["syncedLyrics"] as? String
{
return parseLRC(synced)
}
if let array = json as? [Any], array.isEmpty {
return []
}
}
// Otherwise treat as plain lyrics blob
return [LyricLine(timestamp: 0, text: trimmed)]
}
return []
}
} else {
return []
}
}
private func parseLRC(_ lrc: String) -> [LyricLine] {
let lines = lrc.components(separatedBy: .newlines)
var lyrics: [LyricLine] = []
// Accept patterns like [m:ss], [mm:ss], [mm:ss.xx] where centiseconds are optional
let pattern = "\\[(\\d{1,2}):(\\d{2})(?:\\.(\\d{1,2}))?\\]"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
for line in lines {
let nsLine = line as NSString
let fullRange = NSRange(location: 0, length: nsLine.length)
if let match = regex.firstMatch(in: line, options: [], range: fullRange) {
let minRange = match.range(at: 1)
let secRange = match.range(at: 2)
let centiRange = match.range(at: 3)
let minStr = minRange.location != NSNotFound ? nsLine.substring(with: minRange) : "0"
let secStr = secRange.location != NSNotFound ? nsLine.substring(with: secRange) : "0"
let centiStr = (centiRange.location != NSNotFound) ? nsLine.substring(with: centiRange) : "0"
let minutes = Double(minStr) ?? 0
let seconds = Double(secStr) ?? 0
let centis = Double(centiStr) ?? 0
let timestamp = minutes * 60 + seconds + centis / 100.0
let textStart = match.range.location + match.range.length
if textStart <= nsLine.length {
let text = nsLine.substring(from: textStart).trimmingCharacters(in: .whitespaces)
if !text.isEmpty {
lyrics.append(LyricLine(timestamp: timestamp, text: text))
}
}
}
}
return lyrics.sorted(by: { $0.timestamp < $1.timestamp })
}
func updateCurrentLyric(for elapsedTime: TimeInterval) {
guard !syncedLyrics.isEmpty else { return }
// Find the current lyric based on elapsed time
var newIndex = -1
for (index, lyric) in syncedLyrics.enumerated() {
if elapsedTime >= lyric.timestamp {
newIndex = index
} else {
break
}
}
if newIndex != currentLyricIndex {
currentLyricIndex = newIndex
if newIndex >= 0 && newIndex < syncedLyrics.count {
currentLyrics = syncedLyrics[newIndex].text
}
}
}
// Start a background task that periodically updates the displayed lyric
private func startLyricSync() {
// If already running, keep it
if lyricSyncTask != nil { return }
lyricSyncTask = Task { [weak self] in
guard let self = self else { return }
while !Task.isCancelled {
// Compute estimated playback position and update lyric
let position = self.estimatedPlaybackPosition()
await MainActor.run {
self.updateCurrentLyric(for: position)
}
// Sleep ~300ms between updates
try? await Task.sleep(nanoseconds: 300_000_000)
}
}
}
private func stopLyricSync() {
lyricSyncTask?.cancel()
lyricSyncTask = nil
}
func toggleLyrics() {
// Toggle the UI state first so the views can react immediately.
showLyrics.toggle()
// If lyrics are requested to be shown but we don't have any yet,
// show a loading placeholder and start fetching asynchronously.
if showLyrics && syncedLyrics.isEmpty {
// Provide immediate feedback so the UI can show a loading state.
currentLyrics = "Loading lyrics..."
Task {
await fetchLyrics()
// If fetch completed but no lyrics were found, show a friendly message.
await MainActor.run {
if self.syncedLyrics.isEmpty && self.currentLyrics.isEmpty {
self.currentLyrics = "No lyrics found"
}
}
}
}
}
}
// MARK: - Media Branding
extension MusicManager {
var brandAccentColor: Color {
Self.brandAccentColor(for: Defaults[.mediaController], bundleIdentifier: bundleIdentifier)
}
private static func brandAccentColor(for controller: MediaControllerType, bundleIdentifier: String?) -> Color {
switch controller {
case .appleMusic:
return appleMusicPink
case .spotify:
return spotifyGreen
case .nowPlaying:
if let bundleIdentifier,
let bundleColor = brandAccentColor(forBundleIdentifier: bundleIdentifier) {
return bundleColor
}
fallthrough
case .youtubeMusic:
return .accentColor
}
}
private static func brandAccentColor(forBundleIdentifier bundleIdentifier: String) -> Color? {
switch bundleIdentifier {
case "com.apple.Music":
return appleMusicPink
case "com.spotify.client":
return spotifyGreen
default:
return nil
}
}
private static let appleMusicPink = Color(red: 0.999, green: 0.171, blue: 0.331)
private static let spotifyGreen = Color(red: 0.0, green: 0.857, blue: 0.302)
}
// MARK: - Album Art Flip Helper
private struct AlbumArtFlipModifier: ViewModifier {
let angle: Double
private var normalizedAngle: Double {
var value = angle.truncatingRemainder(dividingBy: 360)
if value < 0 { value += 360 }
return value
}
private var mirrorScale: CGFloat {
(normalizedAngle > 90 && normalizedAngle < 270) ? -1 : 1
}
func body(content: Content) -> some View {
content
.rotation3DEffect(
.degrees(angle),
axis: (x: 0, y: 1, z: 0),
perspective: 0.65
)
.scaleEffect(x: mirrorScale, y: 1)
}
}
extension View {
func albumArtFlip(angle: Double) -> some View {
modifier(AlbumArtFlipModifier(angle: angle))
}
}