一键导入
common-tasks
Load when adding a ViewModel, view, CoreData entity, hub, music source, playlist mutation, or sync trigger. Step-by-step recipes with code patterns.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Load when adding a ViewModel, view, CoreData entity, hub, music source, playlist mutation, or sync trigger. Step-by-step recipes with code patterns.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Build, launch, and capture debug logs from the iOS simulator. Use when you need to verify runtime behavior, measure timing, or diagnose issues without asking the user to manually capture logs.
Use when the user asks to visually test, touch, automate, or walk every Ensemble app surface across iOS/iPadOS and macOS, including agent-run UI sweeps, issue detection, reproduction, log inspection, fix-plan reports, platform parity checks, and repeatable screenshot/accessibility evidence runs.
Canonical Ensemble behavior policy router. Load before changing app behavior, playback, queue, offline/connectivity, downloads, sync/refresh, mutations, platform UI behavior, or verification expectations; update the relevant policy reference when implementation changes or clarifies behavior.
Plex Media Server API reference. Load when implementing or debugging Plex API integration.
Load before designing features, adding services, or touching multiple packages. Compact ownership, dependency, and subsystem rules for Ensemble.
Load when locating files, adding files, or checking package ownership. Compact map plus discovery commands; use rg for live file lists.
| 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. |
Packages/EnsembleCore/Sources/ViewModels/@MainActor class ... ObservableObjectDependencyContainer@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)
}
Packages/EnsembleUI/Sources/Screens/ or .../Components/@StateObject using DependencyContainer.shared.makeXViewModel()@Environment(\.dependencies) var depsstruct MyNewView: View {
@StateObject private var viewModel = DependencyContainer.shared.makeMyNewViewModel()
@Environment(\.dependencies) var deps
var body: some View {
// ...
}
}
The watch target is now its own lightweight Plex experience. It still must not link full EnsembleCore.
Packages/EnsembleDomain.Packages/EnsemblePlex, reusing EnsembleAPI instead of duplicating HTTP logic.Packages/EnsembleWatchCore.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.WatchSessionModel/WatchCompanionBridge; Now Playing should be able to switch between watch-local and phone-remote control.EnsembleUI or EnsembleCore into the watch target.For browse roots that need a regular-width selection/detail layout:
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.SidebarView state; pass it into the browse screen's selection-column mode with a Binding.LargeScreenPlaceholderView for the unselected state..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.
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:
MainTabView.selectedTabSupportsStageFlow if it should unlock landscape.StageFlowTrackPanel ownership in the browse screen.GeometryReader landscape detection, rotation delay timers, or stageFlowRotationSupport(...); those recreate the presenter during sheet/keyboard flows.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 panelsNowPlayingViewportRoot.swift — macOS single-panel Controls branch only if the new panel changes compact viewport behaviorAssign 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.
Use this for SiriKit/App Shortcuts/Spotlight/Now Playing changes:
Packages/EnsembleSiriShared.EnsembleCore; SystemMediaIntegrationService owns donations, Spotlight indexing/deletion, and media user context refresh.MPNowPlayingInfoCenter and MPRemoteCommandCenter writes inside PlaybackNowPlayingBridge.PlaybackStartContext when a UI action semantically starts a track, album, artist, playlist, radio seed, or downloads collection..appUI playback starts. Siri, App Shortcuts, remote commands, autoplay, queue restoration, and background recovery should pass a non-donating origin.sourceCompositeKey plus media id/kind) for Now Playing external IDs, donations, Spotlight identifiers, and artwork cache identity.Ensemble.xcdatamodeld in Packages/EnsemblePersistence/Sources/CoreData/@objc(CDEntityName) class in ManagedObjects.swiftEnsembleCore/Sources/Models/DomainModels.swiftModelMappers.swiftWhen Ensemble.xcdatamodeld changes, refresh the precompiled model used by SwiftPM tests:
scripts/compile_coredata_model.sh
What this does:
Packages/EnsemblePersistence/Sources/CoreData/Ensemble.xcdatamodeldPackages/EnsemblePersistence/Sources/CoreData/Compiled/SwiftPMEnsemble.momdValidation workflow after model changes:
swift test --package-path Packages/EnsemblePersistenceEnsembleCore, EnsembleUI) to ensure no resource regressionsxcodebuild ... -scheme Ensemble ... build) to verify no duplicate-model outputs// In any ViewModel or View with access to DependencyContainer
Task {
await deps.syncCoordinator.syncAll()
}
// Load hubs from Plex API
let hubs = try await deps.syncCoordinator.fetchHubs(for: sourceKey)
// Save a last-good Feed snapshot for offline-first launch.
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)
// Load cached hubs
let cachedSnapshot = try await deps.hubRepository.fetchLatestHomeFeedSnapshot(sourceScopeKey: "plex:account:server")
// Clear all cached hubs
try await deps.hubRepository.deleteAllHubs()
Rules:
HomeHubLoader or BackgroundRefreshCoordinator, not a transient HomeViewModel.saveHubs(_:)/fetchHubs() only for legacy compatibility; new Feed freshness work should use HomeFeedCachedSnapshot.HubItem domain model in DomainModels.swift with new typeHubItemCard.destination computed propertyHubItemCard.destinationView ViewBuilderGenreDetailLoader)PlexModels.swift to decode new type from APIModelMappers.swift for Hub/HubItem if neededWhen adding support for new music sources (Apple Music, Spotify, etc.):
MusicSourceSyncProvider protocolMusicSourceType enumSyncCoordinator.refreshProviders()PlexAccountConfigAccountManager to handle new account typeWhen modifying Plex library enablement/sync behavior:
ProfileView and MusicSourceAccountDetailView (do not reintroduce standalone sync-panel routes).MusicSourceAccountDetailViewModel.refreshInventory() reconciliation semantics:
toggleLibraryEnabled(...) and preserve selective purge semantics:
SyncCoordinator.purgeServerPlaylists(...).PlexLibraryConfig.isEnabled) logic separate from non-destructive visibility filtering.Use this for browse-surface visibility controls that must not affect sync:
let store = DependencyContainer.shared.libraryVisibilityStore
// Hide a source in the active profile (without changing isEnabled)
store.setSourceVisibility(sourceCompositeKey: sourceKey, isVisible: false)
// Switch active profile
store.setActiveProfile(id: profileID)
Rules:
LibraryViewModel, SearchViewModel, HomeViewModel seams).sourceCompositeKey to avoid collisions across servers/libraries.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 // ratingKey from HubItem
@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")
}
}
// .task { fetch from repository by ratingKey }
}
// In ViewModel
@Published var filterOptions = FilterOptions()
var filteredTracks: [Track] {
MediaFilterEngine.filterTracks(items, with: filterOptions, configuration: .library)
}
// Load/save persisted filters
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.
All playlist mutations go through SyncCoordinator, which handles the server call and then refreshes the local CoreData cache automatically.
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
// Create a new playlist
try await syncCoordinator.createPlaylist(name: "My Playlist", for: sourceIdentifier)
// Add tracks to an existing playlist
try await syncCoordinator.addTracksToPlaylist(playlistKey: "12345", trackKeys: ["111", "222"], for: sourceIdentifier)
// Remove a track from a playlist (by its playlistItemID, not ratingKey)
try await syncCoordinator.removeTrackFromPlaylist(playlistKey: "12345", playlistItemID: "999", for: sourceIdentifier)
// Move a track within a playlist
try await syncCoordinator.movePlaylistItem(playlistKey: "12345", itemID: "999", afterItemID: "888", for: sourceIdentifier)
// Rename a playlist
try await syncCoordinator.renamePlaylist(playlistKey: "12345", newTitle: "New Name", for: sourceIdentifier)
Rules:
PlaylistMutationError.smartPlaylistReadOnly. Guard for this before showing mutation UI.SyncCoordinator automatically refreshes the affected playlist from the server and updates CoreData.PlaylistActionSheets.swift for standard add-to-playlist / create-playlist UI — it wires up these calls consistently across the app.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.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.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.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.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.Use this flow for target-based offline support:
OfflineDownloadTargetRepository using a stable target key:
OfflineDownloadService.targetKey(kind:ratingKey:sourceCompositeKey:)LibraryRepository.fetchTracks(forSource:)LibraryRepository.fetchTracks(forAlbum:sourceCompositeKey:)LibraryRepository.fetchTracks(forArtist:sourceCompositeKey:)PlaylistRepository.fetchPlaylist(ratingKey:sourceCompositeKey:) + tracksDownloadManager APIs:
createDownload(forTrackRatingKey:sourceCompositeKey:quality:)fetchDownload(forTrackRatingKey:sourceCompositeKey:)deleteDownload(forTrackRatingKey:sourceCompositeKey:)SyncCoordinator.sourceStatuses for sync timestamp updates in download/offline servicesSyncCoordinator.lastContentChange for browse-surface reloads; do not drive full library reloads from generic sourceStatuses churnSyncCoordinator.onPlaylistRefreshCompleted for playlist-target refreshdownloadQuality and passing mapped StreamingQuality into stream URL generation.Background/recovery rules:
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.handleBackgroundURLSessionEvents(identifier:completionHandler:); the completion handler must run only after download recovery/healing and target progress refresh complete..downloading records from a previous process/session must be normalized to .pending or .paused; never leave them stuck in .downloading.UI integration rules:
ProfileView -> DownloadManagerSettingsView (do not repurpose DownloadsView).DownloadManagerSettingsView; only include sync-enabled libraries.Download / Remove Download), not inline buttons.!track.isDownloaded, with toast feedback.Use these patterns when extending gesture actions:
SettingsManager.TrackSwipeAction and keep TrackSwipeLayout.default sane (2 leading + 2 trailing).MediaTrackList or SongsTrackListHost so row actions stay native and shared across iOS/iPadOS/macOS.MediaTrackList via leadingSwipeActionsConfigurationForRowAt / trailingSwipeActionsConfigurationForRowAt.TrackActionDispatching for playback/queue/favorite/playlist commands and observe NowPlayingRatingProjection or row-local state instead of the full NowPlayingViewModel.NowPlayingViewModel.toggleTrackFavorite(_:), setTrackFavorite(_:for:), or the matching TrackActionDispatching method so server rating + local cache stay consistent.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.Add to Playlist…, Rename…).Use this flow for Siri phrases like "play track/album/artist/playlist ... on Ensemble":
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.normalize, scoreMatch, token-overlap, or edit-distance implementations in app, extension, or Core code; add shared tests in Packages/EnsembleSiriShared/Tests/ instead.EnsembleSiriIntentsExtension/PlayMediaIntentHandler.swift:
SiriMediaIndexStore data..handleInApp only; never execute playback in the extension.SiriPlaybackActivityCodec (SiriIntentPayload.swift) and include schema version.AppDelegate+Siri.application(_:continue:restorationHandler:).SiriPlaybackCoordinator:
executePlayTrack(request:)executePlayAlbum(request:)executePlayArtist(request:)executePlayPlaylist(request:)LibraryRepository/PlaylistRepository), scoped to enabled source keys.SiriMediaIndexNotifications.postRebuildRequest(...) after sync/account configuration changes.EnsembleAppShortcutsProvider) so phrase routing still reaches Ensemble when SiriKit media-domain handoff misses.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()
}
When adding a new setting or data type to iCloud KVS sync:
KVSSyncService for the new data:// In KVSSyncService
static let myFeatureKey = "ensemble_myFeature"
SyncSettingsManager:// Add case to the SyncFeature enum or add a new toggle property
@Published var isMyFeatureSyncEnabled: Bool {
didSet { UserDefaults.standard.set(isMyFeatureSyncEnabled, forKey: "syncMyFeature") }
}
// In the service that owns the data (e.g., SettingsManager, PinManager)
func applyRemoteMyFeature(_ data: Data) {
// Decode and merge remote data, remote wins on conflict
}
// After local mutation, push to KVS if enabled
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:
KVSSyncService (1s window after push).SyncSettingsManager.let syncCoordinator = DependencyContainer.shared.syncCoordinator
// Full sync — fetches the entire library from Plex. Use after initial setup
// or when data integrity is uncertain. Slow on large libraries.
await syncCoordinator.syncAll()
// Incremental sync — fetches only items added/updated since the last sync
// using addedAt>= / updatedAt>= Plex query params. Use for routine updates.
await syncCoordinator.syncAllIncremental()
// Hub-only refresh — fetches fresh hub data for a single source.
// Used by HomeView pull-to-refresh and the periodic 10-minute timer.
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 syncsyncAllIncremental() — pull-to-refresh on library views, startup sync when 1–24h old, periodic 1h timerrefreshHubs(for:) — HomeView pull-to-refresh, periodic 10-min hub timer, post-mutation refresh