mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Atoll: fullscreen album artwork as lockscreen wallpaper
Right-click on the album art in the lock screen music panel to set it as the desktop wallpaper. Right-click again or click anywhere on the background to restore the original wallpaper. Wallpaper also restores automatically on track change or screen unlock. Uses direct plist manipulation of the wallpaper config to avoid leaving traces in the wallpaper picker. Album art PNG is pre-cached in the background on track change for instant application. Adds video artwork URL fetching from iTunes catalog for Apple Music tracks (musicVideo entity search). Signed-off-by: Jis G Jacob <studiokeys@blissroms.org>
This commit is contained in:
parent
78bf018fe2
commit
5bd84270aa
12 changed files with 1946 additions and 83 deletions
|
|
@ -46,6 +46,7 @@ struct LockScreenMusicPanel: View {
|
|||
@State private var isExpanded = false
|
||||
@State private var isVolumeSliderVisible = false
|
||||
@State private var isAirPlayPopoverPresented = false
|
||||
@State private var isArtworkFullscreen = false
|
||||
|
||||
@State private var collapseWorkItem: DispatchWorkItem?
|
||||
@State private var parallaxResumeWorkItem: DispatchWorkItem?
|
||||
|
|
@ -66,6 +67,8 @@ struct LockScreenMusicPanel: View {
|
|||
@Default(.enableLyrics) private var enableLyrics
|
||||
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
|
||||
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
|
||||
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var fullscreenArtworkEnabled
|
||||
@Default(.lockScreenMusicFullscreenVideoArtwork) private var fullscreenVideoArtwork
|
||||
|
||||
init(animator: LockScreenPanelAnimator) {
|
||||
_animator = ObservedObject(wrappedValue: animator)
|
||||
|
|
@ -163,6 +166,11 @@ struct LockScreenMusicPanel: View {
|
|||
parallaxResumeWorkItem = nil
|
||||
isParallaxSuspended = false
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .atollArtworkWallpaperDismissed)) { _ in
|
||||
withAnimation(.easeInOut(duration: 0.28)) {
|
||||
isArtworkFullscreen = false
|
||||
}
|
||||
}
|
||||
.onChange(of: isExpanded) { _, expanded in
|
||||
updatePanelSize()
|
||||
}
|
||||
|
|
@ -329,30 +337,43 @@ struct LockScreenMusicPanel: View {
|
|||
}
|
||||
|
||||
private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View {
|
||||
Button(action: toggleExpanded) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
albumArtImage(size: size, cornerRadius: cornerRadius)
|
||||
if showAppIcon, let icon = lockScreenAppIcon {
|
||||
icon
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: appIconSize, height: appIconSize)
|
||||
|
||||
.shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4)
|
||||
.offset(x: appIconOffset, y: appIconOffset)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
if isArtworkFullscreen {
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.fill(Color.white.opacity(0.06))
|
||||
.overlay(
|
||||
Image(systemName: "photo.fill")
|
||||
.font(.system(size: size * 0.3, weight: .light))
|
||||
.foregroundColor(.white.opacity(0.2))
|
||||
)
|
||||
} else {
|
||||
albumArtImage(size: size, cornerRadius: cornerRadius)
|
||||
if showAppIcon, let icon = lockScreenAppIcon {
|
||||
icon
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: appIconSize, height: appIconSize)
|
||||
.shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4)
|
||||
.offset(x: appIconOffset, y: appIconOffset)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
.albumArtFlip(angle: musicManager.flipAngle)
|
||||
.parallax3D(enableOverride: lockScreenParallaxEnabled, suspended: isParallaxSuspended)
|
||||
.frame(width: size)
|
||||
.background(albumArtBackground(cornerRadius: cornerRadius))
|
||||
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? appIconCornerRadius/3 : appIconCornerRadius, style: .continuous))
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.albumArtFlip(angle: isArtworkFullscreen ? 0 : musicManager.flipAngle)
|
||||
.parallax3D(enableOverride: lockScreenParallaxEnabled && !isArtworkFullscreen, suspended: isParallaxSuspended)
|
||||
.frame(width: size)
|
||||
.background(albumArtBackground(cornerRadius: cornerRadius))
|
||||
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? appIconCornerRadius/3 : appIconCornerRadius, style: .continuous))
|
||||
.opacity(musicManager.isPlaying ? 1 : 0.4)
|
||||
.scaleEffect(musicManager.isPlaying ? 1 : 0.85)
|
||||
.animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying)
|
||||
.animation(.easeInOut(duration: 0.28), value: isArtworkFullscreen)
|
||||
.onTapGesture {
|
||||
toggleExpanded()
|
||||
}
|
||||
.onRightClick {
|
||||
expandArtworkToFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -384,6 +405,41 @@ struct LockScreenMusicPanel: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func expandArtworkToFullscreen() {
|
||||
guard fullscreenArtworkEnabled else { return }
|
||||
|
||||
if FullScreenArtworkWindowManager.shared.isShowing {
|
||||
FullScreenArtworkWindowManager.shared.hide()
|
||||
withAnimation(.easeInOut(duration: 0.28)) {
|
||||
isArtworkFullscreen = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard musicManager.hasActiveSession else { return }
|
||||
|
||||
let artwork = musicManager.albumArt
|
||||
let videoURL = musicManager.videoArtworkURL
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.28)) {
|
||||
isArtworkFullscreen = true
|
||||
}
|
||||
|
||||
FullScreenArtworkWindowManager.shared.onDismiss = {
|
||||
Task { @MainActor in
|
||||
withAnimation(.easeInOut(duration: 0.28)) {
|
||||
NotificationCenter.default.post(name: .atollArtworkWallpaperDismissed, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FullScreenArtworkWindowManager.shared.show(
|
||||
artwork: artwork,
|
||||
videoURL: fullscreenVideoArtwork ? videoURL : nil,
|
||||
allowLiveWallpaper: fullscreenVideoArtwork
|
||||
)
|
||||
}
|
||||
|
||||
private func registerInteraction() {
|
||||
cancelCollapseTimer()
|
||||
guard isExpanded else { return }
|
||||
|
|
@ -1384,3 +1440,48 @@ private struct GlassTextBackdrop: View {
|
|||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Right Click Gesture
|
||||
|
||||
private struct RightClickOverlay: ViewModifier {
|
||||
let action: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.overlay(
|
||||
RightClickReceiverView(action: action)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RightClickReceiverView: NSViewRepresentable {
|
||||
let action: () -> Void
|
||||
|
||||
func makeNSView(context: Context) -> RightClickNSView {
|
||||
let view = RightClickNSView()
|
||||
view.onRightClick = action
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: RightClickNSView, context: Context) {
|
||||
nsView.onRightClick = action
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func onRightClick(perform action: @escaping () -> Void) -> some View {
|
||||
modifier(RightClickOverlay(action: action))
|
||||
}
|
||||
}
|
||||
|
||||
final class RightClickNSView: NSView {
|
||||
var onRightClick: (() -> Void)?
|
||||
|
||||
override func rightMouseDown(with event: NSEvent) {
|
||||
onRightClick?()
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let atollArtworkWallpaperDismissed = Notification.Name("atollArtworkWallpaperDismissed")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2505,6 +2505,11 @@ struct Media: View {
|
|||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
if mediaController == .spotify {
|
||||
SpotifyAuthSettingsSection()
|
||||
}
|
||||
|
||||
Section {
|
||||
Defaults.Toggle(key: .showStandardMediaControls) {
|
||||
Text("Show media controls in Dynamic Island")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
/*
|
||||
* Atoll (DynamicIsland)
|
||||
* Copyright (C) 2024-2026 Atoll Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import AppKit
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct SpotifyAuthSettingsSection: View {
|
||||
@Default(.spotifySPDCCookie) private var spotifySPDCCookie
|
||||
@ObservedObject private var spotifyAuthManager = SpotifyAuthManager.shared
|
||||
|
||||
private var hasCookie: Bool {
|
||||
!SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paste the `sp_dc` cookie from your logged-in Spotify web session.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("sp_dc cookie", text: $spotifySPDCCookie, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.lineLimit(2...4)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(spotifyAuthManager.isAuthenticated ? Color.green : (hasCookie ? Color.orange : Color.secondary))
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text(spotifyAuthManager.sessionStatusText)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let authErrorMessage = spotifyAuthManager.authErrorMessage, !authErrorMessage.isEmpty {
|
||||
Text(authErrorMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Paste from Clipboard") {
|
||||
pasteCookieFromClipboard()
|
||||
}
|
||||
|
||||
Button(spotifyAuthManager.isAuthorizing ? "Validating..." : "Validate Cookie") {
|
||||
spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie)
|
||||
Task {
|
||||
await spotifyAuthManager.validateSession()
|
||||
}
|
||||
}
|
||||
.disabled(!hasCookie || spotifyAuthManager.isAuthorizing)
|
||||
|
||||
Button("Clear") {
|
||||
spotifySPDCCookie = ""
|
||||
spotifyAuthManager.clearSession()
|
||||
}
|
||||
.disabled(!hasCookie && !spotifyAuthManager.isAuthenticated)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("How to get it:")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("1. Open Spotify in a browser and log in")
|
||||
.font(.caption)
|
||||
Text("2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com")
|
||||
.font(.caption)
|
||||
Text("3. Copy the value of `sp_dc` and paste it here")
|
||||
.font(.caption)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Link("Open Spotify Web Player", destination: URL(string: "https://open.spotify.com")!)
|
||||
Link("Method source", destination: URL(string: "https://github.com/Paxsenix0/Spotify-Canvas-API")!)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
} header: {
|
||||
Text("Spotify Canvas Session")
|
||||
} footer: {
|
||||
Text("Atoll uses the local `sp_dc` cookie only to request Spotify's internal web-player token and fetch the matching Canvas for the current track.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
private func pasteCookieFromClipboard() {
|
||||
guard let clipboardText = NSPasteboard.general.string(forType: .string) else { return }
|
||||
spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(clipboardText)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue