mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
added lyrics feature
This commit is contained in:
parent
0d61a4024f
commit
13e60c5211
8 changed files with 464 additions and 47 deletions
|
|
@ -9,22 +9,27 @@
|
|||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
// Note: lyrics display is inlined into the main minimalistic view below and is controlled by Defaults[.enableLyrics]
|
||||
|
||||
struct MinimalisticMusicView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@ObservedObject var musicManager = MusicManager.shared
|
||||
@Default(.enableLyrics) var enableLyrics
|
||||
@State private var isHovering: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 2) {
|
||||
// Main content row
|
||||
HStack(spacing: 0) {
|
||||
// Left: Album Art
|
||||
albumArtView
|
||||
|
||||
// Middle: Song Title (scrolling if needed)
|
||||
// Middle: Song Title, Artist and Lyrics (lyrics shown under artist when enabled)
|
||||
Rectangle()
|
||||
.fill(.black)
|
||||
.overlay(
|
||||
GeometryReader { geo in
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .center, spacing: 2) {
|
||||
if !musicManager.songTitle.isEmpty {
|
||||
MarqueeText(
|
||||
$musicManager.songTitle,
|
||||
|
|
@ -35,6 +40,28 @@ struct MinimalisticMusicView: View {
|
|||
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 {
|
||||
Text(musicManager.currentLyrics)
|
||||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, 4)
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics)
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
|
||||
}
|
||||
|
|
@ -44,7 +71,11 @@ struct MinimalisticMusicView: View {
|
|||
// Right: Music Visualizer
|
||||
visualizerView
|
||||
}
|
||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||
|
||||
// (lyrics are displayed inline under the artist name)
|
||||
}
|
||||
// reserve extra height when lyrics are enabled
|
||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0) + (enableLyrics ? 22 : 0), alignment: .center)
|
||||
.onHover { hovering in
|
||||
isHovering = hovering
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,15 @@ import Defaults
|
|||
import AppKit
|
||||
#endif
|
||||
|
||||
// Lyrics are shown/hidden only via Defaults[.enableLyrics] in settings. Inline display is used in the player views.
|
||||
|
||||
struct MinimalisticMusicPlayerView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
let albumArtNamespace: Namespace.ID
|
||||
@Default(.showMediaOutputControl) var showMediaOutputControl
|
||||
@ObservedObject private var reminderManager = ReminderLiveActivityManager.shared
|
||||
@Default(.enableReminderLiveActivity) private var enableReminderLiveActivity
|
||||
@Default(.enableLyrics) private var enableLyrics
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
|
|
@ -46,6 +49,19 @@ struct MinimalisticMusicPlayerView: View {
|
|||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray)
|
||||
.lineLimit(1)
|
||||
|
||||
// Lyrics shown under the author name, same font size as author
|
||||
if enableLyrics {
|
||||
Text(musicManager.currentLyrics)
|
||||
.font(.system(size: 10, weight: .regular))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 4)
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics)
|
||||
}
|
||||
}
|
||||
.frame(width: textWidth, alignment: .leading)
|
||||
|
||||
|
|
@ -65,12 +81,75 @@ struct MinimalisticMusicPlayerView: View {
|
|||
playbackControls
|
||||
.padding(.top, 4)
|
||||
|
||||
// Lyrics are displayed inline under the artist name (see header area)
|
||||
|
||||
reminderList
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, -15)
|
||||
.padding(.bottom, shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: calculateDynamicHeight(), alignment: .center)
|
||||
.animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics)
|
||||
}
|
||||
|
||||
// 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] {
|
||||
|
|
@ -85,6 +164,32 @@ struct MinimalisticMusicPlayerView: View {
|
|||
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 Defaults[.enableLyrics] {
|
||||
height += 20 + 4 // lyrics + top padding
|
||||
}
|
||||
|
||||
// Add reminder list height if showing
|
||||
if shouldShowReminderList {
|
||||
height += reminderListHeight + ReminderLiveActivityManager.listTopPadding
|
||||
}
|
||||
|
||||
// Add padding
|
||||
height += 15 // top padding
|
||||
height += shouldShowReminderList ? ReminderLiveActivityManager.listBottomPadding : ReminderLiveActivityManager.baselineMinimalisticBottomPadding
|
||||
|
||||
return height
|
||||
}
|
||||
|
||||
private var reminderList: some View {
|
||||
MinimalisticReminderEventListView(reminders: reminderEntries)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
|
@ -472,7 +577,7 @@ private struct MinimalisticReminderDetailsView: View {
|
|||
// MARK: - Playback Controls (Larger)
|
||||
|
||||
private var playbackControls: some View {
|
||||
HStack(spacing: 20) {
|
||||
HStack(spacing: 16) {
|
||||
if Defaults[.showShuffleAndRepeat] {
|
||||
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
|
||||
Task { await musicManager.toggleShuffle() }
|
||||
|
|
@ -489,6 +594,8 @@ private struct MinimalisticReminderDetailsView: View {
|
|||
Task { await musicManager.nextTrack() }
|
||||
}
|
||||
|
||||
// (lyrics toggle removed from UI; controlled via Settings -> Media -> Enable Lyrics)
|
||||
|
||||
if Defaults[.showShuffleAndRepeat] {
|
||||
if showMediaOutputControl {
|
||||
MinimalisticMediaOutputButton()
|
||||
|
|
|
|||
|
|
@ -155,6 +155,18 @@ struct MusicControlsView: View {
|
|||
frameWidth: width
|
||||
)
|
||||
.fontWeight(.medium)
|
||||
// Lyrics shown under the author name (same font size as author) when enabled in settings
|
||||
if Defaults[.enableLyrics] {
|
||||
Text(musicManager.currentLyrics)
|
||||
.font(.headline)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 2)
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut(duration: 0.3), value: musicManager.currentLyrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -908,6 +908,7 @@ struct Media: View {
|
|||
isOn: $coordinator.musicLiveActivityEnabled.animation()
|
||||
)
|
||||
Toggle("Enable sneak peek", isOn: $enableSneakPeek)
|
||||
Defaults.Toggle("Enable lyrics", key: .enableLyrics)
|
||||
Picker("Sneak Peek Style", selection: $sneakPeekStyles){
|
||||
ForEach(SneakPeekStyle.allCases) { style in
|
||||
Text(style.rawValue).tag(style)
|
||||
|
|
|
|||
|
|
@ -7,8 +7,21 @@
|
|||
import AppKit
|
||||
import Combine
|
||||
import Defaults
|
||||
import Foundation
|
||||
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(
|
||||
systemSymbolName: "heart.fill",
|
||||
accessibilityDescription: "Album Art"
|
||||
|
|
@ -48,6 +61,15 @@ class MusicManager: ObservableObject {
|
|||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@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 liveStreamUnknownDurationCount: Int = 0
|
||||
|
|
@ -212,13 +234,14 @@ class MusicManager: ObservableObject {
|
|||
}
|
||||
self.artworkData = state.artwork
|
||||
|
||||
if artworkChanged || state.artwork == nil {
|
||||
// Update last artwork change values
|
||||
self.lastArtworkTitle = state.title
|
||||
self.lastArtworkArtist = state.artist
|
||||
self.lastArtworkAlbum = state.album
|
||||
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
|
||||
if !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
|
||||
|
|
@ -246,6 +269,8 @@ class MusicManager: ObservableObject {
|
|||
|
||||
if timeChanged {
|
||||
self.elapsedTime = state.currentTime
|
||||
// Update current lyric based on elapsed time
|
||||
self.updateCurrentLyric(for: state.currentTime)
|
||||
}
|
||||
|
||||
if durationChanged {
|
||||
|
|
@ -270,6 +295,14 @@ class MusicManager: ObservableObject {
|
|||
|
||||
updateLiveStreamState(with: state)
|
||||
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() {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -528,6 +528,9 @@ extension Defaults.Keys {
|
|||
// MARK: Minimalistic UI Mode
|
||||
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
|
||||
static var defaultMediaController: MediaControllerType {
|
||||
if #available(macOS 15.4, *) {
|
||||
|
|
|
|||
|
|
@ -83,8 +83,35 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
|||
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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
|
@ -171,6 +198,11 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
|||
if Defaults[.enableMinimalisticUI] {
|
||||
let extraHeight = ReminderLiveActivityManager.additionalHeight(forRowCount: minimalisticReminderRowCount)
|
||||
baseSize.height += extraHeight
|
||||
|
||||
// Add extra height for lyrics if enabled in settings
|
||||
if Defaults[.enableLyrics] {
|
||||
baseSize.height += 24 // Additional height for lyrics display
|
||||
}
|
||||
}
|
||||
|
||||
// Only apply dynamic sizing when on stats tab and stats are enabled
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ let downloadSneakSize: CGSize = .init(width: 65, height: 1)
|
|||
let batterySneakSize: CGSize = .init(width: 160, height: 1)
|
||||
|
||||
let openNotchSize: CGSize = .init(width: 640, height: 190)
|
||||
let minimalisticOpenNotchSize: CGSize = .init(width: 420, height: 180)
|
||||
let minimalisticOpenNotchSize: CGSize = .init(width: 420, height: 200)
|
||||
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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue