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:
@@ -29,16 +29,19 @@
|
||||
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
|
||||
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
|
||||
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; };
|
||||
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; };
|
||||
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
|
||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
|
||||
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
|
||||
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
|
||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
||||
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
||||
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
|
||||
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
|
||||
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
|
||||
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
|
||||
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
|
||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
|
||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
|
||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
|
||||
@@ -48,6 +51,7 @@
|
||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
|
||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
|
||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
|
||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
|
||||
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
|
||||
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
|
||||
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
|
||||
@@ -62,6 +66,7 @@
|
||||
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
|
||||
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
|
||||
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
|
||||
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; };
|
||||
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
|
||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
|
||||
@@ -122,11 +127,14 @@
|
||||
3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; };
|
||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; };
|
||||
3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; };
|
||||
3EA01795655C444795577A22 /* LyricsConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsConfirmationView.swift; sourceTree = "<group>"; };
|
||||
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNotesView.swift; sourceTree = "<group>"; };
|
||||
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = "<group>"; };
|
||||
43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedWidget.swift; sourceTree = "<group>"; };
|
||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchService.swift; sourceTree = "<group>"; };
|
||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = "<group>"; };
|
||||
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
|
||||
58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; };
|
||||
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
|
||||
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
|
||||
@@ -135,6 +143,7 @@
|
||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
|
||||
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
|
||||
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; };
|
||||
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; };
|
||||
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; };
|
||||
72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; };
|
||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; };
|
||||
@@ -168,6 +177,7 @@
|
||||
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
|
||||
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
|
||||
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -231,6 +241,7 @@
|
||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
||||
@@ -316,6 +327,7 @@
|
||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
||||
10C16AA6022E4742898745CE /* TypingView.swift */,
|
||||
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
||||
);
|
||||
path = Practice;
|
||||
sourceTree = "<group>";
|
||||
@@ -329,6 +341,17 @@
|
||||
path = Guide;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
895E547BEFB5D0FBF676BE33 /* Lyrics */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3EA01795655C444795577A22 /* LyricsConfirmationView.swift */,
|
||||
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */,
|
||||
58394296923991E56BAC2B02 /* LyricsReaderView.swift */,
|
||||
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */,
|
||||
);
|
||||
path = Lyrics;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A591A3B6F1F13D23D68D7A9D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -518,6 +541,11 @@
|
||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
|
||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
|
||||
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
|
||||
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
|
||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
|
||||
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */,
|
||||
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */,
|
||||
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */,
|
||||
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
|
||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
|
||||
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
|
||||
|
||||
@@ -189,6 +189,7 @@ struct ConjugaApp: App {
|
||||
schema: Schema([
|
||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
SavedSong.self,
|
||||
]),
|
||||
url: url,
|
||||
cloudKitDatabase: .none
|
||||
@@ -196,6 +197,7 @@ struct ConjugaApp: App {
|
||||
return try ModelContainer(
|
||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
SavedSong.self,
|
||||
configurations: localConfig
|
||||
)
|
||||
}
|
||||
@@ -224,7 +226,7 @@ struct ConjugaApp: App {
|
||||
/// Clears accumulated stale schema metadata from previous container configurations.
|
||||
/// Bump the version number to force another reset if the schema changes again.
|
||||
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
|
||||
let resetVersion = 2 // bump: widget schema moved to SharedModels
|
||||
let resetVersion = 3 // bump: added SavedSong to local schema
|
||||
let key = "localStoreResetVersion"
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
|
||||
121
Conjuga/Conjuga/Services/LyricsSearchService.swift
Normal file
121
Conjuga/Conjuga/Services/LyricsSearchService.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
|
||||
struct LyricsSearchResult: Sendable {
|
||||
let title: String
|
||||
let artist: String
|
||||
let lyricsES: String
|
||||
let albumArtURL: String?
|
||||
let appleMusicURL: String?
|
||||
}
|
||||
|
||||
actor LyricsSearchService {
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func searchLyrics(artist: String, title: String) async throws -> [LyricsSearchResult] {
|
||||
async let lrcResults = searchLRCLIB(artist: artist, title: title)
|
||||
async let itunesResults = searchITunes(artist: artist, title: title)
|
||||
|
||||
let lyrics = try await lrcResults
|
||||
let metadata = try? await itunesResults
|
||||
|
||||
return lyrics.map { lrc in
|
||||
let match = metadata?.bestMatch(artist: lrc.artistName, title: lrc.trackName)
|
||||
return LyricsSearchResult(
|
||||
title: lrc.trackName,
|
||||
artist: lrc.artistName,
|
||||
lyricsES: lrc.plainLyrics,
|
||||
albumArtURL: match?.artworkURL600,
|
||||
appleMusicURL: match?.trackViewURL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LRCLIB
|
||||
|
||||
private struct LRCLIBResult: Decodable, Sendable {
|
||||
let trackName: String
|
||||
let artistName: String
|
||||
let plainLyrics: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case trackName, artistName, plainLyrics
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
trackName = try c.decode(String.self, forKey: .trackName)
|
||||
artistName = try c.decode(String.self, forKey: .artistName)
|
||||
plainLyrics = (try? c.decode(String.self, forKey: .plainLyrics)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private func searchLRCLIB(artist: String, title: String) async throws -> [LRCLIBResult] {
|
||||
var components = URLComponents(string: "https://lrclib.net/api/search")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "track_name", value: title),
|
||||
URLQueryItem(name: "artist_name", value: artist),
|
||||
]
|
||||
guard let url = components.url else { return [] }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Conjuga/1.0", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return [] }
|
||||
|
||||
let results = try JSONDecoder().decode([LRCLIBResult].self, from: data)
|
||||
return results.filter { !$0.plainLyrics.isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - iTunes Search
|
||||
|
||||
private struct ITunesResponse: Decodable {
|
||||
let results: [ITunesTrack]
|
||||
}
|
||||
|
||||
private struct ITunesTrack: Decodable {
|
||||
let trackName: String?
|
||||
let artistName: String?
|
||||
let artworkUrl100: String?
|
||||
let trackViewUrl: String?
|
||||
|
||||
var artworkURL600: String? {
|
||||
artworkUrl100?.replacingOccurrences(of: "100x100", with: "600x600")
|
||||
}
|
||||
|
||||
var trackViewURL: String? { trackViewUrl }
|
||||
}
|
||||
|
||||
private struct ITunesMetadata: Sendable {
|
||||
let tracks: [ITunesTrack]
|
||||
|
||||
func bestMatch(artist: String, title: String) -> ITunesTrack? {
|
||||
let normalizedArtist = artist.lowercased()
|
||||
let normalizedTitle = title.lowercased()
|
||||
|
||||
// Prefer exact title+artist match, then just title
|
||||
return tracks.first {
|
||||
($0.trackName ?? "").lowercased().contains(normalizedTitle) &&
|
||||
($0.artistName ?? "").lowercased().contains(normalizedArtist)
|
||||
} ?? tracks.first {
|
||||
($0.trackName ?? "").lowercased().contains(normalizedTitle)
|
||||
} ?? tracks.first
|
||||
}
|
||||
}
|
||||
|
||||
private func searchITunes(artist: String, title: String) async throws -> ITunesMetadata {
|
||||
let query = "\(artist) \(title)"
|
||||
var components = URLComponents(string: "https://itunes.apple.com/search")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "term", value: query),
|
||||
URLQueryItem(name: "media", value: "music"),
|
||||
URLQueryItem(name: "limit", value: "5"),
|
||||
]
|
||||
guard let url = components.url else { return ITunesMetadata(tracks: []) }
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let response = try JSONDecoder().decode(ITunesResponse.self, from: data)
|
||||
return ITunesMetadata(tracks: response.results)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct LyricsLibraryView: View {
|
||||
@Query(sort: \SavedSong.savedDate, order: .reverse) private var songs: [SavedSong]
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if songs.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Songs Yet",
|
||||
systemImage: "music.note.list",
|
||||
description: Text("Tap + to search for Spanish song lyrics.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(songs) { song in
|
||||
NavigationLink(value: song) {
|
||||
SongRowView(song: song)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteSongs)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Lyrics")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
LyricsSearchView()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: SavedSong.self) { song in
|
||||
LyricsReaderView(song: song)
|
||||
}
|
||||
}
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
private func deleteSongs(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
modelContext.delete(songs[index])
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Song Row
|
||||
|
||||
private struct SongRowView: View {
|
||||
let song: SavedSong
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
if !song.albumArtURL.isEmpty, let url = URL(string: song.albumArtURL) {
|
||||
AsyncImage(url: url) { image in
|
||||
image.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.fill.quaternary)
|
||||
}
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else {
|
||||
Image(systemName: "music.note")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 50, height: 50)
|
||||
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(song.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(song.artist)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
88
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift
Normal file
88
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
|
||||
struct LyricsReaderView: View {
|
||||
let song: SavedSong
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
headerSection
|
||||
lyricsBody
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
.navigationTitle(song.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 10) {
|
||||
if !song.albumArtURL.isEmpty, let url = URL(string: song.albumArtURL) {
|
||||
AsyncImage(url: url) { image in
|
||||
image.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.fill.quaternary)
|
||||
}
|
||||
.frame(width: 160, height: 160)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
Text(song.title)
|
||||
.font(.title2.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(song.artist)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if !song.appleMusicURL.isEmpty, let url = URL(string: song.appleMusicURL) {
|
||||
Link(destination: url) {
|
||||
Label("Open in Apple Music", systemImage: "apple.logo")
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
.tint(.pink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lyrics Body
|
||||
|
||||
private var lyricsBody: some View {
|
||||
let spanishLines = song.lyricsES.components(separatedBy: "\n")
|
||||
let englishLines = song.lyricsEN.components(separatedBy: "\n")
|
||||
let lineCount = max(spanishLines.count, englishLines.count)
|
||||
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(0..<lineCount, id: \.self) { index in
|
||||
let es = index < spanishLines.count ? spanishLines[index] : ""
|
||||
let en = index < englishLines.count ? englishLines[index] : ""
|
||||
|
||||
if es.trimmingCharacters(in: .whitespaces).isEmpty &&
|
||||
en.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
// Blank line = section divider
|
||||
Spacer().frame(height: 20)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if !es.isEmpty {
|
||||
Text(es)
|
||||
.font(.body.weight(.medium))
|
||||
}
|
||||
if !en.isEmpty {
|
||||
Text(en)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
173
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsSearchView.swift
Normal file
173
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsSearchView.swift
Normal file
@@ -0,0 +1,173 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import Translation
|
||||
|
||||
struct LyricsSearchView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var artist = ""
|
||||
@State private var songTitle = ""
|
||||
@State private var isSearching = false
|
||||
@State private var searchResults: [LyricsSearchResult] = []
|
||||
@State private var errorMessage: String?
|
||||
@State private var selectedResult: LyricsSearchResult?
|
||||
|
||||
private let service = LyricsSearchService()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
searchSection
|
||||
if isSearching {
|
||||
loadingSection
|
||||
}
|
||||
if let errorMessage {
|
||||
errorSection(errorMessage)
|
||||
}
|
||||
if !searchResults.isEmpty {
|
||||
resultsSection
|
||||
}
|
||||
}
|
||||
.navigationTitle("Search Lyrics")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(item: $selectedResult) { result in
|
||||
LyricsConfirmationView(result: result) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var searchSection: some View {
|
||||
Section {
|
||||
TextField("Artist", text: $artist)
|
||||
.textInputAutocapitalization(.words)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Song Title", text: $songTitle)
|
||||
.textInputAutocapitalization(.words)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
Button {
|
||||
performSearch()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(artist.trimmingCharacters(in: .whitespaces).isEmpty ||
|
||||
songTitle.trimmingCharacters(in: .whitespaces).isEmpty ||
|
||||
isSearching)
|
||||
} header: {
|
||||
Text("Find a Song")
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingSection: some View {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView("Searching...")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func errorSection(_ message: String) -> some View {
|
||||
Section {
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private var resultsSection: some View {
|
||||
Section {
|
||||
ForEach(Array(searchResults.prefix(5).enumerated()), id: \.offset) { _, result in
|
||||
Button {
|
||||
selectedResult = result
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
if let artURL = result.albumArtURL, let url = URL(string: artURL) {
|
||||
AsyncImage(url: url) { image in
|
||||
image.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.fill.quaternary)
|
||||
}
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else {
|
||||
Image(systemName: "music.note")
|
||||
.font(.title2)
|
||||
.frame(width: 50, height: 50)
|
||||
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(result.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(result.artist)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
} header: {
|
||||
Text("Results")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func performSearch() {
|
||||
isSearching = true
|
||||
errorMessage = nil
|
||||
searchResults = []
|
||||
|
||||
Task {
|
||||
do {
|
||||
let results = try await service.searchLyrics(artist: artist, title: songTitle)
|
||||
searchResults = results
|
||||
if results.isEmpty {
|
||||
errorMessage = "No lyrics found. Try a different spelling."
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Search failed. Check your connection."
|
||||
}
|
||||
isSearching = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identifiable conformance for navigation
|
||||
|
||||
extension LyricsSearchResult: Equatable {
|
||||
static func == (lhs: LyricsSearchResult, rhs: LyricsSearchResult) -> Bool {
|
||||
lhs.title == rhs.title && lhs.artist == rhs.artist && lhs.lyricsES == rhs.lyricsES
|
||||
}
|
||||
}
|
||||
|
||||
extension LyricsSearchResult: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(title)
|
||||
hasher.combine(artist)
|
||||
}
|
||||
}
|
||||
|
||||
extension LyricsSearchResult: Identifiable {
|
||||
var id: String { "\(artist)—\(title)" }
|
||||
}
|
||||
@@ -98,6 +98,37 @@ struct PracticeView: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Lyrics
|
||||
NavigationLink {
|
||||
LyricsLibraryView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "music.note.list")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.pink)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Lyrics")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Read Spanish song lyrics with translations")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Quick Actions
|
||||
VStack(spacing: 12) {
|
||||
Text("Quick Actions")
|
||||
|
||||
25
Conjuga/SharedModels/Sources/SharedModels/SavedSong.swift
Normal file
25
Conjuga/SharedModels/Sources/SharedModels/SavedSong.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
@Model
|
||||
public final class SavedSong {
|
||||
public var id: String = ""
|
||||
public var title: String = ""
|
||||
public var artist: String = ""
|
||||
public var lyricsES: String = ""
|
||||
public var lyricsEN: String = ""
|
||||
public var albumArtURL: String = ""
|
||||
public var appleMusicURL: String = ""
|
||||
public var savedDate: Date = Date()
|
||||
|
||||
public init(title: String, artist: String, lyricsES: String, lyricsEN: String, albumArtURL: String = "", appleMusicURL: String = "") {
|
||||
self.id = UUID().uuidString
|
||||
self.title = title
|
||||
self.artist = artist
|
||||
self.lyricsES = lyricsES
|
||||
self.lyricsEN = lyricsEN
|
||||
self.albumArtURL = albumArtURL
|
||||
self.appleMusicURL = appleMusicURL
|
||||
self.savedDate = Date()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user