๐ŸŽฌ SharePlay

FaceTime์œผ๋กœ ํ•จ๊ป˜ ์ฆ๊ธฐ๋Š” ๊ณต์œ  ๊ฒฝํ—˜

iOS 15+tvOS/macOS ์ง€์›

โœจ SharePlay๋ž€?

SharePlay๋Š” FaceTime ํ†ตํ™” ์ค‘ ์˜ํ™”, ์Œ์•…, ์•ฑ ํ™”๋ฉด ๋“ฑ์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋™๊ธฐํ™”ํ•˜์—ฌ ํ•จ๊ป˜ ์ฆ๊ธธ ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. GroupActivities์™€ AVFoundation์„ ํ™œ์šฉํ•˜์—ฌ ๋ฉ€ํ‹ฐ ๋””๋ฐ”์ด์Šค ๊ฐ„ ์™„๋ฒฝํ•œ ๋™๊ธฐํ™”๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ’ก ํ•ต์‹ฌ ๊ธฐ๋Šฅ: ๋ฏธ๋””์–ด ๋™๊ธฐํ™” ยท ํ™”๋ฉด ๊ณต์œ  ยท ๊ทธ๋ฃน ์„ธ์…˜ ยท ์‹ค์‹œ๊ฐ„ ๋™๊ธฐํ™” ยท ์Šค๋งˆํŠธ ๋ณผ๋ฅจ ยท ์žฌ์ƒ ์ œ์–ด ๊ณต์œ  ยท ํฌ๋กœ์Šค ํ”Œ๋žซํผ (iOS/tvOS/macOS)

๐ŸŽฏ 1. Group Activity ์ •์˜

SharePlay ์„ธ์…˜์—์„œ ๊ณต์œ ํ•  ํ™œ๋™์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.

MovieActivity.swift โ€” Group Activity ์ •์˜
import GroupActivities

struct Movie: Hashable, Codable {
    let id: String
    let title: String
    let url: URL
}

// GroupActivity ํ”„๋กœํ† ์ฝœ ์ฑ„ํƒ
struct WatchingMovie: GroupActivity {
    // ๊ณต์œ ํ•  ์˜ํ™” ์ •๋ณด
    let movie: Movie

    // ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ (FaceTime UI์— ํ‘œ์‹œ)
    var metadata: GroupActivityMetadata {
        var metadata = GroupActivityMetadata()
        metadata.title = movie.title
        metadata.type = .watchTogether
        metadata.fallbackURL = movie.url

        // ์„ ํƒ ์‚ฌํ•ญ: ์ด๋ฏธ์ง€, ๋ถ€์ œ๋ชฉ
        // metadata.previewImage = ...
        // metadata.subtitle = "์˜ํ™”"

        return metadata
    }
}

๐Ÿš€ 2. SharePlay ์„ธ์…˜ ์‹œ์ž‘

GroupActivity๋ฅผ ํ™œ์„ฑํ™”ํ•˜๊ณ  ์„ธ์…˜์„ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

SharePlayManager.swift โ€” ์„ธ์…˜ ๊ด€๋ฆฌ
import GroupActivities
import Combine

@Observable
class SharePlayManager {
    var groupSession: GroupSession<WatchingMovie>?
    var isSharePlayActive = false
    var activeParticipants: [Participant] = []

    private var cancellables = Set<AnyCancellable>()
    private var tasks = Set<Task<Void, Never>>()

    init() {
        observeGroupSessions()
    }

    // GroupSession ๋ณ€ํ™” ๊ด€์ฐฐ
    func observeGroupSessions() {
        let task = Task {
            for await session in WatchingMovie.sessions() {
                configureGroupSession(session)
            }
        }
        tasks.insert(task)
    }

