Add Lyrics practice: search, translate, and read Spanish song lyrics
New feature in the Practice tab that lets users search for Spanish songs by artist + title, fetch lyrics from LRCLIB (free, no API key), pull album art from iTunes Search API, auto-translate to English via Apple's on-device Translation framework, and save for offline reading. Components: - SavedSong SwiftData model (local container, no CloudKit sync) - LyricsSearchService actor (LRCLIB + iTunes Search, concurrent) - LyricsSearchView (artist/song fields, result list with album art) - LyricsConfirmationView (lyrics preview, auto-translation, save) - LyricsLibraryView (saved songs list, swipe to delete) - LyricsReaderView (Spanish lines with English subtitles) - Practice tab integration (Lyrics button with NavigationLink) - localStoreResetVersion bumped to 3 for schema migration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import Translation
|
||||
|
||||
struct LyricsConfirmationView: View {
|
||||
let result: LyricsSearchResult
|
||||
let onSave: () -> Void
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var translatedEN = ""
|
||||
@State private var isTranslating = true
|
||||
@State private var translationError = false
|
||||
@State private var translationConfig: TranslationSession.Configuration?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
headerSection
|
||||
lyricsPreview
|
||||
actionButtons
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
.navigationTitle("Confirm Lyrics")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
translationConfig = .init(
|
||||
source: Locale.Language(identifier: "es"),
|
||||
target: Locale.Language(identifier: "en")
|
||||
)
|
||||
}
|
||||
.translationTask(translationConfig) { session in
|
||||
await translateLyrics(session: session)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
if let artURL = result.albumArtURL, let url = URL(string: artURL) {
|
||||
AsyncImage(url: url) { image in
|
||||
image.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.fill.quaternary)
|
||||
.overlay {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.frame(width: 200, height: 200)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
Text(result.title)
|
||||
.font(.title2.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(result.artist)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var lyricsPreview: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Spanish Lyrics")
|
||||
.font(.headline)
|
||||
|
||||
Text(result.lyricsES.prefix(500) + (result.lyricsES.count > 500 ? "\n..." : ""))
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Text("English Translation")
|
||||
.font(.headline)
|
||||
if isTranslating {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
if translationError {
|
||||
Label("Translation unavailable. You can still save with Spanish only.",
|
||||
systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
} else if translatedEN.isEmpty && isTranslating {
|
||||
Text("Translating...")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(translatedEN.prefix(500) + (translatedEN.count > 500 ? "\n..." : ""))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var actionButtons: some View {
|
||||
HStack(spacing: 16) {
|
||||
Button(role: .cancel) {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
saveSong()
|
||||
} label: {
|
||||
Text("Save")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.orange)
|
||||
.disabled(isTranslating && translatedEN.isEmpty && !translationError)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private func translateLyrics(session: sending TranslationSession) async {
|
||||
await MainActor.run { isTranslating = true }
|
||||
let text = result.lyricsES
|
||||
do {
|
||||
let response = try await session.translate(text)
|
||||
await MainActor.run { translatedEN = response.targetText }
|
||||
} catch {
|
||||
print("Translation error: \(error)")
|
||||
await MainActor.run { translationError = true }
|
||||
}
|
||||
await MainActor.run { isTranslating = false }
|
||||
}
|
||||
|
||||
private func saveSong() {
|
||||
let song = SavedSong(
|
||||
title: result.title,
|
||||
artist: result.artist,
|
||||
lyricsES: result.lyricsES,
|
||||
lyricsEN: translatedEN,
|
||||
albumArtURL: result.albumArtURL ?? "",
|
||||
appleMusicURL: result.appleMusicURL ?? ""
|
||||
)
|
||||
modelContext.insert(song)
|
||||
try? modelContext.save()
|
||||
onSave()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user