Fixes #21 — Curated YouTube videos per guide + grammar item

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/<videoId>.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) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-22 18:51:19 -05:00
parent 98badc98ad
commit 5777a210cd
13 changed files with 750 additions and 1 deletions

View File

@@ -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 = "<group>"; };
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.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>"; };
@@ -180,6 +187,7 @@
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
6658C35E454C137B53FC05A4 /* youtube_videos.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = youtube_videos.json; sourceTree = "<group>"; };
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; };
@@ -197,6 +205,7 @@
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = "<group>"; };
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActionsView.swift; sourceTree = "<group>"; };
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
@@ -208,6 +217,7 @@
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoStore.swift; sourceTree = "<group>"; };
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.swift; sourceTree = "<group>"; };
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
@@ -229,6 +239,7 @@
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleCache.swift; sourceTree = "<group>"; };
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = "<group>"; };
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedVideosView.swift; sourceTree = "<group>"; };
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@@ -418,6 +434,7 @@
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */,
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */,
);
path = Guide;
sourceTree = "<group>";
@@ -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;

View File

@@ -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
}