    // SharePlay ์„ธ์…˜ ์‹œ์ž‘
    func startSharePlay(movie: Movie) async throws {
        let activity = WatchingMovie(movie: movie)

        // ์„ธ์…˜ ํ™œ์„ฑํ™” ์‹œ๋„
        switch await activity.prepareForActivation() {
        case .activationPreferred:
            // ์‚ฌ์šฉ์ž๊ฐ€ SharePlay ์„ ํƒ โ†’ ์„ธ์…˜ ํ™œ์„ฑํ™”
            try await activity.activate()

        case .activationDisabled:
            // SharePlay ๋น„ํ™œ์„ฑํ™” โ†’ ๋กœ์ปฌ์—์„œ๋งŒ ์žฌ์ƒ
            print("SharePlay ๋น„ํ™œ์„ฑํ™”๋จ")

        case .cancelled:
            // ์‚ฌ์šฉ์ž๊ฐ€ ์ทจ์†Œ
            print("SharePlay ์ทจ์†Œ๋จ")

        @unknown default:
            break
        }
    }

    // GroupSession ์„ค์ •
    func configureGroupSession(_ session: GroupSession<WatchingMovie>) {
        groupSession = session
        isSharePlayActive = true

        // ์ฐธ๊ฐ€์ž ๋ชฉ๋ก ์—…๋ฐ์ดํŠธ
        session.$activeParticipants
            .sink { [weak self] participants in
                self?.activeParticipants = Array(participants)
            }
            .store(in: &cancellables)

        // ์„ธ์…˜ ์ƒํƒœ ๋ณ€ํ™” ๊ด€์ฐฐ
        session.$state
            .sink { [weak self] state in
                if state == .invalidated {
                    self?.isSharePlayActive = false
                    self?.groupSession = nil
                }
            }
            .store(in: &cancellables)

        // ์„ธ์…˜ ์กฐ์ธ
        session.join()
    }

    // SharePlay ์„ธ์…˜ ์ข…๋ฃŒ
    func endSharePlay() {
        groupSession?.end()
        groupSession = nil
        isSharePlayActive = false
    }

    deinit {
        tasks.forEach { $0.cancel() }
    }
}

๐ŸŽฅ 3. ๋น„๋””์˜ค ๋™๊ธฐํ™”

AVPlayer๋ฅผ SharePlay์™€ ํ†ตํ•ฉํ•˜์—ฌ ์žฌ์ƒ์„ ๋™๊ธฐํ™”ํ•ฉ๋‹ˆ๋‹ค.

VideoPlayerManager.swift โ€” ๋น„๋””์˜ค ๋™๊ธฐํ™”
import AVFoundation
import GroupActivities

@Observable
class VideoPlayerManager {
    var player: AVPlayer?
    var groupSession: GroupSession<WatchingMovie>?

    private var playbackCoordinator: AVPlayerPlaybackCoordinator?

    // SharePlay๋กœ ๋น„๋””์˜ค ์žฌ์ƒ
    func playVideo(url: URL, session: GroupSession<WatchingMovie>?) {
        // AVPlayer ์ƒ์„ฑ
        let playerItem = AVPlayerItem(url: url)
        let player = AVPlayer(playerItem: playerItem)
        self.player = player

        // SharePlay ์„ธ์…˜๊ณผ ์—ฐ๊ฒฐ
        if let session {
            playbackCoordinator = player.playbackCoordinator
            playbackCoordinator?.coordinateWithSession(session)
        }

        // ์žฌ์ƒ ์‹œ์ž‘
        player.play()
    }

    // ์žฌ์ƒ/์ผ์‹œ์ •์ง€ ํ† ๊ธ€
    func togglePlayback() {
        guard let player else { return }

        if player.timeControlStatus == .playing {
            player.pause()
        } else {
            player.play()
        }
    }

    // ํŠน์ • ์‹œ๊ฐ„์œผ๋กœ ์ด๋™
    func seek(to time: CMTime) {
        player?.seek(to: time)
    }
}

๐ŸŽต 4. ์Œ์•… ๋™๊ธฐํ™”

์Œ์•… ์žฌ์ƒ๋„ SharePlay๋กœ ๋™๊ธฐํ™”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

MusicActivity.swift โ€” ์Œ์•… ๊ณต์œ 
import GroupActivities
import MusicKit

