This document provides detailed information about Kaset's architecture, services, and design patterns.
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, Parsers/
β βββ Audio/ β EqualizerService, EqualizerAudioEngine, ProcessTapHelper, BiquadFilter
β βββ Auth/ β AuthService (login state machine)
β βββ Player/ β PlayerService, NowPlayingManager (media keys)
β βββ Scripting/β ScriptCommands (AppleScript integration)
β βββ WebKit/ β WebKitManager (cookie persistence)
β βββ HapticService.swift β Force Touch trackpad haptic feedback
βββ ViewModels/ β State management (HomeViewModel, etc.)
βββ Utilities/ β Helpers (DiagnosticsLogger, extensions)
βββ Views/ β SwiftUI views (MainWindow, Sidebar, PlayerBar, 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 { ... }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: 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
Directory: Sources/Kaset/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, add-to-playlist menu options, create-playlist IDs, and ownership/delete affordances |
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: Sources/Kaset/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: Sources/Kaset/Views/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: 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β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: 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 |
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 |
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 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:) |
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.
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 mode |
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 β
βββββββββββββββββββ
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: 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 |
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 |
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)
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.