大多数浏览器和
Developer App 均支持流媒体播放。
-
打造出色的 ShazamKit 体验
了解你的 App 如何通过使用 ShazamKit 的最新更新提供更加出色的音频匹配体验。我们将为你介绍匹配功能、音频识别更新以及与 Shazam 资料库的交互。了解在音频 App 中使用 ShazamKit 的技巧和最佳实践。想要了解更多有关 ShazamKit 的内容,欢迎观看 WWDC22 中的“使用 ShazamKit 创建大规模自定义目录”以及 WWDC21 中的“探索 ShazamKit”和“使用 ShazamKit 打造自定义音频体验”。
资源
相关视频
WWDC22
WWDC21
-
下载
♪ ♪
David:大家好 我是 David Ilenwabor 一名 ShazamKit 团队的工程师
ShazamKit 是一个框架 可以让你在 App 中实现音频识别 你可以将音频与 Shazam 中 庞大的音乐目录进行匹配 也可以使用自定义目录 与 预先录制的音频进行匹配 2022 年 ShazamKit 发布了一些重大更新 并对处理大规模 自定义目录进行了改进 我们引入了 Shazam CLI 用于处理繁重的工作流程 如使用自定义目录 使用限制时间的媒体项 实现更好的同步 以及使用频率偏移来区分 两个听起来相似的音频片段 如果你还不熟悉该框架的工作原理 欢迎观看“使用 ShazamKit 创建大规模自定义目录”讲座 但我们还是来简单回顾一下 ShazamKit 可以让你将音频转换为 名为 Signatures 的特殊格式 从而进行匹配 你可以将音频流缓冲区或 签名数据传递给 ShazamKit 会话 接着 该会话就会使用签名数据 在 Shazam 目录或自定义目录中 查找匹配项 如果存在匹配项 该会话便会返回一个包含 媒体项目的匹配对象 并且该项目表示匹配项的元数据 然后 你便可以在 App 中 播放这些媒体项目 ShazamKit 可以通过 从音频流缓冲区生成签名 或使用存储在磁盘上的签名文件 来进行匹配 由于签名是不可逆的 因此无法从签名中 重新构建原始录音 从而保护了客户的隐私 目录是一组与媒体项目关联的签名 当查询签名与目录中的 参考签名的一部分 足够匹配时 匹配便发生了 即使查询签名位于嘈杂的环境中 例如餐厅中播放的音乐 也有机会完成匹配 介绍完这些内容后 接下来 我来谈谈 ShazamKit 在今年发布的令人兴奋的更新 在本次讲座中 首先 我会介绍使用 ShazamKit 识别音频的新变化 接着 我会来谈谈 Shazam 资料库 API 该 API 经过重新定义 具备了令人兴奋的新功能 最后 我还会带你 了解一些最佳实践 其中我们可以使用 ShazamKit 打造出更棒的 App 体验 在讲座开始之前 我建议你 从开发者门户中 下载附带的示例代码项目 因为我在整部视频中 都会使用该项目 需要介绍的内容很多 我们这就开始吧
首先 我先来介绍一下音频识别 使用 ShazamKit 来识别来自麦克风音频的过程 可以概括为以下几步 首先 向用户请求麦克风权限 然后 在获得权限后开始录制 接着 将录制的音频缓冲区 传递给 ShazamKit 最后 处理结果 为了演示这一过程 我编译了一个 演示 App 你可以在示例项目中找到该 App 我喜欢跳舞 为了跟上最新的潮流 我编译了一款 App 来帮我 发现与音乐有关的流行舞蹈动作 该 App 通过使用麦克风监听音频 从而找到舞蹈视频 例如 我可以让 Siri 帮我找首歌 嘿 Siri 播放 Dukes 的《Push It》
Siri:现在正在播放 Dukes 的《Push It》 David:接着 我可以轻点 学习舞蹈按钮来进行录制 ♪ ♪ ShazamKit 会识别这首歌 然后 App 便会搜索与之相似的 合适的舞蹈视频 似乎已经找到一个了 看上去我的兄弟 Dancing Dave 正在教我一些舞步 这真是棒极了 那么这是如何实现的呢? 接下来 我来带你了解一下代码 我在 Xcode 中打开了示例项目 并已经在 info.plist 文件中 添加了麦克风使用描述文件 来请求麦克风访问权限 同时还为主屏幕和舞蹈视频屏幕 添加了许多 SwiftUI 视图 但是 Matcher 类 才是音频识别的关键
在初始化时 我使用了一个方法来 配置和设置音频引擎 在该方法中 我安装了一个监听器来 接收 PCM 缓冲区 并准备好音频引擎 此外 我还有一个 match 方法 可以在轻点学习舞蹈按钮时进行调用 我会请求录制权限 如果获得该权限 我就会调用 audioEngine 中的 start 来开始录制 接着 我会告诉 UI 匹配已经开始了 然后调用 session.results 等待匹配结果的异步序列 在收到结果后 如果有匹配项 我将其设置为 match 对象 如果没有 处理无匹配和出错的情况 该类还有一个 stopRecording 函数 在该函数中我可以关闭音频引擎
该函数效果很好 但请注意 在我接收到音频缓冲区之前 我需要进行大量的代码设置来 配置音频引擎 做对这一部分非常困难 尤其如果你对音频编程不熟悉的话 所以 为了让录制和匹配过程 更加简单 我们需要引入一个新 API 名为 SHManagedSession Managed Session 会自动处理录制的开始阶段 因此为你免去了 设置音频缓冲区的麻烦 这让设置和使用该 API 变得非常简单 使用 Managed Session 需要麦克风权限 如果没有此权限 会话便无法开始录制 因此 将麦克风使用描述文件 添加到 App 的 info.plist 文件 尤为重要 Managed Session 会在向用户请求麦克风权限时 使用该描述文件 那么 如何在代码中使用该 API 呢? 首先 创建一个 SHManagedSession 实例 然后调用 result 方法 来等待结果 该方法会返回一个枚举 其中包含 3 个状态 匹配、无匹配和出错 接着 在匹配的情况下 我会使用返回的媒体项切换结果 并处理无匹配和出错的情况 如果我希望进行更长的录制会话 并随时间进行返回多个结果 那我应该怎么做呢? 为了实现这一点 我可以使用 managedSession 中的异步序列结果属性 接着和以前一样 使用接收序列中的每个结果 这样我就可以长时间地录制音频了 最后 在 managedSession 上调用 cancel 我便可以停止匹配 并且 这将取消当前运行的 所有匹配尝试 并停止录制 以上便是这部分的内容 借助 Managed Session 仅需几行代码 便可以开始录制并在匹配后获取结果 回到 App 接下来我将更新 Matcher 的实现 以便可以使用 managedSession 使用 SHManagedSession 替换 SHSession 的所有实例
接着 删除 configureAudioEngine 方法及其使用
并且 在 match 方法中删除 请求录制权限 和启动音频引擎的调用
最后 在 stopRecording 方法中 只需调用 managedSession 中的 cancel 方法 替换现有的代码 即可关闭音频引擎
现在 我来运行一下 App 确保一切都按预期进行 嘿 Siri 播放 Dukes 的《Push It》
Siri:这首是 Dukes 的《Push It》 ♪ ♪ 这也太棒了! 一切仍然运行得很棒 但是这次使用了Managed Session 的代码更好且更清晰 但到这还没有结束 Managed Session 中还更多可以讨论的内容 根据你的用例 你可能还希望使用 managedSession 来提前为匹配尝试做好准备 对 Managed Session 进行准备 能让会话会在匹配过程中响应更快 同时 其还可以为匹配 预先分配必要的资源 并开始预录制等待匹配尝试
为了让你了解使用准备的好处 这个时间线表示了 在没有调用 prepare 的情况下会话的行为 当你请求结果时 会话会为匹配尝试分配资源 接着开始录制 最后返回一个匹配项 但当你调用 prepare 时 会话会立即预先分配资源 并开始预录制 然后在你请求结果时 会话会比之前更快地返回匹配项 为了在代码中实现该过程 只需在请求结果前 调用 prepare 方法即可 是否调用该方法完全取决于你 并且 ShazamKit 会在必要时 以你的名义调用该方法
现在 你可能想知道 “如何跟踪会话的当前行为?” “例如 在一个长时间运行的会话中 我怎么知道其是在录制 还是在匹配 还是在做其他事情?” 为了解决这个问题 Managed Session 提供了一个名为 state 的属性 来表示会话当前的状态 这 3 种状态分别为空闲、 预录制和匹配 空闲状态下 该会话不会进行录制 也不会进行匹配尝试 如果会话刚刚完成了一次匹配尝试 或是你调用了 cancel 又或是会话在执行多个匹配时 终止了异步结果序列 这些状态均为空闲状态 预录制表示会话准备就绪的状态 在该状态下 所有用于匹配的 必要资源都已得到准备 并且该会话会进行预录制 以等待匹配尝试 接着 你便可以继续进行匹配 或取消预录制 匹配是第三种可能的状态 其表示会话正在 进行至少一个匹配尝试 在这种状态下调用 prepare 会被会话忽略 这个示例展示了如何在 SwiftUI 中 使用 managedSession 的状态 来驱动视图行为 这里是 Sample App 中 一个子视图的示例实现 我已经在空闲和匹配的会话状态下 对该视图实现了不同的行为 当前会话状态为空闲 文本视图便设置为聆听音乐 此外 我还添加了一个条件判断 来检查状态是否为匹配 如果是 我就会呈现进度视图 如果不是 我则会 呈现学习舞蹈按钮 由于当前状态为空闲 便会出现学习舞蹈按钮 轻点该按钮 状态就会变为匹配 并且 UI 也会自动刷新 这一次 文本被设置为匹配 进度视图取代了按钮 因为匹配已经开始了 每当会话的状态变化 SwiftUI 都会自动刷新视图 来响应这些变化 而无需任何额外的操作 这是因为 managedSession 遵循 Observable Observable 是 一个新的 Swift 类型 可以让对象自动 向观察者传达自身的变化 因此 SwiftUI 可以轻松响应 managedSession 的任何状态变化 想进一步了解更多 有关 Observable 的有关信息 欢迎观看“探索 SwiftUI 中的 Obervation”讲座 刚刚我介绍了音频识别 现在我再来谈谈 Shazam 资料库
2021 年 ShazamKit 推出了 一个 API 允许开发者在匹配结果 具有有效的 Shazam ID 时 将匹配结果写入 Shazam 资料库中 这就表示该匹配结果对应于 Shazam 目录中的一首歌曲 添加项目在控制中心的音乐识别模块 以及 Shazam App 中可见 前提是你已安装了 Shazam App 并且 该 API 还可以在设备间同步 虽然写入 Shazam 资料库 不需要特殊权限 但我还是建议你 在未告知 客户的情况下避免向其中存储内容 因为保存在资料库中的所有歌曲 都归添加该内容的 App 所有 列表中第二首歌曲 便归 ShazamKit Dance Finder App 所有
在过去这些年里 该 API 的使用呈现了许多用例 但也出现了一些问题 例如 如果你想查看此前添加在 自己 App 中的项目该怎么办? 解决该问题的常规方案是 管理自己的本地存储 但这样做会很繁琐并且很容易出错 由于存在这些缺点 我们引入了一个 名为 SHLibrary 的新类 我建议你采用 SHLibrary 因为该类与此前的 SHMediaLibrary 类相比 可以提供更多的扩展功能 SHLibrary 的一些核心功能包括 将媒体项目添加到 Shazam 资料库 其作用和 SHMediaLibrary 中对应方法的完全相同; 读取媒体项目; 以及从资料库中删除媒体项目 值得注意的是 你的 App 只能读取和 删除它添加到资料库中的内容 并且 读取时返回的项目 只特定于你的 App 而不能代表整个资料库 此外 删除你 App 未曾添加的 媒体项目还会引发错误 接下来 我来解释一下 如何使用 SHLibrary
使用 SHLibrary 十分简单 只需调用默认资料库对象的 addItems 方法即可 并且 该方法包含了 要添加的媒体项目数组 从资料库中读取同样也很简单 接下来的示例将会向你展示 从资料库中读取项目以及在 SwiftUI 中 填充 List 视图的方式 只需要将资料库对象的 item 属性 传递给 List 初始化程序即可 SHLibrary 还遵循新的 Swift Observable 类型 因此 当发生更改时 SwiftUI 视图就会自动重新加载 你还可以在非 UI 环境中 从资料库中读取内容 例如 如果我想从 同步的 Shazam 中 对用户最喜欢的流派进行检索 那么我就可以 请求资料库的当前项目 接着 有了项目后 我就可以通过过滤项目数组 来获取所有返回的流派 接着按照最高频率 对流派进行计数即可 最后 调用资料库对象的 removeItems 传入要删除的媒体项目数组 便可以从资料库中删除项目 回到我的 App 中 由于我已经将识别的歌曲 添加到了资料库中 我可以使用新的 SHLibrary 来读取这些歌曲 在 RecentDancesView 中 我有一个 List 其中包含了初始化程序中 一个空的 mediaItems 数组 我会使用 SHLibrary 中的 项目替代该空数组 以自动读取我的资料库文件
接着 我会运行经过这些更改的 App
在 App 加载后 我便会收到 App 此前添加到 Shazam 资料库中的歌曲列表 借助 SHLibrary 我可以免费获取该功能 而无需维护匹配歌曲的数据库 接下来 我会在每一行上添加 轻扫以删除的操作 从而我可以从资料库中删除歌曲
在行视图上添加 swipeAction
接着在轻扫按钮被轻点时 调用 SHLibrary 中的 removeItems 方法 传入要被删除的媒体项目
这样便完成了 接着我来运行一下 经过这些更改的 App 同时 我也在 iPad 上打开了该 App 我在 iPhone 上轻扫项目 然后轻点删除按钮 接着更改便会同步进行 并且删除的项目 也从 iPad 的列表中删除 这真的太棒了 现在你就已经了解了 如何使用新的资料库 API 以及如何利用 Managed Session 来处理录制 接下来 我会向你呈现一些最佳实践 并为你使用今年引入的新功能 提供一些建议 SHmanagedSession 和 SHSession 密切相关 尽管方式不同 这两种会话都能取得同样的效果 当你希望 ShazamKit 为你处理录音时 请使用 managedSession 当你希望生成音频缓冲区 并将其传递到框架中时 请使用 SHSession 当你希望识别来自麦克风和 AirPod 的音频时 请使用 managedSession 当你只想识别来自 麦克风的音频流时 请使用 SHSession 由于不支持使用 managedSession 来匹配任意签名 因此 如果你有签名文件 或是在内存中加载的签名数据 请使用 SHSession 进行匹配 最后 managedSession 可以 自动处理用于匹配的音频格式 而 SHSession 允许使用多种 PCM 音频格式进行匹配
说到 SHSession 中的音频格式 此前的 matchStreamingBuffer 方法 只能匹配设置为图示采样率的 具有特殊格式设置的 PCM 音频缓冲区 如果音频缓冲区使用了 不受支持的设定 其便会输出 NoMatch 在这次的版本中 SHSession 现可支持大多数格式设置 且采样率更广阔的 PCM 缓冲区 你可以传递这些缓冲区 并且SHSession 会为你处理格式转换 最后 如果在你自定义目录中 存在两个及以上听起来相似的音频 现在当你传入与多个参考签名 匹配的查询签名 ShazamKit 便可以返回 自定义目录中所有的匹配项 将返回的匹配项 按最佳匹配质量排序 你便可以筛选出所需要的 恰当匹配结果 这里提醒你一下 正确注释各自元数据中 听起来相似的参考签名 可以让你区分想要的结果
这个示例向你展示了 如何实现这一点 假设我有一个电视节目 每一集都有相同的开场音效 我可以生成一个 televisionShowCatalog 其中包括代表每集的参考签名 借助该目录 我可以创建一个会话 这样 在匹配开场部分内容时 ShazamKit 便可以返回 具有每集 mediaItems 的匹配结果 接着 我可以筛选 mediaItems 并返回特定集数的 mediaItems 例如第 2 集 这便是正确注释的作用
现在 我已经将今年所有令人兴奋的 更新介绍完毕 作为总结 我将会切换回我的精彩 App 并试着学习一个舞蹈 我会切换到 AirPods 并播放歌曲 由于我在 App 中使用了 Managed Session 因此 它可以监听 AirPods 中 播放的音频 并为我寻找一个舞蹈视频 接下来 我会按下 AirPods 的 触控控制来播放歌曲 并等待 App 检测该音频
太棒了! 看起来 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 [] }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。