struct Song: Hashable, Codable {
    let id: String
    let title: String
    let artist: String
}

struct ListeningTogether: GroupActivity {
    let song: Song

    var metadata: GroupActivityMetadata {
        var metadata = GroupActivityMetadata()
        metadata.title = song.title
        metadata.subtitle = song.artist
        metadata.type = .listenTogether
        return metadata
    }
}

@Observable
class MusicSharePlayManager {
    var groupSession: GroupSession<ListeningTogether>?

    func startListeningTogether(song: Song) async throws {
        let activity = ListeningTogether(song: song)

        switch await activity.prepareForActivation() {
        case .activationPreferred:
            try await activity.activate()
        default:
            break
        }
    }
}

๐Ÿ’ฌ 5. ์ปค์Šคํ…€ ๋ฉ”์‹œ์ง€ ์ „์†ก

GroupSession์„ ํ†ตํ•ด ์ปค์Šคํ…€ ๋ฐ์ดํ„ฐ๋ฅผ ์ฃผ๊ณ ๋ฐ›์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

GroupSessionMessenger.swift โ€” ๋ฉ”์‹œ์ง€ ์ „์†ก
import GroupActivities

// ์ „์†กํ•  ๋ฉ”์‹œ์ง€ ์ •์˜
struct ChatMessage: Codable {
    let senderID: UUID
    let text: String
    let timestamp: Date
}

struct ReactionMessage: Codable {
    let senderID: UUID
    let emoji: String
}

@Observable
class GroupSessionMessenger {
    var groupSession: GroupSession<WatchingMovie>?
    var messages: [ChatMessage] = []
    var reactions: [ReactionMessage] = []

    private var messenger: GroupSessionMessenger?
    private var tasks = Set<Task<Void, Never>>()

    func configureMessenger(session: GroupSession<WatchingMovie>) {
        groupSession = session
        messenger = GroupSessionMessenger(session: session)

        // ์ฑ„ํŒ… ๋ฉ”์‹œ์ง€ ์ˆ˜์‹ 
        let chatTask = Task {
            for await (message, context) in messenger!.messages(of: ChatMessage.self) {
                handleChatMessage(message, from: context.source)
            }
        }
        tasks.insert(chatTask)

        // ๋ฐ˜์‘ ๋ฉ”์‹œ์ง€ ์ˆ˜์‹ 
        let reactionTask = Task {
            for await (reaction, _) in messenger!.messages(of: ReactionMessage.self) {
                handleReaction(reaction)
            }
        }
        tasks.insert(reactionTask)
    }

    // ์ฑ„ํŒ… ๋ฉ”์‹œ์ง€ ์ „์†ก
    func sendChatMessage(_ text: String) async {
        guard let messenger, let localParticipant = groupSession?.localParticipant else { return }

        let message = ChatMessage(
            senderID: localParticipant.id,
            text: text,
            timestamp: Date()
        )

        do {
            try await messenger.send(message)
        } catch {
            print("๋ฉ”์‹œ์ง€ ์ „์†ก ์‹คํŒจ: \(error)")
        }
    }

    // ๋ฐ˜์‘ ์ „์†ก
    func sendReaction(_ emoji: String) async {
        guard let messenger, let localParticipant = groupSession?.localParticipant else { return }

        let reaction = ReactionMessage(
            senderID: localParticipant.id,
            emoji: emoji
        )

        try? await messenger.send(reaction, to: .all)
    }

    private func handleChatMessage(_ message: ChatMessage, from sender: Participant.ID) {
        messages.append(message)
    }

    private func handleReaction(_ reaction: ReactionMessage) {
        reactions.append(reaction)

        // 3์ดˆ ํ›„ ์ž๋™ ์ œ๊ฑฐ
        Task {
            try? await Task.sleep(for: .seconds(3))
            reactions.removeAll { $0.senderID == reaction.senderID }
        }
    }

    deinit {
        tasks.forEach { $0.cancel() }
    }
}

๐Ÿ“ฑ SwiftUI ํ†ตํ•ฉ

SharePlayDemoView.swift โ€” ์ข…ํ•ฉ ๋ฐ๋ชจ
import SwiftUI
import AVKit

struct SharePlayDemoView: View {
    @State private var sharePlayManager = SharePlayManager()
    @State private var videoManager = VideoPlayerManager()
    @State private var messenger = GroupSessionMessenger()

    let sampleMovie = Movie(
        id: "1",
        title: "์ƒ˜ํ”Œ ์˜ํ™”",
        url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")!
    )

    var body: some View {
        NavigationStack {
            VStack {
                // ๋น„๋””์˜ค ํ”Œ๋ ˆ์ด์–ด
                if let player = videoManager.player {
                    VideoPlayer(player: player)
                        .frame(height: 250)
                }

                // SharePlay ์ƒํƒœ
                Section {
                    if sharePlayManager.isSharePlayActive {
                        Label("SharePlay ํ™œ์„ฑ", systemImage: "shareplay")
                            .foregroundStyle(.green)

                        Text("์ฐธ๊ฐ€์ž: \(sharePlayManager.activeParticipants.count)๋ช…")
                            .font(.caption)

                        Button("SharePlay ์ข…๋ฃŒ", role: .destructive) {
                            sharePlayManager.endSharePlay()
                        }
                    } else {
                        Button("SharePlay ์‹œ์ž‘") {
                            Task {
                                try? await sharePlayManager.startSharePlay(movie: sampleMovie)
                            }
                        }
                        .buttonStyle(.borderedProminent)
                    }
                }
                .padding()

                // ์žฌ์ƒ ์ปจํŠธ๋กค
                HStack(spacing: 20) {
                    Button {
                        videoManager.togglePlayback()
                    } label: {
                        Image(systemName: videoManager.player?.timeControlStatus == .playing ? "pause.circle.fill" : "play.circle.fill")
                            .font(.system(size: 50))
                    }
                }
                .padding()

                // ๋ฐ˜์‘ ๋ฒ„ํŠผ
                if sharePlayManager.isSharePlayActive {
                    HStack(spacing: 15) {
                        ForEach(["โค๏ธ", "๐Ÿ˜‚", "๐Ÿ˜ฎ", "๐Ÿ‘"], id: \.self) { emoji in
                            Button(emoji) {
                                Task {
                                    await messenger.sendReaction(emoji)
                                }
                            }
                            .font(.title)
                        }
                    }
                    .padding()

                    // ๋ฐ˜์‘ ํ‘œ์‹œ
                    HStack {
                        ForEach(messenger.reactions, id: \.senderID) { reaction in
                            Text(reaction.emoji)
                                .font(.largeTitle)
                                .transition(.scale.combined(with: .opacity))
                        }
                    }
                }

                Spacer()
            }
            .navigationTitle("SharePlay ๋ฐ๋ชจ")
            .onChange(of: sharePlayManager.groupSession) { _, session in
                if let session {
                    videoManager.playVideo(
                        url: sampleMovie.url,
                        session: session
                    )
                    messenger.configureMessenger(session: session)
                }
            }
        }
    }
}

๐Ÿ’ก HIG ๊ฐ€์ด๋“œ๋ผ์ธ

๐ŸŽฏ ์‹ค์ „ ํ™œ์šฉ

๐Ÿ“š ๋” ์•Œ์•„๋ณด๊ธฐ

โšก๏ธ ํŒ: SharePlay๋Š” iOS 15.1๋ถ€ํ„ฐ ์™„์ „ํžˆ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ์—์„œ๋Š” ํ…Œ์ŠคํŠธ๊ฐ€ ์ œํ•œ์ ์ด๋ฏ€๋กœ, ์‹ค์ œ ๋””๋ฐ”์ด์Šค 2๋Œ€๋กœ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”ง Entitlements ์„ค์ •

SharePlay๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ์•ฑ์— Group Activities entitlement๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

YourApp.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.developer.group-activities</key>
    <true/>
</dict>
</plist>