new version

This commit is contained in:
delli 2026-05-08 19:50:26 +02:00
commit c8a6c2b1fa
10 changed files with 1531 additions and 187 deletions

View file

@ -33289,6 +33289,9 @@
} }
} }
} }
},
"Replaces the artwork tile with the live canvas when the current app provides one, and reuses that moving canvas for the surrounding lighting effect." : {
}, },
"Request Access" : { "Request Access" : {
"localizations" : { "localizations" : {
@ -34267,7 +34270,7 @@
} }
} }
}, },
"Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. The swap is temporary and leaves no trace in System Settings." : { "Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas." : {
}, },
"Ring" : { "Ring" : {
@ -37328,6 +37331,9 @@
} }
} }
} }
},
"Show live canvas in Dynamic Island" : {
}, },
"Show location label" : { "Show location label" : {
"localizations" : { "localizations" : {
@ -43392,6 +43398,9 @@
} }
} }
} }
},
"Use album art layout over fullscreen canvas" : {
}, },
"Use circular battery indicator" : { "Use circular battery indicator" : {
"localizations" : { "localizations" : {

View file

@ -166,9 +166,12 @@ class SpotifyController: MediaControllerProtocol {
state.artwork = existingArtwork state.artwork = existingArtwork
} }
if trackURI == currentCanvasTrackURI { let isSameCanvasTrack = trackURI == currentCanvasTrackURI
if isSameCanvasTrack {
state.liveArtworkURL = playbackState.liveArtworkURL state.liveArtworkURL = playbackState.liveArtworkURL
} else { } else {
state.liveArtworkURL = nil
currentCanvasTrackURI = trackURI.isEmpty ? nil : trackURI currentCanvasTrackURI = trackURI.isEmpty ? nil : trackURI
canvasFetchTask?.cancel() canvasFetchTask?.cancel()
canvasFetchTask = nil canvasFetchTask = nil
@ -185,6 +188,7 @@ class SpotifyController: MediaControllerProtocol {
artworkFetchTask?.cancel() artworkFetchTask?.cancel()
let currentState = state let currentState = state
let expectedTrackURI = trackURI
artworkFetchTask = Task { artworkFetchTask = Task {
do { do {
@ -192,6 +196,10 @@ class SpotifyController: MediaControllerProtocol {
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self = self else { return } guard let self = self else { return }
guard expectedTrackURI.isEmpty || self.currentCanvasTrackURI == expectedTrackURI else {
self.artworkFetchTask = nil
return
}
var updatedState = currentState var updatedState = currentState
updatedState.artwork = data updatedState.artwork = data
updatedState.liveArtworkURL = self.playbackState.liveArtworkURL updatedState.liveArtworkURL = self.playbackState.liveArtworkURL

View file

@ -36,6 +36,7 @@ struct LockScreenMusicPanel: View {
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject private var routeManager = AudioRouteManager.shared @ObservedObject private var routeManager = AudioRouteManager.shared
@ObservedObject private var fullscreenArtworkManager = FullScreenArtworkWindowManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel() @StateObject private var volumeModel = MediaOutputVolumeViewModel()
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@ObservedObject private var animator: LockScreenPanelAnimator @ObservedObject private var animator: LockScreenPanelAnimator
@ -122,6 +123,22 @@ struct LockScreenMusicPanel: View {
private var usesEnhancedCustomLiquidBorder: Bool { private var usesEnhancedCustomLiquidBorder: Bool {
usesCustomLiquidGlass && useEnhancedLiquidBorder usesCustomLiquidGlass && useEnhancedLiquidBorder
} }
private var hidesInlineArtworkForSpotifyCanvasFallback: Bool {
fullscreenArtworkManager.isShowingSpotifyCanvasFallback
}
private var usesSpotifyCanvasFallbackContentPresentation: Bool {
fullscreenArtworkManager.isShowingSpotifyCanvasFallback
}
private var showsDetachedFullscreenLyrics: Bool {
usesSpotifyCanvasFallbackContentPresentation && enableLyrics
}
private var shouldShowInlineLyrics: Bool {
enableLyrics && !showsDetachedFullscreenLyrics
}
var body: some View { var body: some View {
if isActive && musicManager.hasActiveSession { if isActive && musicManager.hasActiveSession {
@ -140,12 +157,17 @@ struct LockScreenMusicPanel: View {
.frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading) .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading)
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
.overlay { .overlay {
if showPanelBorder { if showPanelBorder && !usesSpotifyCanvasFallbackContentPresentation {
panelBorderOverlay panelBorderOverlay
} }
} }
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) .shadow(
.contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) color: usesSpotifyCanvasFallbackContentPresentation ? .clear : Color.black.opacity(0.3),
radius: usesSpotifyCanvasFallbackContentPresentation ? 0 : 20,
x: 0,
y: usesSpotifyCanvasFallbackContentPresentation ? 0 : 10
)
.contentShape(Rectangle())
.animation(.easeInOut(duration: 0.28), value: isExpanded) .animation(.easeInOut(duration: 0.28), value: isExpanded)
.animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider) .animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
.onAppear { .onAppear {
@ -231,9 +253,13 @@ struct LockScreenMusicPanel: View {
collapsedLayout collapsedLayout
} }
} }
.padding(.horizontal, isExpanded ? 24 : 20) .padding(.horizontal, usesSpotifyCanvasFallbackContentPresentation ? 8 : (isExpanded ? 24 : 20))
.padding(.vertical, isExpanded ? 22 : 16) .padding(.vertical, usesSpotifyCanvasFallbackContentPresentation ? 0 : (isExpanded ? 22 : 16))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: usesSpotifyCanvasFallbackContentPresentation ? .center : .topLeading
)
} }
private var collapsedLayout: some View { private var collapsedLayout: some View {
@ -248,25 +274,40 @@ struct LockScreenMusicPanel: View {
} }
private var expandedLayout: some View { private var expandedLayout: some View {
HStack(alignment: .center, spacing: expandedContentSpacing) { Group {
albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius) if hidesInlineArtworkForSpotifyCanvasFallback {
.frame(width: 230, height: 230) VStack(alignment: .leading, spacing: 20) {
expandedHeader
progressBar
.padding(.top, 10)
.frame(maxWidth: .infinity)
playbackControls(alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
HStack(alignment: .center, spacing: expandedContentSpacing) {
albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius)
.frame(width: 230, height: 230)
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
expandedHeader expandedHeader
progressBar progressBar
.padding(.top, 10) .padding(.top, 10)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
playbackControls(alignment: .leading) playbackControls(alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
.frame(maxHeight: .infinity, alignment: .center) .frame(maxHeight: .infinity, alignment: .center)
} }
private var collapsedHeader: some View { private var collapsedHeader: some View {
HStack(alignment: .center, spacing: 16) { HStack(alignment: .center, spacing: hidesInlineArtworkForSpotifyCanvasFallback ? 10 : 16) {
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius) if !hidesInlineArtworkForSpotifyCanvasFallback {
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius)
}
GeometryReader { geo in GeometryReader { geo in
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
@ -338,9 +379,11 @@ struct LockScreenMusicPanel: View {
} }
private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View { private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View {
ZStack(alignment: .bottomTrailing) { let artworkCornerRadius = resolvedArtworkCornerRadius(from: cornerRadius)
return ZStack(alignment: .bottomTrailing) {
if isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork { if isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) RoundedRectangle(cornerRadius: artworkCornerRadius, style: .continuous)
.fill(Color.white.opacity(0.06)) .fill(Color.white.opacity(0.06))
.overlay( .overlay(
Image(systemName: "photo.fill") Image(systemName: "photo.fill")
@ -366,8 +409,8 @@ struct LockScreenMusicPanel: View {
suspended: isParallaxSuspended suspended: isParallaxSuspended
) )
.frame(width: size) .frame(width: size)
.background(albumArtBackground(cornerRadius: cornerRadius)) .background(albumArtBackground(cornerRadius: artworkCornerRadius))
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? appIconCornerRadius/3 : appIconCornerRadius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: artworkCornerRadius, style: .continuous))
.opacity(musicManager.isPlaying ? 1 : 0.4) .opacity(musicManager.isPlaying ? 1 : 0.4)
.scaleEffect(musicManager.isPlaying ? 1 : 0.85) .scaleEffect(musicManager.isPlaying ? 1 : 0.85)
.animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying) .animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying)
@ -544,7 +587,7 @@ struct LockScreenMusicPanel: View {
private func playbackControls(alignment: Alignment) -> some View { private func playbackControls(alignment: Alignment) -> some View {
let spacing: CGFloat = isExpanded ? 24 : 20 let spacing: CGFloat = isExpanded ? 24 : 20
let verticalSpacing: CGFloat = (shouldShowVolumeSlider || enableLyrics) ? 14 : 10 let verticalSpacing: CGFloat = (shouldShowVolumeSlider || shouldShowInlineLyrics) ? 14 : 10
return VStack(spacing: verticalSpacing) { return VStack(spacing: verticalSpacing) {
controlsRow(alignment: alignment, spacing: spacing) controlsRow(alignment: alignment, spacing: spacing)
@ -565,7 +608,7 @@ struct LockScreenMusicPanel: View {
} }
} }
if enableLyrics { if shouldShowInlineLyrics {
lyricsSection(alignment: alignment) lyricsSection(alignment: alignment)
.transition(.move(edge: .top).combined(with: .opacity)) .transition(.move(edge: .top).combined(with: .opacity))
} }
@ -1009,7 +1052,7 @@ struct LockScreenMusicPanel: View {
} }
private var lyricsExtraHeight: CGFloat { private var lyricsExtraHeight: CGFloat {
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics) lyricsHeight(forExpanded: isExpanded, enabled: shouldShowInlineLyrics)
} }
private var accessorySectionExtraHeight: CGFloat { private var accessorySectionExtraHeight: CGFloat {
@ -1039,7 +1082,7 @@ struct LockScreenMusicPanel: View {
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat { private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) + sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
accessorySectionExtraHeight + accessorySectionExtraHeight +
lyricsHeight(forExpanded: expanded, enabled: enableLyrics) lyricsHeight(forExpanded: expanded, enabled: shouldShowInlineLyrics)
} }
private func updatePanelSize(animated: Bool = true) { private func updatePanelSize(animated: Bool = true) {
@ -1094,7 +1137,11 @@ struct LockScreenMusicPanel: View {
@ViewBuilder @ViewBuilder
private var panelBackgroundLayer: some View { private var panelBackgroundLayer: some View {
if usesCustomLiquidGlass { if usesSpotifyCanvasFallbackContentPresentation {
Color.clear
.allowsHitTesting(false)
.accessibilityHidden(true)
} else if usesCustomLiquidGlass {
customLiquidPanelBackdrop customLiquidPanelBackdrop
} else if usesStandardLiquidGlass { } else if usesStandardLiquidGlass {
standardLiquidPanelBackdrop standardLiquidPanelBackdrop
@ -1192,7 +1239,8 @@ struct LockScreenMusicPanel: View {
Image(nsImage: musicManager.albumArt) Image(nsImage: musicManager.albumArt)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? cornerRadius/3 : cornerRadius)) .compositingGroup()
.clipShape(RoundedRectangle(cornerRadius: resolvedArtworkCornerRadius(from: cornerRadius), style: .continuous))
} }
@ViewBuilder @ViewBuilder
@ -1230,6 +1278,13 @@ struct LockScreenMusicPanel: View {
isExpanded ? 18 : 12 isExpanded ? 18 : 12
} }
private func resolvedArtworkCornerRadius(from baseCornerRadius: CGFloat) -> CGFloat {
let aspectRatio = musicManager.albumArt.size.height > 0
? musicManager.albumArt.size.width / musicManager.albumArt.size.height
: 1
return aspectRatio > 1.0 ? baseCornerRadius / 3 : baseCornerRadius
}
@available(macOS 26.0, *) @available(macOS 26.0, *)
private func clearLiquidGlassSurface(cornerRadius: CGFloat) -> some View { private func clearLiquidGlassSurface(cornerRadius: CGFloat) -> some View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
@ -1480,9 +1535,48 @@ private extension View {
final class RightClickNSView: NSView { final class RightClickNSView: NSView {
var onRightClick: (() -> Void)? var onRightClick: (() -> Void)?
private var eventMonitor: Any?
override func rightMouseDown(with event: NSEvent) { deinit {
onRightClick?() removeEventMonitor()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
removeEventMonitor()
} else {
installEventMonitorIfNeeded()
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
private func installEventMonitorIfNeeded() {
guard eventMonitor == nil else { return }
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] event in
guard let self, let window = self.window, event.window === window else {
return event
}
let pointInView = self.convert(event.locationInWindow, from: nil)
guard self.bounds.contains(pointInView) else {
return event
}
self.onRightClick?()
return nil
}
}
private func removeEventMonitor() {
guard let eventMonitor else { return }
NSEvent.removeMonitor(eventMonitor)
self.eventMonitor = nil
} }
} }

View file

@ -1113,8 +1113,17 @@ private struct MinimalisticReminderDetailsView: View {
struct MinimalisticAlbumArtView: View { struct MinimalisticAlbumArtView: View {
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject var vm: DynamicIslandViewModel @ObservedObject var vm: DynamicIslandViewModel
@Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland
let albumArtNamespace: Namespace.ID let albumArtNamespace: Namespace.ID
private var usesLiveCanvasArtwork: Bool {
showLiveCanvasInDynamicIsland && musicManager.videoArtworkURL != nil
}
private var albumArtCornerRadius: CGFloat {
musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0 ? 4 : 12
}
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
if Defaults[.lightingEffect] { if Defaults[.lightingEffect] {
@ -1128,16 +1137,27 @@ struct MinimalisticAlbumArtView: View {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.background( .background(
Image(nsImage: musicManager.albumArt) DynamicIslandArtworkSourceView(
.resizable() cornerRadius: albumArtCornerRadius,
.scaledToFill() contentMode: .fill
)
) )
.clipped() .clipped()
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: albumArtCornerRadius))
.scaleEffect(x: 1.3, y: 1.4) .scaleEffect(x: 1.3, y: 1.4)
.rotationEffect(.degrees(92)) .rotationEffect(.degrees(92))
.blur(radius: 35) .blur(radius: 35)
.opacity(min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3))) .opacity(
usesLiveCanvasArtwork
? (musicManager.isPlaying ? 0.68 : 0.22)
: min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3))
)
.shadow(
color: Color(nsColor: musicManager.avgColor).opacity(usesLiveCanvasArtwork ? 0.28 : 0.16),
radius: usesLiveCanvasArtwork ? 18 : 12,
x: 0,
y: 0
)
} }
private var albumArtButton: some View { private var albumArtButton: some View {
@ -1147,13 +1167,10 @@ struct MinimalisticAlbumArtView: View {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.background( .background(
DynamicIslandArtworkSourceView(
Image(nsImage: musicManager.albumArt) cornerRadius: albumArtCornerRadius,
.resizable() contentMode: .fit
.aspectRatio(contentMode: .fit) )
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? 4 : 12))
) )
.clipped() .clipped()
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)

View file

@ -23,6 +23,112 @@
import Combine import Combine
import Defaults import Defaults
import SwiftUI import SwiftUI
import AppKit
import AVFoundation
private final class DynamicIslandArtworkLoopController {
let player: AVQueuePlayer
private var looper: AVPlayerLooper?
init(url: URL) {
let item = AVPlayerItem(url: url)
player = AVQueuePlayer()
player.isMuted = true
player.actionAtItemEnd = .none
looper = AVPlayerLooper(player: player, templateItem: item)
player.play()
}
deinit {
player.pause()
looper = nil
}
}
private final class DynamicIslandArtworkVideoContainerView: NSView {
let playerLayer = AVPlayerLayer()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer?.backgroundColor = NSColor.clear.cgColor
playerLayer.videoGravity = .resizeAspectFill
playerLayer.backgroundColor = NSColor.clear.cgColor
layer?.addSublayer(playerLayer)
}
required init?(coder: NSCoder) {
nil
}
override func layout() {
super.layout()
playerLayer.frame = bounds
}
}
private struct DynamicIslandArtworkVideoView: NSViewRepresentable {
let url: URL
let videoGravity: AVLayerVideoGravity
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> DynamicIslandArtworkVideoContainerView {
let view = DynamicIslandArtworkVideoContainerView(frame: .zero)
context.coordinator.attach(layer: view.playerLayer, url: url, gravity: videoGravity)
return view
}
func updateNSView(_ nsView: DynamicIslandArtworkVideoContainerView, context: Context) {
context.coordinator.attach(layer: nsView.playerLayer, url: url, gravity: videoGravity)
}
final class Coordinator {
private var controller: DynamicIslandArtworkLoopController?
private var currentURL: URL?
func attach(layer: AVPlayerLayer, url: URL, gravity: AVLayerVideoGravity) {
layer.videoGravity = gravity
if currentURL != url || controller == nil {
currentURL = url
controller = DynamicIslandArtworkLoopController(url: url)
}
if layer.player !== controller?.player {
layer.player = controller?.player
}
}
}
}
struct DynamicIslandArtworkSourceView: View {
@ObservedObject private var musicManager = MusicManager.shared
@Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland
let cornerRadius: CGFloat
let contentMode: ContentMode
private var liveCanvasURL: URL? {
guard showLiveCanvasInDynamicIsland else { return nil }
return musicManager.videoArtworkURL
}
var body: some View {
Group {
if let liveCanvasURL {
DynamicIslandArtworkVideoView(url: liveCanvasURL, videoGravity: .resizeAspectFill)
} else {
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: contentMode)
}
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}
}
// MARK: - Music Player Components // MARK: - Music Player Components
@ -43,8 +149,23 @@ struct MusicPlayerView: View {
struct AlbumArtView: View { struct AlbumArtView: View {
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject var vm: DynamicIslandViewModel @ObservedObject var vm: DynamicIslandViewModel
@Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland
let albumArtNamespace: Namespace.ID let albumArtNamespace: Namespace.ID
private var usesLiveCanvasArtwork: Bool {
showLiveCanvasInDynamicIsland && musicManager.videoArtworkURL != nil
}
private var albumArtCornerRadius: CGFloat {
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
}
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
if Defaults[.lightingEffect] { if Defaults[.lightingEffect] {
@ -58,24 +179,26 @@ struct AlbumArtView: View {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.background( .background(
Image(nsImage: musicManager.albumArt) DynamicIslandArtworkSourceView(
.resizable() cornerRadius: albumArtCornerRadius,
.aspectRatio(contentMode: .fit) contentMode: .fill
.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() .clipped()
.scaleEffect(x: 1.3, y: 1.4) .scaleEffect(x: 1.3, y: 1.4)
.rotationEffect(.degrees(92)) .rotationEffect(.degrees(92))
.blur(radius: 40) .blur(radius: 40)
.opacity(musicManager.isPlaying ? 0.5 : 0) .opacity(
usesLiveCanvasArtwork
? (musicManager.isPlaying ? 0.62 : 0.18)
: (musicManager.isPlaying ? 0.5 : 0)
)
.shadow(
color: Color(nsColor: musicManager.avgColor).opacity(usesLiveCanvasArtwork ? 0.24 : 0.16),
radius: usesLiveCanvasArtwork ? 22 : 14,
x: 0,
y: 0
)
} }
private var albumArtButton: some View { private var albumArtButton: some View {
@ -111,18 +234,10 @@ struct AlbumArtView: View {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.overlay { .overlay {
Image(nsImage: musicManager.albumArt) DynamicIslandArtworkSourceView(
.resizable() cornerRadius: albumArtCornerRadius,
.aspectRatio(contentMode: .fit) 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) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
.clipped() .clipped()

View file

@ -763,6 +763,7 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .media, title: "Skip buttons", keywords: ["skip", "controls", "±10"], highlightID: SettingsTab.media.highlightID(for: "Skip buttons")), SettingsSearchEntry(tab: .media, title: "Skip buttons", keywords: ["skip", "controls", "±10"], highlightID: SettingsTab.media.highlightID(for: "Skip buttons")),
SettingsSearchEntry(tab: .media, title: "Sneak Peek Style", keywords: ["sneak peek", "preview"], highlightID: SettingsTab.media.highlightID(for: "Sneak Peek Style")), SettingsSearchEntry(tab: .media, title: "Sneak Peek Style", keywords: ["sneak peek", "preview"], highlightID: SettingsTab.media.highlightID(for: "Sneak Peek Style")),
SettingsSearchEntry(tab: .media, title: "Enable lyrics", keywords: ["lyrics", "song text"], highlightID: SettingsTab.media.highlightID(for: "Enable lyrics")), SettingsSearchEntry(tab: .media, title: "Enable lyrics", keywords: ["lyrics", "song text"], highlightID: SettingsTab.media.highlightID(for: "Enable lyrics")),
SettingsSearchEntry(tab: .media, title: "Show live canvas in Dynamic Island", keywords: ["canvas", "live canvas", "album art", "dynamic island", "spotify canvas"], highlightID: SettingsTab.media.highlightID(for: "Show live canvas in Dynamic Island")),
SettingsSearchEntry(tab: .media, title: "Auto-hide inactive notch media player", keywords: ["auto hide", "inactive", "placeholder", "notch media"], highlightID: SettingsTab.media.highlightID(for: "Auto-hide inactive notch media player")), SettingsSearchEntry(tab: .media, title: "Auto-hide inactive notch media player", keywords: ["auto hide", "inactive", "placeholder", "notch media"], highlightID: SettingsTab.media.highlightID(for: "Auto-hide inactive notch media player")),
SettingsSearchEntry(tab: .media, title: "Show Change Media Output control", keywords: ["airplay", "route picker", "media output"], highlightID: SettingsTab.media.highlightID(for: "Show Change Media Output control")), SettingsSearchEntry(tab: .media, title: "Show Change Media Output control", keywords: ["airplay", "route picker", "media output"], highlightID: SettingsTab.media.highlightID(for: "Show Change Media Output control")),
SettingsSearchEntry(tab: .media, title: "Enable album art parallax", keywords: ["parallax", "lock screen", "album art"], highlightID: SettingsTab.media.highlightID(for: "Enable album art parallax")), SettingsSearchEntry(tab: .media, title: "Enable album art parallax", keywords: ["parallax", "lock screen", "album art"], highlightID: SettingsTab.media.highlightID(for: "Enable album art parallax")),
@ -2609,6 +2610,11 @@ struct Media: View {
Text("Enable lyrics") Text("Enable lyrics")
} }
.settingsHighlight(id: highlightID("Enable lyrics")) .settingsHighlight(id: highlightID("Enable lyrics"))
Defaults.Toggle(key: .showLiveCanvasInDynamicIsland) {
Text("Show live canvas in Dynamic Island")
}
.settingsHighlight(id: highlightID("Show live canvas in Dynamic Island"))
.help("Replaces the artwork tile with the live canvas when the current app provides one, and reuses that moving canvas for the surrounding lighting effect.")
//Parallax Effect Intensity to control how much parallax is wanted //Parallax Effect Intensity to control how much parallax is wanted
Slider(value: $parallaxEffectIntensity, in: 0...12, step: 1.0) { Slider(value: $parallaxEffectIntensity, in: 0...12, step: 1.0) {
@ -2703,12 +2709,17 @@ struct Media: View {
} }
.disabled(!enableLockScreenMediaWidget) .disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Fullscreen artwork on right-click")) .settingsHighlight(id: highlightID("Fullscreen artwork on right-click"))
Defaults.Toggle(key: .lockScreenUseArtworkLayoutOverFullscreenCanvas) {
Text("Use album art layout over fullscreen canvas")
}
.disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled)
.settingsHighlight(id: highlightID("Use album art layout over fullscreen canvas"))
Defaults.Toggle(key: .lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) { Defaults.Toggle(key: .lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) {
Text("Keep album art visible during fullscreen artwork") Text("Keep album art visible during fullscreen artwork")
} }
.disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled) .disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled)
.settingsHighlight(id: highlightID("Keep album art visible during fullscreen artwork")) .settingsHighlight(id: highlightID("Keep album art visible during fullscreen artwork"))
Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. The swap is temporary and leaves no trace in System Settings.") Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@ -4833,12 +4844,17 @@ struct LockScreenSettings: View {
} }
.disabled(!enableLockScreenMediaWidget) .disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Fullscreen artwork on right-click")) .settingsHighlight(id: highlightID("Fullscreen artwork on right-click"))
Defaults.Toggle(key: .lockScreenUseArtworkLayoutOverFullscreenCanvas) {
Text("Use album art layout over fullscreen canvas")
}
.disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled)
.settingsHighlight(id: highlightID("Use album art layout over fullscreen canvas"))
Defaults.Toggle(key: .lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) { Defaults.Toggle(key: .lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) {
Text("Keep album art visible during fullscreen artwork") Text("Keep album art visible during fullscreen artwork")
} }
.disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled) .disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled)
.settingsHighlight(id: highlightID("Keep album art visible during fullscreen artwork")) .settingsHighlight(id: highlightID("Keep album art visible during fullscreen artwork"))
Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. The swap is temporary and leaves no trace in System Settings.") Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)

File diff suppressed because it is too large Load diff

View file

@ -58,13 +58,30 @@ class LockScreenPanelManager {
return formatter.string(from: Date()) return formatter.string(from: Date())
} }
private func publishPanelFrame(_ frame: NSRect?) {
latestFrame = frame
var userInfo: [AnyHashable: Any] = [:]
if let frame {
userInfo["frame"] = NSValue(rect: frame)
}
NotificationCenter.default.post(
name: .atollLockScreenPanelFrameDidChange,
object: self,
userInfo: userInfo
)
}
private func registerScreenChangeObservers() { private func registerScreenChangeObservers() {
screenChangeObserver = NotificationCenter.default.addObserver( screenChangeObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification, forName: NSApplication.didChangeScreenParametersNotification,
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
self?.handleScreenGeometryChange(reason: "screen-parameters") Task { @MainActor [weak self] in
self?.handleScreenGeometryChange(reason: "screen-parameters")
}
} }
let workspaceCenter = NSWorkspace.shared.notificationCenter let workspaceCenter = NSWorkspace.shared.notificationCenter
@ -73,7 +90,9 @@ class LockScreenPanelManager {
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
self?.handleScreenGeometryChange(reason: "screens-did-wake") Task { @MainActor [weak self] in
self?.handleScreenGeometryChange(reason: "screens-did-wake")
}
} }
workspaceObservers = [wakeObserver] workspaceObservers = [wakeObserver]
@ -94,8 +113,9 @@ class LockScreenPanelManager {
} }
let screenFrame = screen.frame let screenFrame = screen.frame
let targetFrame = collapsedFrame(for: screenFrame) let defaultCollapsedFrame = collapsedFrame(for: screenFrame)
collapsedFrame = targetFrame let targetFrame = targetFrame(for: screenFrame, panelSize: LockScreenMusicPanel.collapsedSize)
collapsedFrame = defaultCollapsedFrame
isPanelExpanded = false isPanelExpanded = false
currentAdditionalHeight = 0 currentAdditionalHeight = 0
@ -127,14 +147,14 @@ class LockScreenPanelManager {
} }
window.setFrame(targetFrame, display: true) window.setFrame(targetFrame, display: true)
latestFrame = targetFrame publishPanelFrame(targetFrame)
hideTask?.cancel() hideTask?.cancel()
panelAnimator.isPresented = false panelAnimator.isPresented = false
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false) LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false)
let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator)) let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator))
hosting.frame = NSRect(origin: .zero, size: targetFrame.size) hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
hosting.autoresizingMask = [.width, .height] hosting.autoresizingMask = [.width, .height]
window.contentView = hosting window.contentView = hosting
// Ensure the underlying window content is clipped to rounded corners // Ensure the underlying window content is clipped to rounded corners
@ -162,18 +182,17 @@ class LockScreenPanelManager {
} }
func updatePanelSize(expanded: Bool, additionalHeight: CGFloat = 0, animated: Bool = true) { func updatePanelSize(expanded: Bool, additionalHeight: CGFloat = 0, animated: Bool = true) {
guard let window = panelWindow, let baseFrame = collapsedFrame else { guard let window = panelWindow, let screen = currentScreen() else {
return return
} }
let resizeDuration: CFTimeInterval = 0.28 let resizeDuration: CFTimeInterval = 0.28
let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
let targetWidth = baseSize.width let targetFrame = targetFrame(
let targetHeight = baseSize.height + additionalHeight for: screen.frame,
let originX = baseFrame.midX - (targetWidth / 2) panelSize: CGSize(width: baseSize.width, height: baseSize.height + additionalHeight)
let originY = baseFrame.origin.y )
let targetFrame = NSRect(x: originX, y: originY, width: targetWidth, height: targetHeight)
if animated { if animated {
NSAnimationContext.runAnimationGroup { context in NSAnimationContext.runAnimationGroup { context in
@ -185,7 +204,7 @@ class LockScreenPanelManager {
window.setFrame(targetFrame, display: true) window.setFrame(targetFrame, display: true)
} }
latestFrame = targetFrame publishPanelFrame(targetFrame)
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated) LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated)
@ -229,7 +248,7 @@ class LockScreenPanelManager {
guard let window = panelWindow else { guard let window = panelWindow else {
print("LockScreenPanelManager: no panel to hide") print("LockScreenPanelManager: no panel to hide")
latestFrame = nil publishPanelFrame(nil)
return return
} }
@ -239,7 +258,7 @@ class LockScreenPanelManager {
await MainActor.run { await MainActor.run {
window?.orderOut(nil) window?.orderOut(nil)
window?.contentView = nil window?.contentView = nil
self.latestFrame = nil self.publishPanelFrame(nil)
print("[\(self.timestamp())] LockScreenPanelManager: panel hidden") print("[\(self.timestamp())] LockScreenPanelManager: panel hidden")
} }
} }
@ -267,6 +286,60 @@ class LockScreenPanelManager {
.store(in: &cancellables) .store(in: &cancellables)
} }
private func targetFrame(for screenFrame: NSRect, panelSize: CGSize) -> NSRect {
let defaultCollapsedFrame = collapsedFrame(for: screenFrame)
if FullScreenArtworkWindowManager.shared.isShowingSpotifyCanvasFallback {
return spotifyCanvasFallbackPanelFrame(
screenFrame: screenFrame,
defaultCollapsedFrame: defaultCollapsedFrame,
panelSize: panelSize
)
}
let originX = defaultCollapsedFrame.midX - (panelSize.width / 2)
return NSRect(
x: originX,
y: defaultCollapsedFrame.origin.y,
width: panelSize.width,
height: panelSize.height
)
}
private func spotifyCanvasFallbackPanelFrame(
screenFrame: NSRect,
defaultCollapsedFrame: NSRect,
panelSize: CGSize
) -> NSRect {
let fullscreenArtworkManager = FullScreenArtworkWindowManager.shared
let spacing = fullscreenArtworkManager.spotifyCanvasFallbackInterItemSpacing(screenFrame: screenFrame)
let artworkSide = fullscreenArtworkManager.spotifyCanvasFallbackArtworkSideLength(
screenFrame: screenFrame,
panelSize: panelSize
)
let totalWidth = artworkSide + spacing + panelSize.width
let horizontalMargin: CGFloat = 48
let minGroupOriginX = screenFrame.minX + horizontalMargin
let maxGroupOriginX = max(screenFrame.maxX - horizontalMargin - totalWidth, minGroupOriginX)
let groupOriginX = min(
max(screenFrame.midX - (totalWidth / 2), minGroupOriginX),
maxGroupOriginX
)
let baseCenterY = screenFrame.midY
let largestHalfHeight = max(artworkSide, panelSize.height) / 2
let minCenterY = screenFrame.minY + largestHalfHeight + 56
let maxCenterY = screenFrame.maxY - largestHalfHeight - 84
let centerY = min(max(baseCenterY, minCenterY), maxCenterY)
return NSRect(
x: groupOriginX + artworkSide + spacing,
y: centerY - (panelSize.height / 2),
width: panelSize.width,
height: panelSize.height
)
}
private func collapsedFrame(for screenFrame: NSRect) -> NSRect { private func collapsedFrame(for screenFrame: NSRect) -> NSRect {
let collapsedSize = LockScreenMusicPanel.collapsedSize let collapsedSize = LockScreenMusicPanel.collapsedSize
let originX = screenFrame.midX - (collapsedSize.width / 2) let originX = screenFrame.midX - (collapsedSize.width / 2)
@ -289,3 +362,7 @@ class LockScreenPanelManager {
LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main
} }
} }
extension Notification.Name {
static let atollLockScreenPanelFrameDidChange = Notification.Name("atollLockScreenPanelFrameDidChange")
}

View file

@ -38,6 +38,16 @@ struct LyricLine: Identifiable, Codable {
} }
} }
private struct LyricsLookupKey: Hashable {
let title: String
let artist: String
let album: String
var isValid: Bool {
!title.isEmpty && !artist.isEmpty
}
}
let defaultImage: NSImage = .init( let defaultImage: NSImage = .init(
systemSymbolName: "heart.fill", systemSymbolName: "heart.fill",
accessibilityDescription: "Album Art" accessibilityDescription: "Album Art"
@ -475,6 +485,10 @@ class MusicManager: ObservableObject {
// Task used to periodically sync displayed lyric with playback position // Task used to periodically sync displayed lyric with playback position
private var lyricSyncTask: Task<Void, Never>? private var lyricSyncTask: Task<Void, Never>?
private var lyricsFetchTask: Task<Void, Never>?
private var lyricsFetchKey: LyricsLookupKey?
private var activeLyricsKey: LyricsLookupKey?
private var lyricsCache: [LyricsLookupKey: [LyricLine]] = [:]
private var explicitLookupTask: Task<Void, Never>? private var explicitLookupTask: Task<Void, Never>?
private var explicitLookupKey: String? private var explicitLookupKey: String?
@ -514,6 +528,13 @@ class MusicManager: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
Defaults.publisher(.enableLyrics)
.receive(on: RunLoop.main)
.sink { [weak self] change in
self?.handleLyricsPreferenceChange(isEnabled: change.newValue)
}
.store(in: &cancellables)
// Observe Pear Desktop launch/terminate for auto-detection // Observe Pear Desktop launch/terminate for auto-detection
setupPearDesktopAutoDetection() setupPearDesktopAutoDetection()
@ -582,6 +603,8 @@ class MusicManager: ObservableObject {
public func destroy() { public func destroy() {
debounceIdleTask?.cancel() debounceIdleTask?.cancel()
lyricsFetchTask?.cancel()
lyricSyncTask?.cancel()
explicitLookupTask?.cancel() explicitLookupTask?.cancel()
cancellables.removeAll() cancellables.removeAll()
controllerCancellables.removeAll() controllerCancellables.removeAll()
@ -743,7 +766,7 @@ class MusicManager: ObservableObject {
self.lastArtworkContentIdentifier = state.contentIdentifier self.lastArtworkContentIdentifier = state.contentIdentifier
self.lastArtworkContentURL = state.contentURL self.lastArtworkContentURL = state.contentURL
self.fetchLyrics() self.prepareLyricsForCurrentTrack()
if let liveArtworkURL = state.liveArtworkURL { if let liveArtworkURL = state.liveArtworkURL {
self.videoArtworkURL = liveArtworkURL self.videoArtworkURL = liveArtworkURL
} else { } else {
@ -1252,53 +1275,108 @@ class MusicManager: ObservableObject {
// MARK: - Lyrics Methods // MARK: - Lyrics Methods
func fetchLyrics() { func fetchLyrics() {
guard Defaults[.enableLyrics] else { return } prepareLyricsForCurrentTrack(forceFetch: true, prioritizeVisibleResult: Defaults[.enableLyrics])
// If the lyrics panel is visible already, provide immediate feedback }
if showLyrics {
Task { @MainActor in private func handleLyricsPreferenceChange(isEnabled: Bool) {
self.currentLyrics = "Loading lyrics..." showLyrics = isEnabled
self.syncedLyrics = []
self.currentLyricIndex = -1 if isEnabled {
} prepareLyricsForCurrentTrack(prioritizeVisibleResult: true)
} else {
stopLyricSync()
}
}
private func prepareLyricsForCurrentTrack(forceFetch: Bool = false, prioritizeVisibleResult: Bool = false) {
guard let lookup = currentLyricsLookupContext() else {
activeLyricsKey = nil
lyricsFetchKey = nil
lyricsFetchTask?.cancel()
lyricsFetchTask = nil
syncedLyrics = []
currentLyrics = ""
currentLyricIndex = -1
stopLyricSync()
return
} }
Task { let key = lookup.key
do { let lyricsEnabled = Defaults[.enableLyrics]
let lyrics = try await fetchLyricsFromAPI(artist: artistName, title: songTitle) let shouldShowLoading = lyricsEnabled && prioritizeVisibleResult
await MainActor.run { let trackChanged = activeLyricsKey != key
self.syncedLyrics = lyrics activeLyricsKey = key
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 trackChanged {
if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty { syncedLyrics = []
self.startLyricSync() currentLyricIndex = -1
} else if self.syncedLyrics.isEmpty { currentLyrics = shouldShowLoading ? "Loading lyrics..." : ""
self.stopLyricSync() stopLyricSync()
} }
if !forceFetch, let cachedLyrics = lyricsCache[key] {
applyLyricsToDisplay(cachedLyrics)
return
}
if lyricsFetchKey == key {
if shouldShowLoading && syncedLyrics.isEmpty {
currentLyrics = "Loading lyrics..."
}
return
}
lyricsFetchTask?.cancel()
lyricsFetchKey = key
if shouldShowLoading || (lyricsEnabled && syncedLyrics.isEmpty) {
currentLyrics = "Loading lyrics..."
}
let requestArtist = lookup.requestArtist
let requestTitle = lookup.requestTitle
let requestAlbum = lookup.requestAlbum
lyricsFetchTask = Task { [weak self] in
guard let self else { return }
do {
let lyrics = try await self.fetchLyricsFromAPI(
artist: requestArtist,
title: requestTitle,
album: requestAlbum
)
guard !Task.isCancelled else { return }
await MainActor.run {
guard self.activeLyricsKey == key else { return }
self.lyricsCache[key] = lyrics
self.lyricsFetchKey = nil
self.lyricsFetchTask = nil
self.applyLyricsToDisplay(lyrics)
} }
} catch { } catch {
print("Failed to fetch lyrics: \(error)") print("Failed to fetch lyrics: \(error)")
await MainActor.run { await MainActor.run {
guard self.activeLyricsKey == key else { return }
self.lyricsFetchKey = nil
self.lyricsFetchTask = nil
self.syncedLyrics = [] self.syncedLyrics = []
self.currentLyrics = ""
self.currentLyricIndex = -1 self.currentLyricIndex = -1
self.currentLyrics = lyricsEnabled ? "No lyrics found" : ""
self.stopLyricSync() self.stopLyricSync()
} }
} }
} }
} }
private func fetchLyricsFromAPI(artist: String, title: String) async throws -> [LyricLine] { private func fetchLyricsFromAPI(artist: String, title: String, album: String) async throws -> [LyricLine] {
guard !artist.isEmpty, !title.isEmpty else { return [] } guard !artist.isEmpty, !title.isEmpty else { return [] }
// Normalize input and percent-encode // Normalize input and percent-encode
let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current) let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current)
let cleanTitle = title.folding(options: .diacriticInsensitive, locale: .current) let cleanTitle = title.folding(options: .diacriticInsensitive, locale: .current)
let cleanAlbum = album.folding(options: .diacriticInsensitive, locale: .current)
guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
return [] return []
@ -1312,7 +1390,8 @@ class MusicManager: ObservableObject {
if let http = response as? HTTPURLResponse, http.statusCode == 200 { if let http = response as? HTTPURLResponse, http.statusCode == 200 {
// Try parse as array JSON (preferred) // Try parse as array JSON (preferred)
if let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], if let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
let first = jsonArray.first { let bestMatch = bestLyricsMatch(in: jsonArray, artist: cleanArtist, title: cleanTitle, album: cleanAlbum) {
let first = bestMatch
let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@ -1354,6 +1433,107 @@ class MusicManager: ObservableObject {
} }
} }
private func currentLyricsLookupContext() -> (key: LyricsLookupKey, requestArtist: String, requestTitle: String, requestAlbum: String)? {
let requestArtist = normalizedLyricsRequestComponent(artistName)
let requestTitle = normalizedLyricsTitle(songTitle)
let requestAlbum = normalizedLyricsRequestComponent(album)
let key = LyricsLookupKey(
title: requestTitle.lowercased(),
artist: requestArtist.lowercased(),
album: requestAlbum.lowercased()
)
return key.isValid ? (key, requestArtist, requestTitle, requestAlbum) : nil
}
private func normalizedLyricsRequestComponent(_ value: String) -> String {
value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
}
private func normalizedLyricsTitle(_ value: String) -> String {
var normalized = normalizedLyricsRequestComponent(value)
let cleanupPatterns = [
"\\s*\\((feat\\.?|ft\\.?|featuring)[^\\)]*\\)",
"\\s*\\[(feat\\.?|ft\\.?|featuring)[^\\]]*\\]",
"\\s*-\\s*(feat\\.?|ft\\.?|featuring)\\s+.*$",
"\\s*\\((remaster(ed)?|live|mono|stereo)[^\\)]*\\)$",
"\\s*\\[(remaster(ed)?|live|mono|stereo)[^\\]]*\\]$"
]
for pattern in cleanupPatterns {
normalized = normalized.replacingOccurrences(
of: pattern,
with: "",
options: [.regularExpression, .caseInsensitive]
)
}
return normalizedLyricsRequestComponent(normalized)
}
private func bestLyricsMatch(in results: [[String: Any]], artist: String, title: String, album: String) -> [String: Any]? {
let normalizedArtist = artist.lowercased()
let normalizedTitle = title.lowercased()
let normalizedAlbum = album.lowercased()
return results.max { lhs, rhs in
lyricsMatchScore(for: lhs, artist: normalizedArtist, title: normalizedTitle, album: normalizedAlbum)
< lyricsMatchScore(for: rhs, artist: normalizedArtist, title: normalizedTitle, album: normalizedAlbum)
}
}
private func lyricsMatchScore(for result: [String: Any], artist: String, title: String, album: String) -> Int {
let resultArtist = ((result["artistName"] as? String) ?? "").lowercased()
let resultTitle = ((result["trackName"] as? String) ?? "").lowercased()
let resultAlbum = ((result["albumName"] as? String) ?? "").lowercased()
var score = 0
if resultTitle == title { score += 8 }
else if resultTitle.contains(title) || title.contains(resultTitle) { score += 4 }
if resultArtist == artist { score += 8 }
else if resultArtist.contains(artist) || artist.contains(resultArtist) { score += 4 }
if !album.isEmpty {
if resultAlbum == album { score += 4 }
else if resultAlbum.contains(album) || album.contains(resultAlbum) { score += 2 }
}
if !(result["syncedLyrics"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
score += 3
}
return score
}
private func applyLyricsToDisplay(_ lyrics: [LyricLine]) {
syncedLyrics = lyrics
currentLyricIndex = -1
guard !lyrics.isEmpty else {
currentLyrics = Defaults[.enableLyrics] ? "No lyrics found" : ""
stopLyricSync()
return
}
let playbackPosition = max(estimatedPlaybackPosition(), elapsedTime)
updateCurrentLyric(for: playbackPosition)
if currentLyricIndex == -1, let firstLine = lyrics.first?.text {
currentLyrics = firstLine
}
if Defaults[.enableLyrics] {
startLyricSync()
} else {
stopLyricSync()
}
}
private func parseLRC(_ lrc: String) -> [LyricLine] { private func parseLRC(_ lrc: String) -> [LyricLine] {
let lines = lrc.components(separatedBy: .newlines) let lines = lrc.components(separatedBy: .newlines)
var lyrics: [LyricLine] = [] var lyrics: [LyricLine] = []

View file

@ -913,6 +913,7 @@ extension Defaults.Keys {
static let lockScreenMusicFullscreenArtworkEnabled = Key<Bool>("lockScreenMusicFullscreenArtworkEnabled", default: true) static let lockScreenMusicFullscreenArtworkEnabled = Key<Bool>("lockScreenMusicFullscreenArtworkEnabled", default: true)
static let lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork = Key<Bool>("lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork", default: false) static let lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork = Key<Bool>("lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork", default: false)
static let lockScreenMusicFullscreenVideoArtwork = Key<Bool>("lockScreenMusicFullscreenVideoArtwork", default: true) static let lockScreenMusicFullscreenVideoArtwork = Key<Bool>("lockScreenMusicFullscreenVideoArtwork", default: true)
static let lockScreenUseArtworkLayoutOverFullscreenCanvas = Key<Bool>("lockScreenShowCenteredAlbumArtOverFullscreenCanvas", default: true)
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor) static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center") static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")
@ -1197,6 +1198,7 @@ extension Defaults.Keys {
// MARK: Lyrics Feature // MARK: Lyrics Feature
static let enableLyrics = Key<Bool>("enableLyrics", default: false) static let enableLyrics = Key<Bool>("enableLyrics", default: false)
static let showLiveCanvasInDynamicIsland = Key<Bool>("showLiveCanvasInDynamicIsland", default: false)
// MARK: Notes Feature // MARK: Notes Feature
static let enableNotes = Key<Bool>("enableNotes", default: false) static let enableNotes = Key<Bool>("enableNotes", default: false)