282cd1b3a3
SavedSong was in the local container alongside reference data, so it got deleted whenever localStoreResetVersion was bumped. Move it to the cloud container (CloudKit-synced) so saved songs persist across schema changes. Update lyrics views to use cloudModelContextProvider. Closes #4 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
217 lines
8.1 KiB
Swift
217 lines
8.1 KiB
Swift
import SwiftUI
|
|
import SharedModels
|
|
import SwiftData
|
|
import Translation
|
|
|
|
struct LyricsConfirmationView: View {
|
|
let result: LyricsSearchResult
|
|
let onSave: () -> Void
|
|
|
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
|
|
|
@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
|
|
let esLines = text.components(separatedBy: "\n")
|
|
print("[LyricsTranslation] Spanish line count: \(esLines.count)")
|
|
print("[LyricsTranslation] Spanish blank lines: \(esLines.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }.count)")
|
|
print("[LyricsTranslation] First 10 ES lines:")
|
|
for (i, line) in esLines.prefix(10).enumerated() {
|
|
print(" ES[\(i)]: \(line.isEmpty ? "(blank)" : line)")
|
|
}
|
|
|
|
do {
|
|
let response = try await session.translate(text)
|
|
let enLines = response.targetText.components(separatedBy: "\n")
|
|
print("[LyricsTranslation] English line count: \(enLines.count)")
|
|
print("[LyricsTranslation] English blank lines: \(enLines.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }.count)")
|
|
print("[LyricsTranslation] First 10 EN lines:")
|
|
for (i, line) in enLines.prefix(10).enumerated() {
|
|
print(" EN[\(i)]: \(line.isEmpty ? "(blank)" : line)")
|
|
}
|
|
// The Translation framework often inserts a blank line after every
|
|
// translated line. Collapse consecutive blank lines into single blanks,
|
|
// then trim leading/trailing blanks so the EN structure matches the ES.
|
|
let normalized = Self.normalizeTranslationLineBreaks(
|
|
translated: response.targetText,
|
|
originalES: text
|
|
)
|
|
let normalizedLines = normalized.components(separatedBy: "\n")
|
|
print("[LyricsTranslation] After normalization: EN lines \(enLines.count) → \(normalizedLines.count) (target: \(esLines.count))")
|
|
|
|
await MainActor.run { translatedEN = normalized }
|
|
} catch {
|
|
print("[LyricsTranslation] Translation error: \(error)")
|
|
await MainActor.run { translationError = true }
|
|
}
|
|
await MainActor.run { isTranslating = false }
|
|
}
|
|
|
|
/// Re-align translated line breaks to match the original Spanish structure.
|
|
/// The Translation framework often inserts a blank line after every translated
|
|
/// line. We strip all blanks from the EN output, then re-insert them at the
|
|
/// same positions where the original ES text has blank lines.
|
|
/// Re-align translated line breaks to match the original Spanish structure.
|
|
private static func normalizeTranslationLineBreaks(translated: String, originalES: String) -> String {
|
|
let esLines = originalES.components(separatedBy: "\n")
|
|
let enContentLines = translated.components(separatedBy: "\n")
|
|
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
|
|
// Walk ES lines. For each blank ES line, insert a blank in the result.
|
|
// For each content ES line, consume the next EN content line.
|
|
var result: [String] = []
|
|
var enIndex = 0
|
|
for esLine in esLines {
|
|
if esLine.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
result.append("")
|
|
} else if enIndex < enContentLines.count {
|
|
result.append(enContentLines[enIndex])
|
|
enIndex += 1
|
|
} else {
|
|
result.append("")
|
|
}
|
|
}
|
|
|
|
print("[LyricsNormalize] ES lines: \(esLines.count), EN content: \(enContentLines.count), result: \(result.count), EN consumed: \(enIndex)")
|
|
return result.joined(separator: "\n")
|
|
}
|
|
|
|
private func saveSong() {
|
|
let song = SavedSong(
|
|
title: result.title,
|
|
artist: result.artist,
|
|
lyricsES: result.lyricsES,
|
|
lyricsEN: translatedEN,
|
|
albumArtURL: result.albumArtURL ?? "",
|
|
appleMusicURL: result.appleMusicURL ?? ""
|
|
)
|
|
cloudModelContext.insert(song)
|
|
try? cloudModelContext.save()
|
|
onSave()
|
|
}
|
|
}
|