Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion Kaset.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<!-- Required for Core Audio process tap (Equalizer feature). -->
<!-- Default download destination for yt-dlp media. -->
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<!-- Required for Core Audio process tap (equalizer feature). -->
<key>com.apple.security.device.audio-input</key>
<true/>

Expand All @@ -22,5 +25,15 @@
<string>com.sertacozercan.Kaset-spks</string>
<string>com.sertacozercan.Kaset-spki</string>
</array>

<!-- Allow reading Homebrew / system yt-dlp + ffmpeg for the download service. -->
<!-- Also allows ~/.local/bin/ for Antigravity CLI (agy). -->
<key>com.apple.security.temporary-exception.files.absolute-path.read-only</key>
<array>
<string>/opt/homebrew/</string>
<string>/usr/local/</string>
<string>/opt/local/</string>
<string>/Users/</string>
</array>
</dict>
</plist>
17 changes: 1 addition & 16 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ let package = Package(
name: "Kaset",
defaultLocalization: "en",
platforms: [
.macOS("15.4"),
.macOS("27.0"),
],
products: [
.executable(
Expand Down Expand Up @@ -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"),
Expand Down
60 changes: 60 additions & 0 deletions Sources/Kaset/KasetApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)) {
Expand Down
39 changes: 39 additions & 0 deletions Sources/Kaset/Models/AI/VideoAskSuggestions.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
40 changes: 40 additions & 0 deletions Sources/Kaset/Models/AI/VideoIntelligence.swift
Original file line number Diff line number Diff line change
@@ -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
}
98 changes: 98 additions & 0 deletions Sources/Kaset/Models/DownloadQuality.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Loading