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 */
|
/* Begin PBXBuildFile section */
|
||||||
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
|
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
|
||||||
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.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 */; };
|
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
|
||||||
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
|
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
|
||||||
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.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 */; };
|
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
|
||||||
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; };
|
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; };
|
||||||
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.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 */; };
|
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
|
||||||
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
|
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
|
||||||
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.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 */; };
|
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
|
||||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
|
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
|
||||||
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.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 */; };
|
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 */; };
|
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; };
|
||||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
|
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
|
||||||
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648436F8326CF95777E2FA58 /* ChatLibraryView.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 */; };
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
|
||||||
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
|
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
|
||||||
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.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 */; };
|
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
|
||||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
|
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
|
||||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -172,6 +178,7 @@
|
|||||||
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -246,6 +257,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */,
|
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */,
|
||||||
|
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -262,6 +274,7 @@
|
|||||||
BC273716CD14A99EFF8206CA /* course_data.json */,
|
BC273716CD14A99EFF8206CA /* course_data.json */,
|
||||||
7E6AF62A3A949630E067DC22 /* Info.plist */,
|
7E6AF62A3A949630E067DC22 /* Info.plist */,
|
||||||
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
|
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
|
||||||
|
6658C35E454C137B53FC05A4 /* youtube_videos.json */,
|
||||||
353C5DE41FD410FA82E3AED7 /* Models */,
|
353C5DE41FD410FA82E3AED7 /* Models */,
|
||||||
1994867BC8E985795A172854 /* Services */,
|
1994867BC8E985795A172854 /* Services */,
|
||||||
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
|
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
|
||||||
@@ -274,6 +287,7 @@
|
|||||||
0931AEB5B728C3A03F06A1CA /* Settings */ = {
|
0931AEB5B728C3A03F06A1CA /* Settings */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */,
|
||||||
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */,
|
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */,
|
||||||
BCCC95A95581458E068E0484 /* SettingsView.swift */,
|
BCCC95A95581458E068E0484 /* SettingsView.swift */,
|
||||||
);
|
);
|
||||||
@@ -314,7 +328,9 @@
|
|||||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
|
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
|
||||||
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
|
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
|
||||||
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
|
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
|
||||||
|
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
|
||||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
||||||
|
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -418,6 +434,7 @@
|
|||||||
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */,
|
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */,
|
||||||
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
|
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
|
||||||
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
|
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
|
||||||
|
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */,
|
||||||
);
|
);
|
||||||
path = Guide;
|
path = Guide;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -538,6 +555,7 @@
|
|||||||
name = Conjuga;
|
name = Conjuga;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
BCCBABD74CADDB118179D8E9 /* SharedModels */,
|
BCCBABD74CADDB118179D8E9 /* SharedModels */,
|
||||||
|
08D6313690BEE4E2F18EADC3 /* YouTubeKit */,
|
||||||
);
|
);
|
||||||
productName = Conjuga;
|
productName = Conjuga;
|
||||||
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
|
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
|
||||||
@@ -592,6 +610,7 @@
|
|||||||
mainGroup = A591A3B6F1F13D23D68D7A9D;
|
mainGroup = A591A3B6F1F13D23D68D7A9D;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
|
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */,
|
||||||
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
|
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
@@ -613,6 +632,7 @@
|
|||||||
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
|
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
|
||||||
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
|
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
|
||||||
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
|
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
|
||||||
|
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -642,6 +662,7 @@
|
|||||||
C8C3880535008764B7117049 /* DataLoader.swift in Sources */,
|
C8C3880535008764B7117049 /* DataLoader.swift in Sources */,
|
||||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
|
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
|
||||||
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
|
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
|
||||||
|
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
|
||||||
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
|
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
|
||||||
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
|
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
|
||||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
|
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
|
||||||
@@ -701,10 +722,13 @@
|
|||||||
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
|
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
|
||||||
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
|
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
|
||||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||||
|
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
|
||||||
|
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
|
||||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
||||||
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
|
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
|
||||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||||
|
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -977,7 +1001,23 @@
|
|||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* 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 */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
08D6313690BEE4E2F18EADC3 /* YouTubeKit */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */;
|
||||||
|
productName = YouTubeKit;
|
||||||
|
};
|
||||||
4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = {
|
4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = SharedModels;
|
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 dictionary = DictionaryService()
|
||||||
@State private var verbExampleCache = VerbExampleCache()
|
@State private var verbExampleCache = VerbExampleCache()
|
||||||
@State private var reflexiveStore = ReflexiveVerbStore()
|
@State private var reflexiveStore = ReflexiveVerbStore()
|
||||||
|
@State private var youtubeVideoStore = YouTubeVideoStore()
|
||||||
|
|
||||||
let localContainer: ModelContainer
|
let localContainer: ModelContainer
|
||||||
let cloudContainer: ModelContainer
|
let cloudContainer: ModelContainer
|
||||||
@@ -117,6 +118,7 @@ struct ConjugaApp: App {
|
|||||||
.environment(dictionary)
|
.environment(dictionary)
|
||||||
.environment(verbExampleCache)
|
.environment(verbExampleCache)
|
||||||
.environment(reflexiveStore)
|
.environment(reflexiveStore)
|
||||||
|
.environment(youtubeVideoStore)
|
||||||
.task {
|
.task {
|
||||||
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
|
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
|
||||||
if needsSeed {
|
if needsSeed {
|
||||||
@@ -216,6 +218,7 @@ struct ConjugaApp: App {
|
|||||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
TextbookChapter.self,
|
TextbookChapter.self,
|
||||||
|
DownloadedVideo.self,
|
||||||
]),
|
]),
|
||||||
url: url,
|
url: url,
|
||||||
cloudKitDatabase: .none
|
cloudKitDatabase: .none
|
||||||
@@ -224,6 +227,7 @@ struct ConjugaApp: App {
|
|||||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
TextbookChapter.self,
|
TextbookChapter.self,
|
||||||
|
DownloadedVideo.self,
|
||||||
configurations: localConfig
|
configurations: localConfig
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -252,7 +256,7 @@ struct ConjugaApp: App {
|
|||||||
/// Clears accumulated stale schema metadata from previous container configurations.
|
/// Clears accumulated stale schema metadata from previous container configurations.
|
||||||
/// Bump the version number to force another reset if the schema changes again.
|
/// Bump the version number to force another reset if the schema changes again.
|
||||||
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
|
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 key = "localStoreResetVersion"
|
||||||
let defaults = UserDefaults.standard
|
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 {
|
struct GrammarNoteDetailView: View {
|
||||||
let note: GrammarNote
|
let note: GrammarNote
|
||||||
|
@Environment(YouTubeVideoStore.self) private var videoStore
|
||||||
|
|
||||||
|
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
|
||||||
|
videoStore.video(forGrammarNoteId: note.id)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -83,6 +88,8 @@ struct GrammarNoteDetailView: View {
|
|||||||
.background(.fill.tertiary, in: Capsule())
|
.background(.fill.tertiary, in: Capsule())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
videoSection
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// Parsed body
|
// Parsed body
|
||||||
@@ -108,6 +115,20 @@ struct GrammarNoteDetailView: View {
|
|||||||
.navigationTitle(note.title)
|
.navigationTitle(note.title)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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
|
// MARK: - Formatted Body
|
||||||
|
|||||||
@@ -127,11 +127,16 @@ private struct TenseRowView: View {
|
|||||||
|
|
||||||
struct GuideDetailView: View {
|
struct GuideDetailView: View {
|
||||||
let guide: TenseGuide
|
let guide: TenseGuide
|
||||||
|
@Environment(YouTubeVideoStore.self) private var videoStore
|
||||||
|
|
||||||
private var tenseInfo: TenseInfo? {
|
private var tenseInfo: TenseInfo? {
|
||||||
TenseInfo.find(guide.tenseId)
|
TenseInfo.find(guide.tenseId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
|
||||||
|
videoStore.video(forTenseId: guide.tenseId)
|
||||||
|
}
|
||||||
|
|
||||||
private var endingTable: TenseEndingTable? {
|
private var endingTable: TenseEndingTable? {
|
||||||
TenseEndingTable.find(guide.tenseId)
|
TenseEndingTable.find(guide.tenseId)
|
||||||
}
|
}
|
||||||
@@ -146,6 +151,9 @@ struct GuideDetailView: View {
|
|||||||
// Header
|
// Header
|
||||||
headerSection
|
headerSection
|
||||||
|
|
||||||
|
// Video section (Issue #21)
|
||||||
|
videoSection
|
||||||
|
|
||||||
// Conjugation ending table
|
// Conjugation ending table
|
||||||
if let table = endingTable {
|
if let table = endingTable {
|
||||||
conjugationTableSection(table)
|
conjugationTableSection(table)
|
||||||
@@ -180,6 +188,22 @@ struct GuideDetailView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.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
|
// MARK: - Header
|
||||||
|
|
||||||
private var headerSection: some View {
|
private var headerSection: some View {
|
||||||
@@ -571,4 +595,5 @@ struct GuideExample: Identifiable {
|
|||||||
#Preview {
|
#Preview {
|
||||||
GuideView()
|
GuideView()
|
||||||
.modelContainer(for: TenseGuide.self, inMemory: true)
|
.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") {
|
NavigationLink("How Features Work") {
|
||||||
FeatureReferenceView()
|
FeatureReferenceView()
|
||||||
}
|
}
|
||||||
|
NavigationLink("Downloaded Videos") {
|
||||||
|
DownloadedVideosView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("About") {
|
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:
|
packages:
|
||||||
SharedModels:
|
SharedModels:
|
||||||
path: SharedModels
|
path: SharedModels
|
||||||
|
YouTubeKit:
|
||||||
|
url: https://github.com/alexeichhorn/YouTubeKit.git
|
||||||
|
from: 0.3.0
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
@@ -49,6 +52,8 @@ targets:
|
|||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
- path: Conjuga/reflexive_verbs.json
|
- path: Conjuga/reflexive_verbs.json
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
|
- path: Conjuga/youtube_videos.json
|
||||||
|
buildPhase: resources
|
||||||
info:
|
info:
|
||||||
path: Conjuga/Info.plist
|
path: Conjuga/Info.plist
|
||||||
properties:
|
properties:
|
||||||
@@ -80,6 +85,7 @@ targets:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- target: ConjugaWidgetExtension
|
- target: ConjugaWidgetExtension
|
||||||
- package: SharedModels
|
- package: SharedModels
|
||||||
|
- package: YouTubeKit
|
||||||
|
|
||||||
ConjugaWidgetExtension:
|
ConjugaWidgetExtension:
|
||||||
type: app-extension
|
type: app-extension
|
||||||
|
|||||||
Reference in New Issue
Block a user