ストリーミングはほとんどのブラウザと
Developerアプリで視聴できます。
-
優れたShazmKit体験の実現
ShazamKitの最新アップデートを使って、優れたオーディオマッチング体験をアプリで提供する方法を紹介します。マッチング機能、音声認識に関するアップデート、Shazamライブラリとのインタラクションについて解説します。オーディオアプリでShazmKitを使用する際のヒントやベストプラクティスについて確認しましょう。ShazmKitの詳細については、WWDC22の「ShazamKitで大規模なカスタムカタログを作成する」、そしてWWDC21の「ShazamKitの詳細」と「ShazamKitによるカスタムオーディオエクスペリエンスの構築」をご確認ください。
リソース
関連ビデオ
WWDC22
WWDC21
-
ダウンロード
♪ ♪
こんにちは ShazamKit Teamの エンジニア David Ilenwaborです
ShazamKitは アプリに音声認識機能を 導入するためのフレームワークです Shazamの膨大な音楽カタログと 音声をマッチングさせたり カスタムカタログを使って 独自に 事前録音した音声をマッチング出来ます 2022年 ShazamKitにいくつかの素晴らしい アップデートが行われ 大規模なカスタム カタログでの作業効率が改善されました Shazam CLIの導入による カスタムカタログを使用する際の 高負荷ワークフローの処理や 時間に制約のある メディアアイテムのより正確な同期 類似した2つの音の断片を区別する 周波数スキューが可能になりました これらの機能に関する詳細は 「ShazamKitで大規模なカスタムカタログを 作成する」をご確認ください 簡単に概要を説明します ShazamKitは音声を シグネチャと呼ばれる 特別な形式に変換しマッチングを行います オーディオバッファのストリームや シグネチャデータを ShazamKitセッションに渡すことができます セッションはそのシグネチャを使用して Shazamカタログまたはカスタムカタログから 一致するものを探します 一致した場合 セッションは 一致したメタデータを表す メディアアイテムを含む マッチオブジェクトを返します その後 アプリで メディアアイテムを表示できます ShazamKitは オーディオバッファの ストリームからシグネチャを生成するか ディスク上に保存された シグネチャファイルを使用して マッチングを行うことができます シグネチャは不可逆的であり シグネチャから元の録音を再構築することは 出来ません これにより お客様の プライバシーが保護されます カタログは 関連するメディア アイテムを持つシグネチャのグループで マッチングはクエリシグネチャが カタログ内の 参照シグネチャの一部と十分に 一致する場合に発生します レストランで流れている音楽など クエリシグネチャに ノイズが多い状況でも マッチすることが可能です 説明はここまでにして 今年のShazamKitの アップデートについてお話します このセッションでは ShazamKitを使用した 音声認識に関する 新しい変更点について説明し エキサイティングな新機能で生まれ変わった Shazam Library APIについてお話します 最後に ShazamKitを使って より良い アプリ体験を作るための ベストプラクティスを紹介します 始める前にデベロッパポータルから 添付された サンプルコードプロジェクトを ダウンロードすることをおすすめします この動画を通して このプロジェクトを使用します 内容が盛りだくさんです さっそく始めましょう
まず 音声認識についてです ShazamKitを使って マイクから音声を認識する 手順をまとめると以下の通りになります まず ユーザーにマイクの許可を求めます 次に 許可が下りたら録音を開始します 次に 録音した音声バッファを ShazamKitに渡し 最後に結果を処理します これを試すために デモアプリが サンプルプロジェクト内にあります 私はダンスが大好きで 最新トレンドについていくために 曲に合わせた 流行のダンスを 見つけられるアプリを作りました このアプリは マイクを使って音声を聞き ダンスビデオを探してくれます たとえば Siriに曲の検索を 手伝ってもらうことができます Hey Siri Dukesの“Push It”を再生して
Siri:Dukesの“Push It”を再生します David:そして「Learn The Dance」 ボタンをタップして 録音を開始します ♪ ♪ ShazamKitは曲を認識し アプリが曲に合うダンスビデオを検索します 見つかったようです 私にそっくりなDancing Daveが ダンスを披露しています 楽しそうですね これはどのように実装したのでしょうか? コードを見てみましょう ここでは Xcodeでサンプル プロジェクトを開いています info.plistにマイクの使用に関する 説明を追加してあります これはマイクへのアクセスの 要求に使用されます また ホーム画面と ダンスビデオ画面をホストする SwiftUIビューがあります ですが このMatcherクラスが すべての音声認識の魔法の源です
初期化時に オーディオエンジンの設定と セットアップを行うメソッドがあります このメソッドでは PCMbuffersを受け取るタップを インストールし オーディオエンジンを準備します 「Learn The Dance」ボタンをタップした時 呼び出される matchメソッドも用意します 録音の許可を要求し 許可が下りたら オーディオエンジンの startを呼び出し録音を開始します 次に UIにマッチングが 開始されたことを伝え session.resultsを呼び出して マッチング結果の 非同期シーケンスを待ちます 結果を受け取った後 マッチがあれば マッチオブジェクトを設定し マッチが無い場合や エラーのケースを処理します このクラスにはstopRecording関数もあり オーディオエンジンを停止できます
この方法は素晴らしいですが オーディオバッファを受け取る前に 多くのセットアップコードで オーディオ エンジンを設定する点にご注目ください これを正しく行うのは難しいかもしれません 特に オーディオプログラミングに 慣れていない方は大変です そこで 録音とマッチングを 簡単にするために SHManagedSessionという 新APIを導入しました Managed Sessionは オーディオバッファを 設定する手間を省き 録音の開始を自動で処理します そのため セットアップも使うのも とても簡単です Managed Sessionを使うには マイクの許可が必要です この許可が無いと セッションは録音を開始できません そのため アプリのinfo.plistファイルに マイクの使用に関する説明を 追加することが重要です Managed Sessionは ユーザーにマイクの アクセスを求める際に この説明を使用します では このコードをAPIで使うには どうすれば良いのでしょうか まず SHManagedSession インスタンスを作成し その後 resultメソッドを 呼び出して結果を待ちます このメソッドは match noMatch もしくはerrorの 3つのいずれかの状態を持つ emumを返します 次に 結果をswitch文で処理し matchの場合には 返されたメディアアイテムを使い noMatchやerrorの場合も含め それぞれ処理を行います 時間の経過と共に多くの結果を返すような より長い録画セッションを持ちたい場合は どうすれば良いでしょうか それには managedSessionの async sequence results プロパティを使います シーケンスから受け取った各結果は 以前と同様に使用できます これにより 長時間の オーディオ録音が可能になります 最後に managedSessionでcancelを 呼び出すことで マッチングを停止します これにより 現在実行中のマッチングが キャンセルされ 録音も停止します これで終わりです Managed Sessionを使用すれば わずか数行のコードで 録音を開始し マッチング後に 結果を受け取ることができます アプリに戻って Matcherの実装を managedSessionを使うように更新します SHSessionのすべてのインスタンスを SHManagedSessionに置き換えられます
そして configureAudioEngineメソッドと その使用箇所を削除します
また matchメソッドでは 録音許可のリクエストと オーディオエンジン開始の 呼び出しも削除できます
最後に stopRecordingメソッドで 既存のオーディオエンジンを 停止するコードを managedSessionのcancelメソッドの 呼び出しに置きかえます
では アプリを実行して すべて期待通りに動作するかを確認します Hey Siri Dukesの“Push It”を再生して
Siri:Dukesの“Push It”を再生します ♪ ♪ やりました! すべて正常に動作しています 今回はManaged Sessionを使うことで コードが改善され すっきりしました しかし これだけではありません Managed Sessionには他にも機能があります ユースケースによっては マッチングの開始前に managedSessionを使って あらかじめ準備できるかもしれません Managed Sessionの準備を行うと マッチング時の応答性が良くなります また マッチングに必要な リソースを事前に割り当て マッチングの試行を予測して 事前に録音を開始します
prepareを使用する利点を説明するため prepareを呼び出さない場合の セッションの動作を時系列で示します resultをリクエストすると セッションはマッチ試行のための リソースを割り当て 録音を開始し 最後にマッチを返します しかし prepareを呼び出すと セッションは直ちにリソースを 事前に割り当て 事前録音を開始します その後 結果を要求すると セッションは以前よりも 早くマッチを返します これをコードで行うには prepareメソッドを 結果を要求する前に呼び出すだけです このメソッドの呼び出しは完全に任意で 必要な場合には ShazamKitが代わりに呼び出します
そこで疑問が生じます セッションの現在の状態を どうやって追跡するのでしょう? たとえば 長時間実行されるセッションで 録音中なのか マッチング中なのか 他の処理中なのか どうやれば分かるのでしょう? そのために Managed Sessionには セッションの現在の状態を表す stateというプロパティがあります stateプロパティには Idle Prerecording Matchingの3つの状態があります Idle状態では セッションは録音も マッチング試行も行っていません この状態は セッションが単一の マッチング試行を完了した直後であったり cancelを呼び出したり 複数のマッチを実行するときに 非同期の 結果シーケンスが終了した場合に該当します Prerecordingは セッションが 準備された後の状態を表します この状態では マッチングに必要な すべてのリソースが準備され セッションは マッチング試行のための 事前録音を行っています その後 マッチングを続行するか 事前録音をキャンセルできます Matchingは セッションが少なくとも 1つのマッチング試行を 行っていることを示す 3つ目の状態です この状態でprepareを呼び出しても セッションは無視します こちらは managedSessionの状態を SwiftUIビューの動作に活用した例です ここでは デモアプリのサブビューに 実装したサンプルを紹介します このビューでは セッションの状態が IdleかMatchingの場合に 異なる動作を実装しています 現在 セッションの状態はIdleであり テキストビューは Hear Musicに設定されています また 状態がMatchingかどうかを チェックする条件分岐もあります Matchingであればプログレスビューを表示し そうでない場合は 「Learn the Dance」ボタンを表示します 現在の状態はIdleのため 「Learn the Dance」ボタンが 表示されています ボタンをタップすると 状態がMatchingに変わり UIが自動的に更新されます 今回はテキストがMatchingに設定され マッチングが始まったので プログレスビューが ボタンの代わりに表示されます セッションの状態が変化するたびに SwiftUIは余分な作業無しに ビューを自動的に更新して 変化に対応します これは managedSessionが Observableに準拠しているためです これはオブジェクトの変更をオブザーバに 自動的に伝える Swiftの新しいタイプです そのため SwiftUIはmanagedSessionの 状態変化に簡単に応答できます Observableについては 「SwiftUIにおけるObservationの説明」 をご確認ください 音声認識の解説は以上です 次はShazamライブラリについてお話します
2021年 ShazamKitは 有効なShazam IDを持つデベロッパに対して マッチ結果をShazamライブラリに 書き込むことを可能にする APIを提供しました これは Shazamカタログ内の曲に 対応していることを意味します 追加されたアイテムはコントロールセンター のミュージック認識モジュールに表示され インストールされていれば Shazamアプリにも表示されます また デバイス間でも同期されます Shazamライブラリへの書き込みに 特別なパーミッションは必要ありませんが ライブラリに保存された曲はすべて 追加したアプリの属性が追加されるため お客様に知らせずにコンテンツを 保存することは避けるようお勧めします ここでは リストの2番目の曲が ShazamKit Dance Finder アプリの属性が付いています
これまでのAPIの利用により さまざまなユースケースが生まれ 欠点も判明しました たとえば みなさんのアプリで追加した アイテムを表示したい場合はどうでしょう? 一般的な解決策は 自身の ローカルストレージを管理することですが これは扱いが面倒で バグの発生源にもなります これらの欠点を解消するため SHLibraryという 新しいクラスが導入されました 以前のSHMediaLibraryクラスと 比較して より幅広い機能を提供する SHLibraryの採用をおすすめします SHLibraryの主な機能には SHMediaLibraryの対応するメソッドと 同じように動作する Shazam Libraryへの メディアアイテムの追加 メディアアイテムの読み込み ライブラリからのメディアアイテムの 削除などがあります アプリは自分でライブラリに追加したもの 以外 読み込みや削除ができません 読み取り時に返されるアイテムは みなさんのアプリに固有のものであり ライブラリ全体を表すものではありません アプリが追加していないメディアアイテムを 削除しようとすると エラーが返されます 次に SHLibraryの使い方を説明します
SHLibraryを使った追加方法は デフォルトのライブラリオブジェクトの addItemsメソッドを呼び出すだけです このメソッドは 追加する メディアアイテムの配列を渡します ライブラリからの読み込みも同様に簡単です 例として ライブラリから アイテムを読み込み SwiftUIのListビューに 表示する方法を示します ライブラリオブジェクトのitemプロパティを リストイニシャライザに渡すだけです SHLibraryもSwiftの新しいObservable タイプに準拠しているため 変更があった時に SwiftUIビューは 自動的にリロードされます また UI以外のコンテキストでも ライブラリから読み込めます たとえば ユーザーの同期されたShazamから 最も人気のある ジャンルを取得したい場合 ライブラリの現在のアイテムを要求できます これが完了したら アイテムの配列をフィルタリングして 返されたすべてのジャンルを取得し 最も頻度の高いジャンルをカウントできます 最後に ライブラリオブジェクトに対して removeItemsを呼び出し 削除するメディアアイテムの 配列を渡すことで ライブラリからアイテムを削除できます アプリに戻ります 認識された曲をライブラリに追加したので 新しいSHLibraryを使って これらの曲を読み取れます RecentDancesViewには イニシャライザ内に 空のmediaItems配列を持つ リストがあります 空の配列を SHLibraryのアイテムに置き換え 自動的にライブラリの アイテムを読み取るようにします
変更後にアプリを実行します
アプリがロードされると アプリがShazamライブラリに追加した 曲のリストが表示されます SHLibraryを使えば この機能を無料で利用でき マッチした曲のデータベースを 維持する必要もありません 次は それぞれの行に スワイプして削除するアクションを追加し ライブラリから曲を削除できるようにします
行のビューにswipeActionを追加します
そして スワイプされた ボタンがタップされた時に SHLibraryの removeItemsメソッドを呼び出し 削除するメディアアイテムを 渡すようにします
完了したら これらの変更を 加えたアプリを実行します iPadでもアプリを開いています iPhoneでアイテムをスワイプし 削除ボタンをタップします 変更は同期され 削除されたアイテムは iPad上のリストからも削除されます すごいですね 新しいライブラリAPIの使い方や Managed Sessionを利用して 録画を処理する方法を学んだところで 今年導入された新機能を使用する際の ベストプラクティスや ヒントをいくつか紹介します SHManagedSessionとSHSessionは 密接に関連しています 方法は違いますが ほぼ同じことが実現可能です ShazamKitに録音を任せたい場合は managedSessionを使います オーディオバッファを生成して フレームワークに渡す場合は SHSessionを使用します マイクやAirPodからの音声を認識する場合は managedSessionを使います マイクからオーディオストリーミングのみ 認識したい場合は SHSessionを使用します managedSessionでは 任意のシグネチャの マッチングはサポートされていません そのため シグネチャファイルや メモリに読み込まれた シグネチャデータがある場合は SHSessionを使ってマッチングします 最後に managedSsessionはマッチング用の オーディオ形式を自動的に処理しますが SHSessionでは複数のPCMオーディオ 形式でのマッチングが可能です
SHSessionのオーディオ形式といえば 以前は matchStreamingBuffer メソッドでは 特定のフォーマット設定の PCMオーディオバッファの マッチングのみ可能でした サポートされていない設定のオーディオ バッファはNoMatchとなっていました 今回のリリースでは SHSessionは 様々なレートでサンプリングされた PCMバッファのほとんどの フォーマット設定をサポートしています これらのバッファを渡すと SHSessionが フォーマット変換を行います 最後に カスタムカタログに 似たような音声が2つ以上ある場合 ShazamKitは クエリシグネチャが 複数のリファレンスシグネチャに 一致する場合 カスタムカタログから すべてのマッチを返すことができます マッチングは 一致度の高い順に ソートされて返され 必要に応じて マッチ結果を フィルタリングすることができます 似ているリファレンスシグネチャに対し それぞれのメタデータで 適切に注釈をつけることで どの結果を求めているかを区別できます
こちらでその例を紹介します たとえば 毎回同じイントロ音を流す テレビ番組があるとします 各エピソードを表す リファレンスシグネチャを含む televisionShowCatalogを 生成することができます このカタログを使用してセッションを作成し イントロ部分をマッチングすると ShazamKitは各エピソードの mediaItemsを含むマッチ結果を返します そうすれば mediaItemsをフィルタリングして 特定のエピソード たとえばエピソード2の mediaItemsのみを返すことができます 適切な注釈がどう役立つのか分かりますね
今年のエキサイティングな アップデートを一通り見て来たので 最後に私の素晴らしいアプリに戻り ダンスをもう1つ学びたいと思います AirPodsに切り替えて曲を再生します アプリは Managed Sessionを使用しているので AirPodsで再生されている音声を聞き取り ダンスビデオを探してくれます AirPodsの タッチコントロールを押して曲を再生し アプリが音声を検出するのを待ちます
お見事! Dancing Daveが アフロビートの踊りを披露しています このトークの後で 私も学んでみます 今回のアップデートに みなさんもワクワクしていただけたら幸いです ご参加ありがとうございました 最高の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 [] }
-
-
特定のトピックをお探しの場合は、上にトピックを入力すると、関連するトピックにすばやく移動できます。
クエリの送信中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。