NavidromeApp/iOS/Views/Lyrics/LyricsSearchSheet.swift
Dallas Groot c0a073bf3f Live lyrics system — foundation + search + editor
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, auto-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

iOS data layer:
- LyricsModels.swift: LyricLine, LyricWord, LyricsData, LRCParser (full LRC parser + serializer)
- LyricsCache: local disk cache keyed by artist-title, memory tier
- CompanionAPIService: 4 new methods (fetchForPath, fetchFromLRCLIB, search, embed)

iOS manager:
- LyricsManager: source pipeline (embedded > lrc > cache > LRCLIB), binary search line/word tracking
- Hooked into AudioPlayer periodic time observer (0.5s sync) and pushWidgetState (song change)
- Word-level progress tracking for karaoke gradient fill

iOS views:
- LyricsOverlayView: karaoke word-by-word highlighting, FlowLayout, ScrollViewReader auto-scroll
- LyricsSearchSheet: LRCLIB search with results, duration mismatch warnings, preview + import
- LyricsEditorView: tap-to-sync timing, per-line ±0.1s adjust, global offset, save to device or embed

Still needs wiring: lyrics toggle button in NowPlayingView, blur panel overlay, mini player ticker
2026-04-12 20:56:48 -07:00

277 lines
10 KiB
Swift

import SwiftUI
//
// LyricsSearchSheet.swift
// Search LRCLIB for lyrics, preview results, and import into the app.
// Presented as a sheet from Now Playing or Track Editor.
//
struct LyricsSearchSheet: View {
@Environment(\.dismiss) var dismiss
@ObservedObject var audioPlayer = AudioPlayer.shared
@State private var query: String
@State private var results: [LRCLIBResult] = []
@State private var isSearching = false
@State private var selectedResult: LRCLIBResult?
@State private var showPreview = false
@State private var errorMessage: String?
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
/// Initialize with a pre-filled query (typically "artist title")
init(initialQuery: String = "") {
_query = State(initialValue: initialQuery)
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
HStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.gray)
TextField("Search lyrics...", text: $query)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.onSubmit { search() }
if !query.isEmpty {
Button {
query = ""
results = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.gray)
}
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.systemGray6))
)
.padding(.horizontal, 16)
.padding(.top, 8)
if isSearching {
Spacer()
ProgressView("Searching...")
.foregroundStyle(.secondary)
Spacer()
} else if let error = errorMessage {
Spacer()
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 28))
.foregroundStyle(.orange)
Text(error)
.font(.system(size: 14))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 40)
Spacer()
} else if results.isEmpty && !query.isEmpty {
Spacer()
Text("No results found")
.foregroundStyle(.secondary)
Spacer()
} else {
List(results) { result in
Button {
selectedResult = result
showPreview = true
} label: {
resultRow(result)
}
.listRowBackground(Color.clear)
}
.listStyle(.plain)
}
}
.navigationTitle("Search Lyrics")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.sheet(isPresented: $showPreview) {
if let result = selectedResult {
LyricsPreviewSheet(result: result) {
// On import
importResult(result)
dismiss()
}
}
}
.onAppear {
if !query.isEmpty { search() }
}
}
}
// MARK: - Result Row
private func resultRow(_ result: LRCLIBResult) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(result.trackName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
Spacer()
if result.hasSynced {
Text("Synced")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.green)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Capsule().fill(.green.opacity(0.15)))
} else if result.hasPlain {
Text("Plain")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.gray)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Capsule().fill(.gray.opacity(0.15)))
}
}
HStack(spacing: 6) {
Text(result.artistName)
.font(.system(size: 13))
.foregroundStyle(.secondary)
.lineLimit(1)
if let album = result.albumName, !album.isEmpty {
Text("·")
.foregroundStyle(.secondary.opacity(0.5))
Text(album)
.font(.system(size: 13))
.foregroundStyle(.secondary.opacity(0.7))
.lineLimit(1)
}
Text("·")
.foregroundStyle(.secondary.opacity(0.5))
Text(formatDuration(result.duration))
.font(.system(size: 13).monospacedDigit())
.foregroundStyle(.secondary)
}
// Duration mismatch warning
if audioPlayer.duration > 0 {
let diff = abs(result.duration - audioPlayer.duration)
if diff > 10 {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10))
Text("Duration differs by \(formatDuration(diff))")
.font(.system(size: 11))
}
.foregroundStyle(.orange.opacity(0.8))
.padding(.top, 2)
}
}
}
.padding(.vertical, 4)
}
// MARK: - Actions
private func search() {
guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { return }
isSearching = true
errorMessage = nil
Task {
do {
let api = CompanionAPIService.shared
let fetched = try await api.searchLyrics(query: query)
await MainActor.run {
results = fetched
isSearching = false
}
} catch {
await MainActor.run {
isSearching = false
errorMessage = "Search failed: \(error.localizedDescription)"
}
}
}
}
private func importResult(_ result: LRCLIBResult) {
if let synced = result.syncedLyrics {
LyricsManager.shared.importLRC(synced, source: "lrclib")
} else if let plain = result.plainLyrics {
LyricsManager.shared.importPlain(plain, source: "lrclib")
}
}
private func formatDuration(_ seconds: Double) -> String {
let total = Int(max(seconds, 0))
return String(format: "%d:%02d", total / 60, total % 60)
}
}
// MARK: - Preview Sheet
struct LyricsPreviewSheet: View {
let result: LRCLIBResult
let onImport: () -> Void
@Environment(\.dismiss) var dismiss
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
// Header
VStack(alignment: .leading, spacing: 4) {
Text(result.trackName)
.font(.system(size: 20, weight: .bold))
Text(result.artistName)
.font(.system(size: 16))
.foregroundStyle(.secondary)
if let album = result.albumName {
Text(album)
.font(.system(size: 14))
.foregroundStyle(.secondary.opacity(0.7))
}
}
.padding(.bottom, 12)
Divider()
// Lyrics text
let text = result.syncedLyrics ?? result.plainLyrics ?? "No lyrics"
Text(text)
.font(.system(size: 15))
.foregroundStyle(.white.opacity(0.8))
.lineSpacing(6)
.padding(.top, 8)
}
.padding(20)
}
.navigationTitle("Preview")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Use These Lyrics") {
onImport()
dismiss()
}
.foregroundStyle(accentPink)
.fontWeight(.semibold)
}
}
}
}
}