-
NowPlaying 프레임워크 만나 보기
앱의 미디어 재생을 잠금 화면, 제어 센터, Dynamic Island, CarPlay와 같은 시스템 표면에 연결하는 Swift 프레임워크인 NowPlaying을 처음으로 살펴보세요. 관찰 가능 API를 사용하여 재생 상태를 게시하고 명령에 응답하는 방법을 알아보세요. 앱이 외부 기기에서 재생 중인 미디어를 표시하고 동일한 시스템 표면에 전체 재생 제어 항목을 적용할 수 있도록 해 주는 새로운 기능인 원격 재생 세션을 살펴보세요.
챕터
- 0:00 - Introduction
- 1:08 - Media sessions
- 5:03 - Remote media sessions
- 10:31 - Media sharing extensions
리소스
-
비디오 검색…
안녕하세요, 저는 Leo Formaggio입니다 Media Frameworks 팀의 엔지니어입니다 미디어는 우리 일상에서 매우 중요한 존재입니다 퇴근길의 팟캐스트 운동 중의 신나는 플레이리스트 장거리 비행 중에 영화 감상 혼자 시간을 보내든 다른 사람들과 함께하든 미디어는 항상 우리 곁에 있습니다 iPhone에서는 잠금 화면에 바로 표시되고 Control Center와 Dynamic Island에도 나타납니다 iPhone을 내려놓고 충전하면 StandBy에서도 한눈에 볼 수 있고 차에 탑승하면 CarPlay에서 전면에 표시됩니다 이것이 시스템 재생 중 화면 경험으로 모든 Apple 플랫폼에서 이용 가능합니다 Apple Watch, Apple Vision Pro, Apple TV도 포함됩니다 NowPlaying 프레임워크를 통해 앱의 미디어를 시스템에 쉽게 연결하는 방법을 보여드리겠습니다
먼저 미디어 세션 API부터 시작해 콘텐츠를 시스템 재생 중 화면에 표시하는 방법을 알아보겠습니다
다음으로, 다른 기기에서 재생 중인 콘텐츠를 시스템으로 가져오는 방법을 원격 미디어 세션으로 설명하겠습니다
마지막으로, Media Sharing Extensions가 iPhone에서 다른 기기로의 미디어 재생을 어떻게 단순화하는지 살펴보겠습니다 제가 개발한 앱을 예시로 사용하겠습니다 집중과 이완을 돕는 자연음을 재생하는 앱입니다 앱에서 다양한 사운드를 선택하고 오디오를 일시 정지하거나 재개할 수도 있습니다 시스템에 콘텐츠를 표시하기 위해 NowPlaying의 미디어 세션 API를 사용했습니다 구현 방법을 보여드리겠습니다 제 PlayerModel입니다 앱의 오디오 엔진을 참조하는 @Observable 클래스로 현재 재생 중인 사운드를 추적하는 프로퍼티가 있습니다 MediaSessionRepresentable 프로토콜은 앱과 시스템 간의 계약과 같습니다 PlayerModel이 이를 준수하면 시스템이 파악할 수 있게 됩니다 앱의 재생 내용과 스킵, 일시 정지 같은 미디어 상호작용 처리 방법을 각 세션 표현에는 고유 식별자가 필요합니다 content 프로퍼티는 재생 중인 사운드를 설명하는 데 사용됩니다 NowPlaying은 Music, Podcast, MovieContent 등 콘텐츠별 유형을 제공해 앱이 재생 중인 미디어 종류를 설명할 수 있습니다 제 경우에는 GenericContent가 적합해서 사용했습니다 각 콘텐츠는 현재 sound.id로 식별됩니다 sound.name을 콘텐츠 제목으로 sound.description을 부제목으로 사용했습니다 미디어 타입은 .audio 또는 .video인데 제 앱은 오디오만 재생합니다 지속 시간을 .continuous로 설정했는데 앱이 자연음을 무한히 재생하기 때문입니다 비동기 클로저로 Artwork를 제공합니다 시스템은 특정 크기의 이미지가 필요할 때마다 이를 호출합니다 잠금 화면에서 어떻게 보이는지 보여드리겠습니다 아트워크와 함께 사운드 이름과 설명이 표시됩니다
PlaybackSnapshot 프로퍼티는 현재 재생 상태를 표시하는 데 사용됩니다 자연음은 연속적이기 때문에 isPlaying 여부만 표시하면 됩니다 재생 시간이 정해진 콘텐츠는 스냅샷에 elapsedTime 매개변수도 지정해야 합니다 commands 프로퍼티를 통해 앱이 지원하는 모든 동작을 정의합니다 각 커맨드에는 클로저가 있으며 사용자가 해당 동작을 수행하면 시스템이 이를 호출합니다 예를 들어, 잠금 화면에서 일시 정지 버튼을 탭하면 일시 정지 커맨드 클로저가 호출되어 플레이어를 일시 정지할 수 있습니다 버튼이 일시 정지 상태로 변경된 것을 볼 수 있습니다 이제 휴대폰에서 재생을 탭하면 재생 커맨드 클로저가 호출되어 플레이어를 재개합니다 마찬가지로, 잠금 화면에서 다음 버튼을 탭하면 다음 커맨드 클로저가 호출됩니다 다음 사운드로 건너뛸 수 있으며 잠금 화면이 새 콘텐츠를 반영해 업데이트됩니다
MediaSessionRepresentable을 채택한 후 콘텐츠를 시스템에서 사용하려면 한 가지를 더 해야 했습니다 MediaSession은 세션 표현과 시스템을 연결하는 역할을 합니다 PlayerModel로 초기화하고 오디오 엔진을 설정하는 곳에서 함께 설정합니다 설정이 완료되면 MediaSession이 모델을 관찰하기 시작하고 재생 중 화면을 자동으로 최신 상태로 유지합니다 이렇게 미디어 세션을 사용해 앱 콘텐츠를 시스템 재생 화면과 통합했습니다 자세한 내용은 Apple Developer 문서의 "Publishing Media Sessions" 아티클을 확인하세요
iPhone 재생 외에도 스마트 스피커에서도 오디오 엔진을 사용할 수 있게 했으며 앱으로 제어할 수 있습니다 앱의 기기 선택 메뉴에서 제어할 스피커를 선택합니다 Living Room Speaker를 탭해 제어를 시작합니다 그리고 웹 서버를 통해 앱이 선택한 스피커에 연결해 재생 상태를 요청하고 커맨드를 전송합니다 스피커에서 재생 중인 콘텐츠를 시스템에 표시하기 위해 원격 미디어 세션 API를 사용했습니다 이 API는 앱 익스텐션과 푸시 알림을 사용해 스피커 업데이트를 수신합니다 이 상호작용이 어떻게 작동하는지 보여드리겠습니다 누군가 스피커와 상호작용하면 스피커가 변경된 상태를 서버에 전달합니다 서버는 Apple Push Notification service, APNs를 사용해 업데이트된 상태와 함께 iPhone에 푸시 알림을 전송합니다 시스템이 업데이트된 상태로 앱 익스텐션을 실행하고 푸시 알림 페이로드에서 가져옵니다 앱 익스텐션은 시스템에 해당 세션의 업데이트된 표현을 제공합니다 APNs로 푸시 알림을 보내는 방법에 대한 자세한 내용은 "Setting up a remote notification server" 아티클을 developer.apple.com/kr에서 확인하세요 상호작용이 iPhone 시스템 UI에서 시작되면 시스템이 앱 익스텐션의 커맨드 핸들러를 호출합니다 앱 익스텐션은 서버에 커맨드를 전송합니다 서버가 스피커에 알리고 스피커가 변경에 반응합니다
앱에 원격 미디어 세션을 채택한 방법을 설명합니다 먼저 앱 익스텐션을 만들었습니다 RemoteMediaSessionExtension 프로토콜을 준수합니다 설정을 위해 NowPlaying의 RemoteMediaSessionExtensionConfiguration과 remote-media extensionPoint 식별자를 사용했습니다 session(:) 메서드는 시스템이 상호작용이 필요할 때마다 호출됩니다 원격 세션 표현과 예를 들어, UI 업데이트 또는 상호작용 처리를 위해서입니다 RemotePlayerState를 사용해 모델을 생성하고 반환할 수 있습니다 앱 익스텐션 설정이 완료되면 모델을 사용해 원격 미디어 세션을 표현하는 방법을 보여드리겠습니다 제 RemotePlayerModel입니다 ServerClient를 참조하는 @Observable 클래스로 서버와 통신하는 데 사용하는 클래스입니다 서버 상태도 추적합니다 이를 기반으로 원격 미디어 세션 표현을 구성하겠습니다 각 원격 미디어 세션 표현에는 고유 식별자가 필요합니다 서버 상태의 sessionID를 사용했습니다 content 프로퍼티는 스피커에서 재생 중인 사운드를 설명합니다 이번에도 GenericContent를 사용해 사운드 식별자를 지정하고 사운드 이름과 설명을 전달했습니다 미디어 타입은 .audio이고 지속 시간은 .continuous입니다 현재 사운드의 이미지를 불러오는 Artwork 객체를 제공했습니다
서버 상태는 스피커의 isPlaying 여부를 나타냅니다 이를 사용해 해당 상태로 PlaybackSnapshot을 생성할 수 있습니다 원격 기기에서 재생을 제어하기 때문에 각 커맨드 클로저는 해당 동작과 함께 서버에 요청을 전송합니다 예를 들어, iPhone에서 재생을 탭하면 재생 커맨드 클로저가 호출됩니다 서버에 재생 요청을 전송해 스피커에서 재생을 재개합니다 마찬가지로, 다음 버튼을 탭하면 서버에 요청이 전송되고 스피커가 다음 사운드로 이동합니다
지금까지 살펴본 RemoteMediaSessionRepresentable 채택은 로컬 재생을 위한 미디어 세션과 매우 유사합니다 다음으로, 나머지 프로퍼티와 메서드를 원격 세션에 특화된 것들을 다루겠습니다 devices 프로퍼티는 해당 세션에서 재생 중인 기기를 시스템에 알립니다 서버의 기기 목록을 MediaDevice 값으로 변환합니다 각 기기에는 다른 세션에서도 일관된 고유 식별자가 필요합니다 기기 이름과 유형을 제공합니다 제 경우에는 .speaker로 지정했으며 볼륨 제어 유형 등 기기 기능 목록도 제공합니다 Control Center에서의 모습입니다 기기 이름이 볼륨 레벨과 함께 표시됩니다
시스템 볼륨 슬라이더로 볼륨을 조정하면 업데이트된 볼륨 레벨로 볼륨 변경 클로저가 호출됩니다 여기서 서버에 볼륨 변경 요청을 전송할 수 있습니다
update(:) 함수는 새 상태와 함께 푸시 알림이 수신되면 호출됩니다 예를 들어, 스피커에서 콘텐츠가 변경될 때입니다 RemotePlayerState는 제가 정의한 구조체로 RemoteMediaSessionAttributes를 준수합니다 서버 상태와 푸시 알림 페이로드를 나타냅니다 여기서 새 데이터로 상태 변수를 업데이트합니다 모델이 observable이기 때문에 NowPlaying이 변경을 감지하고 시스템을 자동으로 업데이트합니다 이렇게 앱의 원격 미디어 세션을 시스템과 통합했습니다 자세한 내용은 "Publishing remote media sessions" 아티클을 확인하세요 Media Sharing Extensions에 대해서도 이야기하고 싶습니다 iPhone에서 스피커와 TV로 미디어를 재생하는 API 세트로 통합 시스템 인터페이스를 통해 작동합니다 Media Sharing Extensions를 사용하면 시스템 기기 선택기를 활용해 앱이 지원하는 모든 미디어 프로토콜을 처리할 수 있습니다 앱의 미디어 기기 선택을 단순화하고 Control Center 같은 시스템 화면에 선택이 반영됩니다
기존에는 미디어 프로토콜을 지원하려면 SDK를 앱 번들에 직접 포함해야 했습니다 Media Sharing Extensions를 사용하면 프로토콜 구현이 앱 외부에 위치하고 시스템이 이를 관리합니다 앱은 재생 기술보다 미디어 콘텐츠에 집중할 수 있습니다
더 많은 프로토콜이 추가될수록 Media Sharing Extensions로 빌드된 앱은 SDK 추가 없이 이를 활용할 수 있습니다
로컬 및 원격 미디어 세션을 NowPlaying 프레임워크로 시스템 재생 화면에 연결하는 방법과 Media Sharing Extensions로 다른 기기에 미디어를 전송하는 방법을 다뤘습니다 로컬로 미디어를 재생하거나 원격 기기의 재생을 제어하는 앱이라면 NowPlaying을 채택해 콘텐츠를 잠금 화면에 연결하세요 Control Center와 그 이상으로도 사람들이 미디어를 편리하게 제어할 수 있는 간단한 통합입니다 앱 밖에서도 가능합니다 Media Sharing Extensions에 대해 알아보세요 시스템 미디어 기기 선택기를 앱에서 활용할 수 있게 해주며 다른 기기로의 미디어 재생 범위를 확장할 수 있습니다 Media Sharing Extensions에 대한 자세한 내용은 "Routing media to third-party devices" 아티클을 확인하세요 여러분의 앱이 시스템 재생 화면으로 확장되기를 기대합니다 시청해 주셔서 감사합니다, 좋은하루 되세요!
-
-
1:57 - Existing PlayerModel implementation
import Observation @Observable final class PlayerModel { let player: SoundPlayer var sound: Sound { player.currentSound } init(player: SoundPlayer) { self.player = player } } -
2:06 - Adopt MediaSessionRepresentable
import NowPlaying extension PlayerModel: MediaSessionRepresentable { var id: String { "ambient-sound-session" } var content: (any MediaContentRepresentable)? { return GenericContent( id: sound.id, title: sound.name, subtitle: sound.description, type: .audio, duration: .live, artwork: Artwork(id: sound.id) { size in let data = try await self.artworkData(size: size) return try ArtworkRepresentation(data: data) } ) } var playbackSnapshot: MediaPlaybackSnapshot? { MediaPlaybackSnapshot( state: player.isPlaying ? .playing() : .paused ) } var commands: [MediaCommand] {[ .play { self.player.play() }, .pause { self.player.pause() }, .previous { self.player.previous() }, .next { self.player.next() } ]} } -
4:31 - MediaSession initialization
import NowPlaying struct PlayerController { let player: SoundPlayer let model: PlayerModel let session: MediaSession<PlayerModel> init() { self.player = SoundPlayer() self.model = PlayerModel(player: player) self.session = MediaSession(model) } } -
6:42 - App extension entry point
import ExtensionFoundation import NowPlaying @main final class SampleAppExtension: @MainActor RemoteMediaSessionExtension { var configuration: some AppExtensionConfiguration { RemoteMediaSessionExtensionConfiguration(extension: self) } var extensionPoint: AppExtensionPoint { AppExtensionPoint.Identifier(host: "com.apple.nowplaying", name: "remote-media") } func session(_ state: RemotePlayerState) async throws -> RemotePlayerModel { RemotePlayerModel(state: state) } } -
7:23 - Existing RemotePlayerModel implementation
import Observation @Observable @MainActor final class RemotePlayerModel { let client: ServerClient var state: RemotePlayerState init(state: RemotePlayerState) { self.client = ServerClient(sessionID: state.sessionID) self.state = state } } -
7:40 - Adopt RemoteMediaSessionRepresentable in app extension
import NowPlaying extension RemotePlayerModel: @MainActor RemoteMediaSessionRepresentable { var id: String { state.sessionID } var content: (any MediaContentRepresentable)? { GenericContent( id: state.sound.id, title: state.sound.name, subtitle: state.sound.description, type: .audio, duration: .live, artwork: Artwork(id: state.sound.id) { size in let data = try await self.artworkData(size: size) return try ArtworkRepresentation(data: data) } ) } var playbackSnapshot: MediaPlaybackSnapshot? { MediaPlaybackSnapshot( state: state.isPlaying ? .playing() : .paused ) } var commands: [MediaCommand] {[ .play { try await self.client.send(.play) }, .pause { try await self.client.send(.pause) }, .previous { try await self.client.send(.previous) }, .next { try await self.client.send(.next) } ]} var devices: [MediaDevice] { state.devices.map { device in MediaDevice( id: device.id, name: device.name, type: .speaker, capabilities: [ .absoluteVolume(device.volume) { volume in // send volume change to server } ] ) } } func update(_ state: RemotePlayerState) { self.state = state } }
-
-
- 0:00 - Introduction
Discover the Now Playing system experience, available across all Apple platforms. It allows apps to surface currently playing media info on system surfaces like the Lock Screen, Dynamic Island, and CarPlay.
- 1:08 - Media sessions
Learn how to use the media sessions API to bring audio or video from your app into the system's Now Playing experience by adopting the MediaSessionRepresentable protocol.
- 5:03 - Remote media sessions
Discover how to extend playback control to devices like smart speakers by adopting RemoteMediaSessionRepresentable and utilizing Apple Push Notification service (APNs).
- 10:31 - Media sharing extensions
Find out how Media Sharing Extensions simplify routing media from iPhone to other devices by leveraging the system device picker without needing to embed additional SDKs.