diff --git a/Kaset.entitlements b/Kaset.entitlements
index dd5b8f575..878ef173f 100644
--- a/Kaset.entitlements
+++ b/Kaset.entitlements
@@ -12,7 +12,10 @@
com.apple.security.files.bookmarks.app-scope
-
+
+ com.apple.security.files.downloads.read-write
+
+
com.apple.security.device.audio-input
@@ -22,5 +25,15 @@
com.sertacozercan.Kaset-spks
com.sertacozercan.Kaset-spki
+
+
+
+ com.apple.security.temporary-exception.files.absolute-path.read-only
+
+ /opt/homebrew/
+ /usr/local/
+ /opt/local/
+ /Users/
+
diff --git a/Package.swift b/Package.swift
index 7eeedff7e..26f93a9b9 100644
--- a/Package.swift
+++ b/Package.swift
@@ -7,7 +7,7 @@ let package = Package(
name: "Kaset",
defaultLocalization: "en",
platforms: [
- .macOS("15.4"),
+ .macOS("27.0"),
],
products: [
.executable(
@@ -64,22 +64,7 @@ let package = Package(
.testTarget(
name: "KasetTests",
dependencies: ["Kaset"],
- // Tests for Apple-Intelligence-powered features are excluded
- // because the underlying APIs are macOS 26+ only and Swift
- // Testing's `@Test` / `@Suite` macros do not compose with
- // `@available(macOS 26, *)`.
exclude: [
- "AIErrorHandlerTests.swift",
- "AIToolTests.swift",
- "CommandBarViewModelTests.swift",
- "CommandExecutorTests.swift",
- "CommandIntentParserTests.swift",
- "FoundationModelsOptimizedPromptIntegrationTests.swift",
- "FoundationModelsPromptLibraryTests.swift",
- "FoundationModelsServiceTests.swift",
- "FoundationModelsTests.swift",
- "MusicIntentIntegrationTests.swift",
- "MusicIntentTests.swift",
],
resources: [
.process("Fixtures"),
diff --git a/Sources/Kaset/KasetApp.swift b/Sources/Kaset/KasetApp.swift
index a24bf6c4e..fbdcbf70c 100644
--- a/Sources/Kaset/KasetApp.swift
+++ b/Sources/Kaset/KasetApp.swift
@@ -119,6 +119,20 @@ struct KasetApp: App {
// YouTube video playback service + the one-audio-source arbiter
let youtubePlayer = YouTubePlayerService(webKitManager: webkit)
youtubePlayer.youtubeClient = youtubeClient
+ // Course mode: when a playlist lesson ends, mark it complete and
+ // auto-advance to the next topic when one remains.
+ youtubePlayer.onVideoEnded = { endedId in
+ Task { @MainActor in
+ let course = YouTubeCourseSession.shared
+ guard course.isActive else { return }
+ course.markCompleted(videoId: endedId)
+ if let next = course.nextLesson {
+ course.syncCurrent(to: next)
+ youtubePlayer.continueWith(video: next)
+ youtubePlayer.setCourseQueue(course.remainingLessons)
+ }
+ }
+ }
let arbiter = PlaybackArbiter(playerService: player, youtubePlayerService: youtubePlayer)
_authService = State(initialValue: auth)
@@ -373,6 +387,52 @@ struct KasetApp: App {
Divider()
+ // Full Screen (YouTube) - F
+ Button(self.youtubePlayerService.isWindowFullscreen ? "Exit Full Screen" : "Full Screen") {
+ if self.youtubePlayerService.currentVideo != nil {
+ if self.youtubePlayerService.surfaceLocation == .inline {
+ self.youtubePlayerService.popOutToWindow()
+ Task { @MainActor in
+ try? await Task.sleep(for: .milliseconds(250))
+ YouTubeVideoWindowController.shared.toggleFullscreen(returnInlineOnExit: true)
+ }
+ } else if self.youtubePlayerService.surfaceLocation == .floating
+ || self.youtubePlayerService.surfaceLocation == .miniPlayer
+ {
+ if self.youtubePlayerService.surfaceLocation == .miniPlayer {
+ self.youtubePlayerService.popOutToWindow()
+ Task { @MainActor in
+ try? await Task.sleep(for: .milliseconds(200))
+ YouTubeVideoWindowController.shared.toggleFullscreen()
+ }
+ } else {
+ YouTubeVideoWindowController.shared.toggleFullscreen()
+ }
+ }
+ }
+ }
+ .keyboardShortcut("f", modifiers: [])
+ .disabled(self.youtubePlayerService.currentVideo == nil)
+
+ // Download current YouTube video - ⌘D
+ Button("Download Video") {
+ guard let video = self.youtubePlayerService.currentVideo else { return }
+ do {
+ _ = try YTDLPService.shared.download(
+ videoId: video.videoId,
+ title: video.title
+ )
+ } catch {
+ DiagnosticsLogger.download.error(
+ "Shortcut download failed: \(error.localizedDescription, privacy: .public)"
+ )
+ }
+ }
+ .keyboardShortcut("d", modifiers: .command)
+ .disabled(self.youtubePlayerService.currentVideo == nil)
+
+ Divider()
+
// Lyrics - ⌘L
Button(self.playerService.showLyrics ? "Hide Lyrics" : "Show Lyrics") {
withAnimation(.easeInOut(duration: 0.2)) {
diff --git a/Sources/Kaset/Models/AI/VideoAskSuggestions.swift b/Sources/Kaset/Models/AI/VideoAskSuggestions.swift
new file mode 100644
index 000000000..add652849
--- /dev/null
+++ b/Sources/Kaset/Models/AI/VideoAskSuggestions.swift
@@ -0,0 +1,39 @@
+import Foundation
+import FoundationModels
+
+// MARK: - VideoAskSuggestions
+
+/// Suggested starter questions for the YouTube-style **Ask** feature.
+@available(macOS 26.0, *)
+@Generable
+struct VideoAskSuggestions {
+ /// 3–5 short, natural questions a viewer might ask about this video.
+ @Guide(description: "List of 3-5 short, natural questions a viewer might ask about this video. Each question should be under 12 words and grounded in the provided metadata.")
+ let questions: [String]
+}
+
+// MARK: - VideoAskTurn
+
+/// One user/assistant exchange in the Ask conversation.
+@available(macOS 26.0, *)
+struct VideoAskTurn: Identifiable, Equatable {
+ let id: UUID
+ let question: String
+ var answer: String?
+ var caveat: String?
+ var isStreaming: Bool
+
+ init(
+ id: UUID = UUID(),
+ question: String,
+ answer: String? = nil,
+ caveat: String? = nil,
+ isStreaming: Bool = false
+ ) {
+ self.id = id
+ self.question = question
+ self.answer = answer
+ self.caveat = caveat
+ self.isStreaming = isStreaming
+ }
+}
diff --git a/Sources/Kaset/Models/AI/VideoIntelligence.swift b/Sources/Kaset/Models/AI/VideoIntelligence.swift
new file mode 100644
index 000000000..588bbbec6
--- /dev/null
+++ b/Sources/Kaset/Models/AI/VideoIntelligence.swift
@@ -0,0 +1,40 @@
+import Foundation
+import FoundationModels
+
+// MARK: - VideoSummary
+
+/// On-device AI summary of a YouTube video from available metadata context.
+@available(macOS 26.0, *)
+@Generable
+struct VideoSummary {
+ /// One-line elevator pitch for the video.
+ @Guide(description: "A single concise sentence capturing what the video is about.")
+ let headline: String
+
+ /// 2–4 key topics or themes.
+ @Guide(description: "List of 2-4 key topics or themes in the video.")
+ let topics: [String]
+
+ /// Overall tone or style (e.g. educational, entertainment, news).
+ @Guide(description: "A short phrase for the video's style or tone (e.g. 'tutorial', 'vlog', 'news analysis').")
+ let style: String
+
+ /// Multi-sentence overview based only on provided metadata.
+ @Guide(description: "A 2-5 sentence summary of the video based only on the provided title, channel, and context. Do not invent claims not supported by the context.")
+ let overview: String
+}
+
+// MARK: - VideoAnswer
+
+/// On-device AI answer to a user question about a YouTube video.
+@available(macOS 26.0, *)
+@Generable
+struct VideoAnswer {
+ /// Direct answer to the user's question.
+ @Guide(description: "A clear, helpful answer to the user's question about the video, grounded only in the provided metadata and context.")
+ let answer: String
+
+ /// Confidence note when context is thin.
+ @Guide(description: "Optional short caveat when metadata is incomplete (e.g. 'Based only on the title and channel — no transcript was available.'). Empty string when not needed.")
+ let caveat: String
+}
diff --git a/Sources/Kaset/Models/DownloadQuality.swift b/Sources/Kaset/Models/DownloadQuality.swift
new file mode 100644
index 000000000..061df0622
--- /dev/null
+++ b/Sources/Kaset/Models/DownloadQuality.swift
@@ -0,0 +1,98 @@
+import Foundation
+
+// MARK: - DownloadQuality
+
+/// Predefined quality / media targets for yt-dlp downloads.
+enum DownloadQuality: String, CaseIterable, Identifiable, Sendable {
+ case best
+ case uhd2160
+ case qhd1440
+ case fullHD1080
+ case hd720
+ case sd480
+ case sd360
+ case audioM4A
+ case audioMP3
+ case videoOnlyBest
+
+ var id: String {
+ self.rawValue
+ }
+
+ var displayName: String {
+ switch self {
+ case .best: String(localized: "Best available")
+ case .uhd2160: "2160p (4K)"
+ case .qhd1440: "1440p"
+ case .fullHD1080: "1080p"
+ case .hd720: "720p"
+ case .sd480: "480p"
+ case .sd360: "360p"
+ case .audioM4A: String(localized: "Audio only (M4A)")
+ case .audioMP3: String(localized: "Audio only (MP3)")
+ case .videoOnlyBest: String(localized: "Video only (best)")
+ }
+ }
+
+ /// Whether this preset extracts audio-only media.
+ var isAudioOnly: Bool {
+ switch self {
+ case .audioM4A, .audioMP3: true
+ default: false
+ }
+ }
+
+ /// yt-dlp `-f` format selection string.
+ var formatSelector: String {
+ switch self {
+ case .best:
+ "bv*+ba/b"
+ case .uhd2160:
+ "bv*[height<=2160]+ba/b[height<=2160]/bv*+ba/b"
+ case .qhd1440:
+ "bv*[height<=1440]+ba/b[height<=1440]/bv*+ba/b"
+ case .fullHD1080:
+ "bv*[height<=1080]+ba/b[height<=1080]/bv*+ba/b"
+ case .hd720:
+ "bv*[height<=720]+ba/b[height<=720]/bv*+ba/b"
+ case .sd480:
+ "bv*[height<=480]+ba/b[height<=480]/bv*+ba/b"
+ case .sd360:
+ "bv*[height<=360]+ba/b[height<=360]/bv*+ba/b"
+ case .audioM4A, .audioMP3:
+ "ba/b"
+ case .videoOnlyBest:
+ "bv*"
+ }
+ }
+
+ /// Optional post-processor audio format (`-x --audio-format`).
+ var audioExtractFormat: String? {
+ switch self {
+ case .audioM4A: "m4a"
+ case .audioMP3: "mp3"
+ default: nil
+ }
+ }
+}
+
+// MARK: - DownloadFolderPreference
+
+/// Where completed downloads should land.
+enum DownloadFolderPreference: String, CaseIterable, Identifiable, Sendable {
+ /// macOS Downloads folder (requires downloads entitlement).
+ case downloads
+ /// User-chosen folder persisted via security-scoped bookmark.
+ case custom
+
+ var id: String {
+ self.rawValue
+ }
+
+ var displayName: String {
+ switch self {
+ case .downloads: String(localized: "Downloads folder")
+ case .custom: String(localized: "Custom folder")
+ }
+ }
+}
diff --git a/Sources/Kaset/Models/YouTube/YouTubeCourseLibraryModels.swift b/Sources/Kaset/Models/YouTube/YouTubeCourseLibraryModels.swift
new file mode 100644
index 000000000..4c623f7fd
--- /dev/null
+++ b/Sources/Kaset/Models/YouTube/YouTubeCourseLibraryModels.swift
@@ -0,0 +1,270 @@
+import Foundation
+
+// MARK: - YouTubeCourseFolder
+
+/// A nested folder in the Courses library (folders can contain folders + courses).
+struct YouTubeCourseFolder: Identifiable, Codable, Hashable, Sendable {
+ var id: String
+ var name: String
+ /// `nil` means root of the library.
+ var parentId: String?
+ var createdAt: Date
+
+ init(id: String = UUID().uuidString, name: String, parentId: String? = nil, createdAt: Date = Date()) {
+ self.id = id
+ self.name = name
+ self.parentId = parentId
+ self.createdAt = createdAt
+ }
+}
+
+// MARK: - YouTubeCourseCatalogEntry
+
+/// A playlist saved as a course in the library, with progress + preview thumbs.
+struct YouTubeCourseCatalogEntry: Identifiable, Codable, Hashable, Sendable {
+ var id: String { self.playlistId }
+
+ var playlistId: String
+ var title: String
+ var channelName: String?
+ var thumbnailURLString: String?
+ /// Up to a handful of lesson thumbnail URLs for the card collage.
+ var lessonThumbnailURLStrings: [String]
+ var lessonCount: Int
+ var completedCount: Int
+ /// Folder this course lives in (`nil` = root).
+ var folderId: String?
+ var lastOpenedAt: Date
+ var createdAt: Date
+ /// Pinned courses float to the top of the library.
+ var isPinned: Bool
+ /// Last lesson the user was watching (for Continue Learning).
+ var lastLessonVideoId: String?
+ var lastLessonTitle: String?
+ /// Approximate total duration in seconds (sum of parsed length texts).
+ var totalDurationSeconds: Int
+ /// Optional user goal: lessons to finish per week.
+ var weeklyGoal: Int?
+
+ var thumbnailURL: URL? {
+ self.thumbnailURLString.flatMap(URL.init(string:))
+ }
+
+ var lessonThumbnailURLs: [URL] {
+ self.lessonThumbnailURLStrings.compactMap(URL.init(string:))
+ }
+
+ var progressFraction: Double {
+ guard self.lessonCount > 0 else { return 0 }
+ return min(1, Double(self.completedCount) / Double(self.lessonCount))
+ }
+
+ var status: YouTubeCourseProgressStatus {
+ if self.lessonCount > 0, self.completedCount >= self.lessonCount {
+ .completed
+ } else if self.completedCount > 0 || self.lastLessonVideoId != nil {
+ .inProgress
+ } else {
+ .notStarted
+ }
+ }
+
+ init(
+ playlistId: String,
+ title: String,
+ channelName: String? = nil,
+ thumbnailURL: URL? = nil,
+ lessonThumbnailURLs: [URL] = [],
+ lessonCount: Int = 0,
+ completedCount: Int = 0,
+ folderId: String? = nil,
+ lastOpenedAt: Date = Date(),
+ createdAt: Date = Date(),
+ isPinned: Bool = false,
+ lastLessonVideoId: String? = nil,
+ lastLessonTitle: String? = nil,
+ totalDurationSeconds: Int = 0,
+ weeklyGoal: Int? = nil
+ ) {
+ self.playlistId = playlistId
+ self.title = title
+ self.channelName = channelName
+ self.thumbnailURLString = thumbnailURL?.absoluteString
+ self.lessonThumbnailURLStrings = lessonThumbnailURLs.prefix(6).map(\.absoluteString)
+ self.lessonCount = lessonCount
+ self.completedCount = completedCount
+ self.folderId = folderId
+ self.lastOpenedAt = lastOpenedAt
+ self.createdAt = createdAt
+ self.isPinned = isPinned
+ self.lastLessonVideoId = lastLessonVideoId
+ self.lastLessonTitle = lastLessonTitle
+ self.totalDurationSeconds = totalDurationSeconds
+ self.weeklyGoal = weeklyGoal
+ }
+}
+
+// MARK: - Progress status / filters
+
+enum YouTubeCourseProgressStatus: String, CaseIterable, Identifiable, Sendable {
+ case notStarted
+ case inProgress
+ case completed
+
+ var id: String { self.rawValue }
+
+ var displayName: String {
+ switch self {
+ case .notStarted: String(localized: "Not started")
+ case .inProgress: String(localized: "In progress")
+ case .completed: String(localized: "Completed")
+ }
+ }
+}
+
+enum YouTubeCourseLibraryFilter: String, CaseIterable, Identifiable, Sendable {
+ case all
+ case inProgress
+ case completed
+ case notStarted
+ case pinned
+
+ var id: String { self.rawValue }
+
+ var displayName: String {
+ switch self {
+ case .all: String(localized: "All")
+ case .inProgress: String(localized: "In Progress")
+ case .completed: String(localized: "Completed")
+ case .notStarted: String(localized: "Not Started")
+ case .pinned: String(localized: "Pinned")
+ }
+ }
+}
+
+enum YouTubeCourseLibrarySort: String, CaseIterable, Identifiable, Sendable {
+ case recent
+ case progress
+ case title
+ case duration
+
+ var id: String { self.rawValue }
+
+ var displayName: String {
+ switch self {
+ case .recent: String(localized: "Recent")
+ case .progress: String(localized: "Progress")
+ case .title: String(localized: "A–Z")
+ case .duration: String(localized: "Duration")
+ }
+ }
+}
+
+// MARK: - Lesson note
+
+struct YouTubeCourseLessonNote: Identifiable, Codable, Hashable, Sendable {
+ var id: String { self.videoId }
+ var videoId: String
+ var text: String
+ var updatedAt: Date
+
+ init(videoId: String, text: String, updatedAt: Date = Date()) {
+ self.videoId = videoId
+ self.text = text
+ self.updatedAt = updatedAt
+ }
+}
+
+// MARK: - Resume position
+
+struct YouTubeCourseResumePosition: Codable, Hashable, Sendable {
+ var videoId: String
+ var seconds: Double
+ var updatedAt: Date
+}
+
+// MARK: - Library snapshot
+
+/// Codable root document for the courses library on disk.
+struct YouTubeCourseLibrarySnapshot: Codable, Sendable {
+ var folders: [YouTubeCourseFolder]
+ var courses: [YouTubeCourseCatalogEntry]
+ /// playlistId → videoId → note
+ var notesByPlaylist: [String: [String: YouTubeCourseLessonNote]]
+ /// playlistId → resume
+ var resumeByPlaylist: [String: YouTubeCourseResumePosition]
+ /// ISO day keys (yyyy-MM-dd) → lessons completed that day
+ var completionHistory: [String: Int]
+
+ static let empty = YouTubeCourseLibrarySnapshot(
+ folders: [],
+ courses: [],
+ notesByPlaylist: [:],
+ resumeByPlaylist: [:],
+ completionHistory: [:]
+ )
+
+ init(
+ folders: [YouTubeCourseFolder],
+ courses: [YouTubeCourseCatalogEntry],
+ notesByPlaylist: [String: [String: YouTubeCourseLessonNote]] = [:],
+ resumeByPlaylist: [String: YouTubeCourseResumePosition] = [:],
+ completionHistory: [String: Int] = [:]
+ ) {
+ self.folders = folders
+ self.courses = courses
+ self.notesByPlaylist = notesByPlaylist
+ self.resumeByPlaylist = resumeByPlaylist
+ self.completionHistory = completionHistory
+ }
+
+ // Backward-compatible decode for older library blobs.
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.folders = try container.decodeIfPresent([YouTubeCourseFolder].self, forKey: .folders) ?? []
+ self.courses = try container.decodeIfPresent([YouTubeCourseCatalogEntry].self, forKey: .courses) ?? []
+ self.notesByPlaylist = try container.decodeIfPresent(
+ [String: [String: YouTubeCourseLessonNote]].self,
+ forKey: .notesByPlaylist
+ ) ?? [:]
+ self.resumeByPlaylist = try container.decodeIfPresent(
+ [String: YouTubeCourseResumePosition].self,
+ forKey: .resumeByPlaylist
+ ) ?? [:]
+ self.completionHistory = try container.decodeIfPresent(
+ [String: Int].self,
+ forKey: .completionHistory
+ ) ?? [:]
+ }
+}
+
+// MARK: - Duration helpers
+
+enum YouTubeCourseDuration {
+ /// Parses YouTube display lengths like "12:34", "1:02:03".
+ static func seconds(from lengthText: String?) -> Int {
+ guard let lengthText, !lengthText.isEmpty else { return 0 }
+ let parts = lengthText.split(separator: ":").compactMap { Int($0) }
+ guard !parts.isEmpty else { return 0 }
+ if parts.count == 1 { return parts[0] }
+ if parts.count == 2 { return parts[0] * 60 + parts[1] }
+ if parts.count >= 3 {
+ return parts[0] * 3600 + parts[1] * 60 + parts[2]
+ }
+ return 0
+ }
+
+ static func totalSeconds(in lessons: [YouTubeVideo]) -> Int {
+ lessons.reduce(0) { $0 + self.seconds(from: $1.lengthText) }
+ }
+
+ static func format(seconds: Int) -> String {
+ guard seconds > 0 else { return "—" }
+ let h = seconds / 3600
+ let m = (seconds % 3600) / 60
+ if h > 0 {
+ return String(localized: "\(h)h \(m)m")
+ }
+ return String(localized: "\(m) min")
+ }
+}
diff --git a/Sources/Kaset/Services/AI/FoundationModelsPromptLibrary.swift b/Sources/Kaset/Services/AI/FoundationModelsPromptLibrary.swift
index d331ca835..4789cac8a 100644
--- a/Sources/Kaset/Services/AI/FoundationModelsPromptLibrary.swift
+++ b/Sources/Kaset/Services/AI/FoundationModelsPromptLibrary.swift
@@ -376,4 +376,247 @@ enum FoundationModelsPromptLibrary {
"""
}
}
+
+ // MARK: - Video Intelligence
+
+ static func videoSummaryInstructions(
+ version: FoundationModelsPromptVersion = .current
+ ) -> String {
+ switch version {
+ case .legacy26_0To26_3:
+ """
+ You summarize YouTube videos for the Kaset app using only the metadata
+ provided (title, channel, views, date, comments snippets, related titles).
+ Never invent transcripts, quotes, or claims that are not supported by
+ that context. Be clear when context is thin.
+ """
+ case .optimized26_4AndLater:
+ """
+ You summarize YouTube videos for Kaset from metadata only.
+ Ground every claim in the provided title, channel, stats, comments,
+ and related titles. Do not invent a transcript or facts not shown.
+ Be concise, useful, and honest about uncertainty.
+ """
+ }
+ }
+
+ static func videoSummaryPrompt(
+ title: String,
+ channelName: String?,
+ viewCountText: String?,
+ publishedText: String?,
+ lengthText: String?,
+ commentSnippets: [String],
+ relatedTitles: [String],
+ version: FoundationModelsPromptVersion = .current
+ ) -> String {
+ let channel = channelName?.isEmpty == false ? channelName! : "Unknown channel"
+ let views = viewCountText ?? "unknown views"
+ let published = publishedText ?? "unknown date"
+ let length = lengthText ?? "unknown length"
+ let comments = commentSnippets.isEmpty
+ ? "(no comments available)"
+ : commentSnippets.enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")
+ let related = relatedTitles.isEmpty
+ ? "(none)"
+ : relatedTitles.enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")
+
+ switch version {
+ case .legacy26_0To26_3:
+ return """
+ Summarize this YouTube video from metadata only:
+
+ Title: \(title)
+ Channel: \(channel)
+ Views: \(views)
+ Published: \(published)
+ Length: \(length)
+
+ Sample comments:
+ \(comments)
+
+ Related video titles:
+ \(related)
+
+ Produce a VideoSummary with headline, topics, style, and overview.
+ """
+ case .optimized26_4AndLater:
+ return """
+ Video metadata (no transcript):
+ Title: \(title)
+ Channel: \(channel)
+ Views: \(views)
+ Published: \(published)
+ Length: \(length)
+
+ Sample comments:
+ \(comments)
+
+ Related titles:
+ \(related)
+
+ Task:
+ - Write a one-sentence headline
+ - List 2-4 topics
+ - Name the style/tone
+ - Write a 2-5 sentence overview grounded only in this context
+ """
+ }
+ }
+
+ static func videoQuestionInstructions(
+ version: FoundationModelsPromptVersion = .current
+ ) -> String {
+ switch version {
+ case .legacy26_0To26_3:
+ """
+ You answer user questions about a YouTube video using only the provided
+ metadata context. If the answer cannot be known from that context,
+ say so clearly and suggest what would be needed (e.g. watching the video).
+ """
+ case .optimized26_4AndLater:
+ """
+ Answer questions about a YouTube video using only the supplied metadata.
+ Be helpful and direct. If the metadata cannot answer the question,
+ say so and keep the caveat short. Never invent a transcript.
+ """
+ }
+ }
+
+ static func videoQuestionPrompt(
+ question: String,
+ title: String,
+ channelName: String?,
+ viewCountText: String?,
+ publishedText: String?,
+ lengthText: String?,
+ commentSnippets: [String],
+ relatedTitles: [String],
+ version: FoundationModelsPromptVersion = .current
+ ) -> String {
+ let channel = channelName?.isEmpty == false ? channelName! : "Unknown channel"
+ let views = viewCountText ?? "unknown views"
+ let published = publishedText ?? "unknown date"
+ let length = lengthText ?? "unknown length"
+ let comments = commentSnippets.isEmpty
+ ? "(no comments available)"
+ : commentSnippets.prefix(8).enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")
+ let related = relatedTitles.isEmpty
+ ? "(none)"
+ : relatedTitles.prefix(6).enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")
+
+ return """
+ Question: \(question)
+
+ Video metadata (no transcript):
+ Title: \(title)
+ Channel: \(channel)
+ Views: \(views)
+ Published: \(published)
+ Length: \(length)
+
+ Sample comments:
+ \(comments)
+
+ Related titles:
+ \(related)
+
+ Answer the question in a VideoAnswer. Use the caveat field when context is insufficient.
+ """
+ }
+
+ /// Multi-turn Ask prompt that includes recent conversation history.
+ static func videoAskConversationPrompt(
+ question: String,
+ priorTurns: [(question: String, answer: String)],
+ title: String,
+ channelName: String?,
+ viewCountText: String?,
+ publishedText: String?,
+ lengthText: String?,
+ commentSnippets: [String],
+ relatedTitles: [String],
+ version: FoundationModelsPromptVersion = .current
+ ) -> String {
+ let base = Self.videoQuestionPrompt(
+ question: question,
+ title: title,
+ channelName: channelName,
+ viewCountText: viewCountText,
+ publishedText: publishedText,
+ lengthText: lengthText,
+ commentSnippets: commentSnippets,
+ relatedTitles: relatedTitles,
+ version: version
+ )
+
+ guard !priorTurns.isEmpty else { return base }
+
+ let history = priorTurns.suffix(4).enumerated().map { index, turn in
+ """
+ Turn \(index + 1)
+ User: \(turn.question)
+ Assistant: \(turn.answer)
+ """
+ }.joined(separator: "\n\n")
+
+ return """
+ \(base)
+
+ Recent conversation (use for follow-ups; still ground facts only in metadata):
+ \(history)
+ """
+ }
+
+ static func videoAskSuggestionInstructions(
+ version: FoundationModelsPromptVersion = .current
+ ) -> String {
+ switch version {
+ case .legacy26_0To26_3:
+ """
+ You invent short, natural viewer questions for YouTube's Ask feature.
+ Questions must be grounded only in the video title, channel, and related
+ metadata — never invent plot details that are not implied by the title.
+ """
+ case .optimized26_4AndLater:
+ """
+ Generate short starter questions for Kaset's Ask feature on a YouTube video.
+ Questions should feel like something a real viewer would tap. Stay grounded
+ in title/channel/metadata only.
+ """
+ }
+ }
+
+ static func videoAskSuggestionPrompt(
+ title: String,
+ channelName: String?,
+ viewCountText: String?,
+ publishedText: String?,
+ lengthText: String?,
+ relatedTitles: [String],
+ version: FoundationModelsPromptVersion = .current
+ ) -> String {
+ let channel = channelName?.isEmpty == false ? channelName! : "Unknown channel"
+ let views = viewCountText ?? "unknown views"
+ let published = publishedText ?? "unknown date"
+ let length = lengthText ?? "unknown length"
+ let related = relatedTitles.isEmpty
+ ? "(none)"
+ : relatedTitles.prefix(6).enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")
+
+ return """
+ Video:
+ Title: \(title)
+ Channel: \(channel)
+ Views: \(views)
+ Published: \(published)
+ Length: \(length)
+
+ Related titles:
+ \(related)
+
+ Generate 3-5 short Ask starter questions a viewer might tap (under 12 words each).
+ Cover a mix of: what is this about, who is it for, key takeaways, and a follow-up curiosity.
+ """
+ }
}
diff --git a/Sources/Kaset/Services/AI/FoundationModelsService.swift b/Sources/Kaset/Services/AI/FoundationModelsService.swift
index 3aec97fc1..56e689a20 100644
--- a/Sources/Kaset/Services/AI/FoundationModelsService.swift
+++ b/Sources/Kaset/Services/AI/FoundationModelsService.swift
@@ -271,17 +271,61 @@ final class FoundationModelsService {
/// - Parameter instructions: System instructions for the session.
/// - Returns: A configured LanguageModelSession, or nil if unavailable.
func createAnalysisSession(instructions: String) -> LanguageModelSession? {
+ // Always re-check system availability — Ask / lyrics can open long after
+ // launch, and the model may have finished downloading in the meantime.
+ self.refreshAvailability()
+
guard self.isAvailable else {
self.logger.warning("Attempted to create analysis session but AI is not available")
return nil
}
+ guard self.supportsLocale(Locale.current) else {
+ self.logger.warning("Analysis session blocked: locale not supported")
+ return nil
+ }
+
self.logger.debug("Creating analysis session for creative content")
return LanguageModelSession(
instructions: instructions
)
}
+ /// Ensures the model is warmed and available before a user-facing Ask call.
+ /// Returns a short failure reason when AI cannot run, otherwise `nil`.
+ func prepareForInteractiveUse() async -> String? {
+ self.refreshAvailability()
+
+ if self.isDisabledByUser {
+ return String(localized: "AI features are turned off in Settings → Intelligence.")
+ }
+
+ switch self.availability {
+ case .available:
+ if !self.isWarmedUp {
+ await self.prewarmSession()
+ self.isWarmedUp = true
+ }
+ guard self.supportsLocale(Locale.current) else {
+ return String(localized: "Apple Intelligence doesn’t support the current language.")
+ }
+ return nil
+ case let .unavailable(reason):
+ switch reason {
+ case .deviceNotEligible:
+ return String(localized: "This Mac doesn’t support Apple Intelligence.")
+ case .appleIntelligenceNotEnabled:
+ return String(localized: "Enable Apple Intelligence in System Settings, then try again.")
+ case .modelNotReady:
+ return String(localized: "Apple Intelligence is still downloading. Try again in a moment.")
+ @unknown default:
+ return String(localized: "Apple Intelligence is currently unavailable.")
+ }
+ @unknown default:
+ return String(localized: "Apple Intelligence is currently unavailable.")
+ }
+ }
+
/// Creates a session for multi-turn conversational interactions.
///
/// Uses balanced temperature for natural dialogue. The session maintains
diff --git a/Sources/Kaset/Services/AI/LectureNotesService.swift b/Sources/Kaset/Services/AI/LectureNotesService.swift
new file mode 100644
index 000000000..64dd62759
--- /dev/null
+++ b/Sources/Kaset/Services/AI/LectureNotesService.swift
@@ -0,0 +1,444 @@
+import AppKit
+import Foundation
+import Observation
+
+// MARK: - LectureNotesService
+
+/// Service that generates structured lecture notes from a YouTube video using
+/// Antigravity CLI (`agy`), then renders them as a LaTeX PDF saved to
+/// ~/Downloads.
+///
+/// Flow:
+/// 1. Gather video context (title, channel, comments, captions context).
+/// 2. Send context to `agy --print` to generate structured LaTeX notes.
+/// 3. Parse the LaTeX output.
+/// 4. Compile LaTeX → PDF using the system's `pdflatex` or `tectonic`.
+/// 5. Save the PDF to ~/Downloads.
+@MainActor
+@Observable
+final class LectureNotesService {
+ // MARK: - State
+
+ enum GenerationState: Equatable {
+ case idle
+ case generatingNotes
+ case renderingPDF
+ case completed(URL)
+ case failed(String)
+ }
+
+ private(set) var state: GenerationState = .idle
+ private(set) var latexSource: String?
+ private(set) var notesTitle: String?
+ private(set) var notesSections: [String] = []
+
+ private let logger = DiagnosticsLogger.ai
+
+ // MARK: - Public API
+
+ /// Generates lecture notes for the given video context and exports as PDF.
+ ///
+ /// - Parameters:
+ /// - videoTitle: The video's title.
+ /// - channelName: The channel/instructor name.
+ /// - metadata: Additional metadata (views, published date, length).
+ /// - comments: Top comments for additional context.
+ /// - captionsContext: Any available caption/transcript text.
+ /// - Returns: URL of the generated PDF (or .tex fallback).
+ func generateAndExport(
+ videoTitle: String,
+ channelName: String?,
+ metadata: String,
+ comments: [String],
+ captionsContext: String?
+ ) async throws -> URL {
+ self.state = .generatingNotes
+ self.latexSource = nil
+ self.notesTitle = nil
+ self.notesSections = []
+
+ // Step 1: Generate LaTeX notes via Antigravity CLI
+ let latex: String
+ do {
+ latex = try await self.generateLatexViaAgy(
+ videoTitle: videoTitle,
+ channelName: channelName,
+ metadata: metadata,
+ comments: comments,
+ captionsContext: captionsContext
+ )
+ self.latexSource = latex
+ self.notesTitle = videoTitle
+ } catch {
+ let message = error.localizedDescription
+ self.state = .failed(message)
+ throw error
+ }
+
+ // Step 2: Compile to PDF
+ self.state = .renderingPDF
+ do {
+ let pdfURL = try await self.compileToPDF(latex: latex, title: videoTitle)
+ self.state = .completed(pdfURL)
+ return pdfURL
+ } catch {
+ self.state = .failed(error.localizedDescription)
+ throw error
+ }
+ }
+
+ /// Resets the service state for a new generation.
+ func reset() {
+ self.state = .idle
+ self.latexSource = nil
+ self.notesTitle = nil
+ self.notesSections = []
+ }
+
+ // MARK: - Antigravity CLI Generation
+
+ private func generateLatexViaAgy(
+ videoTitle: String,
+ channelName: String?,
+ metadata: String,
+ comments: [String],
+ captionsContext: String?
+ ) async throws -> String {
+ // Find agy binary
+ guard let agyPath = self.findAgyBinary() else {
+ throw LectureNotesError.agyNotFound
+ }
+
+ let commentsBlock = comments.prefix(10)
+ .enumerated()
+ .map { " \($0.offset + 1). \($0.element)" }
+ .joined(separator: "\n")
+
+ let captionsBlock: String
+ if let captions = captionsContext, !captions.isEmpty {
+ captionsBlock = """
+
+ Transcript/Captions:
+ \(String(captions.prefix(4000)))
+ """
+ } else {
+ captionsBlock = "\n(No transcript available — infer structure from title, comments, and metadata.)"
+ }
+
+ let prompt = """
+ Generate comprehensive LaTeX lecture notes for this YouTube video. Output ONLY the LaTeX source code, nothing else. Do not wrap in markdown code blocks.
+
+ Video Information:
+ Title: \(videoTitle)
+ Channel/Instructor: \(channelName ?? "Unknown")
+ \(metadata)
+
+ Top viewer comments (for context on content):
+ \(commentsBlock.isEmpty ? "(none)" : commentsBlock)
+ \(captionsBlock)
+
+ Requirements for the LaTeX document:
+ - Use \\documentclass[11pt, a4paper]{article}
+ - Include packages: inputenc, fontenc, lmodern, geometry (1in margins), enumitem, hyperref, xcolor, titlesec, fancyhdr
+ - Use a purple accent color for section headings
+ - Include: title, abstract, key concepts (itemize), 3-6 detailed sections, key takeaways (enumerate), and references/further reading if applicable
+ - Use fancyhdr with "Lecture Notes" on the left and "Generated by Kaset" on the right
+ - Make it comprehensive enough for a student to study from
+ - Escape all special LaTeX characters properly in the content
+
+ Output the complete LaTeX document from \\documentclass to \\end{document}.
+ """
+
+ self.logger.info("Generating lecture notes via agy for: \(videoTitle)")
+
+ let output = try await self.runAgy(binary: agyPath, prompt: prompt)
+
+ // Extract LaTeX from the output (strip any markdown fences if present)
+ let latex = Self.extractLatex(from: output)
+
+ guard !latex.isEmpty else {
+ throw LectureNotesError.noContent
+ }
+
+ // Parse section headings for preview
+ self.notesSections = Self.parseSectionHeadings(from: latex)
+
+ return latex
+ }
+
+ /// Runs `agy --print` with the given prompt and returns stdout.
+ private func runAgy(binary: String, prompt: String) async throws -> String {
+ try await withCheckedThrowingContinuation { continuation in
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: binary)
+ process.arguments = ["--print", prompt]
+ // Run in a temp directory to avoid agy reading project files
+ process.currentDirectoryURL = FileManager.default.temporaryDirectory
+
+ let stdoutPipe = Pipe()
+ let stderrPipe = Pipe()
+ process.standardOutput = stdoutPipe
+ process.standardError = stderrPipe
+
+ do {
+ try process.run()
+ } catch {
+ continuation.resume(throwing: LectureNotesError.agyExecutionFailed(error.localizedDescription))
+ return
+ }
+
+ process.waitUntilExit()
+
+ let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
+ let output = String(data: outputData, encoding: .utf8) ?? ""
+
+ if process.terminationStatus != 0 {
+ let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
+ let errorOutput = String(data: stderrData, encoding: .utf8) ?? ""
+ continuation.resume(
+ throwing: LectureNotesError.agyExecutionFailed(
+ "agy exited with code \(process.terminationStatus): \(errorOutput.prefix(300))"
+ )
+ )
+ } else {
+ continuation.resume(returning: output)
+ }
+ }
+ }
+
+ /// Extracts LaTeX content from agy output, stripping markdown fences if present.
+ private static func extractLatex(from output: String) -> String {
+ var text = output.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // Strip markdown code fences if agy wraps it
+ if text.hasPrefix("```latex") || text.hasPrefix("```tex") {
+ if let firstNewline = text.firstIndex(of: "\n") {
+ text = String(text[text.index(after: firstNewline)...])
+ }
+ } else if text.hasPrefix("```") {
+ if let firstNewline = text.firstIndex(of: "\n") {
+ text = String(text[text.index(after: firstNewline)...])
+ }
+ }
+ if text.hasSuffix("```") {
+ text = String(text.dropLast(3))
+ }
+
+ text = text.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // Ensure it starts with \documentclass
+ if let docStart = text.range(of: "\\documentclass") {
+ text = String(text[docStart.lowerBound...])
+ }
+
+ return text
+ }
+
+ /// Parses section headings from LaTeX for the UI preview.
+ private static func parseSectionHeadings(from latex: String) -> [String] {
+ var headings: [String] = []
+ let pattern = #"\\section\*?\{([^}]+)\}"#
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
+ let matches = regex.matches(in: latex, range: NSRange(latex.startIndex..., in: latex))
+ for match in matches {
+ if let range = Range(match.range(at: 1), in: latex) {
+ headings.append(String(latex[range]))
+ }
+ }
+ return headings
+ }
+
+ // MARK: - Binary Discovery
+
+ private func findAgyBinary() -> String? {
+ // NSHomeDirectory() returns the sandbox container in a sandboxed app.
+ // Use getpwuid to get the real user home directory.
+ let realHome: String
+ if let pw = getpwuid(getuid()), let homeDir = pw.pointee.pw_dir {
+ realHome = String(cString: homeDir)
+ } else {
+ realHome = "/Users/\(NSUserName())"
+ }
+
+ let candidates = [
+ "\(realHome)/.local/bin/agy",
+ "/opt/homebrew/bin/agy",
+ "/usr/local/bin/agy",
+ "/opt/local/bin/agy",
+ "\(realHome)/.antigravity/bin/agy",
+ "\(realHome)/bin/agy",
+ ]
+
+ if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
+ self.logger.info("Found agy at: \(found)")
+ return found
+ }
+
+ // Last resort: ask the shell
+ if let which = Self.which("agy"), FileManager.default.isExecutableFile(atPath: which) {
+ self.logger.info("Found agy via which: \(which)")
+ return which
+ }
+
+ self.logger.error("agy binary not found in any known location")
+ return nil
+ }
+
+ private static func which(_ name: String) -> String? {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
+ process.arguments = [name]
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = FileHandle.nullDevice
+ do {
+ try process.run()
+ process.waitUntilExit()
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ let path = String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return path?.isEmpty == false ? path : nil
+ } catch {
+ return nil
+ }
+ }
+
+ // MARK: - PDF Compilation
+
+ private func compileToPDF(latex: String, title: String) async throws -> URL {
+ let fileManager = FileManager.default
+ let tempDir = fileManager.temporaryDirectory.appendingPathComponent("kaset-notes-\(UUID().uuidString)")
+ try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true)
+
+ let texFile = tempDir.appendingPathComponent("notes.tex")
+ try latex.write(to: texFile, atomically: true, encoding: .utf8)
+
+ // Try tectonic first (single-pass, no TeX Live needed), then pdflatex
+ let compilerPath = self.findLatexCompiler()
+
+ let downloadsURL = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first
+ ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Downloads")
+ let sanitizedTitle = String(
+ title
+ .replacingOccurrences(of: "/", with: "-")
+ .replacingOccurrences(of: ":", with: "-")
+ .replacingOccurrences(of: "\"", with: "")
+ .prefix(80)
+ )
+
+ if let compiler = compilerPath {
+ let pdfFile: URL
+ if compiler.hasSuffix("tectonic") {
+ pdfFile = try await self.runTectonic(compiler: compiler, texFile: texFile, tempDir: tempDir)
+ } else {
+ pdfFile = try await self.runPdflatex(compiler: compiler, texFile: texFile, tempDir: tempDir)
+ }
+
+ let destName = "\(sanitizedTitle) — Notes.pdf"
+ let destURL = downloadsURL.appendingPathComponent(destName)
+
+ try? fileManager.removeItem(at: destURL)
+ try fileManager.copyItem(at: pdfFile, to: destURL)
+
+ // Cleanup temp
+ try? fileManager.removeItem(at: tempDir)
+
+ self.logger.info("Lecture notes PDF saved to: \(destURL.path)")
+ return destURL
+ } else {
+ // No LaTeX compiler — save the .tex source directly
+ let destName = "\(sanitizedTitle) — Notes.tex"
+ let destURL = downloadsURL.appendingPathComponent(destName)
+
+ try? fileManager.removeItem(at: destURL)
+ try latex.write(to: destURL, atomically: true, encoding: .utf8)
+
+ try? fileManager.removeItem(at: tempDir)
+
+ self.logger.warning("No LaTeX compiler found — saved .tex source to Downloads")
+ return destURL
+ }
+ }
+
+ private func findLatexCompiler() -> String? {
+ let candidates = [
+ "/opt/homebrew/bin/tectonic",
+ "/usr/local/bin/tectonic",
+ "/opt/homebrew/bin/pdflatex",
+ "/usr/local/bin/pdflatex",
+ "/Library/TeX/texbin/pdflatex",
+ "/usr/texbin/pdflatex",
+ ]
+ return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
+ }
+
+ private func runTectonic(compiler: String, texFile: URL, tempDir: URL) async throws -> URL {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: compiler)
+ process.arguments = [texFile.path]
+ process.currentDirectoryURL = tempDir
+
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = pipe
+
+ try process.run()
+ process.waitUntilExit()
+
+ let pdfFile = tempDir.appendingPathComponent("notes.pdf")
+ guard FileManager.default.fileExists(atPath: pdfFile.path) else {
+ let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
+ throw LectureNotesError.compilationFailed("tectonic failed: \(String(output.suffix(200)))")
+ }
+ return pdfFile
+ }
+
+ private func runPdflatex(compiler: String, texFile: URL, tempDir: URL) async throws -> URL {
+ // Run pdflatex twice for references/TOC
+ for _ in 0 ..< 2 {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: compiler)
+ process.arguments = [
+ "-interaction=nonstopmode",
+ "-output-directory=\(tempDir.path)",
+ texFile.path,
+ ]
+ process.currentDirectoryURL = tempDir
+
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = pipe
+
+ try process.run()
+ process.waitUntilExit()
+ }
+
+ let pdfFile = tempDir.appendingPathComponent("notes.pdf")
+ guard FileManager.default.fileExists(atPath: pdfFile.path) else {
+ throw LectureNotesError.compilationFailed("pdflatex did not produce output PDF")
+ }
+ return pdfFile
+ }
+}
+
+// MARK: - LectureNotesError
+
+enum LectureNotesError: LocalizedError {
+ case agyNotFound
+ case agyExecutionFailed(String)
+ case compilationFailed(String)
+ case noContent
+
+ var errorDescription: String? {
+ switch self {
+ case .agyNotFound:
+ return "Antigravity CLI (agy) not found. Install it with: curl -fsSL https://antigravity.google/cli/install.sh | bash"
+ case let .agyExecutionFailed(detail):
+ return "Antigravity CLI failed: \(detail)"
+ case let .compilationFailed(detail):
+ return "PDF compilation failed: \(detail)"
+ case .noContent:
+ return "Antigravity did not generate any LaTeX content."
+ }
+ }
+}
diff --git a/Sources/Kaset/Services/Download/YTDLPService.swift b/Sources/Kaset/Services/Download/YTDLPService.swift
new file mode 100644
index 000000000..2b101bb73
--- /dev/null
+++ b/Sources/Kaset/Services/Download/YTDLPService.swift
@@ -0,0 +1,680 @@
+import AppKit
+import Foundation
+import Observation
+
+// MARK: - DownloadJob
+
+/// A single yt-dlp download in progress or completed.
+///
+/// Not actor-isolated: all mutations happen on the main actor via
+/// `YTDLPService`. Keeping this a plain `@Observable` class avoids
+/// MainActor executor checks during SwiftUI list filters (which crashed
+/// the app when progress updates and body re-renders interleaved).
+@Observable
+final class DownloadJob: Identifiable {
+ enum Status: Equatable {
+ case queued
+ case running
+ case completed
+ case failed(String)
+ case cancelled
+ }
+
+ let id: UUID
+ let videoId: String
+ let title: String
+ let quality: DownloadQuality
+ let destinationDirectory: URL
+ let startedAt: Date
+
+ var status: Status = .queued
+ /// 0...1 when yt-dlp reports progress; nil while unknown.
+ var progress: Double?
+ var speedText: String?
+ var etaText: String?
+ var logTail: String = ""
+ var outputURL: URL?
+
+ /// Bumped on meaningful progress so SwiftUI can observe a simple value.
+ var progressRevision: UInt = 0
+
+ init(
+ id: UUID = UUID(),
+ videoId: String,
+ title: String,
+ quality: DownloadQuality,
+ destinationDirectory: URL
+ ) {
+ self.id = id
+ self.videoId = videoId
+ self.title = title
+ self.quality = quality
+ self.destinationDirectory = destinationDirectory
+ self.startedAt = Date()
+ }
+}
+
+// MARK: - YTDLPService
+
+/// Terminal-backed download service that shells out to the system `yt-dlp`
+/// binary (optionally with `ffmpeg` for merge / audio extract).
+///
+/// Downloads land in the user's Downloads folder or a custom folder chosen
+/// in Settings → YouTube. The binary path is auto-detected from common
+/// Homebrew locations, or can be overridden in settings.
+@MainActor
+@Observable
+final class YTDLPService {
+ static let shared = YTDLPService()
+
+ private(set) var jobs: [DownloadJob] = []
+ private(set) var lastError: String?
+ private(set) var resolvedBinaryPath: String?
+
+ private var processes: [UUID: Process] = [:]
+ /// Retained pipes so handlers stay valid until we explicitly tear them down.
+ private var pipes: [UUID: (stdout: Pipe, stderr: Pipe)] = [:]
+ /// Jobs that should ignore further pipe output (finished / cancelled).
+ private var finishedJobIDs: Set = []
+ /// Throttle UI progress writes (ms since reference date of last update).
+ private var lastProgressUIUpdate: [UUID: TimeInterval] = [:]
+ private let logger = DiagnosticsLogger.download
+ private let settings = SettingsManager.shared
+
+ private init() {
+ self.resolvedBinaryPath = Self.discoverBinaryPath(override: self.settings.ytdlpBinaryPath)
+ }
+
+ // MARK: - Availability
+
+ /// Whether a usable yt-dlp binary is currently resolvable.
+ var isAvailable: Bool {
+ self.resolvedBinaryPath != nil
+ }
+
+ /// Re-scan for yt-dlp (after install or path change).
+ func refreshBinaryPath() {
+ self.resolvedBinaryPath = Self.discoverBinaryPath(override: self.settings.ytdlpBinaryPath)
+ if let path = self.resolvedBinaryPath {
+ self.logger.info("yt-dlp resolved at \(path, privacy: .public)")
+ self.lastError = nil
+ } else {
+ self.logger.warning("yt-dlp not found on PATH / common install locations")
+ }
+ }
+
+ /// Candidate absolute paths checked when auto-detecting yt-dlp.
+ static let defaultSearchPaths: [String] = [
+ "/opt/homebrew/bin/yt-dlp",
+ "/usr/local/bin/yt-dlp",
+ "/opt/local/bin/yt-dlp",
+ "/usr/bin/yt-dlp",
+ ]
+
+ static func discoverBinaryPath(override: String?) -> String? {
+ if let override, !override.isEmpty {
+ let expanded = (override as NSString).expandingTildeInPath
+ if FileManager.default.isExecutableFile(atPath: expanded) {
+ return expanded
+ }
+ }
+
+ for path in Self.defaultSearchPaths where FileManager.default.isExecutableFile(atPath: path) {
+ return path
+ }
+
+ // Last resort: ask the login shell (works outside sandbox; may fail inside).
+ if let which = Self.which("yt-dlp"), FileManager.default.isExecutableFile(atPath: which) {
+ return which
+ }
+ return nil
+ }
+
+ private static func which(_ name: String) -> String? {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
+ process.arguments = [name]
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = FileHandle.nullDevice
+ process.environment = Self.augmentedEnvironment()
+ do {
+ try process.run()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else { return nil }
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ let path = String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return (path?.isEmpty == false) ? path : nil
+ } catch {
+ return nil
+ }
+ }
+
+ // MARK: - Destination
+
+ /// Resolves the active download directory from settings, starting
+ /// security-scoped access when a custom bookmark is used.
+ ///
+ /// Caller must pair with `endAccessingDestination` when finished.
+ func beginAccessingDestination() throws -> (url: URL, didStartSecurityScope: Bool) {
+ switch self.settings.downloadFolderPreference {
+ case .downloads:
+ let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
+ ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ return (url, false)
+
+ case .custom:
+ guard let bookmark = self.settings.downloadFolderBookmarkData else {
+ throw YTDLPError.noCustomFolder
+ }
+ var isStale = false
+ let url = try URL(
+ resolvingBookmarkData: bookmark,
+ options: [.withSecurityScope],
+ relativeTo: nil,
+ bookmarkDataIsStale: &isStale
+ )
+ if isStale {
+ // Re-create bookmark so access survives across relaunches.
+ if let refreshed = try? url.bookmarkData(
+ options: .withSecurityScope,
+ includingResourceValuesForKeys: nil,
+ relativeTo: nil
+ ) {
+ self.settings.downloadFolderBookmarkData = refreshed
+ }
+ }
+ let started = url.startAccessingSecurityScopedResource()
+ guard started || url.path.hasPrefix(NSHomeDirectory()) else {
+ throw YTDLPError.folderAccessDenied(url.path)
+ }
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ return (url, started)
+ }
+ }
+
+ func endAccessingDestination(_ url: URL, didStartSecurityScope: Bool) {
+ if didStartSecurityScope {
+ url.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ // MARK: - Download
+
+ /// Enqueues and starts a yt-dlp download for the given video.
+ @discardableResult
+ func download(
+ videoId: String,
+ title: String,
+ quality: DownloadQuality? = nil
+ ) throws -> DownloadJob {
+ self.refreshBinaryPath()
+ guard let binary = self.resolvedBinaryPath else {
+ throw YTDLPError.binaryNotFound
+ }
+
+ let quality = quality ?? self.settings.downloadDefaultQuality
+ let access = try self.beginAccessingDestination()
+ let job = DownloadJob(
+ videoId: videoId,
+ title: title,
+ quality: quality,
+ destinationDirectory: access.url
+ )
+ self.jobs.insert(job, at: 0)
+ self.lastError = nil
+
+ Task { @MainActor in
+ await self.run(job: job, binary: binary, destinationAccess: access)
+ }
+ return job
+ }
+
+ func cancel(_ jobID: UUID) {
+ self.finishedJobIDs.insert(jobID)
+ if let job = self.jobs.first(where: { $0.id == jobID }) {
+ job.status = .cancelled
+ }
+ if let process = self.processes[jobID], process.isRunning {
+ process.terminate()
+ }
+ self.detachPipes(for: jobID)
+ self.processes[jobID] = nil
+ }
+
+ func clearFinishedJobs() {
+ self.jobs.removeAll { job in
+ switch job.status {
+ case .completed, .failed, .cancelled: true
+ case .queued, .running: false
+ }
+ }
+ }
+
+ /// Reveals the download folder (or a completed file) in Finder.
+ func revealInFinder(job: DownloadJob) {
+ if let output = job.outputURL {
+ NSWorkspace.shared.activateFileViewerSelecting([output])
+ } else {
+ NSWorkspace.shared.open(job.destinationDirectory)
+ }
+ }
+
+ /// Opens Terminal.app with a ready-to-run yt-dlp command for power users.
+ func openInTerminal(
+ videoId: String,
+ title: String,
+ quality: DownloadQuality? = nil
+ ) throws {
+ self.refreshBinaryPath()
+ let binary = self.resolvedBinaryPath ?? "yt-dlp"
+ let quality = quality ?? self.settings.downloadDefaultQuality
+ let access = try self.beginAccessingDestination()
+ defer {
+ self.endAccessingDestination(access.url, didStartSecurityScope: access.didStartSecurityScope)
+ }
+
+ let args = Self.buildArguments(
+ binary: binary,
+ videoId: videoId,
+ title: title,
+ quality: quality,
+ destination: access.url
+ )
+ // Escape for a double-quoted shell string.
+ let command = args.map { arg in
+ "'" + arg.replacingOccurrences(of: "'", with: "'\\''") + "'"
+ }.joined(separator: " ")
+
+ let script = """
+ #!/bin/zsh
+ cd \(shellSingleQuoted(access.url.path))
+ echo "Kaset → yt-dlp"
+ echo "\(title.replacingOccurrences(of: "\"", with: "\\\""))"
+ \(command)
+ echo ""
+ echo "Done. Press any key to close."
+ read -k1
+ """
+
+ let temp = FileManager.default.temporaryDirectory
+ .appendingPathComponent("kaset-ytdlp-\(videoId).command")
+ try script.write(to: temp, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: temp.path)
+ NSWorkspace.shared.open(temp)
+ }
+
+ // MARK: - Internals
+
+ private func run(
+ job: DownloadJob,
+ binary: String,
+ destinationAccess: (url: URL, didStartSecurityScope: Bool)
+ ) async {
+ job.status = .running
+ self.finishedJobIDs.remove(job.id)
+
+ let args = Self.buildArguments(
+ binary: binary,
+ videoId: job.videoId,
+ title: job.title,
+ quality: job.quality,
+ destination: destinationAccess.url
+ )
+
+ let process = Process()
+ // args[0] is the binary path; Process wants executable + remaining args.
+ process.executableURL = URL(fileURLWithPath: binary)
+ process.arguments = Array(args.dropFirst())
+ process.currentDirectoryURL = destinationAccess.url
+ process.environment = Self.augmentedEnvironment()
+
+ // Keep pipes alive for the lifetime of the process. Detaching
+ // standardOutput before clearing readabilityHandler used to race and
+ // crash the app when yt-dlp finished (EXC_BAD_ACCESS in FileHandle +
+ // SwiftUI re-render of the download HUD).
+ let stdout = Pipe()
+ let stderr = Pipe()
+ process.standardOutput = stdout
+ process.standardError = stderr
+
+ let jobID = job.id
+ self.processes[jobID] = process
+ self.pipes[jobID] = (stdout, stderr)
+
+ self.attachPipeReader(stdout, jobID: jobID)
+ self.attachPipeReader(stderr, jobID: jobID)
+
+ do {
+ self.logger.info(
+ "Starting yt-dlp for \(job.videoId, privacy: .public) → \(destinationAccess.url.path, privacy: .public)"
+ )
+ try process.run()
+
+ await withCheckedContinuation { (continuation: CheckedContinuation) in
+ process.terminationHandler = { _ in
+ // Detach pipe handlers on the termination queue first so
+ // no further availableData calls race with MainActor cleanup.
+ stdout.fileHandleForReading.readabilityHandler = nil
+ stderr.fileHandleForReading.readabilityHandler = nil
+ continuation.resume()
+ }
+ }
+ } catch {
+ job.status = .failed(error.localizedDescription)
+ self.lastError = error.localizedDescription
+ self.logger.error("yt-dlp failed to launch: \(error.localizedDescription, privacy: .public)")
+ self.cleanup(jobID: job.id, destinationAccess: destinationAccess)
+ return
+ }
+
+ // Process has exited — mark finished so any late MainActor pipe tasks no-op.
+ self.finishedJobIDs.insert(jobID)
+
+ let code = process.terminationStatus
+ if job.status == .cancelled {
+ self.cleanup(jobID: job.id, destinationAccess: destinationAccess)
+ return
+ }
+
+ if code == 0 {
+ job.status = .completed
+ job.progress = 1
+ job.progressRevision &+= 1
+ if job.outputURL == nil {
+ job.outputURL = Self.guessOutputURL(
+ destination: destinationAccess.url,
+ title: job.title,
+ videoId: job.videoId
+ )
+ }
+ self.logger.info("yt-dlp completed for \(job.videoId, privacy: .public)")
+ } else {
+ let message = job.logTail.split(separator: "\n").suffix(3).joined(separator: "\n")
+ let failure = message.isEmpty
+ ? "yt-dlp exited with code \(code)"
+ : String(message)
+ job.status = .failed(failure)
+ self.lastError = failure
+ self.logger.error(
+ "yt-dlp exit \(code) for \(job.videoId, privacy: .public): \(failure, privacy: .public)"
+ )
+ }
+
+ self.cleanup(jobID: job.id, destinationAccess: destinationAccess)
+ }
+
+ private func attachPipeReader(_ pipe: Pipe, jobID: UUID) {
+ pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
+ let data = handle.availableData
+ // Empty data == EOF. Drop the handler immediately.
+ if data.isEmpty {
+ handle.readabilityHandler = nil
+ return
+ }
+ guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return }
+
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ guard !self.finishedJobIDs.contains(jobID) else { return }
+ guard self.processes[jobID] != nil else { return }
+ self.appendLog(jobID: jobID, text: text)
+ self.parseProgress(jobID: jobID, text: text)
+ }
+ }
+ }
+
+ private func detachPipes(for jobID: UUID) {
+ if let pair = self.pipes[jobID] {
+ pair.stdout.fileHandleForReading.readabilityHandler = nil
+ pair.stderr.fileHandleForReading.readabilityHandler = nil
+ }
+ self.pipes[jobID] = nil
+ }
+
+ private func cleanup(
+ jobID: UUID,
+ destinationAccess: (url: URL, didStartSecurityScope: Bool)
+ ) {
+ self.finishedJobIDs.insert(jobID)
+ self.detachPipes(for: jobID)
+
+ if let process = self.processes[jobID] {
+ process.terminationHandler = nil
+ // Only clear process IO after handlers are gone.
+ process.standardOutput = nil
+ process.standardError = nil
+ }
+ self.processes[jobID] = nil
+ self.lastProgressUIUpdate[jobID] = nil
+ self.endAccessingDestination(
+ destinationAccess.url,
+ didStartSecurityScope: destinationAccess.didStartSecurityScope
+ )
+ }
+
+ private func appendLog(jobID: UUID, text: String) {
+ guard !self.finishedJobIDs.contains(jobID) else { return }
+ guard let job = self.jobs.first(where: { $0.id == jobID }) else { return }
+ let combined = job.logTail + text
+ job.logTail = combined.count > 4000 ? String(combined.suffix(4000)) : combined
+ if let dest = Self.parseDestination(from: text) {
+ job.outputURL = URL(fileURLWithPath: dest)
+ }
+ }
+
+ private func parseProgress(jobID: UUID, text: String) {
+ guard !self.finishedJobIDs.contains(jobID) else { return }
+ guard let job = self.jobs.first(where: { $0.id == jobID }) else { return }
+
+ var didChange = false
+ // yt-dlp may flush multiple --newline progress rows in one pipe chunk.
+ for line in text.split(whereSeparator: \.isNewline).map(String.init) {
+ if let percent = Self.parsePercent(from: line) {
+ let next = percent / 100
+ let current = job.progress ?? 0
+ if next > current + 0.001 {
+ job.progress = next
+ didChange = true
+ } else if job.progress == nil {
+ job.progress = next
+ didChange = true
+ }
+ }
+ if let speed = Self.parseField(from: line, label: "at"), speed != job.speedText {
+ job.speedText = speed
+ didChange = true
+ }
+ if let eta = Self.parseField(from: line, label: "ETA"), eta != job.etaText {
+ job.etaText = eta
+ didChange = true
+ }
+ if line.localizedCaseInsensitiveContains("Merging formats")
+ || line.localizedCaseInsensitiveContains("Extracting audio")
+ || line.localizedCaseInsensitiveContains("Embedding thumbnail")
+ {
+ let next = max(job.progress ?? 0, 0.97)
+ if job.progress != next {
+ job.progress = next
+ didChange = true
+ }
+ job.speedText = nil
+ job.etaText = String(localized: "Finishing…")
+ didChange = true
+ }
+ }
+
+ // Throttle Observation/SwiftUI invalidations — yt-dlp can emit many
+ // progress lines per second; updating every tick crashed the download HUD.
+ guard didChange else { return }
+ let now = Date().timeIntervalSinceReferenceDate
+ let last = self.lastProgressUIUpdate[jobID] ?? 0
+ if now - last >= 0.15 || (job.progress ?? 0) >= 0.999 {
+ self.lastProgressUIUpdate[jobID] = now
+ job.progressRevision &+= 1
+ }
+ }
+
+ // MARK: - Argument / parse helpers (pure, unit-testable)
+
+ /// Builds the full argv including the binary as argv[0].
+ static func buildArguments(
+ binary: String,
+ videoId: String,
+ title: String,
+ quality: DownloadQuality,
+ destination: URL
+ ) -> [String] {
+ let url = "https://www.youtube.com/watch?v=\(videoId)"
+ // Sanitize title for the output template (yt-dlp also sanitizes).
+ let safeTitle = title
+ .replacingOccurrences(of: "/", with: "-")
+ .replacingOccurrences(of: "\0", with: "")
+
+ var args: [String] = [
+ binary,
+ "--no-playlist",
+ "--newline",
+ "--no-colors",
+ "--progress",
+ "-f", quality.formatSelector,
+ "-o", "%(title).200B [%(id)s].%(ext)s",
+ "-P", destination.path,
+ "--print", "after_move:filepath",
+ "--print", "after_video:filepath",
+ ]
+
+ if let audioFormat = quality.audioExtractFormat {
+ args += ["-x", "--audio-format", audioFormat]
+ }
+
+ // Prefer mp4 when merging so Finder/Quick Look are happy.
+ if !quality.isAudioOnly {
+ args += ["--merge-output-format", "mp4"]
+ }
+
+ // Embed metadata / thumbnail when possible.
+ args += [
+ "--embed-metadata",
+ "--embed-thumbnail",
+ "--convert-thumbnails", "jpg",
+ ]
+
+ // Soft-fail thumbnail/embed if unsupported for this media.
+ args += ["--ignore-errors"]
+
+ // Keep a readable fallback name in logs.
+ _ = safeTitle
+
+ args.append(url)
+ return args
+ }
+
+ static func parsePercent(from text: String) -> Double? {
+ // Example: [download] 45.2% of 10.00MiB at 1.23MiB/s ETA 00:04
+ guard let range = text.range(of: #"(\d{1,3}(?:\.\d+)?)%"#, options: .regularExpression) else {
+ return nil
+ }
+ let token = text[range].dropLast() // strip %
+ return Double(token)
+ }
+
+ static func parseField(from text: String, label: String) -> String? {
+ // Match "at 1.23MiB/s" or "ETA 00:04"
+ let pattern = #"\#(label)\s+([^\s]+)"#
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
+ let ns = text as NSString
+ guard let match = regex.firstMatch(in: text, range: NSRange(location: 0, length: ns.length)),
+ match.numberOfRanges >= 2
+ else { return nil }
+ return ns.substring(with: match.range(at: 1))
+ }
+
+ static func parseDestination(from text: String) -> String? {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ // after_move / after_video print a bare path.
+ if trimmed.hasPrefix("/"), FileManager.default.fileExists(atPath: trimmed) {
+ return trimmed
+ }
+ // Destination: /path/to/file.mp4
+ if let range = trimmed.range(of: "Destination: ") {
+ let path = String(trimmed[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
+ return path.isEmpty ? nil : path
+ }
+ if trimmed.contains("has already been downloaded") {
+ // [download] File.mp4 has already been downloaded
+ if let start = trimmed.range(of: "[download] ")?.upperBound,
+ let end = trimmed.range(of: " has already")?.lowerBound
+ {
+ return String(trimmed[start ..< end])
+ }
+ }
+ return nil
+ }
+
+ static func guessOutputURL(destination: URL, title: String, videoId: String) -> URL? {
+ let contents = (try? FileManager.default.contentsOfDirectory(
+ at: destination,
+ includingPropertiesForKeys: [.contentModificationDateKey],
+ options: [.skipsHiddenFiles]
+ )) ?? []
+ let matches = contents.filter { $0.lastPathComponent.contains(videoId) }
+ if let best = matches.max(by: { a, b in
+ let da = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
+ let db = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
+ return da < db
+ }) {
+ return best
+ }
+ // Fall back: any recent file with a media extension.
+ let media = contents.filter {
+ ["mp4", "mkv", "webm", "m4a", "mp3", "opus"].contains($0.pathExtension.lowercased())
+ }
+ return media.max(by: { a, b in
+ let da = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
+ let db = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
+ return da < db
+ })
+ }
+
+ static func augmentedEnvironment() -> [String: String] {
+ var env = ProcessInfo.processInfo.environment
+ let extras = [
+ "/opt/homebrew/bin",
+ "/usr/local/bin",
+ "/opt/local/bin",
+ "/usr/bin",
+ "/bin",
+ ]
+ let existing = env["PATH"] ?? ""
+ env["PATH"] = (extras + [existing]).joined(separator: ":")
+ // Avoid interactive prompts.
+ env["PYTHONUNBUFFERED"] = "1"
+ return env
+ }
+
+ private func shellSingleQuoted(_ value: String) -> String {
+ "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
+ }
+}
+
+// MARK: - YTDLPError
+
+enum YTDLPError: LocalizedError {
+ case binaryNotFound
+ case noCustomFolder
+ case folderAccessDenied(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .binaryNotFound:
+ String(localized: "yt-dlp was not found. Install it with Homebrew (brew install yt-dlp) or set the path in Settings → YouTube.")
+ case .noCustomFolder:
+ String(localized: "Choose a custom download folder in Settings → YouTube.")
+ case let .folderAccessDenied(path):
+ String(localized: "Could not access the download folder: \(path)")
+ }
+ }
+}
diff --git a/Sources/Kaset/Services/Player/YouTubePlayerService.swift b/Sources/Kaset/Services/Player/YouTubePlayerService.swift
index 82a574f5a..1cce04dcb 100644
--- a/Sources/Kaset/Services/Player/YouTubePlayerService.swift
+++ b/Sources/Kaset/Services/Player/YouTubePlayerService.swift
@@ -24,6 +24,7 @@ protocol YouTubeWatchPlaybackControlling: AnyObject {
func availableQualityLevels() async -> [String]
func currentQualityLevel() async -> String?
func setQualityLevel(_ level: String)
+ func setPlaybackSpeed(_ speed: Double)
func storyboardSpec(expectedVideoId: String?) async -> String?
func tearDown()
}
@@ -145,6 +146,8 @@ final class YouTubePlayerService {
case none
case inline
case floating
+ /// In-app mini player strip at the bottom of the content column.
+ case miniPlayer
}
/// Current surface placement. KasetApp observes this to open/close the
@@ -191,6 +194,9 @@ final class YouTubePlayerService {
/// The player's current quality level.
private(set) var currentQuality: String?
+ /// The current playback speed (default 1.0 = normal).
+ private(set) var playbackSpeed: Double = 1.0
+
/// YouTube storyboard spec for the current video (drives the ambient
/// backdrop's fine-grained live color). `nil` until fetched / unavailable.
private(set) var storyboardSpec: String?
@@ -441,6 +447,22 @@ final class YouTubePlayerService {
self.surfaceLocation = .inline
}
+ /// Collapses the video into the in-app mini player strip at the bottom
+ /// of the content column. Video audio continues playing while collapsed.
+ func popToMiniPlayer() {
+ guard self.currentVideo != nil else { return }
+ self.logger.info("YouTubePlayer: collapse to in-app mini player")
+ self.surfaceLocation = .miniPlayer
+ }
+
+ /// Expands the in-app mini player back into the full watch view.
+ /// Triggers a pop-in request so YouTubeContentView opens the watch route.
+ func expandFromMiniPlayer() {
+ guard self.surfaceLocation == .miniPlayer, let video = self.currentVideo else { return }
+ self.logger.info("YouTubePlayer: expand from in-app mini player")
+ self.popInRequest = video
+ }
+
/// The floating window asked to dock the video back into the app.
func requestPopIn() {
guard self.surfaceLocation == .floating, let video = self.currentVideo else { return }
@@ -454,12 +476,18 @@ final class YouTubePlayerService {
// MARK: - Skipping
- /// Supplies up-next candidates (the watch page's related list).
+ /// Supplies up-next candidates (the watch page's related list or a course queue).
func setUpNext(_ videos: [YouTubeVideo]) {
let currentId = self.currentVideo?.videoId
self.upNext = videos.filter { $0.videoId != currentId && !$0.isShort }
}
+ /// Replaces up-next with an ordered course remainder (preserves order).
+ func setCourseQueue(_ videos: [YouTubeVideo]) {
+ let currentId = self.currentVideo?.videoId
+ self.upNext = videos.filter { $0.videoId != currentId && !$0.isShort }
+ }
+
/// Skips to the next video (first up-next candidate; fetched lazily
/// when none are known, e.g. when playing in the floating window).
func skipForward() async {
@@ -489,6 +517,12 @@ final class YouTubePlayerService {
self.skipNavigationRequest = nil
}
+ /// Continues a multi-video queue (course playlist) without resetting
+ /// surface placement — used for next-lesson / auto-advance.
+ func continueWith(video: YouTubeVideo) {
+ self.advance(to: video)
+ }
+
private func advance(to video: YouTubeVideo, recordingHistory: Bool = true) {
self.logger.info("YouTubePlayer: advancing to another video")
if recordingHistory, let current = self.currentVideo {
@@ -537,6 +571,7 @@ final class YouTubePlayerService {
self.activeCaptionLanguageCode = nil
self.qualityLevels = []
self.currentQuality = nil
+ self.playbackSpeed = 1.0
self.storyboardSpec = nil
self.storyboardFetchVideoId = nil
self.storyboardFetchInFlightVideoId = nil
@@ -654,6 +689,13 @@ final class YouTubePlayerService {
HapticService.toggle()
}
+ /// Selects a playback speed (e.g. 0.5, 1.0, 1.5, 2.0).
+ func selectPlaybackSpeed(_ speed: Double) {
+ self.playbackSpeed = speed
+ self.playbackController.setPlaybackSpeed(speed)
+ HapticService.toggle()
+ }
+
// MARK: - AirPlay
/// Shows the system AirPlay picker for the video element.
@@ -702,8 +744,8 @@ final class YouTubePlayerService {
}
/// A WatchView for `videoId` is disappearing. If it owns the inline
- /// surface, hand off: keep playing in the floating window, stop if
- /// paused — or, during a source switch, stay paused in place.
+ /// surface, hand off: collapse to the in-app mini player while playing,
+ /// stop if paused — or, during a source switch, stay paused in place.
func inlineSurfaceWillDisappear(videoId: String) {
guard self.activeInlineVideoId == videoId else { return }
self.activeInlineVideoId = nil
@@ -721,7 +763,9 @@ final class YouTubePlayerService {
}
if self.isPlaying, self.shouldPopOutOnNavigateAway() {
- self.popOutToWindow()
+ // Collapse to the in-app mini player strip rather than launching a
+ // separate OS window — matches YouTube's back-swipe behaviour.
+ self.popToMiniPlayer()
} else {
// Playing with pop-out disabled, or paused: stop instead of
// leaving a detached surface.
@@ -739,6 +783,7 @@ final class YouTubePlayerService {
var videoId: String?
var title: String?
var isAd = false
+ var playbackRate: Double = 1.0
}
/// Applies a `STATE_UPDATE` from the watch page observer script.
@@ -775,6 +820,9 @@ final class YouTubePlayerService {
self.progress = update.progress
self.duration = update.duration
self.isShowingAd = update.isAd
+ if update.playbackRate > 0, update.playbackRate != self.playbackSpeed {
+ self.playbackSpeed = update.playbackRate
+ }
self.isPlaybackLoading = false
// Remember the last real content position (ignoring ad playback) so an
diff --git a/Sources/Kaset/Services/SettingsManager.swift b/Sources/Kaset/Services/SettingsManager.swift
index 4ac3d7586..cbb24cd05 100644
--- a/Sources/Kaset/Services/SettingsManager.swift
+++ b/Sources/Kaset/Services/SettingsManager.swift
@@ -32,6 +32,11 @@ final class SettingsManager {
static let ambientBackdropEnabled = "settings.ambientBackdropEnabled"
static let ambientBackdropStyle = "settings.ambientBackdropStyle"
static let popOutVideoOnNavigateAway = "settings.popOutVideoOnNavigateAway"
+ static let downloadFolderPreference = "settings.downloadFolderPreference"
+ static let downloadFolderBookmarkData = "settings.downloadFolderBookmarkData"
+ static let downloadFolderDisplayPath = "settings.downloadFolderDisplayPath"
+ static let downloadDefaultQuality = "settings.downloadDefaultQuality"
+ static let ytdlpBinaryPath = "settings.ytdlpBinaryPath"
#if DEBUG
static let useLegacyMacOS15UI = "settings.debug.useLegacyMacOS15UI"
#endif
@@ -399,6 +404,45 @@ final class SettingsManager {
}
}
+ /// Where yt-dlp should write completed media (Downloads or custom folder).
+ var downloadFolderPreference: DownloadFolderPreference {
+ didSet {
+ UserDefaults.standard.set(self.downloadFolderPreference.rawValue, forKey: Keys.downloadFolderPreference)
+ }
+ }
+
+ /// Security-scoped bookmark for a custom download folder.
+ var downloadFolderBookmarkData: Data? {
+ didSet {
+ if let data = self.downloadFolderBookmarkData {
+ UserDefaults.standard.set(data, forKey: Keys.downloadFolderBookmarkData)
+ } else {
+ UserDefaults.standard.removeObject(forKey: Keys.downloadFolderBookmarkData)
+ }
+ }
+ }
+
+ /// Human-readable path shown in Settings for the custom folder.
+ var downloadFolderDisplayPath: String {
+ didSet {
+ UserDefaults.standard.set(self.downloadFolderDisplayPath, forKey: Keys.downloadFolderDisplayPath)
+ }
+ }
+
+ /// Default quality preset for new downloads.
+ var downloadDefaultQuality: DownloadQuality {
+ didSet {
+ UserDefaults.standard.set(self.downloadDefaultQuality.rawValue, forKey: Keys.downloadDefaultQuality)
+ }
+ }
+
+ /// Optional absolute path override for the yt-dlp binary.
+ var ytdlpBinaryPath: String {
+ didSet {
+ UserDefaults.standard.set(self.ytdlpBinaryPath, forKey: Keys.ytdlpBinaryPath)
+ }
+ }
+
#if DEBUG
/// Debug-only switch that forces the app to render macOS 15 fallback UI on newer OS versions.
var useLegacyMacOS15UI: Bool {
@@ -502,6 +546,26 @@ final class SettingsManager {
self.appSource = .music
}
+ if let rawValue = UserDefaults.standard.string(forKey: Keys.downloadFolderPreference),
+ let preference = DownloadFolderPreference(rawValue: rawValue)
+ {
+ self.downloadFolderPreference = preference
+ } else {
+ self.downloadFolderPreference = .downloads
+ }
+
+ self.downloadFolderBookmarkData = UserDefaults.standard.data(forKey: Keys.downloadFolderBookmarkData)
+ self.downloadFolderDisplayPath = UserDefaults.standard.string(forKey: Keys.downloadFolderDisplayPath) ?? ""
+ self.ytdlpBinaryPath = UserDefaults.standard.string(forKey: Keys.ytdlpBinaryPath) ?? ""
+
+ if let rawValue = UserDefaults.standard.string(forKey: Keys.downloadDefaultQuality),
+ let quality = DownloadQuality(rawValue: rawValue)
+ {
+ self.downloadDefaultQuality = quality
+ } else {
+ self.downloadDefaultQuality = .best
+ }
+
AppLocalization.setLanguage(self.contentLanguage.languageCode)
// Persist migration from legacy lastFMEnabled key (must run after all properties initialized)
diff --git a/Sources/Kaset/Services/YouTube/YouTubeAskService.swift b/Sources/Kaset/Services/YouTube/YouTubeAskService.swift
new file mode 100644
index 000000000..7e1aab7ab
--- /dev/null
+++ b/Sources/Kaset/Services/YouTube/YouTubeAskService.swift
@@ -0,0 +1,654 @@
+import AppKit
+import Foundation
+import Observation
+import WebKit
+
+// MARK: - Ask models
+
+struct YouTubeAskMessage: Identifiable, Equatable, Sendable {
+ enum Role: Equatable, Sendable {
+ case assistant
+ case user
+ }
+
+ let id: UUID
+ let role: Role
+ var text: String
+ var isStreaming: Bool
+
+ init(id: UUID = UUID(), role: Role, text: String, isStreaming: Bool = false) {
+ self.id = id
+ self.role = role
+ self.text = text
+ self.isStreaming = isStreaming
+ }
+}
+
+// MARK: - YouTubeAskService
+
+/// Fetches YouTube’s **Ask about this video** experience from the live website
+/// in a **hidden** `WKWebView`, then surfaces suggestions and answers in Kaset’s
+/// native UI. The webpage is never shown to the user.
+@MainActor
+@Observable
+final class YouTubeAskService: NSObject {
+ static let shared = YouTubeAskService()
+
+ private(set) var videoId: String?
+ private(set) var messages: [YouTubeAskMessage] = []
+ private(set) var suggestions: [String] = []
+ private(set) var isPageReady = false
+ private(set) var isLoadingPage = false
+ private(set) var isAnswering = false
+ private(set) var statusMessage: String?
+ private(set) var errorMessage: String?
+
+ private var webView: WKWebView?
+ private var webKitManager: WebKitManager?
+ private var hostWindow: NSWindow?
+ private var loadGeneration = 0
+ private var answerMessageId: UUID?
+ private let logger = DiagnosticsLogger.webKit
+
+ private static let bridgeName = "kasetAsk"
+
+ override private init() {
+ super.init()
+ }
+
+ // MARK: - Public API
+
+ /// Loads the YouTube watch page off-screen and prepares Ask extraction.
+ func prepare(
+ videoId: String,
+ webKitManager: WebKitManager,
+ usesCookieFreeDataStore: Bool = false
+ ) {
+ if self.videoId == videoId, self.webView != nil, self.isPageReady || self.isLoadingPage {
+ return
+ }
+
+ self.tearDown(keepMessages: false)
+ self.videoId = videoId
+ self.webKitManager = webKitManager
+ self.isLoadingPage = true
+ self.isPageReady = false
+ self.errorMessage = nil
+ self.statusMessage = String(localized: "Connecting to YouTube Ask…")
+ self.suggestions = Self.defaultSuggestions
+ self.messages = [
+ YouTubeAskMessage(
+ role: .assistant,
+ text: String(localized: "Loading YouTube’s Ask for this video… Suggested questions will appear when ready.")
+ ),
+ ]
+
+ self.loadGeneration += 1
+ let generation = self.loadGeneration
+
+ let configuration = webKitManager.createWebViewConfiguration(
+ websiteDataStore: usesCookieFreeDataStore ? .nonPersistent() : nil
+ )
+ configuration.mediaTypesRequiringUserActionForPlayback = [.all]
+ configuration.allowsAirPlayForMediaPlayback = false
+
+ let controller = configuration.userContentController
+ controller.removeScriptMessageHandler(forName: Self.bridgeName)
+ controller.add(self, name: Self.bridgeName)
+ controller.addUserScript(
+ WKUserScript(
+ source: Self.bridgeScript,
+ injectionTime: .atDocumentEnd,
+ forMainFrameOnly: true
+ )
+ )
+
+ let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 420, height: 720), configuration: configuration)
+ webView.navigationDelegate = self
+ webView.customUserAgent = WebKitManager.userAgent
+ webView.setValue(false, forKey: "drawsBackground")
+ #if DEBUG
+ webView.isInspectable = true
+ #endif
+
+ // Keep the view in a hidden window so WebKit fully runs scripts/layout.
+ let window = NSWindow(
+ contentRect: NSRect(x: -10_000, y: -10_000, width: 420, height: 720),
+ styleMask: [.borderless],
+ backing: .buffered,
+ defer: false
+ )
+ window.isReleasedWhenClosed = false
+ window.alphaValue = 0
+ window.ignoresMouseEvents = true
+ window.contentView = webView
+ window.orderBack(nil)
+
+ self.webView = webView
+ self.hostWindow = window
+ webKitManager.registerExtensionHostWebView(webView, role: .youtubeWatch)
+
+ guard generation == self.loadGeneration else { return }
+ guard let url = URL(string: "https://www.youtube.com/watch?v=\(videoId)") else { return }
+ webKitManager.extensionHostWebViewWillNavigate(webView, to: url)
+ webView.load(URLRequest(url: url))
+ self.logger.info("YouTube Ask (hidden) loading \(videoId, privacy: .public)")
+ }
+
+ /// Sends a question through the hidden YouTube page and streams the answer
+ /// into native `messages`.
+ func ask(_ question: String) {
+ let q = question.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !q.isEmpty else { return }
+ guard let webView else {
+ self.errorMessage = String(localized: "Ask is not ready yet.")
+ return
+ }
+
+ self.errorMessage = nil
+ self.isAnswering = true
+ self.statusMessage = String(localized: "Asking YouTube…")
+ self.messages.append(YouTubeAskMessage(role: .user, text: q))
+
+ let assistant = YouTubeAskMessage(role: .assistant, text: "", isStreaming: true)
+ self.answerMessageId = assistant.id
+ self.messages.append(assistant)
+
+ let escaped = Self.escapeForJSString(q)
+ let js = "window.__kasetAskSubmit && window.__kasetAskSubmit(\"\(escaped)\");"
+ webView.evaluateJavaScript(js) { [weak self] _, error in
+ Task { @MainActor in
+ guard let self else { return }
+ if let error {
+ self.failAnswer(String(localized: "Could not send question: \(error.localizedDescription)"))
+ }
+ }
+ }
+ }
+
+ func askSuggestion(_ text: String) {
+ self.ask(text)
+ }
+
+ func clearConversation(keepIntro: Bool = true) {
+ self.answerMessageId = nil
+ self.isAnswering = false
+ if keepIntro {
+ self.messages = [
+ YouTubeAskMessage(
+ role: .assistant,
+ text: String(localized: "Ask anything about this video. Answers come from YouTube’s web Ask feature.")
+ ),
+ ]
+ } else {
+ self.messages = []
+ }
+ }
+
+ func tearDown(keepMessages: Bool = false) {
+ self.loadGeneration += 1
+ if let webView {
+ webView.stopLoading()
+ webView.navigationDelegate = nil
+ webView.configuration.userContentController.removeScriptMessageHandler(forName: Self.bridgeName)
+ }
+ self.hostWindow?.contentView = nil
+ self.hostWindow?.close()
+ self.hostWindow = nil
+ self.webView = nil
+ self.webKitManager = nil
+ self.isLoadingPage = false
+ self.isPageReady = false
+ self.isAnswering = false
+ self.answerMessageId = nil
+ self.statusMessage = nil
+ if !keepMessages {
+ self.messages = []
+ self.suggestions = []
+ self.videoId = nil
+ self.errorMessage = nil
+ }
+ }
+
+ // MARK: - Bridge handling
+
+ private func handleBridgePayload(_ payload: [String: Any]) {
+ let type = payload["type"] as? String ?? ""
+ switch type {
+ case "ready":
+ self.isLoadingPage = false
+ self.isPageReady = true
+ self.statusMessage = String(localized: "YouTube Ask ready")
+ if let intro = payload["intro"] as? String, !intro.isEmpty {
+ self.replaceOrSetIntro(intro)
+ } else {
+ self.replaceOrSetIntro(
+ String(localized: "Hello! Ask me anything about this video. Pick a suggestion or type your own question.")
+ )
+ }
+
+ case "suggestions":
+ if let list = payload["suggestions"] as? [String] {
+ let cleaned = list
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ if !cleaned.isEmpty {
+ self.suggestions = Array(cleaned.prefix(8))
+ }
+ }
+
+ case "answerDelta":
+ if let text = payload["text"] as? String {
+ self.updateStreamingAnswer(text, done: false)
+ }
+
+ case "answer":
+ let text = (payload["text"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ if text.isEmpty {
+ self.failAnswer(String(localized: "YouTube returned an empty answer. Try again."))
+ } else {
+ self.updateStreamingAnswer(text, done: true)
+ }
+
+ case "status":
+ if let message = payload["message"] as? String {
+ self.statusMessage = message
+ }
+
+ case "error":
+ let message = (payload["message"] as? String)
+ ?? String(localized: "YouTube Ask failed.")
+ if self.isAnswering {
+ self.failAnswer(message)
+ } else {
+ self.errorMessage = message
+ self.isLoadingPage = false
+ self.statusMessage = nil
+ }
+
+ case "fallbackMeta":
+ // Page loaded but Ask UI missing — still provide useful chips/meta.
+ self.isLoadingPage = false
+ self.isPageReady = true
+ self.statusMessage = String(localized: "Using video details from YouTube page")
+ if let title = payload["title"] as? String, !title.isEmpty {
+ self.replaceOrSetIntro(
+ String(localized: "YouTube Ask panel wasn’t available for this account/region. I can still answer from the page details for “\(title)”.")
+ )
+ }
+ if let list = payload["suggestions"] as? [String], !list.isEmpty {
+ self.suggestions = list
+ }
+
+ default:
+ break
+ }
+ }
+
+ private func replaceOrSetIntro(_ text: String) {
+ if let first = self.messages.first, first.role == .assistant, self.messages.count == 1 {
+ self.messages[0] = YouTubeAskMessage(role: .assistant, text: text)
+ } else if self.messages.isEmpty {
+ self.messages = [YouTubeAskMessage(role: .assistant, text: text)]
+ }
+ }
+
+ private func updateStreamingAnswer(_ text: String, done: Bool) {
+ guard let id = self.answerMessageId,
+ let index = self.messages.firstIndex(where: { $0.id == id })
+ else { return }
+ self.messages[index].text = text
+ self.messages[index].isStreaming = !done
+ if done {
+ self.isAnswering = false
+ self.answerMessageId = nil
+ self.statusMessage = String(localized: "Answer from YouTube")
+ }
+ }
+
+ private func failAnswer(_ message: String) {
+ if let id = self.answerMessageId,
+ let index = self.messages.firstIndex(where: { $0.id == id })
+ {
+ self.messages[index].text = message
+ self.messages[index].isStreaming = false
+ } else {
+ self.messages.append(YouTubeAskMessage(role: .assistant, text: message))
+ }
+ self.isAnswering = false
+ self.answerMessageId = nil
+ self.errorMessage = message
+ self.statusMessage = nil
+ }
+
+ private static func escapeForJSString(_ value: String) -> String {
+ value
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ .replacingOccurrences(of: "\n", with: "\\n")
+ .replacingOccurrences(of: "\r", with: "\\r")
+ }
+
+ private static let defaultSuggestions: [String] = [
+ String(localized: "What is this video about?"),
+ String(localized: "Summarize the main points"),
+ String(localized: "Who is this for?"),
+ String(localized: "Key takeaways"),
+ ]
+
+ // MARK: - Injected bridge script
+
+ private static let bridgeScript = #"""
+ (function() {
+ if (window.__kasetAskBridge) return;
+ window.__kasetAskBridge = true;
+
+ function post(payload) {
+ try {
+ window.webkit.messageHandlers.kasetAsk.postMessage(payload);
+ } catch (e) {}
+ }
+
+ function silence() {
+ try {
+ document.querySelectorAll('video, audio').forEach(function(el) {
+ el.muted = true; el.volume = 0;
+ try { el.pause(); } catch (e) {}
+ });
+ var p = document.getElementById('movie_player');
+ if (p) {
+ try { p.mute && p.mute(); } catch (e) {}
+ try { p.pauseVideo && p.pauseVideo(); } catch (e) {}
+ try { p.setVolume && p.setVolume(0); } catch (e) {}
+ }
+ } catch (e) {}
+ }
+
+ function textOf(el) {
+ return (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
+ }
+
+ function findAskButtons() {
+ var out = [];
+ var nodes = document.querySelectorAll('button, a, yt-button-shape, tp-yt-paper-button');
+ for (var i = 0; i < nodes.length; i++) {
+ var el = nodes[i];
+ var label = ((el.getAttribute('aria-label') || '') + ' ' + textOf(el)).toLowerCase();
+ if (label.indexOf('ask') !== -1 || label.indexOf('gemini') !== -1) {
+ out.push(el.querySelector('button') || el);
+ }
+ }
+ return out;
+ }
+
+ function openAsk() {
+ var buttons = findAskButtons();
+ for (var i = 0; i < buttons.length; i++) {
+ try { buttons[i].click(); return true; } catch (e) {}
+ }
+ return false;
+ }
+
+ function panelRoot() {
+ // Engagement panels / dialogs used by Ask
+ var panels = document.querySelectorAll(
+ 'ytd-engagement-panel-section-list-renderer, tp-yt-paper-dialog, ytd-interactive-tabbed-header-renderer, #panels'
+ );
+ for (var i = 0; i < panels.length; i++) {
+ var t = textOf(panels[i]).toLowerCase();
+ if (t.indexOf('ask') !== -1 || t.indexOf('gemini') !== -1 || t.indexOf('question') !== -1) {
+ return panels[i];
+ }
+ }
+ // Fallback: whole secondary column
+ return document.querySelector('#secondary') || document.body;
+ }
+
+ function scrapeSuggestions() {
+ var root = panelRoot() || document;
+ var chips = [];
+ var nodes = root.querySelectorAll(
+ 'button, yt-chip-cloud-chip-renderer, tp-yt-paper-chip, .ytChipShapeButtonReset, ytd-button-renderer'
+ );
+ for (var i = 0; i < nodes.length; i++) {
+ var t = textOf(nodes[i]);
+ if (!t || t.length < 4 || t.length > 80) continue;
+ var low = t.toLowerCase();
+ if (low === 'ask' || low === 'send' || low === 'close' || low === 'learn more') continue;
+ if (low.indexOf('subscribe') !== -1) continue;
+ // Prefer question-like chips
+ if (t.indexOf('?') !== -1 || t.split(' ').length >= 3) {
+ if (chips.indexOf(t) === -1) chips.push(t);
+ }
+ }
+ return chips.slice(0, 8);
+ }
+
+ function scrapeAnswerText() {
+ var root = panelRoot() || document;
+ // Prefer message-like blocks inside the panel
+ var candidates = root.querySelectorAll(
+ 'yt-formatted-string, #content-text, .markdown-inline-block, .ytd-comment-renderer #content-text, p, span'
+ );
+ var best = '';
+ for (var i = 0; i < candidates.length; i++) {
+ var t = textOf(candidates[i]);
+ if (t.length > best.length && t.length > 40 && t.length < 8000) {
+ // Skip chrome
+ var low = t.toLowerCase();
+ if (low.indexOf('skip navigation') !== -1) continue;
+ if (low.indexOf('sign in') === 0) continue;
+ best = t;
+ }
+ }
+ return best;
+ }
+
+ function findInput() {
+ var root = panelRoot() || document;
+ return root.querySelector(
+ 'textarea, input[type="text"], div[contenteditable="true"], #contenteditable-root, [aria-label*="question" i], [aria-label*="Ask" i]'
+ );
+ }
+
+ function setInputValue(el, value) {
+ if (!el) return false;
+ if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') {
+ el.focus();
+ el.textContent = value;
+ el.dispatchEvent(new InputEvent('input', { bubbles: true, data: value }));
+ return true;
+ }
+ var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
+ var desc = Object.getOwnPropertyDescriptor(proto, 'value');
+ if (desc && desc.set) desc.set.call(el, value); else el.value = value;
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ return true;
+ }
+
+ function clickSend() {
+ var root = panelRoot() || document;
+ var buttons = root.querySelectorAll('button, yt-button-shape button, #submit-button');
+ for (var i = 0; i < buttons.length; i++) {
+ var el = buttons[i];
+ var label = ((el.getAttribute('aria-label') || '') + ' ' + textOf(el)).toLowerCase();
+ if (label.indexOf('send') !== -1 || label.indexOf('submit') !== -1 || label === 'ask') {
+ try { el.click(); return true; } catch (e) {}
+ }
+ }
+ // Enter key on input
+ var input = findInput();
+ if (input) {
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
+ input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
+ return true;
+ }
+ return false;
+ }
+
+ var lastAnswer = '';
+ var answerWatch = null;
+
+ window.__kasetAskSubmit = function(question) {
+ silence();
+ openAsk();
+ post({ type: 'status', message: 'Sending to YouTube Ask…' });
+ setTimeout(function() {
+ var input = findInput();
+ if (!input) {
+ // Fallback: answer from page metadata if Ask UI not present
+ var meta = scrapeMeta();
+ var reply = metaFallbackAnswer(question, meta);
+ post({ type: 'answer', text: reply });
+ return;
+ }
+ setInputValue(input, question);
+ setTimeout(function() {
+ clickSend();
+ lastAnswer = scrapeAnswerText();
+ var stable = 0;
+ if (answerWatch) clearInterval(answerWatch);
+ answerWatch = setInterval(function() {
+ silence();
+ var now = scrapeAnswerText();
+ if (now && now !== lastAnswer && now.length > lastAnswer.length) {
+ lastAnswer = now;
+ stable = 0;
+ post({ type: 'answerDelta', text: now });
+ } else if (now && now.length > 20) {
+ stable += 1;
+ if (stable >= 4) {
+ clearInterval(answerWatch);
+ answerWatch = null;
+ post({ type: 'answer', text: now });
+ }
+ } else {
+ stable += 1;
+ if (stable >= 20) {
+ clearInterval(answerWatch);
+ answerWatch = null;
+ var meta = scrapeMeta();
+ post({ type: 'answer', text: metaFallbackAnswer(question, meta) });
+ }
+ }
+ }, 700);
+ }, 400);
+ }, 500);
+ };
+
+ function scrapeMeta() {
+ var title = '';
+ var desc = '';
+ try {
+ title = textOf(document.querySelector('h1.ytd-watch-metadata yt-formatted-string, h1 yt-formatted-string, h1')) || document.title;
+ desc = textOf(document.querySelector('#description-inline-expander, ytd-text-inline-expander, #description')) || '';
+ if (!desc && window.ytInitialPlayerResponse) {
+ desc = (window.ytInitialPlayerResponse.videoDetails && window.ytInitialPlayerResponse.videoDetails.shortDescription) || '';
+ title = title || (window.ytInitialPlayerResponse.videoDetails && window.ytInitialPlayerResponse.videoDetails.title) || '';
+ }
+ } catch (e) {}
+ return { title: title, description: desc.slice(0, 2500) };
+ }
+
+ function metaFallbackAnswer(question, meta) {
+ var q = (question || '').toLowerCase();
+ var title = meta.title || 'This video';
+ var desc = meta.description || '';
+ if (!desc) {
+ return 'I could not open YouTube’s interactive Ask UI for this video (it may be unavailable in this region/account). Title: “' + title + '”. Try again later or open the video on youtube.com to use Ask there.';
+ }
+ if (q.indexOf('summar') !== -1 || q.indexOf('about') !== -1 || q.indexOf('point') !== -1 || q.indexOf('takeaway') !== -1) {
+ return 'Based on the YouTube page for “' + title + '”:\n\n' + desc.slice(0, 900) + (desc.length > 900 ? '…' : '') + '\n\n(YouTube Ask UI was not available; this is from the public video description on youtube.com.)';
+ }
+ return 'From the YouTube page (“' + title + '”):\n\n' + desc.slice(0, 700) + (desc.length > 700 ? '…' : '') + '\n\n(YouTube Ask UI was not available; answer derived from page metadata.)';
+ }
+
+ // Boot sequence
+ silence();
+ // Lighter boot: fewer polls, stop early. Aggressive MutationObservers
+ // on a full YouTube page made the whole app feel laggy.
+ var tries = 0;
+ var boot = setInterval(function() {
+ silence();
+ var opened = openAsk();
+ tries += 1;
+ if (tries === 3 || tries === 8 || opened) {
+ var chips = scrapeSuggestions();
+ if (chips.length) post({ type: 'suggestions', suggestions: chips });
+ }
+ if (opened || tries > 12) {
+ clearInterval(boot);
+ var meta = scrapeMeta();
+ if (opened) {
+ post({ type: 'ready', intro: 'Hello! Ask me anything about this video. Answers come from YouTube’s Ask on the web.' });
+ setTimeout(function() {
+ var more = scrapeSuggestions();
+ if (more.length) post({ type: 'suggestions', suggestions: more });
+ }, 1200);
+ } else {
+ post({
+ type: 'fallbackMeta',
+ title: meta.title,
+ suggestions: [
+ 'What is this video about?',
+ 'Summarize the description',
+ 'Key details from the page'
+ ]
+ });
+ }
+ }
+ }, 900);
+ })();
+ """#
+}
+
+// MARK: - WKScriptMessageHandler
+
+extension YouTubeAskService: WKScriptMessageHandler {
+ nonisolated func userContentController(
+ _: WKUserContentController,
+ didReceive message: WKScriptMessage
+ ) {
+ // Copy payload off the message before hopping to MainActor.
+ let body = message.body as? [String: Any]
+ Task { @MainActor in
+ if let body {
+ self.handleBridgePayload(body)
+ }
+ }
+ }
+}
+
+// MARK: - Navigation
+
+extension YouTubeAskService: WKNavigationDelegate {
+ func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) {
+ self.webKitManager?.extensionHostWebViewDidStartNavigation(webView)
+ }
+
+ func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
+ self.webKitManager?.extensionHostWebViewDidFinishNavigation(webView)
+ // Re-inject readiness after SPA settles
+ webView.evaluateJavaScript(Self.bridgeScript, completionHandler: nil)
+ self.statusMessage = String(localized: "Page loaded — opening Ask…")
+ }
+
+ func webView(_ webView: WKWebView, didFail _: WKNavigation!, withError error: Error) {
+ self.webKitManager?.extensionHostWebViewDidFailNavigation(webView)
+ self.isLoadingPage = false
+ self.errorMessage = error.localizedDescription
+ self.statusMessage = nil
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ didFailProvisionalNavigation _: WKNavigation!,
+ withError error: Error
+ ) {
+ self.webKitManager?.extensionHostWebViewDidFailNavigation(webView)
+ self.isLoadingPage = false
+ self.errorMessage = error.localizedDescription
+ self.statusMessage = nil
+ }
+}
diff --git a/Sources/Kaset/Services/YouTube/YouTubeCourseLibrary.swift b/Sources/Kaset/Services/YouTube/YouTubeCourseLibrary.swift
new file mode 100644
index 000000000..692334c00
--- /dev/null
+++ b/Sources/Kaset/Services/YouTube/YouTubeCourseLibrary.swift
@@ -0,0 +1,459 @@
+import Foundation
+import Observation
+
+// MARK: - YouTubeCourseLibrary
+
+/// Persistent library of courses organized into nested folders, with notes,
+/// resume positions, pins, and simple study stats.
+@MainActor
+@Observable
+final class YouTubeCourseLibrary {
+ static let shared = YouTubeCourseLibrary()
+
+ private(set) var folders: [YouTubeCourseFolder] = []
+ private(set) var courses: [YouTubeCourseCatalogEntry] = []
+ private(set) var notesByPlaylist: [String: [String: YouTubeCourseLessonNote]] = [:]
+ private(set) var resumeByPlaylist: [String: YouTubeCourseResumePosition] = [:]
+ private(set) var completionHistory: [String: Int] = [:]
+
+ private let storageKey = "youtube.course.library.v2"
+ private let legacyStorageKey = "youtube.course.library.v1"
+ private let logger = DiagnosticsLogger.player
+
+ private init() {
+ self.load()
+ }
+
+ // MARK: - Queries
+
+ func folders(in parentId: String?) -> [YouTubeCourseFolder] {
+ self.folders
+ .filter { $0.parentId == parentId }
+ .sorted {
+ $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
+ }
+ }
+
+ func courses(
+ in folderId: String?,
+ filter: YouTubeCourseLibraryFilter = .all,
+ sort: YouTubeCourseLibrarySort = .recent,
+ search: String = ""
+ ) -> [YouTubeCourseCatalogEntry] {
+ var list = self.courses.filter { $0.folderId == folderId }
+
+ let query = search.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !query.isEmpty {
+ list = list.filter {
+ $0.title.localizedCaseInsensitiveContains(query)
+ || ($0.channelName?.localizedCaseInsensitiveContains(query) ?? false)
+ }
+ }
+
+ switch filter {
+ case .all:
+ break
+ case .inProgress:
+ list = list.filter { $0.status == .inProgress }
+ case .completed:
+ list = list.filter { $0.status == .completed }
+ case .notStarted:
+ list = list.filter { $0.status == .notStarted }
+ case .pinned:
+ list = list.filter(\.isPinned)
+ }
+
+ list.sort { lhs, rhs in
+ if lhs.isPinned != rhs.isPinned { return lhs.isPinned && !rhs.isPinned }
+ switch sort {
+ case .recent:
+ return lhs.lastOpenedAt > rhs.lastOpenedAt
+ case .progress:
+ return lhs.progressFraction > rhs.progressFraction
+ case .title:
+ return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending
+ case .duration:
+ return lhs.totalDurationSeconds > rhs.totalDurationSeconds
+ }
+ }
+ return list
+ }
+
+ func folder(id: String) -> YouTubeCourseFolder? {
+ self.folders.first { $0.id == id }
+ }
+
+ func course(playlistId: String) -> YouTubeCourseCatalogEntry? {
+ self.courses.first { $0.playlistId == playlistId }
+ }
+
+ /// Best course to resume (pinned incomplete, else most recent incomplete).
+ var continueLearningCourse: YouTubeCourseCatalogEntry? {
+ let incomplete = self.courses.filter { $0.status != .completed }
+ return incomplete.first(where: \.isPinned)
+ ?? incomplete.sorted { $0.lastOpenedAt > $1.lastOpenedAt }.first
+ }
+
+ /// Breadcrumb path from root → folder (inclusive).
+ func path(to folderId: String?) -> [YouTubeCourseFolder] {
+ guard let folderId else { return [] }
+ var path: [YouTubeCourseFolder] = []
+ var current = self.folder(id: folderId)
+ var guardCount = 0
+ while let folder = current, guardCount < 32 {
+ path.insert(folder, at: 0)
+ current = folder.parentId.flatMap { self.folder(id: $0) }
+ guardCount += 1
+ }
+ return path
+ }
+
+ var isEmpty: Bool {
+ self.courses.isEmpty && self.folders.isEmpty
+ }
+
+ var totalCourses: Int { self.courses.count }
+ var completedCourses: Int { self.courses.filter { $0.status == .completed }.count }
+ var inProgressCourses: Int { self.courses.filter { $0.status == .inProgress }.count }
+
+ var lessonsCompletedThisWeek: Int {
+ let calendar = Calendar.current
+ let today = calendar.startOfDay(for: Date())
+ guard let weekAgo = calendar.date(byAdding: .day, value: -6, to: today) else { return 0 }
+ let formatter = Self.dayFormatter
+ return self.completionHistory.reduce(into: 0) { sum, pair in
+ guard let day = formatter.date(from: pair.key), day >= weekAgo else { return }
+ sum += pair.value
+ }
+ }
+
+ // MARK: - Folder mutations
+
+ @discardableResult
+ func createFolder(name: String, parentId: String? = nil) -> YouTubeCourseFolder {
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ let folder = YouTubeCourseFolder(
+ name: trimmed.isEmpty ? String(localized: "New Folder") : trimmed,
+ parentId: parentId
+ )
+ self.folders.append(folder)
+ self.persist()
+ return folder
+ }
+
+ func renameFolder(id: String, name: String) {
+ guard let index = self.folders.firstIndex(where: { $0.id == id }) else { return }
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ self.folders[index].name = trimmed
+ self.persist()
+ }
+
+ func deleteFolder(id: String) {
+ let parentId = self.folder(id: id)?.parentId
+ for index in self.folders.indices where self.folders[index].parentId == id {
+ self.folders[index].parentId = parentId
+ }
+ for index in self.courses.indices where self.courses[index].folderId == id {
+ self.courses[index].folderId = parentId
+ }
+ self.folders.removeAll { $0.id == id }
+ self.persist()
+ }
+
+ func moveCourse(playlistId: String, toFolderId folderId: String?) {
+ guard let index = self.courses.firstIndex(where: { $0.playlistId == playlistId }) else {
+ return
+ }
+ self.courses[index].folderId = folderId
+ self.persist()
+ }
+
+ func moveFolder(id: String, toParentId parentId: String?) {
+ guard let index = self.folders.firstIndex(where: { $0.id == id }) else { return }
+ if let parentId {
+ if parentId == id { return }
+ if self.descendantFolderIds(of: id).contains(parentId) { return }
+ }
+ self.folders[index].parentId = parentId
+ self.persist()
+ }
+
+ func removeCourse(playlistId: String) {
+ self.courses.removeAll { $0.playlistId == playlistId }
+ self.notesByPlaylist[playlistId] = nil
+ self.resumeByPlaylist[playlistId] = nil
+ self.persist()
+ }
+
+ func togglePin(playlistId: String) {
+ guard let index = self.courses.firstIndex(where: { $0.playlistId == playlistId }) else {
+ return
+ }
+ self.courses[index].isPinned.toggle()
+ self.persist()
+ }
+
+ func setWeeklyGoal(playlistId: String, goal: Int?) {
+ guard let index = self.courses.firstIndex(where: { $0.playlistId == playlistId }) else {
+ return
+ }
+ self.courses[index].weeklyGoal = goal.flatMap { $0 > 0 ? $0 : nil }
+ self.persist()
+ }
+
+ func resetProgress(playlistId: String) {
+ UserDefaults.standard.removeObject(forKey: "youtube.course.completed.\(playlistId)")
+ if let index = self.courses.firstIndex(where: { $0.playlistId == playlistId }) {
+ self.courses[index].completedCount = 0
+ self.courses[index].lastLessonVideoId = nil
+ self.courses[index].lastLessonTitle = nil
+ }
+ self.resumeByPlaylist[playlistId] = nil
+ self.persist()
+ }
+
+ // MARK: - Course registration / progress
+
+ func registerOrUpdateCourse(
+ playlist: YouTubePlaylist,
+ lessons: [YouTubeVideo],
+ completedCount: Int,
+ lastLesson: YouTubeVideo? = nil
+ ) {
+ let usable = lessons.filter { !$0.isShort }
+ let thumbs = usable.compactMap(\.thumbnailURL)
+ let primary = playlist.thumbnailURL ?? thumbs.first
+ let lessonThumbs = Array(thumbs.prefix(6))
+ let duration = YouTubeCourseDuration.totalSeconds(in: usable)
+
+ if let index = self.courses.firstIndex(where: { $0.playlistId == playlist.playlistId }) {
+ self.courses[index].title = playlist.title
+ self.courses[index].channelName = playlist.channelName
+ if let primary {
+ self.courses[index].thumbnailURLString = primary.absoluteString
+ }
+ if !lessonThumbs.isEmpty {
+ self.courses[index].lessonThumbnailURLStrings = lessonThumbs.map(\.absoluteString)
+ }
+ self.courses[index].lessonCount = usable.count
+ self.courses[index].completedCount = completedCount
+ self.courses[index].totalDurationSeconds = duration
+ self.courses[index].lastOpenedAt = Date()
+ if let lastLesson {
+ self.courses[index].lastLessonVideoId = lastLesson.videoId
+ self.courses[index].lastLessonTitle = lastLesson.title
+ }
+ } else {
+ var entry = YouTubeCourseCatalogEntry(
+ playlistId: playlist.playlistId,
+ title: playlist.title,
+ channelName: playlist.channelName,
+ thumbnailURL: primary,
+ lessonThumbnailURLs: lessonThumbs,
+ lessonCount: usable.count,
+ completedCount: completedCount,
+ totalDurationSeconds: duration
+ )
+ if let lastLesson {
+ entry.lastLessonVideoId = lastLesson.videoId
+ entry.lastLessonTitle = lastLesson.title
+ }
+ self.courses.insert(entry, at: 0)
+ }
+ self.persist()
+ }
+
+ func updateProgress(playlistId: String, completedCount: Int, lessonCount: Int? = nil) {
+ guard let index = self.courses.firstIndex(where: { $0.playlistId == playlistId }) else {
+ return
+ }
+ let previous = self.courses[index].completedCount
+ self.courses[index].completedCount = completedCount
+ if let lessonCount {
+ self.courses[index].lessonCount = lessonCount
+ }
+ self.courses[index].lastOpenedAt = Date()
+ if completedCount > previous {
+ self.recordCompletion(count: completedCount - previous)
+ }
+ self.persist()
+ }
+
+ func updateLastLesson(playlistId: String, video: YouTubeVideo) {
+ guard let index = self.courses.firstIndex(where: { $0.playlistId == playlistId }) else {
+ return
+ }
+ self.courses[index].lastLessonVideoId = video.videoId
+ self.courses[index].lastLessonTitle = video.title
+ self.courses[index].lastOpenedAt = Date()
+ self.persist()
+ }
+
+ // MARK: - Notes
+
+ func note(playlistId: String, videoId: String) -> String {
+ self.notesByPlaylist[playlistId]?[videoId]?.text ?? ""
+ }
+
+ func setNote(playlistId: String, videoId: String, text: String) {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ var map = self.notesByPlaylist[playlistId] ?? [:]
+ if trimmed.isEmpty {
+ map[videoId] = nil
+ } else {
+ map[videoId] = YouTubeCourseLessonNote(videoId: videoId, text: trimmed)
+ }
+ if map.isEmpty {
+ self.notesByPlaylist[playlistId] = nil
+ } else {
+ self.notesByPlaylist[playlistId] = map
+ }
+ self.persist()
+ }
+
+ func hasNote(playlistId: String, videoId: String) -> Bool {
+ !(self.notesByPlaylist[playlistId]?[videoId]?.text.isEmpty ?? true)
+ }
+
+ func noteCount(playlistId: String) -> Int {
+ self.notesByPlaylist[playlistId]?.values.filter { !$0.text.isEmpty }.count ?? 0
+ }
+
+ // MARK: - Resume positions
+
+ func resumePosition(playlistId: String) -> YouTubeCourseResumePosition? {
+ self.resumeByPlaylist[playlistId]
+ }
+
+ func saveResume(playlistId: String, videoId: String, seconds: Double) {
+ guard seconds.isFinite, seconds >= 0 else { return }
+ // Don't store tiny positions as "resume".
+ guard seconds >= 5 else { return }
+ self.resumeByPlaylist[playlistId] = YouTubeCourseResumePosition(
+ videoId: videoId,
+ seconds: seconds,
+ updatedAt: Date()
+ )
+ self.persist()
+ }
+
+ func clearResume(playlistId: String) {
+ self.resumeByPlaylist[playlistId] = nil
+ self.persist()
+ }
+
+ // MARK: - Stats
+
+ private func recordCompletion(count: Int) {
+ let key = Self.dayFormatter.string(from: Date())
+ self.completionHistory[key, default: 0] += max(0, count)
+ }
+
+ private static let dayFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.calendar = Calendar(identifier: .gregorian)
+ f.locale = Locale(identifier: "en_US_POSIX")
+ f.dateFormat = "yyyy-MM-dd"
+ return f
+ }()
+
+ // MARK: - Helpers
+
+ private func descendantFolderIds(of folderId: String) -> Set {
+ var result: Set = []
+ var queue = [folderId]
+ while let current = queue.popLast() {
+ let children = self.folders.filter { $0.parentId == current }.map(\.id)
+ for child in children where !result.contains(child) {
+ result.insert(child)
+ queue.append(child)
+ }
+ }
+ return result
+ }
+
+ // MARK: - Persistence
+
+ private func load() {
+ let data = UserDefaults.standard.data(forKey: self.storageKey)
+ ?? UserDefaults.standard.data(forKey: self.legacyStorageKey)
+ guard let data else { return }
+ do {
+ let snapshot = try JSONDecoder().decode(YouTubeCourseLibrarySnapshot.self, from: data)
+ self.folders = snapshot.folders
+ self.courses = snapshot.courses
+ self.notesByPlaylist = snapshot.notesByPlaylist
+ self.resumeByPlaylist = snapshot.resumeByPlaylist
+ self.completionHistory = snapshot.completionHistory
+ } catch {
+ self.logger.error("Failed to load course library: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+
+ private func persist() {
+ let snapshot = YouTubeCourseLibrarySnapshot(
+ folders: self.folders,
+ courses: self.courses,
+ notesByPlaylist: self.notesByPlaylist,
+ resumeByPlaylist: self.resumeByPlaylist,
+ completionHistory: self.completionHistory
+ )
+ do {
+ let data = try JSONEncoder().encode(snapshot)
+ UserDefaults.standard.set(data, forKey: self.storageKey)
+ } catch {
+ self.logger.error("Failed to save course library: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+}
+
+// MARK: - Catalog entry Codable (defaults for new fields)
+
+extension YouTubeCourseCatalogEntry {
+ enum CodingKeys: String, CodingKey {
+ case playlistId, title, channelName, thumbnailURLString
+ case lessonThumbnailURLStrings, lessonCount, completedCount
+ case folderId, lastOpenedAt, createdAt
+ case isPinned, lastLessonVideoId, lastLessonTitle
+ case totalDurationSeconds, weeklyGoal
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ self.playlistId = try c.decode(String.self, forKey: .playlistId)
+ self.title = try c.decode(String.self, forKey: .title)
+ self.channelName = try c.decodeIfPresent(String.self, forKey: .channelName)
+ self.thumbnailURLString = try c.decodeIfPresent(String.self, forKey: .thumbnailURLString)
+ self.lessonThumbnailURLStrings = try c.decodeIfPresent([String].self, forKey: .lessonThumbnailURLStrings) ?? []
+ self.lessonCount = try c.decodeIfPresent(Int.self, forKey: .lessonCount) ?? 0
+ self.completedCount = try c.decodeIfPresent(Int.self, forKey: .completedCount) ?? 0
+ self.folderId = try c.decodeIfPresent(String.self, forKey: .folderId)
+ self.lastOpenedAt = try c.decodeIfPresent(Date.self, forKey: .lastOpenedAt) ?? Date()
+ self.createdAt = try c.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
+ self.isPinned = try c.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false
+ self.lastLessonVideoId = try c.decodeIfPresent(String.self, forKey: .lastLessonVideoId)
+ self.lastLessonTitle = try c.decodeIfPresent(String.self, forKey: .lastLessonTitle)
+ self.totalDurationSeconds = try c.decodeIfPresent(Int.self, forKey: .totalDurationSeconds) ?? 0
+ self.weeklyGoal = try c.decodeIfPresent(Int.self, forKey: .weeklyGoal)
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var c = encoder.container(keyedBy: CodingKeys.self)
+ try c.encode(self.playlistId, forKey: .playlistId)
+ try c.encode(self.title, forKey: .title)
+ try c.encodeIfPresent(self.channelName, forKey: .channelName)
+ try c.encodeIfPresent(self.thumbnailURLString, forKey: .thumbnailURLString)
+ try c.encode(self.lessonThumbnailURLStrings, forKey: .lessonThumbnailURLStrings)
+ try c.encode(self.lessonCount, forKey: .lessonCount)
+ try c.encode(self.completedCount, forKey: .completedCount)
+ try c.encodeIfPresent(self.folderId, forKey: .folderId)
+ try c.encode(self.lastOpenedAt, forKey: .lastOpenedAt)
+ try c.encode(self.createdAt, forKey: .createdAt)
+ try c.encode(self.isPinned, forKey: .isPinned)
+ try c.encodeIfPresent(self.lastLessonVideoId, forKey: .lastLessonVideoId)
+ try c.encodeIfPresent(self.lastLessonTitle, forKey: .lastLessonTitle)
+ try c.encode(self.totalDurationSeconds, forKey: .totalDurationSeconds)
+ try c.encodeIfPresent(self.weeklyGoal, forKey: .weeklyGoal)
+ }
+}
diff --git a/Sources/Kaset/Services/YouTube/YouTubeCourseSession.swift b/Sources/Kaset/Services/YouTube/YouTubeCourseSession.swift
new file mode 100644
index 000000000..d834bf5ec
--- /dev/null
+++ b/Sources/Kaset/Services/YouTube/YouTubeCourseSession.swift
@@ -0,0 +1,241 @@
+import Foundation
+import Observation
+
+// MARK: - YouTubeCourseSession
+
+/// Active “course mode” while watching a YouTube playlist as a learning path.
+///
+/// Start it by opening any playlist and playing a video from it. The watch
+/// page then shows a course sidebar: completed lessons, the current topic,
+/// and what’s next — with progress persisted per playlist.
+@MainActor
+@Observable
+final class YouTubeCourseSession {
+ static let shared = YouTubeCourseSession()
+
+ // MARK: - Active course
+
+ /// Whether a playlist is currently being taken as a course.
+ private(set) var isActive = false
+
+ private(set) var playlistId: String?
+ private(set) var playlistTitle: String = ""
+ private(set) var playlistChannelName: String?
+ private(set) var lessons: [YouTubeVideo] = []
+
+ /// Index of the lesson currently being watched (if it is in the course).
+ private(set) var currentIndex: Int?
+
+ /// Video IDs marked complete for the active playlist (this session + disk).
+ private(set) var completedVideoIds: Set = []
+
+ /// Whether the course sidebar should be visible in the watch layout.
+ var isSidebarVisible = true
+
+ private let defaults = UserDefaults.standard
+ private let logger = DiagnosticsLogger.player
+
+ private init() {}
+
+ // MARK: - Derived
+
+ var lessonCount: Int {
+ self.lessons.count
+ }
+
+ var completedCount: Int {
+ self.lessons.filter { self.completedVideoIds.contains($0.videoId) }.count
+ }
+
+ var progressFraction: Double {
+ guard self.lessonCount > 0 else { return 0 }
+ return Double(self.completedCount) / Double(self.lessonCount)
+ }
+
+ var currentLesson: YouTubeVideo? {
+ guard let currentIndex, self.lessons.indices.contains(currentIndex) else {
+ return nil
+ }
+ return self.lessons[currentIndex]
+ }
+
+ var nextLesson: YouTubeVideo? {
+ guard let currentIndex else { return self.lessons.first }
+ let next = currentIndex + 1
+ guard self.lessons.indices.contains(next) else { return nil }
+ return self.lessons[next]
+ }
+
+ var previousLesson: YouTubeVideo? {
+ guard let currentIndex, currentIndex > 0 else { return nil }
+ return self.lessons[currentIndex - 1]
+ }
+
+ /// Remaining lessons after the current one (for player up-next).
+ var remainingLessons: [YouTubeVideo] {
+ guard let currentIndex else { return self.lessons }
+ let start = currentIndex + 1
+ guard start < self.lessons.count else { return [] }
+ return Array(self.lessons[start...])
+ }
+
+ func isCompleted(_ videoId: String) -> Bool {
+ self.completedVideoIds.contains(videoId)
+ }
+
+ func isCurrent(_ videoId: String) -> Bool {
+ self.currentLesson?.videoId == videoId
+ }
+
+ func index(of videoId: String) -> Int? {
+ self.lessons.firstIndex { $0.videoId == videoId }
+ }
+
+ // MARK: - Lifecycle
+
+ /// Begins course mode from a playlist detail, focusing the given video.
+ func start(
+ playlist: YouTubePlaylist,
+ lessons: [YouTubeVideo],
+ startingAt video: YouTubeVideo
+ ) {
+ let ordered = lessons.filter { !$0.isShort && !$0.videoId.isEmpty }
+ guard !ordered.isEmpty else {
+ self.logger.warning("Course: cannot start — playlist has no usable videos")
+ return
+ }
+
+ self.playlistId = playlist.playlistId
+ self.playlistTitle = playlist.title
+ self.playlistChannelName = playlist.channelName
+ self.lessons = ordered
+ self.completedVideoIds = Self.loadCompleted(playlistId: playlist.playlistId)
+ self.currentIndex = ordered.firstIndex { $0.videoId == video.videoId } ?? 0
+ self.isActive = true
+ self.isSidebarVisible = true
+ // Register / refresh in the Courses library (sidebar catalog).
+ YouTubeCourseLibrary.shared.registerOrUpdateCourse(
+ playlist: playlist,
+ lessons: ordered,
+ completedCount: self.completedCount,
+ lastLesson: video
+ )
+ self.logger.info(
+ "Course started: \(playlist.playlistId, privacy: .public) (\(ordered.count) lessons)"
+ )
+ }
+
+ /// Updates the current lesson when the player is on a course video.
+ /// Returns whether the video belongs to the active course.
+ @discardableResult
+ func syncCurrent(to video: YouTubeVideo) -> Bool {
+ guard self.isActive else { return false }
+ guard let index = self.index(of: video.videoId) else { return false }
+ self.currentIndex = index
+ if let playlistId {
+ YouTubeCourseLibrary.shared.updateLastLesson(playlistId: playlistId, video: video)
+ }
+ return true
+ }
+
+ // MARK: - Notes / resume (delegates to library)
+
+ func note(for videoId: String) -> String {
+ guard let playlistId else { return "" }
+ return YouTubeCourseLibrary.shared.note(playlistId: playlistId, videoId: videoId)
+ }
+
+ func setNote(for videoId: String, text: String) {
+ guard let playlistId else { return }
+ YouTubeCourseLibrary.shared.setNote(playlistId: playlistId, videoId: videoId, text: text)
+ }
+
+ func hasNote(for videoId: String) -> Bool {
+ guard let playlistId else { return false }
+ return YouTubeCourseLibrary.shared.hasNote(playlistId: playlistId, videoId: videoId)
+ }
+
+ func saveResume(videoId: String, seconds: Double) {
+ guard let playlistId else { return }
+ YouTubeCourseLibrary.shared.saveResume(
+ playlistId: playlistId,
+ videoId: videoId,
+ seconds: seconds
+ )
+ }
+
+ func resumeSeconds(for videoId: String) -> Double? {
+ guard let playlistId,
+ let resume = YouTubeCourseLibrary.shared.resumePosition(playlistId: playlistId),
+ resume.videoId == videoId
+ else { return nil }
+ return resume.seconds
+ }
+
+ /// Marks a lesson complete (when the user finishes watching it).
+ func markCompleted(videoId: String?) {
+ guard self.isActive, let videoId, !videoId.isEmpty else { return }
+ guard self.lessons.contains(where: { $0.videoId == videoId }) else { return }
+ guard !self.completedVideoIds.contains(videoId) else { return }
+ self.completedVideoIds.insert(videoId)
+ if let playlistId {
+ Self.saveCompleted(self.completedVideoIds, playlistId: playlistId)
+ YouTubeCourseLibrary.shared.updateProgress(
+ playlistId: playlistId,
+ completedCount: self.completedCount,
+ lessonCount: self.lessonCount
+ )
+ }
+ self.logger.info("Course lesson completed: \(videoId, privacy: .public)")
+ }
+
+ /// Marks complete by index (e.g. user toggles from the sidebar).
+ func toggleCompleted(videoId: String) {
+ guard self.isActive else { return }
+ guard self.lessons.contains(where: { $0.videoId == videoId }) else { return }
+ if self.completedVideoIds.contains(videoId) {
+ self.completedVideoIds.remove(videoId)
+ } else {
+ self.completedVideoIds.insert(videoId)
+ }
+ if let playlistId {
+ Self.saveCompleted(self.completedVideoIds, playlistId: playlistId)
+ YouTubeCourseLibrary.shared.updateProgress(
+ playlistId: playlistId,
+ completedCount: self.completedCount,
+ lessonCount: self.lessonCount
+ )
+ }
+ }
+
+ /// Leaves course mode (sidebar goes away; progress stays on disk).
+ func endCourse() {
+ self.isActive = false
+ self.playlistId = nil
+ self.playlistTitle = ""
+ self.playlistChannelName = nil
+ self.lessons = []
+ self.currentIndex = nil
+ self.completedVideoIds = []
+ self.isSidebarVisible = true
+ self.logger.info("Course ended")
+ }
+
+ // MARK: - Persistence
+
+ private static func storageKey(playlistId: String) -> String {
+ "youtube.course.completed.\(playlistId)"
+ }
+
+ private static func loadCompleted(playlistId: String) -> Set {
+ let key = Self.storageKey(playlistId: playlistId)
+ if let array = UserDefaults.standard.array(forKey: key) as? [String] {
+ return Set(array)
+ }
+ return []
+ }
+
+ private static func saveCompleted(_ ids: Set, playlistId: String) {
+ UserDefaults.standard.set(Array(ids), forKey: Self.storageKey(playlistId: playlistId))
+ }
+}
diff --git a/Sources/Kaset/Utilities/DiagnosticsLogger.swift b/Sources/Kaset/Utilities/DiagnosticsLogger.swift
index af534ccc4..c502d3ad4 100644
--- a/Sources/Kaset/Utilities/DiagnosticsLogger.swift
+++ b/Sources/Kaset/Utilities/DiagnosticsLogger.swift
@@ -24,6 +24,9 @@ enum DiagnosticsLogger {
/// Logger for AI/Foundation Models-related events.
static let ai = Logger(subsystem: "com.sertacozercan.Kaset", category: "AI")
+ /// Logger for yt-dlp / media download events.
+ static let download = Logger(subsystem: "com.sertacozercan.Kaset", category: "Download")
+
/// Logger for haptic feedback-related events.
static let haptic = Logger(subsystem: "com.sertacozercan.Kaset", category: "Haptic")
diff --git a/Sources/Kaset/Views/SearchView.swift b/Sources/Kaset/Views/SearchView.swift
index 4a7dbacde..834ae47c3 100644
--- a/Sources/Kaset/Views/SearchView.swift
+++ b/Sources/Kaset/Views/SearchView.swift
@@ -10,8 +10,10 @@ struct SearchView: View {
@Environment(SongLikeStatusManager.self) private var likeStatusManager
@Environment(AuthService.self) private var authService
@Environment(LibraryViewModel.self) private var libraryViewModel: LibraryViewModel?
+ @Environment(\.usesLegacyMacOS15UI) private var usesLegacyMacOS15UI
@State private var navigationPath = NavigationPath()
@State private var networkMonitor = NetworkMonitor.shared
+ @Namespace private var searchGlassNamespace
/// External trigger for focusing the search field (from keyboard shortcut).
@Binding var focusTrigger: Bool
@@ -73,18 +75,18 @@ struct SearchView: View {
private var searchBar: some View {
VStack(spacing: 12) {
- // Keep suggestions as an overlay of the field itself. If the dropdown participates
- // in the search bar's layout, macOS 26 glass materialization can render a
- // second transient plate during updates. Anchoring it as an overlay gives the
- // autocomplete menu a single visual owner and prevents duplicate dropdowns.
- self.searchField
- .overlay(alignment: .top) {
- if self.viewModel.showSuggestions {
- self.suggestionsDropdown
- .padding(.top, 44) // Below search field
+ // The search field and suggestions are wrapped in a glass container
+ // so on macOS 26+ the dropdown materializes from the search field.
+ CompatGlassContainer(spacing: 4) {
+ self.searchField
+ .overlay(alignment: .top) {
+ if self.viewModel.showSuggestions {
+ self.suggestionsDropdown
+ .padding(.top, 44) // Below search field
+ }
}
- }
- .zIndex(1)
+ .zIndex(1)
+ }
// Filter chips
if self.viewModel.shouldShowFilters {
@@ -160,6 +162,7 @@ struct SearchView: View {
}
.padding(10)
.compatGlass(in: .capsule)
+ .compatGlassID("searchField", in: self.searchGlassNamespace)
}
private var suggestionsDropdown: some View {
@@ -172,13 +175,17 @@ struct SearchView: View {
}
}
}
- .compatGlass(in: .rect(cornerRadius: 8))
- .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
+ .compatGlass(in: .rect(cornerRadius: 12))
+ .compatGlassID("searchSuggestions", in: self.searchGlassNamespace)
+ .compatGlassTransition(.materialize)
+ .shadow(color: .black.opacity(0.12), radius: 12, x: 0, y: 6)
.accessibilityIdentifier(AccessibilityID.Search.suggestionsContainer)
}
private func suggestionRow(_ suggestion: SearchSuggestion, index: Int) -> some View {
- Button {
+ let isHighlighted = index == self.selectedSuggestionIndex
+
+ return Button {
self.viewModel.selectSuggestion(suggestion)
} label: {
HStack(spacing: 12) {
@@ -198,7 +205,12 @@ struct SearchView: View {
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
- .background(index == self.selectedSuggestionIndex ? Color.accentColor.opacity(0.15) : Color.clear)
+ .background(
+ isHighlighted
+ ? Color.accentColor.opacity(0.12)
+ : Color.clear,
+ in: .rect(cornerRadius: 6)
+ )
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -216,8 +228,10 @@ struct SearchView: View {
}
private func filterChip(_ filter: SearchViewModel.SearchFilter) -> some View {
- Button {
- withAnimation(AppAnimation.spring) {
+ let isSelected = self.viewModel.selectedFilter == filter
+
+ return Button {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
self.viewModel.selectedFilter = filter
}
} label: {
@@ -225,11 +239,20 @@ struct SearchView: View {
.font(.system(size: 12, weight: .medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
- .background(self.viewModel.selectedFilter == filter ? Color.accentColor : Color.secondary.opacity(0.2))
- .foregroundStyle(self.viewModel.selectedFilter == filter ? .white : .primary)
+ .foregroundStyle(isSelected ? .white : .primary)
+ .background {
+ if isSelected {
+ Capsule()
+ .fill(Color.accentColor)
+ .compatGlassID("filterChip", in: self.searchGlassNamespace)
+ } else {
+ Capsule()
+ .fill(Color.secondary.opacity(0.15))
+ }
+ }
.clipShape(.capsule)
}
- .buttonStyle(.chip(isSelected: self.viewModel.selectedFilter == filter))
+ .buttonStyle(.chip(isSelected: isSelected))
}
// MARK: - Content
diff --git a/Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift b/Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift
index 152e9f15d..b455fb0bd 100644
--- a/Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift
+++ b/Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift
@@ -10,12 +10,18 @@ import SwiftUI
/// explicit selection state, so use a plain button row with our own selected
/// background and brand-accent symbol instead of relying on `NavigationLink`'s
/// active/inactive source-list styling.
+///
+/// On macOS 26+ the selected row renders a Liquid Glass highlight that morphs
+/// naturally between rows on selection change.
struct KasetSidebarRow: View {
let title: String
let systemImage: String
let isSelected: Bool
let action: () -> Void
+ @Environment(\.usesLegacyMacOS15UI) private var usesLegacyMacOS15UI
+ @Namespace private var rowNamespace
+
var body: some View {
Button(action: self.action) {
Label {
@@ -27,21 +33,30 @@ struct KasetSidebarRow: View {
.foregroundStyle(PackageResourceLookup.brandAccent)
}
.frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 10)
- .padding(.vertical, 5)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
.background(self.selectionBackground)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
- .listRowInsets(EdgeInsets(top: 1, leading: 10, bottom: 1, trailing: 10))
+ .listRowInsets(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
.accessibilityAddTraits(self.isSelected ? .isSelected : [])
}
@ViewBuilder
private var selectionBackground: some View {
if self.isSelected {
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .fill(Color.secondary.opacity(0.22))
+ if !self.usesLegacyMacOS15UI, #available(macOS 26.0, *) {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(.thinMaterial)
+ .glassEffect(
+ .regular.tint(PackageResourceLookup.brandAccent.opacity(0.15)),
+ in: .rect(cornerRadius: 8)
+ )
+ } else {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(Color.secondary.opacity(0.22))
+ }
}
}
}
diff --git a/Sources/Kaset/Views/SharedViews/SidebarFooterView.swift b/Sources/Kaset/Views/SharedViews/SidebarFooterView.swift
index 8f817792f..55b3e7ac0 100644
--- a/Sources/Kaset/Views/SharedViews/SidebarFooterView.swift
+++ b/Sources/Kaset/Views/SharedViews/SidebarFooterView.swift
@@ -8,13 +8,15 @@ struct SidebarFooterView: View {
var body: some View {
VStack(spacing: 0) {
Divider()
- .opacity(0.3)
-
- SidebarProfileView()
+ .opacity(0.5)
SourceToggleView()
.padding(.horizontal, 12)
- .padding(.bottom, 8)
+ .padding(.top, 10)
+ .padding(.bottom, 6)
+
+ SidebarProfileView()
+ .padding(.bottom, 4)
}
}
}
diff --git a/Sources/Kaset/Views/YouTube/AmbientVideoBackdrop.swift b/Sources/Kaset/Views/YouTube/AmbientVideoBackdrop.swift
index 8bf755b55..d86b831ea 100644
--- a/Sources/Kaset/Views/YouTube/AmbientVideoBackdrop.swift
+++ b/Sources/Kaset/Views/YouTube/AmbientVideoBackdrop.swift
@@ -89,7 +89,9 @@ struct AmbientVideoBackdrop: View {
if self.style == .soft || self.reduceMotion {
self.auroraLayer(size: geo.size, time: nil)
} else {
- TimelineView(.animation) { timeline in
+ // ~12 fps is plenty for a soft glow and avoids TimelineView
+ // redrawing the whole watch hierarchy at display refresh.
+ TimelineView(.periodic(from: .now, by: 1.0 / 12.0)) { timeline in
self.auroraLayer(
size: geo.size,
time: timeline.date.timeIntervalSinceReferenceDate
@@ -130,7 +132,8 @@ struct AmbientVideoBackdrop: View {
Self.aurora(colors: upperColors, size: size, time: time, colorScheme: self.colorScheme, intensity: intensity)
.opacity(blend)
}
- .animation(.easeInOut(duration: 0.7), value: self.liveFraction)
+ // No explicit animation on liveFraction — quantized steps + periodic
+ // TimelineView already smooth enough; continuous animation was laggy.
}
}
diff --git a/Sources/Kaset/Views/YouTube/SourceToggleView.swift b/Sources/Kaset/Views/YouTube/SourceToggleView.swift
index 2df4ca96b..0bfb43de8 100644
--- a/Sources/Kaset/Views/YouTube/SourceToggleView.swift
+++ b/Sources/Kaset/Views/YouTube/SourceToggleView.swift
@@ -6,6 +6,9 @@ import SwiftUI
/// YouTube Music and YouTube video experiences.
///
/// Lives at the bottom of both sidebars, just above the profile section.
+/// On macOS 26+ the sliding highlight uses Liquid Glass morphing via
+/// `GlassEffectContainer` + `glassEffectID` so the selected segment flows
+/// between positions with the system's glass material transition.
struct SourceToggleView: View {
private static let brandAccent = PackageResourceLookup.brandAccent
@@ -19,27 +22,82 @@ struct SourceToggleView: View {
var body: some View {
Group {
if !self.usesLegacyMacOS15UI, #available(macOS 26.0, *) {
- self.segments
- .glassEffect(.regular.interactive(), in: .capsule)
+ self.liquidGlassSegments
} else {
- self.segments
- .background(.quaternary.opacity(0.5), in: Capsule())
+ self.legacySegments
}
}
.accessibilityIdentifier(AccessibilityID.SourceToggle.container)
.accessibilityElement(children: .contain)
}
- private var segments: some View {
+ // MARK: - macOS 26+ Liquid Glass
+
+ @available(macOS 26.0, *)
+ private var liquidGlassSegments: some View {
+ GlassEffectContainer(spacing: 2) {
+ HStack(spacing: 2) {
+ ForEach(AppSource.allCases) { source in
+ self.liquidGlassSegment(for: source)
+ }
+ }
+ .padding(3)
+ }
+ .glassEffect(.regular.interactive(), in: .capsule)
+ }
+
+ @available(macOS 26.0, *)
+ private func liquidGlassSegment(for source: AppSource) -> some View {
+ let isSelected = self.settings.appSource == source
+
+ return Button {
+ self.select(source)
+ } label: {
+ HStack(spacing: 5) {
+ Image(systemName: source.icon)
+ .font(.system(size: 10, weight: .semibold))
+ Text(source.displayName)
+ .font(.system(size: 11, weight: .semibold))
+ .lineLimit(1)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 6)
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(isSelected ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
+ .background {
+ if isSelected {
+ Capsule()
+ .fill(Self.brandAccent)
+ .glassEffectID("sourceToggleHighlight", in: self.segmentNamespace)
+ .matchedGeometryEffect(id: "selectedSegment", in: self.segmentNamespace)
+ }
+ }
+ .glassEffectID(source.rawValue, in: self.segmentNamespace)
+ .accessibilityIdentifier(AccessibilityID.SourceToggle.segment(for: source))
+ .accessibilityLabel(source.displayName)
+ .accessibilityAddTraits(isSelected ? [.isSelected] : [])
+ .help(
+ source == .music
+ ? String(localized: "Switch to YouTube Music")
+ : String(localized: "Switch to YouTube")
+ )
+ }
+
+ // MARK: - Legacy macOS 15
+
+ private var legacySegments: some View {
HStack(spacing: 2) {
ForEach(AppSource.allCases) { source in
- self.segment(for: source)
+ self.legacySegment(for: source)
}
}
.padding(3)
+ .background(.quaternary.opacity(0.5), in: Capsule())
}
- private func segment(for source: AppSource) -> some View {
+ private func legacySegment(for source: AppSource) -> some View {
let isSelected = self.settings.appSource == source
return Button {
@@ -75,6 +133,8 @@ struct SourceToggleView: View {
)
}
+ // MARK: - Actions
+
private func select(_ source: AppSource) {
guard self.settings.appSource != source else { return }
@@ -82,7 +142,7 @@ struct SourceToggleView: View {
// Pause a docked video in place — don't hand it to the pop-out.
self.youtubePlayer.prepareForSourceSwitch()
}
- withAnimation(.easeInOut(duration: 0.2)) {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
self.settings.appSource = source
}
HapticService.navigation()
diff --git a/Sources/Kaset/Views/YouTube/YouTubeAskNativePanel.swift b/Sources/Kaset/Views/YouTube/YouTubeAskNativePanel.swift
new file mode 100644
index 000000000..314fee337
--- /dev/null
+++ b/Sources/Kaset/Views/YouTube/YouTubeAskNativePanel.swift
@@ -0,0 +1,233 @@
+import SwiftUI
+
+// MARK: - YouTubeAskNativePanel
+
+/// Native “Ask about this video” UI (YouTube-style). Answers are fetched from
+/// YouTube’s website in a **hidden** WebView — the webpage is never shown.
+struct YouTubeAskNativePanel: View {
+ let videoId: String
+ var onClose: (() -> Void)?
+
+ @Environment(WebKitManager.self) private var webKitManager
+ @Environment(AuthService.self) private var authService
+ @State private var askService = YouTubeAskService.shared
+ @State private var draft = ""
+ @FocusState private var inputFocused: Bool
+
+ private static let brandAccent = PackageResourceLookup.brandAccent
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ self.header
+ Divider().opacity(0.4)
+ self.chat
+ if !self.askService.suggestions.isEmpty, !self.askService.isAnswering {
+ Divider().opacity(0.3)
+ self.suggestions
+ }
+ Divider().opacity(0.4)
+ self.composer
+ self.footer
+ }
+ .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .strokeBorder(.primary.opacity(0.08), lineWidth: 1)
+ }
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.askWebPanel)
+ .task(id: self.videoId) {
+ self.askService.prepare(
+ videoId: self.videoId,
+ webKitManager: self.webKitManager,
+ usesCookieFreeDataStore: self.authService.shouldUseCookieFreePlaybackDataStore
+ )
+ }
+ .onDisappear {
+ // Tear down the hidden YouTube page when Ask closes — leaving a
+ // full watch WebView + mutation observers alive is a major lag source.
+ self.askService.tearDown(keepMessages: true)
+ }
+ }
+
+ // MARK: - Header
+
+ private var header: some View {
+ HStack(spacing: 8) {
+ Image(systemName: "sparkle")
+ .foregroundStyle(Self.brandAccent)
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Ask about this video", comment: "Native Ask panel title")
+ .font(.headline)
+ if let status = self.askService.statusMessage {
+ Text(status)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ Spacer()
+ if self.askService.isLoadingPage || self.askService.isAnswering {
+ ProgressView()
+ .controlSize(.small)
+ }
+ if let onClose {
+ Button(action: onClose) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(String(localized: "Close Ask"))
+ }
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ }
+
+ // MARK: - Chat
+
+ private var chat: some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 12) {
+ ForEach(self.askService.messages) { message in
+ self.bubble(message)
+ .id(message.id)
+ }
+ if let error = self.askService.errorMessage, !self.askService.isAnswering {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .padding(.horizontal, 4)
+ }
+ }
+ .padding(14)
+ }
+ .onChange(of: self.askService.messages.count) { _, _ in
+ if let last = self.askService.messages.last?.id {
+ withAnimation {
+ proxy.scrollTo(last, anchor: .bottom)
+ }
+ }
+ }
+ .onChange(of: self.askService.messages.last?.text) { _, _ in
+ if let last = self.askService.messages.last?.id {
+ proxy.scrollTo(last, anchor: .bottom)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ @ViewBuilder
+ private func bubble(_ message: YouTubeAskMessage) -> some View {
+ HStack {
+ if message.role == .user { Spacer(minLength: 36) }
+ VStack(alignment: .leading, spacing: 6) {
+ Text(message.text.isEmpty && message.isStreaming
+ ? String(localized: "Thinking…")
+ : message.text)
+ .font(.callout)
+ .textSelection(.enabled)
+ .foregroundStyle(.primary)
+ if message.isStreaming {
+ ProgressView()
+ .controlSize(.mini)
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background {
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .fill(message.role == .user
+ ? Self.brandAccent.opacity(0.18)
+ : Color.primary.opacity(0.06))
+ }
+ if message.role == .assistant { Spacer(minLength: 20) }
+ }
+ }
+
+ // MARK: - Suggestions
+
+ private var suggestions: some View {
+ VStack(alignment: .trailing, spacing: 8) {
+ ForEach(self.askService.suggestions, id: \.self) { suggestion in
+ Button {
+ self.askService.askSuggestion(suggestion)
+ } label: {
+ Text(suggestion)
+ .font(.caption.weight(.medium))
+ .multilineTextAlignment(.trailing)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .frame(maxWidth: 320, alignment: .trailing)
+ .background(.quaternary.opacity(0.55), in: Capsule())
+ }
+ .buttonStyle(.plain)
+ .disabled(self.askService.isAnswering || self.askService.isLoadingPage)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ }
+
+ // MARK: - Composer
+
+ private var composer: some View {
+ HStack(spacing: 8) {
+ TextField(
+ String(localized: "Ask a question…"),
+ text: self.$draft,
+ axis: .vertical
+ )
+ .textFieldStyle(.plain)
+ .lineLimit(1 ... 4)
+ .focused(self.$inputFocused)
+ .onSubmit { self.send() }
+ .disabled(self.askService.isAnswering)
+
+ Button(action: self.send) {
+ Image(systemName: "arrow.up.circle.fill")
+ .font(.system(size: 28))
+ .foregroundStyle(
+ self.canSend ? Self.brandAccent : Color.secondary.opacity(0.4)
+ )
+ .symbolRenderingMode(.hierarchical)
+ }
+ .buttonStyle(.plain)
+ .disabled(!self.canSend)
+ .accessibilityLabel(String(localized: "Send"))
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.aiAskButton)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ }
+
+ private var canSend: Bool {
+ !self.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ && !self.askService.isAnswering
+ && !self.askService.isLoadingPage
+ }
+
+ private func send() {
+ let q = self.draft.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !q.isEmpty else { return }
+ self.draft = ""
+ self.askService.ask(q)
+ }
+
+ private var footer: some View {
+ HStack {
+ Text("Ask · YouTube (web)", comment: "Native ask footer")
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("Results fetched from youtube.com — not shown as a page", comment: "Native ask source")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+ .padding(.horizontal, 12)
+ .padding(.bottom, 10)
+ }
+}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeAskWebPanel.swift b/Sources/Kaset/Views/YouTube/YouTubeAskWebPanel.swift
new file mode 100644
index 000000000..9ad5e2c8e
--- /dev/null
+++ b/Sources/Kaset/Views/YouTube/YouTubeAskWebPanel.swift
@@ -0,0 +1,308 @@
+import AppKit
+import SwiftUI
+import WebKit
+
+// MARK: - YouTubeAskWebPanel
+
+/// YouTube’s real **“Ask about this video”** panel (Gemini on youtube.com),
+/// embedded via WKWebView with the user’s login cookies.
+///
+/// This intentionally does **not** use Apple Intelligence. Results come from
+/// YouTube’s web product: we load the watch page, mute/pause its player so it
+/// doesn’t fight Kaset’s native playback WebView, then open the Ask UI.
+struct YouTubeAskWebPanel: View {
+ let videoId: String
+ var onClose: (() -> Void)?
+
+ @Environment(WebKitManager.self) private var webKitManager
+ @Environment(AuthService.self) private var authService
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 8) {
+ Image(systemName: "sparkle")
+ .foregroundStyle(PackageResourceLookup.brandAccent)
+ Text("Ask about this video", comment: "YouTube Ask panel title")
+ .font(.headline)
+ Spacer()
+ if let onClose {
+ Button(action: onClose) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(String(localized: "Close Ask"))
+ }
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+
+ Divider().opacity(0.4)
+
+ YouTubeAskWebView(
+ videoId: self.videoId,
+ webKitManager: self.webKitManager,
+ usesCookieFreeDataStore: self.authService.shouldUseCookieFreePlaybackDataStore
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+ HStack(spacing: 6) {
+ Text("Powered by YouTube · Ask", comment: "Web Ask footer")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ Spacer()
+ Text("Results from youtube.com", comment: "Web Ask source note")
+ .font(.caption2)
+ .foregroundStyle(.quaternary)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ }
+ .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .strokeBorder(.primary.opacity(0.08), lineWidth: 1)
+ }
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.askWebPanel)
+ }
+}
+
+// MARK: - WKWebView host
+
+/// Loads `youtube.com/watch?v=` with shared cookies and injects scripts to
+/// open Ask while keeping the embedded page silent.
+struct YouTubeAskWebView: NSViewRepresentable {
+ let videoId: String
+ let webKitManager: WebKitManager
+ var usesCookieFreeDataStore: Bool = false
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(videoId: self.videoId, webKitManager: self.webKitManager)
+ }
+
+ func makeNSView(context: Context) -> WKWebView {
+ let configuration = self.webKitManager.createWebViewConfiguration(
+ websiteDataStore: self.usesCookieFreeDataStore ? .nonPersistent() : nil
+ )
+ // Never autoplay in the Ask surface — Kaset’s player owns audio.
+ configuration.mediaTypesRequiringUserActionForPlayback = [.all]
+ configuration.allowsAirPlayForMediaPlayback = false
+
+ let controller = configuration.userContentController
+ let muteScript = WKUserScript(
+ source: Self.muteAndOpenAskScript,
+ injectionTime: .atDocumentEnd,
+ forMainFrameOnly: true
+ )
+ controller.addUserScript(muteScript)
+
+ let webView = WKWebView(frame: .zero, configuration: configuration)
+ webView.navigationDelegate = context.coordinator
+ webView.customUserAgent = WebKitManager.userAgent
+ webView.setValue(false, forKey: "drawsBackground")
+ #if DEBUG
+ webView.isInspectable = true
+ #endif
+
+ context.coordinator.webView = webView
+ self.webKitManager.registerExtensionHostWebView(webView, role: .youtubeWatch)
+ context.coordinator.loadVideo()
+ return webView
+ }
+
+ func updateNSView(_ webView: WKWebView, context: Context) {
+ if context.coordinator.videoId != self.videoId {
+ context.coordinator.videoId = self.videoId
+ context.coordinator.loadVideo()
+ }
+ }
+
+ static func dismantleNSView(_ nsView: WKWebView, coordinator: Coordinator) {
+ nsView.stopLoading()
+ nsView.navigationDelegate = nil
+ coordinator.webView = nil
+ }
+
+ // MARK: - Injected script
+
+ /// Mutes/pauses any media, repeatedly tries to open YouTube’s Ask entry
+ /// points, and nudges the layout toward a panel-first view.
+ private static let muteAndOpenAskScript = #"""
+ (function() {
+ if (window.__kasetAskHooked) return;
+ window.__kasetAskHooked = true;
+
+ function silence() {
+ try {
+ document.querySelectorAll('video, audio').forEach(function(el) {
+ el.muted = true;
+ el.volume = 0;
+ try { el.pause(); } catch (e) {}
+ el.removeAttribute('autoplay');
+ });
+ var p = document.getElementById('movie_player');
+ if (p) {
+ try { if (typeof p.mute === 'function') p.mute(); } catch (e) {}
+ try { if (typeof p.pauseVideo === 'function') p.pauseVideo(); } catch (e) {}
+ try { if (typeof p.setVolume === 'function') p.setVolume(0); } catch (e) {}
+ }
+ } catch (e) {}
+ }
+
+ function clickAsk() {
+ // Various YouTube UI generations for the Ask / Gemini entry point.
+ var selectors = [
+ 'button[aria-label*="Ask" i]',
+ 'button[aria-label*="Gemini" i]',
+ 'yt-button-shape button[aria-label*="Ask" i]',
+ '#flexible-item-buttons button[aria-label*="Ask" i]',
+ 'ytd-button-renderer a[aria-label*="Ask" i]',
+ 'button[title*="Ask" i]',
+ // Text content fallbacks
+ ];
+ for (var i = 0; i < selectors.length; i++) {
+ var nodes = document.querySelectorAll(selectors[i]);
+ for (var j = 0; j < nodes.length; j++) {
+ var el = nodes[j];
+ var label = ((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || '')).toLowerCase();
+ if (label.indexOf('ask') !== -1 || label.indexOf('gemini') !== -1) {
+ try { el.click(); return true; } catch (e) {}
+ }
+ }
+ }
+ // Walk buttons by visible text
+ var buttons = document.querySelectorAll('button, a, yt-button-shape, tp-yt-paper-button');
+ for (var k = 0; k < buttons.length; k++) {
+ var t = (buttons[k].innerText || buttons[k].textContent || '').trim().toLowerCase();
+ if (t === 'ask' || t.indexOf('ask ') === 0 || t.indexOf('gemini') !== -1) {
+ try {
+ var clickable = buttons[k].querySelector('button') || buttons[k];
+ clickable.click();
+ return true;
+ } catch (e) {}
+ }
+ }
+ return false;
+ }
+
+ function applyPanelFocusCSS() {
+ if (document.getElementById('kaset-ask-style')) return;
+ var style = document.createElement('style');
+ style.id = 'kaset-ask-style';
+ style.textContent = `
+ /* Soften the full watch chrome so Ask panel is the focus */
+ ytd-masthead, #masthead-container, #guide, #guide-content,
+ ytd-mini-guide-renderer, #chips-wrapper, ytd-feed-nudge-renderer {
+ display: none !important;
+ }
+ ytd-watch-flexy[flexy] #columns {
+ max-width: 100% !important;
+ }
+ /* Prefer secondary column (comments / engagement panels) */
+ #secondary {
+ width: 100% !important;
+ max-width: 100% !important;
+ min-width: 0 !important;
+ }
+ #primary {
+ max-width: 0 !important;
+ min-width: 0 !important;
+ overflow: hidden !important;
+ opacity: 0.15 !important;
+ pointer-events: none !important;
+ }
+ ytd-watch-flexy {
+ --ytd-watch-flexy-sidebar-width: 100%;
+ }
+ /* Engagement / Ask panel sheets */
+ ytd-engagement-panel-section-list-renderer[target-id*="ask" i],
+ ytd-engagement-panel-section-list-renderer[visibility="ENGAGEMENT_PANEL_VISIBILITY_EXPANDED"] {
+ display: block !important;
+ width: 100% !important;
+ }
+ `;
+ document.documentElement.appendChild(style);
+ }
+
+ silence();
+ applyPanelFocusCSS();
+
+ var tries = 0;
+ var timer = setInterval(function() {
+ silence();
+ applyPanelFocusCSS();
+ var opened = clickAsk();
+ tries += 1;
+ if (opened || tries > 40) {
+ clearInterval(timer);
+ // Keep silencing for a bit after open in case media restarts.
+ var silenceTimer = setInterval(silence, 800);
+ setTimeout(function() { clearInterval(silenceTimer); }, 12000);
+ }
+ }, 600);
+
+ // Mutation observer for late-loading buttons / panels
+ try {
+ var mo = new MutationObserver(function() {
+ silence();
+ if (tries < 25) clickAsk();
+ });
+ mo.observe(document.documentElement, { childList: true, subtree: true });
+ } catch (e) {}
+ })();
+ """#
+
+ // MARK: - Coordinator
+
+ @MainActor
+ final class Coordinator: NSObject, WKNavigationDelegate {
+ var videoId: String
+ let webKitManager: WebKitManager
+ weak var webView: WKWebView?
+ private let logger = DiagnosticsLogger.webKit
+
+ init(videoId: String, webKitManager: WebKitManager) {
+ self.videoId = videoId
+ self.webKitManager = webKitManager
+ }
+
+ func loadVideo() {
+ guard let webView else { return }
+ let urlString = "https://www.youtube.com/watch?v=\(self.videoId)"
+ guard let url = URL(string: urlString) else { return }
+ self.logger.info("YouTube Ask WebView loading \(self.videoId, privacy: .public)")
+ self.webKitManager.extensionHostWebViewWillNavigate(webView, to: url)
+ webView.load(URLRequest(url: url))
+ }
+
+ func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) {
+ self.webKitManager.extensionHostWebViewDidStartNavigation(webView)
+ }
+
+ func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
+ self.webKitManager.extensionHostWebViewDidFinishNavigation(webView)
+ // Re-run open-ask after navigation settles.
+ webView.evaluateJavaScript(YouTubeAskWebView.muteAndOpenAskScript, completionHandler: nil)
+ }
+
+ func webView(_ webView: WKWebView, didFail _: WKNavigation!, withError error: Error) {
+ self.webKitManager.extensionHostWebViewDidFailNavigation(webView)
+ self.logger.error("YouTube Ask navigation failed: \(error.localizedDescription, privacy: .public)")
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ didFailProvisionalNavigation _: WKNavigation!,
+ withError error: Error
+ ) {
+ self.webKitManager.extensionHostWebViewDidFailNavigation(webView)
+ self.logger.error("YouTube Ask provisional fail: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+}
+
+// MARK: - Accessibility
+
+extension AccessibilityID.YouTubeContent {
+ static let askWebPanel = "youtubeContent.askWebPanel"
+}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeContentView.swift b/Sources/Kaset/Views/YouTube/YouTubeContentView.swift
index 31aa2c179..1b7af7e82 100644
--- a/Sources/Kaset/Views/YouTube/YouTubeContentView.swift
+++ b/Sources/Kaset/Views/YouTube/YouTubeContentView.swift
@@ -34,6 +34,16 @@ struct YouTubeContentView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
+ // Background download HUD (real-time % / progress) — survives navigation.
+ .overlay(alignment: .topTrailing) {
+ YouTubeDownloadHUD()
+ }
+ // In-app mini player: slides up from the bottom when a video collapses
+ // via the back gesture, sitting above the YouTubePlayerBar capsule.
+ .safeAreaInset(edge: .bottom, spacing: 0) {
+ YouTubeInAppMiniPlayer()
+ .animation(.spring(response: 0.38, dampingFraction: 0.85), value: self.youtubePlayer.surfaceLocation)
+ }
.environment(self.store)
// Reconcile on (re)mount: switching to the Music source unmounts this
// view, so the observers below can't see a floating video that finishes
@@ -101,8 +111,9 @@ struct YouTubeContentView: View {
self.store.navigationPath.append(YouTubeRoute.watch(video))
}
- /// Docks a popped-out video back into a watch view: adopts the one that
- /// is already open for this video, or pushes a fresh watch route.
+ /// Expands a mini-player or floating-window video back into a watch view:
+ /// adopts the one that is already open for this video, or pushes a fresh
+ /// watch route.
private func handlePopInRequest(_ request: YouTubeVideo?) {
guard let video = request else { return }
defer {
@@ -153,6 +164,8 @@ struct YouTubeContentView: View {
YouTubePlaylistDetailView(playlistId: "WL", client: self.store.client)
case .playlists:
YouTubePlaylistsView(viewModel: self.store.playlists)
+ case .courses:
+ YouTubeCoursesView()
case .history:
YouTubeHistoryView(viewModel: self.store.history)
}
@@ -174,6 +187,7 @@ enum YouTubeNavigationItem: String, Hashable, CaseIterable, Identifiable {
case likedVideos = "Liked Videos"
case watchLater = "Watch Later"
case playlists = "Playlists"
+ case courses = "Courses"
case history = "History"
var id: String {
@@ -198,6 +212,8 @@ enum YouTubeNavigationItem: String, Hashable, CaseIterable, Identifiable {
String(localized: "Watch Later")
case .playlists:
String(localized: "Playlists")
+ case .courses:
+ String(localized: "Courses")
case .history:
String(localized: "History")
}
@@ -221,6 +237,8 @@ enum YouTubeNavigationItem: String, Hashable, CaseIterable, Identifiable {
"clock"
case .playlists:
"list.and.film"
+ case .courses:
+ "list.bullet.rectangle.portrait.fill"
case .history:
"clock.arrow.circlepath"
}
@@ -228,7 +246,8 @@ enum YouTubeNavigationItem: String, Hashable, CaseIterable, Identifiable {
var requiresSignIn: Bool {
switch self {
- case .home, .search, .explore, .shorts:
+ case .home, .search, .explore, .shorts, .courses:
+ // Courses library is local (playlists played as courses); no login required.
false
case .subscriptions, .likedVideos, .watchLater, .playlists, .history:
true
diff --git a/Sources/Kaset/Views/YouTube/YouTubeCourseSidebar.swift b/Sources/Kaset/Views/YouTube/YouTubeCourseSidebar.swift
new file mode 100644
index 000000000..4b6fba49e
--- /dev/null
+++ b/Sources/Kaset/Views/YouTube/YouTubeCourseSidebar.swift
@@ -0,0 +1,366 @@
+import SwiftUI
+
+// MARK: - YouTubeCourseSidebar
+
+/// Course outline while watching: curriculum, progress, notes, resume badges.
+struct YouTubeCourseSidebar: View {
+ private static let brandAccent = PackageResourceLookup.brandAccent
+
+ @State private var course = YouTubeCourseSession.shared
+ @State private var library = YouTubeCourseLibrary.shared
+ @Environment(YouTubePlayerService.self) private var youtubePlayer
+ var onSelectLesson: (YouTubeVideo) -> Void
+
+ @State private var noteDraft = ""
+ @State private var showNotes = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ self.header
+ Divider().opacity(0.4)
+ self.progressSection
+ Divider().opacity(0.4)
+ if self.showNotes {
+ self.notesEditor
+ Divider().opacity(0.4)
+ }
+ self.lessonList
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .background(.quaternary.opacity(0.2), in: RoundedRectangle(cornerRadius: 14))
+ .overlay {
+ RoundedRectangle(cornerRadius: 14)
+ .strokeBorder(.primary.opacity(0.06), lineWidth: 1)
+ }
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.courseSidebar)
+ .onAppear {
+ self.reloadNoteDraft()
+ }
+ .onChange(of: self.course.currentIndex) { _, _ in
+ self.reloadNoteDraft()
+ }
+ }
+
+ // MARK: - Header
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 8) {
+ Image(systemName: "list.bullet.rectangle.portrait.fill")
+ .foregroundStyle(Self.brandAccent)
+ Text("Course", comment: "Course sidebar title")
+ .font(.headline)
+ Spacer()
+
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ self.showNotes.toggle()
+ }
+ } label: {
+ Image(systemName: self.showNotes ? "note.text" : "square.and.pencil")
+ .foregroundStyle(self.showNotes ? Self.brandAccent : .secondary)
+ }
+ .buttonStyle(.plain)
+ .help(String(localized: "Lesson notes"))
+
+ if let playlistId = self.course.playlistId {
+ Button {
+ self.library.togglePin(playlistId: playlistId)
+ } label: {
+ Image(systemName: self.library.course(playlistId: playlistId)?.isPinned == true
+ ? "pin.fill"
+ : "pin")
+ .foregroundStyle(
+ self.library.course(playlistId: playlistId)?.isPinned == true
+ ? Self.brandAccent
+ : .secondary
+ )
+ }
+ .buttonStyle(.plain)
+ .help(String(localized: "Pin course"))
+ }
+
+ Button {
+ withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) {
+ self.course.endCourse()
+ }
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .help(String(localized: "Exit course mode"))
+ }
+
+ Text(self.course.playlistTitle)
+ .font(.subheadline.weight(.semibold))
+ .lineLimit(2)
+
+ if let channel = self.course.playlistChannelName, !channel.isEmpty {
+ Text(channel)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .padding(14)
+ }
+
+ // MARK: - Progress
+
+ private var progressSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text(
+ String(
+ localized: "\(self.course.completedCount) of \(self.course.lessonCount) completed"
+ )
+ )
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(Int((self.course.progressFraction * 100).rounded()))%")
+ .font(.caption.weight(.bold).monospacedDigit())
+ .foregroundStyle(Self.brandAccent)
+ }
+
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ Capsule().fill(.primary.opacity(0.08))
+ Capsule()
+ .fill(Self.brandAccent)
+ .frame(width: max(4, geo.size.width * self.course.progressFraction))
+ }
+ }
+ .frame(height: 6)
+
+ let remainingSeconds = self.course.remainingLessons.reduce(0) {
+ $0 + YouTubeCourseDuration.seconds(from: $1.lengthText)
+ }
+ if remainingSeconds > 0 {
+ Text(
+ String(
+ localized: "About \(YouTubeCourseDuration.format(seconds: remainingSeconds)) left"
+ )
+ )
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+
+ if let next = self.course.nextLesson {
+ HStack(spacing: 6) {
+ Image(systemName: "arrow.right.circle.fill")
+ .foregroundStyle(Self.brandAccent)
+ .font(.caption)
+ Text("Next: \(next.title)", comment: "Next course lesson preview")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ } else if self.course.completedCount == self.course.lessonCount, self.course.lessonCount > 0 {
+ Label(String(localized: "Course complete — nice work!"), systemImage: "checkmark.seal.fill")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.green)
+ }
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ }
+
+ // MARK: - Notes
+
+ private var notesEditor: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text(String(localized: "Notes for this lesson"))
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Spacer()
+ if let lesson = self.course.currentLesson {
+ Text(lesson.title)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+ }
+ }
+ TextEditor(text: self.$noteDraft)
+ .font(.callout)
+ .frame(minHeight: 80, maxHeight: 120)
+ .scrollContentBackground(.hidden)
+ .padding(8)
+ .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
+ .onChange(of: self.noteDraft) { _, newValue in
+ if let id = self.course.currentLesson?.videoId {
+ self.course.setNote(for: id, text: newValue)
+ }
+ }
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 10)
+ }
+
+ private func reloadNoteDraft() {
+ if let id = self.course.currentLesson?.videoId {
+ self.noteDraft = self.course.note(for: id)
+ } else {
+ self.noteDraft = ""
+ }
+ }
+
+ // MARK: - Lessons
+
+ private var lessonList: some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 4) {
+ ForEach(Array(self.course.lessons.enumerated()), id: \.element.videoId) { index, lesson in
+ YouTubeCourseLessonRow(
+ index: index + 1,
+ lesson: lesson,
+ isCurrent: self.course.isCurrent(lesson.videoId),
+ isCompleted: self.course.isCompleted(lesson.videoId),
+ hasNote: self.course.hasNote(for: lesson.videoId),
+ resumeSeconds: self.course.resumeSeconds(for: lesson.videoId),
+ onSelect: { self.onSelectLesson(lesson) },
+ onToggleComplete: {
+ self.course.toggleCompleted(videoId: lesson.videoId)
+ }
+ )
+ .id(lesson.videoId)
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 8)
+ }
+ .onAppear { self.scrollToCurrent(proxy) }
+ .onChange(of: self.course.currentIndex) { _, _ in
+ self.scrollToCurrent(proxy)
+ }
+ }
+ }
+
+ private func scrollToCurrent(_ proxy: ScrollViewProxy) {
+ guard let id = self.course.currentLesson?.videoId else { return }
+ withAnimation(.easeInOut(duration: 0.25)) {
+ proxy.scrollTo(id, anchor: .center)
+ }
+ }
+}
+
+// MARK: - Lesson row
+
+private struct YouTubeCourseLessonRow: View {
+ private static let brandAccent = PackageResourceLookup.brandAccent
+
+ let index: Int
+ let lesson: YouTubeVideo
+ let isCurrent: Bool
+ let isCompleted: Bool
+ let hasNote: Bool
+ let resumeSeconds: Double?
+ let onSelect: () -> Void
+ let onToggleComplete: () -> Void
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 10) {
+ Button(action: self.onToggleComplete) {
+ Image(systemName: self.statusIcon)
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(self.statusColor)
+ .frame(width: 22, height: 22)
+ }
+ .buttonStyle(.plain)
+
+ Button(action: self.onSelect) {
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 6) {
+ Text(String(format: "%02d", self.index))
+ .font(.caption2.weight(.bold).monospacedDigit())
+ .foregroundStyle(self.isCurrent ? Self.brandAccent : .secondary)
+ if self.isCurrent {
+ Text("NOW", comment: "Current course lesson badge")
+ .font(.system(size: 9, weight: .bold))
+ .padding(.horizontal, 5)
+ .padding(.vertical, 1)
+ .background(Self.brandAccent.opacity(0.18), in: Capsule())
+ .foregroundStyle(Self.brandAccent)
+ }
+ if self.hasNote {
+ Image(systemName: "note.text")
+ .font(.system(size: 9, weight: .bold))
+ .foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 0)
+ if let length = self.lesson.lengthText {
+ Text(length)
+ .font(.caption2.monospacedDigit())
+ .foregroundStyle(.tertiary)
+ }
+ }
+
+ Text(self.lesson.title)
+ .font(.system(size: 12, weight: self.isCurrent ? .semibold : .regular))
+ .foregroundStyle(self.isCurrent ? .primary : .secondary)
+ .multilineTextAlignment(.leading)
+ .lineLimit(3)
+
+ if let resume = self.resumeSeconds, resume > 5, !self.isCompleted {
+ Text(
+ String(
+ localized: "Resume at \(Self.format(seconds: resume))"
+ )
+ )
+ .font(.caption2)
+ .foregroundStyle(Self.brandAccent)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 8)
+ .background {
+ if self.isCurrent {
+ RoundedRectangle(cornerRadius: 10)
+ .fill(Self.brandAccent.opacity(0.10))
+ }
+ }
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.courseLessonRow)
+ }
+
+ private var statusIcon: String {
+ if self.isCompleted {
+ "checkmark.circle.fill"
+ } else if self.isCurrent {
+ "play.circle.fill"
+ } else {
+ "circle"
+ }
+ }
+
+ private var statusColor: Color {
+ if self.isCompleted {
+ .green
+ } else if self.isCurrent {
+ Self.brandAccent
+ } else {
+ .secondary
+ }
+ }
+
+ private static func format(seconds: Double) -> String {
+ let total = Int(seconds)
+ let m = total / 60
+ let s = total % 60
+ return String(format: "%d:%02d", m, s)
+ }
+}
+
+// MARK: - Accessibility
+
+extension AccessibilityID.YouTubeContent {
+ static let courseSidebar = "youtubeContent.courseSidebar"
+ static let courseLessonRow = "youtubeContent.courseLessonRow"
+}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeCoursesView.swift b/Sources/Kaset/Views/YouTube/YouTubeCoursesView.swift
new file mode 100644
index 000000000..912a744eb
--- /dev/null
+++ b/Sources/Kaset/Views/YouTube/YouTubeCoursesView.swift
@@ -0,0 +1,589 @@
+import SwiftUI
+
+// MARK: - YouTubeCoursesView
+
+/// Courses library: nested folders, search/filter/sort, continue learning,
+/// pins, progress, and course cards with video thumbnails.
+struct YouTubeCoursesView: View {
+ private static let brandAccent = PackageResourceLookup.brandAccent
+ private static let columns = [
+ GridItem(.adaptive(minimum: 200, maximum: 280), spacing: 16),
+ ]
+
+ @State private var library = YouTubeCourseLibrary.shared
+ @State private var currentFolderId: String?
+ @State private var searchText = ""
+ @State private var filter: YouTubeCourseLibraryFilter = .all
+ @State private var sort: YouTubeCourseLibrarySort = .recent
+ @State private var showNewFolderAlert = false
+ @State private var newFolderName = ""
+ @State private var renameTarget: YouTubeCourseFolder?
+ @State private var renameText = ""
+ @State private var moveCourseTarget: YouTubeCourseCatalogEntry?
+ @State private var showMoveSheet = false
+ @State private var goalTarget: YouTubeCourseCatalogEntry?
+ @State private var goalText = ""
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ self.headerStack
+ Divider().opacity(0.35)
+ self.browser
+ }
+ .navigationTitle(Text("Courses", comment: "Courses library title"))
+ .searchable(text: self.$searchText, prompt: String(localized: "Search courses"))
+ .alert(String(localized: "New Folder"), isPresented: self.$showNewFolderAlert) {
+ TextField(String(localized: "Folder name"), text: self.$newFolderName)
+ Button(String(localized: "Cancel"), role: .cancel) { self.newFolderName = "" }
+ Button(String(localized: "Create")) {
+ _ = self.library.createFolder(name: self.newFolderName, parentId: self.currentFolderId)
+ self.newFolderName = ""
+ }
+ }
+ .alert(
+ String(localized: "Rename Folder"),
+ isPresented: Binding(
+ get: { self.renameTarget != nil },
+ set: { if !$0 { self.renameTarget = nil } }
+ )
+ ) {
+ TextField(String(localized: "Folder name"), text: self.$renameText)
+ Button(String(localized: "Cancel"), role: .cancel) { self.renameTarget = nil }
+ Button(String(localized: "Rename")) {
+ if let id = self.renameTarget?.id {
+ self.library.renameFolder(id: id, name: self.renameText)
+ }
+ self.renameTarget = nil
+ }
+ }
+ .alert(
+ String(localized: "Weekly goal"),
+ isPresented: Binding(
+ get: { self.goalTarget != nil },
+ set: { if !$0 { self.goalTarget = nil } }
+ )
+ ) {
+ TextField(String(localized: "Lessons per week"), text: self.$goalText)
+ Button(String(localized: "Cancel"), role: .cancel) { self.goalTarget = nil }
+ Button(String(localized: "Save")) {
+ if let id = self.goalTarget?.playlistId {
+ self.library.setWeeklyGoal(playlistId: id, goal: Int(self.goalText))
+ }
+ self.goalTarget = nil
+ }
+ } message: {
+ Text("How many lessons do you want to finish each week?", comment: "Weekly goal prompt")
+ }
+ .sheet(isPresented: self.$showMoveSheet) {
+ if let course = self.moveCourseTarget {
+ CourseMoveFolderSheet(
+ course: course,
+ folders: self.library.folders,
+ onPick: { folderId in
+ self.library.moveCourse(playlistId: course.playlistId, toFolderId: folderId)
+ self.showMoveSheet = false
+ self.moveCourseTarget = nil
+ },
+ onCancel: {
+ self.showMoveSheet = false
+ self.moveCourseTarget = nil
+ }
+ )
+ }
+ }
+ }
+
+ // MARK: - Header
+
+ private var headerStack: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ if self.currentFolderId == nil {
+ self.statsRow
+ if let continueCourse = self.library.continueLearningCourse, self.searchText.isEmpty {
+ self.continueCard(continueCourse)
+ }
+ }
+ self.toolbar
+ self.filterSortRow
+ }
+ .padding(.horizontal, DetailContentLayout.horizontalInset)
+ .padding(.top, 12)
+ .padding(.bottom, 10)
+ }
+
+ private var statsRow: some View {
+ HStack(spacing: 10) {
+ CourseStatChip(
+ title: String(localized: "Courses"),
+ value: "\(self.library.totalCourses)",
+ systemImage: "square.stack.3d.up.fill"
+ )
+ CourseStatChip(
+ title: String(localized: "In progress"),
+ value: "\(self.library.inProgressCourses)",
+ systemImage: "play.circle.fill"
+ )
+ CourseStatChip(
+ title: String(localized: "Done"),
+ value: "\(self.library.completedCourses)",
+ systemImage: "checkmark.seal.fill"
+ )
+ CourseStatChip(
+ title: String(localized: "This week"),
+ value: "\(self.library.lessonsCompletedThisWeek)",
+ systemImage: "flame.fill"
+ )
+ Spacer(minLength: 0)
+ }
+ }
+
+ private func continueCard(_ course: YouTubeCourseCatalogEntry) -> some View {
+ NavigationLink(value: YouTubeRoute.playlist(playlistId: course.playlistId)) {
+ HStack(spacing: 14) {
+ CachedAsyncImage(
+ url: course.thumbnailURL ?? course.lessonThumbnailURLs.first,
+ targetSize: CGSize(width: 240, height: 135)
+ ) { image in
+ image.resizable().aspectRatio(contentMode: .fill)
+ } placeholder: {
+ Rectangle().fill(.quaternary)
+ }
+ .frame(width: 120, height: 68)
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Continue learning", comment: "Continue course hero")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(Self.brandAccent)
+ Text(course.title)
+ .font(.system(size: 14, weight: .semibold))
+ .lineLimit(2)
+ if let last = course.lastLessonTitle {
+ Text(String(localized: "Resume: \(last)"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ ProgressView(value: course.progressFraction)
+ .tint(Self.brandAccent)
+ }
+ Spacer(minLength: 0)
+ Image(systemName: "play.circle.fill")
+ .font(.system(size: 28))
+ .foregroundStyle(Self.brandAccent)
+ }
+ .padding(12)
+ .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ }
+ .buttonStyle(.plain)
+ }
+
+ private var toolbar: some View {
+ HStack(spacing: 10) {
+ self.breadcrumbs
+ Spacer()
+ Button {
+ self.newFolderName = ""
+ self.showNewFolderAlert = true
+ } label: {
+ Label(String(localized: "New Folder"), systemImage: "folder.badge.plus")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+ }
+
+ private var breadcrumbs: some View {
+ HStack(spacing: 6) {
+ Button {
+ self.currentFolderId = nil
+ } label: {
+ Label(String(localized: "All Courses"), systemImage: "square.stack.3d.up.fill")
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(self.currentFolderId == nil ? Self.brandAccent : .primary)
+ .font(.subheadline.weight(.semibold))
+
+ ForEach(self.library.path(to: self.currentFolderId)) { folder in
+ Image(systemName: "chevron.right")
+ .font(.caption2.weight(.bold))
+ .foregroundStyle(.tertiary)
+ Button {
+ self.currentFolderId = folder.id
+ } label: {
+ Text(folder.name)
+ }
+ .buttonStyle(.plain)
+ .font(.subheadline.weight(folder.id == self.currentFolderId ? .semibold : .regular))
+ .foregroundStyle(folder.id == self.currentFolderId ? Self.brandAccent : .primary)
+ }
+ }
+ }
+
+ private var filterSortRow: some View {
+ HStack(spacing: 10) {
+ Picker(String(localized: "Filter"), selection: self.$filter) {
+ ForEach(YouTubeCourseLibraryFilter.allCases) { item in
+ Text(item.displayName).tag(item)
+ }
+ }
+ .pickerStyle(.segmented)
+ .frame(maxWidth: 420)
+
+ Spacer()
+
+ Picker(String(localized: "Sort"), selection: self.$sort) {
+ ForEach(YouTubeCourseLibrarySort.allCases) { item in
+ Text(item.displayName).tag(item)
+ }
+ }
+ .pickerStyle(.menu)
+ .frame(width: 130)
+ }
+ }
+
+ // MARK: - Browser
+
+ @ViewBuilder
+ private var browser: some View {
+ let folders = self.searchText.isEmpty ? self.library.folders(in: self.currentFolderId) : []
+ let courses = self.library.courses(
+ in: self.currentFolderId,
+ filter: self.filter,
+ sort: self.sort,
+ search: self.searchText
+ )
+
+ if folders.isEmpty, courses.isEmpty {
+ ContentUnavailableView {
+ Label(
+ self.library.isEmpty
+ ? String(localized: "No courses yet")
+ : String(localized: "No matching courses"),
+ systemImage: "list.bullet.rectangle.portrait"
+ )
+ } description: {
+ Text(
+ self.library.isEmpty
+ ? "Open any YouTube playlist and choose “Play as Course”. Organize courses into folders, pin favorites, take notes, and track weekly goals."
+ : "Try another filter or search term.",
+ comment: "Courses empty / no results"
+ )
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ScrollView {
+ LazyVGrid(columns: Self.columns, spacing: 18) {
+ ForEach(folders) { folder in
+ CourseFolderCard(
+ folder: folder,
+ courseCount: self.library.courses(in: folder.id).count,
+ subfolderCount: self.library.folders(in: folder.id).count
+ ) {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ self.currentFolderId = folder.id
+ }
+ }
+ .contextMenu {
+ Button(String(localized: "Rename…")) {
+ self.renameTarget = folder
+ self.renameText = folder.name
+ }
+ Button(String(localized: "Delete Folder"), role: .destructive) {
+ if self.currentFolderId == folder.id {
+ self.currentFolderId = folder.parentId
+ }
+ self.library.deleteFolder(id: folder.id)
+ }
+ }
+ }
+
+ ForEach(courses) { course in
+ NavigationLink(value: YouTubeRoute.playlist(playlistId: course.playlistId)) {
+ CourseCatalogCard(course: course)
+ }
+ .buttonStyle(.interactiveCard)
+ .contextMenu {
+ Button {
+ self.library.togglePin(playlistId: course.playlistId)
+ } label: {
+ Label(
+ course.isPinned
+ ? String(localized: "Unpin")
+ : String(localized: "Pin"),
+ systemImage: course.isPinned ? "pin.slash" : "pin"
+ )
+ }
+ Button(String(localized: "Move to Folder…")) {
+ self.moveCourseTarget = course
+ self.showMoveSheet = true
+ }
+ Button(String(localized: "Weekly goal…")) {
+ self.goalTarget = course
+ self.goalText = course.weeklyGoal.map(String.init) ?? "5"
+ }
+ Button(String(localized: "Reset progress"), role: .destructive) {
+ self.library.resetProgress(playlistId: course.playlistId)
+ }
+ Button(String(localized: "Remove from Courses"), role: .destructive) {
+ self.library.removeCourse(playlistId: course.playlistId)
+ }
+ }
+ }
+ }
+ .padding(.vertical, 20)
+ }
+ .contentMargins(.horizontal, DetailContentLayout.horizontalInset, for: .scrollContent)
+ }
+ }
+}
+
+// MARK: - Stat chip
+
+private struct CourseStatChip: View {
+ let title: String
+ let value: String
+ let systemImage: String
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Image(systemName: self.systemImage)
+ .foregroundStyle(PackageResourceLookup.brandAccent)
+ VStack(alignment: .leading, spacing: 0) {
+ Text(self.value)
+ .font(.system(size: 14, weight: .bold).monospacedDigit())
+ Text(self.title)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ }
+}
+
+// MARK: - Folder card
+
+private struct CourseFolderCard: View {
+ let folder: YouTubeCourseFolder
+ let courseCount: Int
+ let subfolderCount: Int
+ let onOpen: () -> Void
+
+ var body: some View {
+ Button(action: self.onOpen) {
+ VStack(alignment: .leading, spacing: 12) {
+ ZStack {
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(.quaternary.opacity(0.55))
+ .aspectRatio(16 / 10, contentMode: .fit)
+ Image(systemName: "folder.fill")
+ .font(.system(size: 36, weight: .medium))
+ .foregroundStyle(.yellow.opacity(0.9))
+ .symbolRenderingMode(.hierarchical)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(self.folder.name)
+ .font(.system(size: 13, weight: .semibold))
+ .lineLimit(2)
+ Text(self.metaLabel)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.courseFolderCard)
+ }
+
+ private var metaLabel: String {
+ var parts: [String] = []
+ if self.subfolderCount > 0 {
+ parts.append(String(localized: "\(self.subfolderCount) folders"))
+ }
+ parts.append(String(localized: "\(self.courseCount) courses"))
+ return parts.joined(separator: " · ")
+ }
+}
+
+// MARK: - Course card
+
+private struct CourseCatalogCard: View {
+ private static let brandAccent = PackageResourceLookup.brandAccent
+ let course: YouTubeCourseCatalogEntry
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ ZStack(alignment: .topTrailing) {
+ self.thumbnailCollage
+ .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+ if self.course.isPinned {
+ Image(systemName: "pin.fill")
+ .font(.caption2.weight(.bold))
+ .padding(6)
+ .background(.ultraThinMaterial, in: Circle())
+ .padding(8)
+ }
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(self.course.title)
+ .font(.system(size: 13, weight: .semibold))
+ .lineLimit(2)
+
+ if let channel = self.course.channelName, !channel.isEmpty {
+ Text(channel)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ HStack(spacing: 6) {
+ Text(self.course.status.displayName)
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(self.statusColor)
+ if self.course.totalDurationSeconds > 0 {
+ Text("·")
+ .foregroundStyle(.tertiary)
+ Text(YouTubeCourseDuration.format(seconds: self.course.totalDurationSeconds))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ if let goal = self.course.weeklyGoal {
+ Text("·")
+ .foregroundStyle(.tertiary)
+ Text(String(localized: "Goal \(goal)/wk"))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ HStack(spacing: 8) {
+ Text(
+ String(localized: "\(self.course.completedCount)/\(self.course.lessonCount)")
+ )
+ .font(.caption2.weight(.semibold).monospacedDigit())
+ .foregroundStyle(.secondary)
+
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ Capsule().fill(.primary.opacity(0.08))
+ Capsule()
+ .fill(Self.brandAccent)
+ .frame(width: max(3, geo.size.width * self.course.progressFraction))
+ }
+ }
+ .frame(height: 4)
+
+ Text("\(Int((self.course.progressFraction * 100).rounded()))%")
+ .font(.caption2.weight(.bold).monospacedDigit())
+ .foregroundStyle(Self.brandAccent)
+ }
+ }
+ }
+ .contentShape(Rectangle())
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.courseCatalogCard)
+ }
+
+ private var statusColor: Color {
+ switch self.course.status {
+ case .completed: .green
+ case .inProgress: Self.brandAccent
+ case .notStarted: .secondary
+ }
+ }
+
+ @ViewBuilder
+ private var thumbnailCollage: some View {
+ let urls = self.course.lessonThumbnailURLs
+ if urls.count >= 4 {
+ let grid = Array(urls.prefix(4))
+ LazyVGrid(
+ columns: [GridItem(.flexible(), spacing: 2), GridItem(.flexible(), spacing: 2)],
+ spacing: 2
+ ) {
+ ForEach(Array(grid.enumerated()), id: \.offset) { _, url in
+ CachedAsyncImage(url: url, targetSize: CGSize(width: 320, height: 180)) { image in
+ image.resizable().aspectRatio(contentMode: .fill)
+ } placeholder: {
+ Rectangle().fill(.quaternary)
+ }
+ .frame(minHeight: 54)
+ .clipped()
+ }
+ }
+ .aspectRatio(16 / 10, contentMode: .fit)
+ } else if let url = self.course.thumbnailURL ?? urls.first {
+ CachedAsyncImage(url: url, targetSize: CGSize(width: 640, height: 360)) { image in
+ image.resizable().aspectRatio(contentMode: .fill)
+ } placeholder: {
+ self.placeholderThumb
+ }
+ .aspectRatio(16 / 10, contentMode: .fit)
+ .clipped()
+ } else {
+ self.placeholderThumb
+ .aspectRatio(16 / 10, contentMode: .fit)
+ }
+ }
+
+ private var placeholderThumb: some View {
+ Rectangle()
+ .fill(.quaternary)
+ .overlay {
+ Image(systemName: "list.bullet.rectangle.portrait.fill")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+// MARK: - Move sheet
+
+private struct CourseMoveFolderSheet: View {
+ let course: YouTubeCourseCatalogEntry
+ let folders: [YouTubeCourseFolder]
+ let onPick: (String?) -> Void
+ let onCancel: () -> Void
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Button {
+ self.onPick(nil)
+ } label: {
+ Label(String(localized: "All Courses (root)"), systemImage: "square.stack.3d.up")
+ }
+ ForEach(
+ self.folders.sorted {
+ $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
+ }
+ ) { folder in
+ Button {
+ self.onPick(folder.id)
+ } label: {
+ Label(folder.name, systemImage: "folder")
+ }
+ }
+ }
+ .navigationTitle(Text("Move “\(self.course.title)”"))
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button(String(localized: "Cancel"), action: self.onCancel)
+ }
+ }
+ }
+ .frame(minWidth: 360, minHeight: 320)
+ }
+}
+
+// MARK: - Accessibility
+
+extension AccessibilityID.YouTubeContent {
+ static let courseFolderCard = "youtubeContent.courseFolderCard"
+ static let courseCatalogCard = "youtubeContent.courseCatalogCard"
+}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeDownloadHUD.swift b/Sources/Kaset/Views/YouTube/YouTubeDownloadHUD.swift
new file mode 100644
index 000000000..edd8021bc
--- /dev/null
+++ b/Sources/Kaset/Views/YouTube/YouTubeDownloadHUD.swift
@@ -0,0 +1,327 @@
+import SwiftUI
+
+// MARK: - DownloadJobSnapshot
+
+/// Main-thread snapshot of a download job for safe SwiftUI rendering.
+/// Avoids walking live `@Observable` class graphs inside `filter` during
+/// rapid progress updates (which crashed the app on download complete).
+private struct DownloadJobSnapshot: Identifiable, Equatable {
+ let id: UUID
+ let videoId: String
+ let title: String
+ let qualityName: String
+ let status: DownloadJob.Status
+ let progress: Double?
+ let speedText: String?
+ let etaText: String?
+ let startedAt: Date
+ let progressRevision: UInt
+
+ init(_ job: DownloadJob) {
+ self.id = job.id
+ self.videoId = job.videoId
+ self.title = job.title
+ self.qualityName = job.quality.displayName
+ self.status = job.status
+ self.progress = job.progress
+ self.speedText = job.speedText
+ self.etaText = job.etaText
+ self.startedAt = job.startedAt
+ self.progressRevision = job.progressRevision
+ }
+
+ var isVisible: Bool {
+ switch self.status {
+ case .queued, .running:
+ true
+ case .completed, .failed, .cancelled:
+ Date().timeIntervalSince(self.startedAt) < 90
+ }
+ }
+}
+
+// MARK: - YouTubeDownloadHUD
+
+/// Floating Liquid Glass HUD for background yt-dlp downloads.
+/// Shows real-time progress percentage, speed, and ETA without blocking the UI.
+struct YouTubeDownloadHUD: View {
+ /// Observe the shared service directly (do NOT store it in `@State` —
+ /// that pattern raced with progress updates and crashed on completion).
+ private var downloadService: YTDLPService { YTDLPService.shared }
+
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ private var visibleSnapshots: [DownloadJobSnapshot] {
+ // Touch progressRevision so Observation tracks throttled progress.
+ let snapshots = self.downloadService.jobs.map { job in
+ _ = job.progressRevision
+ return DownloadJobSnapshot(job)
+ }
+ return Array(snapshots.filter(\.isVisible).prefix(4))
+ }
+
+ var body: some View {
+ let snapshots = self.visibleSnapshots
+ if !snapshots.isEmpty {
+ VStack(alignment: .trailing, spacing: 8) {
+ ForEach(snapshots) { snapshot in
+ DownloadJobCard(
+ snapshot: snapshot,
+ onCancel: {
+ self.downloadService.cancel(snapshot.id)
+ },
+ reveal: {
+ if let job = self.downloadService.jobs.first(where: { $0.id == snapshot.id }) {
+ self.downloadService.revealInFinder(job: job)
+ }
+ }
+ )
+ .transition(
+ self.reduceMotion
+ ? .opacity
+ : .move(edge: .trailing).combined(with: .opacity)
+ )
+ .id("\(snapshot.id)-\(snapshot.progressRevision)-\(String(describing: snapshot.status))")
+ }
+ }
+ .padding(.trailing, 16)
+ .padding(.top, 12)
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.downloadHUD)
+ }
+ }
+}
+
+// MARK: - DownloadJobCard
+
+private struct DownloadJobCard: View {
+ let snapshot: DownloadJobSnapshot
+ let onCancel: () -> Void
+ let reveal: () -> Void
+
+ private static let brandAccent = PackageResourceLookup.brandAccent
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .top, spacing: 10) {
+ self.statusIcon
+ .frame(width: 22, height: 22)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(self.snapshot.title)
+ .font(.system(size: 12, weight: .semibold))
+ .lineLimit(2)
+ Text(self.snapshot.qualityName)
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer(minLength: 8)
+
+ Text(self.percentLabel)
+ .font(.system(size: 13, weight: .bold).monospacedDigit())
+ .foregroundStyle(Self.brandAccent)
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.downloadPercent)
+ }
+
+ GeometryReader { geo in
+ let fraction = min(max(self.snapshot.progress ?? (self.snapshot.status == .completed ? 1 : 0), 0), 1)
+ ZStack(alignment: .leading) {
+ Capsule()
+ .fill(.primary.opacity(0.08))
+ Capsule()
+ .fill(Self.brandAccent)
+ .frame(width: max(4, geo.size.width * fraction))
+ }
+ }
+ .frame(height: 6)
+
+ HStack(spacing: 8) {
+ Text(self.statusText)
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+
+ Spacer()
+
+ switch self.snapshot.status {
+ case .running, .queued:
+ Button(String(localized: "Cancel"), action: self.onCancel)
+ .buttonStyle(.borderless)
+ .font(.system(size: 10, weight: .semibold))
+ case .completed:
+ Button(String(localized: "Show"), action: self.reveal)
+ .buttonStyle(.borderless)
+ .font(.system(size: 10, weight: .semibold))
+ case .failed, .cancelled:
+ EmptyView()
+ }
+ }
+ }
+ .padding(12)
+ .frame(width: 280)
+ .compatGlass(interactive: true, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .shadow(color: .black.opacity(0.18), radius: 16, y: 6)
+ }
+
+ private var percentLabel: String {
+ switch self.snapshot.status {
+ case .completed:
+ return "100%"
+ case .failed, .cancelled:
+ return "—"
+ case .queued, .running:
+ if let progress = self.snapshot.progress {
+ return "\(Int((progress * 100).rounded()))%"
+ }
+ return "…"
+ }
+ }
+
+ private var statusText: String {
+ switch self.snapshot.status {
+ case .queued:
+ return String(localized: "Queued…")
+ case .running:
+ var parts: [String] = [String(localized: "Downloading")]
+ if let speed = self.snapshot.speedText { parts.append(speed) }
+ if let eta = self.snapshot.etaText { parts.append("ETA \(eta)") }
+ return parts.joined(separator: " · ")
+ case .completed:
+ return String(localized: "Saved")
+ case let .failed(message):
+ return message
+ case .cancelled:
+ return String(localized: "Cancelled")
+ }
+ }
+
+ @ViewBuilder
+ private var statusIcon: some View {
+ switch self.snapshot.status {
+ case .queued:
+ Image(systemName: "clock")
+ .foregroundStyle(.secondary)
+ case .running:
+ ProgressView()
+ .controlSize(.small)
+ case .completed:
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ case .failed:
+ Image(systemName: "xmark.octagon.fill")
+ .foregroundStyle(.red)
+ case .cancelled:
+ Image(systemName: "minus.circle")
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+// MARK: - OneClickDownloadButton
+
+/// Instant download control with live ring progress for the current video.
+struct OneClickDownloadButton: View {
+ let video: YouTubeVideo
+ var quality: DownloadQuality = SettingsManager.shared.downloadDefaultQuality
+
+ private var downloadService: YTDLPService { YTDLPService.shared }
+
+ @State private var errorMessage: String?
+ @State private var showError = false
+
+ private var activeJob: DownloadJob? {
+ self.downloadService.jobs.first {
+ $0.videoId == self.video.videoId
+ && ($0.status == .running || $0.status == .queued)
+ }
+ }
+
+ private var completedJob: DownloadJob? {
+ self.downloadService.jobs.first {
+ $0.videoId == self.video.videoId && $0.status == .completed
+ }
+ }
+
+ var body: some View {
+ // Observe throttled revision so the ring updates without hanging off
+ // every intermediate progress write.
+ let revision = self.activeJob?.progressRevision ?? self.completedJob?.progressRevision ?? 0
+ _ = revision
+
+ return Button {
+ self.handleTap()
+ } label: {
+ HStack(spacing: 8) {
+ if let job = self.activeJob {
+ ZStack {
+ Circle()
+ .stroke(.primary.opacity(0.12), lineWidth: 2.5)
+ Circle()
+ .trim(from: 0, to: job.progress ?? 0.05)
+ .stroke(
+ PackageResourceLookup.brandAccent,
+ style: StrokeStyle(lineWidth: 2.5, lineCap: .round)
+ )
+ .rotationEffect(.degrees(-90))
+ Text(self.shortPercent(job))
+ .font(.system(size: 9, weight: .bold).monospacedDigit())
+ }
+ .frame(width: 22, height: 22)
+
+ Text(self.shortPercent(job) + " " + String(localized: "Downloading"))
+ } else if self.completedJob != nil {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ Text(String(localized: "Downloaded"))
+ } else {
+ Image(systemName: "arrow.down.circle.fill")
+ Text(String(localized: "Download"))
+ }
+ }
+ .font(.system(size: 12, weight: .semibold))
+ .padding(.horizontal, 12)
+ .frame(height: 30)
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .compatGlass(interactive: true, in: Capsule())
+ .disabled(self.activeJob != nil)
+ .help(String(localized: "One-click download with yt-dlp (background)"))
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.downloadButton)
+ .alert(String(localized: "Download"), isPresented: self.$showError) {
+ Button(String(localized: "OK"), role: .cancel) {}
+ } message: {
+ Text(self.errorMessage ?? "")
+ }
+ }
+
+ private func shortPercent(_ job: DownloadJob) -> String {
+ if let progress = job.progress {
+ return "\(Int((progress * 100).rounded()))%"
+ }
+ return "…"
+ }
+
+ private func handleTap() {
+ if let completed = self.completedJob {
+ self.downloadService.revealInFinder(job: completed)
+ return
+ }
+ do {
+ _ = try self.downloadService.download(
+ videoId: self.video.videoId,
+ title: self.video.title,
+ quality: self.quality
+ )
+ HapticService.toggle()
+ } catch {
+ self.errorMessage = error.localizedDescription
+ self.showError = true
+ }
+ }
+}
+
+extension AccessibilityID.YouTubeContent {
+ static let downloadHUD = "youtubeContent.downloadHUD"
+ static let downloadPercent = "youtubeContent.downloadPercent"
+}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeDownloadSettingsSection.swift b/Sources/Kaset/Views/YouTube/YouTubeDownloadSettingsSection.swift
new file mode 100644
index 000000000..8a4aa63a2
--- /dev/null
+++ b/Sources/Kaset/Views/YouTube/YouTubeDownloadSettingsSection.swift
@@ -0,0 +1,128 @@
+import AppKit
+import SwiftUI
+
+// MARK: - YouTubeDownloadSettingsSection
+
+/// Settings controls for yt-dlp downloads (folder, quality, binary path).
+struct YouTubeDownloadSettingsSection: View {
+ @Bindable var settings: SettingsManager
+ @State private var downloadService = YTDLPService.shared
+
+ var body: some View {
+ Section {
+ Picker(String(localized: "Save downloads to"), selection: self.$settings.downloadFolderPreference) {
+ ForEach(DownloadFolderPreference.allCases) { preference in
+ Text(preference.displayName).tag(preference)
+ }
+ }
+
+ if self.settings.downloadFolderPreference == .custom {
+ HStack {
+ Text(self.settings.downloadFolderDisplayPath.isEmpty
+ ? String(localized: "No folder selected")
+ : self.settings.downloadFolderDisplayPath)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ .truncationMode(.middle)
+ Spacer()
+ Button(String(localized: "Choose…")) {
+ self.chooseCustomFolder()
+ }
+ }
+ }
+
+ Picker(String(localized: "Default quality"), selection: self.$settings.downloadDefaultQuality) {
+ ForEach(DownloadQuality.allCases) { quality in
+ Text(quality.displayName).tag(quality)
+ }
+ }
+
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(String(localized: "yt-dlp path"))
+ Text(self.downloadService.resolvedBinaryPath
+ ?? String(localized: "Not found — install with: brew install yt-dlp ffmpeg"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+ Spacer()
+ Button(String(localized: "Refresh")) {
+ self.downloadService.refreshBinaryPath()
+ }
+ Button(String(localized: "Browse…")) {
+ self.chooseBinary()
+ }
+ }
+
+ if !self.settings.ytdlpBinaryPath.isEmpty {
+ HStack {
+ Text(self.settings.ytdlpBinaryPath)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ Spacer()
+ Button(String(localized: "Clear override")) {
+ self.settings.ytdlpBinaryPath = ""
+ self.downloadService.refreshBinaryPath()
+ }
+ }
+ }
+ } header: {
+ Text("Downloads (yt-dlp)")
+ } footer: {
+ Text("Kaset shells out to the system yt-dlp tool (and ffmpeg when needed) to save videos or audio. Default location is your Downloads folder; pick a custom folder for another destination.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .onAppear {
+ self.downloadService.refreshBinaryPath()
+ }
+ .onChange(of: self.settings.ytdlpBinaryPath) { _, _ in
+ self.downloadService.refreshBinaryPath()
+ }
+ }
+
+ private func chooseCustomFolder() {
+ let panel = NSOpenPanel()
+ panel.title = String(localized: "Choose Download Folder")
+ panel.message = String(localized: "Videos and audio downloaded via yt-dlp will be saved here.")
+ panel.canChooseDirectories = true
+ panel.canChooseFiles = false
+ panel.allowsMultipleSelection = false
+ panel.canCreateDirectories = true
+ panel.level = .modalPanel
+
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+
+ do {
+ let bookmark = try url.bookmarkData(
+ options: .withSecurityScope,
+ includingResourceValuesForKeys: nil,
+ relativeTo: nil
+ )
+ self.settings.downloadFolderBookmarkData = bookmark
+ self.settings.downloadFolderDisplayPath = url.path
+ self.settings.downloadFolderPreference = .custom
+ } catch {
+ DiagnosticsLogger.download.error("Failed to bookmark download folder: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+
+ private func chooseBinary() {
+ let panel = NSOpenPanel()
+ panel.title = String(localized: "Locate yt-dlp")
+ panel.message = String(localized: "Select the yt-dlp executable (for example /opt/homebrew/bin/yt-dlp).")
+ panel.canChooseDirectories = false
+ panel.canChooseFiles = true
+ panel.allowsMultipleSelection = false
+ panel.level = .modalPanel
+ panel.directoryURL = URL(fileURLWithPath: "/opt/homebrew/bin")
+
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ self.settings.ytdlpBinaryPath = url.path
+ self.downloadService.refreshBinaryPath()
+ }
+}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeDownloadSheet.swift b/Sources/Kaset/Views/YouTube/YouTubeDownloadSheet.swift
new file mode 100644
index 000000000..ac3ed98d2
--- /dev/null
+++ b/Sources/Kaset/Views/YouTube/YouTubeDownloadSheet.swift
@@ -0,0 +1,218 @@
+import AppKit
+import SwiftUI
+
+// MARK: - YouTubeDownloadSheet
+
+/// Sheet for downloading a YouTube video via the system yt-dlp CLI.
+struct YouTubeDownloadSheet: View {
+ let video: YouTubeVideo
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var settings = SettingsManager.shared
+ @State private var downloadService = YTDLPService.shared
+ @State private var quality: DownloadQuality = SettingsManager.shared.downloadDefaultQuality
+ @State private var errorMessage: String?
+ @State private var activeJobID: UUID?
+
+ private var activeJob: DownloadJob? {
+ guard let id = self.activeJobID else { return nil }
+ return self.downloadService.jobs.first { $0.id == id }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Download", comment: "Download sheet title")
+ .font(.title2.bold())
+ Text(self.video.title)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ Spacer()
+ Button {
+ self.dismiss()
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ .font(.title3)
+ }
+ .buttonStyle(.plain)
+ }
+
+ GroupBox {
+ VStack(alignment: .leading, spacing: 10) {
+ LabeledContent(String(localized: "Quality")) {
+ Picker("", selection: self.$quality) {
+ ForEach(DownloadQuality.allCases) { q in
+ Text(q.displayName).tag(q)
+ }
+ }
+ .labelsHidden()
+ .frame(maxWidth: 220)
+ }
+
+ LabeledContent(String(localized: "Save to")) {
+ Text(self.destinationLabel)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ .frame(maxWidth: 260, alignment: .trailing)
+ }
+
+ LabeledContent(String(localized: "yt-dlp")) {
+ HStack(spacing: 6) {
+ Circle()
+ .fill(self.downloadService.isAvailable ? Color.green : Color.orange)
+ .frame(width: 8, height: 8)
+ Text(self.downloadService.resolvedBinaryPath ?? String(localized: "Not found"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ if let errorMessage {
+ Text(errorMessage)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+
+ if let job = self.activeJob {
+ self.jobProgress(job)
+ }
+
+ HStack {
+ Button(String(localized: "Open in Terminal")) {
+ self.openInTerminal()
+ }
+ .disabled(!self.downloadService.isAvailable && self.settings.ytdlpBinaryPath.isEmpty)
+
+ Spacer()
+
+ Button(String(localized: "Cancel Download")) {
+ if let id = self.activeJobID {
+ self.downloadService.cancel(id)
+ }
+ }
+ .disabled(self.activeJob == nil || self.activeJob?.status != .running)
+
+ Button {
+ self.startDownload()
+ } label: {
+ if self.activeJob?.status == .running {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text(String(localized: "Download"))
+ }
+ }
+ .keyboardShortcut(.defaultAction)
+ .disabled(self.activeJob?.status == .running)
+ }
+ }
+ .padding(20)
+ .frame(width: 480)
+ .onAppear {
+ self.downloadService.refreshBinaryPath()
+ self.quality = self.settings.downloadDefaultQuality
+ }
+ }
+
+ private var destinationLabel: String {
+ switch self.settings.downloadFolderPreference {
+ case .downloads:
+ String(localized: "Downloads folder")
+ case .custom:
+ self.settings.downloadFolderDisplayPath.isEmpty
+ ? String(localized: "Custom folder (not set)")
+ : self.settings.downloadFolderDisplayPath
+ }
+ }
+
+ @ViewBuilder
+ private func jobProgress(_ job: DownloadJob) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ switch job.status {
+ case .queued:
+ Text("Queued…", comment: "Download job queued")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ case .running:
+ ProgressView(value: job.progress ?? 0, total: 1) {
+ HStack {
+ Text(String(localized: "Downloading…"))
+ Spacer()
+ if let speed = job.speedText {
+ Text(speed).foregroundStyle(.secondary)
+ }
+ if let eta = job.etaText {
+ Text("ETA \(eta)").foregroundStyle(.secondary)
+ }
+ }
+ .font(.caption)
+ }
+ case .completed:
+ HStack {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ Text(String(localized: "Download complete"))
+ Spacer()
+ Button(String(localized: "Show in Finder")) {
+ self.downloadService.revealInFinder(job: job)
+ }
+ }
+ .font(.callout)
+ case let .failed(message):
+ VStack(alignment: .leading, spacing: 4) {
+ Label(String(localized: "Download failed"), systemImage: "xmark.octagon.fill")
+ .foregroundStyle(.red)
+ .font(.callout)
+ Text(message)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(4)
+ .textSelection(.enabled)
+ }
+ case .cancelled:
+ Text(String(localized: "Cancelled"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(10)
+ .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 8))
+ }
+
+ private func startDownload() {
+ self.errorMessage = nil
+ do {
+ let job = try self.downloadService.download(
+ videoId: self.video.videoId,
+ title: self.video.title,
+ quality: self.quality
+ )
+ self.activeJobID = job.id
+ } catch {
+ self.errorMessage = error.localizedDescription
+ }
+ }
+
+ private func openInTerminal() {
+ self.errorMessage = nil
+ do {
+ try self.downloadService.openInTerminal(
+ videoId: self.video.videoId,
+ title: self.video.title,
+ quality: self.quality
+ )
+ } catch {
+ self.errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeFullscreenChrome.swift b/Sources/Kaset/Views/YouTube/YouTubeFullscreenChrome.swift
new file mode 100644
index 000000000..5c3930f1f
--- /dev/null
+++ b/Sources/Kaset/Views/YouTube/YouTubeFullscreenChrome.swift
@@ -0,0 +1,336 @@
+import AppKit
+import SwiftUI
+
+// MARK: - YouTubeFullscreenChrome
+
+/// Liquid Glass chrome for the floating / fullscreen YouTube video window.
+///
+/// - Auto-hides after a short idle period (fullscreen and windowed).
+/// - Reappears on mouse movement.
+/// - Uses a compact glass control strip (not the full main-window player bar)
+/// so layout does not thrash or introduce scroll jank when chrome appears.
+struct YouTubeFullscreenChrome: View {
+ @Environment(YouTubePlayerService.self) private var youtubePlayer
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ @State private var controlsVisible = true
+ @State private var hideTask: Task?
+ @State private var isScrubbing = false
+ @State private var seekValue: Double = 0
+
+ /// PiP + fullscreen control strip auto-hides after this idle period.
+ private static let idleHideDelay: Duration = .seconds(3)
+ private static let brandAccent = PackageResourceLookup.brandAccent
+
+ var body: some View {
+ ZStack(alignment: .bottom) {
+ // Activity catcher — full-bleed, transparent, passes clicks through
+ // except when we need to count movement. Mouse movement is tracked
+ // via an NSView monitor so WebView scroll/hover does not fight us.
+ MouseActivityTracker {
+ self.noteActivity()
+ }
+ .allowsHitTesting(false)
+
+ if self.controlsVisible {
+ self.glassControlStrip
+ .padding(.horizontal, 20)
+ .padding(.bottom, self.youtubePlayer.isWindowFullscreen ? 28 : 16)
+ .transition(
+ self.reduceMotion
+ ? .opacity
+ : .move(edge: .bottom).combined(with: .opacity)
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .onAppear {
+ self.syncSeekFromPlayer()
+ self.noteActivity()
+ YouTubeVideoWindowController.shared.setWindowChromeVisible(true)
+ }
+ .onDisappear {
+ self.hideTask?.cancel()
+ self.hideTask = nil
+ }
+ .onChange(of: self.youtubePlayer.progress) { _, _ in
+ if !self.isScrubbing {
+ self.syncSeekFromPlayer()
+ }
+ }
+ .onChange(of: self.youtubePlayer.duration) { _, _ in
+ if !self.isScrubbing {
+ self.syncSeekFromPlayer()
+ }
+ }
+ .onChange(of: self.youtubePlayer.isWindowFullscreen) { _, isFull in
+ // Entering fullscreen: show briefly then auto-hide.
+ self.controlsVisible = true
+ YouTubeVideoWindowController.shared.setWindowChromeVisible(true)
+ if isFull {
+ self.scheduleHide()
+ }
+ }
+ .onExitCommand {
+ if self.youtubePlayer.isWindowFullscreen {
+ YouTubeVideoWindowController.shared.toggleFullscreen()
+ }
+ }
+ }
+
+ // MARK: - Glass strip
+
+ private var glassControlStrip: some View {
+ VStack(spacing: 10) {
+ // Title row
+ if let video = self.youtubePlayer.currentVideo {
+ HStack(spacing: 10) {
+ Text(video.title)
+ .font(.system(size: 13, weight: .semibold))
+ .lineLimit(1)
+ .foregroundStyle(.primary)
+ Spacer(minLength: 0)
+ if let channel = video.channelName {
+ Text(channel)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .padding(.horizontal, 4)
+ }
+
+ // Scrubber
+ HStack(spacing: 10) {
+ Text(Self.formatTime(self.displayTime))
+ .font(.system(size: 11).monospacedDigit())
+ .foregroundStyle(.secondary)
+ .frame(width: 44, alignment: .leading)
+
+ Slider(
+ value: self.$seekValue,
+ in: 0 ... 1,
+ onEditingChanged: { editing in
+ self.isScrubbing = editing
+ self.noteActivity()
+ if !editing {
+ let target = self.seekValue * max(self.youtubePlayer.duration, 0.001)
+ self.youtubePlayer.seek(to: target)
+ }
+ }
+ )
+ .controlSize(.small)
+ .tint(Self.brandAccent)
+
+ Text(Self.formatTime(self.youtubePlayer.duration))
+ .font(.system(size: 11).monospacedDigit())
+ .foregroundStyle(.secondary)
+ .frame(width: 44, alignment: .trailing)
+ }
+
+ // Transport
+ HStack(spacing: 14) {
+ chromeButton(systemName: "gobackward.30", label: String(localized: "Back 30 seconds")) {
+ self.youtubePlayer.seekBackward()
+ self.noteActivity()
+ }
+
+ Button {
+ HapticService.playback()
+ self.youtubePlayer.playPause()
+ self.noteActivity()
+ } label: {
+ Image(systemName: self.youtubePlayer.isPlaying ? "pause.fill" : "play.fill")
+ .font(.system(size: 22, weight: .semibold))
+ .frame(width: 44, height: 44)
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ .compatGlass(interactive: true, tint: Self.brandAccent.opacity(0.35), in: Circle())
+ .accessibilityLabel(
+ self.youtubePlayer.isPlaying
+ ? String(localized: "Pause")
+ : String(localized: "Play")
+ )
+
+ chromeButton(systemName: "goforward.30", label: String(localized: "Forward 30 seconds")) {
+ self.youtubePlayer.seekForward()
+ self.noteActivity()
+ }
+
+ Spacer(minLength: 8)
+
+ chromeButton(
+ systemName: self.youtubePlayer.isWindowFullscreen
+ ? "arrow.down.right.and.arrow.up.left"
+ : "arrow.up.left.and.arrow.down.right",
+ label: self.youtubePlayer.isWindowFullscreen
+ ? String(localized: "Exit Full Screen")
+ : String(localized: "Full Screen")
+ ) {
+ YouTubeVideoWindowController.shared.toggleFullscreen()
+ self.noteActivity()
+ }
+
+ if !self.youtubePlayer.isWindowFullscreen {
+ chromeButton(systemName: "pip.exit", label: String(localized: "Dock inline")) {
+ self.youtubePlayer.requestPopIn()
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 18)
+ .padding(.vertical, 14)
+ .frame(maxWidth: 720)
+ .compatGlass(interactive: true, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
+ .shadow(color: .black.opacity(0.28), radius: 24, y: 8)
+ // Keep pointer activity over the strip from immediately re-hiding.
+ .onHover { hovering in
+ if hovering {
+ self.noteActivity(forceVisible: true, scheduleHide: false)
+ } else {
+ self.scheduleHide()
+ }
+ }
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.fullscreenChrome)
+ }
+
+ private func chromeButton(
+ systemName: String,
+ label: String,
+ action: @escaping () -> Void
+ ) -> some View {
+ Button {
+ HapticService.toggle()
+ action()
+ } label: {
+ Image(systemName: systemName)
+ .font(.system(size: 15, weight: .semibold))
+ .frame(width: 36, height: 36)
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ .compatGlass(interactive: true, in: Circle())
+ .accessibilityLabel(label)
+ }
+
+ // MARK: - Activity / auto-hide
+
+ private func noteActivity(forceVisible: Bool = true, scheduleHide: Bool = true) {
+ if forceVisible, !self.controlsVisible {
+ withAnimation(self.reduceMotion ? .easeInOut(duration: 0.12) : .spring(response: 0.32, dampingFraction: 0.88)) {
+ self.controlsVisible = true
+ }
+ YouTubeVideoWindowController.shared.setWindowChromeVisible(true)
+ } else if forceVisible {
+ self.controlsVisible = true
+ YouTubeVideoWindowController.shared.setWindowChromeVisible(true)
+ }
+
+ if scheduleHide, !self.isScrubbing {
+ self.scheduleHide()
+ }
+ }
+
+ private func scheduleHide() {
+ self.hideTask?.cancel()
+ self.hideTask = Task { @MainActor in
+ try? await Task.sleep(for: Self.idleHideDelay)
+ guard !Task.isCancelled, !self.isScrubbing else { return }
+ withAnimation(.easeOut(duration: 0.28)) {
+ self.controlsVisible = false
+ }
+ YouTubeVideoWindowController.shared.setWindowChromeVisible(false)
+ // Hide cursor in true fullscreen for a theater feel.
+ if self.youtubePlayer.isWindowFullscreen {
+ NSCursor.setHiddenUntilMouseMoves(true)
+ }
+ }
+ }
+
+ // MARK: - Seek helpers
+
+ private var displayTime: Double {
+ if self.isScrubbing {
+ return self.seekValue * max(self.youtubePlayer.duration, 0)
+ }
+ return self.youtubePlayer.progress
+ }
+
+ private func syncSeekFromPlayer() {
+ let duration = self.youtubePlayer.duration
+ guard duration > 0 else {
+ self.seekValue = 0
+ return
+ }
+ self.seekValue = min(max(self.youtubePlayer.progress / duration, 0), 1)
+ }
+
+ private static func formatTime(_ seconds: Double) -> String {
+ guard seconds.isFinite, seconds >= 0 else { return "0:00" }
+ let total = Int(seconds)
+ let hours = total / 3600
+ let mins = (total % 3600) / 60
+ let secs = total % 60
+ if hours > 0 {
+ return String(format: "%d:%02d:%02d", hours, mins, secs)
+ }
+ return String(format: "%d:%02d", mins, secs)
+ }
+}
+
+// MARK: - MouseActivityTracker
+
+/// Reports local mouse-move events so chrome can reappear without relying on
+/// SwiftUI `.onHover` (which fails over WKWebView hit testing).
+private struct MouseActivityTracker: NSViewRepresentable {
+ var onActivity: () -> Void
+
+ func makeNSView(context: Context) -> NSView {
+ let view = TrackingView()
+ view.onActivity = self.onActivity
+ return view
+ }
+
+ func updateNSView(_ nsView: NSView, context _: Context) {
+ (nsView as? TrackingView)?.onActivity = self.onActivity
+ }
+
+ private final class TrackingView: NSView {
+ var onActivity: (() -> Void)?
+ private var monitor: Any?
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ self.teardown()
+ guard self.window != nil else { return }
+ self.monitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged]) { [weak self] event in
+ // Only react to events for our window to avoid waking chrome from other windows.
+ if event.window == self?.window {
+ DispatchQueue.main.async {
+ self?.onActivity?()
+ }
+ }
+ return event
+ }
+ }
+
+ override func removeFromSuperview() {
+ self.teardown()
+ super.removeFromSuperview()
+ }
+
+ private func teardown() {
+ if let monitor {
+ NSEvent.removeMonitor(monitor)
+ self.monitor = nil
+ }
+ }
+ }
+}
+
+// MARK: - Accessibility
+
+extension AccessibilityID.YouTubeContent {
+ static let fullscreenChrome = "youtubeContent.fullscreenChrome"
+}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeInAppMiniPlayer.swift b/Sources/Kaset/Views/YouTube/YouTubeInAppMiniPlayer.swift
new file mode 100644
index 000000000..0bcc6da9d
--- /dev/null
+++ b/Sources/Kaset/Views/YouTube/YouTubeInAppMiniPlayer.swift
@@ -0,0 +1,207 @@
+import SwiftUI
+
+// MARK: - YouTubeInAppMiniPlayer
+
+/// Compact mini player when navigating away from a playing YouTube video.
+///
+/// Intentionally minimal controls:
+/// - Progress bar (scrubbable)
+/// - −30s / +30s seek
+/// - Tap the strip to expand back to the watch view
+struct YouTubeInAppMiniPlayer: View {
+ private static let brandAccent = PackageResourceLookup.brandAccent
+ private static let barHeight: CGFloat = 52
+
+ @Environment(YouTubePlayerService.self) private var youtubePlayer
+ @State private var isScrubbing = false
+ @State private var scrubFraction: Double = 0
+
+ var body: some View {
+ if self.youtubePlayer.surfaceLocation == .miniPlayer,
+ let video = self.youtubePlayer.currentVideo
+ {
+ self.miniPlayerCard(for: video)
+ .transition(
+ .asymmetric(
+ insertion: .move(edge: .bottom).combined(with: .opacity),
+ removal: .move(edge: .bottom).combined(with: .opacity)
+ )
+ )
+ }
+ }
+
+ // MARK: - Card
+
+ @ViewBuilder
+ private func miniPlayerCard(for video: YouTubeVideo) -> some View {
+ VStack(spacing: 0) {
+ // Scrubbable progress
+ self.progressBar
+ .frame(height: 18)
+ .padding(.horizontal, 12)
+ .padding(.top, 6)
+
+ HStack(spacing: 12) {
+ // −30s
+ Button {
+ HapticService.playback()
+ self.youtubePlayer.seekBackward()
+ } label: {
+ Image(systemName: "gobackward.30")
+ .font(.system(size: 16, weight: .semibold))
+ .frame(width: 36, height: 36)
+ .contentShape(Rectangle())
+ .foregroundStyle(.primary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(String(localized: "Back 30 seconds"))
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.miniPlayerSeekBack)
+
+ // Title (tap expands)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(video.title)
+ .font(.system(size: 12, weight: .semibold))
+ .lineLimit(1)
+ .foregroundStyle(.primary)
+ HStack(spacing: 6) {
+ Text(Self.formatTime(self.displayProgress))
+ .font(.system(size: 10).monospacedDigit())
+ Text("/")
+ .font(.system(size: 10))
+ Text(Self.formatTime(self.youtubePlayer.duration))
+ .font(.system(size: 10).monospacedDigit())
+ }
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ HapticService.toggle()
+ withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) {
+ self.youtubePlayer.expandFromMiniPlayer()
+ }
+ }
+ .accessibilityAddTraits(.isButton)
+ .accessibilityLabel(String(localized: "Expand video"))
+ .accessibilityHint(video.title)
+
+ // +30s
+ Button {
+ HapticService.playback()
+ self.youtubePlayer.seekForward()
+ } label: {
+ Image(systemName: "goforward.30")
+ .font(.system(size: 16, weight: .semibold))
+ .frame(width: 36, height: 36)
+ .contentShape(Rectangle())
+ .foregroundStyle(.primary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(String(localized: "Forward 30 seconds"))
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.miniPlayerSeekForward)
+ }
+ .padding(.horizontal, 10)
+ .padding(.bottom, 8)
+ .frame(height: Self.barHeight)
+ }
+ .background {
+ Rectangle()
+ .fill(.regularMaterial)
+ .shadow(color: .black.opacity(0.18), radius: 10, y: -3)
+ }
+ .overlay(alignment: .top) {
+ Divider().opacity(0.45)
+ }
+ .onChange(of: self.youtubePlayer.progress) { _, _ in
+ if !self.isScrubbing {
+ self.scrubFraction = self.progressFraction
+ }
+ }
+ .onAppear {
+ self.scrubFraction = self.progressFraction
+ }
+ }
+
+ // MARK: - Progress bar
+
+ private var progressBar: some View {
+ GeometryReader { geo in
+ let width = geo.size.width
+ let fraction = self.isScrubbing ? self.scrubFraction : self.progressFraction
+
+ ZStack(alignment: .leading) {
+ Capsule()
+ .fill(.primary.opacity(0.12))
+ .frame(height: 4)
+ Capsule()
+ .fill(Self.brandAccent)
+ .frame(width: max(4, width * fraction), height: 4)
+ }
+ .frame(maxHeight: .infinity)
+ .contentShape(Rectangle())
+ .gesture(
+ DragGesture(minimumDistance: 0)
+ .onChanged { value in
+ self.isScrubbing = true
+ self.scrubFraction = min(max(value.location.x / max(width, 1), 0), 1)
+ }
+ .onEnded { value in
+ let fraction = min(max(value.location.x / max(width, 1), 0), 1)
+ self.scrubFraction = fraction
+ let duration = self.youtubePlayer.duration
+ if duration > 0 {
+ self.youtubePlayer.seek(to: fraction * duration)
+ }
+ self.isScrubbing = false
+ }
+ )
+ }
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.miniPlayerProgress)
+ }
+
+ // MARK: - Helpers
+
+ private var progressFraction: Double {
+ let dur = self.youtubePlayer.duration
+ guard dur > 0 else { return 0 }
+ return min(max(self.youtubePlayer.progress / dur, 0), 1)
+ }
+
+ private var displayProgress: Double {
+ if self.isScrubbing {
+ return self.scrubFraction * max(self.youtubePlayer.duration, 0)
+ }
+ return self.youtubePlayer.progress
+ }
+
+ private static func formatTime(_ seconds: Double) -> String {
+ guard seconds.isFinite, seconds >= 0 else { return "0:00" }
+ let total = Int(seconds)
+ let mins = total / 60
+ let secs = total % 60
+ return String(format: "%d:%02d", mins, secs)
+ }
+}
+
+// MARK: - Convenience Modifier
+
+extension View {
+ /// Attaches the in-app mini player to the bottom of any YouTube content
+ /// view, sitting above the `YouTubePlayerBar` inset.
+ func youtubeMiniPlayerOverlay() -> some View {
+ self.safeAreaInset(edge: .bottom, spacing: 0) {
+ YouTubeInAppMiniPlayer()
+ }
+ }
+}
+
+// MARK: - AccessibilityID Additions
+
+extension AccessibilityID.YouTubeContent {
+ static let miniPlayerSeekBack = "youtubeContent.miniPlayer.seekBack"
+ static let miniPlayerSeekForward = "youtubeContent.miniPlayer.seekForward"
+ static let miniPlayerProgress = "youtubeContent.miniPlayer.progress"
+ /// Kept for existing UI tests / references.
+ static let miniPlayerPlayPause = "youtubeContent.miniPlayer.playPause"
+ static let miniPlayerClose = "youtubeContent.miniPlayer.close"
+}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeNotesSheet.swift b/Sources/Kaset/Views/YouTube/YouTubeNotesSheet.swift
new file mode 100644
index 000000000..24cca3d70
--- /dev/null
+++ b/Sources/Kaset/Views/YouTube/YouTubeNotesSheet.swift
@@ -0,0 +1,384 @@
+import SwiftUI
+
+// MARK: - YouTubeNotesSheet
+
+/// Sheet that generates structured lecture notes from a YouTube video using
+/// Antigravity CLI (`agy`), then exports them as a LaTeX PDF to ~/Downloads.
+///
+/// Shows generation progress, a preview of the generated notes, and the export result.
+struct YouTubeNotesSheet: View {
+ let video: YouTubeVideo
+ let viewModel: YouTubeWatchViewModel
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var notesService = LectureNotesService()
+ @State private var showLatexSource = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ self.header
+
+ Divider()
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ switch self.notesService.state {
+ case .idle:
+ self.idleContent
+ case .generatingNotes:
+ self.generatingContent(phase: "Generating lecture notes with Antigravity…")
+ case .renderingPDF:
+ self.generatingContent(phase: "Compiling LaTeX to PDF…")
+ case let .completed(url):
+ self.completedContent(url: url)
+ case let .failed(message):
+ self.failedContent(message: message)
+ }
+ }
+ .padding(20)
+ }
+ }
+ .frame(width: 560, height: 520)
+ }
+
+ // MARK: - Header
+
+ private var header: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Generate Lecture Notes", comment: "Notes sheet title")
+ .font(.headline)
+ Text(self.video.title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ Spacer()
+
+ Button {
+ self.dismiss()
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(16)
+ }
+
+ // MARK: - Idle State
+
+ private var idleContent: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ // Description
+ HStack(spacing: 12) {
+ Image(systemName: "doc.text.fill")
+ .font(.system(size: 32))
+ .foregroundStyle(.purple)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("AI-Powered Lecture Notes", comment: "Notes feature headline")
+ .font(.subheadline.weight(.semibold))
+ Text(
+ "Generate structured notes from this video using Antigravity CLI. Notes are exported as a beautifully formatted LaTeX PDF to your Downloads folder.",
+ comment: "Notes feature description"
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.purple.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
+
+ // What's included
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Your notes will include:", comment: "Notes include section title")
+ .font(.subheadline.weight(.medium))
+
+ Self.featureRow(icon: "text.alignleft", text: "Title & abstract")
+ Self.featureRow(icon: "lightbulb", text: "Key concepts & definitions")
+ Self.featureRow(icon: "list.bullet.rectangle", text: "Detailed structured sections")
+ Self.featureRow(icon: "checkmark.circle", text: "Key takeaways")
+ Self.featureRow(icon: "book", text: "References & further reading")
+ }
+
+ Divider()
+
+ // Context info
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Context sources:", comment: "Notes context sources header")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 16) {
+ Self.contextBadge(
+ icon: "film",
+ label: "Video metadata",
+ available: true
+ )
+ Self.contextBadge(
+ icon: "text.bubble",
+ label: "Comments",
+ available: !self.viewModel.comments.isEmpty
+ )
+ Self.contextBadge(
+ icon: "terminal",
+ label: "Antigravity CLI",
+ available: true
+ )
+ }
+ }
+
+ // Powered by badge
+ HStack(spacing: 6) {
+ Image(systemName: "sparkle")
+ .font(.system(size: 10))
+ .foregroundStyle(.purple)
+ Text("Powered by Antigravity CLI (agy)", comment: "Powered by badge")
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ }
+ .padding(.top, 4)
+
+ Spacer(minLength: 16)
+
+ // Generate button
+ Button {
+ Task { await self.generateNotes() }
+ } label: {
+ Label("Generate Notes", systemImage: "sparkles")
+ .font(.system(size: 14, weight: .semibold))
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 10)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(.purple)
+ .controlSize(.large)
+ }
+ }
+
+ // MARK: - Generating State
+
+ private func generatingContent(phase: String) -> some View {
+ VStack(spacing: 20) {
+ Spacer(minLength: 40)
+
+ ProgressView()
+ .controlSize(.large)
+
+ Text(phase)
+ .font(.headline)
+
+ Text("Using Antigravity CLI to analyze video content and generate structured notes.", comment: "Progress note during notes generation")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+
+ Spacer(minLength: 40)
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ // MARK: - Completed State
+
+ private func completedContent(url: URL) -> some View {
+ VStack(alignment: .leading, spacing: 16) {
+ // Success banner
+ HStack(spacing: 12) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 28))
+ .foregroundStyle(.green)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Notes generated!", comment: "Notes generation success title")
+ .font(.subheadline.weight(.semibold))
+ Text(url.lastPathComponent)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ Spacer()
+ }
+ .padding(12)
+ .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
+
+ // Preview of generated notes
+ if !self.notesService.notesSections.isEmpty {
+ self.notesPreview
+ }
+
+ // Actions
+ HStack(spacing: 12) {
+ Button {
+ NSWorkspace.shared.open(url)
+ } label: {
+ Label(
+ url.pathExtension == "pdf" ? "Open PDF" : "Open File",
+ systemImage: "doc.fill"
+ )
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(.purple)
+
+ Button {
+ NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path)
+ } label: {
+ Label("Show in Finder", systemImage: "folder")
+ }
+ .buttonStyle(.bordered)
+
+ if self.notesService.latexSource != nil {
+ Button {
+ self.showLatexSource.toggle()
+ } label: {
+ Label(
+ self.showLatexSource ? "Hide LaTeX" : "Show LaTeX",
+ systemImage: "chevron.left.forwardslash.chevron.right"
+ )
+ }
+ .buttonStyle(.bordered)
+ }
+ }
+
+ if url.pathExtension == "tex" {
+ Text("No LaTeX compiler found — saved .tex source. Install tectonic (`brew install tectonic`) for PDF output.", comment: "No compiler hint")
+ .font(.caption)
+ .foregroundStyle(.orange)
+ }
+
+ if self.showLatexSource, let latex = self.notesService.latexSource {
+ ScrollView {
+ Text(latex)
+ .font(.system(size: 11, design: .monospaced))
+ .textSelection(.enabled)
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(maxHeight: 200)
+ .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 8))
+ }
+ }
+ }
+
+ // MARK: - Failed State
+
+ private func failedContent(message: String) -> some View {
+ VStack(spacing: 16) {
+ Spacer(minLength: 40)
+
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 40))
+ .foregroundStyle(.orange)
+
+ Text("Generation failed", comment: "Notes generation failure title")
+ .font(.headline)
+
+ Text(message)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 20)
+
+ Button("Try Again") {
+ self.notesService.reset()
+ }
+ .buttonStyle(.bordered)
+
+ Spacer(minLength: 40)
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ // MARK: - Notes Preview
+
+ private var notesPreview: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Sections", comment: "Notes preview section header")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ VStack(alignment: .leading, spacing: 6) {
+ ForEach(Array(self.notesService.notesSections.enumerated()), id: \.offset) { index, heading in
+ HStack(spacing: 8) {
+ Text("\(index + 1).")
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ .frame(width: 20, alignment: .trailing)
+ Text(heading)
+ .font(.system(size: 12))
+ .lineLimit(1)
+ }
+ }
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.quaternary.opacity(0.2), in: RoundedRectangle(cornerRadius: 8))
+ }
+ }
+
+ // MARK: - Helpers
+
+ private static func featureRow(icon: String, text: LocalizedStringKey) -> some View {
+ HStack(spacing: 8) {
+ Image(systemName: icon)
+ .font(.system(size: 12))
+ .foregroundStyle(.purple)
+ .frame(width: 20)
+ Text(text)
+ .font(.caption)
+ }
+ }
+
+ private static func contextBadge(icon: String, label: String, available: Bool) -> some View {
+ HStack(spacing: 4) {
+ Image(systemName: icon)
+ .font(.system(size: 10))
+ Text(label)
+ .font(.system(size: 10))
+ }
+ .foregroundStyle(available ? .primary : .tertiary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(
+ available ? Color.purple.opacity(0.1) : Color.secondary.opacity(0.08),
+ in: Capsule()
+ )
+ }
+
+ // MARK: - Generation
+
+ private func generateNotes() async {
+ let title = self.viewModel.data.videoTitle ?? self.video.title
+ let channel = self.viewModel.data.channel?.name ?? self.video.channelName
+ let views = self.viewModel.data.viewCountText ?? self.video.viewCountText ?? ""
+ let published = self.viewModel.data.publishedText ?? self.video.publishedText ?? ""
+ let length = self.video.lengthText ?? ""
+
+ let metadata = """
+ Views: \(views)
+ Published: \(published)
+ Length: \(length)
+ """
+
+ let comments = self.viewModel.comments.prefix(10).map { comment in
+ let text = comment.text.trimmingCharacters(in: .whitespacesAndNewlines)
+ return text.count > 150 ? String(text.prefix(150)) + "…" : text
+ }
+
+ do {
+ _ = try await self.notesService.generateAndExport(
+ videoTitle: title,
+ channelName: channel,
+ metadata: metadata,
+ comments: Array(comments),
+ captionsContext: nil
+ )
+ } catch {
+ DiagnosticsLogger.ai.error("Lecture notes generation failed: \(error.localizedDescription)")
+ }
+ }
+}
diff --git a/Sources/Kaset/Views/YouTube/YouTubePlayerBar.swift b/Sources/Kaset/Views/YouTube/YouTubePlayerBar.swift
index 89128e3d7..918105ce4 100644
--- a/Sources/Kaset/Views/YouTube/YouTubePlayerBar.swift
+++ b/Sources/Kaset/Views/YouTube/YouTubePlayerBar.swift
@@ -34,7 +34,27 @@ struct YouTubePlayerBar: View {
@State private var isAdjustingVolume = false
@State private var showsVolumeOverlay = false
+ /// Hide the bar entirely when nothing is loaded (e.g. YouTube Home with
+ /// no active video). An empty “Not Playing” capsule was confusing and
+ /// stole bottom space for no reason.
+ private var shouldShowBar: Bool {
+ self.youtubePlayer.currentVideo != nil
+ }
+
var body: some View {
+ Group {
+ if self.shouldShowBar {
+ self.barContent
+ .transition(.move(edge: .bottom).combined(with: .opacity))
+ }
+ }
+ .animation(
+ .spring(response: 0.35, dampingFraction: 0.86),
+ value: self.youtubePlayer.currentVideo?.videoId
+ )
+ }
+
+ private var barContent: some View {
CompatGlassContainer(spacing: 0) {
GeometryReader { proxy in
let usesCompactDetails = proxy.size.width <= PlayerBarLayout.compactDetailsBreakpoint
@@ -396,6 +416,7 @@ struct YouTubePlayerBar: View {
)
.disabled(self.youtubePlayer.currentVideo == nil)
+ self.compactSpeedMenu
self.compactCaptionsMenu
self.compactQualityMenu
@@ -480,6 +501,39 @@ struct YouTubePlayerBar: View {
.disabled(self.youtubePlayer.qualityLevels.isEmpty)
}
+ private static let playbackSpeeds: [Double] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
+
+ private static func speedLabel(_ speed: Double) -> String {
+ speed == 1.0 ? String(localized: "Normal") : String(format: "%.2g×", speed)
+ }
+
+ private var compactSpeedMenu: some View {
+ let isNonDefault = self.youtubePlayer.playbackSpeed != 1.0
+ return PlayerBarIconMenu(
+ isSelected: isNonDefault,
+ accessibilityID: AccessibilityID.YouTubeContent.watchSpeedButton,
+ accessibilityLabel: String(localized: "Playback speed")
+ ) {
+ ForEach(Self.playbackSpeeds, id: \.self) { speed in
+ Button {
+ self.youtubePlayer.selectPlaybackSpeed(speed)
+ } label: {
+ if self.youtubePlayer.playbackSpeed == speed {
+ Label(Self.speedLabel(speed), systemImage: "checkmark")
+ } else {
+ Text(Self.speedLabel(speed))
+ }
+ }
+ }
+ } icon: {
+ Image(systemName: "gauge.with.dots.needle.67percent")
+ .font(.system(size: 15, weight: .regular))
+ .frame(width: 16, height: 16)
+ .foregroundStyle(isNonDefault ? Self.brandAccent : .primary)
+ }
+ .disabled(self.youtubePlayer.currentVideo == nil)
+ }
+
private var youtubeVolumeOverlay: some View {
CompatGlassContainer(spacing: 0) {
VStack(spacing: 10) {
@@ -534,7 +588,7 @@ struct YouTubePlayerBar: View {
}
private var youtubeOptionsWidth: CGFloat {
- 210
+ 236
}
/// Fraction (0...1) to render: the live drag value while seeking, otherwise actual progress.
@@ -693,4 +747,5 @@ extension AccessibilityID.YouTubeContent {
static let watchFullView = "youtubeContent.watchFullView"
static let captionsButton = "youtubeContent.captionsButton"
static let qualityButton = "youtubeContent.qualityButton"
+ static let watchSpeedButton = "youtubeContent.watchSpeedButton"
}
diff --git a/Sources/Kaset/Views/YouTube/YouTubePlaylistDetailView.swift b/Sources/Kaset/Views/YouTube/YouTubePlaylistDetailView.swift
index f926d7c8d..08f5dc143 100644
--- a/Sources/Kaset/Views/YouTube/YouTubePlaylistDetailView.swift
+++ b/Sources/Kaset/Views/YouTube/YouTubePlaylistDetailView.swift
@@ -1,9 +1,14 @@
import SwiftUI
/// A YouTube playlist page: header plus its video rows.
+/// Playing any video (or “Play as course”) starts course mode — the watch
+/// page then shows a curriculum sidebar with completed / current / next topics.
struct YouTubePlaylistDetailView: View {
@State private var viewModel: YouTubePlaylistViewModel
+ @Environment(YouTubePlayerService.self) private var youtubePlayer
+ @Environment(AuthService.self) private var authService
+ private static let brandAccent = PackageResourceLookup.brandAccent
private static let columns = [
GridItem(.adaptive(minimum: 210, maximum: 320), spacing: 16),
]
@@ -44,7 +49,7 @@ struct YouTubePlaylistDetailView: View {
private func content(for detail: YouTubePlaylistDetail) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
- VStack(alignment: .leading, spacing: 4) {
+ VStack(alignment: .leading, spacing: 8) {
Text(detail.playlist.title)
.font(.title.bold())
.lineLimit(2)
@@ -56,6 +61,36 @@ struct YouTubePlaylistDetailView: View {
.font(.callout)
.foregroundStyle(.secondary)
}
+
+ if !detail.videos.isEmpty {
+ HStack(spacing: 10) {
+ // Start course from the first incomplete lesson, or the first video.
+ NavigationLink(value: YouTubeRoute.watch(self.courseStartVideo(in: detail))) {
+ Label(
+ String(localized: "Play as Course"),
+ systemImage: "list.bullet.rectangle.portrait.fill"
+ )
+ .font(.system(size: 13, weight: .semibold))
+ .padding(.horizontal, 14)
+ .frame(height: 34)
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .compatGlass(interactive: true, tint: Self.brandAccent, in: Capsule())
+ .simultaneousGesture(TapGesture().onEnded {
+ self.beginCourse(detail: detail, startingAt: self.courseStartVideo(in: detail))
+ })
+
+ Text(
+ "Watch as a course with progress, completed lessons, and what’s next.",
+ comment: "Course mode explainer under playlist header"
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ .padding(.top, 4)
+ }
}
if detail.videos.isEmpty {
@@ -69,6 +104,9 @@ struct YouTubePlaylistDetailView: View {
VideoCard(video: video)
}
.buttonStyle(.interactiveCard)
+ .simultaneousGesture(TapGesture().onEnded {
+ self.beginCourse(detail: detail, startingAt: video)
+ })
}
}
}
@@ -79,4 +117,34 @@ struct YouTubePlaylistDetailView: View {
// floating glass sidebar.
.contentMargins(.horizontal, DetailContentLayout.horizontalInset, for: .scrollContent)
}
+
+ // MARK: - Course entry
+
+ private func courseStartVideo(in detail: YouTubePlaylistDetail) -> YouTubeVideo {
+ let course = YouTubeCourseSession.shared
+ // Prefer first incomplete if we already have progress for this playlist.
+ let completed = course.playlistId == detail.playlist.playlistId
+ ? course.completedVideoIds
+ : Set(
+ (UserDefaults.standard.array(
+ forKey: "youtube.course.completed.\(detail.playlist.playlistId)"
+ ) as? [String]) ?? []
+ )
+ return detail.videos.first { !completed.contains($0.videoId) && !$0.isShort }
+ ?? detail.videos.first { !$0.isShort }
+ ?? detail.videos[0]
+ }
+
+ private func beginCourse(detail: YouTubePlaylistDetail, startingAt video: YouTubeVideo) {
+ YouTubeCourseSession.shared.start(
+ playlist: detail.playlist,
+ lessons: detail.videos,
+ startingAt: video
+ )
+ // Pre-seed the player queue so skip-next walks the course.
+ if self.youtubePlayer.currentVideo?.videoId == video.videoId {
+ self.youtubePlayer.setCourseQueue(YouTubeCourseSession.shared.remainingLessons)
+ }
+ HapticService.toggle()
+ }
}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeSidebar.swift b/Sources/Kaset/Views/YouTube/YouTubeSidebar.swift
index 62f96c4d8..15c89478d 100644
--- a/Sources/Kaset/Views/YouTube/YouTubeSidebar.swift
+++ b/Sources/Kaset/Views/YouTube/YouTubeSidebar.swift
@@ -28,6 +28,11 @@ struct YouTubeSidebar: View {
self.row(for: .shorts)
}
+ // Learning — local course library (folders + playlists played as courses)
+ Section(String(localized: "Learning")) {
+ self.row(for: .courses)
+ }
+
if self.hasPersonalAccount {
// Collection section
Section(String(localized: "Collection")) {
diff --git a/Sources/Kaset/Views/YouTube/YouTubeVideoAIPanel.swift b/Sources/Kaset/Views/YouTube/YouTubeVideoAIPanel.swift
new file mode 100644
index 000000000..3abde1767
--- /dev/null
+++ b/Sources/Kaset/Views/YouTube/YouTubeVideoAIPanel.swift
@@ -0,0 +1,576 @@
+import FoundationModels
+import os
+import SwiftUI
+
+// MARK: - YouTubeVideoAIPanel
+
+/// YouTube **Ask** — on-device Apple Intelligence for the current video.
+///
+/// Designed to work the same way Music AI does: always visible entry, refresh
+/// availability on open, and a plain-text generation path that does not depend
+/// solely on `@Generable` structured decoding (which was failing more often
+/// on video prompts).
+@available(macOS 26.0, *)
+struct YouTubeVideoAIPanel: View {
+ let video: YouTubeVideo
+ let viewModel: YouTubeWatchViewModel
+ var isExpanded: Binding?
+
+ @State private var summaryText: String?
+ @State private var isSummarizing = false
+ @State private var summaryError: String?
+
+ @State private var question = ""
+ @State private var turns: [VideoAskTurn] = []
+ @State private var isAnswering = false
+ @State private var answerError: String?
+ @State private var availabilityHint: String?
+
+ @State private var suggestedQuestions: [String] = []
+ @FocusState private var isQuestionFocused: Bool
+
+ private let logger = DiagnosticsLogger.ai
+
+ private var expanded: Bool {
+ self.isExpanded?.wrappedValue ?? true
+ }
+
+ private var subtitleText: String {
+ if let availabilityHint {
+ return availabilityHint
+ }
+ return String(localized: "Ask questions about this video")
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ self.header
+
+ if self.expanded {
+ self.expandedContent
+ .transition(.opacity.combined(with: .move(edge: .top)))
+ }
+ }
+ .padding(14)
+ .background(.quaternary.opacity(0.25), in: RoundedRectangle(cornerRadius: 14))
+ .overlay {
+ RoundedRectangle(cornerRadius: 14)
+ .strokeBorder(.purple.opacity(0.18), lineWidth: 1)
+ }
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.aiPanel)
+ .task(id: self.video.videoId) {
+ self.turns = []
+ self.summaryText = nil
+ self.suggestedQuestions = self.heuristicSuggestions
+ self.question = ""
+ self.answerError = nil
+ self.summaryError = nil
+ await self.refreshAIStatus()
+ if self.expanded {
+ self.isQuestionFocused = true
+ }
+ }
+ .onChange(of: self.expanded) { _, isOpen in
+ if isOpen {
+ self.isQuestionFocused = true
+ Task { await self.refreshAIStatus() }
+ }
+ }
+ }
+
+ // MARK: - Header
+
+ private var header: some View {
+ HStack(spacing: 10) {
+ ZStack {
+ Circle()
+ .fill(.purple.opacity(0.18))
+ .frame(width: 32, height: 32)
+ Image(systemName: "bubble.left.and.text.bubble.right.fill")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.purple)
+ }
+
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Ask", comment: "YouTube-style Ask feature title")
+ .font(.headline)
+ Text(self.subtitleText)
+ .font(.caption)
+ .foregroundStyle(self.availabilityHint == nil ? Color.secondary : Color.orange)
+ .lineLimit(2)
+ }
+
+ Spacer(minLength: 0)
+
+ if let isExpanded {
+ Button {
+ withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) {
+ isExpanded.wrappedValue.toggle()
+ }
+ } label: {
+ Image(systemName: self.expanded ? "chevron.up" : "chevron.down")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .frame(width: 28, height: 28)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(
+ self.expanded
+ ? String(localized: "Collapse Ask")
+ : String(localized: "Expand Ask")
+ )
+ }
+ }
+ }
+
+ // MARK: - Expanded
+
+ @ViewBuilder
+ private var expandedContent: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Button {
+ Task { await self.summarize() }
+ } label: {
+ if self.isSummarizing {
+ ProgressView().controlSize(.small)
+ } else {
+ Label(
+ self.summaryText == nil
+ ? String(localized: "Summarize video")
+ : String(localized: "Re-summarize"),
+ systemImage: "text.alignleft"
+ )
+ }
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .disabled(self.isSummarizing)
+
+ if !self.turns.isEmpty {
+ Button(String(localized: "Clear chat")) {
+ self.turns = []
+ self.answerError = nil
+ }
+ .buttonStyle(.borderless)
+ .controlSize(.small)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ }
+
+ if let summaryText {
+ Text(summaryText)
+ .font(.callout)
+ .textSelection(.enabled)
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.purple.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
+ } else if let summaryError {
+ Text(summaryError)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+
+ if !self.turns.isEmpty {
+ self.conversation
+ } else if !self.suggestedQuestions.isEmpty {
+ self.suggestionsSection
+ } else {
+ Text(
+ "Ask anything about this video. Uses on-device Apple Intelligence with the title, channel, and comments.",
+ comment: "Ask empty help"
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ if let answerError {
+ Text(answerError)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+
+ self.composer
+ }
+ }
+
+ private var suggestionsSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Suggested", comment: "Ask suggested questions header")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ FlowLayout(spacing: 6) {
+ ForEach(self.suggestedQuestions, id: \.self) { suggestion in
+ Button {
+ Task { await self.ask(prefilled: suggestion) }
+ } label: {
+ Text(suggestion)
+ .font(.caption.weight(.medium))
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(.purple.opacity(0.12), in: Capsule())
+ .foregroundStyle(.primary)
+ }
+ .buttonStyle(.plain)
+ .disabled(self.isAnswering)
+ }
+ }
+ }
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.askSuggestions)
+ }
+
+ private var conversation: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(self.turns) { turn in
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ Spacer(minLength: 40)
+ Text(turn.question)
+ .font(.callout)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(.purple.opacity(0.16), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .textSelection(.enabled)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ if let answer = turn.answer, !answer.isEmpty {
+ Text(answer)
+ .font(.callout)
+ .textSelection(.enabled)
+ } else if turn.isStreaming {
+ ProgressView().controlSize(.mini)
+ }
+ if let caveat = turn.caveat,
+ !caveat.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ {
+ Text(caveat)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ }
+ }
+ }
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.askConversation)
+ }
+
+ private var composer: some View {
+ HStack(spacing: 8) {
+ TextField(
+ String(localized: "Ask anything about this video…"),
+ text: self.$question
+ )
+ .textFieldStyle(.plain)
+ .padding(.horizontal, 12)
+ .frame(height: 34)
+ .background(.quaternary.opacity(0.55), in: Capsule())
+ .focused(self.$isQuestionFocused)
+ .onSubmit { Task { await self.ask() } }
+ .disabled(self.isAnswering)
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.aiQuestionField)
+
+ Button {
+ Task { await self.ask() }
+ } label: {
+ if self.isAnswering {
+ ProgressView()
+ .controlSize(.small)
+ .frame(width: 28, height: 28)
+ } else {
+ Image(systemName: "arrow.up.circle.fill")
+ .font(.system(size: 28))
+ .foregroundStyle(.purple)
+ .symbolRenderingMode(.hierarchical)
+ }
+ }
+ .buttonStyle(.plain)
+ .disabled(
+ self.question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || self.isAnswering
+ )
+ .accessibilityLabel(String(localized: "Ask"))
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.aiAskButton)
+ }
+ }
+
+ // MARK: - Context
+
+ private var title: String {
+ self.viewModel.data.videoTitle ?? self.video.title
+ }
+
+ private var channelName: String? {
+ self.viewModel.data.channel?.name ?? self.video.channelName
+ }
+
+ private var contextBlock: String {
+ let channel = self.channelName ?? "Unknown channel"
+ let views = self.viewModel.data.viewCountText ?? self.video.viewCountText ?? "unknown views"
+ let published = self.viewModel.data.publishedText ?? self.video.publishedText ?? "unknown date"
+ let length = self.video.lengthText ?? "unknown length"
+
+ let comments = self.viewModel.comments.prefix(5).map { comment in
+ let text = comment.text.trimmingCharacters(in: .whitespacesAndNewlines)
+ let clipped = text.count > 120 ? String(text.prefix(120)) + "…" : text
+ return "- \(comment.author): \(clipped)"
+ }.joined(separator: "\n")
+
+ let related = self.viewModel.data.related.prefix(5).map(\.title)
+ .enumerated()
+ .map { "\($0.offset + 1). \($0.element)" }
+ .joined(separator: "\n")
+
+ return """
+ Title: \(self.title)
+ Channel: \(channel)
+ Views: \(views)
+ Published: \(published)
+ Length: \(length)
+
+ Comments:
+ \(comments.isEmpty ? "(none)" : comments)
+
+ Related:
+ \(related.isEmpty ? "(none)" : related)
+ """
+ }
+
+ private var heuristicSuggestions: [String] {
+ var items = [
+ String(localized: "What is this video about?"),
+ String(localized: "Who is this for?"),
+ String(localized: "What are the key takeaways?"),
+ ]
+ if let channel = self.channelName, !channel.isEmpty {
+ items.append(String(localized: "Who is \(channel)?"))
+ }
+ items.append(String(localized: "Is this worth watching?"))
+ return Array(items.prefix(5))
+ }
+
+ // MARK: - AI actions
+
+ private func refreshAIStatus() async {
+ if let reason = await FoundationModelsService.shared.prepareForInteractiveUse() {
+ self.availabilityHint = reason
+ self.logger.warning("YouTube Ask not ready: \(reason, privacy: .public)")
+ } else {
+ self.availabilityHint = nil
+ self.logger.info("YouTube Ask ready")
+ }
+ }
+
+ private func makeSession(instructions: String) -> LanguageModelSession? {
+ FoundationModelsService.shared.createAnalysisSession(instructions: instructions)
+ }
+
+ private func summarize() async {
+ self.isSummarizing = true
+ self.summaryError = nil
+ defer { self.isSummarizing = false }
+
+ if let reason = await FoundationModelsService.shared.prepareForInteractiveUse() {
+ self.summaryError = reason
+ self.availabilityHint = reason
+ return
+ }
+
+ let instructions = """
+ You summarize YouTube videos for the Kaset macOS app.
+ Use only the provided metadata. Do not invent a transcript.
+ Reply with a short plain-text summary (3-6 sentences). No JSON.
+ """
+
+ guard let session = self.makeSession(instructions: instructions) else {
+ self.summaryError = String(localized: "Apple Intelligence is not available")
+ return
+ }
+
+ let prompt = """
+ Summarize this video for a viewer deciding whether to watch:
+
+ \(self.contextBlock)
+
+ Write a clear plain-text summary.
+ """
+
+ do {
+ // Plain-text path — same reliability model as free-form generation.
+ let response = try await session.respond(to: prompt)
+ let text = response.content.trimmingCharacters(in: .whitespacesAndNewlines)
+ if text.isEmpty {
+ self.summaryError = String(localized: "Couldn’t generate a summary. Try again.")
+ } else {
+ self.summaryText = text
+ }
+ } catch {
+ // Structured fallback if plain text fails for any reason.
+ do {
+ let structured = try await session.respond(to: prompt, generating: VideoSummary.self)
+ let s = structured.content
+ self.summaryText = "\(s.headline)\n\n\(s.overview)"
+ } catch {
+ self.summaryError = AIErrorHandler.handleAndMessage(error, context: "video summary")
+ ?? error.localizedDescription
+ self.logger.error("YouTube summarize failed: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+ }
+
+ private func ask(prefilled: String? = nil) async {
+ let q = (prefilled ?? self.question).trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !q.isEmpty else { return }
+
+ self.isAnswering = true
+ self.answerError = nil
+ self.question = ""
+ defer { self.isAnswering = false }
+
+ let turn = VideoAskTurn(question: q, isStreaming: true)
+ self.turns.append(turn)
+ let turnID = turn.id
+
+ if let reason = await FoundationModelsService.shared.prepareForInteractiveUse() {
+ self.answerError = reason
+ self.availabilityHint = reason
+ self.updateTurn(id: turnID) {
+ $0.isStreaming = false
+ $0.answer = reason
+ }
+ return
+ }
+
+ let prior = self.turns
+ .filter { $0.id != turnID }
+ .suffix(3)
+ .compactMap { t -> String? in
+ guard let a = t.answer, !a.isEmpty else { return nil }
+ return "User: \(t.question)\nAssistant: \(a)"
+ }
+ .joined(separator: "\n\n")
+
+ let instructions = """
+ You answer questions about a YouTube video in the Kaset app.
+ Use only the provided metadata (title, channel, stats, comments, related titles).
+ If you cannot know the answer from that context, say so briefly.
+ Reply in plain text only. No JSON. Be concise and helpful.
+ """
+
+ guard let session = self.makeSession(instructions: instructions) else {
+ let message = String(localized: "Apple Intelligence is not available")
+ self.answerError = message
+ self.updateTurn(id: turnID) {
+ $0.isStreaming = false
+ $0.answer = message
+ }
+ return
+ }
+
+ let historyBlock = prior.isEmpty ? "" : "\nRecent conversation:\n\(prior)\n"
+ let prompt = """
+ Question: \(q)
+
+ Video metadata:
+ \(self.contextBlock)
+ \(historyBlock)
+ Answer the question in plain text.
+ """
+
+ do {
+ // Primary path: plain text (matches Music free-form reliability).
+ let response = try await session.respond(to: prompt)
+ let text = response.content.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.updateTurn(id: turnID) {
+ $0.isStreaming = false
+ $0.answer = text.isEmpty
+ ? String(localized: "I couldn’t generate an answer. Try rephrasing.")
+ : text
+ }
+ } catch {
+ self.logger.warning("YouTube Ask plain-text failed, trying structured: \(error.localizedDescription, privacy: .public)")
+ do {
+ let structured = try await session.respond(to: prompt, generating: VideoAnswer.self)
+ self.updateTurn(id: turnID) {
+ $0.isStreaming = false
+ $0.answer = structured.content.answer
+ $0.caveat = structured.content.caveat
+ }
+ } catch {
+ let message = AIErrorHandler.handleAndMessage(error, context: "video ask")
+ ?? error.localizedDescription
+ self.answerError = message
+ self.updateTurn(id: turnID) {
+ $0.isStreaming = false
+ $0.answer = message
+ }
+ self.logger.error("YouTube Ask failed: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+ }
+
+ private func updateTurn(id: UUID, mutate: (inout VideoAskTurn) -> Void) {
+ guard let index = self.turns.firstIndex(where: { $0.id == id }) else { return }
+ var copy = self.turns[index]
+ mutate(©)
+ self.turns[index] = copy
+ }
+}
+
+// MARK: - FlowLayout
+
+@available(macOS 26.0, *)
+private struct FlowLayout: Layout {
+ var spacing: CGFloat = 6
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize {
+ let maxWidth = proposal.width ?? .infinity
+ var x: CGFloat = 0
+ var y: CGFloat = 0
+ var rowHeight: CGFloat = 0
+ var totalHeight: CGFloat = 0
+ var totalWidth: CGFloat = 0
+
+ for subview in subviews {
+ let size = subview.sizeThatFits(.unspecified)
+ if x + size.width > maxWidth, x > 0 {
+ y += rowHeight + self.spacing
+ totalHeight = y
+ x = 0
+ rowHeight = 0
+ }
+ x += size.width + self.spacing
+ rowHeight = max(rowHeight, size.height)
+ totalWidth = max(totalWidth, x - self.spacing)
+ totalHeight = y + rowHeight
+ }
+
+ return CGSize(width: totalWidth, height: totalHeight)
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) {
+ var x = bounds.minX
+ var y = bounds.minY
+ var rowHeight: CGFloat = 0
+
+ for subview in subviews {
+ let size = subview.sizeThatFits(.unspecified)
+ if x + size.width > bounds.maxX, x > bounds.minX {
+ y += rowHeight + self.spacing
+ x = bounds.minX
+ rowHeight = 0
+ }
+ subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
+ x += size.width + self.spacing
+ rowHeight = max(rowHeight, size.height)
+ }
+ }
+}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift b/Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift
index 43a8d3929..45f6a909c 100644
--- a/Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift
+++ b/Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift
@@ -313,63 +313,56 @@ final class YouTubeVideoWindowResizeGuard: NSObject, NSWindowDelegate {
// MARK: - YouTubeVideoWindowContent
-/// Floating window content: corner-to-corner video with hover-revealed
-/// chrome — a compact Liquid Glass bar over the bottom of the video and a
-/// small glass backing under the traffic lights. Cursor leaves → all
-/// chrome fades out.
+/// Floating window content: corner-to-corner video with auto-hiding
+/// Liquid Glass chrome. Fullscreen and windowed both idle-hide the control
+/// strip so the picture stays immersive; mouse movement brings it back.
+///
+/// Uses a dedicated slim glass overlay instead of embedding the main-window
+/// `YouTubePlayerBar` (whose safe-area padding and layout caused scroll/jank
+/// when toggling visibility over the WebView).
private struct YouTubeVideoWindowContent: View {
@Environment(YouTubePlayerService.self) private var youtubePlayer
- @State private var isHovering = false
-
/// Height of the top strip that moves the window. Generous enough to be
/// an easy grab target; the top of the video carries no YouTube controls
/// (the scrubber lives at the bottom), so it costs no native click area.
private static let dragStripHeight: CGFloat = 36
var body: some View {
- ZStack(alignment: .topLeading) {
- ZStack(alignment: .bottom) {
- YouTubeWatchSurfaceView()
- .frame(maxWidth: .infinity, maxHeight: .infinity)
-
- if self.isHovering {
- // The full player bar — same items as the main window.
- YouTubePlayerBar()
- .transition(.opacity)
- }
- }
+ ZStack {
+ YouTubeWatchSurfaceView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ // Kill WebView scroll rubber-banding that felt “buggy” in
+ // fullscreen when the trackpad moved while chrome was hidden.
+ .clipped()
+
+ // Auto-hiding Liquid Glass controls (fullscreen + windowed).
+ YouTubeFullscreenChrome()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
// Top drag strip: the corner-to-corner WebView reports
// mouseDownCanMoveWindow == false and swallows mouseDown, so the
// window's isMovableByWindowBackground is dead everywhere the
// WebView covers — leaving only the hidden titlebar sliver to grab.
- // This native strip sits above the WebView and moves the window
- // explicitly via NSWindow.performDrag.
- WindowDragHandle()
- .frame(maxWidth: .infinity)
- .frame(height: Self.dragStripHeight)
- .overlay(alignment: .top) {
- if self.isHovering {
- // Subtle grab affordance so the drag region is
- // discoverable without cluttering the chrome-free look.
+ VStack {
+ WindowDragHandle()
+ .frame(maxWidth: .infinity)
+ .frame(height: Self.dragStripHeight)
+ .overlay(alignment: .top) {
Capsule()
- .fill(.white.opacity(0.35))
+ .fill(.white.opacity(self.youtubePlayer.isWindowFullscreen ? 0 : 0.28))
.frame(width: 36, height: 5)
.padding(.top, 7)
- .transition(.opacity)
.allowsHitTesting(false)
}
- }
+ Spacer(minLength: 0)
+ }
}
.background(.black)
.ignoresSafeArea()
- .onHover { hovering in
- withAnimation(.easeInOut(duration: 0.18)) {
- self.isHovering = hovering
- }
- YouTubeVideoWindowController.shared.setWindowChromeVisible(hovering)
- }
+ // Prevent the hosting view from participating in scrollable ancestor
+ // geometry that can jitter during fullscreen transitions.
+ .compositingGroup()
}
}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchSurfaceView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchSurfaceView.swift
index 16856bba9..c8e710059 100644
--- a/Sources/Kaset/Views/YouTube/YouTubeWatchSurfaceView.swift
+++ b/Sources/Kaset/Views/YouTube/YouTubeWatchSurfaceView.swift
@@ -19,9 +19,61 @@ struct YouTubeWatchSurfaceView: NSViewRepresentable {
}
}
+// MARK: - ScrollForwardingWKWebView
+
+/// `WKWebView` that never keeps trackpad/mouse-wheel scrolls for its own
+/// document. Extracted YouTube surfaces have no page chrome to scroll, so
+/// events are forwarded to the enclosing SwiftUI `ScrollView` / responder chain.
+///
+/// Clicks, drags, and media keys still hit the WebView normally.
+final class ScrollForwardingWKWebView: WKWebView {
+ override func scrollWheel(with event: NSEvent) {
+ Self.forwardScroll(event, from: self)
+ }
+
+ /// Also catch magnify-style two-finger pans that sometimes arrive as
+ /// smart-magnify / swipe variants on newer macOS.
+ override func wantsScrollEventsForSwipeTracking(on axis: NSEvent.GestureAxis) -> Bool {
+ // Do not claim swipe tracking — let the parent ScrollView own it.
+ false
+ }
+
+ static func forwardScroll(_ event: NSEvent, from view: NSView) {
+ // Prefer the nearest NSScrollView (SwiftUI ScrollView backing).
+ if let scrollView = view.enclosingScrollView {
+ scrollView.scrollWheel(with: event)
+ return
+ }
+ // Walk the responder / superview chain until something handles it.
+ var responder: NSResponder? = view.nextResponder
+ while let current = responder {
+ if let scrollView = current as? NSScrollView {
+ scrollView.scrollWheel(with: event)
+ return
+ }
+ responder = current.nextResponder
+ }
+ var ancestor: NSView? = view.superview
+ while let current = ancestor {
+ if let scrollView = current as? NSScrollView {
+ scrollView.scrollWheel(with: event)
+ return
+ }
+ if let scrollView = current.enclosingScrollView {
+ scrollView.scrollWheel(with: event)
+ return
+ }
+ ancestor = current.superview
+ }
+ view.nextResponder?.scrollWheel(with: event)
+ }
+}
+
// MARK: - YouTubeWatchContainerView
-/// Custom NSView that keeps the WebView sized with the container.
+/// Custom NSView that keeps the WebView sized with the container and
+/// forwards scroll-wheel events to the enclosing ScrollView so the page
+/// remains scrollable when the cursor is over the playing video.
final class YouTubeWatchContainerView: NSView {
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
@@ -45,4 +97,8 @@ final class YouTubeWatchContainerView: NSView {
subview.frame = self.bounds.insetBy(dx: -1.5, dy: -1.5)
}
}
+
+ override func scrollWheel(with event: NSEvent) {
+ ScrollForwardingWKWebView.forwardScroll(event, from: self)
+ }
}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift
index 09865afd7..127d02b30 100644
--- a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift
+++ b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift
@@ -26,6 +26,11 @@ struct YouTubeWatchView: View {
@State private var commentDraft = ""
@State private var settings = SettingsManager.shared
+ @State private var showDownloadSheet = false
+ @State private var showNotesSheet = false
+ /// YouTube web **Ask about this video** panel (Gemini on youtube.com).
+ @State private var isAskExpanded = false
+ @State private var course = YouTubeCourseSession.shared
/// The ambient backdrop style to render: the user's chosen style, or `.off`
/// when they've disabled the feature in Settings → YouTube.
@@ -33,14 +38,15 @@ struct YouTubeWatchView: View {
self.settings.resolvedAmbientStyle
}
- /// 0…1 playback position, only while THIS view's video is the one playing,
- /// for the `.live` storyboard crossfade. `nil` otherwise (guards NaN when
- /// duration is still 0 at cold load).
+ /// 0…1 playback position for `.live` ambient crossfade.
+ /// Quantized to 5% steps so continuous player progress does not
+ /// re-render the whole watch page every tick (major lag source).
private var ambientLiveFraction: Double? {
guard self.youtubePlayer.currentVideo?.videoId == self.video.videoId,
self.youtubePlayer.duration > 0
else { return nil }
- return min(max(self.youtubePlayer.progress / self.youtubePlayer.duration, 0), 1)
+ let raw = min(max(self.youtubePlayer.progress / self.youtubePlayer.duration, 0), 1)
+ return (raw * 20).rounded() / 20
}
/// Storyboard spec for the fine-grained `.live` color, but only while THIS
@@ -58,25 +64,49 @@ struct YouTubeWatchView: View {
VStack(alignment: .leading, spacing: 16) {
self.videoSurface
- // Below the video: title/metadata + comments down the left,
- // the related rail down the right.
+ // Below the video: metadata + comments left; right column is
+ // YouTube’s real web Ask panel (when open), else course outline
+ // or related videos.
HStack(alignment: .top, spacing: 24) {
VStack(alignment: .leading, spacing: 16) {
self.metadataSection
+ self.watchActionBar
+
Divider()
self.commentsSection
}
.frame(maxWidth: .infinity, alignment: .leading)
- self.relatedColumn
- .frame(width: 360)
+ if self.isAskExpanded {
+ // Native UI; YouTube Ask runs in a hidden WebView.
+ YouTubeAskNativePanel(videoId: self.video.videoId) {
+ withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) {
+ self.isAskExpanded = false
+ }
+ }
+ .frame(width: 400)
+ .frame(minHeight: 520)
+ .frame(maxHeight: 720)
+ } else if self.showsCourseSidebar {
+ YouTubeCourseSidebar { lesson in
+ self.selectCourseLesson(lesson)
+ }
+ .frame(width: 340)
+ .frame(minHeight: 420)
+ } else {
+ self.relatedColumn
+ .frame(width: 360)
+ }
}
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
}
+ // Smoother wheel/trackpad feel; reduces rubber-band “buggy” bounce
+ // when the ambient backdrop + glass bar reflow during scroll.
+ .scrollBounceBehavior(.basedOnSize)
// PROTOTYPE: full-bleed ambient color behind the page. `.ignoresSafeArea`
// (inside the modifier) lets it bleed under the bottom player-bar inset,
// so the bar's Liquid Glass capsule refracts the live color.
@@ -98,16 +128,182 @@ struct YouTubeWatchView: View {
#endif
.task {
self.startOrAdoptPlayback()
+ self.syncCourseContext()
await self.viewModel.load()
- // Feed the related list to the player so the bar's next/previous
- // buttons can skip between videos.
- if self.youtubePlayer.currentVideo?.videoId == self.video.videoId {
- self.youtubePlayer.setUpNext(self.viewModel.data.related)
+ self.applyPlayerQueue()
+ self.resumeCoursePositionIfNeeded()
+ }
+ .onChange(of: self.youtubePlayer.currentVideo?.videoId) { _, _ in
+ self.syncCourseContext()
+ self.applyPlayerQueue()
+ }
+ .onChange(of: self.youtubePlayer.watchConclusionGeneration) { _, _ in
+ // Natural finish / skip: mark this lesson complete when it ends
+ // with progress (generation advances only on real conclusions).
+ if self.course.isActive {
+ // Prefer the video this page was opened for when concluding.
+ self.course.markCompleted(videoId: self.video.videoId)
}
}
.onDisappear {
+ // Save resume point for course lessons so “Continue learning” works.
+ if self.course.isActive,
+ self.course.index(of: self.video.videoId) != nil,
+ self.youtubePlayer.currentVideo?.videoId == self.video.videoId
+ {
+ self.course.saveResume(
+ videoId: self.video.videoId,
+ seconds: self.youtubePlayer.progress
+ )
+ }
self.youtubePlayer.inlineSurfaceWillDisappear(videoId: self.video.videoId)
}
+ .sheet(isPresented: self.$showDownloadSheet) {
+ YouTubeDownloadSheet(video: self.video)
+ }
+ .sheet(isPresented: self.$showNotesSheet) {
+ YouTubeNotesSheet(video: self.video, viewModel: self.viewModel)
+ }
+ }
+
+ // MARK: - Course
+
+ private var showsCourseSidebar: Bool {
+ self.course.isActive
+ && self.course.isSidebarVisible
+ && self.course.index(of: self.video.videoId) != nil
+ }
+
+ private func syncCourseContext() {
+ if self.course.isActive {
+ _ = self.course.syncCurrent(to: self.video)
+ }
+ }
+
+ private func applyPlayerQueue() {
+ guard self.youtubePlayer.currentVideo?.videoId == self.video.videoId else { return }
+ if self.course.isActive, self.course.index(of: self.video.videoId) != nil {
+ // Course queue: next lessons in order.
+ self.youtubePlayer.setCourseQueue(self.course.remainingLessons)
+ } else if !self.viewModel.data.related.isEmpty {
+ self.youtubePlayer.setUpNext(self.viewModel.data.related)
+ }
+ }
+
+ private func selectCourseLesson(_ lesson: YouTubeVideo) {
+ HapticService.toggle()
+ self.course.syncCurrent(to: lesson)
+ if self.youtubePlayer.currentVideo?.videoId == lesson.videoId {
+ self.youtubePlayer.dockInline()
+ return
+ }
+ // Keep playing in place and navigate the stack to the lesson.
+ self.youtubePlayer.continueWith(video: lesson)
+ self.youtubePlayer.setCourseQueue(self.course.remainingLessons)
+ }
+
+ /// Jump to the saved course resume timestamp once playback is ready.
+ private func resumeCoursePositionIfNeeded() {
+ guard self.course.isActive,
+ self.youtubePlayer.currentVideo?.videoId == self.video.videoId,
+ let seconds = self.course.resumeSeconds(for: self.video.videoId),
+ seconds > 5
+ else { return }
+ Task { @MainActor in
+ // Give the watch page a moment to attach the video element.
+ try? await Task.sleep(for: .milliseconds(900))
+ guard self.youtubePlayer.currentVideo?.videoId == self.video.videoId else { return }
+ self.youtubePlayer.seek(to: seconds)
+ }
+ }
+
+ // MARK: - Watch Actions
+
+ private var watchActionBar: some View {
+ HStack(spacing: 10) {
+ // YouTube’s real Ask (web) — not Apple Intelligence.
+ Button {
+ withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) {
+ self.isAskExpanded.toggle()
+ }
+ HapticService.toggle()
+ } label: {
+ Label(String(localized: "Ask"), systemImage: "sparkle")
+ .font(.system(size: 12, weight: .semibold))
+ .padding(.horizontal, 12)
+ .frame(height: 30)
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .compatGlass(
+ interactive: true,
+ tint: self.isAskExpanded ? PackageResourceLookup.brandAccent : nil,
+ in: Capsule()
+ )
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.askButton)
+ .help(String(localized: "Ask about this video — answers from YouTube, shown in Kaset UI"))
+
+ // One-click background download with live % ring.
+ OneClickDownloadButton(video: self.video)
+
+ // Options sheet (quality / Terminal) for power users.
+ Button {
+ self.showDownloadSheet = true
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ .font(.system(size: 14, weight: .semibold))
+ .frame(width: 30, height: 30)
+ .contentShape(Circle())
+ }
+ .buttonStyle(.plain)
+ .compatGlass(interactive: true, in: Circle())
+ .help(String(localized: "Download options…"))
+ .accessibilityLabel(String(localized: "Download options"))
+
+ // Generate Lecture Notes button (uses Antigravity CLI)
+ Button {
+ self.showNotesSheet = true
+ } label: {
+ Label(String(localized: "Notes"), systemImage: "doc.text")
+ .font(.system(size: 12, weight: .semibold))
+ .padding(.horizontal, 12)
+ .frame(height: 30)
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .compatGlass(interactive: true, in: Capsule())
+ .help(String(localized: "Generate lecture notes as PDF with Antigravity CLI"))
+ .accessibilityLabel(String(localized: "Generate lecture notes"))
+
+ if self.course.isActive, self.course.index(of: self.video.videoId) != nil {
+ Button {
+ withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) {
+ self.course.isSidebarVisible.toggle()
+ }
+ } label: {
+ Label(
+ String(localized: "Course"),
+ systemImage: self.course.isSidebarVisible
+ ? "list.bullet.rectangle.portrait.fill"
+ : "list.bullet.rectangle.portrait"
+ )
+ .font(.system(size: 12, weight: .semibold))
+ .padding(.horizontal, 12)
+ .frame(height: 30)
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .compatGlass(
+ interactive: true,
+ tint: self.course.isSidebarVisible ? Self.brandAccent : nil,
+ in: Capsule()
+ )
+ .help(String(localized: "Show or hide the course outline"))
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.courseToggle)
+ }
+
+ Spacer(minLength: 0)
+ }
}
// MARK: - Ambient Style Picker (PROTOTYPE)
@@ -150,15 +346,27 @@ struct YouTubeWatchView: View {
&& self.youtubePlayer.surfaceLocation == .floating
}
+ /// Whether this view's video is currently collapsed into the in-app mini player.
+ private var playsInMiniPlayer: Bool {
+ self.youtubePlayer.currentVideo?.videoId == self.video.videoId
+ && self.youtubePlayer.surfaceLocation == .miniPlayer
+ }
+
@ViewBuilder
private var videoSurface: some View {
if self.presentsLiveSurface {
// Clean video surface — playback is controlled from the
// Liquid Glass player bar at the bottom of the window.
+ // ScrollForwardingWKWebView (inside the surface) forwards
+ // trackpad scrolls so the parent ScrollView still moves when
+ // the cursor is over the video.
YouTubeWatchSurfaceView()
.aspectRatio(16 / 9, contentMode: .fit)
.clipShape(.rect(cornerRadius: 12))
.accessibilityIdentifier(AccessibilityID.YouTubeContent.watchSurface)
+ // Drawing isolation so video layer compositing doesn't
+ // force full-page invalidation on every progress tick.
+ .compositingGroup()
} else if self.playsInFloatingWindow {
// Native PiP-style placeholder while the video plays in the
// pop-out window.
@@ -187,6 +395,42 @@ struct YouTubeWatchView: View {
}
.clipShape(.rect(cornerRadius: 12))
.accessibilityIdentifier(AccessibilityID.YouTubeContent.watchSurface)
+ } else if self.playsInMiniPlayer {
+ // Thumbnail placeholder while the video is in the in-app mini player.
+ // Tapping docks it back into this watch view.
+ Button {
+ self.youtubePlayer.dockInline()
+ HapticService.toggle()
+ } label: {
+ CachedAsyncImage(
+ url: self.video.thumbnailURL,
+ targetSize: CGSize(width: 1280, height: 720)
+ ) { image in
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ } placeholder: {
+ Rectangle().fill(.black)
+ }
+ .aspectRatio(16 / 9, contentMode: .fit)
+ .overlay {
+ VStack(spacing: 12) {
+ Image(systemName: "arrow.up.left.and.arrow.down.right")
+ .font(.system(size: 32))
+ .foregroundStyle(.white.opacity(0.85))
+ Text("Playing in mini player — tap to expand", comment: "Watch view placeholder while in mini player")
+ .font(.callout)
+ .foregroundStyle(.white.opacity(0.8))
+ .multilineTextAlignment(.center)
+ }
+ .padding()
+ }
+ .clipShape(.rect(cornerRadius: 12))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(String(localized: "Expand from mini player"))
+ .accessibilityIdentifier(AccessibilityID.YouTubeContent.watchSurface)
} else {
Button {
self.startOrAdoptPlayback()
@@ -218,10 +462,13 @@ struct YouTubeWatchView: View {
}
/// Starts playback of this view's video, or adopts the surface if this
- /// video is already playing (e.g. docking back from the floating window).
+ /// video is already playing (e.g. docking back from the mini player or
+ /// the floating window).
private func startOrAdoptPlayback() {
if self.youtubePlayer.currentVideo?.videoId == self.video.videoId {
- if self.youtubePlayer.surfaceLocation == .floating {
+ if self.youtubePlayer.surfaceLocation == .floating
+ || self.youtubePlayer.surfaceLocation == .miniPlayer
+ {
self.youtubePlayer.dockInline()
}
} else {
@@ -711,4 +958,12 @@ extension AccessibilityID.YouTubeContent {
static let commentPostButton = "youtubeContent.commentPostButton"
static let subscribeButton = "youtubeContent.subscribeButton"
static let watchMoveHere = "youtubeContent.watchMoveHere"
+ static let downloadButton = "youtubeContent.downloadButton"
+ static let askButton = "youtubeContent.askButton"
+ static let aiPanel = "youtubeContent.aiPanel"
+ static let aiQuestionField = "youtubeContent.aiQuestionField"
+ static let aiAskButton = "youtubeContent.aiAskButton"
+ static let askSuggestions = "youtubeContent.askSuggestions"
+ static let askConversation = "youtubeContent.askConversation"
+ static let courseToggle = "youtubeContent.courseToggle"
}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchWebView+Scripts.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchWebView+Scripts.swift
index 7105b8f72..40eda819f 100644
--- a/Sources/Kaset/Views/YouTube/YouTubeWatchWebView+Scripts.swift
+++ b/Sources/Kaset/Views/YouTube/YouTubeWatchWebView+Scripts.swift
@@ -62,7 +62,8 @@ extension YouTubeWatchWebView {
duration: (video.duration && isFinite(video.duration)) ? video.duration : 0,
videoId: videoId,
title: currentTitle(),
- isAd: isAdShowing()
+ isAd: isAdShowing(),
+ playbackRate: video.playbackRate || 1
});
} catch (e) {
console.log('[KasetYT] update error: ' + e);
@@ -661,4 +662,22 @@ extension YouTubeWatchWebView {
completionHandler: nil
)
}
+
+ /// Sets the playback speed on the video element and YouTube player API.
+ func setPlaybackSpeed(_ speed: Double) {
+ guard speed > 0, speed.isFinite else { return }
+ self.webView?.evaluateJavaScript(
+ """
+ (function() {
+ const video = document.querySelector('#movie_player video') || document.querySelector('video');
+ if (video) { video.playbackRate = \(speed); }
+ const player = document.getElementById('movie_player');
+ if (player && typeof player.setPlaybackRate === 'function') {
+ try { player.setPlaybackRate(\(speed)); } catch (e) {}
+ }
+ })();
+ """,
+ completionHandler: nil
+ )
+ }
}
diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchWebView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchWebView.swift
index f6bca44b0..815501eb6 100644
--- a/Sources/Kaset/Views/YouTube/YouTubeWatchWebView.swift
+++ b/Sources/Kaset/Views/YouTube/YouTubeWatchWebView.swift
@@ -69,7 +69,9 @@ final class YouTubeWatchWebView {
targetVolume: playerService.volume
)
- let newWebView = WKWebView(frame: .zero, configuration: configuration)
+ // ScrollForwardingWKWebView so trackpad scroll over the video
+ // reaches the parent SwiftUI ScrollView instead of dying inside WK.
+ let newWebView = ScrollForwardingWKWebView(frame: .zero, configuration: configuration)
newWebView.navigationDelegate = self.coordinator
newWebView.customUserAgent = WebKitManager.userAgent
self.webKitManager = webKitManager
@@ -77,6 +79,8 @@ final class YouTubeWatchWebView {
// Kill the white flash between page navigations.
newWebView.underPageBackgroundColor = .black
+ // Avoid swipe-back stealing gestures from the app ScrollView.
+ newWebView.allowsBackForwardNavigationGestures = false
#if DEBUG
newWebView.isInspectable = true
@@ -261,7 +265,8 @@ final class YouTubeWatchWebView {
duration: body["duration"] as? Double ?? 0,
videoId: (body["videoId"] as? String).flatMap { $0.isEmpty ? nil : $0 },
title: body["title"] as? String,
- isAd: body["isAd"] as? Bool ?? false
+ isAd: body["isAd"] as? Bool ?? false,
+ playbackRate: body["playbackRate"] as? Double ?? 1.0
)
Task { @MainActor in
self.playerService.updatePlaybackState(update)
diff --git a/Sources/Kaset/Views/YouTubeSettingsView.swift b/Sources/Kaset/Views/YouTubeSettingsView.swift
index 01a686a61..480c4b431 100644
--- a/Sources/Kaset/Views/YouTubeSettingsView.swift
+++ b/Sources/Kaset/Views/YouTubeSettingsView.swift
@@ -38,6 +38,8 @@ struct YouTubeSettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
+
+ YouTubeDownloadSettingsSection(settings: self.settings)
}
.formStyle(.grouped)
}
diff --git a/Tests/KasetTests/AIErrorHandlerTests.swift b/Tests/KasetTests/AIErrorHandlerTests.swift
index d524745cd..445ea5517 100644
--- a/Tests/KasetTests/AIErrorHandlerTests.swift
+++ b/Tests/KasetTests/AIErrorHandlerTests.swift
@@ -5,7 +5,7 @@ import Testing
// MARK: - AIErrorTests
/// Tests for AIError enum and its properties.
-@available(macOS 26.0, *)
+
@Suite(.tags(.api))
struct AIErrorTests {
@@ -154,7 +154,7 @@ struct AIErrorTests {
// MARK: - AIErrorHandlerTests
/// Tests for AIErrorHandler utility methods.
-@available(macOS 26.0, *)
+
@Suite(.tags(.api))
struct AIErrorHandlerTests {
diff --git a/Tests/KasetTests/AIToolTests.swift b/Tests/KasetTests/AIToolTests.swift
index 6b013032b..69df54835 100644
--- a/Tests/KasetTests/AIToolTests.swift
+++ b/Tests/KasetTests/AIToolTests.swift
@@ -5,7 +5,7 @@ import Testing
// MARK: - MusicSearchToolTests
/// Tests for MusicSearchTool output formatting and behavior.
-@available(macOS 26.0, *)
+
@Suite(.tags(.api), .serialized)
@MainActor
@@ -137,7 +137,7 @@ struct MusicSearchToolTests {
// MARK: - QueueToolTests
/// Tests for QueueTool output formatting and behavior.
-@available(macOS 26.0, *)
+
@Suite(.tags(.api))
struct QueueToolTests {
diff --git a/Tests/KasetTests/CommandBarViewModelTests.swift b/Tests/KasetTests/CommandBarViewModelTests.swift
index 322f59a28..2a177d34d 100644
--- a/Tests/KasetTests/CommandBarViewModelTests.swift
+++ b/Tests/KasetTests/CommandBarViewModelTests.swift
@@ -2,7 +2,7 @@ import Foundation
import Testing
@testable import Kaset
-@available(macOS 26.0, *)
+
@Suite(.serialized, .timeLimit(.minutes(1)))
@MainActor
diff --git a/Tests/KasetTests/CommandExecutorTests.swift b/Tests/KasetTests/CommandExecutorTests.swift
index 3ae67aba2..d0e6e8caf 100644
--- a/Tests/KasetTests/CommandExecutorTests.swift
+++ b/Tests/KasetTests/CommandExecutorTests.swift
@@ -2,7 +2,7 @@ import Foundation
import Testing
@testable import Kaset
-@available(macOS 26.0, *)
+
@Suite(.serialized, .timeLimit(.minutes(1)))
@MainActor
diff --git a/Tests/KasetTests/CommandIntentParserTests.swift b/Tests/KasetTests/CommandIntentParserTests.swift
index 095a3db67..37db8b881 100644
--- a/Tests/KasetTests/CommandIntentParserTests.swift
+++ b/Tests/KasetTests/CommandIntentParserTests.swift
@@ -1,7 +1,7 @@
import Testing
@testable import Kaset
-@available(macOS 26.0, *)
+
@Suite(.serialized, .timeLimit(.minutes(1)))
struct CommandIntentParserTests {
diff --git a/Tests/KasetTests/FoundationModelsOptimizedPromptIntegrationTests.swift b/Tests/KasetTests/FoundationModelsOptimizedPromptIntegrationTests.swift
index 89d945449..6abac2a99 100644
--- a/Tests/KasetTests/FoundationModelsOptimizedPromptIntegrationTests.swift
+++ b/Tests/KasetTests/FoundationModelsOptimizedPromptIntegrationTests.swift
@@ -16,7 +16,7 @@ private let optimizedPromptIntegrationEnabled: Bool = {
///
/// These tests focus on the optimized prompts Kaset ships, rather than generic schema-only prompts.
/// They are intentionally narrow so we can validate the main 26.4 behavior changes with low flakiness.
-@available(macOS 26.0, *)
+
@Suite(
.tags(.integration, .slow),
diff --git a/Tests/KasetTests/FoundationModelsPromptLibraryTests.swift b/Tests/KasetTests/FoundationModelsPromptLibraryTests.swift
index 7490022d4..d33e2611f 100644
--- a/Tests/KasetTests/FoundationModelsPromptLibraryTests.swift
+++ b/Tests/KasetTests/FoundationModelsPromptLibraryTests.swift
@@ -1,7 +1,7 @@
import Testing
@testable import Kaset
-@available(macOS 26.0, *)
+
@Suite(.tags(.model))
struct FoundationModelsPromptLibraryTests {
diff --git a/Tests/KasetTests/FoundationModelsServiceTests.swift b/Tests/KasetTests/FoundationModelsServiceTests.swift
index af7edfb7e..4eb88fee9 100644
--- a/Tests/KasetTests/FoundationModelsServiceTests.swift
+++ b/Tests/KasetTests/FoundationModelsServiceTests.swift
@@ -5,7 +5,7 @@ import Testing
// MARK: - FoundationModelsServiceTests
/// Tests for FoundationModelsService availability and session creation.
-@available(macOS 26.0, *)
+
@Suite(.tags(.api), .serialized)
@MainActor
diff --git a/Tests/KasetTests/FoundationModelsTests.swift b/Tests/KasetTests/FoundationModelsTests.swift
index 942e86aac..de31245fb 100644
--- a/Tests/KasetTests/FoundationModelsTests.swift
+++ b/Tests/KasetTests/FoundationModelsTests.swift
@@ -4,7 +4,7 @@ import Testing
// MARK: - PlaylistChangesTests
-@available(macOS 26.0, *)
+
@Suite(.tags(.model))
struct PlaylistChangesTests {
@Test("PlaylistChanges with empty removals")
@@ -99,7 +99,7 @@ struct PlaylistChangesTests {
// MARK: - LyricsSummaryTests
-@available(macOS 26.0, *)
+
@Suite(.tags(.model))
struct LyricsSummaryTests {
@Test("LyricsSummary with minimal themes")
@@ -155,7 +155,7 @@ struct LyricsSummaryTests {
// MARK: - FoundationModelsBudgetTests
-@available(macOS 26.0, *)
+
@Suite(.tags(.model))
struct FoundationModelsBudgetTests {
@Test("prompt budget total includes schema tokens")
diff --git a/Tests/KasetTests/MusicIntentTests.swift b/Tests/KasetTests/MusicIntentTests.swift
index 83a140816..e3f0683ba 100644
--- a/Tests/KasetTests/MusicIntentTests.swift
+++ b/Tests/KasetTests/MusicIntentTests.swift
@@ -5,7 +5,7 @@ import Testing
// MARK: - MusicIntentTests
/// Tests for MusicIntent query building and parsing logic.
-@available(macOS 26.0, *)
+
@Suite(.tags(.api))
struct MusicIntentTests {
@@ -416,7 +416,7 @@ struct MusicIntentTests {
// MARK: - MusicActionTests
-@available(macOS 26.0, *)
+
@Suite(.tags(.api))
struct MusicActionTests {
@@ -435,7 +435,7 @@ struct MusicActionTests {
// MARK: - ContentSourceTests
-@available(macOS 26.0, *)
+
@Suite(.tags(.api))
struct ContentSourceTests {
diff --git a/Tests/KasetTests/YTDLPServiceTests.swift b/Tests/KasetTests/YTDLPServiceTests.swift
new file mode 100644
index 000000000..a8a48a6de
--- /dev/null
+++ b/Tests/KasetTests/YTDLPServiceTests.swift
@@ -0,0 +1,75 @@
+import Foundation
+import Testing
+@testable import Kaset
+
+@Suite("YTDLPService", .tags(.service))
+@MainActor
+struct YTDLPServiceTests {
+ @Test("Quality presets produce non-empty format selectors")
+ func formatSelectors() {
+ for quality in DownloadQuality.allCases {
+ #expect(!quality.formatSelector.isEmpty)
+ }
+ #expect(DownloadQuality.audioMP3.audioExtractFormat == "mp3")
+ #expect(DownloadQuality.audioM4A.audioExtractFormat == "m4a")
+ #expect(DownloadQuality.best.audioExtractFormat == nil)
+ #expect(DownloadQuality.audioMP3.isAudioOnly)
+ #expect(!DownloadQuality.fullHD1080.isAudioOnly)
+ }
+
+ @Test("buildArguments includes video URL, format, and destination")
+ func buildArguments() {
+ let dest = URL(fileURLWithPath: "/tmp/kaset-downloads")
+ let args = YTDLPService.buildArguments(
+ binary: "/opt/homebrew/bin/yt-dlp",
+ videoId: "dQw4w9WgXcQ",
+ title: "Never Gonna Give You Up",
+ quality: .fullHD1080,
+ destination: dest
+ )
+
+ #expect(args.first == "/opt/homebrew/bin/yt-dlp")
+ #expect(args.contains("https://www.youtube.com/watch?v=dQw4w9WgXcQ"))
+ #expect(args.contains("-f"))
+ #expect(args.contains(DownloadQuality.fullHD1080.formatSelector))
+ #expect(args.contains("-P"))
+ #expect(args.contains(dest.path))
+ #expect(args.contains("--no-playlist"))
+ }
+
+ @Test("Audio quality adds extract flags")
+ func audioArguments() {
+ let dest = URL(fileURLWithPath: "/tmp/kaset-downloads")
+ let args = YTDLPService.buildArguments(
+ binary: "yt-dlp",
+ videoId: "abc123",
+ title: "Song",
+ quality: .audioMP3,
+ destination: dest
+ )
+ #expect(args.contains("-x"))
+ #expect(args.contains("--audio-format"))
+ #expect(args.contains("mp3"))
+ #expect(!args.contains("--merge-output-format"))
+ }
+
+ @Test("parsePercent reads yt-dlp progress lines")
+ func parsePercent() {
+ #expect(YTDLPService.parsePercent(from: "[download] 45.2% of 10.00MiB at 1.23MiB/s ETA 00:04") == 45.2)
+ #expect(YTDLPService.parsePercent(from: "[download] 100% of 1.00KiB") == 100)
+ #expect(YTDLPService.parsePercent(from: "no progress here") == nil)
+ }
+
+ @Test("parseDestination recognizes Destination lines")
+ func parseDestination() {
+ let path = YTDLPService.parseDestination(from: "Destination: /Users/me/Downloads/video.mp4")
+ #expect(path == "/Users/me/Downloads/video.mp4")
+ }
+
+ @Test("discoverBinaryPath respects override when executable")
+ func discoverOverride() throws {
+ // /bin/sh is always executable on macOS; use it as a stand-in override.
+ let path = try #require(YTDLPService.discoverBinaryPath(override: "/bin/sh"))
+ #expect(path == "/bin/sh")
+ }
+}
diff --git a/Tests/KasetTests/YouTubePlayerServiceTests.swift b/Tests/KasetTests/YouTubePlayerServiceTests.swift
index 079c45e17..89d3439b6 100644
--- a/Tests/KasetTests/YouTubePlayerServiceTests.swift
+++ b/Tests/KasetTests/YouTubePlayerServiceTests.swift
@@ -63,6 +63,7 @@ private final class MockYouTubeWatchPlaybackController: YouTubeWatchPlaybackCont
var quality: [String] = []
private(set) var selectedCaption: String??
private(set) var selectedQuality: String?
+ private(set) var selectedSpeeds: [Double] = []
func availableCaptionTracks() async -> [YouTubeCaptionTrack] {
self.captionTracks
@@ -90,6 +91,10 @@ private final class MockYouTubeWatchPlaybackController: YouTubeWatchPlaybackCont
self.selectedQuality = level
}
+ func setPlaybackSpeed(_ speed: Double) {
+ self.selectedSpeeds.append(speed)
+ }
+
func storyboardSpec(expectedVideoId _: String?) async -> String? {
nil
}
@@ -375,8 +380,8 @@ struct YouTubePlayerServiceTests {
#expect(self.sut.currentVideo?.title == "Drifted Title")
}
- @Test("Inline disappearance while playing pops out to the floating window")
- func disappearWhilePlayingPopsOut() {
+ @Test("Inline disappearance while playing collapses to the in-app mini player")
+ func disappearWhilePlayingCollapsesToMiniPlayer() {
self.sut.play(video: MockYouTubeClient.makeVideo(videoId: "abc"))
self.sut.activeInlineVideoId = "abc"
self.sut.updatePlaybackState(.init(
@@ -386,7 +391,7 @@ struct YouTubePlayerServiceTests {
self.sut.inlineSurfaceWillDisappear(videoId: "abc")
- #expect(self.sut.surfaceLocation == .floating)
+ #expect(self.sut.surfaceLocation == .miniPlayer)
#expect(self.sut.currentVideo != nil)
}
@@ -414,7 +419,7 @@ struct YouTubePlayerServiceTests {
#expect(self.sut.activeInlineVideoId == "abc")
}
- @Test("Pop-out disabled: inline disappearance while playing stops instead of floating")
+ @Test("Pop-out disabled: inline disappearance while playing stops instead of mini player")
func disappearWhilePlayingStopsWhenPopOutDisabled() {
let controller = MockYouTubeWatchPlaybackController()
let sut = YouTubePlayerService(
@@ -478,7 +483,7 @@ struct YouTubePlayerServiceTests {
sut.inlineSurfaceWillDisappear(videoId: "abc")
#expect(sut.surfaceLocation == .none)
- // Flip the gate on; a fresh playback now pops out.
+ // Flip the gate on; a fresh playback now collapses to mini player.
popOutEnabled.value = true
sut.play(video: MockYouTubeClient.makeVideo(videoId: "abc"))
sut.activeInlineVideoId = "abc"
@@ -487,7 +492,7 @@ struct YouTubePlayerServiceTests {
videoId: "abc", title: nil, isAd: false
))
sut.inlineSurfaceWillDisappear(videoId: "abc")
- #expect(sut.surfaceLocation == .floating)
+ #expect(sut.surfaceLocation == .miniPlayer)
}
@Test("Video ended invokes the hook and clears isPlaying")
@@ -848,9 +853,13 @@ struct YouTubePlayerServiceTests {
self.sut.selectQuality("hd720")
#expect(self.sut.currentQuality == "hd720")
#expect(self.controller.selectedQuality == "hd720")
+
+ self.sut.selectPlaybackSpeed(1.5)
+ #expect(self.sut.playbackSpeed == 1.5)
+ #expect(self.controller.selectedSpeeds == [1.5])
}
- @Test("Source switch pauses the docked video in place — no pop-out")
+ @Test("Source switch pauses the docked video in place — no mini player")
func sourceSwitchPausesInPlace() {
self.sut.play(video: MockYouTubeClient.makeVideo(videoId: "abc"))
self.sut.activeInlineVideoId = "abc"
@@ -868,14 +877,31 @@ struct YouTubePlayerServiceTests {
#expect(self.controller.tearDownCount == 0)
// The suppression is one-shot: a later in-app navigation while
- // playing pops out as usual.
+ // playing collapses to the in-app mini player as usual.
self.sut.activeInlineVideoId = "abc"
self.sut.updatePlaybackState(.init(
isPlaying: true, progress: 6, duration: 60,
videoId: "abc", title: nil, isAd: false
))
self.sut.inlineSurfaceWillDisappear(videoId: "abc")
- #expect(self.sut.surfaceLocation == .floating)
+ #expect(self.sut.surfaceLocation == .miniPlayer)
+ }
+
+ @Test("Expand from mini player requests pop-in; dock returns to inline")
+ func miniPlayerExpandAndDock() {
+ self.sut.play(video: MockYouTubeClient.makeVideo(videoId: "abc"))
+ self.sut.updatePlaybackState(.init(
+ isPlaying: true, progress: 5, duration: 60,
+ videoId: "abc", title: nil, isAd: false
+ ))
+ self.sut.popToMiniPlayer()
+ #expect(self.sut.surfaceLocation == .miniPlayer)
+
+ self.sut.expandFromMiniPlayer()
+ #expect(self.sut.popInRequest?.videoId == "abc")
+
+ self.sut.dockInline()
+ #expect(self.sut.surfaceLocation == .inline)
}
@Test("Pop-in request only fires from the floating window")
diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md
index b755999e2..896a292e6 100644
--- a/docs/keyboard-shortcuts.md
+++ b/docs/keyboard-shortcuts.md
@@ -14,6 +14,9 @@ Kaset provides keyboard control for playback and navigation while preserving sta
| `⌘S` | Toggle shuffle (off/on; the player-bar control also cycles to Smart Shuffle) |
| `⌘R` | Cycle repeat mode (Off → All → One) |
| `⇧⌘M` | Switch to Mini Player |
+| `F` | Full Screen (YouTube video) |
+| `⌘D` | Download current YouTube video (yt-dlp, background) |
+| `Esc` | Exit Full Screen (while video window is key) |
Mute is still available from the Playback menu and AppleScript, but Kaset intentionally does not assign a default mute shortcut so the native macOS minimize shortcut (`⌘M`) continues to work.