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:
Trey t
2026-04-11 22:44:40 -05:00
parent 5fa1cc3921
commit faef20e5b8
9 changed files with 718 additions and 1 deletions
@@ -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()
}
}