# SharePlay Patterns

Longer examples for the `shareplay-activities` skill. SKILL.md covers setup,
the session lifecycle and the messenger basics; this file builds on them.

## Contents

- [A shared sticky-note board](#a-shared-sticky-note-board)
- [A reusable manager](#a-reusable-manager)
- [SwiftUI entry points](#swiftui-entry-points)
- [Syncing game state, including late joiners](#syncing-game-state-including-late-joiners)
- [Tracking who joins and leaves](#tracking-who-joins-and-leaves)
- [Nearby participants on visionOS](#nearby-participants-on-visionos)

## A shared sticky-note board

Several people drag notes around one board. Positions change constantly, while
clearing the board must never be lost.

```swift
import GroupActivities
import Observation
import SwiftUI

struct NoteBoard: GroupActivity, Sendable {
    static let activityIdentifier = "org.sample.board.notes"

    var metadata: GroupActivityMetadata {
        var info = GroupActivityMetadata()
        info.title = "Plan on one board"
        info.type = .createTogether
        return info
    }
}

struct Spot: Codable, Sendable, Hashable { var x: Double; var y: Double }
struct Tint: Codable, Sendable, Hashable { var red: Double; var green: Double; var blue: Double }

struct NoteMoved: Codable, Sendable, Identifiable {
    let id: UUID
    var trail: [Spot]
    var tint: Tint
    var size: Double
}

struct BoardCleared: Codable, Sendable {}
```

`CGPoint` and `Color` are not `Codable`, so the message uses small codable
stand-ins for points and colours.

```swift
@Observable @MainActor
final class BoardSync {
    private(set) var notes: [UUID: NoteMoved] = [:]
    private(set) var connected = false
    private var session: GroupSession<NoteBoard>?
    private var fast: GroupSessionMessenger?
    private var safe: GroupSessionMessenger?
    private var jobs: [Task<Void, Never>] = []

    func configure(_ incoming: GroupSession<NoteBoard>) {
        cleanup()
        session = incoming
        let quick = GroupSessionMessenger(session: incoming, deliveryMode: .unreliable)
        let sure = GroupSessionMessenger(session: incoming, deliveryMode: .reliable)
        fast = quick
        safe = sure

        jobs.append(Task { @MainActor [weak self] in
            for await state in incoming.$state.values {
                if case .invalidated = state {
                    self?.cleanup()
                } else if case .joined = state {
                    self?.connected = true
                }
            }
        })
        jobs.append(Task { @MainActor [weak self] in
            for await (note, _) in quick.messages(of: NoteMoved.self) {
                self?.notes[note.id] = note
            }
        })
        jobs.append(Task { @MainActor [weak self] in
            for await _ in sure.messages(of: BoardCleared.self) {
                self?.notes.removeAll()
            }
        })
        incoming.join()
    }

    func move(_ note: NoteMoved) {
        notes[note.id] = note
        guard let fast else { return }
        Task { try? await fast.send(note, to: .all) }
    }

    func clearBoard() {
        notes.removeAll()
        guard let safe else { return }
        Task { try? await safe.send(BoardCleared(), to: .all) }
    }

    func leave() {
        defer { cleanup() }
        session?.leave()
    }

    private func cleanup() {
        for job in jobs { job.cancel() }
        jobs = []
        fast = nil
        safe = nil
        session = nil
        connected = false
    }
}
```

Two messengers share one session: note moves go over the unreliable one for
speed, and clearing goes over the reliable one so nobody misses it. Local
changes apply on this device first and are then sent to everyone.
`configure` starts by cleaning up any earlier session.

## A reusable manager

A generic manager that any activity can use.

```swift
import GroupActivities
import Observation

enum SharePlayError: Error { case notConnected }

@MainActor @Observable
final class SharePlayManager<Activity: GroupActivity> {
    private(set) var participants: Set<Participant> = []
    private(set) var journal: GroupSessionJournal?
    private(set) var session: GroupSession<Activity>?
    private(set) var messenger: GroupSessionMessenger?
    private var jobs: [Task<Void, Never>] = []

    var localParticipant: Participant? { session?.localParticipant }
    var isJoined: Bool {
        if case .joined? = session?.state { return true }
        return false
    }

    func startObserving() async {
        for await incoming in Activity.sessions() {
            adopt(incoming)
        }
    }

    private func adopt(_ incoming: GroupSession<Activity>) {
        reset()
        session = incoming
        messenger = GroupSessionMessenger(session: incoming)
        journal = GroupSessionJournal(session: incoming)
        jobs.append(Task { @MainActor [weak self] in
            for await current in incoming.$activeParticipants.values {
                self?.participants = current
            }
        })
        jobs.append(Task { @MainActor [weak self] in
            for await state in incoming.$state.values {
                if case .invalidated = state { self?.reset() }
            }
        })
        incoming.join()
    }

    func end() {
        defer { reset() }
        session?.end()
    }

    func leave() {
        defer { reset() }
        session?.leave()
    }

    private func connectedMessenger() throws -> GroupSessionMessenger {
        guard let messenger else { throw SharePlayError.notConnected }
        return messenger
    }

    func broadcast<Payload: Codable & Sendable>(_ payload: Payload) async throws {
        try await connectedMessenger().send(payload, to: .all)
    }

    func send<Payload: Codable & Sendable>(_ payload: Payload, to person: Participant) async throws {
        try await connectedMessenger().send(payload, to: .only(person))
    }

    func messages<Payload: Codable & Sendable>(of kind: Payload.Type) -> AsyncThrowingStream<(Payload, Participant), Error> {
        let source = messenger
        return AsyncThrowingStream { sink in
            guard let source else {
                sink.finish(throwing: SharePlayError.notConnected)
                return
            }
            let pump = Task {
                for await (payload, ctx) in source.messages(of: kind) {
                    sink.yield((payload, ctx.source))
                }
                sink.finish()
            }
            sink.onTermination = { _ in pump.cancel() }
        }
    }

    private func reset() {
        for job in jobs { job.cancel() }
        jobs = []
        participants = []
        journal = nil
        messenger = nil
        session = nil
    }
}
```

The `journal` property requires the 17 releases of iOS, iPadOS and tvOS, macOS
14, or the first visionOS.
With an older deployment target, gate it with `@available` or `#available`.
`leave()` leaves and resets; `end()` ends the session for everyone and resets.
The typed send helpers throw `notConnected` when no messenger exists yet.

## SwiftUI entry points

A custom SharePlay button only makes sense while the person is already on a
FaceTime call or in a Messages chat. When the person still has to choose who to
share with, use `ShareLink`, a share sheet or `GroupActivitySharingController`.

### ShareLink and AirDrop

`ShareLink`, SharePlay over AirDrop and the system share sheets all need the
activity to be `Transferable`. Keep what it carries small, such as an ID or a
URL, and load the heavy content after joining. `Transferable` requires
`Sendable`, and a struct only gets that implicitly inside its own file, which is
why `NoteBoard` above declares `Sendable` explicitly.

```swift
import SwiftUI
import GroupActivities
import CoreTransferable

extension NoteBoard: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        GroupActivityTransferRepresentation { $0 }
    }
}

struct InviteButton: View {
    var body: some View {
        ShareLink(item: NoteBoard(), preview: SharePreview("Plan on one board"))
    }
}
```

### A SharePlay button

`GroupActivity` does not require `Sendable`, so the generic parameter adds it:
the button's `Task` hands the activity to the nonisolated
`prepareForActivation()` and `activate()`.

```swift
struct SharePlayButton<Activity: GroupActivity & Sendable>: View {
    @State private var groupState = GroupStateObserver()
    let activity: Activity

    var body: some View {
        if groupState.isEligibleForGroupSession {
            Button {
                Task {
                    guard case .activationPreferred = await activity.prepareForActivation() else { return }
                    _ = try? await activity.activate()
                }
            } label: {
                Label("SharePlay", systemImage: "shareplay")
            }
        }
    }
}
```

### A status badge

```swift
struct SharePlayBadge: View {
    let connected: Bool
    let count: Int

    var body: some View {
        if connected {
            Label("\(count)", systemImage: "shareplay")
                .font(.caption)
                .padding(6)
                .background(.thinMaterial, in: Capsule())
        }
    }
}
```

### Putting it in a screen

Create the manager at the app, scene or feature-coordinator level and inject
it. The screen reads it from the environment and never owns the `sessions()`
lifecycle itself.

```swift
struct BoardScreen: View {
    @Environment(SharePlayManager<NoteBoard>.self) private var shared

    var body: some View {
        BoardCanvas()
            .overlay(alignment: .topTrailing) {
                SharePlayBadge(connected: shared.isJoined, count: shared.participants.count)
            }
            .toolbar { SharePlayButton(activity: NoteBoard()) }
            .task { await shared.startObserving() }
            .onDisappear { shared.leave() }
    }
}
```

## Syncing game state, including late joiners

A shared word puzzle. Whoever started the session hosts it and brings each
newcomer up to date.

```swift
struct WordRace: GroupActivity {
    let puzzleID: UUID
    var metadata: GroupActivityMetadata {
        var info = GroupActivityMetadata()
        info.title = "Word race"
        info.type = .generic
        return info
    }
}

struct NewClue: Codable, Sendable { let number: Int; let clue: String }
struct Guess: Codable, Sendable { let number: Int; let word: String }
struct RaceSnapshot: Codable, Sendable { let clue: NewClue?; let points: [String: Int] }

@Observable @MainActor
final class WordRaceHost {
    private(set) var clue: NewClue?
    private(set) var points: [String: Int] = [:]
    private var jobs: [Task<Void, Never>] = []

    func run(_ session: GroupSession<WordRace>) {
        let messenger = GroupSessionMessenger(session: session)
        let hosting = session.isLocallyInitiated

        jobs.append(Task { @MainActor [weak self] in
            for await (next, _) in messenger.messages(of: NewClue.self) { self?.clue = next }
        })
        jobs.append(Task { @MainActor [weak self] in
            for await (guess, context) in messenger.messages(of: Guess.self) {
                self?.score(guess, from: context.source)
            }
        })
        jobs.append(Task { @MainActor [weak self] in
            for await (snapshot, _) in messenger.messages(of: RaceSnapshot.self) {
                self?.clue = snapshot.clue
                self?.points = snapshot.points
            }
        })
        jobs.append(Task { @MainActor [weak self] in
            var seen = Set<Participant>()
            for await present in session.$activeParticipants.values {
                let arrivals = present.subtracting(seen)
                seen = present
                guard hosting, let self else { continue }
                for newcomer in arrivals {
                    if let clue = self.clue {
                        try? await messenger.send(clue, to: .only(newcomer))
                    }
                    let snapshot = RaceSnapshot(clue: self.clue, points: self.points)
                    try? await messenger.send(snapshot, to: .only(newcomer))
                }
            }
        })
        session.join()
    }

    private func score(_ guess: Guess, from player: Participant) {
        guard guess.number == clue?.number else { return }
        points[player.id.uuidString, default: 0] += 1
    }
}
```

- `session.isLocallyInitiated` marks the device that started the session, which
  acts as host.
- Each message type gets its own task over `messages(of:)`; the guess handler
  reads the sender from `context.source`.
- The participant task remembers who it has seen, and for every new arrival
  the host sends the current clue and then a full snapshot `to: .only(...)`.
- Points are keyed by `participant.id.uuidString`.

## Tracking who joins and leaves

```swift
@MainActor
final class RosterTracker {
    private var known: Set<Participant> = []
    private let welcome: (Participant) async -> Void

    init(welcome: @escaping (Participant) async -> Void) {
        self.welcome = welcome
    }

    func update(with active: Set<Participant>) async {
        let joined = active.subtracting(known)
        let departed = known.subtracting(active)
        for person in joined { await welcome(person) }
        if !departed.isEmpty { Log.share.info("\(departed.count) left") }
        known = active
    }
}
```

## Nearby participants on visionOS

On visionOS 26 and later, people wearing Vision Pro headsets in the same room can
join the same activity. Sessions, messengers and journals treat them exactly
like FaceTime participants. `participant.isNearbyWithLocalParticipant` tells you
who is physically present; check it only when the interface or spatial
placement really depends on it.

```swift
func seat(_ person: Participant, in layout: inout RoomLayout) {
    if person.isNearbyWithLocalParticipant {
        layout.placeBesideLocalUser(person.id)
    } else {
        layout.placeAsSpatialPersona(person.id)
    }
}
```
