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 fetched = try await LyricsService.shared.search(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) } } } } }