This document provides detailed information about Kaset's architecture, services, and design patterns. Kaset now has two parallel content sources over the same Google login: YouTube Music (.music, default) and regular YouTube (.video). Shared infrastructure handles auth, settings, caching, images, and media keys; source-specific clients, models, parsers, view models, and playback WebViews keep the two experiences isolated.
The codebase follows a clean architecture pattern:
Sources/
βββ Kaset/ β Main app target
βββ KasetApp.swift β App entry point
βββ AppDelegate.swift β Window lifecycle
βββ Models/ β Data types (Song, Playlist, Album, Artist, etc.)
βββ Services/ β Business logic
β βββ API/ β YTMusicClient, YouTubeClient, Parsers/
β βββ Audio/ β EqualizerService, EqualizerAudioEngine, ProcessTapHelper, BiquadFilter
β βββ Auth/ β AuthService (login state machine)
β βββ Library/ β Library identity and optimistic reconciliation modules
β βββ Player/ β PlayerService, YouTubePlayerService, PlaybackArbiter, NowPlayingManager, queue metadata and album playback actions
β βββ Scripting/β ScriptCommands (AppleScript integration)
β βββ WebKit/ β WebKitManager (cookie persistence)
β βββ HapticService.swift β Force Touch trackpad haptic feedback
βββ ViewModels/ β State management (music view models plus YouTube/ video-source view models)
βββ Utilities/ β Helpers (DiagnosticsLogger, extensions)
βββ Views/ β SwiftUI views (MainWindow, Sidebar, PlayerBar, YouTube views, etc.)
βββ APIExplorer/ β API explorer CLI tool
Tests/ β Unit tests (KasetTests/)
docs/ β Documentation
βββ adr/ β Architecture Decision Records
All major services have protocol definitions for testability:
// Sources/Kaset/Services/Protocols.swift
protocol YTMusicClientProtocol: Sendable { ... }
protocol AuthServiceProtocol { ... }
protocol PlayerServiceProtocol { ... }
// Sources/Kaset/Services/YouTubeProtocols.swift
protocol YouTubeClientProtocol: Sendable { ... }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
}
}YouTube view models follow the same pattern with YouTubeClientProtocol and live under Sources/Kaset/ViewModels/YouTube/. YouTubeViewModelStore keeps the YouTube navigation stack and view-model caches warm while the user switches back to Music.
- Source of Truth: Services are
@MainActor @Observablesingletons - Environment Injection: Views access services via
@Environment - Cookie Persistence:
WKWebsiteDataStorewith persistent identifier
LibraryViewModel owns observable Library UI state, while LibraryContentReconciler owns optimistic add/remove reconciliation for eventually-consistent YouTube Music Library responses. LibraryMutationActions owns mutation orchestration: calling YouTube Music, invalidating stale caches, applying optimistic state, and scheduling delayed reconciliation when backend snapshots lag. This keeps pending mutation stabilization rules behind small Library interfaces instead of spreading them across view models and action helpers.
File: Sources/Kaset/Services/WebKit/WebKitManager.swift
Manages WebKit infrastructure for the app:
- Owns a persistent
WKWebsiteDataStorefor runtime cookie storage - Backs up auth cookies to macOS Keychain for persistence across app updates
- 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: Sources/Kaset/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: Sources/Kaset/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 librarydeletePlaylist(playlistId:)β Delete a user-owned playlistgetAddToPlaylistOptions(videoId:)β Fetch the signed-in user's add-to-playlist menuaddSongToPlaylist(videoId:playlistId:allowDuplicate:)β Add one song to an existing playlist viabrowse/edit_playlistcreatePlaylist(title:description:privacyStatus:videoIds:)β Create a playlist and optionally seed it with songs
File: Sources/Kaset/Services/API/YouTubeClient.swift
Makes authenticated requests to regular YouTube's internal InnerTube API. It deliberately mirrors YTMusicClient's scaffolding while keeping the origin, client identity, models, and parsers separate:
- Uses
https://www.youtube.comforSAPISIDHASH,Origin,Referer, andX-Origin - Uses the
WEBInnerTube client instead of YouTube Music'sWEB_REMIX - Prefixes shared
APICachekeys withyt:so video-source entries do not collide with Music cache invalidation - Delegates response parsing to YouTube-specific parsers under
Services/API/Parsers/YouTube/
Endpoints / surfaces:
getHomeBundle()/getHomeFeed()β Recommendations, chips, shelves, and paginationsearch(query:filter:)β Videos, channels, and playlistsgetWatchNext(videoId:)β Watch metadata, related videos, channel state, and comment continuationgetComments(continuation:),postComment(...),performCommentAction(...)β Watch-page commentsgetChannel(channelId:),getPlaylist(playlistId:),getDestinationFeed(_:),getShorts()β Browse surfacesgetSubscriptionsFeed(),getSubscribedChannels(),getHistory(forceRefresh:),getUserPlaylists()β Signed-in YouTube surfacesrateVideo(...),setSubscribed(...),addToWatchLater(...),removeFromWatchLater(...)β Mutations
See youtube.md for the full source-toggle and regular YouTube architecture.
Directory: Sources/Kaset/Services/API/Parsers/
Response parsing is extracted into specialized modules:
| Parser | Purpose |
|---|---|
ParsingHelpers.swift |
Shared utilities (thumbnails, artists, duration) |
ResponseTreeSearch.swift |
Recursive search helpers for nested YouTube Music response trees |
HomeResponseParser.swift |
Home/Explore page sections |
SearchResponseParser.swift |
Search results |
LibraryContentParser.swift |
Library browse content, including playlists, followed artists, podcast shows, and uploaded-song virtual tile parsing |
PlaylistEditability.swift |
Playlist ownership/delete affordance detection |
PlaylistParser.swift |
Playlist details, queue tracks, pagination, add-to-playlist menu options, and create-playlist IDs |
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 |
YouTube/ |
Regular YouTube feed, search, watch-next, comments, channel, playlist, guide, and renderer parsers |
Design: Static enum-based parsers with pure functions for testability.
QueueSongMetadata prepares song values before they enter the native queue, centralizing artist cleanup and fallback album/thumbnail rules. AlbumPlaybackActions owns album-specific fetch/queue/play workflows so SwiftUI action helpers do not duplicate album-track enrichment logic. PlaylistPlaybackActions owns playlist playback, radio queue fallback, playlist-artwork fallback, and continuation append/cancel behavior.
File: Sources/Kaset/Services/Player/PlayerService.swift
Controls YouTube Music audio playback via the singleton Music 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 |
shuffleMode |
ShuffleMode |
Tri-state shuffle (off / on / smart) |
shuffleEnabled |
Bool |
Computed shim, true when shuffleMode != .off |
Key Methods:
play(videoId:)β Loads and plays a videoplay(song:)β Plays a Song modelconfirmPlaybackStarted()β Dismisses mini playertoggleShuffle()β Binary onβoff shuffle (menu βS, mini player, AppleScript, AI)cycleShuffleMode()β Player-bar control cycling offβonβsmartβoff (skips smart when disabled in settings)
Smart Shuffle is implemented in PlayerService+SmartShuffle.swift as a rolling
recommendation window (pure helpers nextSuggestionSlot, dedupeSuggestions,
stripSuggested plus async fillSmartShuffleWindow). See
ADR-0025: Smart Shuffle and
docs/playback.md for details.
File: Sources/Kaset/Views/MiniPlayerWebView.swift
Manages the singleton WebView for YouTube Music 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)
}Files:
Sources/Kaset/Services/Player/YouTubePlayerService.swiftSources/Kaset/Views/YouTube/YouTubeWatchWebView.swift
Control regular YouTube video playback through a separate singleton watch WebView. The WebView loads www.youtube.com/watch?v={videoId}, extracts the video surface for inline/floating presentation, and reports playback state, captions, quality, ad state, and timing back to Swift.
YouTubePlayerService.surfaceLocation tracks where the extracted video currently lives:
| Surface | Description |
|---|---|
.inline |
Docked in YouTubeWatchView |
.floating |
Hosted by YouTubeVideoWindowController |
.none |
No active YouTube playback surface |
File: Sources/Kaset/Services/Player/PlaybackArbiter.swift
Coordinates the two playback services so Music and YouTube do not play over each other. When one source starts, the arbiter pauses the other source; NowPlayingManager uses the arbiter's active/last-played source to route media keys.
File: Sources/Kaset/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/YouTubePlayerServicebased on the active source
Note: Now Playing display (track/video info, artwork) is handled natively by WKWebView's Media Session API. This provides better integration with YouTube Music artwork and regular YouTube video metadata.
File: Sources/Kaset/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: Sources/Kaset/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:
Sources/Kaset/Models/FavoriteItem.swiftβ Data model withItemTypeenumSources/Kaset/Views/SharedViews/FavoritesSection.swiftβ Horizontal scrolling UISources/Kaset/Views/SharedViews/FavoritesContextMenu.swiftβ Shared context menu items
File: Sources/Kaset/Services/Notification/NotificationService.swift
Posts local notifications when the current track changes:
- Observes
PlayerService.currentTrackfor changes - Posts silent alerts with track title and artist
- Respects user preference via
SettingsManager.showNowPlayingNotifications - Requests notification authorization on init
Usage: Instantiated in MainWindow and kept alive for app lifetime.
File: Sources/Kaset/Services/NetworkMonitor.swift
Monitors network connectivity using NWPathMonitor:
| Property | Type | Description |
|---|---|---|
isConnected |
Bool |
Whether network is available |
isExpensive |
Bool |
Cellular or hotspot connection |
isConstrained |
Bool |
Low Data Mode enabled |
interfaceType |
InterfaceType |
WiFi, cellular, wired, etc. |
statusDescription |
String |
Human-readable status |
Note: Uses DispatchQueue for NWPathMonitor callbacks (no async/await API from Apple).
File: Sources/Kaset/Services/SettingsManager.swift
Manages user preferences persisted via UserDefaults:
| Setting | Type | Default | Description |
|---|---|---|---|
showNowPlayingNotifications |
Bool |
true |
Track change notifications |
defaultLaunchPage |
LaunchPage |
.home |
Initial page on app launch |
appSource |
AppSource |
.music |
Active source toggle (.music for YouTube Music, .video for regular YouTube) |
hapticFeedbackEnabled |
Bool |
true |
Force Touch feedback |
rememberPlaybackSettings |
Bool |
false |
Persist shuffle/repeat state |
syncedLyricsEnabled |
Bool |
true |
Enable synced lyrics provider lookup before plain lyrics fallback |
popOutVideoOnNavigateAway |
Bool |
true |
Pop a playing YouTube video into the floating window when navigating away; when off, playback stops |
Music LaunchPage Options: Home, Explore, Charts, Moods & Genres, New Releases, Liked Music, Playlists, Last Used
File: Sources/Kaset/Services/Audio/EqualizerService.swift
Owns the equalizer feature's user-facing state and lifecycle. The actual DSP runs in EqualizerAudioEngine via an AudioDeviceIOProc registered on an aggregate device that wraps a Core Audio process tap on WebKit's GPU subprocess. See ADR-0017 for the full architecture rationale.
| Property | Type | Description |
|---|---|---|
settings |
EQSettings |
Active band gains, preamp, preset, enabled flag β persisted as JSON in UserDefaults |
status |
Status |
Computed engine status (off / active / standby / permissionNeeded / error) surfaced to the settings UI |
lastFailure |
StartFailure? |
Last raw engine failure; the UI reads it via status rather than directly |
Persistence: Every mutation triggers a JSON round-trip under the settings.equalizer UserDefaults key. The full settings shape (isEnabled, preampDB, bandGainsDB, preset) restores on next launch.
Engine seam: EqualizerAudioEngineProtocol lets tests inject a no-op stub so EqualizerServiceTests can verify state transitions without touching Core Audio.
β οΈ Naming note:Sources/Kaset/Views/SharedViews/EqualizerView.swiftis unrelated β it's a small now-playing animation indicator (three bouncing bars). The settings UI isEqualizerSettingsView.
File: Sources/Kaset/Services/Lyrics/SyncedLyricsService.swift
Coordinates synced and plain lyrics resolution for the current track:
| Property | Type | Description |
|---|---|---|
currentLyrics |
LyricResult |
Currently displayed .synced, .plain, or .unavailable lyrics |
activeProvider |
String? |
Provider/source label surfaced in the lyrics UI |
isLoading |
Bool |
Whether synced provider search is currently running |
Key Behaviors:
- Searches all registered
LyricsProviderimplementations concurrently usingLyricsSearchInfo - Ships with
LRCLibProvideras the default synced lyrics source and parses LRC payloads withLRCParser - Caches results in memory by
videoIdand can upgrade cached plain lyrics when synced lyrics become available later - Uses
fetchGenerationto ignore stale async completions when the user changes tracks quickly - Preserves plain lyrics fallback state until a higher-quality synced result is resolved
Related Files:
Sources/Kaset/Services/Lyrics/LyricsProvider.swiftβ Provider protocol and search modelSources/Kaset/Services/Lyrics/Providers/LRCLibProvider.swiftβ External synced lyrics providerSources/Kaset/Services/API/Parsers/LRCParser.swiftβ LRC toSyncedLyricsparser
Integration: Created once in KasetApp and injected through the SwiftUI environment for lyrics views.
See ADR-0012: Synced Lyrics Provider Architecture for design details.
File: Sources/Kaset/Services/ShareService.swift
Provides share sheet functionality via the Shareable protocol:
protocol Shareable {
var shareTitle: String { get }
var shareSubtitle: String? { get }
var shareURL: URL? { get }
}Conforming Types: Song, Playlist, Album, Artist
Share Text Format: "Title by Artist" or just "Title" for artists.
File: Sources/Kaset/Services/SongLikeStatusManager.swift
Caches and syncs like/dislike status for songs:
| Method | Description |
|---|---|
status(for:) |
Get cached status for video ID or Song |
isLiked(_:) |
Check if song is liked |
setStatus(_:for:) |
Update cache (optimistic update) |
rate(_:status:) |
Sync rating with YouTube Music API |
Optimistic Updates: Updates cache immediately, rolls back on API failure.
File: Sources/Kaset/Services/URLHandler.swift
Parses YouTube Music URLs, regular YouTube watch links, and custom kaset:// URLs:
| URL Pattern | ParsedContent |
|---|---|
music.youtube.com/watch?v=xxx |
.song(videoId:) |
music.youtube.com/playlist?list=xxx |
.playlist(id:) |
music.youtube.com/browse/MPRExxx |
.album(id:) |
music.youtube.com/channel/UCxxx |
.artist(id:) |
youtube.com/watch?v=xxx |
.youtubeVideo(videoId:) |
youtu.be/xxx |
.youtubeVideo(videoId:) |
kaset://play?v=xxx |
.song(videoId:) |
kaset://playlist?list=xxx |
.playlist(id:) |
kaset://album?id=xxx |
.album(id:) |
kaset://artist?id=xxx |
.artist(id:) |
Usage: Called from KasetApp.onOpenURL to handle deep links. Music song links play through PlayerService; regular YouTube watch links switch to the YouTube source and open playback in the floating video window.
File: Sources/Kaset/Services/Scripting/ScriptCommands.swift
Provides AppleScript support for external automation via NSScriptCommand subclasses:
| Command Class | AppleScript Command | Description |
|---|---|---|
PlayCommand |
play |
Resume playback |
PauseCommand |
pause |
Pause playback |
PlayPauseCommand |
playpause |
Toggle play/pause |
NextTrackCommand |
next track |
Skip to next track |
PreviousTrackCommand |
previous track |
Go to previous track |
SetVolumeCommand |
set volume N |
Set volume (0-100) |
ToggleMuteCommand |
toggle mute |
Toggle mute state |
ToggleShuffleCommand |
toggle shuffle |
Toggle shuffle on/off (binary; does not reach Smart Shuffle) |
CycleRepeatCommand |
cycle repeat |
Cycle repeat (Off β All β One) |
LikeTrackCommand |
like track |
Like current track |
DislikeTrackCommand |
dislike track |
Dislike current track |
GetPlayerInfoCommand |
get player info |
Returns player state as JSON |
Implementation Details:
- Commands access
PlayerService.sharedviaMainActor.assumeIsolated(AppleScript runs on main thread) - Synchronous methods (shuffle, repeat, like/dislike) execute directly
- Async methods (play, pause, volume) spawn detached Tasks
- All commands return AppleScript errors (
-1728) ifPlayerService.sharedis nil - Logging via
DiagnosticsLogger.scripting
Related Files:
Sources/Kaset/Kaset.sdefβ AppleScript dictionary definitionSources/Kaset/Info.plistβNSAppleScriptEnabledandOSAScriptingDefinitionkeys
Usage:
tell application "Kaset"
play
set volume 50
get player info -- Returns JSON string
end tellFile: Sources/Kaset/Services/UpdaterService.swift
Manages application updates via Sparkle framework:
| Property | Type | Description |
|---|---|---|
automaticChecksEnabled |
Bool |
Auto-check on launch |
canCheckForUpdates |
Bool |
Whether check is allowed now |
Key Methods:
checkForUpdates()β Manually trigger update check
See ADR-0007: Sparkle Auto-Updates for design details.
File: Sources/Kaset/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 β
βββββββββββββββββββ
The Music and YouTube API clients share the same high-level request shape but use different origins and InnerTube client identities. The Music path looks like this:
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
YouTubeClient follows the same cookie/SAPISIDHASH flow with https://www.youtube.com, the WEB client context, and yt:-prefixed cache keys.
This diagram covers YouTube Music playback. Regular YouTube playback is documented in youtube.md and uses YouTubePlayerService plus YouTubeWatchWebView.
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: Sources/Kaset/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, .notification, .scripting
Levels: .debug, .info, .warning, .error
The Sources/Kaset/Utilities/ folder contains shared helper components.
File: Sources/Kaset/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 | Structured cancellation for SwiftUI .task lifecycle |
Key Methods:
image(for:targetSize:)β Fetch and cache imageprefetch(urls:targetSize:maxConcurrent:)β Batch prefetch for lists
File: Sources/Kaset/Utilities/ColorExtractor.swift
Extracts dominant colors from album art for dynamic theming:
- Uses
CIAreaAveragefilter for color extraction - Caches extracted colors to avoid repeated processing
- Used by
AccentBackgroundfor gradient overlays
File: Sources/Kaset/Utilities/AnimationConfiguration.swift
Standardized animation timing for consistent motion design:
| Animation | Duration | Curve | Used For |
|---|---|---|---|
.standard |
0.25s | .easeInOut |
General transitions |
.quick |
0.15s | .easeOut |
Button feedback |
.slow |
0.4s | .easeInOut |
Panel reveals |
File: Sources/Kaset/Utilities/AccessibilityIdentifiers.swift
Centralized UI test identifiers organized by feature:
enum AccessibilityID {
enum Player { static let playButton = "player.playButton" }
enum Queue { static let container = "queue.container" }
// ...
}Benefits: Prevents string duplication, enables IDE autocomplete in tests.
File: Sources/Kaset/Utilities/IntelligenceModifier.swift
SwiftUI modifier to hide AI features when unavailable:
.requiresIntelligence() // Hides view if Apple Intelligence unavailableChecks FoundationModelsService.shared.isAvailable and applies opacity/disabled state.
File: Sources/Kaset/Utilities/RetryPolicy.swift
Exponential backoff for network retries:
try await RetryPolicy.execute(
maxAttempts: 3,
initialDelay: .seconds(1),
shouldRetry: { ($0 as? YTMusicError)?.isRetryable ?? false }
) {
try await client.fetchData()
}This section documents performance patterns and optimizations used throughout the codebase.
File: Sources/Kaset/Services/API/YTMusicClient.swift
The API client uses an optimized URLSession configuration:
let configuration = URLSessionConfiguration.default
configuration.httpMaximumConnectionsPerHost = 6 // Connection pool size (HTTP/2 multiplexing is automatic)
configuration.urlCache = URLCache.shared // HTTP caching
configuration.timeoutIntervalForRequest = 15 // Fail fastFile: Sources/Kaset/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
- Mutation invalidation clears browse, next, like, and
playlist/get_add_to_playlistentries so library and add-to-playlist menus refresh after playlist/song changes - Mutation invalidation is scoped to the app's
APICache; it does not flushURLCache.sharedHTTP responses
File: Sources/Kaset/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 |
| Structured cancellation | Prefetch respects SwiftUI .task cancellation |
Prefetch Pattern:
// In view's .task modifier - cancellation is automatic when view disappears
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 when available. Compatibility helpers in LiquidGlassCompat.swift keep the core app usable on macOS 15 by falling back to material backgrounds and non-AI views.
| Component | Glass Pattern |
|---|---|
PlayerBar |
.glassEffect(.regular.interactive(), in: .capsule) |
Sidebar |
List(.sidebar) with .scrollContentBackground(.hidden) (macOS 26) so the system Liquid Glass shows through; detail content slides under it (ADR-0021) |
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 β don't apply a glass material to content. Content may, however, pass beneath the system-provided navigation glass: on macOS 26 detail content slides under the floating
NavigationSplitViewsidebar and refracts through it (see ADR-0021)
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 |
Sources/Kaset/Services/AI/ |
Singleton managing AI availability and sessions |
@Generable Models |
Sources/Kaset/Models/AI/ |
Type-safe structured outputs (MusicIntent, LyricsSummary, etc.) |
| Tools | Sources/Kaset/Services/AI/Tools/ |
Ground AI responses in real catalog data |
AIErrorHandler |
Sources/Kaset/Services/AI/ |
User-friendly error messages |
RequiresIntelligenceModifier |
Sources/Kaset/Utilities/ |
Hide AI features when unavailable |
Tools ground AI responses in real data from the YouTube Music catalog, preventing hallucination.
MusicSearchTool (Sources/Kaset/Services/AI/Tools/MusicSearchTool.swift)
Searches the YouTube Music catalog for songs, artists, and albums:
@Generable
struct MusicSearchResult {
let songs: [SongResult]
let artists: [ArtistResult]
let albums: [AlbumResult]
}QueueTool (Sources/Kaset/Services/AI/Tools/QueueTool.swift)
Provides access to the current playback queue for AI-driven queue management:
- Returns current queue contents
- Used by
QueueIntentfor natural language queue manipulation - Enables commands like "remove duplicates" or "play upbeat songs next"
Type-safe structured outputs for AI responses:
| Model | File | Purpose |
|---|---|---|
MusicIntent |
Sources/Kaset/Models/AI/MusicIntent.swift |
Parsed user commands (play, pause, search, etc.) |
LyricsSummary |
Sources/Kaset/Models/AI/LyricsSummary.swift |
Lyrics explanation with themes and mood |
PlaylistChanges |
Sources/Kaset/Models/AI/PlaylistChanges.swift |
Queue refinement operations |
| 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.
This section documents key SwiftUI views and their integration patterns.
File: Sources/Kaset/Views/QueueView.swift
Right sidebar panel displaying the playback queue:
- Shows "Up Next" header with shuffle/clear actions
- Displays queue items with drag-to-reorder support
- AI-powered "Refine" button for natural language queue editing
- Uses
.glassEffect(.regular.interactive())with.glassEffectTransition(.materialize)
Integration: Toggled via PlayerService.showQueue, placed outside NavigationSplitView in MainWindow.
File: Sources/Kaset/Views/LyricsView.swift
Right sidebar panel displaying song lyrics:
- Reads
SyncedLyricsServicefrom the environment for the currentLyricResult - When
SettingsManager.syncedLyricsEnabledis enabled, buildsLyricsSearchInfofrom the active track and requests synced lyrics first - Falls back to
YTMusicClient.getLyrics(videoId:)for plain YouTube Music lyrics when synced providers return.unavailable - Starts 10 Hz WebView playback polling only while rendering
.syncedlyrics so line highlighting stays aligned without constant idle polling SyncedLyricsDisplayViewauto-centers the current line and supports tap-to-seek- "Explain" button triggers AI-powered
LyricsSummarygeneration for either synced or plain lyrics - Width: 280px, animated show/hide
Integration: Toggled via PlayerService.showLyrics, persists across all navigation states, and consumes playback time from PlayerService.currentTimeMs.
File: Sources/Kaset/Views/CommandBarView.swift
Spotlight-style command palette triggered by βK:
- Natural language input parsed via
MusicIntent - Shows suggestions and action previews
- Supports commands: play, pause, skip, search, queue management
- Floating overlay with glass effect
Trigger: @Environment(\.showCommandBar) environment key.
File: Sources/Kaset/Views/ToastView.swift
Temporary notification overlay for user feedback:
| Toast Type | Duration | Purpose |
|---|---|---|
| Success | 2s | Action completed (e.g., "Added to library") |
| Error | 3s | Action failed with message |
| Info | 2s | Informational feedback |
Usage: Managed via environment or direct state binding.
Directory: Sources/Kaset/Views/SharedViews/
Reusable components used across the app:
| Component | Purpose |
|---|---|
ErrorView |
Standardized error display with retry button |
LoadingView |
Skeleton loading states |
SkeletonView |
Shimmer placeholder for loading content |
EqualizerView |
Animated bars for "now playing" indicator |
HomeSectionItemCard |
Card component for carousel items |
InteractiveCardStyle |
Hover/press effects for cards |
NavigationDestinationsModifier |
Centralized navigation destination handling |
ShareContextMenu |
Context menu for sharing content |
StartRadioContextMenu |
Context menu for starting radio/mix |
FavoritesSection |
Horizontal scrolling favorites row |
FavoritesContextMenu |
Context menu for favorites management |
SongActionsHelper |
Common song action handlers |
AnimationModifiers |
Reusable animation view modifiers |
File: Sources/Kaset/Views/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: Sources/Kaset/Views/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)as the column root - Apply
compatTranslucentSidebar(): on macOS 26 it hides the list's opaque background so the floating Liquid Glass shows through and detail content slides under it; on legacy macOS 15 it falls back to an.ultraThinMaterialfrosted panel (ADR-0021)
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
Foundation Models-backed AI components remain macOS 26.0+ only:
@available(macOS 26.0, *)
struct CommandBarView: View { ... }
@available(macOS 26.0, *)
struct LyricsView: View { ... }Core navigation and playback surfaces should avoid macOS 26-only annotations and use compatibility wrappers or explicit fallback views instead.