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
This commit is contained in:
parent
3824f42ab5
commit
c0a073bf3f
7 changed files with 1545 additions and 0 deletions
|
|
@ -815,6 +815,12 @@ class AudioPlayer: NSObject, ObservableObject {
|
||||||
WidgetBridge.shared.updatePosition(currentTime: time.seconds, isPlaying: self.isPlaying)
|
WidgetBridge.shared.updatePosition(currentTime: time.seconds, isPlaying: self.isPlaying)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
// Sync lyrics line tracking every ~0.5s (every 5th tick of 0.1s interval)
|
||||||
|
if Int(time.seconds * 10) % 5 == 0 {
|
||||||
|
LyricsManager.shared.syncToTime(time.seconds)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodic Now Playing info sync (keeps Dynamic Island/Lock Screen in sync)
|
// Periodic Now Playing info sync (keeps Dynamic Island/Lock Screen in sync)
|
||||||
|
|
@ -1782,6 +1788,16 @@ class AudioPlayer: NSObject, ObservableObject {
|
||||||
/// Called on song change, play/pause, and artwork load.
|
/// Called on song change, play/pause, and artwork load.
|
||||||
private func pushWidgetState() {
|
private func pushWidgetState() {
|
||||||
guard let song = currentSong else { return }
|
guard let song = currentSong else { return }
|
||||||
|
|
||||||
|
// Notify LyricsManager of the current song (deduplicates internally by song ID)
|
||||||
|
LyricsManager.shared.songChanged(
|
||||||
|
songId: song.id,
|
||||||
|
artist: song.artist ?? "Unknown",
|
||||||
|
title: song.title,
|
||||||
|
path: song.path,
|
||||||
|
duration: duration
|
||||||
|
)
|
||||||
|
|
||||||
let upcoming = Array(queue.dropFirst(queueIndex + 1).prefix(3))
|
let upcoming = Array(queue.dropFirst(queueIndex + 1).prefix(3))
|
||||||
.map { (title: $0.title, artist: $0.artist ?? "Unknown") }
|
.map { (title: $0.title, artist: $0.artist ?? "Unknown") }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,71 @@ actor CompanionAPIService {
|
||||||
return profiles
|
return profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Lyrics
|
||||||
|
|
||||||
|
/// Fetch lyrics for a song by its file path. Checks embedded tags, .lrc sidecar, and DB cache.
|
||||||
|
func fetchLyricsForPath(relativePath: String) async throws -> LyricsResponse {
|
||||||
|
let base = try baseURL()
|
||||||
|
guard let encoded = relativePath.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||||
|
let url = URL(string: "\(base)/lyrics/get?relative_path=\(encoded)") else {
|
||||||
|
throw CompanionError.invalidURL
|
||||||
|
}
|
||||||
|
let (data, response) = try await session.data(from: url)
|
||||||
|
try validateResponse(response)
|
||||||
|
return try JSONDecoder().decode(LyricsResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch lyrics from LRCLIB via companion API (exact match by artist/title/duration).
|
||||||
|
func fetchLyricsFromLRCLIB(artist: String, title: String, duration: Double) async throws -> LyricsResponse {
|
||||||
|
let base = try baseURL()
|
||||||
|
var components = URLComponents(url: base.appendingPathComponent("lyrics/fetch"), resolvingAgainstBaseURL: false)!
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "artist", value: artist),
|
||||||
|
URLQueryItem(name: "title", value: title),
|
||||||
|
URLQueryItem(name: "duration", value: String(duration))
|
||||||
|
]
|
||||||
|
guard let url = components.url else { throw CompanionError.invalidURL }
|
||||||
|
let (data, response) = try await session.data(from: url)
|
||||||
|
try validateResponse(response)
|
||||||
|
return try JSONDecoder().decode(LyricsResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search LRCLIB for lyrics matching a query.
|
||||||
|
func searchLyrics(query: String) async throws -> [LRCLIBResult] {
|
||||||
|
let base = try baseURL()
|
||||||
|
var components = URLComponents(url: base.appendingPathComponent("lyrics/search"), resolvingAgainstBaseURL: false)!
|
||||||
|
components.queryItems = [URLQueryItem(name: "q", value: query)]
|
||||||
|
guard let url = components.url else { throw CompanionError.invalidURL }
|
||||||
|
let (data, response) = try await session.data(from: url)
|
||||||
|
try validateResponse(response)
|
||||||
|
return try JSONDecoder().decode([LRCLIBResult].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Embed LRC lyrics into an audio file via Companion API.
|
||||||
|
func embedLyrics(relativePath: String, lrcContent: String) async throws {
|
||||||
|
let base = try baseURL()
|
||||||
|
let url = base.appendingPathComponent("lyrics/embed")
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
|
||||||
|
let boundary = UUID().uuidString
|
||||||
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
var body = Data()
|
||||||
|
func field(_ name: String, _ value: String) {
|
||||||
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
|
||||||
|
body.append("\(value)\r\n".data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
field("relative_path", relativePath)
|
||||||
|
field("lrc_content", lrcContent)
|
||||||
|
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
|
request.httpBody = body
|
||||||
|
|
||||||
|
let (_, response) = try await session.data(for: request)
|
||||||
|
try validateResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Visualizer Frames (GET /visualizer/frames?relative_path=...)
|
// MARK: - Visualizer Frames (GET /visualizer/frames?relative_path=...)
|
||||||
|
|
||||||
/// Fetch pre-computed Mitsuha visualizer frames from the server.
|
/// Fetch pre-computed Mitsuha visualizer frames from the server.
|
||||||
|
|
|
||||||
367
iOS/Views/Lyrics/LyricsEditorView.swift
Normal file
367
iOS/Views/Lyrics/LyricsEditorView.swift
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
275
iOS/Views/Lyrics/LyricsManager.swift
Normal file
275
iOS/Views/Lyrics/LyricsManager.swift
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// LyricsManager.swift
|
||||||
|
// Orchestrates lyrics loading, line/word tracking during playback,
|
||||||
|
// and the source priority pipeline (embedded > lrc > cache > LRCLIB).
|
||||||
|
//
|
||||||
|
// Performance: line tracking uses binary search (O(log n)).
|
||||||
|
// Word tracking only runs when the lyrics view is visible.
|
||||||
|
// No @Published on per-frame properties — views use TimelineView.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final class LyricsManager: ObservableObject {
|
||||||
|
static let shared = LyricsManager()
|
||||||
|
|
||||||
|
// MARK: - Published State (low-frequency updates)
|
||||||
|
|
||||||
|
/// The loaded lyrics for the current song. Nil = no lyrics available.
|
||||||
|
@Published var currentLyrics: LyricsData?
|
||||||
|
|
||||||
|
/// Whether lyrics are currently being fetched.
|
||||||
|
@Published var isLoading = false
|
||||||
|
|
||||||
|
/// The current line index — updated ~every 0.5s from the sync timer.
|
||||||
|
@Published var currentLineIndex: Int = 0
|
||||||
|
|
||||||
|
/// Whether the lyrics overlay is visible in Now Playing.
|
||||||
|
@Published var isLyricsVisible = false
|
||||||
|
|
||||||
|
// MARK: - Non-Published State (polled per-frame by views)
|
||||||
|
|
||||||
|
/// Current word index within the active line. Updated per-frame when visible.
|
||||||
|
/// NOT @Published — views read this from TimelineView/Canvas to avoid SwiftUI thrash.
|
||||||
|
var currentWordIndex: Int = 0
|
||||||
|
|
||||||
|
/// The current playback time — snapshot for word-level tracking.
|
||||||
|
var lastSyncedTime: Double = 0
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private var currentSongId: String?
|
||||||
|
private var currentArtist: String?
|
||||||
|
private var currentTitle: String?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Song Changed
|
||||||
|
|
||||||
|
/// Call when a new song starts playing. Kicks off the lyrics fetch pipeline.
|
||||||
|
func songChanged(songId: String, artist: String, title: String, path: String?, duration: Double) {
|
||||||
|
// Skip if same song
|
||||||
|
guard songId != currentSongId else { return }
|
||||||
|
currentSongId = songId
|
||||||
|
currentArtist = artist
|
||||||
|
currentTitle = title
|
||||||
|
currentLineIndex = 0
|
||||||
|
currentWordIndex = 0
|
||||||
|
lastSyncedTime = 0
|
||||||
|
|
||||||
|
// Check local cache first (instant, no network)
|
||||||
|
if let cached = LyricsCache.shared.get(artist: artist, title: title) {
|
||||||
|
currentLyrics = cached
|
||||||
|
DebugLogger.shared.log("Lyrics: loaded from cache (\(cached.lines.count) lines)", category: "Lyrics")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch async from companion API
|
||||||
|
isLoading = true
|
||||||
|
Task {
|
||||||
|
await fetchLyrics(artist: artist, title: title, path: path, duration: duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear lyrics when playback stops.
|
||||||
|
func clear() {
|
||||||
|
currentSongId = nil
|
||||||
|
currentArtist = nil
|
||||||
|
currentTitle = nil
|
||||||
|
currentLyrics = nil
|
||||||
|
currentLineIndex = 0
|
||||||
|
currentWordIndex = 0
|
||||||
|
lastSyncedTime = 0
|
||||||
|
isLyricsVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Time Sync (called from AudioPlayer's sync timer)
|
||||||
|
|
||||||
|
/// Update the current line based on playback time. Call every ~0.5s.
|
||||||
|
/// Uses binary search for O(log n) lookup.
|
||||||
|
func syncToTime(_ time: Double) {
|
||||||
|
lastSyncedTime = time
|
||||||
|
guard let lyrics = currentLyrics, !lyrics.lines.isEmpty else { return }
|
||||||
|
|
||||||
|
let newIndex = findLineIndex(at: time, in: lyrics.lines)
|
||||||
|
if newIndex != currentLineIndex {
|
||||||
|
currentLineIndex = newIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update word index if we have word-level data
|
||||||
|
if isLyricsVisible,
|
||||||
|
newIndex < lyrics.lines.count,
|
||||||
|
let words = lyrics.lines[newIndex].words {
|
||||||
|
currentWordIndex = findWordIndex(at: time, in: words)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fine-grained word tracking — call per-frame from TimelineView when visible.
|
||||||
|
/// Returns the word progress (0.0–1.0) within the current word for gradient fill.
|
||||||
|
func wordProgress(at time: Double) -> (lineIndex: Int, wordIndex: Int, wordFraction: Double) {
|
||||||
|
guard let lyrics = currentLyrics, !lyrics.lines.isEmpty else {
|
||||||
|
return (0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let lineIdx = findLineIndex(at: time, in: lyrics.lines)
|
||||||
|
let line = lyrics.lines[lineIdx]
|
||||||
|
|
||||||
|
guard let words = line.words, !words.isEmpty else {
|
||||||
|
return (lineIdx, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let wordIdx = findWordIndex(at: time, in: words)
|
||||||
|
let word = words[min(wordIdx, words.count - 1)]
|
||||||
|
let wordDuration = word.endTime - word.startTime
|
||||||
|
let fraction: Double
|
||||||
|
if wordDuration > 0 {
|
||||||
|
fraction = min(max((time - word.startTime) / wordDuration, 0), 1)
|
||||||
|
} else {
|
||||||
|
fraction = time >= word.startTime ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return (lineIdx, wordIdx, fraction)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text of the current line, for the mini player ticker.
|
||||||
|
var currentLineText: String? {
|
||||||
|
guard let lyrics = currentLyrics, !lyrics.lines.isEmpty,
|
||||||
|
currentLineIndex < lyrics.lines.count else { return nil }
|
||||||
|
return lyrics.lines[currentLineIndex].text
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manual Lyrics Import
|
||||||
|
|
||||||
|
/// Import lyrics from LRC text (pasted or from search result).
|
||||||
|
func importLRC(_ lrc: String, source: String = "lrclib") {
|
||||||
|
let lines = LRCParser.parse(lrc)
|
||||||
|
let data = LyricsData(lines: lines, source: source, hasSynced: true)
|
||||||
|
currentLyrics = data
|
||||||
|
|
||||||
|
if let artist = currentArtist, let title = currentTitle {
|
||||||
|
LyricsCache.shared.store(data, artist: artist, title: title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import plain (unsynced) lyrics.
|
||||||
|
func importPlain(_ text: String, source: String = "manual") {
|
||||||
|
let lines = text.components(separatedBy: .newlines)
|
||||||
|
.enumerated()
|
||||||
|
.filter { !$0.element.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
|
.map { LyricLine(id: $0.offset, startTime: 0, endTime: nil, text: $0.element, words: nil) }
|
||||||
|
|
||||||
|
let data = LyricsData(lines: lines, source: source, hasSynced: false)
|
||||||
|
currentLyrics = data
|
||||||
|
|
||||||
|
if let artist = currentArtist, let title = currentTitle {
|
||||||
|
LyricsCache.shared.store(data, artist: artist, title: title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update lyrics after timing editor saves (replaces current with new timestamps).
|
||||||
|
func updateTimedLyrics(_ lines: [LyricLine]) {
|
||||||
|
let data = LyricsData(lines: lines, source: "manual", hasSynced: true)
|
||||||
|
currentLyrics = data
|
||||||
|
|
||||||
|
if let artist = currentArtist, let title = currentTitle {
|
||||||
|
LyricsCache.shared.store(data, artist: artist, title: title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private: Fetch Pipeline
|
||||||
|
|
||||||
|
private func fetchLyrics(artist: String, title: String, path: String?, duration: Double) async {
|
||||||
|
// 1. Try Companion API /lyrics/get (checks embedded + .lrc + cache)
|
||||||
|
if CompanionSettings.shared.isEnabled, let path = path {
|
||||||
|
do {
|
||||||
|
let api = CompanionAPIService.shared
|
||||||
|
let response = try await api.fetchLyricsForPath(relativePath: path)
|
||||||
|
if let synced = response.syncedLyrics, !synced.isEmpty {
|
||||||
|
let lines = LRCParser.parse(synced)
|
||||||
|
let data = LyricsData(lines: lines, source: response.source ?? "embedded", hasSynced: true)
|
||||||
|
await MainActor.run {
|
||||||
|
self.currentLyrics = data
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
LyricsCache.shared.store(data, artist: artist, title: title)
|
||||||
|
DebugLogger.shared.log("Lyrics: found via companion (\(response.source ?? "?"), \(lines.count) lines)", category: "Lyrics")
|
||||||
|
return
|
||||||
|
} else if let plain = response.plainLyrics, !plain.isEmpty {
|
||||||
|
await MainActor.run {
|
||||||
|
self.importPlain(plain, source: response.source ?? "embedded")
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
DebugLogger.shared.log("Lyrics: plain text via companion (\(response.source ?? "?"))", category: "Lyrics")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
DebugLogger.shared.log("Lyrics: companion fetch failed: \(error.localizedDescription)", category: "Lyrics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try LRCLIB via Companion API
|
||||||
|
if CompanionSettings.shared.isEnabled {
|
||||||
|
do {
|
||||||
|
let api = CompanionAPIService.shared
|
||||||
|
let response = try await api.fetchLyricsFromLRCLIB(artist: artist, title: title, duration: duration)
|
||||||
|
if let synced = response.syncedLyrics, !synced.isEmpty {
|
||||||
|
let lines = LRCParser.parse(synced)
|
||||||
|
let data = LyricsData(lines: lines, source: "lrclib", hasSynced: true)
|
||||||
|
await MainActor.run {
|
||||||
|
self.currentLyrics = data
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
LyricsCache.shared.store(data, artist: artist, title: title)
|
||||||
|
DebugLogger.shared.log("Lyrics: found on LRCLIB (synced, \(lines.count) lines)", category: "Lyrics")
|
||||||
|
return
|
||||||
|
} else if let plain = response.plainLyrics, !plain.isEmpty {
|
||||||
|
await MainActor.run {
|
||||||
|
self.importPlain(plain, source: "lrclib")
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
DebugLogger.shared.log("Lyrics: found on LRCLIB (plain text)", category: "Lyrics")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
DebugLogger.shared.log("Lyrics: LRCLIB fetch failed: \(error.localizedDescription)", category: "Lyrics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.currentLyrics = nil
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
|
DebugLogger.shared.log("Lyrics: none found for \(artist) — \(title)", category: "Lyrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binary Search
|
||||||
|
|
||||||
|
/// Find the active line index at a given time. O(log n).
|
||||||
|
private func findLineIndex(at time: Double, in lines: [LyricLine]) -> Int {
|
||||||
|
var lo = 0, hi = lines.count - 1
|
||||||
|
while lo <= hi {
|
||||||
|
let mid = (lo + hi) / 2
|
||||||
|
if lines[mid].startTime <= time {
|
||||||
|
lo = mid + 1
|
||||||
|
} else {
|
||||||
|
hi = mid - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max(hi, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the active word index at a given time. O(log n).
|
||||||
|
private func findWordIndex(at time: Double, in words: [LyricWord]) -> Int {
|
||||||
|
var lo = 0, hi = words.count - 1
|
||||||
|
while lo <= hi {
|
||||||
|
let mid = (lo + hi) / 2
|
||||||
|
if words[mid].startTime <= time {
|
||||||
|
lo = mid + 1
|
||||||
|
} else {
|
||||||
|
hi = mid - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max(hi, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
284
iOS/Views/Lyrics/LyricsModels.swift
Normal file
284
iOS/Views/Lyrics/LyricsModels.swift
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// LyricsModels.swift
|
||||||
|
// Data types for synced lyrics, LRC parsing, and local disk cache.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// MARK: - Data Models
|
||||||
|
|
||||||
|
struct LyricLine: Codable, Identifiable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let startTime: Double // seconds
|
||||||
|
var endTime: Double? // nil = until next line
|
||||||
|
let text: String
|
||||||
|
var words: [LyricWord]? // nil = line-level only (no karaoke)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LyricWord: Codable, Identifiable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let text: String
|
||||||
|
let startTime: Double
|
||||||
|
let endTime: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LyricsData: Codable {
|
||||||
|
let lines: [LyricLine]
|
||||||
|
let source: String // "embedded", "lrc_file", "lrclib", "manual"
|
||||||
|
let hasSynced: Bool
|
||||||
|
|
||||||
|
static let empty = LyricsData(lines: [], source: "", hasSynced: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - LRCLIB Search Result
|
||||||
|
|
||||||
|
struct LRCLIBResult: Codable, Identifiable {
|
||||||
|
let id: Int?
|
||||||
|
let trackName: String
|
||||||
|
let artistName: String
|
||||||
|
let albumName: String?
|
||||||
|
let duration: Double
|
||||||
|
let hasSynced: Bool
|
||||||
|
let hasPlain: Bool
|
||||||
|
let syncedLyrics: String?
|
||||||
|
let plainLyrics: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Companion API Lyrics Response
|
||||||
|
|
||||||
|
struct LyricsResponse: Codable {
|
||||||
|
let syncedLyrics: String?
|
||||||
|
let plainLyrics: String?
|
||||||
|
let source: String?
|
||||||
|
let cached: Bool?
|
||||||
|
let found: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - LRC Parser
|
||||||
|
|
||||||
|
/// Parses standard LRC format into structured LyricLine/LyricWord arrays.
|
||||||
|
/// Supports both line-level `[mm:ss.xx]text` and enhanced word-level
|
||||||
|
/// `[mm:ss.xx]<mm:ss.xx>word<mm:ss.xx>word` formats.
|
||||||
|
enum LRCParser {
|
||||||
|
|
||||||
|
/// Parse LRC text into an array of LyricLines with computed endTimes.
|
||||||
|
static func parse(_ lrc: String) -> [LyricLine] {
|
||||||
|
let rawLines = lrc.components(separatedBy: .newlines)
|
||||||
|
var results: [LyricLine] = []
|
||||||
|
var idx = 0
|
||||||
|
|
||||||
|
for raw in rawLines {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty else { continue }
|
||||||
|
|
||||||
|
// Skip metadata lines like [ar:Artist] [ti:Title]
|
||||||
|
if trimmed.hasPrefix("[") && !trimmed.contains(":") { continue }
|
||||||
|
if let metaEnd = trimmed.firstIndex(of: "]"),
|
||||||
|
!trimmed[trimmed.index(after: trimmed.startIndex)...metaEnd].contains(where: { $0.isNumber }) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract all timestamps from the line (multi-timestamp lines)
|
||||||
|
let timestamps = extractTimestamps(from: trimmed)
|
||||||
|
guard !timestamps.isEmpty else { continue }
|
||||||
|
|
||||||
|
// Get the text after the last timestamp bracket
|
||||||
|
let text = extractText(from: trimmed)
|
||||||
|
guard !text.isEmpty else { continue }
|
||||||
|
|
||||||
|
// Parse word-level timestamps if present
|
||||||
|
let words = parseWords(from: text, lineStartTime: timestamps[0])
|
||||||
|
let cleanText = words != nil
|
||||||
|
? words!.map(\.text).joined(separator: " ")
|
||||||
|
: text
|
||||||
|
|
||||||
|
for ts in timestamps {
|
||||||
|
results.append(LyricLine(
|
||||||
|
id: idx,
|
||||||
|
startTime: ts,
|
||||||
|
endTime: nil,
|
||||||
|
text: cleanText,
|
||||||
|
words: words
|
||||||
|
))
|
||||||
|
idx += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by time and compute endTimes
|
||||||
|
results.sort { $0.startTime < $1.startTime }
|
||||||
|
for i in 0..<results.count {
|
||||||
|
results[i] = LyricLine(
|
||||||
|
id: i,
|
||||||
|
startTime: results[i].startTime,
|
||||||
|
endTime: i + 1 < results.count ? results[i + 1].startTime : nil,
|
||||||
|
text: results[i].text,
|
||||||
|
words: results[i].words
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert structured LyricLines back to LRC format string.
|
||||||
|
static func toLRC(_ lines: [LyricLine]) -> String {
|
||||||
|
lines.map { line in
|
||||||
|
let m = Int(line.startTime) / 60
|
||||||
|
let s = line.startTime - Double(m * 60)
|
||||||
|
return String(format: "[%02d:%05.2f]%@", m, s, line.text)
|
||||||
|
}.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private static func extractTimestamps(from line: String) -> [Double] {
|
||||||
|
let pattern = #"\[(\d{1,2}):(\d{2})[.:]*(\d{0,3})\]"#
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
|
||||||
|
let nsRange = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||||
|
let matches = regex.matches(in: line, range: nsRange)
|
||||||
|
|
||||||
|
return matches.compactMap { match in
|
||||||
|
guard match.numberOfRanges >= 3,
|
||||||
|
let minRange = Range(match.range(at: 1), in: line),
|
||||||
|
let secRange = Range(match.range(at: 2), in: line) else { return nil }
|
||||||
|
|
||||||
|
let minutes = Double(line[minRange]) ?? 0
|
||||||
|
let seconds = Double(line[secRange]) ?? 0
|
||||||
|
var millis: Double = 0
|
||||||
|
if match.numberOfRanges >= 4,
|
||||||
|
let msRange = Range(match.range(at: 3), in: line) {
|
||||||
|
let msStr = String(line[msRange])
|
||||||
|
if !msStr.isEmpty {
|
||||||
|
let padded = msStr.padding(toLength: 3, withPad: "0", startingAt: 0)
|
||||||
|
millis = (Double(padded) ?? 0) / 1000.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return minutes * 60 + seconds + millis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractText(from line: String) -> String {
|
||||||
|
// Remove all [mm:ss.xx] timestamps
|
||||||
|
let pattern = #"\[\d{1,2}:\d{2}[.:]*\d{0,3}\]"#
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return line }
|
||||||
|
let nsRange = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||||
|
return regex.stringByReplacingMatches(in: line, range: nsRange, withTemplate: "")
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseWords(from text: String, lineStartTime: Double) -> [LyricWord]? {
|
||||||
|
// Enhanced LRC: <mm:ss.xx>word<mm:ss.xx>word
|
||||||
|
let pattern = #"<(\d{1,2}):(\d{2})[.:]*(\d{0,3})>"#
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||||
|
let nsRange = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||||
|
let matches = regex.matches(in: text, range: nsRange)
|
||||||
|
|
||||||
|
guard !matches.isEmpty else { return nil }
|
||||||
|
|
||||||
|
var words: [LyricWord] = []
|
||||||
|
let splits = regex.matches(in: text, range: nsRange)
|
||||||
|
|
||||||
|
for (i, match) in splits.enumerated() {
|
||||||
|
guard let minRange = Range(match.range(at: 1), in: text),
|
||||||
|
let secRange = Range(match.range(at: 2), in: text) else { continue }
|
||||||
|
|
||||||
|
let minutes = Double(text[minRange]) ?? 0
|
||||||
|
let seconds = Double(text[secRange]) ?? 0
|
||||||
|
var millis: Double = 0
|
||||||
|
if match.numberOfRanges >= 4,
|
||||||
|
let msRange = Range(match.range(at: 3), in: text) {
|
||||||
|
let msStr = String(text[msRange])
|
||||||
|
if !msStr.isEmpty {
|
||||||
|
millis = (Double(msStr.padding(toLength: 3, withPad: "0", startingAt: 0)) ?? 0) / 1000.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let startTime = minutes * 60 + seconds + millis
|
||||||
|
|
||||||
|
// Extract word text between this timestamp and the next
|
||||||
|
let matchEnd = Range(match.range, in: text)!.upperBound
|
||||||
|
let nextStart: String.Index
|
||||||
|
if i + 1 < splits.count {
|
||||||
|
nextStart = Range(splits[i + 1].range, in: text)!.lowerBound
|
||||||
|
} else {
|
||||||
|
nextStart = text.endIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
let wordText = String(text[matchEnd..<nextStart]).trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !wordText.isEmpty else { continue }
|
||||||
|
|
||||||
|
let endTime: Double
|
||||||
|
if i + 1 < splits.count {
|
||||||
|
// Next word's start time
|
||||||
|
guard let nMin = Range(splits[i + 1].range(at: 1), in: text),
|
||||||
|
let nSec = Range(splits[i + 1].range(at: 2), in: text) else { continue }
|
||||||
|
let nm = Double(text[nMin]) ?? 0
|
||||||
|
let ns = Double(text[nSec]) ?? 0
|
||||||
|
endTime = nm * 60 + ns
|
||||||
|
} else {
|
||||||
|
endTime = startTime + 1.0 // estimate
|
||||||
|
}
|
||||||
|
|
||||||
|
words.append(LyricWord(id: i, text: wordText, startTime: startTime, endTime: endTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
return words.isEmpty ? nil : words
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lyrics Cache
|
||||||
|
|
||||||
|
/// Persistent local cache for fetched lyrics. Stores in Documents/lyrics/ as JSON files.
|
||||||
|
/// Keyed by a normalized "artist-title" hash so the same lyrics load instantly offline.
|
||||||
|
class LyricsCache {
|
||||||
|
static let shared = LyricsCache()
|
||||||
|
|
||||||
|
private let cacheDir: URL
|
||||||
|
private var memoryCache: [String: LyricsData] = [:]
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||||
|
cacheDir = docs.appendingPathComponent("lyrics", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cacheKey(artist: String, title: String) -> String {
|
||||||
|
let normalized = "\(artist.lowercased())_\(title.lowercased())"
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: " ", with: "_")
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cacheURL(for key: String) -> URL {
|
||||||
|
cacheDir.appendingPathComponent("\(key).json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(artist: String, title: String) -> LyricsData? {
|
||||||
|
let key = cacheKey(artist: artist, title: title)
|
||||||
|
if let cached = memoryCache[key] { return cached }
|
||||||
|
let url = cacheURL(for: key)
|
||||||
|
guard let data = try? Data(contentsOf: url),
|
||||||
|
let lyrics = try? JSONDecoder().decode(LyricsData.self, from: data) else { return nil }
|
||||||
|
memoryCache[key] = lyrics
|
||||||
|
return lyrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func store(_ lyrics: LyricsData, artist: String, title: String) {
|
||||||
|
let key = cacheKey(artist: artist, title: title)
|
||||||
|
memoryCache[key] = lyrics
|
||||||
|
if let data = try? JSONEncoder().encode(lyrics) {
|
||||||
|
try? data.write(to: cacheURL(for: key), options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(artist: String, title: String) {
|
||||||
|
let key = cacheKey(artist: artist, title: title)
|
||||||
|
memoryCache.removeValue(forKey: key)
|
||||||
|
try? FileManager.default.removeItem(at: cacheURL(for: key))
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
memoryCache.removeAll()
|
||||||
|
if let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
|
||||||
|
for file in files { try? FileManager.default.removeItem(at: file) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
261
iOS/Views/Lyrics/LyricsOverlayView.swift
Normal file
261
iOS/Views/Lyrics/LyricsOverlayView.swift
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// LyricsOverlayView.swift
|
||||||
|
// Full-screen lyrics overlay for Now Playing. Replaces the cover art
|
||||||
|
// area when active. Visualizer stays at top, blurs where text begins.
|
||||||
|
//
|
||||||
|
// Performance:
|
||||||
|
// - TimelineView(.animation) only active when visible
|
||||||
|
// - Binary search for line/word lookup (O(log n))
|
||||||
|
// - No @Published for per-frame word updates
|
||||||
|
// - Canvas for karaoke gradient fill (zero SwiftUI diff)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct LyricsOverlayView: View {
|
||||||
|
@ObservedObject var lyricsManager = LyricsManager.shared
|
||||||
|
@ObservedObject var audioPlayer = AudioPlayer.shared
|
||||||
|
|
||||||
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let lyrics = lyricsManager.currentLyrics, !lyrics.lines.isEmpty {
|
||||||
|
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !audioPlayer.isPlaying)) { timeline in
|
||||||
|
let now = audioPlayer.currentTime
|
||||||
|
let progress = lyricsManager.wordProgress(at: now)
|
||||||
|
|
||||||
|
lyricsContent(
|
||||||
|
lyrics: lyrics,
|
||||||
|
currentTime: now,
|
||||||
|
lineIndex: progress.lineIndex,
|
||||||
|
wordIndex: progress.wordIndex,
|
||||||
|
wordFraction: progress.wordFraction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if lyricsManager.isLoading {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white.opacity(0.5))
|
||||||
|
Text("Searching for lyrics...")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.white.opacity(0.4))
|
||||||
|
.padding(.top, 8)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "music.note")
|
||||||
|
.font(.system(size: 32, weight: .light))
|
||||||
|
.foregroundStyle(.white.opacity(0.15))
|
||||||
|
Text("No lyrics available")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.3))
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
// Open search sheet
|
||||||
|
NotificationCenter.default.post(name: .openLyricsSearch, object: nil)
|
||||||
|
} label: {
|
||||||
|
Text("Search Lyrics")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(accentPink)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(accentPink.opacity(0.15))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lyrics Content
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func lyricsContent(
|
||||||
|
lyrics: LyricsData,
|
||||||
|
currentTime: Double,
|
||||||
|
lineIndex: Int,
|
||||||
|
wordIndex: Int,
|
||||||
|
wordFraction: Double
|
||||||
|
) -> some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 16) {
|
||||||
|
// Top padding so first line isn't crammed at top
|
||||||
|
Spacer().frame(height: 40).id("top")
|
||||||
|
|
||||||
|
ForEach(lyrics.lines) { line in
|
||||||
|
let isCurrent = line.id == lineIndex
|
||||||
|
let isPast = line.id < lineIndex
|
||||||
|
let distance = line.id - lineIndex
|
||||||
|
|
||||||
|
if lyrics.hasSynced && isCurrent && line.words != nil {
|
||||||
|
// Karaoke mode: word-by-word highlighting
|
||||||
|
karaokeLineView(
|
||||||
|
line: line,
|
||||||
|
wordIndex: wordIndex,
|
||||||
|
wordFraction: wordFraction,
|
||||||
|
currentTime: currentTime
|
||||||
|
)
|
||||||
|
.id(line.id)
|
||||||
|
} else {
|
||||||
|
// Standard line view
|
||||||
|
Text(line.text)
|
||||||
|
.font(.system(size: isCurrent ? 26 : 21, weight: .bold))
|
||||||
|
.foregroundStyle(
|
||||||
|
isCurrent ? .white :
|
||||||
|
isPast ? .white.opacity(0.25) :
|
||||||
|
distance <= 2 ? .white.opacity(0.35) :
|
||||||
|
.white.opacity(0.18)
|
||||||
|
)
|
||||||
|
.lineLimit(3)
|
||||||
|
.id(line.id)
|
||||||
|
.onTapGesture {
|
||||||
|
if lyrics.hasSynced {
|
||||||
|
audioPlayer.seek(to: line.startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom padding so last line can scroll to center
|
||||||
|
Spacer().frame(height: 200)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
}
|
||||||
|
.mask(
|
||||||
|
// Fade top and bottom edges — NOT black, just alpha gradient
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
LinearGradient(colors: [.clear, .white], startPoint: .top, endPoint: .bottom)
|
||||||
|
.frame(height: 40)
|
||||||
|
Color.white
|
||||||
|
LinearGradient(colors: [.white, .clear], startPoint: .top, endPoint: .bottom)
|
||||||
|
.frame(height: 60)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onChange(of: lineIndex) { _, newIndex in
|
||||||
|
withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) {
|
||||||
|
proxy.scrollTo(newIndex, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Karaoke Line (word-by-word highlighting)
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func karaokeLineView(
|
||||||
|
line: LyricLine,
|
||||||
|
wordIndex: Int,
|
||||||
|
wordFraction: Double,
|
||||||
|
currentTime: Double
|
||||||
|
) -> some View {
|
||||||
|
let words = line.words ?? []
|
||||||
|
|
||||||
|
// Use a flow layout of individual word views
|
||||||
|
FlowLayout(spacing: 5) {
|
||||||
|
ForEach(words) { word in
|
||||||
|
let isSung = word.id < wordIndex
|
||||||
|
let isSinging = word.id == wordIndex
|
||||||
|
let fraction = isSinging ? wordFraction : (isSung ? 1.0 : 0.0)
|
||||||
|
|
||||||
|
KaraokeWordView(
|
||||||
|
text: word.text,
|
||||||
|
fillFraction: fraction,
|
||||||
|
isSung: isSung || isSinging
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
audioPlayer.seek(to: line.startTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Karaoke Word View (gradient fill per word)
|
||||||
|
|
||||||
|
struct KaraokeWordView: View {
|
||||||
|
let text: String
|
||||||
|
let fillFraction: Double
|
||||||
|
let isSung: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// Overlay technique: full white text masked by a gradient that slides
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 26, weight: .bold))
|
||||||
|
.foregroundStyle(isSung && fillFraction >= 1.0 ? .white : .white.opacity(0.3))
|
||||||
|
.overlay {
|
||||||
|
if fillFraction > 0 && fillFraction < 1.0 {
|
||||||
|
GeometryReader { geo in
|
||||||
|
Rectangle()
|
||||||
|
.fill(.white)
|
||||||
|
.frame(width: geo.size.width * fillFraction)
|
||||||
|
}
|
||||||
|
.mask(
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 26, weight: .bold))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Flow Layout (horizontal word wrap)
|
||||||
|
|
||||||
|
/// Simple flow layout that wraps words to the next line when they exceed width.
|
||||||
|
struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 5
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
let result = computeLayout(proposal: proposal, subviews: subviews)
|
||||||
|
return result.size
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
|
let result = computeLayout(proposal: proposal, subviews: subviews)
|
||||||
|
for (index, offset) in result.offsets.enumerated() {
|
||||||
|
subviews[index].place(at: CGPoint(x: bounds.minX + offset.x, y: bounds.minY + offset.y),
|
||||||
|
proposal: .unspecified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LayoutResult {
|
||||||
|
var offsets: [CGPoint]
|
||||||
|
var size: CGSize
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeLayout(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
|
||||||
|
let maxWidth = proposal.width ?? .infinity
|
||||||
|
var offsets: [CGPoint] = []
|
||||||
|
var x: CGFloat = 0
|
||||||
|
var y: CGFloat = 0
|
||||||
|
var rowHeight: CGFloat = 0
|
||||||
|
var totalWidth: CGFloat = 0
|
||||||
|
|
||||||
|
for subview in subviews {
|
||||||
|
let size = subview.sizeThatFits(.unspecified)
|
||||||
|
if x + size.width > maxWidth && x > 0 {
|
||||||
|
x = 0
|
||||||
|
y += rowHeight + spacing * 0.5
|
||||||
|
rowHeight = 0
|
||||||
|
}
|
||||||
|
offsets.append(CGPoint(x: x, y: y))
|
||||||
|
rowHeight = max(rowHeight, size.height)
|
||||||
|
x += size.width + spacing
|
||||||
|
totalWidth = max(totalWidth, x)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LayoutResult(offsets: offsets, size: CGSize(width: totalWidth, height: y + rowHeight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let openLyricsSearch = Notification.Name("openLyricsSearch")
|
||||||
|
}
|
||||||
277
iOS/Views/Lyrics/LyricsSearchSheet.swift
Normal file
277
iOS/Views/Lyrics/LyricsSearchSheet.swift
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue