From apple-kit-skills
Implements GroupActivities and SharePlay for shared real-time experiences including media playback sync, collaborative features, and state sharing on iOS, macOS, tvOS, visionOS.
npx claudepluginhub dpearson2699/swift-ios-skills --plugin all-ios-skillsThis skill uses the workspace's default tool permissions.
Build shared real-time experiences using the GroupActivities framework. SharePlay
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
Build shared real-time experiences using the GroupActivities framework. SharePlay connects people over FaceTime or iMessage, synchronizing media playback, app state, or custom data. Targets Swift 6.3 / iOS 26+.
Add the Group Activities entitlement to your app:
<key>com.apple.developer.group-session</key>
<true/>
For apps that start SharePlay without a FaceTime call (iOS 17+), add:
<key>NSSupportsGroupActivities</key>
<true/>
import GroupActivities
let observer = GroupStateObserver()
// Check if a FaceTime call or iMessage group 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
import CoreTransferable
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) |
.workoutTogether | Shared fitness sessions |
The activity struct must conform to Codable so the system can transfer it
between devices.
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 tasks = TaskGroup()
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
Task {
for await state in session.$state.values {
handleState(state)
}
}
// Observe participant changes
Task {
for await participants in session.$activeParticipants.values {
handleParticipants(participants)
}
}
// Join the session
session.join()
}
}
| 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 app state between participants.
Messages must be Codable:
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) -- guaranteed delivery, ordered
let reliableMessenger = GroupSessionMessenger(
session: session,
deliveryMode: .reliable
)
// Unreliable -- faster, no guarantees (good for frequent position updates)
let unreliableMessenger = GroupSessionMessenger(
session: session,
deliveryMode: .unreliable
)
Use .reliable for state-changing actions (play/pause, selections). Use
.unreliable for high-frequency ephemeral data (cursor positions, drawing strokes).
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, play/pause/seek actions on any participant's player are automatically synchronized to all other participants. No manual message passing is needed for playback controls.
// Notify participants about playback events
let event = GroupSessionEvent(
originator: session.localParticipant,
action: .play,
url: nil
)
session.showNotice(event)
import GroupActivities
import UIKit
func startSharePlay() async throws {
let activity = WatchTogetherActivity(
movieID: "123",
movieTitle: "Great Movie"
)
switch await activity.prepareForActivation() {
case .activationPreferred:
// Already in a FaceTime/iMessage session — activate directly
_ = try await activity.activate()
case .activationDisabled:
// SharePlay is disabled or unavailable
print("SharePlay not available")
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)
For ShareLink (SwiftUI) and direct activity.activate() patterns, see
references/shareplay-patterns.md.
For large data (images, files), use GroupSessionJournal instead of
GroupSessionMessenger (which has a size limit):
import GroupActivities
let journal = GroupSessionJournal(session: session)
// Upload a file
let attachment = try await journal.add(imageData)
// 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 has a per-message size limit
let largeImage = try Data(contentsOf: imageURL) // 5 MB
try await messenger.send(largeImage, to: .all) // May fail
// CORRECT -- use GroupSessionJournal for files
let journal = GroupSessionJournal(session: session)
try await journal.add(largeImage)
// 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)
}
}
}
}
com.apple.developer.group-session) addedGroupActivity struct is Codable with meaningful metadatasessions() 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 created with appropriate deliveryMode$state and $activeParticipants publishers observed for lifecycle changesGroupSessionJournal used for large file transfers instead of messengerAVPlaybackCoordinator used for media sync (not manual messages)GroupStateObserver.isEligibleForGroupSession checked before showing SharePlay UIprepareForActivation() called before presenting sharing controller