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:
parent
c0a073bf3f
commit
7413163d57
5 changed files with 107 additions and 16 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue