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)
|
||||
#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)
|
||||
|
|
@ -1782,6 +1788,16 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
/// Called on song change, play/pause, and artwork load.
|
||||
private func pushWidgetState() {
|
||||
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))
|
||||
.map { (title: $0.title, artist: $0.artist ?? "Unknown") }
|
||||
|
||||
|
|
|
|||
|
|
@ -400,6 +400,71 @@ actor CompanionAPIService {
|
|||
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=...)
|
||||
|
||||
/// 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