diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 89e19af..31b27d8 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -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") } diff --git a/iOS/Views/Companion/CompanionAPIService.swift b/iOS/Views/Companion/CompanionAPIService.swift index e9ff113..ca4a307 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -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. diff --git a/iOS/Views/Lyrics/LyricsEditorView.swift b/iOS/Views/Lyrics/LyricsEditorView.swift new file mode 100644 index 0000000..a9ce208 --- /dev/null +++ b/iOS/Views/Lyrics/LyricsEditorView.swift @@ -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) -> 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) + } +} diff --git a/iOS/Views/Lyrics/LyricsManager.swift b/iOS/Views/Lyrics/LyricsManager.swift new file mode 100644 index 0000000..43e2cb6 --- /dev/null +++ b/iOS/Views/Lyrics/LyricsManager.swift @@ -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) + } +} diff --git a/iOS/Views/Lyrics/LyricsModels.swift b/iOS/Views/Lyrics/LyricsModels.swift new file mode 100644 index 0000000..b35892a --- /dev/null +++ b/iOS/Views/Lyrics/LyricsModels.swift @@ -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]wordword` 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.. 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..= 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.. [LyricWord]? { + // Enhanced LRC: wordword + 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..= 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.. 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) } + } + } +} diff --git a/iOS/Views/Lyrics/LyricsOverlayView.swift b/iOS/Views/Lyrics/LyricsOverlayView.swift new file mode 100644 index 0000000..f59211a --- /dev/null +++ b/iOS/Views/Lyrics/LyricsOverlayView.swift @@ -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") +} diff --git a/iOS/Views/Lyrics/LyricsSearchSheet.swift b/iOS/Views/Lyrics/LyricsSearchSheet.swift new file mode 100644 index 0000000..5aac21a --- /dev/null +++ b/iOS/Views/Lyrics/LyricsSearchSheet.swift @@ -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) + } + } + } + } +}