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.