From faef20e5b8b6dd9f51b1364d62ab1d4269fda5f4 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 11 Apr 2026 22:44:40 -0500 Subject: [PATCH] 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) --- Conjuga/Conjuga.xcodeproj/project.pbxproj | 28 +++ Conjuga/Conjuga/ConjugaApp.swift | 4 +- .../Services/LyricsSearchService.swift | 121 ++++++++++++ .../Lyrics/LyricsConfirmationView.swift | 160 ++++++++++++++++ .../Practice/Lyrics/LyricsLibraryView.swift | 89 +++++++++ .../Practice/Lyrics/LyricsReaderView.swift | 88 +++++++++ .../Practice/Lyrics/LyricsSearchView.swift | 173 ++++++++++++++++++ .../Conjuga/Views/Practice/PracticeView.swift | 31 ++++ .../Sources/SharedModels/SavedSong.swift | 25 +++ 9 files changed, 718 insertions(+), 1 deletion(-) create mode 100644 Conjuga/Conjuga/Services/LyricsSearchService.swift create mode 100644 Conjuga/Conjuga/Views/Practice/Lyrics/LyricsConfirmationView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/Lyrics/LyricsLibraryView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift create mode 100644 Conjuga/Conjuga/Views/Practice/Lyrics/LyricsSearchView.swift create mode 100644 Conjuga/SharedModels/Sources/SharedModels/SavedSong.swift diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index de9a6fe..e8aa5d5 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -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 = ""; }; 3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = ""; }; 3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = ""; }; + 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsConfirmationView.swift; sourceTree = ""; }; 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNotesView.swift; sourceTree = ""; }; 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = ""; }; 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedWidget.swift; sourceTree = ""; }; + 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchService.swift; sourceTree = ""; }; 49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = ""; }; 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = ""; }; + 58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = ""; }; 5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = ""; }; 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = ""; }; @@ -135,6 +143,7 @@ 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = ""; }; 69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = ""; }; 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = ""; }; + 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = ""; }; 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = ""; }; 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = ""; }; 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = ""; }; @@ -168,6 +177,7 @@ E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = ""; }; E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = ""; }; E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = ""; }; /* 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 = ""; @@ -329,6 +341,17 @@ path = Guide; sourceTree = ""; }; + 895E547BEFB5D0FBF676BE33 /* Lyrics */ = { + isa = PBXGroup; + children = ( + 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */, + FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */, + 58394296923991E56BAC2B02 /* LyricsReaderView.swift */, + 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */, + ); + path = Lyrics; + sourceTree = ""; + }; 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 */, diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index 4ffef9c..0e3649d 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -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 diff --git a/Conjuga/Conjuga/Services/LyricsSearchService.swift b/Conjuga/Conjuga/Services/LyricsSearchService.swift new file mode 100644 index 0000000..523fd76 --- /dev/null +++ b/Conjuga/Conjuga/Services/LyricsSearchService.swift @@ -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) + } +} diff --git a/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsConfirmationView.swift b/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsConfirmationView.swift new file mode 100644 index 0000000..939e2b7 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsConfirmationView.swift @@ -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() + } +} diff --git a/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsLibraryView.swift b/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsLibraryView.swift new file mode 100644 index 0000000..779fe50 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsLibraryView.swift @@ -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) + } +} diff --git a/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift b/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift new file mode 100644 index 0000000..84e2b62 --- /dev/null +++ b/Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift @@ -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.. 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)" } +} diff --git a/Conjuga/Conjuga/Views/Practice/PracticeView.swift b/Conjuga/Conjuga/Views/Practice/PracticeView.swift index 8454d1c..9e583b6 100644 --- a/Conjuga/Conjuga/Views/Practice/PracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/PracticeView.swift @@ -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") diff --git a/Conjuga/SharedModels/Sources/SharedModels/SavedSong.swift b/Conjuga/SharedModels/Sources/SharedModels/SavedSong.swift new file mode 100644 index 0000000..3c0faab --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/SavedSong.swift @@ -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() + } +}