From 5777a210cdca92b70b3017d3dbd1511877deb339 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 22 Apr 2026 18:51:19 -0500 Subject: [PATCH] =?UTF-8?q?Fixes=20#21=20=E2=80=94=20Curated=20YouTube=20v?= =?UTF-8?q?ideos=20per=20guide=20+=20grammar=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of the 56 tense guides and grammar notes gets a curated YouTube video attached (54 with picks, 2 silent nulls on rare / hard-to-find topics). Users can stream in YouTube/Safari, download via YouTubeKit for offline viewing, or play the local MP4 full-screen via AVPlayer. YouTubeVideoStore loads the bundled youtube_videos.json at launch and serves lookups by tense id or grammar note id. VideoDownloadService resolves the best progressive MP4 stream off the main actor (YouTubeKit isn't Sendable), writes to documents/videos/.mp4, and records a DownloadedVideo row in the local SwiftData container so the app knows what's on disk across launches. VideoActionsButtonRow is the unified UI for both detail views: three large buttons — Stream (red, always enabled), Download (blue, disabled while in flight and after completion, shows progress), Play (green, enabled only when downloaded). Full-screen cover on tap. Settings gains a Downloaded Videos list with swipe-delete, total-size summary, and a 500 MB warning. Local store reset version bumped to 4 for the new DownloadedVideo schema. Known fragility: YouTubeKit scrapes YouTube's private stream API and will break when YouTube changes their internal format. Streaming keeps working regardless. Co-Authored-By: Claude Opus 4.7 (1M context) --- Conjuga/Conjuga.xcodeproj/project.pbxproj | 40 ++++ .../xcshareddata/swiftpm/Package.resolved | 15 ++ Conjuga/Conjuga/ConjugaApp.swift | 6 +- .../Services/VideoDownloadService.swift | 201 ++++++++++++++++++ .../Conjuga/Services/YouTubeVideoStore.swift | 61 ++++++ .../Views/Guide/GrammarNotesView.swift | 21 ++ Conjuga/Conjuga/Views/Guide/GuideView.swift | 25 +++ .../Views/Guide/VideoActionsView.swift | 188 ++++++++++++++++ .../Views/Settings/DownloadedVideosView.swift | 99 +++++++++ .../Conjuga/Views/Settings/SettingsView.swift | 3 + Conjuga/Conjuga/youtube_videos.json | 61 ++++++ .../SharedModels/DownloadedVideo.swift | 25 +++ Conjuga/project.yml | 6 + 13 files changed, 750 insertions(+), 1 deletion(-) create mode 100644 Conjuga/Conjuga.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Conjuga/Conjuga/Services/VideoDownloadService.swift create mode 100644 Conjuga/Conjuga/Services/YouTubeVideoStore.swift create mode 100644 Conjuga/Conjuga/Views/Guide/VideoActionsView.swift create mode 100644 Conjuga/Conjuga/Views/Settings/DownloadedVideosView.swift create mode 100644 Conjuga/Conjuga/youtube_videos.json create mode 100644 Conjuga/SharedModels/Sources/SharedModels/DownloadedVideo.swift diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index ae3e06e..970fdc0 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; }; 04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.swift */; }; + 05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */; }; 0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; }; 0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; }; 0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; }; @@ -27,6 +28,7 @@ 352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; }; 354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; }; 35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; }; + 362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; }; 36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; }; 377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; }; 39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; }; @@ -72,7 +74,9 @@ A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; }; AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; }; ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.swift */; }; + AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; }; ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F23A9C2935408B136C7C7 /* StoryGenerator.swift */; }; + B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */; }; B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; }; B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; }; B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648436F8326CF95777E2FA58 /* ChatLibraryView.swift */; }; @@ -100,9 +104,11 @@ E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; }; ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; }; F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; }; + F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */ = {isa = PBXBuildFile; fileRef = 6658C35E454C137B53FC05A4 /* youtube_videos.json */; }; F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; }; F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; }; FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; }; + FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -172,6 +178,7 @@ 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = ""; }; 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = ""; }; 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = ""; }; + 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.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 = ""; }; @@ -180,6 +187,7 @@ 626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = ""; }; 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = ""; }; 648436F8326CF95777E2FA58 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = ""; }; + 6658C35E454C137B53FC05A4 /* youtube_videos.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = youtube_videos.json; sourceTree = ""; }; 69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = ""; }; 6A48474D969CEF5F573DF09B /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = ""; }; 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = ""; }; @@ -197,6 +205,7 @@ 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = ""; }; 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = ""; }; 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = ""; }; + 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActionsView.swift; sourceTree = ""; }; 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = ""; }; 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = ""; }; 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = ""; }; @@ -208,6 +217,7 @@ A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = ""; }; A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = ""; }; AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoStore.swift; sourceTree = ""; }; B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.swift; sourceTree = ""; }; BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = ""; }; BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -229,6 +239,7 @@ EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleCache.swift; sourceTree = ""; }; F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = ""; }; F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = ""; }; + FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedVideosView.swift; sourceTree = ""; }; FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -246,6 +257,7 @@ buildActionMask = 2147483647; files = ( BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */, + 362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -262,6 +274,7 @@ BC273716CD14A99EFF8206CA /* course_data.json */, 7E6AF62A3A949630E067DC22 /* Info.plist */, 3644B5ED77F29A65877D926A /* reflexive_verbs.json */, + 6658C35E454C137B53FC05A4 /* youtube_videos.json */, 353C5DE41FD410FA82E3AED7 /* Models */, 1994867BC8E985795A172854 /* Services */, BFC1AEBE02CE22E6474FFEA6 /* Utilities */, @@ -274,6 +287,7 @@ 0931AEB5B728C3A03F06A1CA /* Settings */ = { isa = PBXGroup; children = ( + FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */, 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */, BCCC95A95581458E068E0484 /* SettingsView.swift */, ); @@ -314,7 +328,9 @@ 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */, EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */, 02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */, + 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */, D570252DA3DCDD9217C71863 /* WidgetDataService.swift */, + AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */, ); path = Services; sourceTree = ""; @@ -418,6 +434,7 @@ 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */, 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */, 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */, + 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */, ); path = Guide; sourceTree = ""; @@ -538,6 +555,7 @@ name = Conjuga; packageProductDependencies = ( BCCBABD74CADDB118179D8E9 /* SharedModels */, + 08D6313690BEE4E2F18EADC3 /* YouTubeKit */, ); productName = Conjuga; productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */; @@ -592,6 +610,7 @@ mainGroup = A591A3B6F1F13D23D68D7A9D; minimizedProjectReferenceProxies = 1; packageReferences = ( + E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */, 548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */, ); preferredProjectObjectVersion = 77; @@ -613,6 +632,7 @@ CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */, 2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */, 97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */, + F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -642,6 +662,7 @@ C8C3880535008764B7117049 /* DataLoader.swift in Sources */, 84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */, 90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */, + B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */, 14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */, D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */, A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */, @@ -701,10 +722,13 @@ BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */, 4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */, 81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */, + AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */, + FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */, 4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */, 78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */, 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, + 05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -977,7 +1001,23 @@ }; /* End XCLocalSwiftPackageReference section */ +/* Begin XCRemoteSwiftPackageReference section */ + E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/alexeichhorn/YouTubeKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.3.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ + 08D6313690BEE4E2F18EADC3 /* YouTubeKit */ = { + isa = XCSwiftPackageProductDependency; + package = E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */; + productName = YouTubeKit; + }; 4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = { isa = XCSwiftPackageProductDependency; productName = SharedModels; diff --git a/Conjuga/Conjuga.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Conjuga/Conjuga.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..137167e --- /dev/null +++ b/Conjuga/Conjuga.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "1b6ada17bf1104878f9520a6f7cb3cd84338c0da74dc3761cef075709d7df45d", + "pins" : [ + { + "identity" : "youtubekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/alexeichhorn/YouTubeKit.git", + "state" : { + "revision" : "65be95dbb1dbd749499e0638871568c823822276", + "version" : "0.4.8" + } + } + ], + "version" : 3 +} diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index 11e206e..37f80f3 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -42,6 +42,7 @@ struct ConjugaApp: App { @State private var dictionary = DictionaryService() @State private var verbExampleCache = VerbExampleCache() @State private var reflexiveStore = ReflexiveVerbStore() + @State private var youtubeVideoStore = YouTubeVideoStore() let localContainer: ModelContainer let cloudContainer: ModelContainer @@ -117,6 +118,7 @@ struct ConjugaApp: App { .environment(dictionary) .environment(verbExampleCache) .environment(reflexiveStore) + .environment(youtubeVideoStore) .task { let needsSeed = await DataLoader.needsSeeding(container: localContainer) if needsSeed { @@ -216,6 +218,7 @@ struct ConjugaApp: App { Verb.self, VerbForm.self, IrregularSpan.self, TenseGuide.self, CourseDeck.self, VocabCard.self, TextbookChapter.self, + DownloadedVideo.self, ]), url: url, cloudKitDatabase: .none @@ -224,6 +227,7 @@ struct ConjugaApp: App { for: Verb.self, VerbForm.self, IrregularSpan.self, TenseGuide.self, CourseDeck.self, VocabCard.self, TextbookChapter.self, + DownloadedVideo.self, configurations: localConfig ) } @@ -252,7 +256,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 = 3 // bump: SavedSong moved to cloud container + let resetVersion = 4 // bump: DownloadedVideo added to local container (Issue #21) let key = "localStoreResetVersion" let defaults = UserDefaults.standard diff --git a/Conjuga/Conjuga/Services/VideoDownloadService.swift b/Conjuga/Conjuga/Services/VideoDownloadService.swift new file mode 100644 index 0000000..d3b5f5e --- /dev/null +++ b/Conjuga/Conjuga/Services/VideoDownloadService.swift @@ -0,0 +1,201 @@ +import Foundation +import SwiftData +import SharedModels +import YouTubeKit + +/// Downloads YouTube videos for offline viewing (Issue #21, phase 3). +/// +/// Uses YouTubeKit to resolve stream URLs, then a `URLSession` download task +/// to persist the MP4 under the app's documents directory. Metadata is +/// recorded in SwiftData via `DownloadedVideo` so the app knows what's on +/// disk across launches. +/// +/// **Known fragility**: YouTubeKit scrapes YouTube's private stream API and +/// will break when YouTube changes their internal format. When it does, the +/// service throws `DownloadError.extractionFailed` and the UI should fall +/// back to streaming (phase 2) which remains available. +@MainActor +@Observable +final class VideoDownloadService { + + enum DownloadError: Error, LocalizedError { + case extractionFailed(String) + case noSuitableStream + case downloadFailed(String) + case fileWriteFailed(String) + + var errorDescription: String? { + switch self { + case .extractionFailed(let why): "Could not extract video: \(why)" + case .noSuitableStream: "No downloadable stream found for this video." + case .downloadFailed(let why): "Download failed: \(why)" + case .fileWriteFailed(let why): "Could not save video: \(why)" + } + } + } + + /// In-flight downloads by videoId. Progress is Double in [0, 1]. + var activeDownloads: [String: Double] = [:] + + static let shared = VideoDownloadService() + + // MARK: - Paths + + private static var videosDirectory: URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return docs.appendingPathComponent("videos", isDirectory: true) + } + + private static func ensureDirectory() throws { + let url = videosDirectory + if !FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + } + + static func fileURL(for videoId: String) -> URL { + videosDirectory.appendingPathComponent("\(videoId).mp4") + } + + /// True if a downloaded MP4 exists for this videoId. + static func isDownloaded(videoId: String) -> Bool { + FileManager.default.fileExists(atPath: fileURL(for: videoId).path) + } + + // MARK: - Download + + /// Downloads a YouTube video to local storage and records it in SwiftData. + /// Throws on any failure. Caller is responsible for showing errors. + func download( + videoId: String, + title: String, + into modelContext: ModelContext + ) async throws { + guard !activeDownloads.keys.contains(videoId) else { return } + activeDownloads[videoId] = 0 + + defer { activeDownloads.removeValue(forKey: videoId) } + + try Self.ensureDirectory() + + // 1. Resolve stream URL via YouTubeKit. Run off the main actor because + // YouTubeKit.YouTube isn't Sendable and does synchronous work we don't + // want blocking UI. + let streamURL: URL + do { + streamURL = try await Self.resolveStreamURL(videoId: videoId) + } catch let e as DownloadError { + throw e + } catch { + throw DownloadError.extractionFailed(error.localizedDescription) + } + + // 2. Download the stream to disk with progress tracking. + let destURL = Self.fileURL(for: videoId) + do { + let (tempURL, response) = try await URLSession.shared.download( + for: URLRequest(url: streamURL), + delegate: DownloadProgressDelegate { [weak self] progress in + Task { @MainActor in + self?.activeDownloads[videoId] = progress + } + } + ) + _ = response + + // Move the temp file to our persistent location (atomic). + if FileManager.default.fileExists(atPath: destURL.path) { + try FileManager.default.removeItem(at: destURL) + } + try FileManager.default.moveItem(at: tempURL, to: destURL) + } catch let e as DownloadError { + throw e + } catch { + throw DownloadError.downloadFailed(error.localizedDescription) + } + + // 3. Record in SwiftData. + let attrs = try? FileManager.default.attributesOfItem(atPath: destURL.path) + let byteCount = (attrs?[.size] as? Int) ?? 0 + let entry = DownloadedVideo( + videoId: videoId, + title: title, + filename: "\(videoId).mp4", + byteCount: byteCount + ) + modelContext.insert(entry) + try? modelContext.save() + } + + /// Deletes the downloaded file and its SwiftData row. + func delete(videoId: String, modelContext: ModelContext) { + let url = Self.fileURL(for: videoId) + try? FileManager.default.removeItem(at: url) + + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.videoId == videoId } + ) + if let existing = try? modelContext.fetch(descriptor) { + for entry in existing { + modelContext.delete(entry) + } + try? modelContext.save() + } + } + + /// Resolves the best progressive-MP4 stream URL for a YouTube videoId. + /// Runs off the main actor because `YouTube` isn't Sendable. + nonisolated private static func resolveStreamURL(videoId: String) async throws -> URL { + let youtube = YouTube(videoID: videoId) + let streams = try await youtube.streams + let candidate = streams + .filter { $0.isProgressive && $0.subtype == "mp4" } + .sorted { ($0.bitrate ?? 0) > ($1.bitrate ?? 0) } + .first + ?? streams.filter({ $0.subtype == "mp4" }).first + guard let stream = candidate else { throw DownloadError.noSuitableStream } + return stream.url + } + + /// Total bytes used by all downloads. + static func totalBytesUsed() -> Int { + let url = videosDirectory + guard let contents = try? FileManager.default.contentsOfDirectory( + at: url, includingPropertiesForKeys: [.fileSizeKey] + ) else { return 0 } + return contents.reduce(0) { acc, file in + let size = (try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + return acc + size + } + } +} + +// MARK: - URLSession progress delegate + +private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate { + let onProgress: (Double) -> Void + + init(onProgress: @escaping (Double) -> Void) { + self.onProgress = onProgress + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + guard totalBytesExpectedToWrite > 0 else { return } + let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) + onProgress(progress) + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + // Not used — `URLSession.download(for:delegate:)` already returns the temp URL. + } +} diff --git a/Conjuga/Conjuga/Services/YouTubeVideoStore.swift b/Conjuga/Conjuga/Services/YouTubeVideoStore.swift new file mode 100644 index 0000000..dd41382 --- /dev/null +++ b/Conjuga/Conjuga/Services/YouTubeVideoStore.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Curated YouTube-video lookup for guide + grammar items (Issue #21). +/// Loads the bundled `youtube_videos.json` at init, serves tense-guide and +/// grammar-note videos by id. The data is static after load; `static let shared` +/// lets services access it without environment injection. +@MainActor +@Observable +final class YouTubeVideoStore { + + struct VideoEntry: Codable, Hashable, Sendable, Identifiable { + let videoId: String + let title: String + var id: String { videoId } + } + + static let shared = YouTubeVideoStore() + + private(set) var tenseVideos: [String: VideoEntry] = [:] + private(set) var grammarVideos: [String: VideoEntry] = [:] + + init(bundle: Bundle = .main) { + load(from: bundle) + } + + /// Returns the curated video for a tense guide, or nil if unmapped. + func video(forTenseId id: String) -> VideoEntry? { + tenseVideos[id] + } + + /// Returns the curated video for a grammar note, or nil if unmapped. + func video(forGrammarNoteId id: String) -> VideoEntry? { + grammarVideos[id] + } + + /// All distinct videoIds present in the store. Useful for bulk operations + /// like "download all" or cache cleanup. + var allVideoIds: Set { + Set(tenseVideos.values.map(\.videoId)).union(grammarVideos.values.map(\.videoId)) + } + + private func load(from bundle: Bundle) { + guard let url = bundle.url(forResource: "youtube_videos", withExtension: "json"), + let data = try? Data(contentsOf: url) else { + print("[YouTubeVideoStore] bundled youtube_videos.json not found") + return + } + struct Root: Decodable { + let tenseGuides: [String: VideoEntry] + let grammarNotes: [String: VideoEntry] + } + do { + let root = try JSONDecoder().decode(Root.self, from: data) + tenseVideos = root.tenseGuides + grammarVideos = root.grammarNotes + print("[YouTubeVideoStore] loaded \(tenseVideos.count) tense + \(grammarVideos.count) grammar entries") + } catch { + print("[YouTubeVideoStore] decode failed: \(error)") + } + } +} diff --git a/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift b/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift index 325fc4d..b735955 100644 --- a/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift +++ b/Conjuga/Conjuga/Views/Guide/GrammarNotesView.swift @@ -67,6 +67,11 @@ private struct GrammarNoteRow: View { struct GrammarNoteDetailView: View { let note: GrammarNote + @Environment(YouTubeVideoStore.self) private var videoStore + + private var curatedVideo: YouTubeVideoStore.VideoEntry? { + videoStore.video(forGrammarNoteId: note.id) + } var body: some View { ScrollView { @@ -83,6 +88,8 @@ struct GrammarNoteDetailView: View { .background(.fill.tertiary, in: Capsule()) } + videoSection + Divider() // Parsed body @@ -108,6 +115,20 @@ struct GrammarNoteDetailView: View { .navigationTitle(note.title) .navigationBarTitleDisplayMode(.inline) } + + @ViewBuilder + private var videoSection: some View { + if let video = curatedVideo { + VideoActionsButtonRow(video: video) + } else { + Label("No video yet", systemImage: "play.slash") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(.fill.quinary, in: Capsule()) + } + } } // MARK: - Formatted Body diff --git a/Conjuga/Conjuga/Views/Guide/GuideView.swift b/Conjuga/Conjuga/Views/Guide/GuideView.swift index d67feef..2b44032 100644 --- a/Conjuga/Conjuga/Views/Guide/GuideView.swift +++ b/Conjuga/Conjuga/Views/Guide/GuideView.swift @@ -127,11 +127,16 @@ private struct TenseRowView: View { struct GuideDetailView: View { let guide: TenseGuide + @Environment(YouTubeVideoStore.self) private var videoStore private var tenseInfo: TenseInfo? { TenseInfo.find(guide.tenseId) } + private var curatedVideo: YouTubeVideoStore.VideoEntry? { + videoStore.video(forTenseId: guide.tenseId) + } + private var endingTable: TenseEndingTable? { TenseEndingTable.find(guide.tenseId) } @@ -146,6 +151,9 @@ struct GuideDetailView: View { // Header headerSection + // Video section (Issue #21) + videoSection + // Conjugation ending table if let table = endingTable { conjugationTableSection(table) @@ -180,6 +188,22 @@ struct GuideDetailView: View { .navigationBarTitleDisplayMode(.inline) } + // MARK: - Video (Issue #21) + + @ViewBuilder + private var videoSection: some View { + if let video = curatedVideo { + VideoActionsButtonRow(video: video) + } else { + Label("No video yet", systemImage: "play.slash") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(.fill.quinary, in: Capsule()) + } + } + // MARK: - Header private var headerSection: some View { @@ -571,4 +595,5 @@ struct GuideExample: Identifiable { #Preview { GuideView() .modelContainer(for: TenseGuide.self, inMemory: true) + .environment(YouTubeVideoStore()) } diff --git a/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift b/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift new file mode 100644 index 0000000..2efd25a --- /dev/null +++ b/Conjuga/Conjuga/Views/Guide/VideoActionsView.swift @@ -0,0 +1,188 @@ +import SwiftUI +import SwiftData +import AVKit +import SharedModels + +/// Three-button row for a curated YouTube video (Issue #21): +/// - **Stream** — opens in the YouTube app (falls back to Safari). +/// - **Download** — pulls the MP4 via YouTubeKit, shows progress, then enables Play. +/// - **Play** — enabled only when the video exists on disk; plays via AVPlayer. +/// +/// Used by both `GuideDetailView` and `GrammarNoteDetailView` to keep the +/// video affordances consistent. +struct VideoActionsButtonRow: View { + let video: YouTubeVideoStore.VideoEntry + + @Environment(\.openURL) private var openURL + @Environment(\.modelContext) private var modelContext + + @State private var downloadService = VideoDownloadService.shared + @State private var isDownloaded: Bool + @State private var playerVideoId: String? + @State private var downloadError: String? + + init(video: YouTubeVideoStore.VideoEntry) { + self.video = video + self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId)) + } + + private var activeProgress: Double? { + downloadService.activeDownloads[video.videoId] + } + + private var isDownloading: Bool { + activeProgress != nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(video.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + + HStack(spacing: 10) { + streamButton + downloadButton + playButton + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12)) + .fullScreenCover(item: Binding( + get: { playerVideoId.map { LocalVideoID(videoId: $0) } }, + set: { playerVideoId = $0?.videoId } + )) { id in + LocalVideoPlayerSheet(videoId: id.videoId, title: video.title) + } + .alert("Download failed", isPresented: .init( + get: { downloadError != nil }, + set: { if !$0 { downloadError = nil } } + )) { + Button("OK") { downloadError = nil } + } message: { + Text(downloadError ?? "") + } + .onAppear { + // Refresh on appear in case the user deleted the file via Settings. + isDownloaded = VideoDownloadService.isDownloaded(videoId: video.videoId) + } + } + + // MARK: - Buttons + + private var streamButton: some View { + Button { + if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") { + openURL(url) + } + } label: { + Label("Stream", systemImage: "play.rectangle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .controlSize(.large) + } + + @ViewBuilder + private var downloadButton: some View { + Button { + Task { await startDownload() } + } label: { + Group { + if let progress = activeProgress { + HStack(spacing: 6) { + ProgressView(value: progress) + .frame(width: 40) + Text("\(Int(progress * 100))%") + .font(.caption.monospacedDigit()) + } + } else if isDownloaded { + Label("Downloaded", systemImage: "checkmark.circle.fill") + } else { + Label("Download", systemImage: "arrow.down.to.line") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.blue) + .controlSize(.large) + .disabled(isDownloaded || isDownloading) + } + + private var playButton: some View { + Button { + playerVideoId = video.videoId + } label: { + Label("Play", systemImage: "play.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.green) + .controlSize(.large) + .disabled(!isDownloaded) + } + + // MARK: - Actions + + private func startDownload() async { + do { + try await downloadService.download( + videoId: video.videoId, + title: video.title, + into: modelContext + ) + isDownloaded = true + } catch { + downloadError = error.localizedDescription + } + } +} + +// MARK: - Helper identifiable wrapper so .sheet(item:) can use a plain String + +private struct LocalVideoID: Identifiable { + let videoId: String + var id: String { videoId } +} + +// MARK: - Local playback sheet + +struct LocalVideoPlayerSheet: View { + let videoId: String + let title: String + + @Environment(\.dismiss) private var dismiss + @State private var player: AVPlayer + + init(videoId: String, title: String) { + self.videoId = videoId + self.title = title + self._player = State(initialValue: AVPlayer(url: VideoDownloadService.fileURL(for: videoId))) + } + + var body: some View { + NavigationStack { + ZStack { + Color.black.ignoresSafeArea() + VideoPlayer(player: player) + .ignoresSafeArea() + .onAppear { player.play() } + .onDisappear { player.pause() } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.black, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } +} diff --git a/Conjuga/Conjuga/Views/Settings/DownloadedVideosView.swift b/Conjuga/Conjuga/Views/Settings/DownloadedVideosView.swift new file mode 100644 index 0000000..9301b39 --- /dev/null +++ b/Conjuga/Conjuga/Views/Settings/DownloadedVideosView.swift @@ -0,0 +1,99 @@ +import SwiftUI +import SwiftData +import SharedModels + +/// Lists downloaded YouTube videos with per-item deletion and total-size +/// summary (Issue #21, phase 4). Files live in the local (non-synced) +/// SwiftData container and the app's documents directory. +struct DownloadedVideosView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \DownloadedVideo.downloadedAt, order: .reverse) + private var downloads: [DownloadedVideo] + + @State private var downloadService = VideoDownloadService.shared + @State private var confirmDeleteAll = false + + private var totalBytes: Int { + downloads.reduce(0) { $0 + $1.byteCount } + } + + var body: some View { + List { + if downloads.isEmpty { + Section { + ContentUnavailableView( + "No downloads", + systemImage: "arrow.down.to.line", + description: Text("Tap Download on any guide or grammar video to save it for offline viewing.") + ) + } + } else { + Section { + LabeledContent("Total size", value: sizeString(totalBytes)) + if totalBytes > 500_000_000 { + Label("Downloads exceed 500 MB", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + } + } + + Section("Videos") { + ForEach(downloads) { download in + VStack(alignment: .leading, spacing: 4) { + Text(download.title) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + HStack { + Text(sizeString(download.byteCount)) + Text("·") + Text(download.downloadedAt.formatted(date: .abbreviated, time: .omitted)) + } + .font(.caption) + .foregroundStyle(.secondary) + } + .swipeActions { + Button(role: .destructive) { + downloadService.delete(videoId: download.videoId, modelContext: modelContext) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + + Section { + Button(role: .destructive) { + confirmDeleteAll = true + } label: { + Label("Delete all downloads", systemImage: "trash") + } + } + } + } + .navigationTitle("Downloaded Videos") + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog( + "Delete all \(downloads.count) downloaded videos?", + isPresented: $confirmDeleteAll, + titleVisibility: .visible + ) { + Button("Delete All", role: .destructive) { + for download in downloads { + downloadService.delete(videoId: download.videoId, modelContext: modelContext) + } + } + Button("Cancel", role: .cancel) {} + } + } + + private func sizeString(_ bytes: Int) -> String { + ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) + } +} + +#Preview { + NavigationStack { + DownloadedVideosView() + } + .modelContainer(for: DownloadedVideo.self, inMemory: true) +} diff --git a/Conjuga/Conjuga/Views/Settings/SettingsView.swift b/Conjuga/Conjuga/Views/Settings/SettingsView.swift index c3751e4..057e342 100644 --- a/Conjuga/Conjuga/Views/Settings/SettingsView.swift +++ b/Conjuga/Conjuga/Views/Settings/SettingsView.swift @@ -123,6 +123,9 @@ struct SettingsView: View { NavigationLink("How Features Work") { FeatureReferenceView() } + NavigationLink("Downloaded Videos") { + DownloadedVideosView() + } } Section("About") { diff --git a/Conjuga/Conjuga/youtube_videos.json b/Conjuga/Conjuga/youtube_videos.json new file mode 100644 index 0000000..8a79014 --- /dev/null +++ b/Conjuga/Conjuga/youtube_videos.json @@ -0,0 +1,61 @@ +{ + "note": "Curated YouTube videos per guide/grammar item for Issue #21. Each entry: {videoId, title}. Missing entries surface a 'No video yet' label in the app.", + "tenseGuides": { + "ind_presente": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"}, + "ind_preterito": {"videoId": "R4SiKCStHuU", "title": "Preterite / pretérito in Spanish: how to form it (animated)"}, + "ind_imperfecto": {"videoId": "hMg05drgI7w", "title": "Spanish Imperfect Tense Tutorial v2.0"}, + "ind_futuro": {"videoId": "yjQGJFCUOog", "title": "Regular Future Tense Conjugation in Spanish (w/ Ser, Estar & Ir)"}, + "ind_perfecto": {"videoId": "y_yeb6qkMbs", "title": "Forming the PRESENT PERFECT in Spanish (PRESENTE PERFECTO)"}, + "ind_pluscuamperfecto": {"videoId": "5VpGDhJ8eNw", "title": "Past Perfect / Pluperfect / Pluscuamperfecto in Spanish"}, + "ind_futuro_perfecto": {"videoId": "459J8Cy-9DU", "title": "FUTURE PERFECT: How to form verbs in the futuro perfecto in Spanish"}, + "cond_presente": {"videoId": "9ctJ6I-4NJ8", "title": "03 Spanish Lesson - Conditional Tense"}, + "cond_perfecto": {"videoId": "jTBATres2hw", "title": "How to form the CONDITIONAL PERFECT in Spanish (condicional perfecto)"}, + "subj_presente": {"videoId": "CRvXpo45oHw", "title": "The Subjunctive in Spanish — The Language Tutor Lesson 58"}, + "subj_imperfecto_1": {"videoId": "oqMCJORRdVs", "title": "Easily conquer the Spanish Imperfect Subjunctive"}, + "subj_imperfecto_2": {"videoId": "oqMCJORRdVs", "title": "Easily conquer the Spanish Imperfect Subjunctive"}, + "subj_perfecto": {"videoId": "gAgFFpt6-08", "title": "Present Perfect Subjunctive Spanish Guide: How to Use 'Haya'"}, + "subj_pluscuamperfecto_1": {"videoId": "aAQCodqWhkU", "title": "The Past Perfect or Pluperfect Subjunctive in Spanish: Forms and Uses"}, + "subj_pluscuamperfecto_2": {"videoId": "aAQCodqWhkU", "title": "The Past Perfect or Pluperfect Subjunctive in Spanish: Forms and Uses"}, + "subj_futuro": {"videoId": "YPWJsmD3hN4", "title": "Spanish Answers, Episode 10: Future Subjunctive"}, + "subj_futuro_perfecto": {"videoId": "9vmo2C-0iuQ", "title": "Free Spanish Lessons 151 - Spanish Subjunctive Tense: Future Perfect"}, + "imp_afirmativo": {"videoId": "uQi14msiaYg", "title": "Commands in Spanish: The Imperative Mood Explained"}, + "imp_negativo": {"videoId": "wsLFs_OQOfM", "title": "The Negative Imperative in Spanish"} + }, + "grammarNotes": { + "ser-vs-estar": {"videoId": "X-7k7R3Ca9U", "title": "SER vs. ESTAR — The COMPLETE guide | How to Use 'To Be' in Spanish"}, + "por-vs-para": {"videoId": "PX6wnebioOA", "title": "Por vs Para — The definitive guide"}, + "preterite-vs-imperfect": {"videoId": "DfrpSIAuUjg", "title": "Preterite vs Imperfect in Spanish: Never Confuse Them Again"}, + "subjunctive-triggers": {"videoId": "OzGWFJTcrKc", "title": "Spanish Subjunctive Part 2/5: Wishes, Emotions & Doubt (WEIRDO Triggers)"}, + "reflexive-verbs": {"videoId": "z2UXjjp3vnI", "title": "Spanish Reflexive Verbs: How-To, 20 Verbs & My 1 RULE"}, + "object-pronouns": {"videoId": "vJD6AeHZ0j4", "title": "DIRECT & INDIRECT OBJECT PRONOUNS in Spanish: ALL you need to know"}, + "gustar-like-verbs": {"videoId": "eCDWXZlDHUA", "title": "How Verbs Like Gustar Work: Never Confuse Them Again"}, + "comparatives-superlatives": {"videoId": "OSxtLNHaRQg", "title": "Learn the COMPARATIVE and SUPERLATIVE in Spanish"}, + "conditional-if-clauses": {"videoId": "thvW8qVsqkE", "title": "Si Clauses: The Spanish Hypothetical Explained"}, + "commands-imperative": {"videoId": "uQi14msiaYg", "title": "Commands in Spanish: The Imperative Mood Explained"}, + "saber-vs-conocer": {"videoId": "j87i7MVCvIE", "title": "Saber vs. Conocer: Right (and WRONG) Times to Use These Spanish Verbs"}, + "double-negatives": {"videoId": "dmcLNMYxMFI", "title": "Learn Spanish Grammar: Double Negatives in Spanish"}, + "adjective-placement": {"videoId": "JNh6nuZe_zo", "title": "SPANISH ADJECTIVES: BEFORE or AFTER NOUNS??"}, + "tener-expressions": {"videoId": "uD1rcv_ZTNA", "title": "Idiomatic Expressions with TENER"}, + "personal-a": {"videoId": "5QRZ13VZ2PE", "title": "Personal 'A' in Spanish: What is it & How to Use it"}, + "relative-pronouns": {"videoId": "2YmFy5sJOj8", "title": "Master Spanish Relative Pronouns: donde, cuando, como, que, quien, cuyo"}, + "future-vs-ir-a": {"videoId": "oGHz-O_m0tk", "title": "IR A + Infinitive VS. Future Tense: What's the difference in Spanish?"}, + "accent-marks-stress": {"videoId": "iBWTR-a3pZc", "title": "LA TILDE | Word Stress and Accent Marks in Spanish"}, + "se-constructions": {"videoId": "ndxsrGD7b-8", "title": "Understanding 'SE' in Spanish: Reflexive, Passive, and Impersonal Constructions"}, + "spanish-suffixes": {"videoId": "2acPjFrmJCc", "title": "How to use Suffixes in Spanish - Basic Grammar"}, + "common-irregular-verbs": {"videoId": "1CmeCwO0t5w", "title": "Master the 4 Most Important Irregular Verbs in Spanish (SER, ESTAR, TENER, IR)"}, + "types-of-irregular-verbs": {"videoId": "tQuQcuwsIqw", "title": "Stem-Changing Verbs in Spanish: 90% of 'Irregular' Verbs Solved"}, + "present-indicative-conjugation": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"}, + "articles-and-gender": {"videoId": "h2b37zYtQuc", "title": "Definite Articles in Spanish: Rules and Examples"}, + "possessive-adjectives": {"videoId": "zJQxR4mUj2Y", "title": "Possessive adjectives in Spanish for beginners"}, + "demonstrative-adjectives": {"videoId": "jZJ0tE3WZlo", "title": "THIS & THAT in Spanish: How to use ESTE, ESE, AQUEL"}, + "greetings-farewells": {"videoId": "AqfQQZVmTUw", "title": "Every Spanish Greeting You Need (Formal, Casual & Slang)"}, + "poder-infinitive": {"videoId": "hCUbz5942EY", "title": "Spanish - The Verb 'Poder' Explained In 3 Minutes"}, + "al-del-contractions": {"videoId": "nWPZZWIwWxg", "title": "Spanish Contractions AL and DEL — The Language Tutor Lesson 15"}, + "prepositional-pronouns": {"videoId": "l29XtaZSSyY", "title": "PREPOSITIONAL PRONOUNS: How and when to use them in Spanish"}, + "irregular-yo-verbs": {"videoId": "yRf6adUKSzQ", "title": "Spanish Irregular Yo Form Verbs — Go Go Verbs Song"}, + "stem-changing-verbs": {"videoId": "tQuQcuwsIqw", "title": "Stem-Changing Verbs in Spanish: 90% of 'Irregular' Verbs Solved"}, + "stressed-possessives": {"videoId": "epObIkGAPoU", "title": "Spanish Long Form Possessive Adjectives Grammar | Possessive Pronouns"}, + "present-perfect-tense": {"videoId": "y_yeb6qkMbs", "title": "Forming the PRESENT PERFECT in Spanish (PRESENTE PERFECTO)"}, + "future-perfect-tense": {"videoId": "459J8Cy-9DU", "title": "FUTURE PERFECT: How to form verbs in the futuro perfecto in Spanish"} + } +} diff --git a/Conjuga/SharedModels/Sources/SharedModels/DownloadedVideo.swift b/Conjuga/SharedModels/Sources/SharedModels/DownloadedVideo.swift new file mode 100644 index 0000000..6318689 --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/DownloadedVideo.swift @@ -0,0 +1,25 @@ +import Foundation +import SwiftData + +/// Persistent record of a YouTube video downloaded to the device (Issue #21). +/// Files live in the app's documents directory under `videos/.mp4`; +/// this model tracks the metadata needed to locate, display, and manage them. +/// +/// Lives in the local store, not CloudKit — downloads are per-device. +@Model +public final class DownloadedVideo { + /// YouTube video ID — the primary key (unique). + @Attribute(.unique) public var videoId: String = "" + public var title: String = "" + public var filename: String = "" + public var byteCount: Int = 0 + public var downloadedAt: Date = Date() + + public init(videoId: String, title: String, filename: String, byteCount: Int) { + self.videoId = videoId + self.title = title + self.filename = filename + self.byteCount = byteCount + self.downloadedAt = Date() + } +} diff --git a/Conjuga/project.yml b/Conjuga/project.yml index ade1eaf..078b13f 100644 --- a/Conjuga/project.yml +++ b/Conjuga/project.yml @@ -28,6 +28,9 @@ schemes: packages: SharedModels: path: SharedModels + YouTubeKit: + url: https://github.com/alexeichhorn/YouTubeKit.git + from: 0.3.0 settings: base: @@ -49,6 +52,8 @@ targets: buildPhase: resources - path: Conjuga/reflexive_verbs.json buildPhase: resources + - path: Conjuga/youtube_videos.json + buildPhase: resources info: path: Conjuga/Info.plist properties: @@ -80,6 +85,7 @@ targets: dependencies: - target: ConjugaWidgetExtension - package: SharedModels + - package: YouTubeKit ConjugaWidgetExtension: type: app-extension