This document provides detailed information about Kaset's architecture, services, and design patterns.
The codebase follows a clean architecture pattern:
App/ β App entry point, AppDelegate
Core/ β Shared logic (platform-independent)
βββ Models/ β Data types (Song, Playlist, Album, Artist, etc.)
βββ Services/ β Business logic
β βββ API/ β YTMusicClient, Parsers/
β βββ Auth/ β AuthService (login state machine)
β βββ Player/ β PlayerService, NowPlayingManager (media keys)
β βββ WebKit/ β WebKitManager (cookie persistence)
β βββ HapticService.swift β Force Touch trackpad haptic feedback
βββ ViewModels/ β State management (HomeViewModel, etc.)
βββ Utilities/ β Helpers (DiagnosticsLogger, extensions)
Views/
βββ macOS/ β SwiftUI views (MainWindow, Sidebar, PlayerBar, etc.)
Tests/ β Unit tests (KasetTests/)
docs/ β Documentation
βββ adr/ β Architecture Decision Records
All major services have protocol definitions for testability:
// Core/Services/Protocols.swift
protocol YTMusicClientProtocol: Sendable { ... }
protocol AuthServiceProtocol { ... }
protocol PlayerServiceProtocol { ... }ViewModels accept protocols via dependency injection with default implementations:
@MainActor @Observable
final class HomeViewModel {
private let client: YTMusicClientProtocol
init(client: YTMusicClientProtocol = YTMusicClient.shared) {
self.client = client
}
}- Source of Truth: Services are
@MainActor @Observablesingletons - Environment Injection: Views access services via
@Environment - Cookie Persistence:
WKWebsiteDataStorewith persistent identifier
File: Core/Services/WebKit/WebKitManager.swift
Manages WebKit infrastructure for the app:
- Owns a persistent
WKWebsiteDataStorefor cookie storage - Provides cookie access via
getAllCookies() - Observes cookie changes via
WKHTTPCookieStoreObserver - Creates WebView configurations with shared data store
@MainActor @Observable
final class WebKitManager {
static let shared = WebKitManager()
func getAllCookies() async -> [HTTPCookie]
func createWebViewConfiguration() -> WKWebViewConfiguration
}File: Core/Services/Auth/AuthService.swift
Manages authentication state:
| State | Description |
|---|---|
.loggedOut |
No valid session |
.loggingIn |
Login sheet presented |
.loggedIn |
Valid __Secure-3PAPISID cookie found |
Key Methods:
checkLoginStatus()β Checks cookies for valid sessionstartLogin()β Presents login sheetsessionExpired()β Handles 401/403 from API
File: Core/Services/API/YTMusicClient.swift
Makes authenticated requests to YouTube Music's internal API:
- Computes
SAPISIDHASHauthorization per request - Uses browser-style headers to avoid bot detection
- Throws
YTMusicError.authExpiredon 401/403 - Delegates response parsing to modular parsers
Endpoints:
getHome()β Home page sections (with pagination viagetHomeContinuation())getExplore()β Explore page (new releases, charts, moods)search(query:)β Search resultsgetLibraryPlaylists()β User's playlistsgetLikedSongs()β User's liked songs (with pagination viagetLikedSongsContinuation())getPlaylist(id:)β Playlist details (with pagination viagetPlaylistContinuation())getPlaylistAllTracks(playlistId:)β All tracks via queue API (for radio playlists)getArtist(id:)β Artist details with songs and albumsgetLyrics(videoId:)β Lyrics for a track (two-step: next β browse)rateSong(videoId:rating:)β Like/dislike a songsubscribeToArtist(channelId:)β Subscribe to an artistunsubscribeFromArtist(channelId:)β Unsubscribe from an artistsubscribeToPlaylist(playlistId:)β Add playlist to libraryunsubscribeFromPlaylist(playlistId:)β Remove playlist from library
Directory: Core/Services/API/Parsers/
Response parsing is extracted into specialized modules:
| Parser | Purpose |
|---|---|
ParsingHelpers.swift |
Shared utilities (thumbnails, artists, duration) |
HomeResponseParser.swift |
Home/Explore page sections |
SearchResponseParser.swift |
Search results |
PlaylistParser.swift |
Playlist details, library playlists, queue tracks, pagination |
ArtistParser.swift |
Artist details (songs, albums, subscription status) |
RadioQueueParser.swift |
Radio queue from "next" endpoint |
SongMetadataParser.swift |
Full song metadata with feedback tokens |
LyricsParser.swift |
Lyrics extraction |
Design: Static enum-based parsers with pure functions for testability.
File: Core/Services/Player/PlayerService.swift
Controls audio playback via singleton WebView:
| Property | Type | Description |
|---|---|---|
currentTrack |
Song? |
Currently playing track |
isPlaying |
Bool |
Playback state |
progress |
Double |
Current position (seconds) |
duration |
Double |
Track length (seconds) |
pendingPlayVideoId |
String? |
Video ID to play |
showMiniPlayer |
Bool |
Mini player visibility |
showLyrics |
Bool |
Lyrics panel visibility |
Key Methods:
play(videoId:)β Loads and plays a videoplay(song:)β Plays a Song modelconfirmPlaybackStarted()β Dismisses mini player
File: Views/macOS/MiniPlayerWebView.swift
Manages the singleton WebView for playback:
- Creates exactly ONE WebView for app lifetime
- Handles video loading with pause-before-load
- JavaScript bridge for playback state updates
- Survives window close for background audio
@MainActor
final class SingletonPlayerWebView {
static let shared = SingletonPlayerWebView()
func getWebView(webKitManager:, playerService:) -> WKWebView
func loadVideo(videoId: String)
}File: Core/Services/Player/NowPlayingManager.swift
Remote command center integration for media key support:
- Registers
MPRemoteCommandCenterhandlers - Handles media keys (play/pause, next, previous, seek)
- Routes commands to
PlayerServiceβSingletonPlayerWebView
Note: Now Playing display (track info, album art) is handled natively by WKWebView's Media Session API. This provides better integration with album artwork from YouTube Music.
File: Core/Services/HapticService.swift
Provides tactile feedback on Macs with Force Touch trackpads:
| Feedback Type | Pattern | Used For |
|---|---|---|
.playbackAction |
.generic |
Play, pause, skip |
.toggle |
.alignment |
Shuffle, repeat, like/dislike |
.sliderBoundary |
.levelChange |
Volume/seek at 0% or 100% |
.navigation |
.alignment |
Sidebar selection |
.success |
.generic |
Add to library, search submit |
.error |
.generic |
Action failures |
Accessibility: Respects user preference (Settings β General) and system "Reduce Motion" setting.
File: Core/Services/FavoritesManager.swift
Manages user-curated Favorites section on Home view:
| Property | Type | Description |
|---|---|---|
items |
[FavoriteItem] |
Ordered list of pinned items |
isVisible |
Bool |
true when items exist |
Supported Item Types: Song, Album, Playlist, Artist
Key Methods:
add(_:)β Adds item to front of list (no duplicates)remove(contentId:)β Removes by videoId/browseIdtoggle(_:)β Adds if not pinned, removes if pinnedmove(from:to:)β Reorders via drag-and-dropisPinned(contentId:)β Checks if item is in Favorites
Persistence:
- Location:
~/Library/Application Support/Kaset/favorites.json - Format: JSON-encoded
[FavoriteItem] - Writes: Async on background thread via
Task.detached - Reads: Synchronous at init (one-time on app launch)
Related Files:
Core/Models/FavoriteItem.swiftβ Data model withItemTypeenumViews/macOS/SharedViews/FavoritesSection.swiftβ Horizontal scrolling UIViews/macOS/SharedViews/FavoritesContextMenu.swiftβ Shared context menu items
File: App/AppDelegate.swift
Application lifecycle management:
- Implements
NSWindowDelegateto hide window instead of close - Keeps app running when window is closed (
applicationShouldTerminateAfterLastWindowClosedreturnsfalse) - Handles dock icon click to reopen window
App Launch
β
βΌ
βββββββββββββββββββ
β Check cookies βββββ __Secure-3PAPISID exists? βββββ
β in WebKitManagerβ β
βββββββββββββββββββ β
β No β Yes
βΌ βΌ
βββββββββββββββββββ βββββββββββββββββββ
β Show LoginSheet β β AuthService β
β (WKWebView) β β .loggedIn β
βββββββββββββββββββ βββββββββββββββββββ
β
β User signs in β cookies set
β
βΌ
βββββββββββββββββββ
β Observer fires β
β cookiesDidChangeβ
βββββββββββββββββββ
β
βΌ
βββββββββββββββββββ
β Extract SAPISID β
β Dismiss sheet β
βββββββββββββββββββ
YTMusicClient.getHome()
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β buildAuthHeaders() β
β 1. Get cookies from WebKitManager β
β 2. Extract __Secure-3PAPISID β
β 3. Compute SAPISIDHASH = ts_SHA1(ts+sapi+origin)β
β 4. Build Cookie, Authorization, Origin headers β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β POST https://music.youtube.com/youtubei/v1/browseβ
β Body: { context: { client: WEB_REMIX }, ... } β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββ 200 OK β Parse JSON β Return HomeResponse
β
βββ 401/403 β Throw YTMusicError.authExpired
β AuthService.sessionExpired()
β Show LoginSheet
User clicks Play
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β PlayerService.play(videoId:) β
β β Sets pendingPlayVideoId β
β β Shows mini player toast β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β PersistentPlayerView appears β
β β Gets singleton WebView β
β β Loads music.youtube.com/watch?v={videoId} β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β WKWebView plays audio (DRM handled by WebKit) β
β β JS bridge sends STATE_UPDATE messages β
β β PlayerService updates isPlaying, progress β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β WKWebView Media Session (native) β
β β Updates macOS Now Playing (with album art) β
β NowPlayingManager β
β β Registers media key handlers β PlayerService β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
User closes window (βW or red button)
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β AppDelegate.windowShouldClose(_:) β
β β Returns false (prevents close) β
β β Hides window instead β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β WebView remains alive (in singleton) β
β β Audio continues playing β
β β Media keys still work β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
β User clicks dock icon
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β AppDelegate.applicationShouldHandleReopen β
β β Shows hidden window β
β β Same WebView still playing β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
β User quits (βQ)
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β App terminates β
β β WebView destroyed β Audio stops β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
File: Core/Models/YTMusicError.swift
Unified error type for the app:
| Error | Description |
|---|---|
.authExpired |
Session invalid (401/403) |
.notAuthenticated |
No valid session |
.networkError |
Connection failed |
.parseError |
JSON decoding failed |
.apiError |
API returned error with code |
.playbackError |
Playback-related failure |
.unknown |
Generic error |
- API returns 401/403 β
YTMusicClientthrows.authExpired AuthService.sessionExpired()called β state becomes.loggedOutAuthService.needsReauthset totrueMainWindowobserves and presentsLoginSheet- User re-authenticates β sheet dismissed, view reloads
All services log via DiagnosticsLogger:
DiagnosticsLogger.player.info("Loading video: \(videoId)")
DiagnosticsLogger.auth.error("Cookie extraction failed")Categories: .player, .auth, .api, .webKit, .haptic
Levels: .debug, .info, .warning, .error
This section documents performance patterns and optimizations used throughout the codebase.
File: Core/Services/API/YTMusicClient.swift
The API client uses an optimized URLSession configuration:
let configuration = URLSessionConfiguration.default
configuration.httpShouldUsePipelining = true // Parallel requests on same connection
configuration.httpMaximumConnectionsPerHost = 6 // Connection pool size
configuration.urlCache = URLCache.shared // HTTP caching
configuration.timeoutIntervalForRequest = 15 // Fail fastFile: Core/Services/API/APICache.swift
In-memory cache with TTL and LRU eviction:
| TTL | Endpoints |
|---|---|
| 5 minutes | Home, Explore, Library |
| 2 minutes | Search |
| 30 minutes | Playlist, Song metadata |
| 1 hour | Artist |
| 24 hours | Lyrics |
Design:
- Pre-allocated dictionary capacity to reduce rehashing
- Periodic eviction (every 30 seconds) instead of per-write
- Stable cache keys using SHA256 hash of sorted JSON body
File: Core/Utilities/ImageCache.swift
Thread-safe actor with memory and disk caching:
| Feature | Description |
|---|---|
| Memory cache | 200 items, 50MB limit via NSCache |
| Disk cache | 200MB limit with LRU eviction |
| Downsampling | Images resized to display size before caching |
| Prefetch cancellation | cancelPrefetch(id:) stops in-flight requests |
Prefetch Pattern:
// In view's .task modifier
await ImageCache.shared.prefetch(
urls: section.items.prefix(10).compactMap { $0.thumbnailURL },
targetSize: CGSize(width: 160, height: 160),
maxConcurrent: 4
)Avoid creating new array identity on every render:
// β Bad: Array(enumerated()) creates new array identity
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
Row(item)
}
// β
Good: Direct iteration with stable id
ForEach(items) { item in
Row(item)
}
// β
Good: Enumeration only when rank is needed (charts)
ForEach(Array(chartItems.enumerated()), id: \.element.id) { index, item in
ChartRow(item, rank: index + 1)
}For frequently changing values (e.g., playback progress at 60Hz), cache formatted strings:
// In PlayerBar
@State private var formattedProgress: String = "0:00"
@State private var lastProgressSecond: Int = -1
.onChange(of: playerService.progress) { _, newValue in
let currentSecond = Int(newValue)
if currentSecond != lastProgressSecond {
lastProgressSecond = currentSecond
formattedProgress = formatTime(newValue) // Only 1x per second
}
}Cancel async work when views disappear or inputs change:
// In CachedAsyncImage
@State private var loadTask: Task<Void, Never>?
.task(id: url) {
loadTask?.cancel()
loadTask = Task {
guard !Task.isCancelled else { return }
image = await ImageCache.shared.image(for: url)
}
}
.onDisappear {
loadTask?.cancel()
}- NSCache for images responds to memory pressure automatically
DispatchSource.makeMemoryPressureSourceclears image cache on system warning- Prefetch tasks are cancellable to prevent memory buildup during fast scrolling
Before completing non-trivial features, verify:
- No
awaitcalls inside loops orForEach - Lists use
LazyVStack/LazyHStackfor large datasets - Network calls cancelled on view disappear (
.taskhandles this) - Parsers have
measure {}tests if processing large payloads - Images use
ImageCachewith appropriatetargetSize - Search input is debounced (not firing on every keystroke)
- ForEach uses stable identity (avoid
Array(enumerated())unless needed)
The app uses Apple's Liquid Glass design language introduced in macOS 26.
| Component | Glass Pattern |
|---|---|
PlayerBar |
.glassEffect(.regular.interactive(), in: .capsule) |
Sidebar |
Wrapped in GlassEffectContainer |
QueueView / LyricsView |
.glassEffectTransition(.materialize) |
| Search field | .glassEffect(.regular, in: .capsule) |
| Search suggestions | .glassEffect(.regular, in: .rect(cornerRadius: 8)) |
- Use
GlassEffectContainerto wrap multiple glass elements - Use
.glassEffectTransition(.materialize)for panels that appear/disappear - Use
@Namespace+.glassEffectID()for morphing between states - Avoid glass-on-glass β don't apply
.buttonStyle(.glass)to buttons already inside a glass container - Reserve glass for navigation/floating controls β not for content areas
Kaset integrates Apple's on-device Foundation Models framework for AI-powered features. See ADR-0005: Foundation Models Architecture for detailed design decisions.
User Input (natural language)
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β FoundationModelsService β
β β Check availability β
β β Create session with tools β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β LanguageModelSession β
β β Parse input to @Generable type β
β β Call tools for grounded data β
β β Return structured response β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Execute Action β
β β MusicIntent β PlayerService β
β β QueueIntent β PlayerService queue methods β
β β LyricsSummary β Display in UI β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
| Component | Location | Purpose |
|---|---|---|
FoundationModelsService |
Core/Services/AI/ |
Singleton managing AI availability and sessions |
@Generable Models |
Core/Models/AI/ |
Type-safe structured outputs (MusicIntent, LyricsSummary, etc.) |
| Tools | Core/Services/AI/Tools/ |
Ground AI responses in real catalog data |
AIErrorHandler |
Core/Services/AI/ |
User-friendly error messages |
RequiresIntelligenceModifier |
Core/Utilities/ |
Hide AI features when unavailable |
| Feature | Trigger | Model Used |
|---|---|---|
| Command Bar | βK | MusicIntent, MusicQuery |
| Lyrics Explanation | "Explain" button in lyrics view | LyricsSummary |
| Queue Management | Natural language in command bar | QueueIntent |
| Queue Refinement | Refine button in queue view | QueueChanges |
- Token Limit: 4,096 tokens per session. Chunk large playlists, truncate long lyrics.
- Streaming: Use
streamResponsefor long-form content (lyrics explanation). - Tools: Always use tools to ground responses in real dataβprevents hallucination.
- Graceful Degradation: Use
.requiresIntelligence()modifier to hide unavailable features. - Error Handling: Use
AIErrorHandlerfor user-friendly messages.
File: Views/macOS/PlayerBar.swift
A floating capsule-shaped player bar at the bottom of the content area:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ββ βΆ βΆβΆ β π΅ [Thumbnail] Song Title - Artist β πβββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β β
Playback Now Playing Volume
Controls Info Control
Implementation:
GlassEffectContainer(spacing: 0) {
HStack {
playbackControls
Spacer()
centerSection // thumbnail + track info
Spacer()
volumeControl
}
.glassEffect(.regular.interactive(), in: .capsule)
}Key Points:
- Uses
GlassEffectContainerto wrap glass elements .glassEffect(.regular.interactive(), in: .capsule)for the liquid glass look- Only shows functional buttons (no placeholder buttons)
- Thumbnail and track info in center section
The PlayerBar must be added to every navigable view via safeAreaInset:
// In HomeView, LibraryView, SearchView, PlaylistDetailView
.safeAreaInset(edge: .bottom, spacing: 0) {
PlayerBar()
}Why not in MainWindow?
NavigationSplitViewdetail views have their own navigation stacks- Views pushed onto a
NavigationStackdon't inherit parent'ssafeAreaInset - Each view must explicitly include the
PlayerBar
File: Views/macOS/Sidebar.swift
Clean, minimal sidebar with only functional navigation:
ββββββββββββββββββββ
β π Search β β Main navigation
β π Home β
ββββββββββββββββββββ€
β Library β β Section header
β π΅ Playlists β β Functional items only
ββββββββββββββββββββ
Design Principles:
- Only show items that have implemented functionality
- Remove placeholder items (Artists, Albums, Songs, Liked Songs, etc.)
- Use standard SwiftUI
Listwith.listStyle(.sidebar)
UI elements that must remain visible across all navigation states (like the lyrics sidebar) should be placed outside the NavigationSplitView hierarchy in MainWindow:
// MainWindow.swift
var mainContent: some View {
HStack(spacing: 0) {
NavigationSplitView { ... } // Sidebar + detail navigation
// Lyrics sidebar OUTSIDE navigation - persists across all pushed views
LyricsView(...)
.frame(width: playerService.showLyrics ? 280 : 0)
}
}Why?
- Views pushed onto a
NavigationStackreplace content inside the stack - If a sidebar is inside the stack, pushed views won't see it
- Placing persistent elements outside the navigation hierarchy ensures they remain visible regardless of navigation state
Pattern: Global overlays/sidebars β MainWindow level, outside NavigationSplitView
All UI components require macOS 26.0+ for Liquid Glass:
@available(macOS 26.0, *)
struct PlayerBar: View { ... }
@available(macOS 26.0, *)
struct MainWindow: View { ... }