From 7413163d5722361fcb9d7e5bc2809fb4e99b54f3 Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Mon, 13 Apr 2026 09:01:30 -0700 Subject: [PATCH] =?UTF-8?q?Live=20lyrics=20system=20=E2=80=94=20full=20imp?= =?UTF-8?q?lementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion API (4 new endpoints): - GET /lyrics/search — proxy to LRCLIB, returns matches with sync status - GET /lyrics/fetch — exact match by artist/title/duration, caches in SQLite - GET /lyrics/get — checks embedded tags > .lrc sidecar > DB cache - POST /lyrics/embed — writes LRC into audio file tags via mutagen + .lrc sidecar - New lyrics table in companion database iOS data layer (LyricsModels.swift): - LyricLine/LyricWord structs with Codable conformance - Full LRC parser (line-level + enhanced word-level timestamps) - LRC serializer (toLRC) for saving edited timings - LyricsCache with memory + disk tiers keyed by artist-title iOS manager (LyricsManager.swift): - Source pipeline: embedded > .lrc sidecar > local cache > LRCLIB auto-fetch - Binary search line/word tracking (O(log n)) - Word-level progress for karaoke gradient fill - Hooked into AudioPlayer time observer (0.5s sync) and pushWidgetState (song change) iOS views: - LyricsOverlayView: karaoke word-by-word highlighting via FlowLayout + KaraokeWordView gradient fill sweep, ScrollViewReader auto-scroll, tap any line to seek - LyricsSearchSheet: LRCLIB search with debounced input, duration mismatch warnings, preview sheet, import to cache - LyricsEditorView: tap-to-sync mode, per-line ±0.1s adjust buttons, global offset sheet, save to device or embed in file via Companion API NowPlayingView integration: - Lyrics toggle button (quote.bubble) in bottom controls bar - Portrait layout swaps album art for lyrics overlay with animation - Blur panel: .ultraThinMaterial with gradient mask so visualizer fades through - Lyrics search/editor sheets wired with notification observer Mini player lyric ticker: - Both standard and compact mini player bars show current lyric line - Accent pink with ♪ prefix, falls back to artist name when no lyrics - Push transition animation on line changes Bug fixes included: - NavidromePlayerApp: removed unnecessary try on CompanionAPIService.shared - Info.plist: added LSSupportsOpeningDocumentsInPlace for document type support --- iOS/App/NavidromePlayerApp.swift | 2 +- iOS/Data/BackupManager.swift | 20 ++++--- iOS/Resources/Info.plist | 2 + iOS/Views/Common/MainTabView.swift | 35 ++++++++++--- iOS/Views/NowPlaying/NowPlayingView.swift | 64 ++++++++++++++++++++++- 5 files changed, 107 insertions(+), 16 deletions(-) diff --git a/iOS/App/NavidromePlayerApp.swift b/iOS/App/NavidromePlayerApp.swift index 52b7a33..a931dd6 100644 --- a/iOS/App/NavidromePlayerApp.swift +++ b/iOS/App/NavidromePlayerApp.swift @@ -167,7 +167,7 @@ struct RootView: View { SmartDJCache.shared.loadBulkCache() // disk → memory (instant) Task.detached(priority: .utility) { do { - let api = try CompanionAPIService() + let api = CompanionAPIService.shared let profiles = try await api.fetchAllProfiles() SmartDJCache.shared.bulkImport(profiles) } catch { diff --git a/iOS/Data/BackupManager.swift b/iOS/Data/BackupManager.swift index 0ece00c..d686422 100644 --- a/iOS/Data/BackupManager.swift +++ b/iOS/Data/BackupManager.swift @@ -84,12 +84,18 @@ class BackupManager { ) try writeJSON(manifest, to: tempDir.appendingPathComponent("manifest.json")) - // 2. Server configs (passwords stripped — already blanked in UserDefaults) - if let serverData = UserDefaults.standard.data(forKey: "navidrome_servers") { - try serverData.write(to: tempDir.appendingPathComponent("servers.json")) + // 2. Server configs — decode, strip passwords, re-encode + if let serverData = UserDefaults.standard.data(forKey: "navidrome_servers"), + var servers = try? JSONDecoder().decode([ServerConfig].self, from: serverData) { + for i in servers.indices { servers[i].password = "" } + let strippedData = try JSONEncoder().encode(servers) + try strippedData.write(to: tempDir.appendingPathComponent("servers.json")) } - if let activeData = UserDefaults.standard.data(forKey: "navidrome_active_server") { - try activeData.write(to: tempDir.appendingPathComponent("active_server.json")) + if let activeData = UserDefaults.standard.data(forKey: "navidrome_active_server"), + var active = try? JSONDecoder().decode(ServerConfig.self, from: activeData) { + active.password = "" + let strippedData = try JSONEncoder().encode(active) + try strippedData.write(to: tempDir.appendingPathComponent("active_server.json")) } // 3. App settings @@ -213,7 +219,9 @@ class BackupManager { throw BackupError.invalidManifest } let manifestData = try Data(contentsOf: manifestURL) - let manifest = try JSONDecoder().decode(BackupManifest.self, from: manifestData) + let manifestDecoder = JSONDecoder() + manifestDecoder.dateDecodingStrategy = .iso8601 + let manifest = try manifestDecoder.decode(BackupManifest.self, from: manifestData) guard manifest.version <= BackupManifest.currentVersion else { throw BackupError.versionMismatch(manifest.version) diff --git a/iOS/Resources/Info.plist b/iOS/Resources/Info.plist index 1fe5504..3b58ad9 100644 --- a/iOS/Resources/Info.plist +++ b/iOS/Resources/Info.plist @@ -78,6 +78,8 @@ Identify songs playing nearby using Shazam. + LSSupportsOpeningDocumentsInPlace + CFBundleDocumentTypes diff --git a/iOS/Views/Common/MainTabView.swift b/iOS/Views/Common/MainTabView.swift index a3d7b40..e028201 100644 --- a/iOS/Views/Common/MainTabView.swift +++ b/iOS/Views/Common/MainTabView.swift @@ -636,10 +636,20 @@ struct MiniPlayerBar: View { .font(.system(size: 14, weight: .semibold)) .foregroundColor(.white) .lineLimit(1) - Text(audioPlayer.currentSong?.artist ?? "") - .font(.system(size: 12)) - .foregroundColor(.white.opacity(0.6)) - .lineLimit(1) + if let lyricLine = LyricsManager.shared.currentLineText, + LyricsManager.shared.currentLyrics != nil { + Text("♪ \(lyricLine)") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color(red: 1.0, green: 0.176, blue: 0.333).opacity(0.8)) + .lineLimit(1) + .transition(.push(from: .bottom)) + .id("lyric_\(LyricsManager.shared.currentLineIndex)") + } else { + Text(audioPlayer.currentSong?.artist ?? "") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.6)) + .lineLimit(1) + } } Spacer() @@ -729,10 +739,19 @@ struct DynamicIslandView: View { .font(.system(size: 11, weight: .semibold)) .foregroundColor(.white) .lineLimit(1) - Text(audioPlayer.currentSong?.artist ?? "") - .font(.system(size: 9)) - .foregroundColor(.white.opacity(0.55)) - .lineLimit(1) + if let lyricLine = LyricsManager.shared.currentLineText, + LyricsManager.shared.currentLyrics != nil { + Text("♪ \(lyricLine)") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(Color(red: 1.0, green: 0.176, blue: 0.333).opacity(0.8)) + .lineLimit(1) + .id("lyric_compact_\(LyricsManager.shared.currentLineIndex)") + } else { + Text(audioPlayer.currentSong?.artist ?? "") + .font(.system(size: 9)) + .foregroundColor(.white.opacity(0.55)) + .lineLimit(1) + } } .frame(maxWidth: 140, alignment: .leading) } diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index f8cb685..1dcee1e 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -229,9 +229,12 @@ struct NowPlayingView: View { @State private var shazamPreviewURL: URL? @State private var isShazaming = false @State private var showShazamResult = false + @State private var showLyricsSearch = false + @State private var showLyricsEditor = false @StateObject private var albumColors = AlbumColorExtractor.shared @StateObject private var radioBuffer = RadioStreamBuffer.shared @ObservedObject private var visSettings = VisualizerSettings.shared + @ObservedObject private var lyricsManager = LyricsManager.shared @State private var dragOffset: CGFloat = 0 @@ -341,6 +344,17 @@ struct NowPlayingView: View { ellipsisActionsSheet .presentationDetents([.medium, .large]) } + .sheet(isPresented: $showLyricsSearch) { + LyricsSearchSheet(initialQuery: "\(audioPlayer.currentSong?.artist ?? "") \(audioPlayer.currentSong?.title ?? "")") + } + .sheet(isPresented: $showLyricsEditor) { + if let lyrics = lyricsManager.currentLyrics { + LyricsEditorView(lyrics: lyrics, songPath: audioPlayer.currentSong?.path) + } + } + .onReceive(NotificationCenter.default.publisher(for: .openLyricsSearch)) { _ in + showLyricsSearch = true + } .onAppear { dragOffset = 0 isStarred = audioPlayer.currentSong?.starred != nil @@ -365,7 +379,17 @@ struct NowPlayingView: View { VStack(spacing: 0) { topBar Spacer() - albumArt(size: 300) + + // Album art OR lyrics overlay + if lyricsManager.isLyricsVisible { + lyricsOverlay + .frame(height: 340) + .transition(.opacity) + } else { + albumArt(size: 300) + .transition(.opacity) + } + Spacer() songInfo progressBar.padding(.top, 16) @@ -725,6 +749,33 @@ struct NowPlayingView: View { } } + // MARK: - Lyrics Overlay + + /// Replaces the cover art area when lyrics are active. + /// The visualizer continues behind — a blur panel fades in where lyrics text starts. + private var lyricsOverlay: some View { + ZStack { + // Blur panel: .ultraThinMaterial with a gradient mask so the top + // fades to transparent (visualizer shows through), bottom is opaque + Rectangle() + .fill(.ultraThinMaterial) + .mask( + VStack(spacing: 0) { + LinearGradient( + colors: [.clear, .white], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 50) + Color.white + } + ) + + // Lyrics content on top of the blur + LyricsOverlayView() + } + } + // MARK: - Album Art private func albumArt(size: CGFloat) -> some View { @@ -1028,6 +1079,17 @@ struct NowPlayingView: View { Image(systemName: "list.bullet").font(.system(size: isLandscape ? 14 : 16)).foregroundColor(.gray) }.frame(width: 40, height: 40) Spacer() + // Lyrics toggle + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + lyricsManager.isLyricsVisible.toggle() + } + }) { + Image(systemName: "quote.bubble") + .font(.system(size: isLandscape ? 14 : 16)) + .foregroundColor(lyricsManager.isLyricsVisible ? accentPink : .gray) + }.frame(width: 40, height: 40) + Spacer() AirPlayButton().frame(width: 40, height: 40) } }