From a80d39e1bfa37a966912886f1b240850d6ae3000 Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Fri, 12 Dec 2025 17:00:33 -0800 Subject: [PATCH 1/3] compat glass effects and webview --- scrobble.xcodeproj/project.pbxproj | 16 +- scrobble/Utils/GlassEffect+Compat.swift | 86 +++++++ scrobble/Views/AppSelectionView.swift | 5 +- scrobble/Views/FriendCardView.swift | 2 +- scrobble/Views/FriendsView.swift | 6 +- scrobble/Views/LastFMAuthSheetView.swift | 9 +- scrobble/Views/LastFMWebAuthView.swift | 2 + scrobble/Views/LastFMWebAuthViewLegacy.swift | 250 +++++++++++++++++++ scrobble/Views/MenuButtonsView.swift | 71 +++--- scrobble/Views/ScrobblingView.swift | 8 +- 10 files changed, 401 insertions(+), 54 deletions(-) create mode 100644 scrobble/Utils/GlassEffect+Compat.swift create mode 100644 scrobble/Views/LastFMWebAuthViewLegacy.swift diff --git a/scrobble.xcodeproj/project.pbxproj b/scrobble.xcodeproj/project.pbxproj index 9e74f86..175fa14 100644 --- a/scrobble.xcodeproj/project.pbxproj +++ b/scrobble.xcodeproj/project.pbxproj @@ -33,6 +33,8 @@ A6A580A82E78DB7F0079DC91 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = A6A580A72E78DB7F0079DC91 /* MediaRemoteAdapter */; }; A6A580A92E78DB9F0079DC91 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = A6A580A72E78DB7F0079DC91 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; A6CC84552EE8470C00236D47 /* MarqueeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6CC84542EE8470C00236D47 /* MarqueeText.swift */; }; + A6COMPAT012EFBAA0000001 /* GlassEffect+Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6COMPAT012EFBAA0000002 /* GlassEffect+Compat.swift */; }; + A6COMPAT012EFBAA0000003 /* LastFMWebAuthViewLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6COMPAT012EFBAA0000004 /* LastFMWebAuthViewLegacy.swift */; }; A6F213002C8C021800E1D23B /* scrobbleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F212FF2C8C021800E1D23B /* scrobbleApp.swift */; }; A6F213022C8C021800E1D23B /* ScrobblingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F213012C8C021800E1D23B /* ScrobblingView.swift */; }; A6F213042C8C021900E1D23B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A6F213032C8C021900E1D23B /* Assets.xcassets */; }; @@ -89,6 +91,8 @@ A6A580A22E78D8480079DC91 /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; }; A6A580A42E78D9340079DC91 /* NowPlayingFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingFetcher.swift; sourceTree = ""; }; A6CC84542EE8470C00236D47 /* MarqueeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarqueeText.swift; sourceTree = ""; }; + A6COMPAT012EFBAA0000002 /* GlassEffect+Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlassEffect+Compat.swift"; sourceTree = ""; }; + A6COMPAT012EFBAA0000004 /* LastFMWebAuthViewLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastFMWebAuthViewLegacy.swift; sourceTree = ""; }; A6F212FC2C8C021800E1D23B /* scrobble.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = scrobble.app; sourceTree = BUILT_PRODUCTS_DIR; }; A6F212FF2C8C021800E1D23B /* scrobbleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = scrobbleApp.swift; sourceTree = ""; }; A6F213012C8C021800E1D23B /* ScrobblingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrobblingView.swift; sourceTree = ""; }; @@ -206,6 +210,7 @@ A6F213012C8C021800E1D23B /* ScrobblingView.swift */, A64304BA2D52EC87001998B6 /* LastFMAuthSheetView.swift */, A65800992E78A56E00A30A42 /* LastFMWebAuthView.swift */, + A6COMPAT012EFBAA0000004 /* LastFMWebAuthViewLegacy.swift */, A6F213102C8C037500E1D23B /* PreferencesView.swift */, A65671EB2EEADB9F00773C58 /* UpdateView.swift */, A66926022EE83DE1000921B1 /* ScrobblingServicesView.swift */, @@ -237,6 +242,7 @@ isa = PBXGroup; children = ( A6F2FEF12EE7A429003826F7 /* Logger.swift */, + A6COMPAT012EFBAA0000002 /* GlassEffect+Compat.swift */, ); path = Utils; sourceTree = ""; @@ -317,6 +323,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A6COMPAT012EFBAA0000001 /* GlassEffect+Compat.swift in Sources */, + A6COMPAT012EFBAA0000003 /* LastFMWebAuthViewLegacy.swift in Sources */, A6F2131C2C8C085900E1D23B /* Music.h in Sources */, A622991D2EE7D72900592239 /* ServicesStatusView.swift in Sources */, A64304C82D52FBEC001998B6 /* AuthState.swift in Sources */, @@ -491,7 +499,7 @@ CODE_SIGN_ENTITLEMENTS = scrobble/scrobble.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"scrobble/Preview Content\""; ENABLE_APP_SANDBOX = NO; @@ -512,7 +520,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = computerdata.scrobble; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -541,7 +549,7 @@ CODE_SIGN_ENTITLEMENTS = scrobble/scrobble.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"scrobble/Preview Content\""; ENABLE_APP_SANDBOX = NO; @@ -562,7 +570,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = computerdata.scrobble; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/scrobble/Utils/GlassEffect+Compat.swift b/scrobble/Utils/GlassEffect+Compat.swift new file mode 100644 index 0000000..8cd14bd --- /dev/null +++ b/scrobble/Utils/GlassEffect+Compat.swift @@ -0,0 +1,86 @@ +// +// GlassEffect+Compat.swift +// scrobble +// +// Created by Brett Henderson on 12/12/25. +// Backwards compatibility layer for glass effects (macOS 15+) +// + +import SwiftUI + +// MARK: - Adaptive Glass Effect Modifier + +extension View { + /// Applies a glass effect on macOS 26+, or an ultrathin material background on macOS 15+ + @ViewBuilder + func compatGlass(in shape: RoundedRectangle = RoundedRectangle(cornerRadius: 8)) -> some View { + if #available(macOS 26, *) { + self.glassEffect(in: shape) + } else { + self.background(.ultraThinMaterial, in: shape) + } + } + + /// Applies a glass effect with custom corner radius + @ViewBuilder + func compatGlass(cornerRadius: CGFloat) -> some View { + if #available(macOS 26, *) { + self.glassEffect(in: .rect(cornerRadius: cornerRadius)) + } else { + self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + } + } + + /// Applies a clear glass effect on macOS 26+, or a subtle overlay on macOS 15+ + @ViewBuilder + func compatGlassClear() -> some View { + if #available(macOS 26, *) { + self.glassEffect(.clear) + } else { + self.background(.ultraThinMaterial.opacity(0.3), in: RoundedRectangle(cornerRadius: 8)) + } + } + + /// Applies scroll edge effect style on macOS 26+, no-op on macOS 15+ + @ViewBuilder + func compatScrollEdgeEffectStyle() -> some View { + if #available(macOS 26, *) { + self.scrollEdgeEffectStyle(.soft, for: .all) + } else { + self + } + } +} + +// MARK: - Adaptive Glass Button Style + +/// On macOS 15, provides a material-based fallback for glass buttons. +struct CompatGlassButtonStyleLegacy: ButtonStyle { + var isSelected: Bool = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color.accentColor.opacity(0.2) : Color.clear) + } + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + .opacity(configuration.isPressed ? 0.8 : 1.0) + } +} + +// MARK: - Button Style Extensions + +extension View { + /// Applies glass button styling with backwards compatibility + @ViewBuilder + func compatGlassButtonStyle(selected: Bool = false) -> some View { + if #available(macOS 26, *) { + self.buttonStyle(selected ? .glass(.regular.tint(.accentColor)) : .glass) + } else { + self.buttonStyle(CompatGlassButtonStyleLegacy(isSelected: selected)) + } + } +} diff --git a/scrobble/Views/AppSelectionView.swift b/scrobble/Views/AppSelectionView.swift index cb3a667..82e3a0e 100644 --- a/scrobble/Views/AppSelectionView.swift +++ b/scrobble/Views/AppSelectionView.swift @@ -92,7 +92,7 @@ struct AppSelectionView: View { } .padding(8) .background(Color.accentColor.opacity(0.1)) - .glassEffect(in: .rect(cornerRadius: 8)) + .compatGlass(cornerRadius: 8) } .padding(.horizontal) @@ -166,8 +166,7 @@ struct AppSelectionButton: View { .padding(.horizontal, 8) } - .buttonStyle(isSelected ? .glass(.identity.tint(.accentColor)) : .glass) - .tint(isSelected ? .accentColor : .none) + .compatGlassButtonStyle(selected: isSelected) } } diff --git a/scrobble/Views/FriendCardView.swift b/scrobble/Views/FriendCardView.swift index e894f2b..2173120 100644 --- a/scrobble/Views/FriendCardView.swift +++ b/scrobble/Views/FriendCardView.swift @@ -99,7 +99,7 @@ struct FriendCardView: View { } .padding() .frame(maxWidth: .infinity, alignment: .leading) - .glassEffect(in: .rect(cornerRadius: 10)) + .compatGlass(cornerRadius: 10) .shadow(radius: 1) } diff --git a/scrobble/Views/FriendsView.swift b/scrobble/Views/FriendsView.swift index 448493a..58b1b52 100644 --- a/scrobble/Views/FriendsView.swift +++ b/scrobble/Views/FriendsView.swift @@ -26,11 +26,11 @@ struct FriendsView: View { Image(systemName: "arrow.clockwise") } .disabled(model.isLoading) - .buttonStyle(.glass) + .compatGlassButtonStyle() } .padding(.horizontal) .padding(.top) - .scrollEdgeEffectStyle(.soft, for: .bottom) + .compatScrollEdgeEffectStyle() if model.isLoading && model.friends.isEmpty { ProgressView() @@ -51,7 +51,7 @@ struct FriendsView: View { } .padding(.horizontal) } - .scrollEdgeEffectStyle(.soft, for: .all) + .compatScrollEdgeEffectStyle() .refreshable { model.refreshData() } diff --git a/scrobble/Views/LastFMAuthSheetView.swift b/scrobble/Views/LastFMAuthSheetView.swift index c8e454d..724dfd9 100644 --- a/scrobble/Views/LastFMAuthSheetView.swift +++ b/scrobble/Views/LastFMAuthSheetView.swift @@ -41,8 +41,13 @@ struct LastFMAuthSheetView: View { .padding() .frame(width: 400, height: 200) } else { - LastFMWebAuthView(lastFmManager: lastFmManager) - .environment(authState) + if #available(macOS 26, *) { + LastFMWebAuthView(lastFmManager: lastFmManager) + .environment(authState) + } else { + LastFMWebAuthViewLegacy(lastFmManager: lastFmManager) + .environment(authState) + } } } } diff --git a/scrobble/Views/LastFMWebAuthView.swift b/scrobble/Views/LastFMWebAuthView.swift index c8f6484..5c338db 100644 --- a/scrobble/Views/LastFMWebAuthView.swift +++ b/scrobble/Views/LastFMWebAuthView.swift @@ -9,6 +9,7 @@ import SwiftUI import WebKit import Observation +@available(macOS 26, *) struct LastFMWebAuthView: View { var lastFmManager: LastFmDesktopManager @Environment(AuthState.self) var authState @@ -180,6 +181,7 @@ struct LastFMWebAuthView: View { } } +@available(macOS 26, *) #Preview { let authState = AuthState() let manager = LastFmDesktopManager(apiKey: "test", apiSecret: "test", username: "test", authState: authState) diff --git a/scrobble/Views/LastFMWebAuthViewLegacy.swift b/scrobble/Views/LastFMWebAuthViewLegacy.swift new file mode 100644 index 0000000..7befbde --- /dev/null +++ b/scrobble/Views/LastFMWebAuthViewLegacy.swift @@ -0,0 +1,250 @@ +// +// LastFMWebAuthViewLegacy.swift +// scrobble +// +// Created by Brett Henderson on 2/4/25. +// +// Legacy WKWebView-based auth view for macOS 15 compatibility + +import SwiftUI +import WebKit + +struct LastFMWebAuthViewLegacy: View { + var lastFmManager: LastFmDesktopManager + @Environment(AuthState.self) var authState + @State private var isLoading = true + @State private var currentURL = "" + + var body: some View { + VStack(spacing: 0) { + // Header + VStack(spacing: 8) { + Text("Last.fm Authentication") + .font(.headline) + + if isLoading { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(0.8) + Text("Loading authorization page...") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + + // WebView + LegacyWebView( + lastFmManager: lastFmManager, + isLoading: $isLoading, + currentURL: $currentURL, + onAuthorizationComplete: { + completeAuthorization() + } + ) + + // Footer + HStack(spacing: 16) { + Button("Cancel") { + authState.isAuthenticating = false + lastFmManager.completeAuthorization(authorized: false) + } + + Spacer() + + if !isLoading { + Button("I've authorized the app") { + completeAuthorization() + } + .keyboardShortcut(.defaultAction) + + Button("Reload") { + NotificationCenter.default.post( + name: NSNotification.Name("LegacyWebViewReload"), + object: nil + ) + } + .buttonStyle(.bordered) + } + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + } + .frame(width: 600, height: 500) + } + + private func completeAuthorization() { + Log.debug("Completing authorization...", category: .ui) + authState.isAuthenticating = true + lastFmManager.completeAuthorization(authorized: true) + } +} + +// MARK: - WKWebView NSViewRepresentable Wrapper + +struct LegacyWebView: NSViewRepresentable { + var lastFmManager: LastFmDesktopManager + @Binding var isLoading: Bool + @Binding var currentURL: String + var onAuthorizationComplete: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .default() + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + context.coordinator.webView = webView + + // Listen for reload notifications + NotificationCenter.default.addObserver( + context.coordinator, + selector: #selector(Coordinator.reload), + name: NSNotification.Name("LegacyWebViewReload"), + object: nil + ) + + // Load auth page + loadAuthPage(webView: webView) + + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) { + // No updates needed + } + + private func loadAuthPage(webView: WKWebView) { + guard !lastFmManager.currentAuthToken.isEmpty else { + return + } + + let authURL = "https://www.last.fm/api/auth/?api_key=\(lastFmManager.apiKey)&token=\(lastFmManager.currentAuthToken)&cb=scrobble://auth" + + if let url = URL(string: authURL) { + let request = URLRequest(url: url) + webView.load(request) + } + } + + // MARK: - Coordinator + + class Coordinator: NSObject, WKNavigationDelegate { + var parent: LegacyWebView + weak var webView: WKWebView? + + init(_ parent: LegacyWebView) { + self.parent = parent + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc func reload() { + guard let webView = webView, + !parent.lastFmManager.currentAuthToken.isEmpty else { return } + + let authURL = "https://www.last.fm/api/auth/?api_key=\(parent.lastFmManager.apiKey)&token=\(parent.lastFmManager.currentAuthToken)&cb=scrobble://auth" + + if let url = URL(string: authURL) { + let request = URLRequest(url: url) + webView.load(request) + } + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + DispatchQueue.main.async { + self.parent.isLoading = true + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + DispatchQueue.main.async { + self.parent.isLoading = false + self.parent.currentURL = webView.url?.absoluteString ?? "" + self.checkIfAuthorizationComplete(webView: webView) + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + DispatchQueue.main.async { + self.parent.isLoading = false + } + Log.error("WebView navigation failed: \(error)", category: .ui) + } + + private func checkIfAuthorizationComplete(webView: WKWebView) { + Log.debug("Checking if authorization is complete...", category: .ui) + + guard let currentURL = webView.url?.absoluteString else { return } + + Log.debug("Current URL: \(currentURL)", category: .ui) + + // Check URL patterns that might indicate success + if currentURL.contains("authorized") || + currentURL.contains("success") || + currentURL.contains("callback") { + Log.debug("Authorization appears complete based on URL", category: .ui) + DispatchQueue.main.async { + self.parent.onAuthorizationComplete() + } + return + } + + // Check page content for authorization indicators + checkPageContentForSuccess(webView: webView) + } + + private func checkPageContentForSuccess(webView: WKWebView) { + let script = """ + (function() { + const bodyText = document.body.innerText.toLowerCase(); + const hasSuccessText = bodyText.includes('authorized') || + bodyText.includes('success') || + bodyText.includes('application has been authorized') || + bodyText.includes('permission granted') || + bodyText.includes('you have successfully authorized'); + + const hasSuccessElement = document.querySelector('.auth-success, .success, .authorized, .permission-granted, .successful') !== null; + + const hasLastFmSuccess = bodyText.includes('application authorized') || + bodyText.includes('app authorized') || + bodyText.includes('successfully granted'); + + return hasSuccessText || hasSuccessElement || hasLastFmSuccess; + })(); + """ + + webView.evaluateJavaScript(script) { [weak self] result, error in + if let error = error { + Log.error("Error checking page content: \(error)", category: .ui) + return + } + + if let isSuccess = result as? Bool, isSuccess { + Log.debug("Detected successful authorization via page content", category: .ui) + DispatchQueue.main.async { + self?.parent.onAuthorizationComplete() + } + } else { + Log.debug("No success indicators found yet", category: .ui) + } + } + } + } +} + +#Preview { + let authState = AuthState() + let manager = LastFmDesktopManager(apiKey: "test", apiSecret: "test", username: "test", authState: authState) + return LastFMWebAuthViewLegacy(lastFmManager: manager) + .environment(authState) +} diff --git a/scrobble/Views/MenuButtonsView.swift b/scrobble/Views/MenuButtonsView.swift index 0ed194b..20e7065 100644 --- a/scrobble/Views/MenuButtonsView.swift +++ b/scrobble/Views/MenuButtonsView.swift @@ -13,45 +13,42 @@ struct MenuButtonsView: View { @Environment(\.openWindow) var openWindow var body: some View { - GlassEffectContainer(spacing: 10) { - HStack(alignment: .center) { - Button { - openWindow(id: "scrobbler") - } label: { - Label("Window", systemImage: "rectangle.expand.vertical" ) - .foregroundStyle(.secondary.opacity(0.7)) - .font(.caption2) - } - .buttonStyle(.glass) - - Spacer() - -// Button { -// openWindow(id: "settings") -// } label: { -// Label("Preferences", systemImage: "gearshape" ) -// .foregroundStyle(.secondary.opacity(0.7)) -// .font(.caption2) -// -// } -// .buttonStyle(.glass) -// .foregroundStyle(.tertiary) - - - Button { - NSApplication.shared.terminate(nil) - } label: { - Label("Quit", systemImage: "xmark.circle" ) - .foregroundStyle(.secondary.opacity(0.7)) - .font(.caption2) - - } - .buttonStyle(.glass) - .foregroundStyle(.tertiary) - + if #available(macOS 26, *) { + GlassEffectContainer(spacing: 10) { + buttonsContent + } + } else { + buttonsContent + .padding(10) + } + } + + private var buttonsContent: some View { + HStack(alignment: .center) { + Button { + openWindow(id: "scrobbler") + } label: { + Label("Window", systemImage: "rectangle.expand.vertical" ) + .foregroundStyle(.secondary.opacity(0.7)) + .font(.caption2) } - .padding(.horizontal) + .compatGlassButtonStyle() + + Spacer() + + Button { + NSApplication.shared.terminate(nil) + } label: { + Label("Quit", systemImage: "xmark.circle" ) + .foregroundStyle(.secondary.opacity(0.7)) + .font(.caption2) + + } + .compatGlassButtonStyle() + .foregroundStyle(.tertiary) + } + .padding(.horizontal) } } diff --git a/scrobble/Views/ScrobblingView.swift b/scrobble/Views/ScrobblingView.swift index 87bb5b8..db428b4 100644 --- a/scrobble/Views/ScrobblingView.swift +++ b/scrobble/Views/ScrobblingView.swift @@ -59,7 +59,7 @@ struct ScrobblingViewStatusCardView: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .glassEffect(in: .rect(cornerRadius: 8)) + .compatGlass(cornerRadius: 8) } } @@ -214,7 +214,7 @@ struct ScrobblingView: View { .environment(scrobbler) } .padding() - .glassEffect(in: .rect(cornerRadius: 8)) + .compatGlass(cornerRadius: 8) .padding(.horizontal) .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: scrobbler.servicesLastUpdated) { _, _ in @@ -245,7 +245,7 @@ struct ScrobblingView: View { .scaledToFit() .frame(width: 50, height: 50) .cornerRadius(8) - .glassEffect(.clear) + .compatGlassClear() } else { RoundedRectangle(cornerRadius: 8) .fill(.quaternary) @@ -284,7 +284,7 @@ struct ScrobblingView: View { } } - .glassEffect(in: .rect(cornerRadius: 8)) + .compatGlass(cornerRadius: 8) .padding(.horizontal) if scrobbler.isScrobbling { From 5a237e222ba3a9a6b57d6d0330782139da45efbf Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Fri, 12 Dec 2025 21:38:27 -0800 Subject: [PATCH 2/3] add launch item config --- scrobble.xcodeproj/project.pbxproj | 4 ++ scrobble/Models/PreferencesManager.swift | 9 ++++ .../Views/LaunchAtLoginSettingsView.swift | 53 +++++++++++++++++++ scrobble/Views/PreferencesView.swift | 2 + scrobble/scrobbleApp.swift | 10 ++++ 5 files changed, 78 insertions(+) create mode 100644 scrobble/Views/LaunchAtLoginSettingsView.swift diff --git a/scrobble.xcodeproj/project.pbxproj b/scrobble.xcodeproj/project.pbxproj index 175fa14..c815a06 100644 --- a/scrobble.xcodeproj/project.pbxproj +++ b/scrobble.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ A6F2FEEC2EE79C16003826F7 /* FriendsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F2FEEB2EE79C16003826F7 /* FriendsModel.swift */; }; A6F2FEEF2EE79E47003826F7 /* Secrets.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = A6F2FEED2EE79E47003826F7 /* Secrets.xcconfig */; }; A6F2FEF22EE7A429003826F7 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F2FEF12EE7A429003826F7 /* Logger.swift */; }; + A6F954FC2EED236000501378 /* LaunchAtLoginSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F954FB2EED236000501378 /* LaunchAtLoginSettingsView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -109,6 +110,7 @@ A6F2FEEB2EE79C16003826F7 /* FriendsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsModel.swift; sourceTree = ""; }; A6F2FEED2EE79E47003826F7 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; A6F2FEF12EE7A429003826F7 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + A6F954FB2EED236000501378 /* LaunchAtLoginSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginSettingsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -213,6 +215,7 @@ A6COMPAT012EFBAA0000004 /* LastFMWebAuthViewLegacy.swift */, A6F213102C8C037500E1D23B /* PreferencesView.swift */, A65671EB2EEADB9F00773C58 /* UpdateView.swift */, + A6F954FB2EED236000501378 /* LaunchAtLoginSettingsView.swift */, A66926022EE83DE1000921B1 /* ScrobblingServicesView.swift */, ); path = Views; @@ -334,6 +337,7 @@ A64B5FAE2E7E0AF1000F1BAE /* SupportedMusicApp.swift in Sources */, A6CC84552EE8470C00236D47 /* MarqueeText.swift in Sources */, A6F213022C8C021800E1D23B /* ScrobblingView.swift in Sources */, + A6F954FC2EED236000501378 /* LaunchAtLoginSettingsView.swift in Sources */, A65671EC2EEADB9F00773C58 /* UpdateView.swift in Sources */, A6A580A52E78D9340079DC91 /* NowPlayingFetcher.swift in Sources */, A63A2B1B2D271A95000BC92E /* ContentView.swift in Sources */, diff --git a/scrobble/Models/PreferencesManager.swift b/scrobble/Models/PreferencesManager.swift index 1923a4a..e3556d2 100644 --- a/scrobble/Models/PreferencesManager.swift +++ b/scrobble/Models/PreferencesManager.swift @@ -76,6 +76,13 @@ class PreferencesManager { didSet { UserDefaults.standard.set(enableLastFm, forKey: "enableLastFm") } } + // App settings + var launchAtLogin: Bool = false { + didSet { + UserDefaults.standard.set(launchAtLogin, forKey: "launchAtLogin") + } + } + private var _isInitialized = false init() { @@ -107,6 +114,8 @@ class PreferencesManager { self.enableCustomScrobbler = UserDefaults.standard.bool(forKey: "enableCustomScrobbler") self.blueskyHandle = UserDefaults.standard.string(forKey: "blueskyHandle") ?? "" + self.launchAtLogin = UserDefaults.standard.bool(forKey: "launchAtLogin") ?? false + // Initialize Last.fm settings if UserDefaults.standard.object(forKey: "enableLastFm") != nil { self.enableLastFm = UserDefaults.standard.bool(forKey: "enableLastFm") diff --git a/scrobble/Views/LaunchAtLoginSettingsView.swift b/scrobble/Views/LaunchAtLoginSettingsView.swift new file mode 100644 index 0000000..3cc0531 --- /dev/null +++ b/scrobble/Views/LaunchAtLoginSettingsView.swift @@ -0,0 +1,53 @@ +// +// LaunchAtLoginSettingsView.swift +// scrobble +// +// Created by Brett Henderson on 12/12/25. +// + +import SwiftUI +import ServiceManagement + +struct LaunchAtLoginSettingsView: View { + @Environment(PreferencesManager.self) var preferencesManager + + var body: some View { + @Bindable var preferencesManager = preferencesManager + + + Section { + VStack(alignment: .leading, spacing: 4) { + Toggle("Launch at Login", isOn: $preferencesManager.launchAtLogin) + Text(""" + when enabled, scrobble will automatically start when you log in to your computer. + """) + .font(.caption) + .foregroundStyle(.secondary) + } + + + } header: { + Text("Startup") + } + .onAppear { + if SMAppService.mainApp.status == .enabled { + preferencesManager.launchAtLogin = true + } else { + preferencesManager.launchAtLogin = false + } + } + .onChange(of: preferencesManager.launchAtLogin) { _, newValue in + if newValue == true { + try? SMAppService.mainApp.register() + } else { + try? SMAppService.mainApp.unregister() + } + } + + } +} + +#Preview { + LaunchAtLoginSettingsView() + .environment(PreferencesManager()) +} diff --git a/scrobble/Views/PreferencesView.swift b/scrobble/Views/PreferencesView.swift index f7d5f35..9af0b0c 100644 --- a/scrobble/Views/PreferencesView.swift +++ b/scrobble/Views/PreferencesView.swift @@ -164,6 +164,8 @@ struct PreferencesView: View { ScrobbleTimingSettingsView() + LaunchAtLoginSettingsView() + Section { ScrobblingServicesView() } header: { diff --git a/scrobble/scrobbleApp.swift b/scrobble/scrobbleApp.swift index c686b66..7fe08e9 100644 --- a/scrobble/scrobbleApp.swift +++ b/scrobble/scrobbleApp.swift @@ -64,11 +64,16 @@ struct scrobbleApp: App { .environment(appState) } .padding(8) + .containerBackground( + .ultraThinMaterial, for: .window + ) + .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) } label: { Image(systemName: "music.note") } .menuBarExtraStyle(.window) + WindowGroup("Scrobbler", id: "scrobbler") { ContentView() @@ -82,6 +87,11 @@ struct scrobbleApp: App { .environment(authState) } } + .containerBackground( + .ultraThinMaterial, for: .window + ) + .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) + } .defaultPosition(.center) .defaultSize(width: 400, height: 600) From 1844940d37688e5bd7a3ae1d9e238a5758972ea1 Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Sat, 13 Dec 2025 15:19:09 -0800 Subject: [PATCH 3/3] styling tweaks --- scrobble/Utils/GlassEffect+Compat.swift | 4 +++- scrobble/Views/AppSelectionView.swift | 17 ++++++++++------- scrobble/Views/FriendsView.swift | 1 + scrobble/Views/MenuButtonsView.swift | 16 +++++++++++++--- scrobble/Views/ScrobblingView.swift | 2 +- scrobble/scrobbleApp.swift | 1 + 6 files changed, 29 insertions(+), 12 deletions(-) diff --git a/scrobble/Utils/GlassEffect+Compat.swift b/scrobble/Utils/GlassEffect+Compat.swift index 8cd14bd..2ec962d 100644 --- a/scrobble/Utils/GlassEffect+Compat.swift +++ b/scrobble/Utils/GlassEffect+Compat.swift @@ -78,7 +78,9 @@ extension View { @ViewBuilder func compatGlassButtonStyle(selected: Bool = false) -> some View { if #available(macOS 26, *) { - self.buttonStyle(selected ? .glass(.regular.tint(.accentColor)) : .glass) + self + .tint(selected ? .accentColor : .clear) + .buttonStyle(selected ? .glass(.regular.tint(.accentColor)) : .glass) } else { self.buttonStyle(CompatGlassButtonStyleLegacy(isSelected: selected)) } diff --git a/scrobble/Views/AppSelectionView.swift b/scrobble/Views/AppSelectionView.swift index 82e3a0e..7908c79 100644 --- a/scrobble/Views/AppSelectionView.swift +++ b/scrobble/Views/AppSelectionView.swift @@ -43,18 +43,20 @@ struct AppSelectionView: View { if !runningApps.isEmpty { VStack(alignment: .leading, spacing: 4) { - HStack { + HStack(alignment: .center) { Image(systemName: "circle.fill") .foregroundStyle(.green) .font(.caption) - Text("Currently Running") + Text("Currently Running: ") .font(.caption) .foregroundStyle(.secondary) + + Text(runningApps.map { $0.displayName }.joined(separator: ", ")) + .font(.caption2) + .foregroundStyle(.secondary) } - Text(runningApps.map { $0.displayName }.joined(separator: ", ")) - .font(.caption2) - .foregroundStyle(.secondary) + } .padding(.horizontal) } @@ -145,7 +147,7 @@ struct AppSelectionButton: View { ZStack { Image(systemName: app.icon) .font(.title2) - .foregroundStyle(isSelected ? .white : .primary) + .foregroundStyle(isSelected ? .white : .secondary) if isRunning { Circle() @@ -158,7 +160,7 @@ struct AppSelectionButton: View { Text(app.displayName) .font(.caption) .fontWeight(.medium) - .foregroundStyle(isSelected ? .white : .primary) + .foregroundStyle(isSelected ? .white : .secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) @@ -167,6 +169,7 @@ struct AppSelectionButton: View { } .compatGlassButtonStyle(selected: isSelected) + } } diff --git a/scrobble/Views/FriendsView.swift b/scrobble/Views/FriendsView.swift index 58b1b52..5f1d7be 100644 --- a/scrobble/Views/FriendsView.swift +++ b/scrobble/Views/FriendsView.swift @@ -28,6 +28,7 @@ struct FriendsView: View { .disabled(model.isLoading) .compatGlassButtonStyle() } + .background(.clear) .padding(.horizontal) .padding(.top) .compatScrollEdgeEffectStyle() diff --git a/scrobble/Views/MenuButtonsView.swift b/scrobble/Views/MenuButtonsView.swift index 20e7065..079c8e1 100644 --- a/scrobble/Views/MenuButtonsView.swift +++ b/scrobble/Views/MenuButtonsView.swift @@ -24,23 +24,33 @@ struct MenuButtonsView: View { } private var buttonsContent: some View { - HStack(alignment: .center) { + HStack(alignment: .center, spacing: 4) { Button { openWindow(id: "scrobbler") } label: { Label("Window", systemImage: "rectangle.expand.vertical" ) - .foregroundStyle(.secondary.opacity(0.7)) + .foregroundStyle(.secondary.opacity(0.5)) + .font(.caption2) + } + .compatGlassButtonStyle() + + Spacer() + + SettingsLink { + Label("Settings", systemImage: "gearshape" ) + .foregroundStyle(.secondary.opacity(0.5)) .font(.caption2) } .compatGlassButtonStyle() + Spacer() Button { NSApplication.shared.terminate(nil) } label: { Label("Quit", systemImage: "xmark.circle" ) - .foregroundStyle(.secondary.opacity(0.7)) + .foregroundStyle(.secondary.opacity(0.5)) .font(.caption2) } diff --git a/scrobble/Views/ScrobblingView.swift b/scrobble/Views/ScrobblingView.swift index db428b4..bf8d34d 100644 --- a/scrobble/Views/ScrobblingView.swift +++ b/scrobble/Views/ScrobblingView.swift @@ -245,7 +245,7 @@ struct ScrobblingView: View { .scaledToFit() .frame(width: 50, height: 50) .cornerRadius(8) - .compatGlassClear() + .shadow(radius: 2) } else { RoundedRectangle(cornerRadius: 8) .fill(.quaternary) diff --git a/scrobble/scrobbleApp.swift b/scrobble/scrobbleApp.swift index 7fe08e9..0299f20 100644 --- a/scrobble/scrobbleApp.swift +++ b/scrobble/scrobbleApp.swift @@ -110,6 +110,7 @@ struct scrobbleApp: App { .environment(authState) } } + }