Skip to content

Latest commit

Β 

History

History
1168 lines (878 loc) Β· 44.7 KB

File metadata and controls

1168 lines (878 loc) Β· 44.7 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:

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

Service Protocols

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

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: Sources/Kaset/Services/WebKit/WebKitManager.swift

Manages WebKit infrastructure for the app:

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

AuthService

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

YTMusicClient

File: Sources/Kaset/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
  • deletePlaylist(playlistId:) β†’ Delete a user-owned playlist
  • getAddToPlaylistOptions(videoId:) β†’ Fetch the signed-in user's add-to-playlist menu
  • addSongToPlaylist(videoId:playlistId:allowDuplicate:) β†’ Add one song to an existing playlist via browse/edit_playlist
  • createPlaylist(title:description:privacyStatus:videoIds:) β†’ Create a playlist and optionally seed it with songs

API Parsers

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.

PlayerService

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

SingletonPlayerWebView

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

NowPlayingManager

File: Sources/Kaset/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: 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.

FavoritesManager

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

  • Sources/Kaset/Models/FavoriteItem.swift β€” Data model with ItemType enum
  • Sources/Kaset/Views/SharedViews/FavoritesSection.swift β€” Horizontal scrolling UI
  • Sources/Kaset/Views/SharedViews/FavoritesContextMenu.swift β€” Shared context menu items

NotificationService

File: Sources/Kaset/Services/Notification/NotificationService.swift

Posts local notifications when the current track changes:

  • Observes PlayerService.currentTrack for 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.

NetworkMonitor

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

SettingsManager

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

EqualizerService

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.swift is unrelated β€” it's a small now-playing animation indicator (three bouncing bars). The settings UI is EqualizerSettingsView.

SyncedLyricsService

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 LyricsProvider implementations concurrently using LyricsSearchInfo
  • Ships with LRCLibProvider as the default synced lyrics source and parses LRC payloads with LRCParser
  • Caches results in memory by videoId and can upgrade cached plain lyrics when synced lyrics become available later
  • Uses fetchGeneration to 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 model
  • Sources/Kaset/Services/Lyrics/Providers/LRCLibProvider.swift β€” External synced lyrics provider
  • Sources/Kaset/Services/API/Parsers/LRCParser.swift β€” LRC to SyncedLyrics parser

Integration: Created once in KasetApp and injected through the SwiftUI environment for lyrics views.

See ADR-0012: Synced Lyrics Provider Architecture for design details.

ShareService

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.

SongLikeStatusManager

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.

URLHandler

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.

ScriptCommands (AppleScript)

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.shared via MainActor.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) if PlayerService.shared is nil
  • Logging via DiagnosticsLogger.scripting

Related Files:

  • Sources/Kaset/Kaset.sdef β€” AppleScript dictionary definition
  • Sources/Kaset/Info.plist β€” NSAppleScriptEnabled and OSAScriptingDefinition keys

Usage:

tell application "Kaset"
    play
    set volume 50
    get player info  -- Returns JSON string
end tell

UpdaterService

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

AppDelegate

File: Sources/Kaset/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: 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

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, .notification, .scripting

Levels: .debug, .info, .warning, .error

Utilities

The Sources/Kaset/Utilities/ folder contains shared helper components.

ImageCache

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 image
  • prefetch(urls:targetSize:maxConcurrent:) β€” Batch prefetch for lists

ColorExtractor

File: Sources/Kaset/Utilities/ColorExtractor.swift

Extracts dominant colors from album art for dynamic theming:

  • Uses CIAreaAverage filter for color extraction
  • Caches extracted colors to avoid repeated processing
  • Used by AccentBackground for gradient overlays

AnimationConfiguration

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

AccessibilityIdentifiers

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.

IntelligenceModifier

File: Sources/Kaset/Utilities/IntelligenceModifier.swift

SwiftUI modifier to hide AI features when unavailable:

.requiresIntelligence()  // Hides view if Apple Intelligence unavailable

Checks FoundationModelsService.shared.isAvailable and applies opacity/disabled state.

RetryPolicy

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

Performance Guidelines

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

Network Optimization

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 fast

API Caching

File: 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_playlist entries so library and add-to-playlist menus refresh after playlist/song changes
  • Mutation invalidation is scoped to the app's APICache; it does not flush URLCache.shared HTTP responses

Image Caching

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
)

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+ with macOS 15 fallbacks)

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.

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

AI Tools

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 QueueIntent for natural language queue manipulation
  • Enables commands like "remove duplicates" or "play upbeat songs next"

@Generable Models

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

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.

UI Components

This section documents key SwiftUI views and their integration patterns.

QueueView

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.

LyricsView

File: Sources/Kaset/Views/LyricsView.swift

Right sidebar panel displaying song lyrics:

  • Reads SyncedLyricsService from the environment for the current LyricResult
  • When SettingsManager.syncedLyricsEnabled is enabled, builds LyricsSearchInfo from 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 .synced lyrics so line highlighting stays aligned without constant idle polling
  • SyncedLyricsDisplayView auto-centers the current line and supports tap-to-seek
  • "Explain" button triggers AI-powered LyricsSummary generation 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.

CommandBarView

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.

ToastView

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.

SharedViews

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

PlayerBar

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

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.