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
367 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|