๐ฌ 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 ๊ฐ์ด๋๋ผ์ธ
- ์๋ ๊ฐ์ง: FaceTime ํตํ ์ค์ผ ๋ ์๋์ผ๋ก SharePlay ์ต์ ์ ๊ณต
- ์์ ๋ฐฉ๋ฒ: ๋ช ํํ SharePlay ์์ ๋ฒํผ ์ ๊ณต
- ์ํ ํ์: SharePlay ํ์ฑ ์ฌ๋ถ๋ฅผ ๋ช ํํ ํ์
- ์ปจํธ๋กค ๊ณต์ : ๋ชจ๋ ์ฐธ๊ฐ์๊ฐ ์ฌ์์ ์ ์ดํ ์ ์์ด์ผ ํจ
- ์ค๋งํธ ๋ณผ๋ฅจ: ์์คํ ์ด ์๋์ผ๋ก FaceTime๊ณผ ๋ฏธ๋์ด ์ค๋์ค ๋ฐธ๋ฐ์ค ์กฐ์
- ๊ฐ๋ณ ์ ํ: ์ฐธ๊ฐ์๊ฐ SharePlay ์ฐธ์ฌ/์ข ๋ฃ๋ฅผ ์์ ๋กญ๊ฒ ์ ํ
- ๊ถํ: Info.plist์
NSLocalNetworkUsageDescription์ถ๊ฐ
๐ฏ ์ค์ ํ์ฉ
- ์คํธ๋ฆฌ๋ฐ ์ฑ: ์ํ/๋๋ผ๋ง๋ฅผ ์น๊ตฌ์ ํจ๊ป ์์ฒญ
- ์์ ์ฑ: ํ๋ ์ด๋ฆฌ์คํธ๋ฅผ ์ค์๊ฐ์ผ๋ก ๊ณต์
- ์ด๋ ์ฑ: ๊ฐ์ ์ด๋ ์์์ ํจ๊ป ๋ฐ๋ผํ๊ธฐ
- ๊ฒ์ ์ฑ: ํ๋ฉด ๊ณต์ ๋ก ํจ๊ป ๊ฒ์ ํ๋ ์ด
- ๊ต์ก ์ฑ: ๊ฐ์๋ ํํ ๋ฆฌ์ผ์ ํจ๊ป ์์ฒญ
๐ ๋ ์์๋ณด๊ธฐ
- GroupActivities ๊ณต์ ๋ฌธ์
- WWDC 2021: Coordinate media experiences with Group Activities
- WWDC 2022: What's new in SharePlay
- HIG: SharePlay
โก๏ธ ํ: 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>