NavidromeApp/iOS/Views/Lyrics/LyricsEditorView.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

367 lines
14 KiB
Swift

import SwiftUI
//
// LyricsEditorView.swift
// Timing editor for syncing lyrics to audio. Two modes:
// 1. Tap-to-Sync: play the song, tap a button as each line begins
// 2. Fine-tune: ±0.1s nudge buttons per line, global offset
//
// Save options: local cache or embed into file via Companion API.
//
struct LyricsEditorView: View {
@Environment(\.dismiss) var dismiss
@ObservedObject var audioPlayer = AudioPlayer.shared
@State private var lines: [EditableLine]
@State private var isTapSyncMode = false
@State private var tapSyncIndex = 0
@State private var showSaveOptions = false
@State private var isSaving = false
@State private var saveMessage: String?
@State private var globalOffset: Double = 0
@State private var showOffsetSheet = false
private let songPath: String?
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
struct EditableLine: Identifiable {
let id: Int
var startTime: Double? // nil = not yet timed
var text: String
}
init(lyrics: LyricsData, songPath: String?) {
self.songPath = songPath
if lyrics.hasSynced {
_lines = State(initialValue: lyrics.lines.map {
EditableLine(id: $0.id, startTime: $0.startTime, text: $0.text)
})
} else {
_lines = State(initialValue: lyrics.lines.enumerated().map {
EditableLine(id: $0.offset, startTime: nil, text: $0.element.text)
})
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Playback bar
playbackBar
// Lines list
List {
ForEach($lines) { $line in
lineRow(line: $line)
}
}
.listStyle(.plain)
// Bottom action bar
bottomBar
}
.navigationTitle("Sync Lyrics")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { showSaveOptions = true }
.fontWeight(.semibold)
.foregroundStyle(accentPink)
}
}
.confirmationDialog("Save Lyrics", isPresented: $showSaveOptions) {
Button("Save to Device") { saveToDevice() }
if songPath != nil && CompanionSettings.shared.isEnabled {
Button("Embed in Audio File") { embedInFile() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Where should the synced lyrics be saved?")
}
.sheet(isPresented: $showOffsetSheet) {
offsetSheet
}
}
}
// MARK: - Playback Bar
private var playbackBar: some View {
HStack(spacing: 12) {
Button {
if audioPlayer.isPlaying {
audioPlayer.pause()
} else {
audioPlayer.resume()
}
} label: {
Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 32))
.foregroundStyle(accentPink)
}
// Time display
Text(formatTime(audioPlayer.currentTime))
.font(.system(size: 14).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 50, alignment: .trailing)
// Progress bar
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(.systemGray5))
RoundedRectangle(cornerRadius: 2)
.fill(accentPink)
.frame(width: geo.size.width * (audioPlayer.duration > 0 ? audioPlayer.currentTime / audioPlayer.duration : 0))
}
}
.frame(height: 4)
Text(formatTime(audioPlayer.duration))
.font(.system(size: 14).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 50, alignment: .leading)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Color(.systemGray6).opacity(0.5))
}
// MARK: - Line Row
private func lineRow(line: Binding<EditableLine>) -> some View {
let isActive = isTapSyncMode && line.wrappedValue.id == tapSyncIndex
return HStack(spacing: 10) {
// Timestamp
if let time = line.wrappedValue.startTime {
Text(formatTimePrecise(time))
.font(.system(size: 13, weight: .semibold).monospacedDigit())
.foregroundStyle(accentPink)
.frame(width: 55, alignment: .leading)
} else {
Text("—:——")
.font(.system(size: 13, weight: .semibold).monospacedDigit())
.foregroundStyle(.gray.opacity(0.4))
.frame(width: 55, alignment: .leading)
}
// Text
Text(line.wrappedValue.text)
.font(.system(size: 15))
.foregroundStyle(isActive ? .white : .white.opacity(0.8))
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
// ± adjust buttons (only if timed)
if line.wrappedValue.startTime != nil {
HStack(spacing: 2) {
Button {
line.wrappedValue.startTime = max((line.wrappedValue.startTime ?? 0) - 0.1, 0)
} label: {
Image(systemName: "minus")
.font(.system(size: 12, weight: .medium))
.frame(width: 28, height: 28)
.background(Color(.systemGray5).cornerRadius(6))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
Button {
line.wrappedValue.startTime = (line.wrappedValue.startTime ?? 0) + 0.1
} label: {
Image(systemName: "plus")
.font(.system(size: 12, weight: .medium))
.frame(width: 28, height: 28)
.background(Color(.systemGray5).cornerRadius(6))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
}
.padding(.vertical, 4)
.listRowBackground(isActive ? accentPink.opacity(0.1) : Color.clear)
}
// MARK: - Bottom Bar
private var bottomBar: some View {
HStack(spacing: 12) {
// Offset All
Button {
showOffsetSheet = true
} label: {
Text("Offset All ±")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.systemGray5))
)
}
// Tap to Sync
Button {
if isTapSyncMode {
// Stamp current line
if tapSyncIndex < lines.count {
lines[tapSyncIndex].startTime = audioPlayer.currentTime
tapSyncIndex += 1
if tapSyncIndex >= lines.count {
isTapSyncMode = false
}
}
} else {
// Enter tap-sync mode
isTapSyncMode = true
tapSyncIndex = lines.firstIndex(where: { $0.startTime == nil }) ?? 0
if !audioPlayer.isPlaying {
audioPlayer.resume()
}
}
} label: {
Text(isTapSyncMode ? "Tap ▸ Line \(tapSyncIndex + 1)" : "Tap to Sync ▸")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(accentPink)
)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Color(.systemGray6).opacity(0.5))
}
// MARK: - Offset Sheet
private var offsetSheet: some View {
NavigationStack {
VStack(spacing: 24) {
Text("Shift all timestamps by:")
.font(.system(size: 17, weight: .medium))
Text(String(format: "%+.1fs", globalOffset))
.font(.system(size: 48, weight: .bold).monospacedDigit())
.foregroundStyle(accentPink)
HStack(spacing: 20) {
Button { globalOffset -= 1.0 } label: { offsetBtn("-1.0s") }
Button { globalOffset -= 0.1 } label: { offsetBtn("-0.1s") }
Button { globalOffset += 0.1 } label: { offsetBtn("+0.1s") }
Button { globalOffset += 1.0 } label: { offsetBtn("+1.0s") }
}
Button("Apply") {
applyGlobalOffset()
showOffsetSheet = false
}
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(accentPink)
.padding(.top, 8)
}
.padding(24)
.navigationTitle("Global Offset")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { showOffsetSheet = false }
}
}
}
.presentationDetents([.medium])
}
private func offsetBtn(_ label: String) -> some View {
Text(label)
.font(.system(size: 14, weight: .semibold).monospacedDigit())
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(.systemGray5))
)
.foregroundStyle(.white)
}
// MARK: - Actions
private func applyGlobalOffset() {
for i in lines.indices {
if let t = lines[i].startTime {
lines[i].startTime = max(t + globalOffset, 0)
}
}
globalOffset = 0
}
private func buildLyricLines() -> [LyricLine] {
var result: [LyricLine] = []
let sorted = lines.filter { $0.startTime != nil }.sorted { ($0.startTime ?? 0) < ($1.startTime ?? 0) }
for (i, line) in sorted.enumerated() {
let endTime = i + 1 < sorted.count ? sorted[i + 1].startTime : nil
result.append(LyricLine(
id: i,
startTime: line.startTime ?? 0,
endTime: endTime,
text: line.text,
words: nil
))
}
return result
}
private func saveToDevice() {
let builtLines = buildLyricLines()
LyricsManager.shared.updateTimedLyrics(builtLines)
dismiss()
}
private func embedInFile() {
guard let path = songPath else { return }
isSaving = true
let builtLines = buildLyricLines()
let lrc = LRCParser.toLRC(builtLines)
Task {
do {
let api = CompanionAPIService.shared
try await api.embedLyrics(relativePath: path, lrcContent: lrc)
LyricsManager.shared.updateTimedLyrics(builtLines)
await MainActor.run {
isSaving = false
dismiss()
}
} catch {
await MainActor.run {
isSaving = false
saveMessage = "Embed failed: \(error.localizedDescription)"
}
}
}
}
// MARK: - Formatting
private func formatTime(_ t: Double) -> String {
let total = Int(max(t, 0))
return String(format: "%d:%02d", total / 60, total % 60)
}
private func formatTimePrecise(_ t: Double) -> String {
let m = Int(t) / 60
let s = t - Double(m * 60)
return String(format: "%d:%04.1f", m, s)
}
}