mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #109 from Ebullioscopic/feature/media
Lyrics Feature
This commit is contained in:
commit
53dd8be77f
14 changed files with 918 additions and 109 deletions
|
|
@ -383,6 +383,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
AUTOMATION_APPLE_EVENTS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
|
|
@ -394,6 +395,12 @@
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||||
|
ENABLE_RESOURCE_ACCESS_CALENDARS = YES;
|
||||||
|
ENABLE_RESOURCE_ACCESS_CAMERA = YES;
|
||||||
|
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||||
|
ENABLE_RESOURCE_ACCESS_LOCATION = YES;
|
||||||
|
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)",
|
"$(PROJECT_DIR)",
|
||||||
|
|
@ -423,6 +430,12 @@
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
|
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
|
||||||
PRODUCT_NAME = Atoll;
|
PRODUCT_NAME = Atoll;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO;
|
||||||
|
RUNTIME_EXCEPTION_ALLOW_JIT = NO;
|
||||||
|
RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO;
|
||||||
|
RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO;
|
||||||
|
RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO;
|
||||||
|
RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
SYSTEM_FRAMEWORK_SEARCH_PATHS = (
|
SYSTEM_FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
|
@ -437,6 +450,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
AUTOMATION_APPLE_EVENTS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
|
||||||
|
|
@ -448,6 +462,12 @@
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
|
||||||
|
ENABLE_RESOURCE_ACCESS_CALENDARS = YES;
|
||||||
|
ENABLE_RESOURCE_ACCESS_CAMERA = YES;
|
||||||
|
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
|
||||||
|
ENABLE_RESOURCE_ACCESS_LOCATION = YES;
|
||||||
|
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)",
|
"$(PROJECT_DIR)",
|
||||||
|
|
@ -477,6 +497,12 @@
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
|
PRODUCT_BUNDLE_IDENTIFIER = com.Ebullioscopic.Atoll;
|
||||||
PRODUCT_NAME = Atoll;
|
PRODUCT_NAME = Atoll;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO;
|
||||||
|
RUNTIME_EXCEPTION_ALLOW_JIT = NO;
|
||||||
|
RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO;
|
||||||
|
RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO;
|
||||||
|
RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO;
|
||||||
|
RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
SYSTEM_FRAMEWORK_SEARCH_PATHS = (
|
SYSTEM_FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,6 @@ struct ContentView: View {
|
||||||
// Dynamic sizing based on view type and graph count with smooth transitions
|
// Dynamic sizing based on view type and graph count with smooth transitions
|
||||||
var dynamicNotchSize: CGSize {
|
var dynamicNotchSize: CGSize {
|
||||||
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||||||
|
|
||||||
if Defaults[.enableMinimalisticUI] && vm.notchState == .open {
|
|
||||||
let reminderCount = reminderManager.activeWindowReminders.count
|
|
||||||
let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: reminderCount)
|
|
||||||
baseSize.height += extraHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
guard coordinator.currentView == .stats else {
|
guard coordinator.currentView == .stats else {
|
||||||
return baseSize
|
return baseSize
|
||||||
|
|
|
||||||
|
|
@ -278,12 +278,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
// Use minimalistic or normal size based on settings
|
// Use minimalistic or normal size based on settings
|
||||||
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||||||
|
|
||||||
if Defaults[.enableMinimalisticUI] && vm.notchState == .open {
|
|
||||||
let reminderCount = ReminderLiveActivityManager.shared.activeWindowReminders.count
|
|
||||||
let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: reminderCount)
|
|
||||||
baseSize.height += extraHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only apply dynamic sizing when on stats tab and stats are enabled
|
// Only apply dynamic sizing when on stats tab and stats are enabled
|
||||||
guard coordinator.currentView == .stats && Defaults[.enableStatsFeature] else {
|
guard coordinator.currentView == .stats && Defaults[.enableStatsFeature] else {
|
||||||
|
|
@ -367,6 +361,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
// Migrate legacy progress bar settings
|
// Migrate legacy progress bar settings
|
||||||
Defaults.Keys.migrateProgressBarStyle()
|
Defaults.Keys.migrateProgressBarStyle()
|
||||||
|
Defaults.Keys.migrateMusicAuxControls()
|
||||||
|
|
||||||
// Initialize idle animations (load bundled + built-in face)
|
// Initialize idle animations (load bundled + built-in face)
|
||||||
idleAnimationManager.initializeDefaultAnimations()
|
idleAnimationManager.initializeDefaultAnimations()
|
||||||
|
|
|
||||||
|
|
@ -10497,6 +10497,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Left button" : {
|
||||||
|
"comment" : "A label for a picker that lets the user select a control for the left button on a media player.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"License key" : {
|
"License key" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -15403,10 +15407,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Replace repeat button with media output control" : {
|
|
||||||
"comment" : "A toggle that allows users to replace the repeat button with a media output control.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Replaces system HUD notifications with Dynamic Island displays." : {
|
"Replaces system HUD notifications with Dynamic Island displays." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -15582,6 +15582,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Right button" : {
|
||||||
|
"comment" : "A label for the right button in the media controls picker.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Rotation:" : {
|
"Rotation:" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -16791,6 +16795,10 @@
|
||||||
},
|
},
|
||||||
"Show floating pause/stop controls" : {
|
"Show floating pause/stop controls" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Show media output control in other layouts" : {
|
||||||
|
"comment" : "A toggle that lets the user enable or disable the media output control in layouts other than the home screen.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Show on a specific display" : {
|
"Show on a specific display" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,20 @@ struct HoverButton: View {
|
||||||
var icon: String
|
var icon: String
|
||||||
var iconColor: Color = .white;
|
var iconColor: Color = .white;
|
||||||
var scale: Image.Scale = .medium
|
var scale: Image.Scale = .medium
|
||||||
|
var pressEffect: PressEffect? = nil
|
||||||
var action: () -> Void
|
var action: () -> Void
|
||||||
var contentTransition: ContentTransition = .symbolEffect;
|
var contentTransition: ContentTransition = .symbolEffect;
|
||||||
|
|
||||||
@State private var isHovering = false
|
@State private var isHovering = false
|
||||||
|
@State private var pressOffset: CGFloat = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let size = CGFloat(scale == .large ? 40 : 30)
|
let size = CGFloat(scale == .large ? 40 : 30)
|
||||||
|
|
||||||
Button(action: action) {
|
Button(action: {
|
||||||
|
triggerPressEffect()
|
||||||
|
action()
|
||||||
|
}) {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.clear)
|
.fill(.clear)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
|
@ -37,10 +42,31 @@ struct HoverButton: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.offset(x: pressOffset)
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
withAnimation(.smooth(duration: 0.3)) {
|
withAnimation(.smooth(duration: 0.3)) {
|
||||||
isHovering = hovering
|
isHovering = hovering
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func triggerPressEffect() {
|
||||||
|
guard let pressEffect else { return }
|
||||||
|
|
||||||
|
switch pressEffect {
|
||||||
|
case .nudge(let amount):
|
||||||
|
withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) {
|
||||||
|
pressOffset = amount
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
|
||||||
|
withAnimation(.spring(response: 0.28, dampingFraction: 0.72)) {
|
||||||
|
pressOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PressEffect {
|
||||||
|
case nudge(CGFloat)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,41 +9,64 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Defaults
|
import Defaults
|
||||||
|
|
||||||
|
// Note: lyrics display is inlined into the main minimalistic view below and is controlled by Defaults[.enableLyrics]
|
||||||
|
|
||||||
struct MinimalisticMusicView: View {
|
struct MinimalisticMusicView: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
@ObservedObject var musicManager = MusicManager.shared
|
@ObservedObject var musicManager = MusicManager.shared
|
||||||
|
@Default(.enableLyrics) var enableLyrics
|
||||||
@State private var isHovering: Bool = false
|
@State private var isHovering: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
VStack(spacing: 2) {
|
||||||
// Left: Album Art
|
// Main content row
|
||||||
albumArtView
|
HStack(spacing: 0) {
|
||||||
|
// Left: Album Art
|
||||||
// Middle: Song Title (scrolling if needed)
|
albumArtView
|
||||||
Rectangle()
|
|
||||||
.fill(.black)
|
// Middle: Song Title, Artist and Lyrics (lyrics shown under artist when enabled)
|
||||||
.overlay(
|
Rectangle()
|
||||||
GeometryReader { geo in
|
.fill(.black)
|
||||||
HStack(alignment: .center) {
|
.overlay(
|
||||||
if !musicManager.songTitle.isEmpty {
|
GeometryReader { geo in
|
||||||
MarqueeText(
|
VStack(alignment: .center, spacing: 2) {
|
||||||
$musicManager.songTitle,
|
if !musicManager.songTitle.isEmpty {
|
||||||
font: .system(size: 12, weight: .semibold),
|
MarqueeText(
|
||||||
nsFont: .subheadline,
|
$musicManager.songTitle,
|
||||||
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
|
font: .system(size: 12, weight: .semibold),
|
||||||
minDuration: 0.4,
|
nsFont: .subheadline,
|
||||||
frameWidth: max(0, geo.size.width - 8)
|
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
|
||||||
)
|
minDuration: 0.4,
|
||||||
|
frameWidth: max(0, geo.size.width - 8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artist name
|
||||||
|
if !musicManager.artistName.isEmpty {
|
||||||
|
Text(musicManager.artistName)
|
||||||
|
.font(.system(size: 10, weight: .regular))
|
||||||
|
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lyrics under the author name (same font size as author)
|
||||||
|
if enableLyrics {
|
||||||
|
lyricsLineView
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
|
||||||
}
|
}
|
||||||
.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
|
)
|
||||||
}
|
.frame(width: vm.closedNotchSize.width)
|
||||||
)
|
|
||||||
.frame(width: vm.closedNotchSize.width)
|
// Right: Music Visualizer
|
||||||
|
visualizerView
|
||||||
// Right: Music Visualizer
|
}
|
||||||
visualizerView
|
|
||||||
|
// (lyrics are displayed inline under the artist name)
|
||||||
}
|
}
|
||||||
|
// reserve extra height when lyrics are enabled
|
||||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
isHovering = hovering
|
isHovering = hovering
|
||||||
|
|
@ -87,3 +110,33 @@ struct MinimalisticMusicView: View {
|
||||||
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
|
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension MinimalisticMusicView {
|
||||||
|
var lyricsLineView: some View {
|
||||||
|
let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
return HStack(spacing: 6) {
|
||||||
|
if !line.isEmpty {
|
||||||
|
Image(systemName: "music.note")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.symbolRenderingMode(.monochrome)
|
||||||
|
|
||||||
|
Text(line)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.88))
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.id(line)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .move(edge: .bottom).combined(with: .opacity),
|
||||||
|
removal: .move(edge: .top).combined(with: .opacity)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.animation(.smooth(duration: 0.32), value: line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,16 @@ import Defaults
|
||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Lyrics are shown/hidden only via Defaults[.enableLyrics] in settings. Inline display is used in the player views.
|
||||||
|
|
||||||
struct MinimalisticMusicPlayerView: View {
|
struct MinimalisticMusicPlayerView: View {
|
||||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
let albumArtNamespace: Namespace.ID
|
let albumArtNamespace: Namespace.ID
|
||||||
@Default(.showMediaOutputControl) var showMediaOutputControl
|
@Default(.musicAuxLeftControl) private var leftAuxControl
|
||||||
|
@Default(.musicAuxRightControl) private var rightAuxControl
|
||||||
@ObservedObject private var reminderManager = ReminderLiveActivityManager.shared
|
@ObservedObject private var reminderManager = ReminderLiveActivityManager.shared
|
||||||
@Default(.enableReminderLiveActivity) private var enableReminderLiveActivity
|
@Default(.enableReminderLiveActivity) private var enableReminderLiveActivity
|
||||||
|
@Default(.enableLyrics) private var enableLyrics
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
@ -46,6 +50,7 @@ struct MinimalisticMusicPlayerView: View {
|
||||||
.font(.system(size: 10, weight: .regular))
|
.font(.system(size: 10, weight: .regular))
|
||||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
}
|
}
|
||||||
.frame(width: textWidth, alignment: .leading)
|
.frame(width: textWidth, alignment: .leading)
|
||||||
|
|
||||||
|
|
@ -65,12 +70,77 @@ struct MinimalisticMusicPlayerView: View {
|
||||||
playbackControls
|
playbackControls
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
if enableLyrics {
|
||||||
|
lyricsView
|
||||||
|
.padding(.top, 10)
|
||||||
|
}
|
||||||
|
|
||||||
reminderList
|
reminderList
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.top, -15)
|
.padding(.top, -15)
|
||||||
.padding(.bottom, shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding)
|
.padding(.bottom, ReminderLiveActivityManager.baselineMinimalisticBottomPadding)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: calculateDynamicHeight(), alignment: .top)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TypingLyricView
|
||||||
|
|
||||||
|
struct TypingLyricView: View {
|
||||||
|
let text: String
|
||||||
|
let color: Color
|
||||||
|
let id: Int
|
||||||
|
let playbackRate: Double
|
||||||
|
let isPlaying: Bool
|
||||||
|
@State private var displayed: String = ""
|
||||||
|
@State private var lastText: String = ""
|
||||||
|
@State private var animationTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(displayed)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(color)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.id(id)
|
||||||
|
.onChange(of: text) { _, newText in
|
||||||
|
animateTyping(newText)
|
||||||
|
}
|
||||||
|
.onChange(of: isPlaying) { _, playing in
|
||||||
|
if !playing {
|
||||||
|
animationTask?.cancel()
|
||||||
|
} else if displayed != text {
|
||||||
|
animateTyping(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
animateTyping(text)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
animationTask?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func animateTyping(_ newText: String) {
|
||||||
|
animationTask?.cancel()
|
||||||
|
displayed = ""
|
||||||
|
lastText = newText
|
||||||
|
let chars = Array(newText)
|
||||||
|
|
||||||
|
animationTask = Task {
|
||||||
|
for (i, c) in chars.enumerated() {
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
try? await Task.sleep(for: .milliseconds(Int(30 / max(playbackRate, 0.1))))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
if lastText == newText {
|
||||||
|
displayed += String(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var reminderEntries: [ReminderLiveActivityManager.ReminderEntry] {
|
private var reminderEntries: [ReminderLiveActivityManager.ReminderEntry] {
|
||||||
|
|
@ -85,6 +155,34 @@ struct MinimalisticMusicPlayerView: View {
|
||||||
ReminderLiveActivityManager.additionalHeight(forRowCount: reminderEntries.count)
|
ReminderLiveActivityManager.additionalHeight(forRowCount: reminderEntries.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func calculateDynamicHeight() -> CGFloat {
|
||||||
|
var height: CGFloat = 50 // Base height for header
|
||||||
|
|
||||||
|
// Add progress bar height
|
||||||
|
height += 6 + 4 // progress bar + top padding
|
||||||
|
|
||||||
|
// Add playback controls height
|
||||||
|
height += 40 + 2 // controls + top padding
|
||||||
|
|
||||||
|
// Add lyrics height if enabled in settings (reserve space even while loading)
|
||||||
|
if enableLyrics {
|
||||||
|
let lyricsTopPadding: CGFloat = 10
|
||||||
|
let lyricsEstimatedHeight: CGFloat = 34
|
||||||
|
height += lyricsTopPadding + lyricsEstimatedHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reminder list height if showing
|
||||||
|
if shouldShowReminderList {
|
||||||
|
height += reminderListHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add padding
|
||||||
|
height += 15 // top padding
|
||||||
|
height += ReminderLiveActivityManager.baselineMinimalisticBottomPadding
|
||||||
|
|
||||||
|
return height
|
||||||
|
}
|
||||||
|
|
||||||
private var reminderList: some View {
|
private var reminderList: some View {
|
||||||
MinimalisticReminderEventListView(reminders: reminderEntries)
|
MinimalisticReminderEventListView(reminders: reminderEntries)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
@ -93,6 +191,36 @@ struct MinimalisticMusicPlayerView: View {
|
||||||
.animation(.easeInOut(duration: 0.18), value: shouldShowReminderList)
|
.animation(.easeInOut(duration: 0.18), value: shouldShowReminderList)
|
||||||
.environmentObject(vm)
|
.environmentObject(vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var lyricsView: some View {
|
||||||
|
let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let transition: AnyTransition = .asymmetric(
|
||||||
|
insertion: .move(edge: .bottom).combined(with: .opacity),
|
||||||
|
removal: .move(edge: .top).combined(with: .opacity)
|
||||||
|
)
|
||||||
|
|
||||||
|
return HStack(spacing: 6) {
|
||||||
|
if !line.isEmpty {
|
||||||
|
Image(systemName: "music.note")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.symbolRenderingMode(.monochrome)
|
||||||
|
|
||||||
|
Text(line)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(.white.opacity(0.88))
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.trailing, 6)
|
||||||
|
.id(line)
|
||||||
|
.transition(transition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.animation(.smooth(duration: 0.32), value: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private struct MinimalisticReminderEventListView: View {
|
private struct MinimalisticReminderEventListView: View {
|
||||||
|
|
@ -472,31 +600,25 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
// MARK: - Playback Controls (Larger)
|
// MARK: - Playback Controls (Larger)
|
||||||
|
|
||||||
private var playbackControls: some View {
|
private var playbackControls: some View {
|
||||||
HStack(spacing: 20) {
|
let controls = resolvedAuxControls
|
||||||
|
|
||||||
|
return HStack(spacing: 16) {
|
||||||
if Defaults[.showShuffleAndRepeat] {
|
if Defaults[.showShuffleAndRepeat] {
|
||||||
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
auxButton(for: controls.left)
|
||||||
Task { await musicManager.toggleShuffle() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
controlButton(icon: "backward.fill", size: 18) {
|
controlButton(icon: "backward.fill", size: 18, pressEffect: .nudge(-8)) {
|
||||||
Task { await musicManager.previousTrack() }
|
Task { await musicManager.previousTrack() }
|
||||||
}
|
}
|
||||||
|
|
||||||
playPauseButton
|
playPauseButton
|
||||||
|
|
||||||
controlButton(icon: "forward.fill", size: 18) {
|
controlButton(icon: "forward.fill", size: 18, pressEffect: .nudge(8)) {
|
||||||
Task { await musicManager.nextTrack() }
|
Task { await musicManager.nextTrack() }
|
||||||
}
|
}
|
||||||
|
|
||||||
if Defaults[.showShuffleAndRepeat] {
|
if Defaults[.showShuffleAndRepeat] {
|
||||||
if showMediaOutputControl {
|
auxButton(for: controls.right)
|
||||||
MinimalisticMediaOutputButton()
|
|
||||||
} else {
|
|
||||||
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
|
|
||||||
Task { await musicManager.toggleRepeat() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
@ -511,13 +633,22 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
frameSize: CGSize(width: 54, height: 54),
|
frameSize: CGSize(width: 54, height: 54),
|
||||||
cornerRadius: 20,
|
cornerRadius: 20,
|
||||||
foregroundColor: .white,
|
foregroundColor: .white,
|
||||||
|
pressEffect: .none,
|
||||||
|
symbolEffectStyle: .replace,
|
||||||
action: {
|
action: {
|
||||||
Task { await musicManager.togglePlay() }
|
Task { await musicManager.togglePlay() }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func controlButton(icon: String, size: CGFloat = 18, isActive: Bool = false, action: @escaping () -> Void) -> some View {
|
private func controlButton(
|
||||||
|
icon: String,
|
||||||
|
size: CGFloat = 18,
|
||||||
|
isActive: Bool = false,
|
||||||
|
pressEffect: MinimalisticSquircircleButton.PressEffect = .none,
|
||||||
|
symbolEffect: MinimalisticSquircircleButton.SymbolEffectStyle = .none,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
MinimalisticSquircircleButton(
|
MinimalisticSquircircleButton(
|
||||||
icon: icon,
|
icon: icon,
|
||||||
fontSize: size,
|
fontSize: size,
|
||||||
|
|
@ -525,9 +656,42 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
frameSize: CGSize(width: 40, height: 40),
|
frameSize: CGSize(width: 40, height: 40),
|
||||||
cornerRadius: 16,
|
cornerRadius: 16,
|
||||||
foregroundColor: isActive ? .red : .white.opacity(0.85),
|
foregroundColor: isActive ? .red : .white.opacity(0.85),
|
||||||
|
pressEffect: pressEffect,
|
||||||
|
symbolEffectStyle: symbolEffect,
|
||||||
action: action
|
action: action
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func auxButton(for control: MusicAuxiliaryControl) -> some View {
|
||||||
|
switch control {
|
||||||
|
case .shuffle:
|
||||||
|
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
||||||
|
Task { await musicManager.toggleShuffle() }
|
||||||
|
}
|
||||||
|
case .repeatMode:
|
||||||
|
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off, symbolEffect: .replace) {
|
||||||
|
Task { await musicManager.toggleRepeat() }
|
||||||
|
}
|
||||||
|
case .mediaOutput:
|
||||||
|
MinimalisticMediaOutputButton()
|
||||||
|
case .lyrics:
|
||||||
|
controlButton(
|
||||||
|
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
|
||||||
|
isActive: enableLyrics,
|
||||||
|
symbolEffect: .replace
|
||||||
|
) {
|
||||||
|
enableLyrics.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) {
|
||||||
|
guard leftAuxControl == rightAuxControl else {
|
||||||
|
return (leftAuxControl, rightAuxControl)
|
||||||
|
}
|
||||||
|
return (leftAuxControl, MusicAuxiliaryControl.alternative(excluding: leftAuxControl))
|
||||||
|
}
|
||||||
private struct MinimalisticMediaOutputButton: View {
|
private struct MinimalisticMediaOutputButton: View {
|
||||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||||
|
|
@ -542,7 +706,8 @@ private struct MinimalisticReminderDetailsView: View {
|
||||||
fontWeight: .medium,
|
fontWeight: .medium,
|
||||||
frameSize: CGSize(width: 40, height: 40),
|
frameSize: CGSize(width: 40, height: 40),
|
||||||
cornerRadius: 16,
|
cornerRadius: 16,
|
||||||
foregroundColor: .white.opacity(0.85)
|
foregroundColor: .white.opacity(0.85),
|
||||||
|
symbolEffectStyle: .replace
|
||||||
) {
|
) {
|
||||||
isPopoverPresented.toggle()
|
isPopoverPresented.toggle()
|
||||||
if isPopoverPresented {
|
if isPopoverPresented {
|
||||||
|
|
@ -655,15 +820,41 @@ private struct MinimalisticSquircircleButton: View {
|
||||||
let frameSize: CGSize
|
let frameSize: CGSize
|
||||||
let cornerRadius: CGFloat
|
let cornerRadius: CGFloat
|
||||||
let foregroundColor: Color
|
let foregroundColor: Color
|
||||||
|
let pressEffect: PressEffect
|
||||||
|
let symbolEffectStyle: SymbolEffectStyle
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
@State private var isHovering = false
|
@State private var isHovering = false
|
||||||
|
@State private var pressOffset: CGFloat = 0
|
||||||
|
|
||||||
|
init(
|
||||||
|
icon: String,
|
||||||
|
fontSize: CGFloat,
|
||||||
|
fontWeight: Font.Weight,
|
||||||
|
frameSize: CGSize,
|
||||||
|
cornerRadius: CGFloat,
|
||||||
|
foregroundColor: Color,
|
||||||
|
pressEffect: PressEffect = .none,
|
||||||
|
symbolEffectStyle: SymbolEffectStyle = .none,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.icon = icon
|
||||||
|
self.fontSize = fontSize
|
||||||
|
self.fontWeight = fontWeight
|
||||||
|
self.frameSize = frameSize
|
||||||
|
self.cornerRadius = cornerRadius
|
||||||
|
self.foregroundColor = foregroundColor
|
||||||
|
self.pressEffect = pressEffect
|
||||||
|
self.symbolEffectStyle = symbolEffectStyle
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button {
|
||||||
Image(systemName: icon)
|
triggerPressEffect()
|
||||||
.font(.system(size: fontSize, weight: fontWeight))
|
action()
|
||||||
.foregroundColor(foregroundColor)
|
} label: {
|
||||||
|
iconView()
|
||||||
.frame(width: frameSize.width, height: frameSize.height)
|
.frame(width: frameSize.width, height: frameSize.height)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||||
|
|
@ -672,10 +863,62 @@ private struct MinimalisticSquircircleButton: View {
|
||||||
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.offset(x: pressOffset)
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
withAnimation(.easeOut(duration: 0.18)) {
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
isHovering = hovering
|
isHovering = hovering
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func triggerPressEffect() {
|
||||||
|
guard case let .nudge(amount) = pressEffect else { return }
|
||||||
|
|
||||||
|
withAnimation(.spring(response: 0.16, dampingFraction: 0.72)) {
|
||||||
|
pressOffset = amount
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
|
||||||
|
withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) {
|
||||||
|
pressOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func iconView() -> some View {
|
||||||
|
let image = Image(systemName: icon)
|
||||||
|
.font(.system(size: fontSize, weight: fontWeight))
|
||||||
|
.foregroundColor(foregroundColor)
|
||||||
|
|
||||||
|
switch symbolEffectStyle {
|
||||||
|
case .none:
|
||||||
|
image
|
||||||
|
case .replace:
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
image
|
||||||
|
.contentTransition(.symbolEffect(.replace))
|
||||||
|
.id(icon)
|
||||||
|
} else {
|
||||||
|
image
|
||||||
|
}
|
||||||
|
case .bounce:
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
image.symbolEffect(.bounce, value: icon)
|
||||||
|
} else {
|
||||||
|
image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PressEffect {
|
||||||
|
case none
|
||||||
|
case nudge(CGFloat)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SymbolEffectStyle {
|
||||||
|
case none
|
||||||
|
case replace
|
||||||
|
case bounce
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,9 @@ struct MusicControlsView: View {
|
||||||
@State private var dragging: Bool = false
|
@State private var dragging: Bool = false
|
||||||
@State private var lastDragged: Date = .distantPast
|
@State private var lastDragged: Date = .distantPast
|
||||||
let showShuffleAndRepeat: Bool
|
let showShuffleAndRepeat: Bool
|
||||||
@Default(.showMediaOutputControl) private var showMediaOutputControl
|
@Default(.musicAuxLeftControl) private var leftAuxControl
|
||||||
|
@Default(.musicAuxRightControl) private var rightAuxControl
|
||||||
|
@Default(.enableLyrics) private var enableLyrics
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
|
|
@ -155,6 +157,35 @@ struct MusicControlsView: View {
|
||||||
frameWidth: width
|
frameWidth: width
|
||||||
)
|
)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
|
// Lyrics shown under the author name (same font size as author) when enabled in settings
|
||||||
|
if enableLyrics {
|
||||||
|
let transition = AnyTransition.asymmetric(
|
||||||
|
insertion: .move(edge: .bottom).combined(with: .opacity),
|
||||||
|
removal: .move(edge: .top).combined(with: .opacity)
|
||||||
|
)
|
||||||
|
|
||||||
|
let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
if !line.isEmpty {
|
||||||
|
let lyricsBinding = Binding<String>(
|
||||||
|
get: { musicManager.currentLyrics },
|
||||||
|
set: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
MarqueeText(
|
||||||
|
lyricsBinding,
|
||||||
|
font: .headline,
|
||||||
|
nsFont: .headline,
|
||||||
|
textColor: .white.opacity(0.8),
|
||||||
|
minDuration: 0.35,
|
||||||
|
frameWidth: width
|
||||||
|
)
|
||||||
|
.padding(.top, 2)
|
||||||
|
.id(line)
|
||||||
|
.transition(transition)
|
||||||
|
.animation(.easeInOut(duration: 0.32), value: line)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,32 +213,23 @@ struct MusicControlsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var playbackControls: some View {
|
private var playbackControls: some View {
|
||||||
HStack(spacing: 8) {
|
let controls = resolvedAuxControls
|
||||||
|
|
||||||
|
return HStack(spacing: 8) {
|
||||||
if showShuffleAndRepeat {
|
if showShuffleAndRepeat {
|
||||||
HoverButton(
|
auxButton(for: controls.left)
|
||||||
icon: "shuffle", iconColor: musicManager.isShuffled ? .red : .white,
|
|
||||||
scale: .medium
|
|
||||||
) {
|
|
||||||
MusicManager.shared.toggleShuffle()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
HoverButton(icon: "backward.fill", scale: .medium) {
|
HoverButton(icon: "backward.fill", scale: .medium, pressEffect: .nudge(-6)) {
|
||||||
MusicManager.shared.previousTrack()
|
MusicManager.shared.previousTrack()
|
||||||
}
|
}
|
||||||
HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) {
|
HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) {
|
||||||
MusicManager.shared.togglePlay()
|
MusicManager.shared.togglePlay()
|
||||||
}
|
}
|
||||||
HoverButton(icon: "forward.fill", scale: .medium) {
|
HoverButton(icon: "forward.fill", scale: .medium, pressEffect: .nudge(6)) {
|
||||||
MusicManager.shared.nextTrack()
|
MusicManager.shared.nextTrack()
|
||||||
}
|
}
|
||||||
if showShuffleAndRepeat {
|
if showShuffleAndRepeat {
|
||||||
if showMediaOutputControl {
|
auxButton(for: controls.right)
|
||||||
MediaOutputPickerButton()
|
|
||||||
} else {
|
|
||||||
HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
|
|
||||||
MusicManager.shared.toggleRepeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
@ -232,6 +254,45 @@ struct MusicControlsView: View {
|
||||||
return .red
|
return .red
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var resolvedAuxControls: (left: MusicAuxiliaryControl, right: MusicAuxiliaryControl) {
|
||||||
|
guard leftAuxControl == rightAuxControl else {
|
||||||
|
return (leftAuxControl, rightAuxControl)
|
||||||
|
}
|
||||||
|
return (leftAuxControl, MusicAuxiliaryControl.alternative(excluding: leftAuxControl))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func auxButton(for control: MusicAuxiliaryControl) -> some View {
|
||||||
|
switch control {
|
||||||
|
case .shuffle:
|
||||||
|
HoverButton(
|
||||||
|
icon: "shuffle",
|
||||||
|
iconColor: musicManager.isShuffled ? .red : .white,
|
||||||
|
scale: .medium
|
||||||
|
) {
|
||||||
|
MusicManager.shared.toggleShuffle()
|
||||||
|
}
|
||||||
|
case .repeatMode:
|
||||||
|
HoverButton(
|
||||||
|
icon: repeatIcon,
|
||||||
|
iconColor: repeatIconColor,
|
||||||
|
scale: .medium
|
||||||
|
) {
|
||||||
|
MusicManager.shared.toggleRepeat()
|
||||||
|
}
|
||||||
|
case .mediaOutput:
|
||||||
|
MediaOutputPickerButton()
|
||||||
|
case .lyrics:
|
||||||
|
HoverButton(
|
||||||
|
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
|
||||||
|
iconColor: enableLyrics ? .accentColor : .white,
|
||||||
|
scale: .medium
|
||||||
|
) {
|
||||||
|
enableLyrics.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Main View
|
// MARK: - Main View
|
||||||
|
|
|
||||||
|
|
@ -855,6 +855,10 @@ struct Media: View {
|
||||||
@Default(.sneakPeekStyles) var sneakPeekStyles
|
@Default(.sneakPeekStyles) var sneakPeekStyles
|
||||||
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
@Default(.enableMinimalisticUI) var enableMinimalisticUI
|
||||||
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
|
||||||
|
@Default(.musicAuxLeftControl) private var musicAuxLeftControl
|
||||||
|
@Default(.musicAuxRightControl) private var musicAuxRightControl
|
||||||
|
@State private var previousLeftAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxLeftControl]
|
||||||
|
@State private var previousRightAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxRightControl]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
|
@ -896,9 +900,21 @@ struct Media: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Defaults.Toggle(key: .showMediaOutputControl) {
|
Defaults.Toggle(key: .showMediaOutputControl) {
|
||||||
Text("Replace repeat button with media output control")
|
Text("Show media output control in other layouts")
|
||||||
}
|
}
|
||||||
.disabled(!showShuffleAndRepeat)
|
.disabled(!showShuffleAndRepeat)
|
||||||
|
if showShuffleAndRepeat {
|
||||||
|
Picker("Left button", selection: $musicAuxLeftControl) {
|
||||||
|
ForEach(MusicAuxiliaryControl.allCases) { control in
|
||||||
|
Text(control.displayName).tag(control)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker("Right button", selection: $musicAuxRightControl) {
|
||||||
|
ForEach(MusicAuxiliaryControl.allCases) { control in
|
||||||
|
Text(control.displayName).tag(control)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Media controls")
|
Text("Media controls")
|
||||||
}
|
}
|
||||||
|
|
@ -908,6 +924,7 @@ struct Media: View {
|
||||||
isOn: $coordinator.musicLiveActivityEnabled.animation()
|
isOn: $coordinator.musicLiveActivityEnabled.animation()
|
||||||
)
|
)
|
||||||
Toggle("Enable sneak peek", isOn: $enableSneakPeek)
|
Toggle("Enable sneak peek", isOn: $enableSneakPeek)
|
||||||
|
Defaults.Toggle("Enable lyrics", key: .enableLyrics)
|
||||||
Picker("Sneak Peek Style", selection: $sneakPeekStyles){
|
Picker("Sneak Peek Style", selection: $sneakPeekStyles){
|
||||||
ForEach(SneakPeekStyle.allCases) { style in
|
ForEach(SneakPeekStyle.allCases) { style in
|
||||||
Text(style.rawValue).tag(style)
|
Text(style.rawValue).tag(style)
|
||||||
|
|
@ -954,6 +971,42 @@ struct Media: View {
|
||||||
Defaults[.enableFullscreenMediaDetection] = hideNotchOption != .never
|
Defaults[.enableFullscreenMediaDetection] = hideNotchOption != .never
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
ensureAuxControlsUnique()
|
||||||
|
previousLeftAuxControl = musicAuxLeftControl
|
||||||
|
previousRightAuxControl = musicAuxRightControl
|
||||||
|
}
|
||||||
|
.onChange(of: musicAuxLeftControl) { newValue in
|
||||||
|
if newValue == musicAuxRightControl {
|
||||||
|
let fallback = MusicAuxiliaryControl.alternative(
|
||||||
|
excluding: newValue,
|
||||||
|
preferring: previousLeftAuxControl
|
||||||
|
)
|
||||||
|
if fallback != musicAuxRightControl {
|
||||||
|
musicAuxRightControl = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousLeftAuxControl = newValue
|
||||||
|
}
|
||||||
|
.onChange(of: musicAuxRightControl) { newValue in
|
||||||
|
if newValue == musicAuxLeftControl {
|
||||||
|
let fallback = MusicAuxiliaryControl.alternative(
|
||||||
|
excluding: newValue,
|
||||||
|
preferring: previousRightAuxControl
|
||||||
|
)
|
||||||
|
if fallback != musicAuxLeftControl {
|
||||||
|
musicAuxLeftControl = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousRightAuxControl = newValue
|
||||||
|
}
|
||||||
|
.onChange(of: showShuffleAndRepeat) { isEnabled in
|
||||||
|
if isEnabled {
|
||||||
|
ensureAuxControlsUnique()
|
||||||
|
}
|
||||||
|
}
|
||||||
.navigationTitle("Media")
|
.navigationTitle("Media")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -965,6 +1018,15 @@ struct Media: View {
|
||||||
return MediaControllerType.allCases
|
return MediaControllerType.allCases
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func ensureAuxControlsUnique() {
|
||||||
|
guard showShuffleAndRepeat, musicAuxLeftControl == musicAuxRightControl else { return }
|
||||||
|
|
||||||
|
let fallback = MusicAuxiliaryControl.alternative(excluding: musicAuxLeftControl)
|
||||||
|
if fallback != musicAuxRightControl {
|
||||||
|
musicAuxRightControl = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CalendarSettings: View {
|
struct CalendarSettings: View {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,21 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
import Combine
|
import Combine
|
||||||
import Defaults
|
import Defaults
|
||||||
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Lyric Data Structures
|
||||||
|
struct LyricLine: Identifiable, Codable {
|
||||||
|
let id = UUID()
|
||||||
|
let timestamp: TimeInterval
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
init(timestamp: TimeInterval, text: String) {
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.text = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let defaultImage: NSImage = .init(
|
let defaultImage: NSImage = .init(
|
||||||
systemSymbolName: "heart.fill",
|
systemSymbolName: "heart.fill",
|
||||||
accessibilityDescription: "Album Art"
|
accessibilityDescription: "Album Art"
|
||||||
|
|
@ -48,6 +61,15 @@ class MusicManager: ObservableObject {
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@Published var usingAppIconForArtwork: Bool = false
|
@Published var usingAppIconForArtwork: Bool = false
|
||||||
|
|
||||||
|
// MARK: - Lyrics Properties
|
||||||
|
@Published var currentLyrics: String = ""
|
||||||
|
@Published var syncedLyrics: [LyricLine] = []
|
||||||
|
@Published var showLyrics: Bool = false
|
||||||
|
@Published var currentLyricIndex: Int = -1
|
||||||
|
|
||||||
|
// Task used to periodically sync displayed lyric with playback position
|
||||||
|
private var lyricSyncTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var artworkData: Data? = nil
|
private var artworkData: Data? = nil
|
||||||
|
|
||||||
private var liveStreamUnknownDurationCount: Int = 0
|
private var liveStreamUnknownDurationCount: Int = 0
|
||||||
|
|
@ -212,13 +234,14 @@ class MusicManager: ObservableObject {
|
||||||
}
|
}
|
||||||
self.artworkData = state.artwork
|
self.artworkData = state.artwork
|
||||||
|
|
||||||
if artworkChanged || state.artwork == nil {
|
// Update last artwork change values
|
||||||
// Update last artwork change values
|
self.lastArtworkTitle = state.title
|
||||||
self.lastArtworkTitle = state.title
|
self.lastArtworkArtist = state.artist
|
||||||
self.lastArtworkArtist = state.artist
|
self.lastArtworkAlbum = state.album
|
||||||
self.lastArtworkAlbum = state.album
|
self.lastArtworkBundleIdentifier = state.bundleIdentifier
|
||||||
self.lastArtworkBundleIdentifier = state.bundleIdentifier
|
|
||||||
}
|
// Fetch lyrics for new track whenever content changes
|
||||||
|
self.fetchLyrics()
|
||||||
|
|
||||||
// Only update sneak peek if there's actual content and something changed
|
// Only update sneak peek if there's actual content and something changed
|
||||||
if !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
|
if !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
|
||||||
|
|
@ -246,6 +269,8 @@ class MusicManager: ObservableObject {
|
||||||
|
|
||||||
if timeChanged {
|
if timeChanged {
|
||||||
self.elapsedTime = state.currentTime
|
self.elapsedTime = state.currentTime
|
||||||
|
// Update current lyric based on elapsed time
|
||||||
|
self.updateCurrentLyric(for: state.currentTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
if durationChanged {
|
if durationChanged {
|
||||||
|
|
@ -270,6 +295,14 @@ class MusicManager: ObservableObject {
|
||||||
|
|
||||||
updateLiveStreamState(with: state)
|
updateLiveStreamState(with: state)
|
||||||
self.timestampDate = state.lastUpdated
|
self.timestampDate = state.lastUpdated
|
||||||
|
|
||||||
|
// Manage lyric sync task based on playback/lyrics availability
|
||||||
|
if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty {
|
||||||
|
// Ensure syncing runs while lyrics are enabled
|
||||||
|
startLyricSync()
|
||||||
|
} else {
|
||||||
|
stopLyricSync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func triggerFlipAnimation() {
|
private func triggerFlipAnimation() {
|
||||||
|
|
@ -509,6 +542,204 @@ class MusicManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Lyrics Methods
|
||||||
|
func fetchLyrics() {
|
||||||
|
guard Defaults[.enableLyrics] else { return }
|
||||||
|
// If the lyrics panel is visible already, provide immediate feedback
|
||||||
|
if showLyrics {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.currentLyrics = "Loading lyrics..."
|
||||||
|
self.syncedLyrics = []
|
||||||
|
self.currentLyricIndex = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let lyrics = try await fetchLyricsFromAPI(artist: artistName, title: songTitle)
|
||||||
|
await MainActor.run {
|
||||||
|
self.syncedLyrics = lyrics
|
||||||
|
self.currentLyricIndex = -1
|
||||||
|
if !lyrics.isEmpty {
|
||||||
|
self.currentLyrics = lyrics[0].text
|
||||||
|
} else {
|
||||||
|
self.currentLyrics = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// If lyrics are enabled, start syncing them to playback position
|
||||||
|
if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty {
|
||||||
|
self.startLyricSync()
|
||||||
|
} else if self.syncedLyrics.isEmpty {
|
||||||
|
self.stopLyricSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch lyrics: \(error)")
|
||||||
|
await MainActor.run {
|
||||||
|
self.syncedLyrics = []
|
||||||
|
self.currentLyrics = ""
|
||||||
|
self.currentLyricIndex = -1
|
||||||
|
self.stopLyricSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchLyricsFromAPI(artist: String, title: String) async throws -> [LyricLine] {
|
||||||
|
guard !artist.isEmpty, !title.isEmpty else { return [] }
|
||||||
|
|
||||||
|
// Normalize input and percent-encode
|
||||||
|
let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current)
|
||||||
|
let cleanTitle = title.folding(options: .diacriticInsensitive, locale: .current)
|
||||||
|
guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||||
|
let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use LRCLIB search endpoint which returns an array JSON with `plainLyrics` and/or `syncedLyrics`.
|
||||||
|
let urlString = "https://lrclib.net/api/search?track_name=\(encodedTitle)&artist_name=\(encodedArtist)"
|
||||||
|
guard let url = URL(string: urlString) else { return [] }
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(from: url)
|
||||||
|
if let http = response as? HTTPURLResponse, http.statusCode == 200 {
|
||||||
|
// Try parse as array JSON (preferred)
|
||||||
|
if let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
|
||||||
|
let first = jsonArray.first {
|
||||||
|
let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|
||||||
|
if !synced.isEmpty {
|
||||||
|
return parseLRC(synced)
|
||||||
|
} else if !plain.isEmpty {
|
||||||
|
return [LyricLine(timestamp: 0, text: plain)]
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: try to decode as UTF8 and handle as LRC or plain text
|
||||||
|
if let lrcString = String(data: data, encoding: .utf8), !lrcString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
// If it contains a syncedLyrics key in an object, try that
|
||||||
|
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||||
|
let synced = json["syncedLyrics"] as? String {
|
||||||
|
return parseLRC(synced)
|
||||||
|
}
|
||||||
|
// Otherwise treat as plain lyrics blob
|
||||||
|
return [LyricLine(timestamp: 0, text: lrcString.trimmingCharacters(in: .whitespacesAndNewlines))]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseLRC(_ lrc: String) -> [LyricLine] {
|
||||||
|
let lines = lrc.components(separatedBy: .newlines)
|
||||||
|
var lyrics: [LyricLine] = []
|
||||||
|
|
||||||
|
// Accept patterns like [m:ss], [mm:ss], [mm:ss.xx] where centiseconds are optional
|
||||||
|
let pattern = "\\[(\\d{1,2}):(\\d{2})(?:\\.(\\d{1,2}))?\\]"
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
let nsLine = line as NSString
|
||||||
|
let fullRange = NSRange(location: 0, length: nsLine.length)
|
||||||
|
if let match = regex.firstMatch(in: line, options: [], range: fullRange) {
|
||||||
|
let minRange = match.range(at: 1)
|
||||||
|
let secRange = match.range(at: 2)
|
||||||
|
let centiRange = match.range(at: 3)
|
||||||
|
|
||||||
|
let minStr = minRange.location != NSNotFound ? nsLine.substring(with: minRange) : "0"
|
||||||
|
let secStr = secRange.location != NSNotFound ? nsLine.substring(with: secRange) : "0"
|
||||||
|
let centiStr = (centiRange.location != NSNotFound) ? nsLine.substring(with: centiRange) : "0"
|
||||||
|
|
||||||
|
let minutes = Double(minStr) ?? 0
|
||||||
|
let seconds = Double(secStr) ?? 0
|
||||||
|
let centis = Double(centiStr) ?? 0
|
||||||
|
let timestamp = minutes * 60 + seconds + centis / 100.0
|
||||||
|
|
||||||
|
let textStart = match.range.location + match.range.length
|
||||||
|
if textStart <= nsLine.length {
|
||||||
|
let text = nsLine.substring(from: textStart).trimmingCharacters(in: .whitespaces)
|
||||||
|
if !text.isEmpty {
|
||||||
|
lyrics.append(LyricLine(timestamp: timestamp, text: text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lyrics.sorted(by: { $0.timestamp < $1.timestamp })
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCurrentLyric(for elapsedTime: TimeInterval) {
|
||||||
|
guard !syncedLyrics.isEmpty else { return }
|
||||||
|
|
||||||
|
// Find the current lyric based on elapsed time
|
||||||
|
var newIndex = -1
|
||||||
|
for (index, lyric) in syncedLyrics.enumerated() {
|
||||||
|
if elapsedTime >= lyric.timestamp {
|
||||||
|
newIndex = index
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if newIndex != currentLyricIndex {
|
||||||
|
currentLyricIndex = newIndex
|
||||||
|
if newIndex >= 0 && newIndex < syncedLyrics.count {
|
||||||
|
currentLyrics = syncedLyrics[newIndex].text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a background task that periodically updates the displayed lyric
|
||||||
|
private func startLyricSync() {
|
||||||
|
// If already running, keep it
|
||||||
|
if lyricSyncTask != nil { return }
|
||||||
|
|
||||||
|
lyricSyncTask = Task { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
while !Task.isCancelled {
|
||||||
|
// Compute estimated playback position and update lyric
|
||||||
|
let position = self.estimatedPlaybackPosition()
|
||||||
|
await MainActor.run {
|
||||||
|
self.updateCurrentLyric(for: position)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep ~300ms between updates
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopLyricSync() {
|
||||||
|
lyricSyncTask?.cancel()
|
||||||
|
lyricSyncTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleLyrics() {
|
||||||
|
// Toggle the UI state first so the views can react immediately.
|
||||||
|
showLyrics.toggle()
|
||||||
|
|
||||||
|
// If lyrics are requested to be shown but we don't have any yet,
|
||||||
|
// show a loading placeholder and start fetching asynchronously.
|
||||||
|
if showLyrics && syncedLyrics.isEmpty {
|
||||||
|
// Provide immediate feedback so the UI can show a loading state.
|
||||||
|
currentLyrics = "Loading lyrics..."
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await fetchLyrics()
|
||||||
|
|
||||||
|
// If fetch completed but no lyrics were found, show a friendly message.
|
||||||
|
await MainActor.run {
|
||||||
|
if self.syncedLyrics.isEmpty && self.currentLyrics.isEmpty {
|
||||||
|
self.currentLyrics = "No lyrics found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Album Art Flip Helper
|
// MARK: - Album Art Flip Helper
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,52 @@ enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable
|
||||||
var id: String { self.rawValue }
|
var id: String { self.rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||||
|
case shuffle
|
||||||
|
case repeatMode
|
||||||
|
case mediaOutput
|
||||||
|
case lyrics
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .shuffle:
|
||||||
|
return "Shuffle"
|
||||||
|
case .repeatMode:
|
||||||
|
return "Repeat"
|
||||||
|
case .mediaOutput:
|
||||||
|
return "Media Output"
|
||||||
|
case .lyrics:
|
||||||
|
return "Lyrics"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbolName: String {
|
||||||
|
switch self {
|
||||||
|
case .shuffle:
|
||||||
|
return "shuffle"
|
||||||
|
case .repeatMode:
|
||||||
|
return "repeat"
|
||||||
|
case .mediaOutput:
|
||||||
|
return "speaker.wave.2"
|
||||||
|
case .lyrics:
|
||||||
|
return "quote.bubble"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func alternative(
|
||||||
|
excluding control: MusicAuxiliaryControl,
|
||||||
|
preferring candidate: MusicAuxiliaryControl? = nil
|
||||||
|
) -> MusicAuxiliaryControl {
|
||||||
|
if let candidate, candidate != control {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
return allCases.first { $0 != control } ?? .shuffle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable {
|
enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||||
case adaptive = "Adaptive"
|
case adaptive = "Adaptive"
|
||||||
case solid = "Solid"
|
case solid = "Solid"
|
||||||
|
|
@ -372,6 +418,9 @@ extension Defaults.Keys {
|
||||||
static let waitInterval = Key<Double>("waitInterval", default: 3)
|
static let waitInterval = Key<Double>("waitInterval", default: 3)
|
||||||
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: false)
|
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: false)
|
||||||
static let showMediaOutputControl = Key<Bool>("showMediaOutputControl", default: false)
|
static let showMediaOutputControl = Key<Bool>("showMediaOutputControl", default: false)
|
||||||
|
static let musicAuxLeftControl = Key<MusicAuxiliaryControl>("musicAuxLeftControl", default: .shuffle)
|
||||||
|
static let musicAuxRightControl = Key<MusicAuxiliaryControl>("musicAuxRightControl", default: .repeatMode)
|
||||||
|
static let didMigrateMusicAuxControls = Key<Bool>("didMigrateMusicAuxControls", default: false)
|
||||||
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
||||||
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
||||||
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
||||||
|
|
@ -530,6 +579,9 @@ extension Defaults.Keys {
|
||||||
// MARK: Minimalistic UI Mode
|
// MARK: Minimalistic UI Mode
|
||||||
static let enableMinimalisticUI = Key<Bool>("enableMinimalisticUI", default: false)
|
static let enableMinimalisticUI = Key<Bool>("enableMinimalisticUI", default: false)
|
||||||
|
|
||||||
|
// MARK: Lyrics Feature
|
||||||
|
static let enableLyrics = Key<Bool>("enableLyrics", default: true)
|
||||||
|
|
||||||
// Helper to determine the default media controller based on macOS version
|
// Helper to determine the default media controller based on macOS version
|
||||||
static var defaultMediaController: MediaControllerType {
|
static var defaultMediaController: MediaControllerType {
|
||||||
if #available(macOS 15.4, *) {
|
if #available(macOS 15.4, *) {
|
||||||
|
|
@ -549,4 +601,24 @@ extension Defaults.Keys {
|
||||||
Defaults[.progressBarStyle] = .gradient
|
Defaults[.progressBarStyle] = .gradient
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func migrateMusicAuxControls() {
|
||||||
|
if Defaults[.didMigrateMusicAuxControls] == false {
|
||||||
|
if Defaults[.showMediaOutputControl] {
|
||||||
|
Defaults[.musicAuxRightControl] = .mediaOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
Defaults[.didMigrateMusicAuxControls] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeMusicAuxControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeMusicAuxControls() {
|
||||||
|
guard Defaults[.musicAuxLeftControl] == Defaults[.musicAuxRightControl] else { return }
|
||||||
|
|
||||||
|
let current = Defaults[.musicAuxLeftControl]
|
||||||
|
let fallback = MusicAuxiliaryControl.alternative(excluding: current)
|
||||||
|
Defaults[.musicAuxRightControl] = fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
|
|
||||||
@Published var notchSize: CGSize = getClosedNotchSize()
|
@Published var notchSize: CGSize = getClosedNotchSize()
|
||||||
@Published var closedNotchSize: CGSize = getClosedNotchSize()
|
@Published var closedNotchSize: CGSize = getClosedNotchSize()
|
||||||
private var minimalisticReminderRowCount: Int = 0
|
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
destroy()
|
destroy()
|
||||||
|
|
@ -77,13 +76,39 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
ReminderLiveActivityManager.shared.$activeWindowReminders
|
ReminderLiveActivityManager.shared.$activeWindowReminders
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.receive(on: RunLoop.main)
|
.receive(on: RunLoop.main)
|
||||||
.sink { [weak self] entries in
|
.sink { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.minimalisticReminderRowCount = entries.count
|
|
||||||
let updatedTarget = self.calculateDynamicNotchSize()
|
let updatedTarget = self.calculateDynamicNotchSize()
|
||||||
guard self.notchState == .open else { return }
|
guard self.notchState == .open else { return }
|
||||||
guard self.notchSize != updatedTarget else { return }
|
guard self.notchSize != updatedTarget else { return }
|
||||||
self.notchSize = updatedTarget
|
withAnimation(.smooth) {
|
||||||
|
self.notchSize = updatedTarget
|
||||||
|
}
|
||||||
|
if let delegate = AppDelegate.shared {
|
||||||
|
delegate.ensureWindowSize(updatedTarget, animated: true, force: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Observe settings + lyrics changes to dynamically resize the notch
|
||||||
|
let enableLyricsPublisher = Defaults.publisher(.enableLyrics).map { $0.newValue }
|
||||||
|
|
||||||
|
enableLyricsPublisher
|
||||||
|
.combineLatest(MusicManager.shared.$currentLyrics)
|
||||||
|
.removeDuplicates { $0.0 == $1.0 && $0.1 == $1.1 }
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
guard Defaults[.enableMinimalisticUI] else { return }
|
||||||
|
let updatedTarget = self.calculateDynamicNotchSize()
|
||||||
|
guard self.notchState == .open else { return }
|
||||||
|
guard self.notchSize != updatedTarget else { return }
|
||||||
|
withAnimation(.smooth) {
|
||||||
|
self.notchSize = updatedTarget
|
||||||
|
}
|
||||||
|
if let delegate = AppDelegate.shared {
|
||||||
|
delegate.ensureWindowSize(updatedTarget, animated: true, force: false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
@ -168,30 +193,25 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
// Use minimalistic size if minimalistic UI is enabled
|
// Use minimalistic size if minimalistic UI is enabled
|
||||||
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||||||
|
|
||||||
if Defaults[.enableMinimalisticUI] {
|
|
||||||
let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: minimalisticReminderRowCount)
|
|
||||||
baseSize.height += extraHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only apply dynamic sizing when on stats tab and stats are enabled
|
// Only apply dynamic sizing when on stats tab and stats are enabled
|
||||||
guard DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else {
|
guard DynamicIslandViewCoordinator.shared.currentView == .stats && Defaults[.enableStatsFeature] else {
|
||||||
return baseSize
|
return baseSize
|
||||||
}
|
}
|
||||||
|
|
||||||
let enabledGraphsCount = [
|
let enabledGraphsCount = [
|
||||||
Defaults[.showCpuGraph],
|
Defaults[.showCpuGraph],
|
||||||
Defaults[.showMemoryGraph],
|
Defaults[.showMemoryGraph],
|
||||||
Defaults[.showGpuGraph],
|
Defaults[.showGpuGraph],
|
||||||
Defaults[.showNetworkGraph],
|
Defaults[.showNetworkGraph],
|
||||||
Defaults[.showDiskGraph]
|
Defaults[.showDiskGraph]
|
||||||
].filter { $0 }.count
|
].filter { $0 }.count
|
||||||
|
|
||||||
// If 4+ graphs are enabled, increase width
|
// If 4+ graphs are enabled, increase width
|
||||||
if enabledGraphsCount >= 4 {
|
if enabledGraphsCount >= 4 {
|
||||||
let extraWidth: CGFloat = CGFloat(enabledGraphsCount - 3) * 120
|
let extraWidth: CGFloat = CGFloat(enabledGraphsCount - 3) * 120
|
||||||
return CGSize(width: baseSize.width + extraWidth, height: baseSize.height)
|
return CGSize(width: baseSize.width + extraWidth, height: baseSize.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseSize
|
return baseSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,25 @@ let downloadSneakSize: CGSize = .init(width: 65, height: 1)
|
||||||
let batterySneakSize: CGSize = .init(width: 160, height: 1)
|
let batterySneakSize: CGSize = .init(width: 160, height: 1)
|
||||||
|
|
||||||
let openNotchSize: CGSize = .init(width: 640, height: 190)
|
let openNotchSize: CGSize = .init(width: 640, height: 190)
|
||||||
let minimalisticOpenNotchSize: CGSize = .init(width: 420, height: 180)
|
private let minimalisticBaseOpenNotchSize: CGSize = .init(width: 420, height: 180)
|
||||||
|
private let minimalisticLyricsExtraHeight: CGFloat = 40
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
var minimalisticOpenNotchSize: CGSize {
|
||||||
|
var size = minimalisticBaseOpenNotchSize
|
||||||
|
|
||||||
|
if Defaults[.enableLyrics] {
|
||||||
|
size.height += minimalisticLyricsExtraHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
let reminderCount = ReminderLiveActivityManager.shared.activeWindowReminders.count
|
||||||
|
if reminderCount > 0 {
|
||||||
|
let reminderHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: reminderCount)
|
||||||
|
size.height += reminderHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
return size
|
||||||
|
}
|
||||||
let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14))
|
let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14))
|
||||||
let minimalisticCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 35, bottom: 35), closed: cornerRadiusInsets.closed)
|
let minimalisticCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 35, bottom: 35), closed: cornerRadiusInsets.closed)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
<a href="https://github.com/Ebullioscopic/Atoll/releases">
|
<a href="https://github.com/Ebullioscopic/Atoll/releases">
|
||||||
<img src="https://img.shields.io/github/downloads/Ebullioscopic/Atoll/total?label=Downloads" alt="GitHub downloads"/>
|
<img src="https://img.shields.io/github/downloads/Ebullioscopic/Atoll/total?label=Downloads" alt="GitHub downloads"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://discord.gg/zvjB4tJBkt">
|
<a href="https://discord.gg/PaqFkRTDF8">
|
||||||
<img src="https://img.shields.io/discord/1429481472942669896?label=Discord&logo=discord&color=7289da" alt="Discord server"/>
|
<img src="https://img.shields.io/discord/1429481472942669896?label=Discord&logo=discord&color=7289da" alt="Discord server"/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.gg/zvjB4tJBkt">Join our Discord community</a>
|
<a href="https://discord.gg/PaqFkRTDF8">Join our Discord community</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll).
|
**Project rename:** DynamicIsland is now called **Atoll**. Visit the new repository at [github.com/Ebullioscopic/Atoll](https://github.com/Ebullioscopic/Atoll).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue