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
|
|
|
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 {
|
2026-04-14 00:27:10 -07:00
|
|
|
let fetched = try await LyricsService.shared.search(query: query)
|
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
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|