| name | common-tasks |
| description | Load when adding a ViewModel, view, CoreData entity, hub, music source, playlist mutation, or sync trigger. Step-by-step recipes with code patterns. |
Ensemble Common Development Tasks
Adding a New ViewModel
- Create in
Packages/EnsembleCore/Sources/ViewModels/
- Make it
@MainActor class ... ObservableObject
- Add factory method to
DependencyContainer
- Inject dependencies via initializer
@MainActor
class MyNewViewModel: ObservableObject {
@Published var items: [Item] = []
private let libraryRepository: LibraryRepositoryProtocol
init(libraryRepository: LibraryRepositoryProtocol) {
self.libraryRepository = libraryRepository
}
}
In DependencyContainer:
func makeMyNewViewModel() -> MyNewViewModel {
MyNewViewModel(libraryRepository: libraryRepository)
}
Adding a New View
- Create in
Packages/EnsembleUI/Sources/Screens/ or .../Components/
- Inject ViewModel via
@StateObject using DependencyContainer.shared.makeXViewModel()
- Access environment dependencies:
@Environment(\.dependencies) var deps
struct MyNewView: View {
@StateObject private var viewModel = DependencyContainer.shared.makeMyNewViewModel()
@Environment(\.dependencies) var deps
var body: some View {
}
}
Adding Watch Behavior
The watch target is now its own lightweight Plex experience. It still must not link full EnsembleCore.
- Put watch-portable models in
Packages/EnsembleDomain.
- Put Plex discovery/catalog/stream facade work in
Packages/EnsemblePlex, reusing EnsembleAPI instead of duplicating HTTP logic.
- Put watch bootstrap, Plex Link fallback, KVS/iCloud hints, local catalog cache, and local playback in
Packages/EnsembleWatchCore.
- Put watch SwiftUI in
EnsembleWatch/Views/; keep the Apple Music-style hierarchy: pins at top, library categories below, detail lists after tap, and a persistent top-right Now Playing toolbar button.
- Keep iPhone remote control in
WatchSessionModel/WatchCompanionBridge; Now Playing should be able to switch between watch-local and phone-remote control.
- Keep payloads compact and Codable. Do not import
EnsembleUI or EnsembleCore into the watch target.
- Downloads are intentionally out of the first standalone watch pass; add them later with watch-specific execution/storage rather than the iOS offline download service.
Adding Large-Screen Browse Polish
For browse roots that need a regular-width selection/detail layout:
- Keep the existing compact list/push navigation as the compact root fallback.
- Keep
SidebarView's root NavigationSplitView as the stable app sidebar + detail host. Put the browse list/detail split inside the selected section's detail host so the app sidebar is not recreated when switching between single-pane and browse sections.
- Store the selected item in
SidebarView state; pass it into the browse screen's selection-column mode with a Binding.
- Use
LargeScreenPlaceholderView for the unselected state.
- Add
.refreshCommand { await viewModel.refresh() } whenever the screen already supports .refreshable.
Do not route iPhone through the large-screen browse host, and do not remove existing compact navigation links.
Modifying StageFlow Browse Surfaces
MainTabView owns iPhone StageFlow activation, chrome suppression, and the single rotation-support registration. Browse screens should only read @Environment(\.isStageFlowActive) and swap their local content when the root says StageFlow is active.
When adding or changing a StageFlow-capable browse screen:
- Add the tab to
MainTabView.selectedTabSupportsStageFlow if it should unlock landscape.
- Keep playback resolution and
StageFlowTrackPanel ownership in the browse screen.
- Do not add screen-local
GeometryReader landscape detection, rotation delay timers, or stageFlowRotationSupport(...); those recreate the presenter during sheet/keyboard flows.
- Do not delay root orientation unregister when the selected tab stops supporting StageFlow; unsupported tabs should return to portrait immediately so custom root chrome is not laid out in transient landscape.
Adding a New Now Playing Panel/Card
When adding a new card/panel to the Now Playing view, it must be added in the shared page surfaces:
NowPlayingCarousel.swift — iPhone swipe carousel (TabView pages)
NowPlayingDetailPanel.swift — shared Queue/Lyrics/Info renderer used by iPad, AirPlay, and macOS detail panels
NowPlayingViewportRoot.swift — macOS single-panel Controls branch only if the new panel changes compact viewport behavior
Assign your new card a page index and add a case in the carousel body plus NowPlayingDetailPanel. NowPlayingWidePanelLayout should keep using the shared detail renderer, and ExternalDisplayNowPlayingView should stay a TV/dark/background shell around NowPlayingWidePanelLayout, not grow its own panel switch.
Modifying System Media Integration
Use this for SiriKit/App Shortcuts/Spotlight/Now Playing changes:
- Put pure media identity, index, payload, normalization, and resolver changes in
Packages/EnsembleSiriShared.
- Keep playback execution and side effects in
EnsembleCore; SystemMediaIntegrationService owns donations, Spotlight indexing/deletion, and media user context refresh.
- Keep all
MPNowPlayingInfoCenter and MPRemoteCommandCenter writes inside PlaybackNowPlayingBridge.
- Pass
PlaybackStartContext when a UI action semantically starts a track, album, artist, playlist, radio seed, or downloads collection.
- Donate only direct
.appUI playback starts. Siri, App Shortcuts, remote commands, autoplay, queue restoration, and background recovery should pass a non-donating origin.
- Use source-scoped identifiers (
sourceCompositeKey plus media id/kind) for Now Playing external IDs, donations, Spotlight identifiers, and artwork cache identity.
- For Spotlight cleanup, delete explicit identifiers or source/domain-scoped identifiers only. Do not use global delete-all APIs.
Adding a New CoreData Entity
- Update
Ensemble.xcdatamodeld in Packages/EnsemblePersistence/Sources/CoreData/
- Create
@objc(CDEntityName) class in ManagedObjects.swift
- Add domain model in
EnsembleCore/Sources/Models/DomainModels.swift
- Add mapper in
ModelMappers.swift
- Update relevant repository
CoreData Model Compilation for SwiftPM Tests
When Ensemble.xcdatamodeld changes, refresh the precompiled model used by SwiftPM tests:
scripts/compile_coredata_model.sh
What this does:
- Compiles
Packages/EnsemblePersistence/Sources/CoreData/Ensemble.xcdatamodeld
- Outputs
Packages/EnsemblePersistence/Sources/CoreData/Compiled/SwiftPMEnsemble.momd
- Keeps package tests stable across environments where model bundle resolution differs
Validation workflow after model changes:
- Run
swift test --package-path Packages/EnsemblePersistence
- Run dependent package tests (
EnsembleCore, EnsembleUI) to ensure no resource regressions
- Run app build (
xcodebuild ... -scheme Ensemble ... build) to verify no duplicate-model outputs
Running a Full Sync
Task {
await deps.syncCoordinator.syncAll()
}
Working with Hubs
let hubs = try await deps.syncCoordinator.fetchHubs(for: sourceKey)
let snapshot = HomeFeedCachedSnapshot(
sourceScopeKey: "plex:account:server",
sourceName: "Editing Music",
fetchedAt: Date(),
refreshReason: "network",
freshnessState: .fresh,
isLastGood: true,
hubs: hubs
)
try await deps.hubRepository.saveHomeFeedSnapshot(snapshot)
let cachedSnapshot = try await deps.hubRepository.fetchLatestHomeFeedSnapshot(sourceScopeKey: "plex:account:server")
try await deps.hubRepository.deleteAllHubs()
Rules:
- Feed refresh should use
HomeHubLoader or BackgroundRefreshCoordinator, not a transient HomeViewModel.
- Do not save empty network hub results over the last-good snapshot.
- Use
saveHubs(_:)/fetchHubs() only for legacy compatibility; new Feed freshness work should use HomeFeedCachedSnapshot.
Adding Hub Support to New Content Types
- Update
HubItem domain model in DomainModels.swift with new type
- Add case to
HubItemCard.destination computed property
- Add case to
HubItemCard.destinationView ViewBuilder
- Create DetailLoader if needed (e.g.,
GenreDetailLoader)
- Update
PlexModels.swift to decode new type from API
- Add mapper in
ModelMappers.swift for Hub/HubItem if needed
Adding a New Music Source
When adding support for new music sources (Apple Music, Spotify, etc.):
- Create new provider implementing
MusicSourceSyncProvider protocol
- Add source type to
MusicSourceType enum
- Register provider in
SyncCoordinator.refreshProviders()
- Add account configuration model similar to
PlexAccountConfig
- Update
AccountManager to handle new account type
Updating Plex Source Selection (Account-Centric Flow)
When modifying Plex library enablement/sync behavior:
- Keep source entry points in
ProfileView and MusicSourceAccountDetailView (do not reintroduce standalone sync-panel routes).
- Use
MusicSourceAccountDetailViewModel.refreshInventory() reconciliation semantics:
- Newly discovered libraries default to unchecked.
- Removed libraries are auto-disabled and purged.
- For toggle-off behavior, call
toggleLibraryEnabled(...) and preserve selective purge semantics:
- Purge only the unchecked library’s cache.
- If no enabled libraries remain on that server, also purge server-level playlists via
SyncCoordinator.purgeServerPlaylists(...).
- Keep sync-enable (
PlexLibraryConfig.isEnabled) logic separate from non-destructive visibility filtering.
Working With LibraryVisibilityProfile Groundwork
Use this for browse-surface visibility controls that must not affect sync:
let store = DependencyContainer.shared.libraryVisibilityStore
store.setSourceVisibility(sourceCompositeKey: sourceKey, isVisible: false)
store.setActiveProfile(id: profileID)
Rules:
- Visibility profiles hide/show content only; they do not enable/disable sync libraries.
- Apply profile filtering in ViewModels after loading data (
LibraryViewModel, SearchViewModel, HomeViewModel seams).
- Keep source filtering keyed by full
sourceCompositeKey to avoid collisions across servers/libraries.
Creating a DetailLoader
For new content types that need async hub-to-detail navigation:
Before adding new root navigation glue, register typed destinations through NavigationCoordinator.Destination. Reuse NavigationCoordinator.targetTab(for:), pathSnapshot(for:), setPath(_:for:), and EnsembleUI's pathBinding(for:) extension for per-tab stacks. On iPad/macOS sidebar roots, map destinations through SidebarSelection.selection(for:fallback:) so compact/regular split behavior stays consistent.
struct MyDetailLoader: View {
let itemId: String
@State private var item: MyModel?
@State private var isLoading = true
@State private var error: Error?
@Environment(\.dependencies) var deps
var body: some View {
if let item = item {
MyDetailView(item: item)
} else if isLoading {
ProgressView("Loading...")
} else if let error = error {
Text("Error: \(error.localizedDescription)")
} else {
Text("Not found")
}
}
}
Using FilterOptions
@Published var filterOptions = FilterOptions()
var filteredTracks: [Track] {
MediaFilterEngine.filterTracks(items, with: filterOptions, configuration: .library)
}
FilterPersistence.load(for: "MyView")
FilterPersistence.save(filterOptions, for: "MyView")
Use MediaFilterEngine instead of reimplementing search, genre, download, year, or artist filters in views or ViewModels. Pick an existing named configuration (.library, .playlistDetail, .favorites, .albumDetail, .artistDetail) or add a tested configuration when a surface intentionally differs. Keep expensive filtering/sorting out of SwiftUI body computation; cache results through a Combine pipeline when the source list can be large.
Use MediaFormatters instead of local ByteCountFormatter, minute/second, or collection-duration helpers. bytes(_:) is for download estimates/progress, fileBytes(_:) is for ordinary file-size display, logBytes(_:) is for diagnostic log sizes, and trackClock(_:)/collectionDuration(_:) cover media durations.
Working with Playlist Mutations
All playlist mutations go through SyncCoordinator, which handles the server call and then refreshes the local CoreData cache automatically.
Adding Media Drag And Drop
Use MediaDragPayload in Packages/EnsembleUI/Sources/Utility/ for in-app drags involving tracks, albums, playlists, or merged display playlists. Track drag sources should use MediaDragPayload.trackItemProvider(for:shareService:) on iOS/iPadOS and MediaDragPayload.trackPasteboardWriter(for:shareService:) for native AppKit rows: both keep the app-specific payload internal for Ensemble drops, and expose the same prepared audio file URL and TrackFileExportMetadata export naming used by Share File to external destinations such as Finder or Files. Do not expose the JSON fallback to external pasteboards.
Playlist drops are copy/add operations only. UI drag providers should route through MediaDragExportPolicy so track drags keep internal payload plus external file-promise copy support while album/playlist drags stay app-internal only. Drop surfaces should load MediaDragPayload, pass payload.dropReferences into Core PlaylistDropResolver, then present toasts for PlaylistDropResolutionError. Do not duplicate media matching, source compatibility, album/playlist expansion, or dedupe in views. Reject smart or merged playlist targets, unresolved items, and cross-source drops without changing playlist contents.
let syncCoordinator = DependencyContainer.shared.syncCoordinator
try await syncCoordinator.createPlaylist(name: "My Playlist", for: sourceIdentifier)
try await syncCoordinator.addTracksToPlaylist(playlistKey: "12345", trackKeys: ["111", "222"], for: sourceIdentifier)
try await syncCoordinator.removeTrackFromPlaylist(playlistKey: "12345", playlistItemID: "999", for: sourceIdentifier)
try await syncCoordinator.movePlaylistItem(playlistKey: "12345", itemID: "999", afterItemID: "888", for: sourceIdentifier)
try await syncCoordinator.renamePlaylist(playlistKey: "12345", newTitle: "New Name", for: sourceIdentifier)
Rules:
- Smart playlists are read-only. All mutations on smart playlists throw
PlaylistMutationError.smartPlaylistReadOnly. Guard for this before showing mutation UI.
- After a successful mutation,
SyncCoordinator automatically refreshes the affected playlist from the server and updates CoreData.
- Use
PlaylistActionSheets.swift for standard add-to-playlist / create-playlist UI — it wires up these calls consistently across the app.
- Use
PlaylistActionPresentationHost plus .playlistActionPresentation(request:nowPlayingVM:) for view-owned "Add to Playlist…" sheets and recent-playlist quick actions. Do not add local PlaylistPickerPayload structs, duplicate PlaylistPickerSheet modifiers, or direct recent-playlist add logic in root/detail views.
- Use
PlaylistMutationWorkflow for playlist rename/delete UI, including merged playlist Rename All/Delete All. It returns pending/result toast payloads and mutation outcomes; views should only handle confirmations, local optimistic state, navigation dismissal, and pin/sidebar updates. Treat merged "all" operations strictly: partial rename is a warning and partial delete is an error.
- Use
MetadataMutationWorkflow for track, album, and artist metadata edit/delete UI. It builds mutation requests, calls the mutation service, and returns standardized toast payloads; views should only own local ContextMenuMetadataEditorRequest sheet state, confirmation dialogs, and post-delete navigation. Do not route context-menu metadata editors through root presenters or hide local navigation/search chrome around them.
- Use
PlaylistActionService or the NowPlayingViewModel compatibility wrappers before add-to-playlist mutations. They normalize library-scoped keys to server keys, reject known cross-server tracks, dedupe repeated tracks, and stamp unknown-source tracks with the selected server key for the mutation path.
- Use
PlaylistDropResolver for drag/drop playlist copy-add flows. It returns the resolved target playlist and compatible tracks; the view should only call addTracksOptimistically(_:to:) and map resolver errors to user feedback.
Adding Offline Download Targets (Library / Album / Artist / Playlist)
Use this flow for target-based offline support:
- Persist target state in
OfflineDownloadTargetRepository using a stable target key:
OfflineDownloadService.targetKey(kind:ratingKey:sourceCompositeKey:)
- Resolve memberships from repositories:
- library target:
LibraryRepository.fetchTracks(forSource:)
- album target:
LibraryRepository.fetchTracks(forAlbum:sourceCompositeKey:)
- artist target:
LibraryRepository.fetchTracks(forArtist:sourceCompositeKey:)
- playlist target:
PlaylistRepository.fetchPlaylist(ratingKey:sourceCompositeKey:) + tracks
- Upsert downloads through source-aware
DownloadManager APIs:
createDownload(forTrackRatingKey:sourceCompositeKey:quality:)
fetchDownload(forTrackRatingKey:sourceCompositeKey:)
deleteDownload(forTrackRatingKey:sourceCompositeKey:)
- Keep removal reference-counted by checking membership counts before deleting local files.
- Trigger reconcile after source changes:
- observe
SyncCoordinator.sourceStatuses for sync timestamp updates in download/offline services
- observe
SyncCoordinator.lastContentChange for browse-surface reloads; do not drive full library reloads from generic sourceStatuses churn
- wire
SyncCoordinator.onPlaylistRefreshCompleted for playlist-target refresh
- Respect download quality by reading
downloadQuality and passing mapped StreamingQuality into stream URL generation.
Background/recovery rules:
- Keep
OfflineDownloadService as the queue and target source of truth. Platform events must route through OfflineDownloadBackgroundCoordinating; do not start queue work directly from AppDelegate, macOS delegates, or URLSession callbacks.
- iOS background URLSession wakeups call
handleBackgroundURLSessionEvents(identifier:completionHandler:); the completion handler must run only after download recovery/healing and target progress refresh complete.
- macOS sleep should pause in-flight bookkeeping as resumable/paused, not failed. Wake/foreground should run the same recovery sweep and then resume eligible pending work under network/user/Low Power policy.
- Stale
.downloading records from a previous process/session must be normalized to .pending or .paused; never leave them stuck in .downloading.
UI integration rules:
- Settings manager entry point remains
ProfileView -> DownloadManagerSettingsView (do not repurpose DownloadsView).
- Keep library-wide offline toggles inside
DownloadManagerSettingsView; only include sync-enabled libraries.
- Album/artist/playlist download toggles are context/detail menu actions (
Download / Remove Download), not inline buttons.
- Track rows should dim and block taps offline when
!track.isDownloaded, with toast feedback.
Adding Track Swipe or Long-Press Actions
Use these patterns when extending gesture actions:
- Add/adjust action definitions in
SettingsManager.TrackSwipeAction and keep TrackSwipeLayout.default sane (2 leading + 2 trailing).
- Ensure layout sanitization prevents duplicate assignments and malformed persisted payloads.
- For track lists, prefer
MediaTrackList or SongsTrackListHost so row actions stay native and shared across iOS/iPadOS/macOS.
- For detail track tables, map actions in
MediaTrackList via leadingSwipeActionsConfigurationForRowAt / trailingSwipeActionsConfigurationForRowAt.
- For high-volume track rows/cards/tables, accept
TrackActionDispatching for playback/queue/favorite/playlist commands and observe NowPlayingRatingProjection or row-local state instead of the full NowPlayingViewModel.
- For favorite mutations, call
NowPlayingViewModel.toggleTrackFavorite(_:), setTrackFavorite(_:for:), or the matching TrackActionDispatching method so server rating + local cache stay consistent.
- For context menus, define the allowed action set in
MediaMenuCatalog and render it through SwiftUIMediaMenuRenderer, UIKitMediaMenuRenderer, or AppKitMediaMenuRenderer. For standalone SwiftUI track cards/menus, use TrackActionsContextMenu. Parent views should add only scoped actions such as queue removal, pin/unpin, edit/delete, shuffle/repeat, playlist-picker presentation, or playlist management.
- If action opens follow-up UI, keep ellipsis in labels (
Add to Playlist…, Rename…).
Adding or Updating Siri Media Play Intents (In-App-First)
Use this flow for Siri phrases like "play track/album/artist/playlist ... on Ensemble":
- Use
EnsembleSiriShared for all Siri phrase normalization and fuzzy scoring:
SiriSharedConstants owns the App Group identifier and Siri index filename.
SiriPhraseNormalizer owns basic normalization, app-name suffix trimming, connector-word trimming, media-type prefix stripping, and query variants.
SiriMatchScorer owns exact/prefix/contains/token-overlap/edit-distance scoring.
- Do not add new local
normalize, scoreMatch, token-overlap, or edit-distance implementations in app, extension, or Core code; add shared tests in Packages/EnsembleSiriShared/Tests/ instead.
- Keep extension logic thin in
EnsembleSiriIntentsExtension/PlayMediaIntentHandler.swift:
- Resolve candidates from
SiriMediaIndexStore data.
- Rank deterministically (exact normalized > prefix > contains + tie-breakers).
- Return disambiguation when confidence is close.
- Return
.handleInApp only; never execute playback in the extension.
- Encode handoff payload with
SiriPlaybackActivityCodec (SiriIntentPayload.swift) and include schema version.
- Route in app lifecycle via
AppDelegate+Siri.application(_:continue:restorationHandler:).
- Execute playback in
SiriPlaybackCoordinator:
executePlayTrack(request:)
executePlayAlbum(request:)
executePlayArtist(request:)
executePlayPlaylist(request:)
- Use repository precision-search APIs for Siri matching (
LibraryRepository/PlaylistRepository), scoped to enabled source keys.
- Keep index fresh by posting
SiriMediaIndexNotifications.postRebuildRequest(...) after sync/account configuration changes.
- Add App Intents fallback for album/playlist in app target (
EnsembleAppShortcutsProvider) so phrase routing still reaches Ensemble when SiriKit media-domain handoff misses.
- After index availability checks/rebuilds at launch, call
EnsembleAppShortcutsProvider.updateAppShortcutParameters() (iOS 16+) to refresh Siri shortcut parameter vocabulary.
Coordinator usage pattern:
let payload = SiriPlaybackActivityCodec.payload(from: userActivity.userInfo)
if let payload {
try await DependencyContainer.shared.siriPlaybackCoordinator.execute(payload: payload)
}
App Intents fallback pattern:
if #available(iOS 16.0, *) {
EnsembleAppShortcutsProvider.updateAppShortcutParameters()
}
Adding a New Synced Feature to KVS
When adding a new setting or data type to iCloud KVS sync:
- Add a KVS key in
KVSSyncService for the new data:
static let myFeatureKey = "ensemble_myFeature"
- Add a feature toggle in
SyncSettingsManager:
@Published var isMyFeatureSyncEnabled: Bool {
didSet { UserDefaults.standard.set(isMyFeatureSyncEnabled, forKey: "syncMyFeature") }
}
- Add a remote-change callback to handle incoming KVS data:
func applyRemoteMyFeature(_ data: Data) {
}
- Add push wiring so local changes push to KVS:
if syncSettingsManager.isMyFeatureSyncEnabled {
kvsSyncService.push(key: KVSSyncService.myFeatureKey, value: encodedData)
}
-
Gate with SyncSettingsManager toggle — always check syncSettingsManager.isMyFeatureSyncEnabled before pushing or applying remote changes.
-
Wire in DependencyContainer — register the KVS observer callback in DependencyContainer alongside existing sync wiring.
Rules:
- KVS has a 1 MB total limit and 1024 key limit — only use for small data.
- Echo-loop suppression is automatic in
KVSSyncService (1s window after push).
- If the feature has a dependency (like libraries → sources), add cascade logic in
SyncSettingsManager.
- On re-enable, pull from iCloud and overwrite local (consistent with existing re-enable flow).
Triggering Incremental vs Full Sync
let syncCoordinator = DependencyContainer.shared.syncCoordinator
await syncCoordinator.syncAll()
await syncCoordinator.syncAllIncremental()
try await syncCoordinator.refreshHubs(for: sourceIdentifier)
When to use each:
syncAll() — manual "sync now" triggered by user, post-account-add, or when >24h since last sync
syncAllIncremental() — pull-to-refresh on library views, startup sync when 1–24h old, periodic 1h timer
refreshHubs(for:) — HomeView pull-to-refresh, periodic 10-min hub timer, post-mutation refresh