Skip to content

Latest commit

Β 

History

History
751 lines (588 loc) Β· 27.9 KB

File metadata and controls

751 lines (588 loc) Β· 27.9 KB

Architecture & Services

This document provides detailed information about Kaset's architecture, services, and design patterns.

Core Structure

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

Service Protocols

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
    }
}

State Management

  • Source of Truth: Services are @MainActor @Observable singletons
  • Environment Injection: Views access services via @Environment
  • Cookie Persistence: WKWebsiteDataStore with persistent identifier

Key Services

WebKitManager

File: Core/Services/WebKit/WebKitManager.swift

Manages WebKit infrastructure for the app:

  • Owns a persistent WKWebsiteDataStore for 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
}

AuthService

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 session
  • startLogin() β€” Presents login sheet
  • sessionExpired() β€” Handles 401/403 from API

YTMusicClient

File: Core/Services/API/YTMusicClient.swift

Makes authenticated requests to YouTube Music's internal API:

  • Computes SAPISIDHASH authorization per request
  • Uses browser-style headers to avoid bot detection
  • Throws YTMusicError.authExpired on 401/403
  • Delegates response parsing to modular parsers

Endpoints:

  • getHome() β†’ Home page sections (with pagination via getHomeContinuation())
  • getExplore() β†’ Explore page (new releases, charts, moods)
  • search(query:) β†’ Search results
  • getLibraryPlaylists() β†’ User's playlists
  • getLikedSongs() β†’ User's liked songs (with pagination via getLikedSongsContinuation())
  • getPlaylist(id:) β†’ Playlist details (with pagination via getPlaylistContinuation())
  • getPlaylistAllTracks(playlistId:) β†’ All tracks via queue API (for radio playlists)
  • getArtist(id:) β†’ Artist details with songs and albums
  • getLyrics(videoId:) β†’ Lyrics for a track (two-step: next β†’ browse)
  • rateSong(videoId:rating:) β†’ Like/dislike a song
  • subscribeToArtist(channelId:) β†’ Subscribe to an artist
  • unsubscribeFromArtist(channelId:) β†’ Unsubscribe from an artist
  • subscribeToPlaylist(playlistId:) β†’ Add playlist to library
  • unsubscribeFromPlaylist(playlistId:) β†’ Remove playlist from library

API Parsers

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.

PlayerService

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 video
  • play(song:) β€” Plays a Song model
  • confirmPlaybackStarted() β€” Dismisses mini player

SingletonPlayerWebView

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)
}

NowPlayingManager

File: Core/Services/Player/NowPlayingManager.swift

Remote command center integration for media key support:

  • Registers MPRemoteCommandCenter handlers
  • 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.

HapticService

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.

FavoritesManager

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/browseId
  • toggle(_:) β€” Adds if not pinned, removes if pinned
  • move(from:to:) β€” Reorders via drag-and-drop
  • isPinned(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 with ItemType enum
  • Views/macOS/SharedViews/FavoritesSection.swift β€” Horizontal scrolling UI
  • Views/macOS/SharedViews/FavoritesContextMenu.swift β€” Shared context menu items

AppDelegate

File: App/AppDelegate.swift

Application lifecycle management:

  • Implements NSWindowDelegate to hide window instead of close
  • Keeps app running when window is closed (applicationShouldTerminateAfterLastWindowClosed returns false)
  • Handles dock icon click to reopen window

Authentication Flow

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   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

API Request Flow

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

Playback Flow

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 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Background Audio Flow

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              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Error Handling

YTMusicError

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

Error Flow

  1. API returns 401/403 β†’ YTMusicClient throws .authExpired
  2. AuthService.sessionExpired() called β†’ state becomes .loggedOut
  3. AuthService.needsReauth set to true
  4. MainWindow observes and presents LoginSheet
  5. User re-authenticates β†’ sheet dismissed, view reloads

Logging

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

Performance Guidelines

This section documents performance patterns and optimizations used throughout the codebase.

Network Optimization

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 fast

API Caching

File: 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

Image Caching

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
)

SwiftUI View Optimization

Stable ForEach Identity

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)
}

Throttled UI Updates

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
    }
}

Task Cancellation

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()
}

Memory Management

  • NSCache for images responds to memory pressure automatically
  • DispatchSource.makeMemoryPressureSource clears image cache on system warning
  • Prefetch tasks are cancellable to prevent memory buildup during fast scrolling

Profiling Checklist

Before completing non-trivial features, verify:

  • No await calls inside loops or ForEach
  • Lists use LazyVStack/LazyHStack for large datasets
  • Network calls cancelled on view disappear (.task handles this)
  • Parsers have measure {} tests if processing large payloads
  • Images use ImageCache with appropriate targetSize
  • Search input is debounced (not firing on every keystroke)
  • ForEach uses stable identity (avoid Array(enumerated()) unless needed)

UI Design (macOS 26+)

The app uses Apple's Liquid Glass design language introduced in macOS 26.

Glass Effect Patterns

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))

Glass Effect Best Practices

  1. Use GlassEffectContainer to wrap multiple glass elements
  2. Use .glassEffectTransition(.materialize) for panels that appear/disappear
  3. Use @Namespace + .glassEffectID() for morphing between states
  4. Avoid glass-on-glass β€” don't apply .buttonStyle(.glass) to buttons already inside a glass container
  5. Reserve glass for navigation/floating controls β€” not for content areas

Foundation Models (Apple Intelligence)

Kaset integrates Apple's on-device Foundation Models framework for AI-powered features. See ADR-0005: Foundation Models Architecture for detailed design decisions.

Architecture Overview

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                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key Components

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

AI Features

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

Best Practices

  1. Token Limit: 4,096 tokens per session. Chunk large playlists, truncate long lyrics.
  2. Streaming: Use streamResponse for long-form content (lyrics explanation).
  3. Tools: Always use tools to ground responses in real dataβ€”prevents hallucination.
  4. Graceful Degradation: Use .requiresIntelligence() modifier to hide unavailable features.
  5. Error Handling: Use AIErrorHandler for user-friendly messages.

PlayerBar

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 GlassEffectContainer to 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

PlayerBar Integration

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?

  • NavigationSplitView detail views have their own navigation stacks
  • Views pushed onto a NavigationStack don't inherit parent's safeAreaInset
  • Each view must explicitly include the PlayerBar

Sidebar

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 List with .listStyle(.sidebar)

Persistent UI Elements

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 NavigationStack replace 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

@available Attributes

All UI components require macOS 26.0+ for Liquid Glass:

@available(macOS 26.0, *)
struct PlayerBar: View { ... }

@available(macOS 26.0, *)
struct MainWindow: View { ... }