diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index fa343990..e3470545 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -12308,6 +12308,9 @@ } } } + }, + "Lyrics unavailable β€” tap to retry" : { + }, "Made with 🫢🏻 by not so boring not.people" : { "localizations" : { @@ -19311,6 +19314,9 @@ } } } + }, + "Show lyrics column beside player" : { + }, "Show menu bar icon" : { "localizations" : { diff --git a/boringNotch/boringNotch.entitlements b/boringNotch/boringNotch.entitlements index 7f3881d6..8b47eb37 100644 --- a/boringNotch/boringNotch.entitlements +++ b/boringNotch/boringNotch.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.cs.disable-library-validation + com.apple.security.automation.apple-events com.apple.security.device.camera diff --git a/boringNotch/components/Notch/BoringHeader.swift b/boringNotch/components/Notch/BoringHeader.swift index c727aaff..0ffed423 100644 --- a/boringNotch/components/Notch/BoringHeader.swift +++ b/boringNotch/components/Notch/BoringHeader.swift @@ -42,6 +42,24 @@ struct BoringHeader: View { OpenNotchHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon) .transition(.scale(scale: 0.8).combined(with: .opacity)) } else { + if Defaults[.showCalendar] { + Button(action: { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + vm.calendarPanelOpen.toggle() + } + }) { + Capsule() + .fill(vm.calendarPanelOpen ? Color.effectiveAccent : .black) + .frame(width: 30, height: 30) + .overlay { + Image(systemName: "calendar") + .foregroundColor(.white) + .padding() + .imageScale(.medium) + } + } + .buttonStyle(PlainButtonStyle()) + } if Defaults[.showMirror] { Button(action: { vm.toggleCameraPreview() diff --git a/boringNotch/components/Notch/NotchHomeView.swift b/boringNotch/components/Notch/NotchHomeView.swift index 0c29b8a4..d33c407d 100644 --- a/boringNotch/components/Notch/NotchHomeView.swift +++ b/boringNotch/components/Notch/NotchHomeView.swift @@ -14,14 +14,87 @@ import SwiftUI struct MusicPlayerView: View { @EnvironmentObject var vm: BoringViewModel + @ObservedObject var musicManager = MusicManager.shared let albumArtNamespace: Namespace.ID + @Default(.lyricsColumnLayout) private var lyricsColumnLayout + @State private var separatorHovered: Bool = false + + var showLyricsColumn: Bool { + Defaults[.enableLyrics] && lyricsColumnLayout && !musicManager.syncedLyrics.isEmpty + } + + var showSplitLayout: Bool { + showLyricsColumn || vm.calendarPanelOpen + } var body: some View { - HStack { - AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).padding(.all, 5) - MusicControlsView().drawingGroup().compositingGroup() + if showSplitLayout { + HStack(alignment: .center, spacing: 0) { + // Compact player (~270px) + HStack { + AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) + .padding(.all, 5) + .frame(width: 90, height: 90) + MusicControlsView(hideLyricsLine: true) + .drawingGroup().compositingGroup() + } + .frame(width: 270) + + // Separator β€” tap to collapse right panel + Button { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + if vm.calendarPanelOpen { + vm.calendarPanelOpen = false + } else { + lyricsColumnLayout = false + } + } + } label: { + ZStack { + Color.clear.frame(width: 17, height: 80) + Rectangle() + .fill(.white.opacity(separatorHovered ? 0.4 : 0.15)) + .frame(width: 1, height: 70) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 4) + .onHover { h in + separatorHovered = h + if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } + + // Right panel: calendar or lyrics column + Group { + if vm.calendarPanelOpen { + CalendarView() + .onHover { vm.isHoveringCalendar = $0 } + .transition(.opacity.combined(with: .scale(scale: 0.97, anchor: .trailing))) + } else { + TimelineView(.animation(minimumInterval: 0.25)) { timeline in + LyricsColumnView(elapsed: computeElapsed(from: timeline.date)) + } + .transition(.opacity.combined(with: .scale(scale: 0.97, anchor: .leading))) + } + } + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: vm.calendarPanelOpen) + } + } else { + HStack { + AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace).padding(.all, 5) + MusicControlsView(hideLyricsLine: false) + .drawingGroup().compositingGroup() + } } } + + private func computeElapsed(from date: Date) -> Double { + guard musicManager.isPlaying else { return musicManager.elapsedTime } + let delta = date.timeIntervalSince(musicManager.timestampDate) + let progressed = musicManager.elapsedTime + (delta * musicManager.playbackRate) + return min(max(progressed, 0), musicManager.songDuration) + } } struct AlbumArtView: View { @@ -110,6 +183,7 @@ struct AlbumArtView: View { } struct MusicControlsView: View { + var hideLyricsLine: Bool = false @ObservedObject var musicManager = MusicManager.shared @EnvironmentObject var vm: BoringViewModel @ObservedObject var webcamManager = WebcamManager.shared @@ -118,6 +192,7 @@ struct MusicControlsView: View { @State private var lastDragged: Date = .distantPast @Default(.musicControlSlots) private var slotConfig @Default(.musicControlSlotLimit) private var slotLimit + @Default(.lyricsColumnLayout) private var lyricsColumnLayout var body: some View { VStack(alignment: .leading) { @@ -153,7 +228,11 @@ struct MusicControlsView: View { frameWidth: width ) .fontWeight(.medium) - if Defaults[.enableLyrics] { + if Defaults[.enableLyrics] && !hideLyricsLine { + let lyricsUnavailable = !musicManager.isFetchingLyrics + && musicManager.syncedLyrics.isEmpty + && musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !musicManager.songTitle.isEmpty TimelineView(.animation(minimumInterval: 0.25)) { timeline in let currentElapsed: Double = { guard musicManager.isPlaying else { return musicManager.elapsedTime } @@ -167,7 +246,7 @@ struct MusicControlsView: View { return musicManager.lyricLine(at: currentElapsed) } let trimmed = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "No lyrics found" : trimmed.replacingOccurrences(of: "\n", with: " ") + return trimmed.isEmpty ? "No lyrics found β€” tap to retry" : trimmed.replacingOccurrences(of: "\n", with: " ") }() let isPersian = line.unicodeScalars.contains { scalar in let v = scalar.value @@ -185,6 +264,19 @@ struct MusicControlsView: View { .opacity(musicManager.isPlaying ? 1 : 0) .transition(.opacity.combined(with: .move(edge: .top))) } + .onTapGesture { + if lyricsUnavailable { + musicManager.retryLyricsFetch() + } else if !musicManager.syncedLyrics.isEmpty { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + lyricsColumnLayout = true + } + } + } + .onHover { hovering in + guard !musicManager.syncedLyrics.isEmpty || lyricsUnavailable else { return } + if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } } } } @@ -318,6 +410,154 @@ struct FavoriteControlButton: View { } } +// MARK: - Scroll wheel capture (macOS only) + +private class ScrollWheelNSView: NSView { + var onScroll: ((CGFloat) -> Void)? + private var monitor: Any? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil { + monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + guard let self, + let eventWindow = event.window, + let selfWindow = self.window, + eventWindow === selfWindow else { return event } + let mouseInView = self.convert(event.locationInWindow, from: nil) + if self.bounds.contains(mouseInView) { + DispatchQueue.main.async { self.onScroll?(event.scrollingDeltaY) } + return nil // consume so nothing else scrolls + } + return event + } + } else { + if let m = monitor { NSEvent.removeMonitor(m); monitor = nil } + } + } + + deinit { + if let m = monitor { NSEvent.removeMonitor(m) } + } +} + +private struct ScrollWheelCapture: NSViewRepresentable { + let onScroll: (CGFloat) -> Void + func makeNSView(context: Context) -> ScrollWheelNSView { + let v = ScrollWheelNSView() + v.onScroll = onScroll + return v + } + func updateNSView(_ nsView: ScrollWheelNSView, context: Context) { + nsView.onScroll = onScroll + } +} + +struct LyricsColumnView: View { + @ObservedObject var musicManager = MusicManager.shared + let elapsed: Double + + @State private var userOffset: Int = 0 + @State private var scrollAccumulator: CGFloat = 0 + @State private var resetWorkItem: DispatchWorkItem? + + private var isScrolled: Bool { userOffset != 0 } + + private var lyricsUnavailable: Bool { + !musicManager.isFetchingLyrics + && musicManager.syncedLyrics.isEmpty + && musicManager.currentLyrics.isEmpty + && !musicManager.songTitle.isEmpty + } + + var body: some View { + let context: (prev: String?, current: String, next: String?) = { + if musicManager.isFetchingLyrics { return (nil, "Loading lyrics…", nil) } + if lyricsUnavailable { return (nil, "", nil) } + return musicManager.lyricContext(at: elapsed, offset: userOffset) + }() + + VStack(alignment: .leading, spacing: 8) { + // Previous line + Text(context.prev ?? " ") + .font(.subheadline) + .foregroundColor(.white.opacity(0.3)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + // Current line β€” highlighted, or retry button when unavailable + if lyricsUnavailable { + Button { + musicManager.retryLyricsFetch() + } label: { + HStack(spacing: 5) { + Image(systemName: "arrow.clockwise") + Text("Lyrics unavailable β€” tap to retry") + } + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.white.opacity(0.4)) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text(context.current) + .font(.title3) + .fontWeight(.semibold) + // Dim when browsing away from the live line + .foregroundColor(isScrolled ? .white.opacity(0.6) : .white) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Next line + Text(context.next ?? " ") + .font(.subheadline) + .foregroundColor(.white.opacity(0.3)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + .id(lyricsUnavailable ? "unavailable" : "\(context.current)-\(userOffset)") + .transition(.opacity.combined(with: .scale(scale: 0.97, anchor: .leading))) + .padding(.leading, 4) + .padding(.trailing, 8) + .opacity(musicManager.isPlaying ? 1 : 0.5) + .animation(.spring(response: 0.35, dampingFraction: 0.85), value: userOffset) + .animation(.spring(response: 0.55, dampingFraction: 0.85), value: context.current) + .background( + ScrollWheelCapture { delta in + guard !musicManager.syncedLyrics.isEmpty else { return } + handleScroll(delta) + } + ) + } + + private func handleScroll(_ delta: CGFloat) { + scrollAccumulator += delta + let threshold: CGFloat = 18 + guard abs(scrollAccumulator) >= threshold else { return } + + let steps = Int(scrollAccumulator / threshold) + let maxOffset = musicManager.syncedLyrics.count - 1 + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + // scroll up (delta > 0) β†’ earlier lines (negative offset) + // scroll down (delta < 0) β†’ later lines (positive offset) + userOffset = max(-maxOffset, min(maxOffset, userOffset - steps)) + } + scrollAccumulator -= CGFloat(steps) * threshold + + // Auto-snap back to live position after 2 s of inactivity + resetWorkItem?.cancel() + let work = DispatchWorkItem { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + userOffset = 0 + } + } + resetWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: work) + } +} + private extension Array where Element == MusicControlButton { func padded(to length: Int, filler: MusicControlButton) -> [MusicControlButton] { if count >= length { return self } @@ -440,19 +680,9 @@ struct NotchHomeView: View { } private var mainContent: some View { - HStack(alignment: .top, spacing: (shouldShowCamera && Defaults[.showCalendar]) ? 10 : 15) { + HStack(alignment: .top, spacing: 15) { MusicPlayerView(albumArtNamespace: albumArtNamespace) - if Defaults[.showCalendar] { - CalendarView() - .frame(width: shouldShowCamera ? 170 : 215) - .onHover { isHovering in - vm.isHoveringCalendar = isHovering - } - .environmentObject(vm) - .transition(.opacity) - } - if shouldShowCamera { CameraPreviewView(webcamManager: webcamManager) .scaledToFit() diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift index cff23f33..86cd6eb3 100644 --- a/boringNotch/components/Settings/SettingsView.swift +++ b/boringNotch/components/Settings/SettingsView.swift @@ -600,6 +600,7 @@ struct Media: View { @Default(.sneakPeekStyles) var sneakPeekStyles @Default(.enableLyrics) var enableLyrics + @Default(.lyricsColumnLayout) var lyricsColumnLayout var body: some View { Form { @@ -685,6 +686,12 @@ struct Media: View { customBadge(text: "Beta") } } + if enableLyrics { + Defaults.Toggle(key: .lyricsColumnLayout) { + Text("Show lyrics column beside player") + } + .padding(.leading, 16) + } } header: { Text("Media controls") } footer: { diff --git a/boringNotch/managers/MusicManager.swift b/boringNotch/managers/MusicManager.swift index cf8730c2..fdd7cec8 100644 --- a/boringNotch/managers/MusicManager.swift +++ b/boringNotch/managers/MusicManager.swift @@ -20,6 +20,7 @@ class MusicManager: ObservableObject { private var cancellables = Set() private var controllerCancellables = Set() private var debounceIdleTask: Task? + private var lyricsFetchDebounceTask: Task? // Helper to check if macOS has removed support for NowPlayingController public private(set) var isNowPlayingDeprecated: Bool = false @@ -51,6 +52,32 @@ class MusicManager: ObservableObject { @Published var currentLyrics: String = "" @Published var isFetchingLyrics: Bool = false @Published var syncedLyrics: [(time: Double, text: String)] = [] + + // MARK: - Lyrics cache + private struct LyricsCacheEntry: Codable { + let plainLyrics: String + let syncedLyrics: String + } + private var lyricsCache: [String: LyricsCacheEntry] = [:] + private var lyricsCacheURL: URL { + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("boringNotch", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("lyrics_cache.json") + } + private func lyricsCacheKey(title: String, artist: String) -> String { + "\(normalizedQuery(title))|\(normalizedQuery(artist))" + } + private func loadLyricsCache() { + guard let data = try? Data(contentsOf: lyricsCacheURL), + let decoded = try? JSONDecoder().decode([String: LyricsCacheEntry].self, from: data) + else { return } + lyricsCache = decoded + } + private func saveLyricsCache() { + guard let data = try? JSONEncoder().encode(lyricsCache) else { return } + try? data.write(to: lyricsCacheURL, options: .atomic) + } @Published var canFavoriteTrack: Bool = false @Published var isFavoriteTrack: Bool = false @@ -62,6 +89,11 @@ class MusicManager: ObservableObject { private var lastArtworkAlbum: String = "Self Love" private var lastArtworkBundleIdentifier: String? = nil + // Track the title/artist for which lyrics were last fetched to prevent redundant re-fetches + // (Chromium browsers send multiple metadata updates as artwork/info loads in stages) + private var lyricsLastTitle: String = "" + private var lyricsLastArtist: String = "" + @Published var isFlipping: Bool = false private var flipWorkItem: DispatchWorkItem? @@ -70,6 +102,8 @@ class MusicManager: ObservableObject { // MARK: - Initialization init() { + loadLyricsCache() + // Listen for changes to the default controller preference NotificationCenter.default.publisher(for: Notification.Name.mediaControllerChanged) .sink { [weak self] _ in @@ -98,6 +132,7 @@ class MusicManager: ObservableObject { public func destroy() { debounceIdleTask?.cancel() + lyricsFetchDebounceTask?.cancel() cancellables.removeAll() controllerCancellables.removeAll() flipWorkItem?.cancel() @@ -341,66 +376,122 @@ class MusicManager: ObservableObject { } // MARK: - Lyrics - private func fetchLyricsIfAvailable(bundleIdentifier: String?, title: String, artist: String) { - guard Defaults[.enableLyrics], !title.isEmpty else { - DispatchQueue.main.async { - self.isFetchingLyrics = false - self.currentLyrics = "" - } - return - } + + /// Executes the actual lyrics fetch (Apple Music AppleScript path then LRCLib web fallback). + /// Must be called after the metadata has settled; use `fetchLyricsIfAvailable` for debounced entry. + @MainActor + private func performLyricsFetchNow(bundleIdentifier: String?, title: String, artist: String) async { + self.isFetchingLyrics = true + self.currentLyrics = "" // Prefer native Apple Music lyrics when available if let bundleIdentifier = bundleIdentifier, bundleIdentifier.contains("com.apple.Music") { - Task { @MainActor in - let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") - guard !runningApps.isEmpty else { - await self.fetchLyricsFromWeb(title: title, artist: artist) - return - } + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Music") + guard !runningApps.isEmpty else { + await fetchLyricsFromWeb(title: title, artist: artist) + return + } - self.isFetchingLyrics = true - self.currentLyrics = "" - do { - let script = """ - tell application \"Music\" - if it is running then - if player state is playing or player state is paused then - try - set l to lyrics of current track - if l is missing value then - return \"\" - else - return l - end if - on error + do { + let script = """ + tell application \"Music\" + if it is running then + if player state is playing or player state is paused then + try + set l to lyrics of current track + if l is missing value then return \"\" - end try - else + else + return l + end if + on error return \"\" - end if + end try else return \"\" end if - end tell - """ - if let result = try await AppleScriptHelper.execute(script), let lyricsString = result.stringValue, !lyricsString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - self.currentLyrics = lyricsString.trimmingCharacters(in: .whitespacesAndNewlines) - self.isFetchingLyrics = false - self.syncedLyrics = [] - return - } - } catch { - // fall through to web lookup + else + return \"\" + end if + end tell + """ + if let result = try await AppleScriptHelper.execute(script), + let lyricsString = result.stringValue, + !lyricsString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.currentLyrics = lyricsString.trimmingCharacters(in: .whitespacesAndNewlines) + self.isFetchingLyrics = false + self.syncedLyrics = [] + return } - await self.fetchLyricsFromWeb(title: title, artist: artist) + } catch { + // fall through to web lookup } - } else { - Task { @MainActor in - self.isFetchingLyrics = true + } + + await fetchLyricsFromWeb(title: title, artist: artist) + } + + private func fetchLyricsIfAvailable(bundleIdentifier: String?, title: String, artist: String) { + guard Defaults[.enableLyrics] else { + DispatchQueue.main.async { + self.isFetchingLyrics = false self.currentLyrics = "" - await self.fetchLyricsFromWeb(title: title, artist: artist) + self.syncedLyrics = [] } + return + } + + // Don't clear lyrics for transient empty-title updates (common with Chromium browsers + // sending non-diff refreshes where title/artist arrive as null) + guard !title.isEmpty else { return } + + // Debounce: cancel the previous pending fetch and restart the timer. + // After 800 ms of no further metadata updates, we read the CURRENT (settled) + // songTitle/artistName rather than the stale captured values from this event. + // This collapses Arc/Chromium's rapid multi-stage updates into a single fetch. + lyricsFetchDebounceTask?.cancel() + lyricsFetchDebounceTask = Task { @MainActor [weak self] in + guard let self else { return } + try? await Task.sleep(for: .milliseconds(800)) + guard !Task.isCancelled else { return } + + // Read the most up-to-date metadata after the settling period + let stableTitle = self.songTitle + let stableArtist = self.artistName + let stableBundleID = self.bundleIdentifier + + guard !stableTitle.isEmpty else { return } + + // Idempotency: skip if title+artist haven't changed since the last fetch. + // Compare cleaned values so "Artist - Topic" == "Artist" for the same song. + let cleanedTitle = self.cleanForLyricsSearch(stableTitle, isArtist: false) + let cleanedArtist = self.cleanForLyricsSearch(stableArtist, isArtist: true) + guard cleanedTitle != self.cleanForLyricsSearch(self.lyricsLastTitle, isArtist: false) || + cleanedArtist != self.cleanForLyricsSearch(self.lyricsLastArtist, isArtist: true) else { + return + } + + self.lyricsLastTitle = stableTitle + self.lyricsLastArtist = stableArtist + await self.performLyricsFetchNow(bundleIdentifier: stableBundleID, title: stableTitle, artist: stableArtist) + } + } + + func retryLyricsFetch() { + guard !isFetchingLyrics else { return } + // Cancel any pending debounced fetch and execute immediately with current stable values + lyricsFetchDebounceTask?.cancel() + lyricsLastTitle = "" + lyricsLastArtist = "" + let title = songTitle + let artist = artistName + let bundle = bundleIdentifier + guard !title.isEmpty else { return } + lyricsLastTitle = title + lyricsLastArtist = artist + Task { @MainActor [weak self] in + guard let self else { return } + await self.performLyricsFetchNow(bundleIdentifier: bundle, title: title, artist: artist) } } @@ -410,10 +501,64 @@ class MusicManager: ObservableObject { .replacingOccurrences(of: "\u{FFFD}", with: "") } + /// Strips YouTube / streaming-platform noise before sending to LRCLib. + /// e.g. "Artist - Topic" β†’ "Artist", "Song (Official Video)" β†’ "Song" + private func cleanForLyricsSearch(_ string: String, isArtist: Bool) -> String { + var s = string + + // Artist-specific suffixes added by YouTube Music + if isArtist { + let artistSuffixes = [ + " - Topic", " (Topic)", " - topic", + " VEVO", " Vevo", + " (Official Artist Channel)", + " (Official Channel)", + ] + for suffix in artistSuffixes { + if s.hasSuffix(suffix) { + s = String(s.dropLast(suffix.count)) + break + } + } + } + + // Title-specific suffixes + if !isArtist { + let titleSuffixes = [ + " (Official Video)", " (Official Audio)", " (Official Music Video)", + " [Official Video]", " [Official Audio]", " [Official Music Video]", + " (Official Lyric Video)", " (Lyric Video)", " (Lyrics)", + " (Music Video)", " [Music Video]", + " (Visualizer)", " [Visualizer]", + " (Audio)", " [Audio]", + " (Live)", " [Live]", + " (Explicit)", " [Explicit]", + ] + for suffix in titleSuffixes { + if s.hasSuffix(suffix) { + s = String(s.dropLast(suffix.count)) + break + } + } + } + + return s.trimmingCharacters(in: .whitespacesAndNewlines) + } + @MainActor - private func fetchLyricsFromWeb(title: String, artist: String) async { - let cleanTitle = normalizedQuery(title) - let cleanArtist = normalizedQuery(artist) + private func fetchLyricsFromWeb(title: String, artist: String, retries: Int = 1) async { + let cleanTitle = normalizedQuery(cleanForLyricsSearch(title, isArtist: false)) + let cleanArtist = normalizedQuery(cleanForLyricsSearch(artist, isArtist: true)) + + // Check in-memory/disk cache first (use cleaned key so "Song (Official Video)" hits same entry as "Song") + let cacheKey = "\(cleanTitle)|\(cleanArtist)" + if let cached = lyricsCache[cacheKey] { + self.currentLyrics = cached.plainLyrics + self.isFetchingLyrics = false + self.syncedLyrics = cached.syncedLyrics.isEmpty ? [] : self.parseLRC(cached.syncedLyrics) + return + } + guard let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { self.currentLyrics = "" @@ -431,6 +576,11 @@ class MusicManager: ObservableObject { do { let (data, response) = try await URLSession.shared.data(from: url) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + if retries > 0 { + try? await Task.sleep(for: .seconds(2)) + await fetchLyricsFromWeb(title: title, artist: artist, retries: retries - 1) + return + } self.currentLyrics = "" self.isFetchingLyrics = false return @@ -448,12 +598,22 @@ class MusicManager: ObservableObject { } else { self.syncedLyrics = [] } + // Persist to cache only when we actually got lyrics + if !plain.isEmpty || !synced.isEmpty { + lyricsCache[cacheKey] = LyricsCacheEntry(plainLyrics: plain, syncedLyrics: synced) + saveLyricsCache() + } } else { self.currentLyrics = "" self.isFetchingLyrics = false self.syncedLyrics = [] } } catch { + if retries > 0 { + try? await Task.sleep(for: .seconds(2)) + await fetchLyricsFromWeb(title: title, artist: artist, retries: retries - 1) + return + } self.currentLyrics = "" self.isFetchingLyrics = false self.syncedLyrics = [] @@ -506,6 +666,23 @@ class MusicManager: ObservableObject { return syncedLyrics[idx].text } + func lyricContext(at elapsed: Double, offset: Int = 0) -> (prev: String?, current: String, next: String?) { + guard !syncedLyrics.isEmpty else { return (nil, currentLyrics, nil) } + var low = 0, high = syncedLyrics.count - 1, idx = 0 + while low <= high { + let mid = (low + high) / 2 + if syncedLyrics[mid].time <= elapsed { + idx = mid; low = mid + 1 + } else { high = mid - 1 } + } + let target = max(0, min(syncedLyrics.count - 1, idx + offset)) + return ( + target > 0 ? syncedLyrics[target - 1].text : nil, + syncedLyrics[target].text, + target < syncedLyrics.count - 1 ? syncedLyrics[target + 1].text : nil + ) + } + private func triggerFlipAnimation() { // Cancel any existing animation flipWorkItem?.cancel() diff --git a/boringNotch/models/BoringViewModel.swift b/boringNotch/models/BoringViewModel.swift index 25390e76..50de10a2 100644 --- a/boringNotch/models/BoringViewModel.swift +++ b/boringNotch/models/BoringViewModel.swift @@ -30,6 +30,7 @@ class BoringViewModel: NSObject, ObservableObject { @Published var edgeAutoOpenActive: Bool = false @Published var isHoveringCalendar: Bool = false + @Published var calendarPanelOpen: Bool = false @Published var isBatteryPopoverActive: Bool = false @Published var screenUUID: String? diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 8477434c..b5e1a4a6 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -129,6 +129,7 @@ extension Defaults.Keys { static let waitInterval = Key("waitInterval", default: 3) static let showShuffleAndRepeat = Key("showShuffleAndRepeat", default: false) static let enableLyrics = Key("enableLyrics", default: false) + static let lyricsColumnLayout = Key("lyricsColumnLayout", default: false) static let musicControlSlots = Key<[MusicControlButton]>( "musicControlSlots", default: MusicControlButton.defaultLayout