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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
201
Conjuga/Conjuga/Services/VideoDownloadService.swift
Normal file
201
Conjuga/Conjuga/Services/VideoDownloadService.swift
Normal file
@@ -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<DownloadedVideo>(
|
||||
predicate: #Predicate<DownloadedVideo> { $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.
|
||||
}
|
||||
}
|
||||
61
Conjuga/Conjuga/Services/YouTubeVideoStore.swift
Normal file
61
Conjuga/Conjuga/Services/YouTubeVideoStore.swift
Normal file
@@ -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<String> {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
188
Conjuga/Conjuga/Views/Guide/VideoActionsView.swift
Normal file
188
Conjuga/Conjuga/Views/Guide/VideoActionsView.swift
Normal file
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
Conjuga/Conjuga/Views/Settings/DownloadedVideosView.swift
Normal file
99
Conjuga/Conjuga/Views/Settings/DownloadedVideosView.swift
Normal file
@@ -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)
|
||||
}
|
||||
@@ -123,6 +123,9 @@ struct SettingsView: View {
|
||||
NavigationLink("How Features Work") {
|
||||
FeatureReferenceView()
|
||||
}
|
||||
NavigationLink("Downloaded Videos") {
|
||||
DownloadedVideosView()
|
||||
}
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
|
||||
61
Conjuga/Conjuga/youtube_videos.json
Normal file
61
Conjuga/Conjuga/youtube_videos.json
Normal file
@@ -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"}
|
||||
}
|
||||
}
|
||||
@@ -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/<videoId>.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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user