From 1b1881764ad5d433eeede2f92db94b7d6959e3b2 Mon Sep 17 00:00:00 2001 From: Abhishek Meena Date: Thu, 9 Jul 2026 10:46:12 +0530 Subject: [PATCH 1/2] feat: add Apple Intelligence support for macOS 27 beta / macOS 26.4+ --- Kaset.entitlements | 15 +- Package.swift | 17 +- Sources/Kaset/KasetApp.swift | 60 ++ .../Kaset/Models/AI/VideoAskSuggestions.swift | 39 + .../Kaset/Models/AI/VideoIntelligence.swift | 40 ++ Sources/Kaset/Models/DownloadQuality.swift | 98 +++ .../YouTube/YouTubeCourseLibraryModels.swift | 270 +++++++ .../AI/FoundationModelsPromptLibrary.swift | 243 +++++++ .../Services/AI/FoundationModelsService.swift | 44 ++ .../Services/AI/LectureNotesService.swift | 444 ++++++++++++ .../Services/Download/YTDLPService.swift | 680 ++++++++++++++++++ .../Player/YouTubePlayerService.swift | 56 +- Sources/Kaset/Services/SettingsManager.swift | 64 ++ .../Services/YouTube/YouTubeAskService.swift | 650 +++++++++++++++++ .../YouTube/YouTubeCourseLibrary.swift | 459 ++++++++++++ .../YouTube/YouTubeCourseSession.swift | 241 +++++++ .../Kaset/Utilities/DiagnosticsLogger.swift | 3 + Sources/Kaset/Views/SearchView.swift | 63 +- .../Views/SharedViews/KasetSidebarRow.swift | 25 +- .../Views/SharedViews/SidebarFooterView.swift | 10 +- .../Views/YouTube/SourceToggleView.swift | 76 +- .../Views/YouTube/YouTubeAskNativePanel.swift | 232 ++++++ .../Views/YouTube/YouTubeAskWebPanel.swift | 308 ++++++++ .../Views/YouTube/YouTubeContentView.swift | 25 +- .../Views/YouTube/YouTubeCourseSidebar.swift | 366 ++++++++++ .../Views/YouTube/YouTubeCoursesView.swift | 589 +++++++++++++++ .../Views/YouTube/YouTubeDownloadHUD.swift | 327 +++++++++ .../YouTubeDownloadSettingsSection.swift | 128 ++++ .../Views/YouTube/YouTubeDownloadSheet.swift | 218 ++++++ .../YouTube/YouTubeFullscreenChrome.swift | 336 +++++++++ .../YouTube/YouTubeInAppMiniPlayer.swift | 207 ++++++ .../Views/YouTube/YouTubeNotesSheet.swift | 384 ++++++++++ .../Views/YouTube/YouTubePlayerBar.swift | 57 +- .../YouTube/YouTubePlaylistDetailView.swift | 70 +- .../Kaset/Views/YouTube/YouTubeSidebar.swift | 5 + .../Views/YouTube/YouTubeVideoAIPanel.swift | 576 +++++++++++++++ .../YouTubeVideoWindowController.swift | 63 +- .../YouTube/YouTubeWatchSurfaceView.swift | 17 +- .../Views/YouTube/YouTubeWatchView.swift | 268 ++++++- .../YouTube/YouTubeWatchWebView+Scripts.swift | 21 +- .../Views/YouTube/YouTubeWatchWebView.swift | 3 +- Sources/Kaset/Views/YouTubeSettingsView.swift | 2 + Tests/KasetTests/AIErrorHandlerTests.swift | 4 +- Tests/KasetTests/AIToolTests.swift | 4 +- .../KasetTests/CommandBarViewModelTests.swift | 2 +- Tests/KasetTests/CommandExecutorTests.swift | 2 +- .../KasetTests/CommandIntentParserTests.swift | 2 +- ...odelsOptimizedPromptIntegrationTests.swift | 2 +- .../FoundationModelsPromptLibraryTests.swift | 2 +- .../FoundationModelsServiceTests.swift | 2 +- Tests/KasetTests/FoundationModelsTests.swift | 6 +- Tests/KasetTests/MusicIntentTests.swift | 6 +- Tests/KasetTests/YTDLPServiceTests.swift | 75 ++ .../YouTubePlayerServiceTests.swift | 44 +- docs/keyboard-shortcuts.md | 3 + 55 files changed, 7817 insertions(+), 136 deletions(-) create mode 100644 Sources/Kaset/Models/AI/VideoAskSuggestions.swift create mode 100644 Sources/Kaset/Models/AI/VideoIntelligence.swift create mode 100644 Sources/Kaset/Models/DownloadQuality.swift create mode 100644 Sources/Kaset/Models/YouTube/YouTubeCourseLibraryModels.swift create mode 100644 Sources/Kaset/Services/AI/LectureNotesService.swift create mode 100644 Sources/Kaset/Services/Download/YTDLPService.swift create mode 100644 Sources/Kaset/Services/YouTube/YouTubeAskService.swift create mode 100644 Sources/Kaset/Services/YouTube/YouTubeCourseLibrary.swift create mode 100644 Sources/Kaset/Services/YouTube/YouTubeCourseSession.swift create mode 100644 Sources/Kaset/Views/YouTube/YouTubeAskNativePanel.swift create mode 100644 Sources/Kaset/Views/YouTube/YouTubeAskWebPanel.swift create mode 100644 Sources/Kaset/Views/YouTube/YouTubeCourseSidebar.swift create mode 100644 Sources/Kaset/Views/YouTube/YouTubeCoursesView.swift create mode 100644 Sources/Kaset/Views/YouTube/YouTubeDownloadHUD.swift create mode 100644 Sources/Kaset/Views/YouTube/YouTubeDownloadSettingsSection.swift create mode 100644 Sources/Kaset/Views/YouTube/YouTubeDownloadSheet.swift create mode 100644 Sources/Kaset/Views/YouTube/YouTubeFullscreenChrome.swift create mode 100644 Sources/Kaset/Views/YouTube/YouTubeInAppMiniPlayer.swift create mode 100644 Sources/Kaset/Views/YouTube/YouTubeNotesSheet.swift create mode 100644 Sources/Kaset/Views/YouTube/YouTubeVideoAIPanel.swift create mode 100644 Tests/KasetTests/YTDLPServiceTests.swift 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..723a94667 --- /dev/null +++ b/Sources/Kaset/Services/YouTube/YouTubeAskService.swift @@ -0,0 +1,650 @@ +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(); + var tries = 0; + var boot = setInterval(function() { + silence(); + var opened = openAsk(); + tries += 1; + var chips = scrapeSuggestions(); + if (chips.length) post({ type: 'suggestions', suggestions: chips }); + if (opened || tries > 25) { + 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 }); + }, 1500); + } else { + post({ + type: 'fallbackMeta', + title: meta.title, + suggestions: [ + 'What is this video about?', + 'Summarize the description', + 'Key details from the page' + ] + }); + } + } + }, 700); + })(); + """# +} + +// 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/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..9bc76d870 --- /dev/null +++ b/Sources/Kaset/Views/YouTube/YouTubeAskNativePanel.swift @@ -0,0 +1,232 @@ +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 { + // Keep the hidden session warm while browsing the same video; + // full tear-down when leaving the watch surface is fine. + } + } + + // 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..f039644ba 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeWatchSurfaceView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeWatchSurfaceView.swift @@ -21,7 +21,9 @@ struct YouTubeWatchSurfaceView: NSViewRepresentable { // 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 +47,17 @@ final class YouTubeWatchContainerView: NSView { subview.frame = self.bounds.insetBy(dx: -1.5, dy: -1.5) } } + + override func scrollWheel(with event: NSEvent) { + // The WKWebView swallows scroll-wheel events for its internal + // document scrolling. Since we strip all YouTube page chrome and + // only show the video surface, there is nothing to scroll inside + // the WebView. Forward the event to the enclosing NSScrollView + // (SwiftUI's ScrollView backing) so the watch page scrolls normally. + if let scrollView = self.enclosingScrollView { + scrollView.scrollWheel(with: event) + } else { + self.nextResponder?.scrollWheel(with: event) + } + } } diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift index 09865afd7..b3b6951f7 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. @@ -58,25 +63,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 +127,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,6 +345,12 @@ 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 { @@ -187,6 +388,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 +455,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 +951,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..38904096f 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeWatchWebView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeWatchWebView.swift @@ -261,7 +261,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. From b3dc5524fe1e1e5c8667522b60bc0483cb6ca91c Mon Sep 17 00:00:00 2001 From: Abhishek Meena Date: Thu, 9 Jul 2026 13:59:16 +0530 Subject: [PATCH 2/2] fix(youtube): reduce watch-page lag and forward scroll over video Scroll over the extracted video surface now reaches the parent ScrollView via ScrollForwardingWKWebView. Ambient glow and Ask boot work are throttled so continuous player progress and a hidden full YouTube page no longer jank the whole watch hierarchy. --- .../Services/YouTube/YouTubeAskService.swift | 14 +++-- .../Views/YouTube/AmbientVideoBackdrop.swift | 7 ++- .../Views/YouTube/YouTubeAskNativePanel.swift | 5 +- .../YouTube/YouTubeWatchSurfaceView.swift | 61 ++++++++++++++++--- .../Views/YouTube/YouTubeWatchView.swift | 15 +++-- .../Views/YouTube/YouTubeWatchWebView.swift | 6 +- 6 files changed, 84 insertions(+), 24 deletions(-) diff --git a/Sources/Kaset/Services/YouTube/YouTubeAskService.swift b/Sources/Kaset/Services/YouTube/YouTubeAskService.swift index 723a94667..7e1aab7ab 100644 --- a/Sources/Kaset/Services/YouTube/YouTubeAskService.swift +++ b/Sources/Kaset/Services/YouTube/YouTubeAskService.swift @@ -566,14 +566,18 @@ final class YouTubeAskService: NSObject { // 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; - var chips = scrapeSuggestions(); - if (chips.length) post({ type: 'suggestions', suggestions: chips }); - if (opened || tries > 25) { + 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) { @@ -581,7 +585,7 @@ final class YouTubeAskService: NSObject { setTimeout(function() { var more = scrapeSuggestions(); if (more.length) post({ type: 'suggestions', suggestions: more }); - }, 1500); + }, 1200); } else { post({ type: 'fallbackMeta', @@ -594,7 +598,7 @@ final class YouTubeAskService: NSObject { }); } } - }, 700); + }, 900); })(); """# } 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/YouTubeAskNativePanel.swift b/Sources/Kaset/Views/YouTube/YouTubeAskNativePanel.swift index 9bc76d870..314fee337 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeAskNativePanel.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeAskNativePanel.swift @@ -43,8 +43,9 @@ struct YouTubeAskNativePanel: View { ) } .onDisappear { - // Keep the hidden session warm while browsing the same video; - // full tear-down when leaving the watch surface is fine. + // 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) } } diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchSurfaceView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchSurfaceView.swift index f039644ba..c8e710059 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeWatchSurfaceView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeWatchSurfaceView.swift @@ -19,6 +19,56 @@ 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 and @@ -49,15 +99,6 @@ final class YouTubeWatchContainerView: NSView { } override func scrollWheel(with event: NSEvent) { - // The WKWebView swallows scroll-wheel events for its internal - // document scrolling. Since we strip all YouTube page chrome and - // only show the video surface, there is nothing to scroll inside - // the WebView. Forward the event to the enclosing NSScrollView - // (SwiftUI's ScrollView backing) so the watch page scrolls normally. - if let scrollView = self.enclosingScrollView { - scrollView.scrollWheel(with: event) - } else { - self.nextResponder?.scrollWheel(with: event) - } + ScrollForwardingWKWebView.forwardScroll(event, from: self) } } diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift index b3b6951f7..127d02b30 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeWatchView.swift @@ -38,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 @@ -356,10 +357,16 @@ struct YouTubeWatchView: 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. diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchWebView.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchWebView.swift index 38904096f..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