Ver Fonte

音乐播放控制类添加注释

onecold há 4 meses atrás
pai
commit
ca22618979

+ 213 - 0
entry/src/main/ets/controller/MusicPlaybackController.ets

@@ -6,6 +6,9 @@ import { setFindPlaylist } from '../common/util/FindPlaylistStore'
 import { PlaybackCoordinator } from './PlaybackCoordinator'
 import { VideoItem } from '../viewmodel/VideoItem'
 
+/**
+ * 面向 UI 暴露的播放控制动作集合。
+ */
 export interface MusicPlaybackControllerActions {
   playOrPause: () => void | Promise<void>
   playNext: () => void | Promise<void>
@@ -14,6 +17,9 @@ export interface MusicPlaybackControllerActions {
   seekTo: (value: string, source?: string) => void | Promise<void>
 }
 
+/**
+ * 内存歌单直接播放时所需的参数集合。
+ */
 export interface InMemoryPlaylistPlaybackOptions {
   playlistId: string
   playlistName: string
@@ -23,17 +29,26 @@ export interface InMemoryPlaylistPlaybackOptions {
   playType?: number
 }
 
+/**
+ * 播放模式切换后的解析结果。
+ */
 export interface LoopModeResolution {
   playType: number
   toastText: string
 }
 
+/**
+ * AVSession 循环模式与应用内模式互转时的解析结果。
+ */
 export interface SessionLoopModeResolution {
   playType: number
   toastText: string
   reportedLoopMode: number
 }
 
+/**
+ * 进度跳转所需的输入参数。
+ */
 export interface SeekResolutionOptions {
   requestedValue: string
   activeDuration: number
@@ -42,21 +57,33 @@ export interface SeekResolutionOptions {
   isRemoteSong?: boolean
 }
 
+/**
+ * 进度跳转解析结果。
+ */
 export interface SeekResolution {
   canSeek: boolean
   seekPos: number
 }
 
+/**
+ * 队列索引移动结果。
+ */
 export interface QueueIndexResolution {
   nextIndex: number
   reachedBoundary: boolean
 }
 
+/**
+ * 目标歌曲在队列中的定位结果。
+ */
 export interface QueueSongIndexResolution {
   index: number
   shouldAppend: boolean
 }
 
+/**
+ * 播放完成时下一步动作的解析结果。
+ */
 export interface CompletionActionResolution {
   action: 'play_next' | 'replay_current' | 'stop_current' | 'random_next'
   showBoundaryToast: boolean
@@ -67,11 +94,19 @@ export type PreviousPlaybackAction = 'history_previous' | 'queue_previous'
 export type TogglePlaybackAction = 'pause' | 'resume_cast' | 'resume_local'
 export type ItemPlaybackToggleAction = 'toggle_current' | 'play_target'
 
+/**
+ * 统一播放控制入口,负责纯逻辑解析与运行时转发。
+ */
 export class MusicPlaybackController {
   private static instance?: MusicPlaybackController
   private actions?: MusicPlaybackControllerActions
   private playbackCoordinator: PlaybackCoordinator = PlaybackCoordinator.getInstance()
 
+  /**
+   * 获取播放控制器单例。
+   *
+   * @returns 全局唯一的 MusicPlaybackController 实例。
+   */
   public static getInstance(): MusicPlaybackController {
     if (!MusicPlaybackController.instance) {
       MusicPlaybackController.instance = new MusicPlaybackController()
@@ -79,6 +114,12 @@ export class MusicPlaybackController {
     return MusicPlaybackController.instance
   }
 
+  /**
+   * 计算下一个应用内循环模式及其提示文案。
+   *
+   * @param playType 当前播放模式。
+   * @returns 下一个播放模式和对应提示文案。
+   */
   public static resolveNextLoopMode(playType: number): LoopModeResolution {
     const nextType = playType >= 4 || playType < 0 ? 0 : playType + 1
     if (nextType === 1) {
@@ -96,6 +137,12 @@ export class MusicPlaybackController {
     return { playType: 0, toastText: '连续循环播放' }
   }
 
+  /**
+   * 将会话层循环模式转换为应用内部播放模式,并给出提示文案。
+   *
+   * @param mode 会话层上报的循环模式。
+   * @returns 应用内播放模式、提示文案和回传给会话层的模式值。
+   */
   public static resolveSessionLoopMode(mode: number): SessionLoopModeResolution {
     let reportedLoopMode = mode + 1
     if (reportedLoopMode >= 4) {
@@ -117,6 +164,12 @@ export class MusicPlaybackController {
     return { playType: 4, toastText: '连续播放不循环', reportedLoopMode }
   }
 
+  /**
+   * 将应用内播放模式映射为 AVSession 可识别的循环模式值。
+   *
+   * @param playType 应用内播放模式。
+   * @returns AVSession 对应的循环模式值。
+   */
   public static resolveAvSessionLoopMode(playType: number): number {
     if (playType === 1) {
       return 1
@@ -133,6 +186,12 @@ export class MusicPlaybackController {
     return 2
   }
 
+  /**
+   * 根据输入参数计算安全的跳转进度,并处理 CUE 偏移与远程歌曲限制。
+   *
+   * @param options 跳转进度解析参数。
+   * @returns 是否允许跳转以及最终跳转位置。
+   */
   public static resolveSeekPosition(options: SeekResolutionOptions): SeekResolution {
     let seekPos = Number.parseInt(options.requestedValue)
     if (Number.isNaN(seekPos)) {
@@ -164,6 +223,13 @@ export class MusicPlaybackController {
     return { canSeek: true, seekPos }
   }
 
+  /**
+   * 将百分比进度转换为实际毫秒值。
+   *
+   * @param percent 百分比进度,取值范围预期为 0 到 100。
+   * @param activeDuration 当前媒体有效时长,单位毫秒。
+   * @returns 对应的毫秒进度。
+   */
   public static resolveSeekValueFromPercent(percent: number, activeDuration: number): number {
     if (activeDuration <= 0) {
       return 0
@@ -172,6 +238,13 @@ export class MusicPlaybackController {
     return clampedPercent * (activeDuration / 100)
   }
 
+  /**
+   * 解析下一首的队列索引,并标记是否触达边界。
+   *
+   * @param currentIndex 当前播放索引。
+   * @param queueLength 队列总长度。
+   * @returns 下一首索引和边界信息。
+   */
   public static resolveNextQueueIndex(currentIndex: number, queueLength: number): QueueIndexResolution {
     if (queueLength <= 0) {
       return { nextIndex: -1, reachedBoundary: true }
@@ -182,6 +255,13 @@ export class MusicPlaybackController {
     return { nextIndex: currentIndex + 1, reachedBoundary: false }
   }
 
+  /**
+   * 解析上一首的队列索引,并标记是否触达边界。
+   *
+   * @param currentIndex 当前播放索引。
+   * @param queueLength 队列总长度。
+   * @returns 上一首索引和边界信息。
+   */
   public static resolvePreviousQueueIndex(currentIndex: number, queueLength: number): QueueIndexResolution {
     if (queueLength <= 0) {
       return { nextIndex: -1, reachedBoundary: true }
@@ -192,6 +272,13 @@ export class MusicPlaybackController {
     return { nextIndex: currentIndex - 1, reachedBoundary: false }
   }
 
+  /**
+   * 在现有队列中定位目标歌曲;若不存在则给出追加位置。
+   *
+   * @param queueFilePaths 当前队列内的文件路径列表。
+   * @param targetFilePath 目标歌曲文件路径。
+   * @returns 目标索引以及是否需要追加到队列尾部。
+   */
   public static resolveQueueSongIndex(queueFilePaths: string[], targetFilePath: string): QueueSongIndexResolution {
     const existingIndex = queueFilePaths.findIndex((filePath: string): boolean => filePath === targetFilePath)
     if (existingIndex >= 0) {
@@ -200,6 +287,14 @@ export class MusicPlaybackController {
     return { index: queueFilePaths.length, shouldAppend: true }
   }
 
+  /**
+   * 判断当前是否应在队列尾部停止播放。
+   *
+   * @param playType 当前播放模式。
+   * @param currentIndex 当前播放索引。
+   * @param queueLength 队列总长度。
+   * @returns 是否应该在末尾停止。
+   */
   public static shouldStopAtQueueEnd(playType: number, currentIndex: number, queueLength: number): boolean {
     if (queueLength <= 0) {
       return false
@@ -207,6 +302,14 @@ export class MusicPlaybackController {
     return playType === 4 && currentIndex >= queueLength - 1
   }
 
+  /**
+   * 解析单曲播放完成后控制层应执行的动作。
+   *
+   * @param playType 当前播放模式。
+   * @param currentIndex 当前播放索引。
+   * @param queueLength 队列总长度。
+   * @returns 完成后的动作以及是否提示边界。
+   */
   public static resolveCompletionAction(playType: number, currentIndex: number,
     queueLength: number): CompletionActionResolution {
     if (playType === 1) {
@@ -224,6 +327,14 @@ export class MusicPlaybackController {
     return { action: 'play_next', showBoundaryToast: false }
   }
 
+  /**
+   * 解析“下一首”按钮应采用的动作。
+   *
+   * @param playType 当前播放模式。
+   * @param currentIndex 当前播放索引。
+   * @param queueLength 队列总长度。
+   * @returns 下一首动作类型。
+   */
   public static resolveNextPlaybackAction(playType: number, currentIndex: number, queueLength: number): NextPlaybackAction {
     if (playType === 3) {
       return 'random_play'
@@ -234,6 +345,12 @@ export class MusicPlaybackController {
     return 'advance_queue'
   }
 
+  /**
+   * 解析“上一首”按钮应采用的动作。
+   *
+   * @param playType 当前播放模式。
+   * @returns 上一首动作类型。
+   */
   public static resolvePreviousPlaybackAction(playType: number): PreviousPlaybackAction {
     if (playType === 3) {
       return 'history_previous'
@@ -241,6 +358,13 @@ export class MusicPlaybackController {
     return 'queue_previous'
   }
 
+  /**
+   * 解析播放/暂停切换时应执行的动作。
+   *
+   * @param isPlaying 当前本地播放器是否正在播放。
+   * @param isCastPlaying 当前投屏状态是否正在播放。
+   * @returns 切换动作类型。
+   */
   public static resolveTogglePlaybackAction(isPlaying: boolean, isCastPlaying: boolean): TogglePlaybackAction {
     if (isPlaying) {
       return 'pause'
@@ -251,6 +375,15 @@ export class MusicPlaybackController {
     return 'resume_local'
   }
 
+  /**
+   * 解析点击某个条目时,是切换当前歌曲状态还是直接播放目标歌曲。
+   *
+   * @param currentVideoUrl 当前播放文件路径。
+   * @param targetFilePath 目标歌曲文件路径。
+   * @param isPlaying 当前是否处于播放状态。
+   * @param allowAnyCurrentPlaying 是否允许“任意正在播放”时直接切换当前状态。
+   * @returns 条目点击后应执行的动作。
+   */
   public static resolveItemPlaybackToggleAction(currentVideoUrl: string, targetFilePath: string,
     isPlaying: boolean, allowAnyCurrentPlaying: boolean = false): ItemPlaybackToggleAction {
     if (currentVideoUrl === targetFilePath || (allowAnyCurrentPlaying && isPlaying)) {
@@ -259,6 +392,12 @@ export class MusicPlaybackController {
     return 'play_target'
   }
 
+  /**
+   * 构建发现页等内存歌单的统一播放请求对象。
+   *
+   * @param options 内存歌单播放参数。
+   * @returns 可落入待播仓库和事件总线的播放请求对象。
+   */
   public static buildInMemoryPlaylistPlayRequest(options: InMemoryPlaylistPlaybackOptions): PlaylistPlayRequest {
     const songs = options.songs ?? []
     const playlistId = options.playlistId.indexOf('find-') === 0 ? options.playlistId : `find-${options.playlistId}`
@@ -276,37 +415,84 @@ export class MusicPlaybackController {
     )
   }
 
+  /**
+   * 注册 UI 层自定义动作实现,优先覆盖协调器默认行为。
+   *
+   * @param actions 播放动作实现集合。
+   */
   public setActions(actions: MusicPlaybackControllerActions): void {
     this.actions = actions
   }
 
+  /**
+   * 清理已注册的动作实现;传入参数时仅在匹配当前实例时才清理。
+   *
+   * @param actions 可选的动作实现引用,用于避免误清理。
+   */
   public clearActions(actions?: MusicPlaybackControllerActions): void {
     if (!actions || this.actions === actions) {
       this.actions = undefined
     }
   }
 
+  /**
+   * 直接请求协调器播放单曲,不依赖页面事件宿主。
+   *
+   * @param song 待播放歌曲。
+   * @param source 调用来源,默认标记为 controller。
+   * @param playType 可选的播放模式。
+   * @returns 播放请求完成后的 Promise。
+   */
   public async playSong(song: VideoItem, source: string = 'controller', playType?: number): Promise<void> {
     await this.playbackCoordinator.playSong(song, source, playType)
   }
 
+  /**
+   * 直接请求协调器播放完整队列,不依赖页面事件宿主。
+   *
+   * @param queue 待播放队列。
+   * @param startIndex 起播索引。
+   * @param source 调用来源,默认标记为 controller。
+   * @param playType 可选的播放模式。
+   * @returns 播放请求完成后的 Promise。
+   */
   public async playQueue(queue: VideoItem[], startIndex: number, source: string = 'controller',
     playType?: number): Promise<void> {
     await this.playbackCoordinator.playQueue(queue, startIndex, source, playType)
   }
 
+  /**
+   * 直接调用协调器切换播放/暂停,绕过 UI 动作覆写。
+   *
+   * @returns 切换动作完成后的 Promise。
+   */
   public async playOrPauseDirect(): Promise<void> {
     await this.playbackCoordinator.playOrPause()
   }
 
+  /**
+   * 直接调用协调器播放下一首,绕过 UI 动作覆写。
+   *
+   * @returns 切歌动作完成后的 Promise。
+   */
   public async playNextDirect(): Promise<void> {
     await this.playbackCoordinator.playNext()
   }
 
+  /**
+   * 直接调用协调器播放上一首,绕过 UI 动作覆写。
+   *
+   * @returns 切歌动作完成后的 Promise。
+   */
   public async playPreviousDirect(): Promise<void> {
     await this.playbackCoordinator.playPrevious()
   }
 
+  /**
+   * 切换播放/暂停;若 UI 层注册了动作实现,则优先走自定义实现。
+   *
+   * @returns 切换动作完成后的 Promise。
+   */
   public async playOrPause(): Promise<void> {
     if (this.actions) {
       await this.actions.playOrPause()
@@ -315,6 +501,11 @@ export class MusicPlaybackController {
     await this.playbackCoordinator.playOrPause()
   }
 
+  /**
+   * 播放下一首;优先使用 UI 层注入的动作实现。
+   *
+   * @returns 切歌动作完成后的 Promise。
+   */
   public async playNext(): Promise<void> {
     if (this.actions) {
       await this.actions.playNext?.()
@@ -323,6 +514,11 @@ export class MusicPlaybackController {
     await this.playNextDirect()
   }
 
+  /**
+   * 播放上一首;优先使用 UI 层注入的动作实现。
+   *
+   * @returns 切歌动作完成后的 Promise。
+   */
   public async playPrevious(): Promise<void> {
     if (this.actions) {
       await this.actions.playPrevious?.()
@@ -331,10 +527,22 @@ export class MusicPlaybackController {
     await this.playPreviousDirect()
   }
 
+  /**
+   * 切换循环模式;该动作仅由 UI 层注入实现。
+   *
+   * @returns 模式切换完成后的 Promise。
+   */
   public async setLoopMode(): Promise<void> {
     await this.actions?.setLoopMode?.()
   }
 
+  /**
+   * 跳转到指定进度;优先使用 UI 层注入的动作实现。
+   *
+   * @param value 目标进度值,通常为毫秒字符串。
+   * @param source 可选的调用来源标识。
+   * @returns 跳转动作完成后的 Promise。
+   */
   public async seekTo(value: string, source?: string): Promise<void> {
     if (this.actions) {
       await this.actions.seekTo?.(value, source)
@@ -343,6 +551,11 @@ export class MusicPlaybackController {
     await this.playbackCoordinator.seekTo(value, source)
   }
 
+  /**
+   * 通过事件总线发起一组内存歌曲的统一播放,并预先写入歌单上下文。
+   *
+   * @param options 内存歌单播放参数。
+   */
   public playInMemoryPlaylist(options: InMemoryPlaylistPlaybackOptions): void {
     if (!options.songs || options.songs.length <= 0) {
       return

+ 64 - 0
entry/src/main/ets/controller/PlaybackCoordinator.ets

@@ -1,6 +1,9 @@
 import { PlaybackStateBridge } from './PlaybackStateBridge'
 import { VideoItem } from '../viewmodel/VideoItem'
 
+/**
+ * 播放运行时需要对外暴露的最小能力集合。
+ */
 export interface PlaybackRuntime {
   playQueue: (queue: VideoItem[], startIndex: number, source: string, playType?: number) => Promise<void>
   playOrPause: () => Promise<void>
@@ -9,12 +12,24 @@ export interface PlaybackRuntime {
   seekTo: (value: string, source?: string) => Promise<void>
 }
 
+/**
+ * 负责在控制层与具体播放运行时之间做转发,并维护状态回滚。
+ */
 export class PlaybackCoordinator {
   private static instance?: PlaybackCoordinator
   private runtime?: PlaybackRuntime
   private stateBridge: PlaybackStateBridge = PlaybackStateBridge.getInstance()
+
+  /**
+   * 私有化构造函数,限制通过单例入口创建协调器。
+   */
   private constructor() {}
 
+  /**
+   * 获取播放协调器单例。
+   *
+   * @returns 全局唯一的 PlaybackCoordinator 实例。
+   */
   public static getInstance(): PlaybackCoordinator {
     if (!PlaybackCoordinator.instance) {
       PlaybackCoordinator.instance = new PlaybackCoordinator()
@@ -22,16 +37,35 @@ export class PlaybackCoordinator {
     return PlaybackCoordinator.instance
   }
 
+  /**
+   * 注册当前可用的播放运行时实现。
+   *
+   * @param runtime 实际执行播放动作的运行时对象。
+   */
   public setRuntime(runtime: PlaybackRuntime): void {
     this.runtime = runtime
   }
 
+  /**
+   * 清理已注册的运行时;传入参数时仅在匹配当前实例时才清理。
+   *
+   * @param runtime 可选的运行时引用,用于避免误清理其他实例。
+   */
   public clearRuntime(runtime?: PlaybackRuntime): void {
     if (!runtime || this.runtime === runtime) {
       this.runtime = undefined
     }
   }
 
+  /**
+   * 请求运行时播放指定队列,并在失败时恢复之前的播放状态。
+   *
+   * @param queue 待播放的队列。
+   * @param startIndex 起播索引。
+   * @param source 播放请求来源标识。
+   * @param playType 可选的播放模式。
+   * @returns 播放请求完成后的 Promise。
+   */
   public async playQueue(queue: VideoItem[], startIndex: number, source: string, playType?: number): Promise<void> {
     if (!this.runtime || queue.length <= 0) {
       return
@@ -47,22 +81,52 @@ export class PlaybackCoordinator {
     }
   }
 
+  /**
+   * 以单曲形式发起播放,本质上会包装成单元素队列。
+   *
+   * @param song 待播放歌曲。
+   * @param source 播放请求来源标识。
+   * @param playType 可选的播放模式。
+   * @returns 播放请求完成后的 Promise。
+   */
   public async playSong(song: VideoItem, source: string, playType?: number): Promise<void> {
     await this.playQueue([song], 0, source, playType)
   }
 
+  /**
+   * 切换当前播放与暂停状态。
+   *
+   * @returns 切换动作完成后的 Promise。
+   */
   public async playOrPause(): Promise<void> {
     await this.runtime?.playOrPause()
   }
 
+  /**
+   * 播放队列中的下一首。
+   *
+   * @returns 切歌动作完成后的 Promise。
+   */
   public async playNext(): Promise<void> {
     await this.runtime?.playNext()
   }
 
+  /**
+   * 播放队列中的上一首。
+   *
+   * @returns 切歌动作完成后的 Promise。
+   */
   public async playPrevious(): Promise<void> {
     await this.runtime?.playPrevious()
   }
 
+  /**
+   * 跳转到指定播放位置。
+   *
+   * @param value 目标进度值,通常为毫秒字符串。
+   * @param source 可选的调用来源标识。
+   * @returns 跳转动作完成后的 Promise。
+   */
   public async seekTo(value: string, source?: string): Promise<void> {
     await this.runtime?.seekTo(value, source)
   }

+ 51 - 0
entry/src/main/ets/controller/PlaybackStateBridge.ets

@@ -1,5 +1,8 @@
 import { VideoItem } from '../viewmodel/VideoItem'
 
+/**
+ * 播放状态快照,供控制层和 UI 层共享当前播放上下文。
+ */
 export interface PlaybackSnapshot {
   queue: VideoItem[]
   currentIndex: number
@@ -9,6 +12,9 @@ export interface PlaybackSnapshot {
   durationMs: number
 }
 
+/**
+ * 负责缓存当前播放队列与播放状态,并同步到全局存储。
+ */
 export class PlaybackStateBridge {
   private static instance?: PlaybackStateBridge
   private snapshot: PlaybackSnapshot = {
@@ -20,9 +26,17 @@ export class PlaybackStateBridge {
     durationMs: 0
   }
 
+  /**
+   * 私有化构造函数,限制外部直接实例化。
+   */
   private constructor() {
   }
 
+  /**
+   * 获取播放状态桥接单例。
+   *
+   * @returns 全局唯一的 PlaybackStateBridge 实例。
+   */
   public static getInstance(): PlaybackStateBridge {
     if (!PlaybackStateBridge.instance) {
       PlaybackStateBridge.instance = new PlaybackStateBridge()
@@ -30,6 +44,9 @@ export class PlaybackStateBridge {
     return PlaybackStateBridge.instance
   }
 
+  /**
+   * 清空当前缓存的播放状态,并重置全局存储中的播放相关字段。
+   */
   public reset(): void {
     this.snapshot = this.buildSnapshot([], -1, undefined, false, 0, 0)
     AppStorage.setOrCreate('currentSong', undefined)
@@ -38,6 +55,12 @@ export class PlaybackStateBridge {
     AppStorage.setOrCreate('isPlaying', false)
   }
 
+  /**
+   * 用新的播放队列替换当前快照,并按安全索引同步当前歌曲。
+   *
+   * @param queue 新的播放队列。
+   * @param startIndex 请求开始播放的索引。
+   */
   public replaceQueue(queue: VideoItem[], startIndex: number): void {
     const safeQueue = queue.slice()
     const maxIndex = Math.max(safeQueue.length - 1, 0)
@@ -51,6 +74,13 @@ export class PlaybackStateBridge {
     AppStorage.setOrCreate('isPlaying', false)
   }
 
+  /**
+   * 更新当前播放态和进度信息,不修改队列本身。
+   *
+   * @param isPlaying 当前是否处于播放状态。
+   * @param positionMs 当前播放进度,单位毫秒。
+   * @param durationMs 当前媒体时长,单位毫秒。
+   */
   public updatePlaybackState(isPlaying: boolean, positionMs: number, durationMs: number): void {
     this.snapshot = this.buildSnapshot(
       this.snapshot.queue,
@@ -63,6 +93,11 @@ export class PlaybackStateBridge {
     AppStorage.setOrCreate('isPlaying', isPlaying)
   }
 
+  /**
+   * 用外部传入的快照恢复内部状态,并同步全局存储。
+   *
+   * @param snapshot 需要恢复的播放状态快照。
+   */
   public restoreSnapshot(snapshot: PlaybackSnapshot): void {
     const safeQueue = snapshot.queue.slice()
     this.snapshot = this.buildSnapshot(
@@ -79,6 +114,11 @@ export class PlaybackStateBridge {
     AppStorage.setOrCreate('isPlaying', snapshot.isPlaying)
   }
 
+  /**
+   * 获取当前播放状态的副本,避免外部直接修改内部引用。
+   *
+   * @returns 当前播放状态快照副本。
+   */
   public getSnapshot(): PlaybackSnapshot {
     return this.buildSnapshot(
       this.snapshot.queue,
@@ -90,6 +130,17 @@ export class PlaybackStateBridge {
     )
   }
 
+  /**
+   * 构建一个新的播放状态快照,并对队列做浅拷贝隔离。
+   *
+   * @param queue 播放队列。
+   * @param currentIndex 当前播放索引。
+   * @param currentSong 当前歌曲。
+   * @param isPlaying 是否正在播放。
+   * @param positionMs 当前进度,单位毫秒。
+   * @param durationMs 当前总时长,单位毫秒。
+   * @returns 新的播放状态快照对象。
+   */
   private buildSnapshot(queue: VideoItem[], currentIndex: number, currentSong: VideoItem | undefined,
     isPlaying: boolean, positionMs: number, durationMs: number): PlaybackSnapshot {
     return {