스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
멋진 ShazamKit 경험 만들기
ShazamKit의 최신 업데이트를 통해 앱이 어떻게 뛰어난 오디오 매치 경험을 제공할 수 있는지 확인해 보세요. 매치 기능, 오디오 인식 업데이트, Shazam 라이브러리와의 상호 작용에 대해 알아봅니다. 오디오 앱에서 ShazamKit을 사용하기 위한 팁과 모범 사례를 알아보세요. ShazamKit에 대한 자세한 내용은 WWDC22의 'ShazamKit으로 대규모 맞춤형 카탈로그 만들기'와 WWDC21의 'ShazamKit 살펴보기' 및 'ShazamKit으로 맞춤형 오디오 경험 만들기'를 참고하세요.
리소스
관련 비디오
WWDC22
WWDC21
-
다운로드
♪ ♪
안녕하세요, 데이비드 렌워보예요 ShazamKit 팀 엔지니어죠
ShazamKit는 프레임워크로서 앱에 오디오 인식 기능을 넣을 수 있게 해 주죠 Shazam의 방대한 음악 카탈로그에 오디오를 매치할 수도 있고 맞춤형 카탈로그에 미리 녹음한 오디오를 매치할 수도 있죠 2022년에 ShazamKit는 몇 가지 중요한 업데이트를 통해 맞춤형 카탈로그를 대규모로 사용하는 작업을 개선하였습니다 그때 도입된 것이 Shazam CLI로 맞춤형 카탈로그를 사용할 때 무거운 워크플로를 처리합니다 또한 더 나은 동기화를 위한 시간제한 미디어 항목과 비슷한 소리를 내는 두 오디오 비트를 구별하기 위한 주파수 비대칭화도 도입되었습니다 이런 것들이 어떻게 작동하는지 잘 모르신다면 'ShazamKit로 대규모 맞춤형 카탈로그 만들기' 영상을 보세요 하지만 간단히 개요를 설명해 드리면 ShazamKit는 매치할 때 오디오를 서명이라는 특수 형식으로 변환합니다 오디오 버퍼 스트림이나 서명 데이터를 ShazamKit 세션으로 전달할 수 있습니다 그러면 세션은 서명을 사용하여 매치하는 항목을 찾죠 Shazam 카탈로그 또는 사용자 지정 카탈로그에서요 매치되는 것이 있다면 세션은 매치된 객체를 반환합니다 해당 매치의 메타데이터를 표시하는 미디어 항목과 함께요 그러면 앱에 미디어 항목을 표시할 수 있습니다 ShazamKit가 매치를 수행할 때는 오디오 버퍼 스트림에서 서명을 생성하거나 디스크에 저장할 수 있는 서명을 이용합니다 서명은 비가역적입니다 즉, 서명에서 원본 녹음을 재구성하는 것은 불가능하다는 뜻입니다 이런 특성이 고객의 개인정보를 보호하죠 카탈로그는 관련된 미디어 항목이 있는 서명 모음이며 매치는 쿼리 서명이 카탈로그의 참조 서명 일부와 충분히 매치할 때 일어납니다 매치는 쿼리 서명에 잡음이 많아도 일어날 수 있습니다 예를 들면 식당에서 음악이 재생된다든지 할 때도요 이제 간단한 개요 설명을 마치고 올해 나온 ShazamKit의 흥미로운 새 업데이트를 알아보죠 이 세션에서는 ShazamKit 오디오 인식의 새 변경 사항을 설명하고 Shazam Library API를 이야기해 보겠습니다 이번에 흥미로운 새 기능으로 재정의되었죠 마지막으로 몇 가지 모범 사례를 통해 ShazamKit로 더 나은 앱 경험 만드는 법을 알아보겠습니다 시작하기 전에 다운로드해 두실 게 있는데요 개발자 포털에 있는 샘플 코드 프로젝트입니다 이 프로젝트는 본 영상 전체에서 사용될 것입니다 다룰 내용이 많으므로 바로 시작하겠습니다
첫 번째는 오디오 인식입니다 마이크로 들어온 오디오를 ShazamKit로 인식하는 과정은 다음과 같은 단계로 요약할 수 있습니다 먼저 사용자에게 마이크 권한을 요청하고 권한을 부여받아 녹음을 시작한 뒤 녹음된 오디오 버퍼를 ShazamKit에 전달하고 마지막으로 결과를 처리하게 됩니다 시연을 위해 샘플 프로젝트에 있는 데모 앱을 만들었습니다 저는 춤을 좋아해서 최신 트렌드를 따라잡기 위해 노래에 맞는 유행하는 댄스 동작을 찾아주는 앱을 만들었습니다 이 앱은 마이크를 사용하여 오디오를 듣고 댄스 영상을 찾는 방식으로 작동합니다 예를 들어 Siri에게 노래를 찾아달라고 요청할 수 있습니다 Siri야 듀크스의 'Push It' 재생해 줘
듀크스의 'Push It' 재생합니다 이제 댄스 배우기 버튼을 탭해서 녹음을 시작할 수 있습니다 ♪ ♪ ShazamKit가 노래를 인식하고 노래에 어울리는 댄스 비디오를 검색합니다 하나 찾은 것 같네요, 흠! 제 쌍둥이 댄싱 데이브가 몇 가지 동작을 보여주네요 흥미로워 보입니다 어떻게 구현한 걸까요? 코드를 보여 드리겠습니다 Xcode에서 샘플 프로젝트를 열었습니다 info.plist 파일에 마이크 사용 설명을 추가했습니다 마이크 액세스를 요청하는 데 사용되죠 또한 여러 가지 SwiftUI 뷰도 있죠 홈 화면과 댄스 비디오 화면에 대해서요 그러나 이 Matcher라는 클래스에서 오디오 인식의 모든 마법이 일어납니다
초기화 시에는 오디오 엔진을 구성하고 설정하는 메서드가 있는데 이 메서드에서는 탭을 설치해서 PCM버퍼를 수신하고 오디오 엔진을 준비하게 했습니다 댄스 배우기 버튼을 탭하면 호출되는 match 메서드도 있습니다 녹음 권한을 요청하고 이것이 허가되면 audioEngine.start를 호출하여 녹음을 시작합니다 그런 다음 UI에 매치가 시작되었음을 알리며 session.results를 호출하고 매치 결과의 비동기 시퀀스를 기다립니다 결과를 받은 후 매치하는 경우에는 매치 객체를 설정하고 매치하지 않는 경우와 오류도 처리합니다 이 클래스에는 stopRecording 함수도 있습니다 오디오 엔진을 중지하는 함수죠
이것은 훌륭하게 작동하지만 설정 코드가 많다는 걸 알아두세요 오디오 버퍼를 받기 전에 오디오 엔진을 설정하기 위해서죠 올바르게 설정하기 어려울 수 있습니다 특히 오디오 프로그래밍에 익숙하지 않다면요 그래서 녹음과 매치를 더 쉽게 하기 위해 SHManagedSession이라는 새로운 API를 도입했습니다 ManagedSession은 자동으로 녹음 시작을 처리하며 오디오 버퍼를 설정하는 번거로움이 없습니다 설정 및 사용이 매우 쉽습니다 ManagedSession을 사용하려면 마이크 권한이 필요합니다 이 권한이 없으면 세션이 녹음을 시작할 수 없죠 따라서 마이크 사용 설명을 추가하는 것이 중요합니다 앱의 info.plist 파일에요 ManagedSession은 이 설명을 써서 사용자에게 마이크 액세스를 요청합니다 그렇다면 코드에서 이 API를 어떻게 쓸 수 있을까요? 먼저 SHManagedSession의 인스턴스를 생성한 다음 결과 메서드를 호출하여 결과를 기다릴 수 있습니다 이 메서드의 반환값은 열거형이며 세 가지 상태가 있는데요 Match, NoMatch, Error입니다 다음으로 Match인 경우 반환된 미디어 항목을 사용하여 결과를 전환하고 NoMatch나 Error인 경우를 처리합니다 더 긴 녹음 세션을 원한다면 어떻게 해야 할까요? 시간이 지남에 따라 많은 결과를 반환할 수 있게요 비동기 시퀀스 결과 프로퍼티를 사용하면 가능하죠 ManagedSession에서요 이전과 마찬가지로 시퀀스에서 수신한 각 결과를 사용할 수 있어 오디오를 오랫동안 계속 녹음할 수 있습니다 마지막으로 매치를 중지할 때는 managedSession.cancel()을 호출합니다 이렇게 하면 현재 실행 중인 모든 매치 시도가 취소되고 녹음이 중지됩니다 이게 다입니다 ManagedSession을 사용하면 불과 몇 줄의 코드만으로 녹음을 시작하고 매치 후 결과를 받을 수 있습니다 제 앱으로 돌아가서 Matcher 구현을 업데이트하죠 ManagedSession을 사용해서요 SHSession의 모든 인스턴스를 SHManagedSession으로 대체합니다
그런 다음 configureAudioEngine 메서드를 삭제할 수 있습니다
그리고 match 메서드에서 녹음 허가를 요청하고 오디오 엔진을 시작하는 호출을 삭제합니다
마지막으로 stopRecording 메서드에서 오디오 엔진을 중지하는 기존 코드를 managedSession.cancel 메서드를 호출하는 것만으로 대체할 수 있죠
이제 앱을 실행하여 모든 것이 여전히 예상대로 작동하는지 확인하겠습니다 Siri야, 듀크스의 'Push It' 재생해 줘
듀크스의 'Push It'입니다 ♪ ♪ 신나네요! 모든 것이 여전히 정상적으로 작동하지만 ManagedSession을 사용하여 코드가 개선되고 더 깔끔해졌네요 이뿐만 아니라 ManagedSession에는 더 많은 기능이 있습니다 사용 예에 따라 ManagedSession을 사용하여 매치 시도를 미리 준비할 수 있습니다 ManagedSession을 준비해 두면 매치 때 세션의 응답성이 향상되고 매치에 필요한 리소스도 미리 할당되며 매치 시도를 예상하여 사전 녹음을 시작합니다
prepare를 활용할 때의 이점을 생각해 보시도록 prepare()를 호출하지 않은 세션의 동작을 나타내는 타임라인을 보여 드리죠 result()를 요청하면 세션은 매치 시도에 필요한 리소스를 할당하고 녹음을 시작한 다음 최종적으로 매치를 반환합니다 그러나 prepare()를 호출하면 세션이 즉시 리소스를 사전 할당 하고 사전 녹음을 시작한 다음 result()를 요청하면 세션이 이전보다 빠르게 매치하는 항목을 반환합니다 코드에서 이 작업을 수행하려면 prepare 메서드만 호출하면 되죠 결과를 요청하기 전에요 이 메서드를 호출하는 것은 전적으로 여러분의 몫이며 필요한 경우 ShazamKit가 사용자를 대신해 호출합니다
이런 게 궁금하실 수도 있겠죠 '세션의 현재 동작을 추적하려면 어떻게 해야 할까?' '예를 들어 오래 실행되는 세션에서' '어떻게 하면 세션이 녹음 중인지 매치 중인지, 다른 작업 중인지' '알 수 있을까?' 이를 돕기 위해 ManagedSession엔 state라는 프로퍼티가 있습니다 세션의 현재 상태를 나타내죠 이 state에는 Idle Prerecoding, Matching 이렇게 세 가지가 있습니다 Idle 상태에서는 세션이 녹음 또는 매치 시도를 하지 않습니다 세션이 방금 단일 매치 시도를 완료했거나 취소를 호출한 경우 또는 세션이 다중 매치를 수행하며 비동기 결과 시퀀스를 종료한 경우 등에 그렇죠 Prerecording은 세션이 준비된 후의 상태를 나타냅니다 이 상태에서는 매치에 필요한 모든 리소스가 준비되고 세션이 매치 시도를 위해 사전 녹음 중입니다 여기서는 매치를 진행하거나 사전 녹음을 취소할 수 있습니다 세 번째로 가능한 상태는 Matching으로 세션이 하나 이상의 매치 시도 중임을 나타냅니다 이 상태에서 prepare 호출은 세션에서 무시됩니다 다음은 ManagedSession 상태를 SwiftUI에서 사용하여 뷰 동작을 수행하는 방법에 대한 예제입니다 데모 앱의 하위 뷰를 샘플로 구현해 본 것입니다 이 뷰에 대해 다른 동작을 구현했는데요 상태가 Idle 상태인지 Matching인지에 따라 다릅니다 현재 세션의 상태는 Idle이고 텍스트 뷰는 '음악 듣기'로 설정되어 있습니다 또한 상태가 Matching인지 여부를 확인하는 조건이 있습니다 Mathing이면 진행률 보기를 표시하고 Matching이 아니면 댄스 배우기 버튼을 표시합니다 현재 상태가 Idle이므로 댄스 배우기 버튼이 표시되고 버튼을 탭하면 상태가 Matching으로 변경되고 UI가 자동으로 새로 고침 됩니다 이번에는 텍스트가 Matching으로 되어 있고 Matching이 시작되었으므로 진행률 보기가 버튼을 대체합니다 세션의 상태가 변경될 때마다 SwiftUI가 자동으로 뷰를 새로 고침 하여 별도의 작업 없이 변경 사항에 대응합니다 ManagedSession이 Observable이기 때문이죠 이는 새로운 Swift 유형으로 객체가 자동으로 변경 사항을 Observer에게 전달합니다 따라서 SwiftUI가 ManagedSession의 모든 상태 변화에 쉽게 대응할 수 있습니다 Observable을 자세히 알아보려면 'SwiftUI의 Observation 알아보기' 영상을 참고하세요 이제 오디오 인식에 대해 다뤘으니 Shazam 라이브러리를 이야기해 보겠습니다
2021년에 ShazamKit는 개발자가 매치 결과를 Shazam 라이브러리에 쓸 수 있는 API를 제공했죠 매치 결과에 유효한 Shamzam ID가 있다면요 즉, Shazam 카탈로그에 들어 있는 노래라는 거죠 추가된 항목은 Control Center 음악 인식 모듈에 나타납니다 Shazam 앱이 설치된 경우 앱에서도 보이죠 기기 간에도 동기화됩니다 Shazam 라이브러리에 쓰는 데 특별한 권한이 필요한 것은 아니지만 고객에게 알리지 않고 콘텐츠를 저장하지는 마세요 라이브러리에 저장된 모든 노래는 해당 노래를 추가한 앱에 귀속되니까요 여기서 목록의 두 번째 노래는 ShazamKit 댄스 파인더 앱에 귀속됩니다
수년에 걸쳐 이 API를 사용하면서 다양한 사용 예가 나타났고 몇 가지 단점이 드러났습니다 예를 들어, 자신의 앱에서 추가한 항목을 보려면 어떻게 해야 하죠? 가장 좋은 해결책은 로컬 스토리지를 관리하는 것인데 이는 처리하기 지난한 작업이며 버그가 발생하기 쉽죠 이러한 단점으로 인해 SHLibrary라는 새로운 클래스가 도입되었습니다 SHLibrary를 채택하시기 바랍니다 이전 SHMediaLibrary 클래스에 비해서 더 광범위한 기능을 제공하거든요 SHLibrary의 핵심 기능 중 일부 즉, Shazam 라이브러리에 미디어 항목 추가 같은 것은 SHMediaLibrary의 해당 방법과 동일하게 작동하죠 미디어 항목 읽기 라이브러리에서 미디어 항목 삭제도 그렇습니다 앱은 라이브러리에 추가한 항목만 읽고 삭제할 수 있으며 읽을 때 반환되는 항목은 앱에만 해당하며 전체 라이브러리를 나타내지 않습니다 또한 앱에서 추가하지 않은 미디어 항목을 삭제하려고 시도하면 오류가 발생합니다 이어서 SHLibrary 사용 방법을 설명하겠습니다
SHLibrary를 추가하려면 간단히 addItems 메서드를 호출하면 되죠 기본 라이브러리 객체에서요 이 메서드는 추가할 미디어 항목의 배열을 가져옵니다 라이브러리에서 읽는 것도 마찬가지로 간단합니다 예를 들어 라이브러리에서 항목을 읽고 SwiftUI에서 목록 뷰를 채우는 방법은 다음과 같습니다 라이브러리 객체의 프로퍼티를 목록 이니셜라이저에 전달하기만 하면 됩니다 SHLibrary는 또한 Swift Observable 유형이므로 변경 사항이 있으면 SwiftUI 뷰가 자동으로 다시 로드됩니다 UI가 아닌 콘텍스트의 라이브러리에서 읽을 수도 있죠 예를 들어, 동기화된 Shazam에서 사용자의 가장 인기 있는 장르를 검색하려면 라이브러리의 현재 항목을 요청할 수 있습니다 이 항목이 있으면 항목 배열을 필터링하여 반환된 모든 장르를 가져오고 가장 빈도가 높은 장르를 계산할 수 있습니다 마지막으로, 항목을 제거할 때는 SHLibrary.default.removeItems를 호출해서 제거할 미디어 항목 배열을 전달하면 됩니다 제 앱으로 돌아가 보죠 인식된 노래를 라이브러리에 추가했으므로 새 SHLibrary를 사용하여 그 노래들을 읽을 수 있습니다 RecentDancesView에는 빈 meadiaItems 배열이 포함된 목록이 이니셜라이저에 있죠 빈 배열을 SHLibrary의 항목으로 교체하여 라이브러리 항목을 자동으로 읽습니다
이러한 변경 사항을 적용하여 앱을 실행하면
앱이 로드되자마자 앱이 Shazam 라이브러리에 추가한 노래 목록이 표시됩니다 SHLibrary를 사용하면 이 기능을 무료로 사용할 수 있고 매치된 노래 데이터베이스를 유지할 필요가 없습니다 다음으로, 각 행에 '쓸어넘겨 삭제' 동작을 추가하여 라이브러리에서 노래를 삭제할 수 있습니다
행 뷰에 swipeAction을 추가합니다
쓸어넘기기 버튼이 탭되면 SHLibrary.default.removeItems 메서드를 호출하여 삭제할 미디어 항목을 전달합니다
이제 이러한 변경 사항이 적용된 앱을 실행해 보겠습니다 iPad에서 앱을 열고 iPhone에서 항목을 쓸어넘긴 뒤 삭제 버튼을 누르면 변경 사항이 동기화되고 삭제된 항목도 iPad의 목록에서 제거됩니다 멋지네요 이제 새로운 라이브러리 API를 사용하는 방법과 ManagedSession을 사용하여 녹음을 처리하는 방법을 배웠죠 몇 가지 모범 사례를 살펴보고 올해 도입된 새로운 기능을 쓰는 몇 가지 팁을 제공하겠습니다 SHManagedSession과 SHSession은 밀접한 관련이 있으며 서로 다른 방식이지만 거의 동일한 작업을 수행합니다 ManagedSession은 ShazamKit로 녹음을 처리할 때 쓰시면 됩니다 SHSession은 오디오 버퍼를 만들어 프레임워크로 전달할 때 쓰세요 ManagedSession은 마이크 또는 AirPods에서 나오는 오디오를 인식할 때 쓰시고요 SHSession은 마이크에서 나오는 오디오 스트리밍만 인식할 때 사용합니다 임의의 서명을 ManagedSession과 매치하는 기능은 지원하지 않아서 서명 파일이나 메모리에 로드된 서명 데이터가 있는 경우 SHSession을 사용하여 매치시켜야 합니다 마지막으로, ManagedSession은 매치용 오디오 포맷을 자동으로 처리하죠 SHSession은 여러 PCM 오디오 포맷과 매치할 수 있습니다
SHSession의 오디오 형식은 원래 matchStreamingBuffer 메서드를 사용하여 이러한 샘플 레이트에서 특정 포맷 설정을 가진 PCM 오디오 버퍼만 매치했습니다 지원되지 않는 설정이 있는 오디오 버퍼는 NoMatch가 됐죠 이번 릴리스에서 SHSession은 PCM 버퍼를 지원하며 다양한 샘플 레이트를 가진 포맷 대부분이 가능합니다 이러한 버퍼를 전달하면 SHSession이 포맷 변환을 처리합니다 마지막으로, 유사한 사운드의 오디오 비트가 사용자 지정 카탈로그에 있는 경우 ShazamKit는 맞춤형 카탈로그의 모든 매치를 반환할 수 있습니다 여러 참조 서명과 매치되는 쿼리 서명을 전달하면요 매치되는 항목은 최상의 매치 품질에 따라 정렬되어 반환되며 원하는 적절한 매치 결과를 필터링할 수 있습니다 팁을 드리자면 각 메타데이터에서 비슷하게 들리는 참조 서명에 적절하게 주석을 달면 원하는 결과들 사이에서 구별해 낼 수 있습니다
다음은 이를 달성하는 방법을 다룬 예제입니다 모든 에피소드의 인트로 사운드가 동일한 TV 프로그램이 있다고 가정해 보겠습니다 각 에피소드를 나타내는 참조 서명이 포함된 televisionShowCatalog를 생성할 수 있습니다 이 카탈로그를 사용하여 세션을 생성할 수 있고 인트로 섹션을 매치할 때 ShazamKit는 각 에피소드의 미디어 항목이 포함된 매치 결과를 반환합니다 그런 다음 mediaItems를 필터링하여 특정 에피소드의 medaiItems만 반환할 수 있습니다 예를 들면 에피소드 2라든지요 적절한 주석은 이런 식으로 도움을 줍니다
올해의 모든 흥미로운 업데이트를 살펴봤습니다 이제 제 멋진 앱으로 돌아가서 춤을 하나 더 배워 봐야겠네요 AirPods로 전환하여 노래를 재생합니다 앱에서 ManagedSession을 사용하고 있으므로 AirPods에서 재생되는 오디오를 듣고 댄스 영상을 찾을 수 있습니다 AirPods의 터치 컨트롤을 눌러 노래를 재생하고 앱이 오디오를 감지할 때까지 기다리겠습니다
멋지네요! 댄싱 데이브가 아프로비트 동작을 몇 가지 선보이는 것 같은데 이 세션이 끝나면 열심히 배워보겠습니다 여러분도 새로운 업데이트를 저희만큼 기대하고 계실 겁니다 참여해 주셔서 감사드리며 WWDC를 즐기시길 바랍니다 ♪ ♪
-
-
6:46 - Single match with SHManagedSession
let managedSession = SHManagedSession() let result = await managedSession.result() switch result { case .match(let match): print("Match found. MediaItemsCount: \(match.mediaItems.count)") case .noMatch(_): print("No match found") case .error(_, _): print("An error occurred") }
-
7:16 - Multiple matches with SHManagedSession
let managedSession = SHManagedSession() // Continuously match for await result in managedSession.results { switch result { case .match(let match): print("Match found. MediaItemsCount: \(match.mediaItems.count)") case .noMatch(_): print("No match found") case .error(_, _): print("An error occurred") } }
-
7:37 - Stop SHManagedSession
let managedSession = SHManagedSession() // Cancel the session managedSession.cancel()
-
8:02 - ShazamKit Matcher with SHManagedSession
import Foundation import ShazamKit struct MatchResult: Identifiable, Equatable { let id = UUID() let match: SHMatch? } @MainActor final class Matcher: ObservableObject { @Published var isMatching = false @Published var currentMatchResult: MatchResult? var currentMediaItem: SHMatchedMediaItem? { currentMatchResult?.match?.mediaItems.first } private let session: SHManagedSession init() { if let catalog = try? ResourcesProvider.catalog() { session = SHManagedSession(catalog: catalog) } else { session = SHManagedSession() } } func match() async { isMatching = true for await result in session.results { switch result { case .match(let match): Task { @MainActor in self.currentMatchResult = MatchResult(match: match) } case .noMatch(_): print("No match") endSession() case .error(let error, _): print("Error \(error.localizedDescription)") endSession() } stopRecording() } } func stopRecording() { session.cancel() } func endSession() { // Reset result of any previous match. isMatching = false currentMatchResult = MatchResult(match: nil) } }
-
10:07 - Preparing SHManagedSession
let managedSession = SHManagedSession() await managedSession.prepare() let result = await managedSession.result()
-
11:39 - SHManagedSession Idle State in SwiftUI
struct MatchView: View { let session: SHManagedSession var body: some View { VStack { Text(session.state == .idle ? "Hear Music?" : "Matching") if session.state == .matching { ProgressView() } else { Button { // start match } label: { Text("Learn the Dance") } } } }
-
12:25 - SHManagedSession Matching State in SwiftUI
struct MatchView: View { let session: SHManagedSession var body: some View { VStack { Text(session.state == .idle ? "Hear Music?" : "Matching") if session.state == .matching { ProgressView() } else { Button { // start match } label: { Text("Learn the Dance") } } } } }
-
15:23 - Adding with SHLibrary
func add(mediaItems: [SHMediaItem]) async throws { try await SHLibrary.default.addItems(mediaItems) }
-
15:34 - Reading with SHLibrary
struct LibraryView: View { var body: some View { List(SHLibrary.default.items) { item in MediaItemView(item: item) } } }
-
16:00 - Reading with SHLibrary in a non-UI context
// Determine a user’s most popular genre let currentItems = await SHLibrary.default.items let genres = currentItems.flatMap { $0.genres } // count frequency of genres and get the highest let mostPopularGenre = highestOccurringGenre(from: genres)
-
16:25 - SHLibrary Remove
func remove(mediaItems: [SHMediaItem]) async throws { try await SHLibrary.default.removeItems(mediaItems) }
-
16:42 - RecentDancesView with SHLibrary read and delete implementation
import SwiftUI import ShazamKit enum NavigationPath: Hashable { case nowPlayingView(videoURL: URL) case danceCompletionView } struct RecentDancesView: View { private enum ViewConstants { static let emptyStateImageName: String = "EmptyStateIcon" static let emptyStateTextTitle: String = "No Dances Yet?" static let emptyStateTextSubtitle: String = "Find some music to start learning" static let deleteSwipeViewOpacity: Double = 0.5 static let matchingStateTextTopPadding: CGFloat = 24 static let matchingStateTextBottomPadding: CGFloat = 16 static let progressViewScaleEffect: CGFloat = 1.1 static let progressViewBottomPadding: CGFloat = 12.0 static let learnDanceButtonWidth: CGFloat = 250 static let curvedTopSideRectangleHeight: CGFloat = 200 static let listRowBottomInset: CGFloat = 30.0 static let matchingStateText: String = "Get Ready..." static let notMatchingStateText: String = "Hear Music?" static let noMatchText: String = "No dance video for audio" static let navigationTitleText: String = "Recent Dances" static let learnDanceButtonText: String = "Learn the Dance" static let retryButtonText: String = "Try Again" static let cancelButtonText: String = "Cancel" } // MARK: Properties private var isListEmpty: Bool { SHLibrary.default.items.isEmpty } @State private var matchingState: String = ViewConstants.notMatchingStateText @State private var matchButtonText: String = ViewConstants.learnDanceButtonText @State private var canRetryMatchAttempt = false @State private var navigationPath: [NavigationPath] = [] // MARK: Environment @EnvironmentObject private var matcher: Matcher @Environment(\.openURL) var openURL var body: some View { NavigationStack(path: $navigationPath) { ZStack(alignment: .bottom) { List(SHLibrary.default.items, id: \.self) { mediaItem in RecentDanceRowView(mediaItem: mediaItem) .onTapGesture(perform: { guard let appleMusicURL = mediaItem.appleMusicURL else { return } openURL(appleMusicURL) }) .swipeActions { Button { Task { try? await SHLibrary.default.removeItems([mediaItem]) } } label: { Image(systemName: "trash") .symbolRenderingMode(.hierarchical) } .tint(.appPrimary.opacity(0.5)) } } .listStyle(.plain) .overlay { if isListEmpty { ContentUnavailableView { Label(ViewConstants.emptyStateTextTitle, image: ImageResource(name: ViewConstants.emptyStateImageName, bundle: Bundle.main)) .font(.title) .foregroundStyle(Color.white) } description: { Text(ViewConstants.emptyStateTextSubtitle) .foregroundStyle(Color.white) } } } .safeAreaInset(edge: .bottom, spacing: ViewConstants.listRowBottomInset) { ZStack(alignment: .top) { CurvedTopSideRectangle() VStack { Text(matchingState) .font(.body) .foregroundStyle(.white) .padding(.top, ViewConstants.matchingStateTextTopPadding) .padding(.bottom, ViewConstants.matchingStateTextBottomPadding) if matcher.isMatching { ProgressView() .progressViewStyle(.circular) .tint(.appPrimary) .scaleEffect(x: ViewConstants.progressViewScaleEffect, y: ViewConstants.progressViewScaleEffect) .padding(.bottom, ViewConstants.progressViewBottomPadding) Button(ViewConstants.cancelButtonText) { canRetryMatchAttempt = false matcher.stopRecording() matcher.endSession() } .foregroundStyle(Color.appPrimary) .font(.subheadline) .fontWeight(.semibold) } else { Button { Task { await matcher.match() } matchingState = ViewConstants.matchingStateText canRetryMatchAttempt = true } label: { Text(matchButtonText) .foregroundStyle(.black) .font(.title3) .fontWeight(.heavy) .frame(maxWidth: .infinity) } .frame(width: ViewConstants.learnDanceButtonWidth) .padding() .background(Color.appPrimary) .clipShape(Capsule()) } } } .edgesIgnoringSafeArea(.bottom) .frame(height: ViewConstants.curvedTopSideRectangleHeight) } } .background(Color.appSecondary) .navigationTitle(isListEmpty ? "" : ViewConstants.navigationTitleText) .preferredColorScheme(.dark) .toolbarColorScheme(.dark, for: .navigationBar) .navigationBarTitleDisplayMode(.large) .toolbarBackground(Color.appSecondary, for: .navigationBar) .frame(maxHeight: .infinity) .onChange(of: matcher.currentMatchResult, { _, result in guard navigationPath.isEmpty else { print("Dance video already displayed") return } guard let match = result?.match, let url = ResourcesProvider.videoURL(forFilename: match.mediaItems.first?.videoTitle ?? "") else { matchingState = canRetryMatchAttempt ? ViewConstants.noMatchText : ViewConstants.notMatchingStateText matchButtonText = canRetryMatchAttempt ? ViewConstants.retryButtonText : ViewConstants.learnDanceButtonText return } canRetryMatchAttempt = false // Add the video playing view to the navigation stack. navigationPath.append(.nowPlayingView(videoURL: url)) }) .navigationDestination(for: NavigationPath.self, destination: { newNavigationPath in switch newNavigationPath { case .nowPlayingView(let videoURL): NowPlayingView(navigationPath: $navigationPath, nowPlayingViewModel: NowPlayingViewModel(player: AVPlayer(url: videoURL))) case .danceCompletionView: DanceCompletionView(navigationPath: $navigationPath) } }) .onAppear { if AVAudioSession.sharedInstance().category != .ambient { Task.detached { try? AVAudioSession.sharedInstance().setCategory(.ambient) } } matchingState = ViewConstants.notMatchingStateText matchButtonText = ViewConstants.learnDanceButtonText } } } }
-
20:23 - Filtering for specific media items
func match(from televisionShowCatalog: SHCustomCatalog) async -> [SHMatchedMediaItem] { let managedSession = SHManagedSession(catalog: televisionShowCatalog) let result = await managedSession.result() if case .match(let match) = result { // filter for only media items related to a particular episode let filteredMediaItems = match.mediaItems.filter { $0.title == "Episode 2" } return filteredMediaItems } return [] }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.