skills/shareplay-activities/SKILL.md
Build shared real-time experiences using GroupActivities and SharePlay. Use when implementing shared media playback, collaborative app features, synchronized game state, or any FaceTime, Messages, AirDrop, or nearby visionOS group activity on iOS, macOS, tvOS, or visionOS.
npx skillsauth add dpearson2699/swift-ios-skills shareplay-activitiesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Build shared real-time experiences using the GroupActivities framework. SharePlay connects people over FaceTime, Messages, AirDrop, and nearby visionOS sharing, synchronizing media playback, app state, or custom data. Targets Swift 6.3 / iOS 26+.
Add the Group Activities capability to the app target in Xcode. Xcode adds the required entitlement and updates the provisioning profile:
<key>com.apple.developer.group-session</key>
<true/>
Configure this only for app targets. Group Activities are not available in widgets, extensions, or App Clips.
import GroupActivities
let observer = GroupStateObserver()
// Check if a FaceTime call or Messages conversation is active
if observer.isEligibleForGroupSession {
showSharePlayButton()
}
Observe changes reactively:
for await isEligible in observer.$isEligibleForGroupSession.values {
showSharePlayButton(isEligible)
}
Conform to GroupActivity and provide metadata:
import GroupActivities
struct WatchTogetherActivity: GroupActivity {
let movieID: String
let movieTitle: String
var metadata: GroupActivityMetadata {
var meta = GroupActivityMetadata()
meta.title = movieTitle
meta.type = .watchTogether
meta.fallbackURL = URL(string: "https://example.com/movie/\(movieID)")
return meta
}
}
| Type | Use Case |
|---|---|
| .generic | Default for custom activities |
| .watchTogether | Video playback |
| .listenTogether | Audio playback |
| .createTogether | Collaborative creation (drawing, editing) |
| .exploreTogether | Shared browsing, planning, or exploration |
| .learnTogether | Shared learning or studying |
| .readTogether | Shared reading |
| .shopTogether | Shared shopping |
| .workoutTogether | Shared fitness sessions |
GroupActivity is Codable; stored activity data must be codable. Add
Transferable only for SwiftUI ShareLink, SharePlay over AirDrop, or
AppKit/UIKit share sheets. Keep payloads minimal: use identifiers or URLs
instead of large data.
Set up a long-lived task to receive sessions when another participant starts the activity:
@Observable
@MainActor
final class SharePlayManager {
private var session: GroupSession<WatchTogetherActivity>?
private var messenger: GroupSessionMessenger?
private var sessionTasks: [Task<Void, Never>] = []
func observeSessions() {
Task {
for await session in WatchTogetherActivity.sessions() {
self.configureSession(session)
}
}
}
private func configureSession(
_ session: GroupSession<WatchTogetherActivity>
) {
self.session = session
self.messenger = GroupSessionMessenger(session: session)
// Observe session state changes
let stateTask = Task {
for await state in session.$state.values {
handleState(state)
}
}
sessionTasks.append(stateTask)
// Observe participant changes
let participantTask = Task {
for await participants in session.$activeParticipants.values {
handleParticipants(participants)
}
}
sessionTasks.append(participantTask)
// Join the session
session.join()
}
private func cleanUp() {
sessionTasks.forEach { $0.cancel() }
sessionTasks.removeAll()
session = nil
messenger = nil
}
}
| State | Description |
|---|---|
| .waiting | Session exists but local participant has not joined |
| .joined | Local participant is actively in the session |
| .invalidated(reason:) | Session ended (check reason for details) |
private func handleState(_ state: GroupSession<WatchTogetherActivity>.State) {
switch state {
case .waiting:
print("Waiting to join")
case .joined:
print("Joined session")
loadActivity(session?.activity)
case .invalidated(let reason):
print("Session ended: \(reason)")
cleanUp()
@unknown default:
break
}
}
private func handleParticipants(_ participants: Set<Participant>) {
print("Active participants: \(participants.count)")
}
// Leave the session (other participants continue)
session?.leave()
// End the session for all participants
session?.end()
Use GroupSessionMessenger to sync small, time-sensitive app state between
participants.
Messages must be Codable; keep each message under 256 KB.
struct SyncMessage: Codable {
let action: String
let timestamp: Date
let data: [String: String]
}
func sendSync(_ message: SyncMessage) async throws {
guard let messenger else { return }
try await messenger.send(message, to: .all)
}
// Send to specific participants
try await messenger.send(message, to: .only(participant))
func observeMessages() {
guard let messenger else { return }
Task {
for await (message, context) in messenger.messages(of: SyncMessage.self) {
let sender = context.source
handleReceivedMessage(message, from: sender)
}
}
}
// Reliable (default) -- checked and retried for crucial state
let reliableMessenger = GroupSessionMessenger(
session: session,
deliveryMode: .reliable
)
// Unreliable -- lower latency, no delivery guarantee
let unreliableMessenger = GroupSessionMessenger(
session: session,
deliveryMode: .unreliable
)
Use .reliable for state-changing actions such as selections or turns. Use
.unreliable for high-frequency ephemeral data such as cursor positions,
drawing strokes, and reactions.
For video/audio, use AVPlaybackCoordinator with AVPlayer:
import AVFoundation
import GroupActivities
func configurePlayback(
session: GroupSession<WatchTogetherActivity>,
player: AVPlayer
) {
// Connect the player's coordinator to the session
let coordinator = player.playbackCoordinator
coordinator.coordinateWithSession(session)
}
Once connected, AVFoundation synchronizes play/pause, seeking, rate, playback speed, and time. Do not put AVPlayer transport fields in messenger messages or snapshots, including late-joiner snapshots; use custom messages only for state outside playback.
import GroupActivities
import UIKit
func startSharePlay() async throws {
let activity = WatchTogetherActivity(
movieID: "123",
movieTitle: "Great Movie"
)
switch await activity.prepareForActivation() {
case .activationPreferred:
// A conversation is active and the user chose to share.
_ = try await activity.activate()
case .activationDisabled:
// The user chose local playback, or sharing is unavailable.
startLocalExperience()
case .cancelled:
break
@unknown default:
break
}
}
When no conversation is active (i.e., isEligibleForGroupSession is false),
use GroupActivitySharingController to let the user pick contacts first:
let controller = try GroupActivitySharingController(activity)
present(controller, animated: true)
Use the shareplay SF Symbol for custom controls. Treat GroupActivityMetadata
as discovery copy: concise title, subtitle, image, and type aligned with the
entry point. Keep sibling domains out: GameKit owns auth, matchmaking,
leaderboards, achievements, and voice/chat; TabletopKit owns seats, board
equipment, spatial placement, turns, rules, and authoritative tabletop state;
AVKit owns playback UI. SharePlay owns invitations, lifecycle, participants, and
coordination handoffs. See references/shareplay-patterns.md for SwiftUI ShareLink, AirDrop, and direct activation patterns.
For larger, non-time-sensitive attachments, use GroupSessionJournal instead
of GroupSessionMessenger. Journal items must conform to Transferable, are
available to late joiners, and are limited to 100 MB. It requires iOS/iPadOS/tvOS
17+, macOS 14+, or visionOS 1+. For larger/protected assets, share a pointer or manifest and use server storage or app-managed file transfer.
import GroupActivities
let journal = GroupSessionJournal(session: session)
// Upload a Transferable file or data item
let attachment = try await journal.add(sharedImageItem)
// Observe incoming attachments
Task {
for await attachments in journal.attachments {
for attachment in attachments {
let data = try await attachment.load(Data.self)
handleReceivedFile(data)
}
}
}
// WRONG -- session is received but never joined
for await session in MyActivity.sessions() {
self.session = session
// Session stays in .waiting state forever
}
// CORRECT -- join after configuring
for await session in MyActivity.sessions() {
self.session = session
self.messenger = GroupSessionMessenger(session: session)
session.join()
}
// WRONG -- session stays alive after the user navigates away
func viewDidDisappear() {
// Nothing -- session leaks
}
// CORRECT -- leave when the view is dismissed
func viewDidDisappear() {
session?.leave()
session = nil
messenger = nil
}
// WRONG -- broadcasting state without handling late joiners
func onJoin() {
// New participant has no idea what the current state is
}
// CORRECT -- send full state to new participants
func handleParticipants(_ participants: Set<Participant>) {
let newParticipants = participants.subtracting(knownParticipants)
for participant in newParticipants {
Task {
try await messenger?.send(currentState, to: .only(participant))
}
}
knownParticipants = participants
}
// WRONG -- messenger is small/time-sensitive; journal is Transferable and <=100 MB
let imageData = try Data(contentsOf: imageURL) // 300 KB
try await messenger.send(imageData, to: .all) // Too large
// CORRECT -- journal attachments up to 100 MB; otherwise share a pointer/manifest
let journal = GroupSessionJournal(session: session)
try await journal.add(sharedImageItem)
// Larger/protected assets: server storage or app-managed file transfer
// WRONG -- manually syncing play/pause when using AVPlayer
func play() {
player.play()
try await messenger.send(PlayMessage(), to: .all)
}
// CORRECT -- let AVPlaybackCoordinator handle it
player.playbackCoordinator.coordinateWithSession(session)
player.play() // Automatically synced to all participants
// WRONG -- each time the view appears, a new listener is created
struct MyView: View {
var body: some View {
Text("Hello")
.task {
for await session in MyActivity.sessions() { }
}
}
}
// CORRECT -- observe sessions in a long-lived manager
@Observable
final class ActivityManager {
init() {
Task {
for await session in MyActivity.sessions() {
configureSession(session)
}
}
}
}
GroupActivity struct is Codable with meaningful metadataTransferable conformance added when using ShareLink, AirDrop, or share sheetssessions() observed in a long-lived object (not a SwiftUI view body)session.join() called after receiving and configuring the sessionsession.leave() called when the user navigates away or dismissesGroupSessionMessenger messages stay under 256 KB with appropriate deliveryMode$state and $activeParticipants publishers observed for lifecycle changesGroupSessionJournal used for non-time-sensitive Transferable attachmentsAVPlaybackCoordinator used for media sync (not manual messages)GroupStateObserver.isEligibleForGroupSession checked before showing SharePlay UIGroupActivitySharingController used when no conversation is activedevelopment
Implement, review, or improve data visualizations using Swift Charts. Use when building bar, line, area, point, pie, donut, or iOS 26 3D charts; when adding chart selection, scrolling, annotations, axes, scales, legends, or foregroundStyle grouping; when plotting functions with BarPlot, LinePlot, AreaPlot, PointPlot, Chart3D, or SurfacePlot; or when creating heat maps, Gantt charts, grouped bars, sparklines, threshold lines, or spatial visualizations.
data-ai
Select, implement, or migrate between app architecture patterns for Apple platform apps. Use when choosing between MV (Model-View with @Observable), MVVM, MVI, TCA (The Composable Architecture), Clean Architecture, VIPER, or Coordinator patterns; when evaluating architecture fit for a feature's complexity; when migrating from one pattern to another; or when reviewing whether an app's current architecture is appropriate. Scoped to Apple-platform patterns using Swift 6.3, SwiftUI, and UIKit.
development
Apply Swift API Design Guidelines to name, label, and document Swift APIs. Covers argument label rules (prepositional phrase rule, grammatical phrase rule, first-label omission), mutating/nonmutating pair naming (-ed/-ing participle pattern, form- prefix, sort/sorted, formUnion/union), side-effect naming (noun for pure, verb for mutating), documentation comment structure (summary by declaration kind, O(1) complexity rule), clarity at call site, role-based naming, protocol naming (-able/-ible/-ing), default arguments over method families, casing conventions, and terminology. Use when designing new Swift APIs, reviewing naming and argument labels, writing documentation comments, or refactoring for call site clarity.
development
Implement, review, or improve in-app purchases and subscriptions using StoreKit 2. Use when building paywalls with SubscriptionStoreView or ProductView, processing transactions with Product and Transaction APIs, verifying entitlements, handling purchase flows (consumable, non-consumable, auto-renewable), implementing offer codes or promotional/win-back/introductory offers, managing subscription status and renewal state, setting up StoreKit testing with configuration files, or integrating Family Sharing, Ask to Buy, refund handling, and billing retry logic.