Reorganize source code and optimize API use

- it was slightly improved how it interacted with the Last.fm api

- the source code files were reorganized for a cleaner and more organized appearance when encoding

- small graphical bugs of the interface were fixed when rendering with Swift.
This commit is contained in:
Jesus Chapman 2026-06-21 12:14:48 -05:00
parent 6a7f1cb9cb
commit c1cba5735f
26 changed files with 2621 additions and 2724 deletions

View File

@ -44,6 +44,7 @@ Valentine is made possible by these incredible open-source projects:
- **[LRCLib](https://lrclib.net/)**: The open-source lyrics API used to seamlessly search and fetch precise, time-synchronized `.lrc` lyrics.
- **[Mutagen](https://mutagen.readthedocs.io/)**: A highly robust Python multimedia tagging library used by our `MutagenInstallerService` to inject the lyrics securely into the ID3 metadata.
- **[AVFoundation](https://developer.apple.com/av-foundation/)**: Apple's native framework driving the `AudioEngine`, providing flawless audio playback and waveform data.
- **[Last.fm API](https://www.last.fm/api)**: Used to scrobble tracks in your Last.fm account
---
@ -77,6 +78,7 @@ To build Valentine from source, you need a Mac running **macOS Tahoe 26** or new
3. **Configure the Project:**
- Go to the project settings and ensure you have selected your Apple Developer ID team.
- Valentine uses Xcode 16's Synchronized Folders, so everything is ready to go out of the box.
- **Note:** You will need to provide your own [Last.fm API Key](https://www.last.fm/api/account/create) in `Secrets.swift` for use Last.fm api, the compilations released by me include my personal api key.
4. **Build & Run:**
- Select your Mac as the destination.
- Press `Cmd + R` to compile and launch.

View File

@ -267,7 +267,7 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 002;
CURRENT_PROJECT_VERSION = 003;
DEVELOPMENT_TEAM = 7CQPG22234;
ENABLE_APP_SANDBOX = NO;
ENABLE_FILE_ACCESS_MUSIC_FOLDER = readwrite;
@ -291,7 +291,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.1;
MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = dev.jesuschapman.Valentine;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@ -312,7 +312,7 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 002;
CURRENT_PROJECT_VERSION = 003;
DEVELOPMENT_TEAM = 7CQPG22234;
ENABLE_APP_SANDBOX = NO;
ENABLE_FILE_ACCESS_MUSIC_FOLDER = readwrite;
@ -336,7 +336,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.1;
MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = dev.jesuschapman.Valentine;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;

View File

@ -186,18 +186,34 @@ struct ContentView: View {
}
private func handleDrop(providers: [NSItemProvider]) -> Bool {
let group = DispatchGroup()
let queue = DispatchQueue(label: "dropQueue")
var urls: [URL] = []
for provider in providers {
provider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { (urlData, error) in
group.enter()
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { (urlData, error) in
defer { group.leave() }
if let urlData = urlData as? Data,
let urlString = String(data: urlData, encoding: .utf8),
let url = URL(string: urlString) {
urls.append(url)
queue.async {
urls.append(url)
}
} else if let url = urlData as? URL {
queue.async {
urls.append(url)
}
}
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
engine.addTracks(urls)
group.notify(queue: .main) {
queue.sync {
if !urls.isEmpty {
engine.addTracks(urls)
}
}
}
return true
}

View File

@ -16,6 +16,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@main
struct ValentineApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@AppStorage("appTheme") private var appTheme = 0
var body: some Scene {
WindowGroup {
RootView()
@ -35,6 +37,7 @@ struct ValentineApp: App {
Window("Settings", id: "settings") {
SettingsView()
.preferredColorScheme(appTheme == 1 ? .light : (appTheme == 2 ? .dark : nil))
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentMinSize)
@ -59,6 +62,7 @@ struct RootView: View {
}
}
.animation(.easeInOut, value: isMiniPlayerMode)
.preferredColorScheme(appTheme == 1 ? .light : (appTheme == 2 ? .dark : nil))
.onAppear {
updateTheme(theme: appTheme)
configureWindow(forMiniPlayer: isMiniPlayerMode)
@ -78,11 +82,11 @@ struct RootView: View {
engine.showLyricsEditor = true
}
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("AddFile"))) { _ in engine.showAddFileDialog() }
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("AddFolder"))) { _ in engine.showAddFolderDialog() }
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ClearPlaylist"))) { _ in engine.clearPlaylist() }
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("EditLyrics"))) { _ in engine.checkAndShowLyricsEditor() }
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ReinstallMutagen"))) { _ in engine.showMutagenInstaller = true }
.onReceive(NotificationCenter.default.publisher(for: .addFile)) { _ in engine.showAddFileDialog() }
.onReceive(NotificationCenter.default.publisher(for: .addFolder)) { _ in engine.showAddFolderDialog() }
.onReceive(NotificationCenter.default.publisher(for: .clearPlaylist)) { _ in engine.clearPlaylist() }
.onReceive(NotificationCenter.default.publisher(for: .editLyrics)) { _ in engine.checkAndShowLyricsEditor() }
.onReceive(NotificationCenter.default.publisher(for: .reinstallMutagen)) { _ in engine.showMutagenInstaller = true }
}
private func updateTheme(theme: Int) {
@ -167,19 +171,19 @@ struct ValentineCommands: Commands {
CommandGroup(replacing: .newItem) {
Button(action: { NotificationCenter.default.post(name: NSNotification.Name("AddFile"), object: nil) }) {
Button(action: { NotificationCenter.default.post(name: .addFile, object: nil) }) {
Label("Add File...", systemImage: "doc.badge.plus")
}
.keyboardShortcut("o", modifiers: [.command])
Button(action: { NotificationCenter.default.post(name: NSNotification.Name("AddFolder"), object: nil) }) {
Button(action: { NotificationCenter.default.post(name: .addFolder, object: nil) }) {
Label("Add Folder...", systemImage: "folder.badge.plus")
}
.keyboardShortcut("o", modifiers: [.command, .shift])
Divider()
Button(action: { NotificationCenter.default.post(name: NSNotification.Name("ClearPlaylist"), object: nil) }) {
Button(action: { NotificationCenter.default.post(name: .clearPlaylist, object: nil) }) {
Label("Clear Playlist", systemImage: "trash")
}
.keyboardShortcut(.delete, modifiers: [.command])
@ -187,14 +191,14 @@ struct ValentineCommands: Commands {
CommandGroup(after: .textEditing) {
Divider()
Button(action: { NotificationCenter.default.post(name: NSNotification.Name("EditLyrics"), object: nil) }) {
Button(action: { NotificationCenter.default.post(name: .editLyrics, object: nil) }) {
Label("Edit Lyrics", systemImage: "music.note.list")
}
.keyboardShortcut("e", modifiers: [.command])
}
CommandGroup(replacing: .help) {
Button(action: { NotificationCenter.default.post(name: NSNotification.Name("ReinstallMutagen"), object: nil) }) {
Button(action: { NotificationCenter.default.post(name: .reinstallMutagen, object: nil) }) {
Label("Reinstall Mutagen", systemImage: "arrow.triangle.2.circlepath")
}
}

View File

@ -0,0 +1,11 @@
import Foundation
import SwiftUI
struct DesignConstants {
struct CornerRadius {
static let small: CGFloat = 8
static let medium: CGFloat = 12
static let large: CGFloat = 16
static let circular: CGFloat = 22
}
}

View File

@ -0,0 +1,9 @@
import Foundation
extension Notification.Name {
static let addFile = Notification.Name("AddFile")
static let addFolder = Notification.Name("AddFolder")
static let clearPlaylist = Notification.Name("ClearPlaylist")
static let editLyrics = Notification.Name("EditLyrics")
static let reinstallMutagen = Notification.Name("ReinstallMutagen")
}

View File

@ -0,0 +1,11 @@
import Foundation
extension TimeInterval {
func formatTime() -> String {
guard self > 0 else { return "0:00" }
let totalSeconds = Int(self)
let minutes = totalSeconds / 60
let seconds = totalSeconds % 60
return String(format: "%d:%02d", minutes, seconds)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -13,15 +13,16 @@ struct LiquidGlassModifier: ViewModifier {
}
extension View {
func liquidGlass(cornerRadius: CGFloat = 16) -> some View {
func liquidGlass(cornerRadius: CGFloat = DesignConstants.CornerRadius.large) -> some View {
self.modifier(LiquidGlassModifier(cornerRadius: cornerRadius))
}
}
struct LiquidGlassButtonStyle: ButtonStyle {
var cornerRadius: CGFloat = 16
var cornerRadius: CGFloat = DesignConstants.CornerRadius.large
var isActive: Bool = false
@Environment(\.colorScheme) var colorScheme
@State private var isHovered = false
func makeBody(configuration: Configuration) -> some View {

View File

@ -43,6 +43,8 @@ class AudioEngine: ObservableObject {
private var player: AVPlayer?
private var timeObserver: Any?
private var endObserver: NSObjectProtocol?
private var userDefaultsObserver: NSObjectProtocol?
private var hasScrobbledCurrentTrack = false
private var currentTrackStartTime: Int = 0
@ -58,7 +60,7 @@ class AudioEngine: ObservableObject {
setupAudioSession()
setupRemoteCommandCenter()
NotificationCenter.default.addObserver(forName: UserDefaults.didChangeNotification, object: nil, queue: .main) { [weak self] _ in
self.userDefaultsObserver = NotificationCenter.default.addObserver(forName: UserDefaults.didChangeNotification, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
let newGlow = UserDefaults.standard.bool(forKey: "isGlowEffectEnabled")
@ -69,6 +71,26 @@ class AudioEngine: ObservableObject {
}
}
deinit {
if let observer = timeObserver {
player?.removeTimeObserver(observer)
}
if let endObserver = endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
if let defaultsObserver = userDefaultsObserver {
NotificationCenter.default.removeObserver(defaultsObserver)
}
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.removeTarget(nil)
commandCenter.pauseCommand.removeTarget(nil)
commandCenter.togglePlayPauseCommand.removeTarget(nil)
commandCenter.nextTrackCommand.removeTarget(nil)
commandCenter.previousTrackCommand.removeTarget(nil)
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
}
private func setupAudioSession() {
}
@ -208,8 +230,19 @@ class AudioEngine: ObservableObject {
player?.removeTimeObserver(observer)
timeObserver = nil
}
if let endObserver = endObserver {
NotificationCenter.default.removeObserver(endObserver)
self.endObserver = nil
}
let playerItem = AVPlayerItem(url: track.url)
endObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main) { [weak self] _ in
Task { @MainActor in
self?.nextTrack(isAutomatic: true)
}
}
player = AVPlayer(playerItem: playerItem)
player?.volume = volume
currentTrackIndex = index
@ -232,10 +265,6 @@ class AudioEngine: ObservableObject {
LastFMService.shared.scrobble(track: currentTrack.title, artist: currentTrack.artist, album: currentTrack.album, timestamp: self.currentTrackStartTime)
}
}
if self.duration > 0 && self.currentTime >= self.duration - 0.1 {
self.nextTrack(isAutomatic: true)
}
}
}

View File

@ -56,16 +56,15 @@ class LastFMService: ObservableObject {
}
allParams["format"] = "json"
var components = URLComponents(string: "https://ws.audioscrobbler.com/2.0/")!
components.queryItems = allParams.map { URLQueryItem(name: $0.key, value: $0.value) }
guard let url = components.url else {
throw URLError(.badURL)
}
let url = URL(string: "https://ws.audioscrobbler.com/2.0/")!
var request = URLRequest(url: url)
request.httpMethod = "POST" // Last.fm recommends POST for scrobbling and now playing
var components = URLComponents()
components.queryItems = allParams.map { URLQueryItem(name: $0.key, value: $0.value) }
request.httpBody = components.query?.data(using: .utf8)
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {

View File

@ -0,0 +1,32 @@
import SwiftUI
struct HoverButton: View {
let title: LocalizedStringKey
let isPrimary: Bool
let action: () -> Void
@State private var isHovered = false
var body: some View {
Button(action: action) {
Text(title)
.font(.headline)
.foregroundColor(isPrimary ? .white : .primary)
.frame(maxWidth: .infinity, minHeight: 44)
.background(isPrimary ? Color.accentColor.opacity(0.6) : Color.secondary.opacity(0.2))
.glassEffect(.regular.interactive(), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.scaleEffect(isHovered ? 1.02 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered)
.onHover { hovering in
isHovered = hovering
if hovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
}
.buttonStyle(.plain)
}
}

View File

@ -0,0 +1,63 @@
import SwiftUI
struct QueueRowView: View {
let track: Track
let isPlaying: Bool
let isSelectionMode: Bool
let isSelected: Bool
var body: some View {
HStack(spacing: 12) {
if isSelectionMode {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundColor(isSelected ? .accentColor : .secondary)
}
if let albumArt = track.albumArt {
albumArt
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
RoundedRectangle(cornerRadius: 6)
.fill(Color.primary.opacity(0.1))
.frame(width: 40, height: 40)
.overlay(
Image(systemName: "music.note")
.foregroundColor(.primary.opacity(0.3))
)
}
VStack(alignment: .leading, spacing: 2) {
Text(track.title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.primary)
Text(track.artist)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text(track.duration.formatTime())
.font(.caption.monospacedDigit())
.foregroundColor(.secondary)
if isPlaying && !isSelectionMode {
Image(systemName: "speaker.wave.2.fill")
.foregroundColor(.primary)
.font(.system(size: 12))
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isPlaying && !isSelectionMode ? Color.primary.opacity(0.15) : (isSelected ? Color.blue.opacity(0.2) : Color.clear))
)
.animation(.easeInOut(duration: 0.2), value: isPlaying)
.animation(.easeInOut(duration: 0.2), value: isSelectionMode)
.animation(.easeInOut(duration: 0.2), value: isSelected)
}
}

View File

@ -0,0 +1,190 @@
import SwiftUI
enum ThemeStyle {
case system, light, dark
}
struct ThemeSelectionView: View {
@Binding var selection: Int
var body: some View {
HStack(spacing: 20) {
ThemeThumbnail(title: "Follow System", isSelected: selection == 0, style: .system) {
selection = 0
}
ThemeThumbnail(title: "Light", isSelected: selection == 1, style: .light) {
selection = 1
}
ThemeThumbnail(title: "Dark", isSelected: selection == 2, style: .dark) {
selection = 2
}
}
.padding(.vertical, 8)
}
}
struct ThemeThumbnail: View {
let title: LocalizedStringKey
let isSelected: Bool
let style: ThemeStyle
let action: () -> Void
var body: some View {
VStack(spacing: 8) {
Button(action: action) {
ZStack {
// Background
RoundedRectangle(cornerRadius: 8)
.fill(Color.clear)
.frame(width: 76, height: 52)
if style == .system {
HStack(spacing: 0) {
ZStack {
LinearGradient(colors: [.cyan.opacity(0.6), .white], startPoint: .topLeading, endPoint: .bottomTrailing)
WindowGraphic(isDark: false)
}
ZStack {
LinearGradient(colors: [.blue.opacity(0.8), .black], startPoint: .topLeading, endPoint: .bottomTrailing)
WindowGraphic(isDark: true)
}
}
.frame(width: 72, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
let isDark = style == .dark
ZStack {
if isDark {
LinearGradient(colors: [.blue.opacity(0.8), .black], startPoint: .topLeading, endPoint: .bottomTrailing)
} else {
LinearGradient(colors: [.cyan.opacity(0.6), .white], startPoint: .topLeading, endPoint: .bottomTrailing)
}
WindowGraphic(isDark: isDark)
}
.frame(width: 72, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
if isSelected {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.accentColor, lineWidth: 3)
.frame(width: 76, height: 52)
}
}
}
.buttonStyle(.plain)
Text(title)
.font(.system(size: 11, weight: isSelected ? .bold : .regular))
}
}
}
struct WindowGraphic: View {
let isDark: Bool
var body: some View {
VStack(spacing: 0) {
Spacer().frame(height: 12)
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 4)
.fill(isDark ? Color(white: 0.1) : .white)
.shadow(color: .black.opacity(0.2), radius: 2, y: 1)
// Content bar
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor)
.frame(width: 40, height: 8)
.padding(.leading, 8)
.padding(.top, 8)
// Traffic lights
HStack(spacing: 2) {
Circle().fill(Color.red).frame(width: 4, height: 4)
Circle().fill(Color.yellow).frame(width: 4, height: 4)
Circle().fill(Color.green).frame(width: 4, height: 4)
}
.padding(.leading, 8)
.padding(.top, 24)
}
.frame(width: 56, height: 40)
}
}
}
struct LiquidGlassSelectionView: View {
@Binding var selection: Int
var body: some View {
HStack(spacing: 20) {
LiquidGlassThumbnail(title: "Transparent", isSelected: selection == 1, isTinted: false) {
selection = 1
}
LiquidGlassThumbnail(title: "Tinted", isSelected: selection == 0, isTinted: true) {
selection = 0
}
}
.padding(.vertical, 8)
}
}
struct LiquidGlassThumbnail: View {
let title: LocalizedStringKey
let isSelected: Bool
let isTinted: Bool
let action: () -> Void
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack(spacing: 8) {
Button(action: action) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.clear)
.frame(width: 86, height: 60)
let isDark = colorScheme == .dark
ZStack {
if isDark {
LinearGradient(colors: [.blue.opacity(0.8), .black], startPoint: .topLeading, endPoint: .bottomTrailing)
} else {
LinearGradient(colors: [.cyan.opacity(0.6), .white], startPoint: .topLeading, endPoint: .bottomTrailing)
}
if isTinted {
RoundedRectangle(cornerRadius: 12)
.fill(isDark ? Color.accentColor.opacity(0.4) : Color(white: 1.0).opacity(0.7))
.background(.regularMaterial)
.shadow(color: .black.opacity(0.2), radius: 2, y: 1)
.frame(width: 50, height: 26)
.clipShape(RoundedRectangle(cornerRadius: 12))
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.clear)
.background(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color(white: 1.0).opacity(isDark ? 0.2 : 0.6), lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.2), radius: 2, y: 1)
.frame(width: 50, height: 26)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.frame(width: 80, height: 54)
.clipShape(RoundedRectangle(cornerRadius: 8))
if isSelected {
RoundedRectangle(cornerRadius: 10)
.stroke(Color.accentColor, lineWidth: 3)
.frame(width: 86, height: 60)
}
}
}
.buttonStyle(.plain)
Text(title)
.font(.system(size: 11, weight: isSelected ? .bold : .regular))
}
}
}

View File

@ -0,0 +1,16 @@
import SwiftUI
struct WindowButtonsHider: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
if let window = view.window {
window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
}
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}

View File

@ -82,11 +82,11 @@ struct LyricsView: View {
VStack(spacing: 16) {
Image(systemName: "music.mic")
.font(.system(size: 64))
.foregroundColor(.white.opacity(0.3))
.foregroundColor(.primary.opacity(0.3))
Text("No Lyrics Available")
.font(.title3)
.fontWeight(.medium)
.foregroundColor(.white.opacity(0.5))
.foregroundColor(.primary.opacity(0.5))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

View File

@ -133,7 +133,7 @@ struct MiniPlayerView: View {
if showMiniLyrics {
VStack(spacing: 4) {
Divider()
.background(Color.white.opacity(0.1))
.background(Color.primary.opacity(0.1))
.padding(.horizontal, 20)
VStack(spacing: 8) {
@ -185,7 +185,7 @@ struct MiniPlayerView: View {
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.white.opacity(0.15), lineWidth: 1)
.stroke(Color.primary.opacity(0.15), lineWidth: 1)
.blendMode(.overlay)
)
.ignoresSafeArea()

View File

@ -14,6 +14,8 @@ struct PlaybackControlsView: View {
.frame(width: 43, height: 43)
}
.buttonStyle(LiquidGlassButtonStyle(cornerRadius: 21.5, isActive: false))
.accessibilityLabel("Previous Track")
.keyboardShortcut(.leftArrow, modifiers: .command)
Button(action: {
engine.togglePlayback()
@ -24,6 +26,8 @@ struct PlaybackControlsView: View {
.frame(width: 58, height: 58)
}
.buttonStyle(LiquidGlassButtonStyle(cornerRadius: 29, isActive: engine.isPlaying))
.accessibilityLabel(engine.isPlaying ? "Pause" : "Play")
.keyboardShortcut(.space, modifiers: [])
Button(action: {
engine.nextTrack()
@ -34,6 +38,8 @@ struct PlaybackControlsView: View {
.frame(width: 43, height: 43)
}
.buttonStyle(LiquidGlassButtonStyle(cornerRadius: 21.5, isActive: false))
.accessibilityLabel("Next Track")
.keyboardShortcut(.rightArrow, modifiers: .command)
}
}
}

View File

@ -10,37 +10,42 @@ struct PlayerView: View {
VStack(spacing: 8) {
Spacer(minLength: 0)
if engine.showLyrics {
LyricsView(engine: engine)
.frame(maxWidth: .infinity, minHeight: 160, maxHeight: 325)
.layoutPriority(1)
} else if let art = engine.currentTrack?.albumArt {
Rectangle()
.fill(Color.clear)
.frame(minWidth: 160, maxWidth: 325, minHeight: 160, maxHeight: 325)
.aspectRatio(1, contentMode: .fit)
.overlay(
art
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.4), radius: 15, x: 0, y: 10)
.layoutPriority(1)
} else {
RoundedRectangle(cornerRadius: 16)
.fill(.ultraThinMaterial)
.frame(minWidth: 160, maxWidth: 325, minHeight: 160, maxHeight: 325)
.aspectRatio(1, contentMode: .fit)
.overlay(
Image(systemName: "music.note")
.font(.system(size: 80))
.foregroundColor(.white.opacity(0.3))
)
.layoutPriority(1)
Group {
if engine.showLyrics {
LyricsView(engine: engine)
.frame(maxWidth: .infinity, minHeight: 160, maxHeight: 325)
.layoutPriority(1)
} else if let art = engine.currentTrack?.albumArt {
Rectangle()
.fill(Color.clear)
.frame(minWidth: 160, maxWidth: 325, minHeight: 160, maxHeight: 325)
.aspectRatio(1, contentMode: .fit)
.overlay(
art
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.4), radius: 15, x: 0, y: 10)
.layoutPriority(1)
} else {
RoundedRectangle(cornerRadius: 16)
.fill(.ultraThinMaterial)
.frame(minWidth: 160, maxWidth: 325, minHeight: 160, maxHeight: 325)
.aspectRatio(1, contentMode: .fit)
.overlay(
Image(systemName: "music.note")
.font(.system(size: 80))
.foregroundColor(.primary.opacity(0.3))
)
.layoutPriority(1)
}
}
.id(engine.currentTrack?.id)
.transition(.opacity.combined(with: .scale(scale: 0.95)))
.animation(.easeInOut(duration: 0.4), value: engine.currentTrack?.id)
Spacer(minLength: 0)
WaveformView(engine: engine)
@ -98,6 +103,7 @@ struct PlayerView: View {
}
.contentTransition(.symbolEffect(.replace))
.buttonStyle(LiquidGlassButtonStyle(cornerRadius: 16, isActive: engine.shuffleMode))
.accessibilityLabel(engine.shuffleMode ? "Shuffle On" : "Shuffle Off")
Button(action: {
withAnimation(.easeInOut(duration: 0.4)) {
@ -125,6 +131,7 @@ struct PlayerView: View {
}
.contentTransition(.symbolEffect(.replace))
.buttonStyle(LiquidGlassButtonStyle(cornerRadius: 16, isActive: engine.repeatMode != .off))
.accessibilityLabel("Repeat \(engine.repeatMode == .off ? "Off" : (engine.repeatMode == .one ? "One" : "All"))")
Spacer()
@ -139,6 +146,7 @@ struct PlayerView: View {
.frame(width: 32, height: 32)
}
.buttonStyle(LiquidGlassButtonStyle(cornerRadius: 16, isActive: engine.showLyrics))
.accessibilityLabel(engine.showLyrics ? "Hide Lyrics" : "Show Lyrics")
Button(action: {
engine.checkAndShowLyricsEditor()
@ -149,6 +157,7 @@ struct PlayerView: View {
.frame(width: 32, height: 32)
}
.buttonStyle(LiquidGlassButtonStyle(cornerRadius: 16, isActive: false))
.accessibilityLabel("Edit Lyrics")
}
.padding(.horizontal, 40)
.padding(.bottom, 12)

View File

@ -54,9 +54,10 @@ struct PlaylistView: View {
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule().fill(.ultraThinMaterial))
.overlay(
Capsule().strokeBorder(Color.primary.opacity(0.1), lineWidth: 1)
.background(
RoundedRectangle(cornerRadius: DesignConstants.CornerRadius.medium)
.fill(.clear)
.liquidGlass(cornerRadius: DesignConstants.CornerRadius.medium)
)
.padding(.trailing, 8)
}
@ -75,7 +76,9 @@ struct PlaylistView: View {
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
.liquidGlass(cornerRadius: 14)
.liquidGlass(cornerRadius: DesignConstants.CornerRadius.medium)
.accessibilityLabel(isSearchVisible ? "Close Search" : "Search Playlist")
.keyboardShortcut("f", modifiers: .command)
.padding(.trailing, 4)
Button(action: {
@ -92,7 +95,8 @@ struct PlaylistView: View {
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
.liquidGlass(cornerRadius: 14)
.liquidGlass(cornerRadius: DesignConstants.CornerRadius.medium)
.accessibilityLabel(isSelectionMode ? "Exit Selection Mode" : "Select Tracks")
}
.padding()
@ -119,94 +123,83 @@ struct PlaylistView: View {
}
.buttonStyle(.plain)
.keyboardShortcut(.delete, modifiers: .command)
.padding(.horizontal)
.padding(.bottom, 8)
}
ScrollView {
LazyVStack(spacing: 4) {
ForEach(filteredTracks, id: \.1.id) { index, track in
QueueRowView(
track: track,
isPlaying: engine.currentTrackIndex == index,
isSelectionMode: isSelectionMode,
isSelected: selectedTracks.contains(track.id)
)
.contentShape(Rectangle())
.onTapGesture {
if isSelectionMode {
if selectedTracks.contains(track.id) {
selectedTracks.remove(track.id)
if filteredTracks.isEmpty {
VStack(spacing: 16) {
Spacer()
Image(systemName: isSearchVisible ? "magnifyingglass" : "music.note.list")
.font(.system(size: 48))
.foregroundColor(.secondary.opacity(0.5))
Text(isSearchVisible ? "No results found" : "Drag & Drop Audio Files Here")
.font(.headline)
.foregroundColor(.secondary)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(spacing: 4) {
ForEach(filteredTracks, id: \.1.id) { index, track in
QueueRowView(
track: track,
isPlaying: engine.currentTrackIndex == index,
isSelectionMode: isSelectionMode,
isSelected: selectedTracks.contains(track.id)
)
.contentShape(Rectangle())
.onTapGesture {
if isSelectionMode {
if selectedTracks.contains(track.id) {
selectedTracks.remove(track.id)
} else {
selectedTracks.insert(track.id)
}
} else {
selectedTracks.insert(track.id)
engine.playTrack(at: index)
}
} else {
engine.playTrack(at: index)
}
.contextMenu {
Button(action: {
if let currentIndex = engine.currentTrackIndex {
let trackIndexInQueue = engine.queue.firstIndex(where: { $0.id == track.id })
if let tIndex = trackIndexInQueue, tIndex != currentIndex {
let t = engine.queue.remove(at: tIndex)
let newCurrentIndex = currentIndex > tIndex ? currentIndex - 1 : currentIndex
engine.currentTrackIndex = newCurrentIndex
let insertIndex = min(newCurrentIndex + 1, engine.queue.count)
engine.queue.insert(t, at: insertIndex)
}
}
}) {
Label("Play Next", systemImage: "text.insert")
}
Button(action: {
NSWorkspace.shared.activateFileViewerSelecting([track.url])
}) {
Label("Show in Finder", systemImage: "folder")
}
Divider()
Button(role: .destructive, action: {
engine.removeTracks(withIds: [track.id])
}) {
Label("Remove", systemImage: "trash")
}
.tint(.red)
.foregroundColor(.red)
}
}
}
.padding(.horizontal, 8)
.padding(.bottom, 16)
}
.padding(.horizontal, 8)
.padding(.bottom, 16)
}
}
}
}
struct QueueRowView: View {
let track: Track
let isPlaying: Bool
let isSelectionMode: Bool
let isSelected: Bool
var body: some View {
HStack(spacing: 12) {
if isSelectionMode {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundColor(isSelected ? .accentColor : .secondary)
}
if let albumArt = track.albumArt {
albumArt
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
RoundedRectangle(cornerRadius: 6)
.fill(Color.white.opacity(0.1))
.frame(width: 40, height: 40)
.overlay(
Image(systemName: "music.note")
.foregroundColor(.white.opacity(0.3))
)
}
VStack(alignment: .leading, spacing: 2) {
Text(track.title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.primary)
Text(track.artist)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if isPlaying && !isSelectionMode {
Image(systemName: "speaker.wave.2.fill")
.foregroundColor(.primary)
.font(.system(size: 12))
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isPlaying && !isSelectionMode ? Color.white.opacity(0.15) : (isSelected ? Color.blue.opacity(0.2) : Color.clear))
)
.animation(.easeInOut(duration: 0.2), value: isPlaying)
.animation(.easeInOut(duration: 0.2), value: isSelectionMode)
.animation(.easeInOut(duration: 0.2), value: isSelected)
}
}

View File

@ -64,7 +64,8 @@ struct AboutView: View {
CreditSection(title: "SPECIAL THANKS", items: [
"The Amberol Team",
"Mutagen",
"LRCLIB"
"LRCLIB",
"Last.fm"
])
CreditSection(title: "LICENSE", items: [

View File

@ -0,0 +1,36 @@
import SwiftUI
struct GeneralSettingsView: View {
@AppStorage("isGlowEffectEnabled") private var isGlowEffectEnabled = false
@AppStorage("isNeonEffectEnabled") private var isNeonEffectEnabled = false
@AppStorage("miniPlayerGlassMode") private var miniPlayerGlassMode = 0
@AppStorage("appTheme") private var appTheme = 0
var body: some View {
Form {
Section(header: Text("Appearance")) {
VStack(alignment: .leading, spacing: 12) {
Text("App Theme")
ThemeSelectionView(selection: $appTheme)
}
VStack(alignment: .leading, spacing: 4) {
Text("Mini Player Appearance")
.font(.body)
Text("Choose an appearance for the mini player.")
.font(.caption)
.foregroundColor(.secondary)
LiquidGlassSelectionView(selection: $miniPlayerGlassMode)
.padding(.top, 4)
}
.padding(.vertical, 8)
}
Section(header: Text("Synced Lyrics Effects")) {
Toggle("Glow Effect", isOn: $isGlowEffectEnabled)
Toggle("Neon Effect", isOn: $isNeonEffectEnabled)
}
}
.formStyle(.grouped)
}
}

View File

@ -0,0 +1,141 @@
import SwiftUI
struct LyricsAppearanceView: View {
@ObservedObject var settings = LyricsAppearanceManager.shared
@State private var previewIsDark = true
@State private var previewNeon = false
@State private var previewGlow = false
@State private var applyMode = 0 // 0: Both Themes, 1: Specific Theme
// Bindings for the currently selected theme to edit
private var isEditingDark: Bool {
if applyMode == 0 { return previewIsDark }
return previewIsDark // Wait, if specific theme, it edits the preview's current theme.
}
private var fontDesignBinding: Binding<Int> {
Binding(
get: { isEditingDark ? settings.fontDesignDark : settings.fontDesignLight },
set: { newValue in
if applyMode == 0 {
settings.fontDesignDark = newValue
settings.fontDesignLight = newValue
} else {
if isEditingDark { settings.fontDesignDark = newValue }
else { settings.fontDesignLight = newValue }
}
}
)
}
private func colorBinding(for stringBindingLight: Binding<String>, stringBindingDark: Binding<String>, defaultColor: Color) -> Binding<Color> {
Binding<Color>(
get: {
let hex = isEditingDark ? stringBindingDark.wrappedValue : stringBindingLight.wrappedValue
return hex.isEmpty ? defaultColor : Color(hex: hex)
},
set: { newValue in
let hex = newValue.toHex()
if applyMode == 0 {
stringBindingDark.wrappedValue = hex
stringBindingLight.wrappedValue = hex
} else {
if isEditingDark { stringBindingDark.wrappedValue = hex }
else { stringBindingLight.wrappedValue = hex }
}
}
)
}
var body: some View {
VStack(spacing: 0) {
// PREVIEW SECTION
VStack {
HStack {
Text("Preview")
.font(.headline)
.foregroundColor(.primary)
Spacer()
Toggle("Dark Mode", isOn: $previewIsDark)
.toggleStyle(.switch)
.controlSize(.mini)
}
.padding()
ZStack {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(previewIsDark ? Color(white: 0.1) : Color(white: 0.95))
VStack(spacing: 16) {
previewText("Lorem ipsum dolor sit amet", isActive: false)
previewText("Consectetur adipiscing elit", isActive: true)
previewText("Sed do eiusmod tempor", isActive: false)
}
}
.frame(height: 180)
.padding(.horizontal)
.padding(.bottom)
}
.background(Color(NSColor.windowBackgroundColor))
Divider()
// SETTINGS SECTION
ScrollView {
VStack(spacing: 24) {
Picker("Apply to:", selection: $applyMode) {
Text("Both Themes").tag(0)
Text(previewIsDark ? "Dark Theme Only" : "Light Theme Only").tag(1)
}
.pickerStyle(SegmentedPickerStyle())
HStack {
Toggle("Neon Effect", isOn: $previewNeon)
Spacer()
Toggle("Glow Effect", isOn: $previewGlow)
}
VStack(alignment: .leading, spacing: 12) {
Text("Typography")
.font(.subheadline)
.fontWeight(.semibold)
Picker("Font Design", selection: fontDesignBinding) {
Text("Rounded").tag(1)
Text("Default").tag(0)
Text("Monospaced").tag(2)
Text("Serif").tag(3)
}
}
VStack(alignment: .leading, spacing: 12) {
Text("Colors")
.font(.subheadline)
.fontWeight(.semibold)
ColorPicker("Font Color", selection: colorBinding(for: $settings.fontColorLight, stringBindingDark: $settings.fontColorDark, defaultColor: .primary))
ColorPicker("Neon Color", selection: colorBinding(for: $settings.neonColorLight, stringBindingDark: $settings.neonColorDark, defaultColor: .white))
ColorPicker("Glow Color", selection: colorBinding(for: $settings.glowColorLight, stringBindingDark: $settings.glowColorDark, defaultColor: .accentColor))
}
Button("Reset to Defaults") {
settings.resetToDefaults()
}
.padding(.top, 10)
}
.padding(24)
}
}
}
private func previewText(_ text: String, isActive: Bool) -> some View {
Text(text)
.font(.system(size: isActive ? 24 : 18, weight: isActive ? .bold : .medium, design: settings.getFontDesign(isDark: previewIsDark)))
.foregroundColor((previewNeon && isActive) ? settings.getNeonColor(isDark: previewIsDark) : settings.getFontColor(isDark: previewIsDark, isActive: isActive))
.shadow(color: (previewNeon && isActive) ? settings.getNeonColor(isDark: previewIsDark).opacity(0.8) : .clear, radius: 10, x: 0, y: 0)
.shadow(color: (previewNeon && isActive) ? settings.getNeonColor(isDark: previewIsDark).opacity(0.4) : .clear, radius: 20, x: 0, y: 0)
.shadow(color: (previewGlow && isActive) ? settings.getGlowColor(isDark: previewIsDark).opacity(0.8) : .clear, radius: 15, x: 0, y: 0)
.shadow(color: (previewGlow && isActive) ? settings.getGlowColor(isDark: previewIsDark).opacity(0.5) : .clear, radius: 5, x: 0, y: 0)
}
}

View File

@ -48,7 +48,7 @@ struct MutagenInstallerView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.black.opacity(0.1))
.background(Color.secondary.opacity(0.1))
.cornerRadius(12)
// Progress Bar
@ -134,33 +134,3 @@ struct MutagenInstallerView: View {
}
}
struct HoverButton: View {
let title: LocalizedStringKey
let isPrimary: Bool
let action: () -> Void
@State private var isHovered = false
var body: some View {
Button(action: action) {
Text(title)
.font(.headline)
.foregroundColor(isPrimary ? .white : .primary)
.frame(maxWidth: .infinity, minHeight: 44)
.background(isPrimary ? Color.accentColor.opacity(0.6) : Color.secondary.opacity(0.2))
.glassEffect(.regular.interactive(), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.scaleEffect(isHovered ? 1.02 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered)
.onHover { hovering in
isHovered = hovering
if hovering {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
}
.buttonStyle(.plain)
}
}

View File

@ -75,381 +75,4 @@ struct SettingsView: View {
}
}
struct GeneralSettingsView: View {
@AppStorage("isGlowEffectEnabled") private var isGlowEffectEnabled = false
@AppStorage("isNeonEffectEnabled") private var isNeonEffectEnabled = false
@AppStorage("miniPlayerGlassMode") private var miniPlayerGlassMode = 0
@AppStorage("appTheme") private var appTheme = 0
var body: some View {
Form {
Section(header: Text("Appearance")) {
VStack(alignment: .leading, spacing: 12) {
Text("App Theme")
ThemeSelectionView(selection: $appTheme)
}
VStack(alignment: .leading, spacing: 4) {
Text("Mini Player Appearance")
.font(.body)
Text("Choose an appearance for the mini player.")
.font(.caption)
.foregroundColor(.secondary)
LiquidGlassSelectionView(selection: $miniPlayerGlassMode)
.padding(.top, 4)
}
.padding(.vertical, 8)
}
Section(header: Text("Synced Lyrics Effects")) {
Toggle("Glow Effect", isOn: $isGlowEffectEnabled)
Toggle("Neon Effect", isOn: $isNeonEffectEnabled)
}
}
.formStyle(.grouped)
}
}
struct LyricsAppearanceView: View {
@ObservedObject var settings = LyricsAppearanceManager.shared
@State private var previewIsDark = true
@State private var previewNeon = false
@State private var previewGlow = false
@State private var applyMode = 0 // 0: Both Themes, 1: Specific Theme
// Bindings for the currently selected theme to edit
private var isEditingDark: Bool {
if applyMode == 0 { return previewIsDark }
return previewIsDark // Wait, if specific theme, it edits the preview's current theme.
}
private var fontDesignBinding: Binding<Int> {
Binding(
get: { isEditingDark ? settings.fontDesignDark : settings.fontDesignLight },
set: { newValue in
if applyMode == 0 {
settings.fontDesignDark = newValue
settings.fontDesignLight = newValue
} else {
if isEditingDark { settings.fontDesignDark = newValue }
else { settings.fontDesignLight = newValue }
}
}
)
}
private func colorBinding(for stringBindingLight: Binding<String>, stringBindingDark: Binding<String>, defaultColor: Color) -> Binding<Color> {
Binding<Color>(
get: {
let hex = isEditingDark ? stringBindingDark.wrappedValue : stringBindingLight.wrappedValue
return hex.isEmpty ? defaultColor : Color(hex: hex)
},
set: { newValue in
let hex = newValue.toHex()
if applyMode == 0 {
stringBindingDark.wrappedValue = hex
stringBindingLight.wrappedValue = hex
} else {
if isEditingDark { stringBindingDark.wrappedValue = hex }
else { stringBindingLight.wrappedValue = hex }
}
}
)
}
var body: some View {
VStack(spacing: 0) {
// PREVIEW SECTION
VStack {
HStack {
Text("Preview")
.font(.headline)
.foregroundColor(.primary)
Spacer()
Toggle("Dark Mode", isOn: $previewIsDark)
.toggleStyle(.switch)
.controlSize(.mini)
}
.padding()
ZStack {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(previewIsDark ? Color(white: 0.1) : Color(white: 0.95))
VStack(spacing: 16) {
previewText("Lorem ipsum dolor sit amet", isActive: false)
previewText("Consectetur adipiscing elit", isActive: true)
previewText("Sed do eiusmod tempor", isActive: false)
}
}
.frame(height: 180)
.padding(.horizontal)
.padding(.bottom)
}
.background(Color(NSColor.windowBackgroundColor))
Divider()
// SETTINGS SECTION
ScrollView {
VStack(spacing: 24) {
Picker("Apply to:", selection: $applyMode) {
Text("Both Themes").tag(0)
Text(previewIsDark ? "Dark Theme Only" : "Light Theme Only").tag(1)
}
.pickerStyle(SegmentedPickerStyle())
HStack {
Toggle("Neon Effect", isOn: $previewNeon)
Spacer()
Toggle("Glow Effect", isOn: $previewGlow)
}
VStack(alignment: .leading, spacing: 12) {
Text("Typography")
.font(.subheadline)
.fontWeight(.semibold)
Picker("Font Design", selection: fontDesignBinding) {
Text("Rounded").tag(1)
Text("Default").tag(0)
Text("Monospaced").tag(2)
Text("Serif").tag(3)
}
}
VStack(alignment: .leading, spacing: 12) {
Text("Colors")
.font(.subheadline)
.fontWeight(.semibold)
ColorPicker("Font Color", selection: colorBinding(for: $settings.fontColorLight, stringBindingDark: $settings.fontColorDark, defaultColor: .primary))
ColorPicker("Neon Color", selection: colorBinding(for: $settings.neonColorLight, stringBindingDark: $settings.neonColorDark, defaultColor: .white))
ColorPicker("Glow Color", selection: colorBinding(for: $settings.glowColorLight, stringBindingDark: $settings.glowColorDark, defaultColor: .accentColor))
}
Button("Reset to Defaults") {
settings.resetToDefaults()
}
.padding(.top, 10)
}
.padding(24)
}
}
}
private func previewText(_ text: String, isActive: Bool) -> some View {
Text(text)
.font(.system(size: isActive ? 24 : 18, weight: isActive ? .bold : .medium, design: settings.getFontDesign(isDark: previewIsDark)))
.foregroundColor((previewNeon && isActive) ? settings.getNeonColor(isDark: previewIsDark) : settings.getFontColor(isDark: previewIsDark, isActive: isActive))
.shadow(color: (previewNeon && isActive) ? settings.getNeonColor(isDark: previewIsDark).opacity(0.8) : .clear, radius: 10, x: 0, y: 0)
.shadow(color: (previewNeon && isActive) ? settings.getNeonColor(isDark: previewIsDark).opacity(0.4) : .clear, radius: 20, x: 0, y: 0)
.shadow(color: (previewGlow && isActive) ? settings.getGlowColor(isDark: previewIsDark).opacity(0.8) : .clear, radius: 15, x: 0, y: 0)
.shadow(color: (previewGlow && isActive) ? settings.getGlowColor(isDark: previewIsDark).opacity(0.5) : .clear, radius: 5, x: 0, y: 0)
}
}
enum ThemeStyle {
case system, light, dark
}
struct ThemeSelectionView: View {
@Binding var selection: Int
var body: some View {
HStack(spacing: 20) {
ThemeThumbnail(title: "Follow System", isSelected: selection == 0, style: .system) {
selection = 0
}
ThemeThumbnail(title: "Light", isSelected: selection == 1, style: .light) {
selection = 1
}
ThemeThumbnail(title: "Dark", isSelected: selection == 2, style: .dark) {
selection = 2
}
}
.padding(.vertical, 8)
}
}
struct ThemeThumbnail: View {
let title: LocalizedStringKey
let isSelected: Bool
let style: ThemeStyle
let action: () -> Void
var body: some View {
VStack(spacing: 8) {
Button(action: action) {
ZStack {
// Background
RoundedRectangle(cornerRadius: 8)
.fill(Color.clear)
.frame(width: 76, height: 52)
if style == .system {
HStack(spacing: 0) {
ZStack {
LinearGradient(colors: [.cyan.opacity(0.6), .white], startPoint: .topLeading, endPoint: .bottomTrailing)
WindowGraphic(isDark: false)
}
ZStack {
LinearGradient(colors: [.blue.opacity(0.8), .black], startPoint: .topLeading, endPoint: .bottomTrailing)
WindowGraphic(isDark: true)
}
}
.frame(width: 72, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
let isDark = style == .dark
ZStack {
if isDark {
LinearGradient(colors: [.blue.opacity(0.8), .black], startPoint: .topLeading, endPoint: .bottomTrailing)
} else {
LinearGradient(colors: [.cyan.opacity(0.6), .white], startPoint: .topLeading, endPoint: .bottomTrailing)
}
WindowGraphic(isDark: isDark)
}
.frame(width: 72, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
if isSelected {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.accentColor, lineWidth: 3)
.frame(width: 76, height: 52)
}
}
}
.buttonStyle(.plain)
Text(title)
.font(.system(size: 11, weight: isSelected ? .bold : .regular))
}
}
}
struct WindowGraphic: View {
let isDark: Bool
var body: some View {
VStack(spacing: 0) {
Spacer().frame(height: 12)
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 4)
.fill(isDark ? Color(white: 0.1) : .white)
.shadow(color: .black.opacity(0.2), radius: 2, y: 1)
// Content bar
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor)
.frame(width: 40, height: 8)
.padding(.leading, 8)
.padding(.top, 8)
// Traffic lights
HStack(spacing: 2) {
Circle().fill(Color.red).frame(width: 4, height: 4)
Circle().fill(Color.yellow).frame(width: 4, height: 4)
Circle().fill(Color.green).frame(width: 4, height: 4)
}
.padding(.leading, 8)
.padding(.top, 24)
}
.frame(width: 56, height: 40)
}
}
}
struct LiquidGlassSelectionView: View {
@Binding var selection: Int
var body: some View {
HStack(spacing: 20) {
LiquidGlassThumbnail(title: "Transparent", isSelected: selection == 1, isTinted: false) {
selection = 1
}
LiquidGlassThumbnail(title: "Tinted", isSelected: selection == 0, isTinted: true) {
selection = 0
}
}
.padding(.vertical, 8)
}
}
struct LiquidGlassThumbnail: View {
let title: LocalizedStringKey
let isSelected: Bool
let isTinted: Bool
let action: () -> Void
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack(spacing: 8) {
Button(action: action) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.clear)
.frame(width: 86, height: 60)
let isDark = colorScheme == .dark
ZStack {
if isDark {
LinearGradient(colors: [.blue.opacity(0.8), .black], startPoint: .topLeading, endPoint: .bottomTrailing)
} else {
LinearGradient(colors: [.cyan.opacity(0.6), .white], startPoint: .topLeading, endPoint: .bottomTrailing)
}
if isTinted {
RoundedRectangle(cornerRadius: 12)
.fill(isDark ? Color.accentColor.opacity(0.4) : Color(white: 1.0).opacity(0.7))
.background(.regularMaterial)
.shadow(color: .black.opacity(0.2), radius: 2, y: 1)
.frame(width: 50, height: 26)
.clipShape(RoundedRectangle(cornerRadius: 12))
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.clear)
.background(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color(white: 1.0).opacity(isDark ? 0.2 : 0.6), lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.2), radius: 2, y: 1)
.frame(width: 50, height: 26)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.frame(width: 80, height: 54)
.clipShape(RoundedRectangle(cornerRadius: 8))
if isSelected {
RoundedRectangle(cornerRadius: 10)
.stroke(Color.accentColor, lineWidth: 3)
.frame(width: 86, height: 60)
}
}
}
.buttonStyle(.plain)
Text(title)
.font(.system(size: 11, weight: isSelected ? .bold : .regular))
}
}
}
struct WindowButtonsHider: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
if let window = view.window {
window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
}
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}

View File

@ -13,5 +13,5 @@
<choice id="dev.jesuschapman.Valentine" visible="false">
<pkg-ref id="dev.jesuschapman.Valentine"/>
</choice>
<pkg-ref id="dev.jesuschapman.Valentine" version="1.1 - 002" onConclusion="none">Valentine-component.pkg</pkg-ref>
<pkg-ref id="dev.jesuschapman.Valentine" version="1.2 - 003" onConclusion="none">Valentine-component.pkg</pkg-ref>
</installer-gui-script>