Live lyrics system — full implementation

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
This commit is contained in:
Dallas Groot 2026-04-13 09:01:30 -07:00
parent c0a073bf3f
commit 7413163d57
5 changed files with 107 additions and 16 deletions

View file

@ -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 {

View file

@ -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)

View file

@ -78,6 +78,8 @@
<string>Identify songs playing nearby using Shazam.</string>
<!-- Register .nvdbackup file type so AirDrop opens the app automatically -->
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>

View file

@ -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)
}

View file

@ -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)
}
}