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

Each of the 56 tense guides and grammar notes gets a curated YouTube video
attached (54 with picks, 2 silent nulls on rare / hard-to-find topics).
Users can stream in YouTube/Safari, download via YouTubeKit for offline
viewing, or play the local MP4 full-screen via AVPlayer.

YouTubeVideoStore loads the bundled youtube_videos.json at launch and serves
lookups by tense id or grammar note id. VideoDownloadService resolves the
best progressive MP4 stream off the main actor (YouTubeKit isn't Sendable),
writes to documents/videos/<videoId>.mp4, and records a DownloadedVideo row
in the local SwiftData container so the app knows what's on disk across
launches.

VideoActionsButtonRow is the unified UI for both detail views: three large
buttons — Stream (red, always enabled), Download (blue, disabled while in
flight and after completion, shows progress), Play (green, enabled only
when downloaded). Full-screen cover on tap. Settings gains a Downloaded
Videos list with swipe-delete, total-size summary, and a 500 MB warning.

Local store reset version bumped to 4 for the new DownloadedVideo schema.

Known fragility: YouTubeKit scrapes YouTube's private stream API and will
break when YouTube changes their internal format. Streaming keeps working
regardless.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-22 18:51:19 -05:00
parent 98badc98ad
commit 5777a210cd
13 changed files with 750 additions and 1 deletions

View File

@@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.swift */; };
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */; };
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; };
@@ -27,6 +28,7 @@
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; };
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; };
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
@@ -72,7 +74,9 @@
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.swift */; };
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; };
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F23A9C2935408B136C7C7 /* StoryGenerator.swift */; };
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */; };
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; };
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648436F8326CF95777E2FA58 /* ChatLibraryView.swift */; };
@@ -100,9 +104,11 @@
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */ = {isa = PBXBuildFile; fileRef = 6658C35E454C137B53FC05A4 /* youtube_videos.json */; };
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -172,6 +178,7 @@
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.swift; sourceTree = "<group>"; };
58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; };
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
@@ -180,6 +187,7 @@
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
6658C35E454C137B53FC05A4 /* youtube_videos.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = youtube_videos.json; sourceTree = "<group>"; };
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; };
@@ -197,6 +205,7 @@
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = "<group>"; };
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActionsView.swift; sourceTree = "<group>"; };
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
@@ -208,6 +217,7 @@
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoStore.swift; sourceTree = "<group>"; };
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.swift; sourceTree = "<group>"; };
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
@@ -229,6 +239,7 @@
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleCache.swift; sourceTree = "<group>"; };
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = "<group>"; };
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedVideosView.swift; sourceTree = "<group>"; };
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -246,6 +257,7 @@
buildActionMask = 2147483647;
files = (
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */,
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -262,6 +274,7 @@
BC273716CD14A99EFF8206CA /* course_data.json */,
7E6AF62A3A949630E067DC22 /* Info.plist */,
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
6658C35E454C137B53FC05A4 /* youtube_videos.json */,
353C5DE41FD410FA82E3AED7 /* Models */,
1994867BC8E985795A172854 /* Services */,
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
@@ -274,6 +287,7 @@
0931AEB5B728C3A03F06A1CA /* Settings */ = {
isa = PBXGroup;
children = (
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */,
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */,
BCCC95A95581458E068E0484 /* SettingsView.swift */,
);
@@ -314,7 +328,9 @@
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -418,6 +434,7 @@
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */,
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */,
);
path = Guide;
sourceTree = "<group>";
@@ -538,6 +555,7 @@
name = Conjuga;
packageProductDependencies = (
BCCBABD74CADDB118179D8E9 /* SharedModels */,
08D6313690BEE4E2F18EADC3 /* YouTubeKit */,
);
productName = Conjuga;
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
@@ -592,6 +610,7 @@
mainGroup = A591A3B6F1F13D23D68D7A9D;
minimizedProjectReferenceProxies = 1;
packageReferences = (
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */,
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
);
preferredProjectObjectVersion = 77;
@@ -613,6 +632,7 @@
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -642,6 +662,7 @@
C8C3880535008764B7117049 /* DataLoader.swift in Sources */,
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
@@ -701,10 +722,13 @@
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -977,7 +1001,23 @@
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/alexeichhorn/YouTubeKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.3.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
08D6313690BEE4E2F18EADC3 /* YouTubeKit */ = {
isa = XCSwiftPackageProductDependency;
package = E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */;
productName = YouTubeKit;
};
4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = {
isa = XCSwiftPackageProductDependency;
productName = SharedModels;

View File

@@ -0,0 +1,15 @@
{
"originHash" : "1b6ada17bf1104878f9520a6f7cb3cd84338c0da74dc3761cef075709d7df45d",
"pins" : [
{
"identity" : "youtubekit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/alexeichhorn/YouTubeKit.git",
"state" : {
"revision" : "65be95dbb1dbd749499e0638871568c823822276",
"version" : "0.4.8"
}
}
],
"version" : 3
}

View File

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

View 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.
}
}

View 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)")
}
}
}

View File

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

View File

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

View 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() }
}
}
}
}
}

View 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)
}

View File

@@ -123,6 +123,9 @@ struct SettingsView: View {
NavigationLink("How Features Work") {
FeatureReferenceView()
}
NavigationLink("Downloaded Videos") {
DownloadedVideosView()
}
}
Section("About") {

View 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"}
}
}

View File

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

View File

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