diff --git a/.nx/version-plans/version-plan-1786948536412.md b/.nx/version-plans/version-plan-1786948536412.md new file mode 100644 index 0000000000..3f1bbed262 --- /dev/null +++ b/.nx/version-plans/version-plan-1786948536412.md @@ -0,0 +1,5 @@ +--- +core-bundle: patch +--- + +refactor(core): 统一时间单位 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b1103c9b10..e42251262c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,4 @@ export * from "./bg-render/index.ts"; export type * from "./interfaces.ts"; export * from "./lyric-player/index.ts"; export * as spring from "./utils/spring.ts"; +export { Duration, MediaTime } from "./utils/time.ts"; diff --git a/packages/core/src/lyric-player/base/bottom-line.ts b/packages/core/src/lyric-player/base/bottom-line.ts index 58c5a64155..0b13ba2f34 100644 --- a/packages/core/src/lyric-player/base/bottom-line.ts +++ b/packages/core/src/lyric-player/base/bottom-line.ts @@ -1,5 +1,6 @@ import type { Disposable, HasElement } from "#interfaces"; import type { Spring } from "#utils/spring.ts"; +import type { Duration } from "#utils/time.ts"; /** 底栏的位移动画弹簧 */ export interface BottomLineTransforms { @@ -34,18 +35,18 @@ export interface BottomLine extends HasElement, Disposable { * @param top 底栏的 Y 坐标 * @param blur 底栏的模糊度 * @param immediate 为 true 时绕过弹簧立刻跳转至目标位置 - * @param delay 弹簧过渡的延迟,单位为秒 + * @param delay 弹簧过渡的延迟 */ setTransform( top?: number, blur?: number, immediate?: boolean, - delay?: number, + delay?: Duration, ): void; /** * 逐帧推进弹簧动画并应用样式 - * @param delta 距离上一次调用的时长,单位为秒 + * @param delta 距离上一次调用的时长 */ - update(delta?: number): void; + update(delta?: Duration): void; } diff --git a/packages/core/src/lyric-player/base/group.ts b/packages/core/src/lyric-player/base/group.ts index 907c8f2716..4fe065472c 100644 --- a/packages/core/src/lyric-player/base/group.ts +++ b/packages/core/src/lyric-player/base/group.ts @@ -1,5 +1,6 @@ import type { Disposable } from "#interfaces"; import { Spring } from "#utils/spring.ts"; +import { Duration, MediaTime } from "#utils/time.ts"; import { LyricLineRenderMode } from "./consts.ts"; import type { LyricLineBase } from "./line.ts"; @@ -19,7 +20,7 @@ export abstract class LyricLineGroupBase< public posY: Spring = new Spring(0); public bgSlideY: Spring = new Spring(-80); public top = 0; - public delay = 0; + public delay: Duration = Duration.ZERO; public isActive = false; public opacity = 1; @@ -34,14 +35,14 @@ export abstract class LyricLineGroupBase< public bgLine?: T | undefined, ) {} - get startTime(): number { + get startTime(): MediaTime { // 优化歌词时 `syncMainAndBackgroundLines` 已经把时间同步好了,直接读取主歌词的即可 // 要是用户关掉了这个优化,我们认为在这种情况下主歌词和背景人声显示不同步是符合用户预期的 - return this.mainLine.getLine().startTime; + return MediaTime.fromMillis(this.mainLine.getLine().startTime); } - get endTime(): number { - return this.mainLine.getLine().endTime; + get endTime(): MediaTime { + return MediaTime.fromMillis(this.mainLine.getLine().endTime); } onLineSizeChange(size: [number, number]): void { @@ -56,7 +57,7 @@ export abstract class LyricLineGroupBase< setTransform( top: number, immediate: boolean, - delay: number, + delay: Duration, isActive: boolean, opacity: number, blur: number, @@ -90,7 +91,7 @@ export abstract class LyricLineGroupBase< this.isUiDirty = true; } - private setLineTransformations(immediate: boolean, delay: number) { + private setLineTransformations(immediate: boolean, delay: Duration) { const enableScale = this.lyricPlayer.getEnableScale(); const isPlaying = this.lyricPlayer.getIsPlaying(); @@ -117,7 +118,7 @@ export abstract class LyricLineGroupBase< abstract get isInSight(): boolean; - update(delta: number): void { + update(delta: Duration = Duration.ZERO): void { if (this.lyricPlayer.getEnableSpring()) { const posMoving = !this.posY.arrived(); const bgMoving = !this.bgSlideY.arrived(); diff --git a/packages/core/src/lyric-player/base/index.ts b/packages/core/src/lyric-player/base/index.ts index 18bdd11c76..edf43992b0 100644 --- a/packages/core/src/lyric-player/base/index.ts +++ b/packages/core/src/lyric-player/base/index.ts @@ -8,6 +8,7 @@ import styles from "#styles/lyric-player.module.css"; import { clampPositive } from "#utils/clamp.ts"; import { areOptimizeOptionsEqual } from "#utils/optimize-lyric.ts"; import type { SpringParams } from "#utils/spring.ts"; +import { Duration, MediaTime } from "#utils/time.ts"; import type { BottomLine } from "./bottom-line.ts"; import { LayoutAlignAnchor, @@ -180,10 +181,7 @@ export abstract class LyricPlayerBase private lyricGroupIndexMap = new WeakMap(); private onPageShow = () => { this.isPageVisible = true; - this.setCurrentTime( - this.timelineController.getSnapshot().currentTime, - true, - ); + this.setCurrentTime(this.getCurrentTime(), true); }; private onPageHide = () => { this.isPageVisible = false; @@ -564,9 +562,9 @@ export abstract class LyricPlayerBase * @param isSeek 这个进度变化是否为跳转触发的 */ setCurrentTime(time: number, isSeek = false): void { - time = Math.round(time); + const mediaTime = MediaTime.round(MediaTime.fromMillis(time)); - const diff = this.timelineController.sync(time, isSeek); + const diff = this.timelineController.sync(mediaTime, isSeek); if (!diff.hasChanged) { return; @@ -646,7 +644,9 @@ export abstract class LyricPlayerBase this.buildLyricGroups(); // 对歌词组进行排序,确保滑动窗口与二分查找算法面对的时间线是严格升序的 - this.currentLyricGroups.sort((a, b) => a.startTime - b.startTime); + this.currentLyricGroups.sort((a, b) => + MediaTime.cmp(a.startTime, b.startTime), + ); for (let i = 0; i < this.currentLyricGroups.length; i++) { this.lyricGroupIndexMap.set(this.currentLyricGroups[i], i); } @@ -689,7 +689,9 @@ export abstract class LyricPlayerBase let interval: number | undefined; if (currentGroup && prevGroup) { - interval = currentGroup.startTime - prevGroup.startTime; + interval = Duration.asMillis( + MediaTime.since(currentGroup.startTime, prevGroup.startTime), + ); } const policy = getPosYSpringPolicy(isSeeking, isInterludeActive, interval); @@ -829,8 +831,10 @@ export abstract class LyricPlayerBase const latestIndex = snapshot.latestHighlightedIndex ?? fallbackFocusIndex; const activeCount = result.lineCount; - let delay = 0; - let baseDelay = strategy.disableStagger ? 0 : 0.05; + let delay = Duration.ZERO; + let baseDelay = strategy.disableStagger + ? Duration.ZERO + : Duration.fromSecs(0.05); for (let i = 0; i < activeCount; i++) { const group = this.currentLyricGroups[i]; @@ -906,8 +910,10 @@ export abstract class LyricPlayerBase // 应用阶梯式的动画延迟 const lineH = instruction.height; if (curPos + lineH >= 0 && !snapshot.isSeeking) { - delay += baseDelay; - if (i >= snapshot.scrollToIndex) baseDelay /= 1.05; + delay = Duration.add(delay, baseDelay); + if (i >= snapshot.scrollToIndex) { + baseDelay = Duration.mulF64(baseDelay, 1 / 1.05); + } } } @@ -1017,8 +1023,9 @@ export abstract class LyricPlayerBase */ update(delta = 0): void { - this.bottomLine.update(delta / 1000); - this.interludeDots.update(delta); + const d = Duration.fromMillis(delta); + this.bottomLine.update(d); + this.interludeDots.update(d); } protected onResize(): void {} @@ -1061,7 +1068,9 @@ export abstract class LyricPlayerBase * @returns 当前播放位置 */ getCurrentTime(): number { - return this.timelineController.getSnapshot().currentTime; + return MediaTime.asMillis( + this.timelineController.getSnapshot().currentTime, + ); } /** diff --git a/packages/core/src/lyric-player/base/interlude-dots.ts b/packages/core/src/lyric-player/base/interlude-dots.ts index 55a9dcd7d7..3279f76576 100644 --- a/packages/core/src/lyric-player/base/interlude-dots.ts +++ b/packages/core/src/lyric-player/base/interlude-dots.ts @@ -1,4 +1,5 @@ import type { Disposable, HasElement } from "#interfaces"; +import type { Duration, MediaTime } from "#utils/time.ts"; /** * 间奏点组件的抽象接口 @@ -16,8 +17,8 @@ export interface InterludeDots extends HasElement, Disposable { * @param forceReset 是否强制重置动画起点,如 Seek、重新布局或切换间奏时 */ setInterlude( - interlude?: [number, number], - currentTime?: number, + interlude?: [MediaTime, MediaTime], + currentTime?: MediaTime, forceReset?: boolean, ): void; @@ -33,7 +34,7 @@ export interface InterludeDots extends HasElement, Disposable { /** * 逐帧推进间奏点动画并写入样式 - * @param delta 距离上一次调用的时长,单位为毫秒 + * @param delta 距离上一次调用的时长 */ - update(delta?: number): void; + update(delta?: Duration): void; } diff --git a/packages/core/src/lyric-player/base/line.ts b/packages/core/src/lyric-player/base/line.ts index df6f5e8623..12f9dae91f 100644 --- a/packages/core/src/lyric-player/base/line.ts +++ b/packages/core/src/lyric-player/base/line.ts @@ -1,6 +1,7 @@ import type { Disposable, LyricLine, LyricWord } from "#interfaces"; import { isCJK } from "#utils/is-cjk.ts"; import { Spring } from "#utils/spring.ts"; +import { Duration } from "#utils/time.ts"; import { LyricLineRenderMode } from "./consts.ts"; interface LineTransforms { @@ -16,7 +17,7 @@ export abstract class LyricLineBase extends EventTarget implements Disposable { protected scale = 1; protected blur = 0; protected opacity = 1; - protected delay = 0; + protected delay: Duration = Duration.ZERO; protected isUiDirty = true; @@ -54,7 +55,7 @@ export abstract class LyricLineBase extends EventTarget implements Disposable { opacity: number = this.opacity, blur: number = this.blur, _immediate = false, - delay = 0, + delay: Duration = Duration.ZERO, _mode: LyricLineRenderMode = LyricLineRenderMode.SOLID, ): void { this.scale = scale; @@ -85,6 +86,6 @@ export abstract class LyricLineBase extends EventTarget implements Disposable { word.word.trim().length > 1 ); } - abstract update(delta?: number): void; + abstract update(delta?: Duration): void; dispose(): void {} } diff --git a/packages/core/src/lyric-player/base/scroll.ts b/packages/core/src/lyric-player/base/scroll.ts index 1d15ebe128..84cc0b2016 100644 --- a/packages/core/src/lyric-player/base/scroll.ts +++ b/packages/core/src/lyric-player/base/scroll.ts @@ -1,3 +1,5 @@ +import { Duration } from "#utils/time.ts"; + export type ScrollInputType = "touch" | "wheel"; export interface ScrollEngineHooks { @@ -34,6 +36,18 @@ export interface ScrollEngineHooks { onAutoAlignResume: () => void; } +/** 滚动静止后恢复自动排版与对齐的等待时间(5秒) */ +const AUTO_ALIGN_RESUME_DELAY_MS = 5000; + +/** 滚轮交互结束防抖时间(150毫秒) */ +const WHEEL_IDLE_TIMEOUT_MS = 150; + +/** 惯性动画基准物理帧时间 */ +const BASE_FRAME_DURATION = Duration.fromMillis(1000 / 60); + +/** 惯性单帧最大跨度阈值,超过 100ms 视为掉帧或切换页面丢弃当前步进 */ +const MAX_INERTIA_FRAME_DELTA_MS = 100; + export class ScrollInteractionEngine { private offset: number = 0; @@ -46,7 +60,7 @@ export class ScrollInteractionEngine { lastY: 0, startOffset: 0, speed: 0, - startTime: 0, + lastTimestamp: 0, /** * 是否已经突破 10px 意图阈值 */ @@ -116,7 +130,7 @@ export class ScrollInteractionEngine { this.scrolledTimeoutId = window.setTimeout(() => { this.scrolledTimeoutId = 0; this.hooks.onAutoAlignResume(); - }, 5000); + }, AUTO_ALIGN_RESUME_DELAY_MS); } //#endregion @@ -130,7 +144,7 @@ export class ScrollInteractionEngine { state.startX = touch.screenX; state.lastY = touch.screenY; state.startOffset = this.offset; - state.startTime = Date.now(); + state.lastTimestamp = performance.now(); state.speed = 0; return; } @@ -149,7 +163,7 @@ export class ScrollInteractionEngine { state.startX = touch.screenX; state.lastY = touch.screenY; state.startOffset = this.offset; - state.startTime = Date.now(); + state.lastTimestamp = performance.now(); state.speed = 0; state.isIntentConfirmed = false; }; @@ -180,14 +194,15 @@ export class ScrollInteractionEngine { state.startOffset - (currentY - state.startY), ); - const now = Date.now(); - const dt = now - state.startTime; - if (dt > 0) { - state.speed = (currentY - state.lastY) / dt; + const now = performance.now(); + const dt = Duration.fromMillis(now - state.lastTimestamp); + const dtMs = Duration.asMillis(dt); + if (dtMs > 0) { + state.speed = (currentY - state.lastY) / dtMs; } state.lastY = currentY; - state.startTime = now; + state.lastTimestamp = now; this.hooks.onScrollUpdate(true); }; @@ -222,7 +237,7 @@ export class ScrollInteractionEngine { state.startX = remainingTouch.screenX; state.lastY = remainingTouch.screenY; state.startOffset = this.offset; - state.startTime = Date.now(); + state.lastTimestamp = performance.now(); state.speed = 0; return; } @@ -257,19 +272,21 @@ export class ScrollInteractionEngine { let lastFrameTime = performance.now(); const onScrollFrame = (time: number) => { - const dt = time - lastFrameTime; + const dt = Duration.fromMillis(time - lastFrameTime); lastFrameTime = time; + const dtMs = Duration.asMillis(dt); - if (dt <= 0 || dt > 100) { + if (dtMs <= 0 || dtMs > MAX_INERTIA_FRAME_DELTA_MS) { this.inertiaRafId = requestAnimationFrame(onScrollFrame); return; } if (Math.abs(state.speed) > 0.05) { - this.offset -= state.speed * dt; + this.offset -= state.speed * dtMs; this.offset = this.clampOffset(this.offset); - state.speed *= 0.95 ** (dt / 16); + const steps = Duration.divDuration(dt, BASE_FRAME_DURATION); + state.speed *= 0.95 ** steps; this.hooks.onScrollUpdate(true); this.inertiaRafId = requestAnimationFrame(onScrollFrame); @@ -305,7 +322,7 @@ export class ScrollInteractionEngine { this.wheelEndTimeoutId = window.setTimeout(() => { this.wheelEndTimeoutId = 0; this.endInteractionAndStartTimer(); - }, 150); + }, WHEEL_IDLE_TIMEOUT_MS); }; //#endregion @@ -351,7 +368,7 @@ export class ScrollInteractionEngine { this.touchState.speed = 0; this.touchState.startY = 0; this.touchState.lastY = 0; - this.touchState.startTime = 0; + this.touchState.lastTimestamp = 0; this.offset = this.clampOffset(targetOffset); } diff --git a/packages/core/src/lyric-player/base/timeline.ts b/packages/core/src/lyric-player/base/timeline.ts index 074035e246..4280dc2267 100644 --- a/packages/core/src/lyric-player/base/timeline.ts +++ b/packages/core/src/lyric-player/base/timeline.ts @@ -1,18 +1,20 @@ +import { Duration, MediaTime } from "#utils/time.ts"; + //#region 类型定义 /** * 用于进度计算的最小歌词数据 */ export interface TimeBounds { - readonly startTime: number; - readonly endTime: number; + readonly startTime: MediaTime; + readonly endTime: MediaTime; } /** * 当前命中的间奏区间信息 */ export interface PlayerInterlude { - readonly startTime: number; - readonly endTime: number; + readonly startTime: MediaTime; + readonly endTime: MediaTime; /** * 间奏点应插入的位置基准 * @@ -37,7 +39,7 @@ export interface TimelineSnapshot { * * 例如给 InterludeDots 计算播放动画的当前时间戳 */ - readonly currentTime: number; + readonly currentTime: MediaTime; /** * 标识当前帧是否处于跳转状态 @@ -199,7 +201,7 @@ export class TimelineController { private expiredHighlightedIds: number[] = []; private readonly snapshot: Mutable = { - currentTime: 0, + currentTime: MediaTime.ZERO, isSeeking: false, playingGroups: this.playingGroupsSet, highlightedGroups: this.highlightedGroupsSet, @@ -246,7 +248,7 @@ export class TimelineController { return this.snapshot; } - public sync(time: number, forceSeek = false): TimelineDiff { + public sync(time: MediaTime, forceSeek = false): TimelineDiff { this.addedPlayingIds.length = 0; this.removedPlayingIds.length = 0; this.addedHighlightedIds.length = 0; @@ -327,7 +329,7 @@ export class TimelineController { /** * 处理正常播放时的时间线推导 */ - private performPlayback(time: number): void { + private performPlayback(time: MediaTime): void { // 我在这里定义了歌词的不同状态: // 播放行:只要当前时间落在 [startTime, endTime) 内,就是在播放行,播放行是高亮行的真子集 // 高亮行:UI 层真正看到的高亮状态 @@ -438,7 +440,7 @@ export class TimelineController { * * 将会丢弃所有高亮状态的行,直接根据当前时间重新计算播放状态的行 */ - private performSeek(time: number): void { + private performSeek(time: MediaTime): void { for (const id of this.playingGroupsSet) { this.removedPlayingIds.push(id); } @@ -495,15 +497,16 @@ export class TimelineController { //#region 间奏计算 private calculateInterludes(bounds: TimeBounds[]): PlayerInterlude[] { const interludes: PlayerInterlude[] = []; + const minGap = Duration.fromMillis(4000); for (let i = -1; i < bounds.length - 1; i++) { const prevGroup = i === -1 ? null : bounds[i]; const nextGroup = bounds[i + 1]; - const gapStart = prevGroup ? prevGroup.endTime : 0; - const gapEnd = Math.max(gapStart, nextGroup.startTime); + const gapStart = prevGroup ? prevGroup.endTime : MediaTime.ZERO; + const gapEnd = MediaTime.max(gapStart, nextGroup.startTime); - if (gapEnd - gapStart >= 4000) { + if (MediaTime.since(gapEnd, gapStart) >= minGap) { interludes.push({ startTime: gapStart, endTime: gapEnd, @@ -515,7 +518,7 @@ export class TimelineController { return interludes; } - private updateInterludeState(time: number, isSeek: boolean): void { + private updateInterludeState(time: MediaTime, isSeek: boolean): void { let activeInterlude: PlayerInterlude | undefined; if (this.precalculatedInterludes.length > 0) { @@ -582,7 +585,7 @@ export class TimelineController { this.playingGroupsSet.clear(); this.highlightedGroupsSet.clear(); - this.snapshot.currentTime = 0; + this.snapshot.currentTime = MediaTime.ZERO; this.snapshot.isSeeking = false; this.snapshot.scrollToIndex = 0; this.snapshot.latestHighlightedIndex = undefined; diff --git a/packages/core/src/lyric-player/dom/bottom-line.ts b/packages/core/src/lyric-player/dom/bottom-line.ts index de9a589285..698ec17e68 100644 --- a/packages/core/src/lyric-player/dom/bottom-line.ts +++ b/packages/core/src/lyric-player/dom/bottom-line.ts @@ -5,6 +5,7 @@ import type { import type { LyricPlayerBase } from "#lyric/base/index.ts"; import styles from "#styles/lyric-player.module.css"; import { Spring } from "#utils/spring.ts"; +import { Duration } from "#utils/time.ts"; /** * 底栏组件的 DOM 实现 @@ -65,7 +66,7 @@ export class BottomLineEl implements BottomLine { top: number = this.top, blur = 0, immediate = false, - delay = 0, + delay: Duration = Duration.ZERO, ): void { this.top = top; @@ -86,9 +87,9 @@ export class BottomLineEl implements BottomLine { /** * 逐帧推进弹簧动画并应用样式 - * @param delta 距离上一次调用的时长,单位为秒 + * @param delta 距离上一次调用的时长 */ - public update(delta = 0): void { + public update(delta: Duration = Duration.ZERO): void { if (!this.lyricPlayer.getEnableSpring()) return; this.lineTransforms.posY.update(delta); this.rebuildStyle(); diff --git a/packages/core/src/lyric-player/dom/index.ts b/packages/core/src/lyric-player/dom/index.ts index 9bf517c1ea..5d822aa1a5 100644 --- a/packages/core/src/lyric-player/dom/index.ts +++ b/packages/core/src/lyric-player/dom/index.ts @@ -10,6 +10,7 @@ import { LyricPlayerBase } from "#lyric/base/index.ts"; import type { InterludeDots } from "#lyric/base/interlude-dots.ts"; import type { LyricLineBase } from "#lyric/base/line.ts"; import styles from "#styles/lyric-player.module.css"; +import { Duration } from "#utils/time.ts"; import { BottomLineEl } from "./bottom-line.ts"; import { InterludeDotsEl } from "./interlude-dots.ts"; import { LyricLineGroup } from "./lyric-group.ts"; @@ -229,9 +230,9 @@ export class DomLyricPlayer extends LyricPlayerBase { ); } if (!this.isPageVisible) return; - const deltaS = delta / 1000; + const d = Duration.fromMillis(delta); for (const group of this.currentLyricGroups) { - group.update(deltaS); + group.update(d); } for (const group of this.currentLyricGroups) { diff --git a/packages/core/src/lyric-player/dom/interlude-dots.ts b/packages/core/src/lyric-player/dom/interlude-dots.ts index 36957ce469..42c71295d4 100644 --- a/packages/core/src/lyric-player/dom/interlude-dots.ts +++ b/packages/core/src/lyric-player/dom/interlude-dots.ts @@ -1,6 +1,7 @@ import type { InterludeDots } from "#lyric/base/interlude-dots.ts"; import styles from "#styles/lyric-player.module.css"; import { clamp, clamp01, clampPositive } from "#utils/clamp.ts"; +import { Duration, MediaTime } from "#utils/time.ts"; /** * 带过冲回弹的缓动,用于结束阶段的收缩演出 @@ -21,7 +22,7 @@ function easeOutExpo(x: number): number { return x === 1 ? 1 : 1 - 2 ** (-10 * x); } -const TARGET_BREATHE_DURATION = 1500; +const TARGET_BREATHE_DURATION = 4500; /** * 间奏点的 DOM 渲染实现 @@ -38,14 +39,14 @@ export class InterludeDotsEl implements InterludeDots { private top = 0; private lastStyle = ""; - private currentTime = 0; + private currentTime: MediaTime = MediaTime.ZERO; private playing = true; /** * 当前的动画时间区间 `[动画起点, 结束时间]` * @remarks 起点是重新锚定后的动画起点,与间奏的真实开始时间可能不同 */ - private currentInterlude?: [number, number]; + private currentInterlude?: [MediaTime, MediaTime]; constructor() { this.element.className = styles.interludeDots; @@ -71,8 +72,8 @@ export class InterludeDotsEl implements InterludeDots { * @param forceReset 是否强制重置动画起点,如 Seek、重新布局或切换间奏时 */ public setInterlude( - interlude?: [number, number], - currentTime?: number, + interlude?: [MediaTime, MediaTime], + currentTime?: MediaTime, forceReset = false, ): void { if (!interlude) { @@ -120,11 +121,11 @@ export class InterludeDotsEl implements InterludeDots { * 2. 持续:正弦呼吸缩放,三个圆点随进度依次点亮 * 3. 结束 (最后 750ms):以 easeInOutBack 收缩回弹,最后 375ms 渐隐 * - * @param delta 距离上一次调用的时长,单位为毫秒 + * @param delta 距离上一次调用的时长 */ - public update(delta = 0): void { + public update(delta: Duration = Duration.ZERO): void { if (!this.playing) return; - this.currentTime += delta; + this.currentTime = MediaTime.add(this.currentTime, delta); let curStyle = ""; curStyle += `transform:translate(${this.left.toFixed( @@ -133,9 +134,12 @@ export class InterludeDotsEl implements InterludeDots { // 计算缩放大小 if (this.currentInterlude) { - const interludeDuration = - this.currentInterlude[1] - this.currentInterlude[0]; - const currentDuration = this.currentTime - this.currentInterlude[0]; + const interludeDuration = Duration.asMillis( + MediaTime.since(this.currentInterlude[1], this.currentInterlude[0]), + ); + const currentDuration = Duration.asMillis( + MediaTime.since(this.currentTime, this.currentInterlude[0]), + ); if (currentDuration <= interludeDuration) { // 将总时长按基准呼吸时长切分为整数次呼吸 const breatheDuration = diff --git a/packages/core/src/lyric-player/dom/lyric-group.ts b/packages/core/src/lyric-player/dom/lyric-group.ts index c0744ef970..3bef236ea0 100644 --- a/packages/core/src/lyric-player/dom/lyric-group.ts +++ b/packages/core/src/lyric-player/dom/lyric-group.ts @@ -1,6 +1,7 @@ import { LyricLineGroupBase } from "#lyric/base/group.ts"; import styles from "#styles/lyric-player.module.css"; import { clamp01 } from "#utils/clamp.ts"; +import { Duration } from "#utils/time.ts"; import type { DomLyricPlayer } from "./index.ts"; import type { LyricLineEl } from "./lyric-line.ts"; @@ -91,7 +92,7 @@ export class LyricLineGroup extends LyricLineGroupBase { } } - override update(delta: number): void { + override update(delta: Duration = Duration.ZERO): void { super.update(delta); } diff --git a/packages/core/src/lyric-player/dom/lyric-line.ts b/packages/core/src/lyric-player/dom/lyric-line.ts index 5afa3540ea..05bf48dad1 100644 --- a/packages/core/src/lyric-player/dom/lyric-line.ts +++ b/packages/core/src/lyric-player/dom/lyric-line.ts @@ -8,6 +8,7 @@ import { isCJK } from "#utils/is-cjk.ts"; import { LineBalancer } from "#utils/line-balancer.ts"; import { chunkAndSplitLyricWords } from "#utils/lyric-split-words.ts"; import { createMatrix4, matrix4ToCSS, scaleMatrix4 } from "#utils/matrix.ts"; +import { Duration } from "#utils/time.ts"; import type { DomLyricPlayer } from "."; interface RealWord extends LyricWord { @@ -915,10 +916,10 @@ export class LyricLineEl extends LyricLineBase { override setTransform( scale: number = this.scale, - opacity = 1, + opacity: number = this.opacity, blur = 0, immediate = false, - delay = 0, + delay: Duration = Duration.ZERO, mode: LyricLineRenderMode = LyricLineRenderMode.SOLID, ): void { super.setTransform(scale, opacity, blur, immediate, delay); @@ -926,7 +927,7 @@ export class LyricLineEl extends LyricLineBase { this.setRenderMode(mode); this.top = 0; this.scale = scale; - this.delay = (delay * 1000) | 0; + this.delay = delay; const enableSpring = this.lyricPlayer.getEnableSpring(); @@ -937,7 +938,7 @@ export class LyricLineEl extends LyricLineBase { } } - update(delta = 0): void { + update(delta: Duration = Duration.ZERO): void { if (!this.lyricPlayer.getEnableSpring()) return; const scaleMoving = !this.lineTransforms.scale.arrived(); diff --git a/packages/core/src/utils/linear.ts b/packages/core/src/utils/linear.ts deleted file mode 100644 index 0bd8a2cd40..0000000000 --- a/packages/core/src/utils/linear.ts +++ /dev/null @@ -1,120 +0,0 @@ -type seconds = number; - -export interface LinearParams { - duration: number; // Duration of the transition in seconds -} - -export class Linear { - private currentPosition = 0; - private targetPosition = 0; - private currentTime = 0; - private params: Partial = {}; - private currentSolver: (t: seconds) => number; - private startTime = 0; - private queueParams: - | (Partial & { - time: number; - }) - | undefined; - private queuePosition: - | { - time: number; - position: number; - } - | undefined; - constructor(currentPosition = 0) { - this.targetPosition = currentPosition; - this.currentPosition = this.targetPosition; - this.currentSolver = () => this.targetPosition; - } - private resetSolver() { - this.currentTime = 0; - this.startTime = 0; - this.currentSolver = solveLinear( - this.currentPosition, - this.targetPosition, - this.params, - ); - } - arrived(): boolean { - return ( - Math.abs(this.targetPosition - this.currentPosition) < 0.01 && - this.queueParams === undefined && - this.queuePosition === undefined - ); - } - setPosition(targetPosition: number): void { - this.targetPosition = targetPosition; - this.currentPosition = targetPosition; - this.currentSolver = () => this.targetPosition; - } - update(delta = 0): void { - this.currentTime += delta; - this.currentPosition = this.currentSolver( - this.currentTime - this.startTime, - ); - if (this.queueParams) { - this.queueParams.time -= delta; - if (this.queueParams.time <= 0) { - this.updateParams({ - ...this.queueParams, - }); - } - } - if (this.queuePosition) { - this.queuePosition.time -= delta; - if (this.queuePosition.time <= 0) { - this.setTargetPosition(this.queuePosition.position); - } - } - if (this.arrived()) { - this.setPosition(this.targetPosition); - } - } - updateParams(params: Partial, delay = 0): void { - if (delay > 0) { - this.queueParams = { - ...(this.queuePosition ?? {}), - ...params, - time: delay, - }; - } else { - this.queuePosition = undefined; - this.params = { - ...this.params, - ...params, - }; - this.resetSolver(); - } - } - setTargetPosition(targetPosition: number, delay = 0): void { - if (delay > 0) { - this.queuePosition = { - ...(this.queuePosition ?? {}), - position: targetPosition, - time: delay, - }; - } else { - this.queuePosition = undefined; - this.targetPosition = targetPosition; - this.resetSolver(); - } - } - getCurrentPosition(): number { - return this.currentPosition; - } -} - -function solveLinear( - from: number, - to: number, - params?: Partial, -): (t: seconds) => number { - const duration = params?.duration ?? 1; - const delta = to - from; - return (t: seconds) => { - if (t < 0) return from; - if (t > duration) return to; - return from + (delta * t) / duration; - }; -} diff --git a/packages/core/src/utils/schedule.ts b/packages/core/src/utils/schedule.ts deleted file mode 100644 index 0242c55a83..0000000000 --- a/packages/core/src/utils/schedule.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @fileoverview - * @see https://github.com/wilsonpage/fastdom/blob/master/fastdom.js - */ - -interface Task { - task: () => T; - resolve: (value: T) => void; - reject: (reason?: unknown) => void; -} - -// biome-ignore lint/suspicious/noExplicitAny: util functions -const measureTasks: Task[] = []; -// biome-ignore lint/suspicious/noExplicitAny: util functions -const mutateTasks: Task[] = []; -let scheduled = false; - -function onFlush() { - let tmp = mutateTasks.shift(); - while (tmp) { - try { - tmp.resolve(tmp.task()); - } catch (error) { - tmp.reject(error); - } - tmp = mutateTasks.shift(); - } - tmp = measureTasks.shift(); - while (tmp) { - try { - tmp.resolve(tmp.task()); - } catch (error) { - tmp.reject(error); - } - tmp = measureTasks.shift(); - } - scheduled = false; -} - -function scheduleFlush() { - if (!scheduled) { - scheduled = true; - requestAnimationFrame(onFlush); - } -} - -export function measure(callback: () => T): Promise { - const task: Task = { - task: callback, - resolve: () => {}, - reject: () => {}, - }; - const promise = new Promise((resolve, reject) => { - task.resolve = resolve; - task.reject = reject; - }); - measureTasks.push(task); - scheduleFlush(); - return promise; -} - -export function mutate(callback: () => void): Promise { - const task: Task = { - task: callback, - resolve: () => {}, - reject: () => {}, - }; - const promise = new Promise((resolve, reject) => { - task.resolve = resolve; - task.reject = reject; - }); - mutateTasks.push(task); - scheduleFlush(); - return promise; -} diff --git a/packages/core/src/utils/spring.ts b/packages/core/src/utils/spring.ts index 988427cce7..3c594cf45c 100644 --- a/packages/core/src/utils/spring.ts +++ b/packages/core/src/utils/spring.ts @@ -1,4 +1,5 @@ import { getVelocity } from "./derivative.ts"; +import { Duration } from "./time.ts"; /** MIT License github.com/pushkine/ */ export interface SpringParams { @@ -8,24 +9,24 @@ export interface SpringParams { soft: boolean; // = false } -type seconds = number; +type Seconds = number; export class Spring { private currentPosition = 0; private targetPosition = 0; - private currentTime = 0; + private currentTime: Seconds = 0; private params: Partial = {}; - private currentSolver: (t: seconds) => number; - private getV: (t: seconds) => number; - private getV2: (t: seconds) => number; + private currentSolver: (t: Seconds) => number; + private getV: (t: Seconds) => number; + private getV2: (t: Seconds) => number; private queueParams: | (Partial & { - time: number; + time: Seconds; }) | undefined; private queuePosition: | { - time: number; + time: Seconds; position: number; } | undefined; @@ -65,11 +66,12 @@ export class Spring { this.getV = () => 0; this.getV2 = () => 0; } - update(delta = 0): void { - this.currentTime += delta; + update(delta: Duration = Duration.ZERO): void { + const dt = Duration.asSecsF64(delta); + this.currentTime += dt; this.currentPosition = this.currentSolver(this.currentTime); if (this.queueParams) { - this.queueParams.time -= delta; + this.queueParams.time -= dt; if (this.queueParams.time <= 0) { this.updateParams({ ...this.queueParams, @@ -77,7 +79,7 @@ export class Spring { } } if (this.queuePosition) { - this.queuePosition.time -= delta; + this.queuePosition.time -= dt; if (this.queuePosition.time <= 0) { this.setTargetPosition(this.queuePosition.position); } @@ -86,12 +88,16 @@ export class Spring { this.setPosition(this.targetPosition); } } - updateParams(params: Partial, delay = 0): void { - if (delay > 0) { + updateParams( + params: Partial, + delay: Duration = Duration.ZERO, + ): void { + const delaySecs = Duration.asSecsF64(delay); + if (delaySecs > 0) { this.queueParams = { ...(this.queuePosition ?? {}), ...params, - time: delay, + time: delaySecs, }; } else { this.queuePosition = undefined; @@ -102,17 +108,24 @@ export class Spring { this.resetSolver(); } } - setTargetPosition(targetPosition: number, delay = 0): void { - if (delay <= 0 && Math.abs(this.targetPosition - targetPosition) < 0.001) { + setTargetPosition( + targetPosition: number, + delay: Duration = Duration.ZERO, + ): void { + const delaySecs = Duration.asSecsF64(delay); + if ( + delaySecs <= 0 && + Math.abs(this.targetPosition - targetPosition) < 0.001 + ) { this.queuePosition = undefined; return; } - if (delay > 0) { + if (delaySecs > 0) { this.queuePosition = { ...(this.queuePosition ?? {}), position: targetPosition, - time: delay, + time: delaySecs, }; } else { this.queuePosition = undefined; @@ -129,9 +142,9 @@ function solveSpring( from: number, velocity: number, to: number, - delay: seconds = 0, + delay: Seconds = 0, params?: Partial, -): (t: seconds) => number { +): (t: Seconds) => number { const soft = params?.soft ?? false; const stiffness = params?.stiffness ?? 100; const damping = params?.damping ?? 10; @@ -140,7 +153,7 @@ function solveSpring( if (soft || 1.0 <= damping / (2.0 * Math.sqrt(stiffness * mass))) { const angular_frequency = -Math.sqrt(stiffness / mass); const leftover = -angular_frequency * delta - velocity; - return (t: seconds) => { + return (t: Seconds) => { t -= delay; if (t < 0) return from; return to - (delta + t * leftover) * Math.E ** (t * angular_frequency); @@ -151,7 +164,7 @@ function solveSpring( (damping * delta - 2.0 * mass * velocity) / damping_frequency; const dfm = (0.5 * damping_frequency) / mass; const dm = -(0.5 * damping) / mass; - return (t: seconds) => { + return (t: Seconds) => { t -= delay; if (t < 0) return from; return ( diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts new file mode 100644 index 0000000000..28b19620b1 --- /dev/null +++ b/packages/core/src/utils/time.ts @@ -0,0 +1,52 @@ +declare const DURATION_BRAND: unique symbol; +declare const MEDIA_TIME_BRAND: unique symbol; + +/** + * 一段时长,内部以毫秒浮点数表示 + */ +export type Duration = { readonly [DURATION_BRAND]: true }; + +/** + * 媒体时间轴上的一个时间点,内部以毫秒浮点数表示 + * + * 原点为歌曲起始 0ms + */ +export type MediaTime = { readonly [MEDIA_TIME_BRAND]: true }; + +const toD = (ms: number): Duration => ms as unknown as Duration; +const toM = (ms: number): MediaTime => ms as unknown as MediaTime; +const toNum = (t: Duration | MediaTime): number => t as unknown as number; + +export const Duration = { + ZERO: toD(0) as Duration, + fromMillis: (ms: number): Duration => toD(ms), + fromSecs: (s: number): Duration => toD(s * 1000), + asMillis: (d: Duration): number => toNum(d), + asSecsF64: (d: Duration): number => toNum(d) / 1000, + add: (a: Duration, b: Duration): Duration => toD(toNum(a) + toNum(b)), + sub: (a: Duration, b: Duration): Duration => toD(toNum(a) - toNum(b)), + saturatingSub: (a: Duration, b: Duration): Duration => + toD(Math.max(0, toNum(a) - toNum(b))), + mulF64: (d: Duration, factor: number): Duration => toD(toNum(d) * factor), + divDuration: (a: Duration, b: Duration): number => toNum(a) / toNum(b), + min: (a: Duration, b: Duration): Duration => (a < b ? a : b), + max: (a: Duration, b: Duration): Duration => (a > b ? a : b), + clampPositive: (d: Duration): Duration => toD(Math.max(0, toNum(d))), + isZero: (d: Duration): boolean => toNum(d) === 0, + isFinite: (d: Duration): boolean => Number.isFinite(toNum(d)), +} as const; + +export const MediaTime = { + ZERO: toM(0) as MediaTime, + fromMillis: (ms: number): MediaTime => toM(ms), + asMillis: (t: MediaTime): number => toNum(t), + since: (a: MediaTime, b: MediaTime): Duration => toD(toNum(a) - toNum(b)), + saturatingSince: (a: MediaTime, b: MediaTime): Duration => + toD(Math.max(0, toNum(a) - toNum(b))), + add: (t: MediaTime, d: Duration): MediaTime => toM(toNum(t) + toNum(d)), + sub: (t: MediaTime, d: Duration): MediaTime => toM(toNum(t) - toNum(d)), + min: (a: MediaTime, b: MediaTime): MediaTime => (a < b ? a : b), + max: (a: MediaTime, b: MediaTime): MediaTime => (a > b ? a : b), + cmp: (a: MediaTime, b: MediaTime): number => toNum(a) - toNum(b), + round: (t: MediaTime): MediaTime => toM(Math.round(toNum(t))), +} as const; diff --git a/packages/core/src/utils/wa-spring.ts b/packages/core/src/utils/wa-spring.ts deleted file mode 100644 index 2f621d4ee0..0000000000 --- a/packages/core/src/utils/wa-spring.ts +++ /dev/null @@ -1,139 +0,0 @@ -import bezier from "bezier-easing"; -import type { Disposable } from "../interfaces.ts"; -import { getVelocity } from "./derivative"; - -// export interface SpringParams { -// mass: number; // = 1.0 -// damping: number; // = 10.0 -// stiffness: number; // = 100.0 -// } - -type seconds = number; - -type CSSStyleKeys = { - [Style in keyof CSSStyleDeclaration]: Style extends string - ? CSSStyleDeclaration[Style] extends string - ? Style - : never - : never; -}[keyof CSSStyleDeclaration]; - -/** - * 基于 Web Animation API 的弹簧动画工具类,效果上可能逊于实时演算的版本 - */ -export class WebAnimationSpring extends EventTarget implements Disposable { - private currentAnimation: Animation; - private targetPosition = 0; - private isStatic = true; - // private params: Partial = {}; - private currentSolver: (t: seconds) => number = () => this.targetPosition; - private getV: (t: seconds) => number = () => 0; - - constructor( - private element: HTMLElement, - private styleName: CSSStyleKeys, - private valueGenerator: (value: number) => string, - private currentPosition = 0, - ) { - super(); - this.targetPosition = currentPosition; - this.currentAnimation = element.animate( - [ - { - [styleName]: valueGenerator(currentPosition), - }, - ], - { - duration: 1000, - fill: "both", - composite: "add", - }, - ); - } - - makeStatic(): void { - this.getV = () => 0; - this.currentSolver = () => this.targetPosition; - this.currentAnimation.cancel(); - this.currentAnimation = this.element.animate( - [ - { - [this.styleName]: this.valueGenerator(this.targetPosition), - }, - { - [this.styleName]: this.valueGenerator(this.targetPosition), - }, - ], - { - duration: Number.POSITIVE_INFINITY, - id: `wa-spring-static-${this.styleName}`, - fill: "both", - easing: "cubic-bezier(0.5, 0, 0.5, 1)", - composite: "add", - }, - ); - this.currentAnimation.pause(); - } - - setTargetPosition(targetPosition: number): void { - this.targetPosition = targetPosition; - this.onStepFinished(); - } - - getCurrentPosition(): number { - if (this.isStatic || !this.currentAnimation.effect) - return this.currentPosition; - - const timing = this.currentAnimation.effect?.getComputedTiming(); - return this.currentSolver(timing.progress ?? 1); - } - - getCurrentVelocity(): number { - if (this.isStatic || !this.currentAnimation.effect) return 0; - - const timing = this.currentAnimation.effect?.getComputedTiming(); - return this.getV(timing.progress ?? 1); - } - - private onStepFinished() { - const currentPosition = this.getCurrentPosition(); - if (Math.abs(this.targetPosition - currentPosition) < 0.0001) { - this.makeStatic(); - this.dispatchEvent(new Event("finished")); - return; - } - this.currentSolver = bezier(0.5, 0, 0.5, 1); - this.getV = getVelocity(this.currentSolver); - this.currentAnimation.cancel(); - const delta = (this.targetPosition - currentPosition) * 1.05; - this.currentPosition += delta; - this.currentAnimation = this.element.animate( - [ - { - [this.styleName]: this.valueGenerator(currentPosition), - }, - { - [this.styleName]: this.valueGenerator(this.currentPosition), - }, - ], - { - duration: 250, - id: `wa-spring-dynamic-${this.styleName}`, - fill: "forwards", - easing: "cubic-bezier(0.5, 0, 0.5, 1)", - composite: "add", - }, - ); - this.currentAnimation.onfinish = () => this.onStepFinished(); - } - - stop(): void { - if (this.currentAnimation) { - this.currentAnimation.cancel(); - } - } - - dispose(): void { - this.stop(); - } -} diff --git a/packages/core/test/time.assert-types.ts b/packages/core/test/time.assert-types.ts new file mode 100644 index 0000000000..bd1b98b596 --- /dev/null +++ b/packages/core/test/time.assert-types.ts @@ -0,0 +1,75 @@ +import type { Duration, MediaTime } from "../src/utils/time.ts"; + +declare const d1: Duration; +declare const d2: Duration; +declare const m1: MediaTime; +declare const m2: MediaTime; +declare const n: number; + +//#region 有效操作 +export const _cmpD1: boolean = d1 < d2; +export const _cmpD2: boolean = d1 <= d2; +export const _cmpD3: boolean = d1 > d2; +export const _cmpD4: boolean = d1 >= d2; +export const _cmpD5: boolean = d1 === d2; +export const _cmpD6: boolean = d1 !== d2; +export const _cmpM1: boolean = m1 < m2; +export const _cmpM2: boolean = m1 <= m2; +export const _cmpM3: boolean = m1 > m2; +export const _cmpM4: boolean = m1 >= m2; +export const _cmpM5: boolean = m1 === m2; +export const _cmpM6: boolean = m1 !== m2; +//#endregion + +//#region 无效操作 +//#region 直接计算 +// @ts-expect-error Operator '+' cannot be applied to types 'MediaTime' and '.MediaTime'. +export const _errAddMM: number = m1 + m2; + +// @ts-expect-error Operator '+' cannot be applied to types 'Duration' and 'Duration'. +export const _errAddDD: number = d1 + d2; + +// @ts-expect-error The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type. +export const _errSubMM: number = m1 - m2; + +// @ts-expect-error The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type. +export const _errMulM: number = m1 * 2; + +// @ts-expect-error The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type. +export const _errMulD: number = d1 * 2; +//#endregion + +//#region 不同类型的比较 +// @ts-expect-error Operator '<' cannot be applied to types 'Duration' and 'MediaTime'. +export const _errCmpDM: boolean = d1 < m1; + +// @ts-expect-error Operator '>' cannot be applied to types 'MediaTime' and 'Duration'. +export const _errCmpMD: boolean = m1 > d1; + +// @ts-expect-error Operator '<' cannot be applied to types 'MediaTime' and 'number'. +export const _errCmpMN: boolean = m1 < n; + +// @ts-expect-error Operator '<' cannot be applied to types 'Duration' and 'number'. +export const _errCmpDN: boolean = d1 < n; +//#endregion + +//#region 不兼容的赋值 +// @ts-expect-error Property '[DURATION_BRAND]' is missing in type 'MediaTime' but required in type 'Duration'. +export const _errAssignMD: Duration = m1; + +// @ts-expect-error Property '[MEDIA_TIME_BRAND]' is missing in type 'Duration' but required in type 'MediaTime'. +export const _errAssignDM: MediaTime = d1; + +// @ts-expect-error Type 'number' is not assignable to type 'MediaTime'. +export const _errAssignNM: MediaTime = n; + +// @ts-expect-error Type 'number' is not assignable to type 'Duration'. +export const _errAssignND: Duration = n; + +// @ts-expect-error Type 'MediaTime' is not assignable to type 'number'. +export const _errAssignMN: number = m1; + +// @ts-expect-error Type 'Duration' is not assignable to type 'number'. +export const _errAssignDN: number = d1; +//#endregion +//#endregion diff --git a/packages/core/test/time.test.ts b/packages/core/test/time.test.ts new file mode 100644 index 0000000000..8edd553ff9 --- /dev/null +++ b/packages/core/test/time.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { Duration, MediaTime } from "../src/utils/time.ts"; + +describe("Duration", () => { + it("converts between millis and seconds correctly", () => { + const d1 = Duration.fromMillis(1500); + expect(Duration.asMillis(d1)).toBe(1500); + expect(Duration.asSecsF64(d1)).toBe(1.5); + + const d2 = Duration.fromSecs(2.5); + expect(Duration.asMillis(d2)).toBe(2500); + expect(Duration.asSecsF64(d2)).toBe(2.5); + + expect(Duration.isZero(Duration.ZERO)).toBe(true); + expect(Duration.isZero(Duration.fromMillis(10))).toBe(false); + }); + + it("handles arithmetic operations", () => { + const a = Duration.fromMillis(300); + const b = Duration.fromMillis(200); + + const sum = Duration.add(a, b); + expect(Duration.asMillis(sum)).toBe(500); + + const diff = Duration.sub(a, b); + expect(Duration.asMillis(diff)).toBe(100); + + const scaled = Duration.mulF64(a, 2.5); + expect(Duration.asMillis(scaled)).toBe(750); + + const ratio = Duration.divDuration(a, b); + expect(ratio).toBe(1.5); + }); + + it("handles saturatingSub, min, max, clampPositive, isFinite", () => { + const small = Duration.fromMillis(100); + const big = Duration.fromMillis(500); + + expect(Duration.asMillis(Duration.saturatingSub(small, big))).toBe(0); + expect(Duration.asMillis(Duration.saturatingSub(big, small))).toBe(400); + + expect(Duration.asMillis(Duration.min(small, big))).toBe(100); + expect(Duration.asMillis(Duration.max(small, big))).toBe(500); + + const neg = Duration.fromMillis(-50); + expect(Duration.asMillis(Duration.clampPositive(neg))).toBe(0); + expect(Duration.asMillis(Duration.clampPositive(small))).toBe(100); + + expect(Duration.isFinite(Duration.fromMillis(100))).toBe(true); + expect( + Duration.isFinite(Duration.fromMillis(Number.POSITIVE_INFINITY)), + ).toBe(false); + }); +}); + +describe("MediaTime", () => { + it("converts millis and handles affine algebra", () => { + const t1 = MediaTime.fromMillis(1000); + const t2 = MediaTime.fromMillis(2500); + + expect(MediaTime.asMillis(t1)).toBe(1000); + expect(MediaTime.asMillis(t2)).toBe(2500); + + const span = MediaTime.since(t2, t1); + expect(Duration.asMillis(span)).toBe(1500); + + const satSpan = MediaTime.saturatingSince(t1, t2); + expect(Duration.asMillis(satSpan)).toBe(0); + + const d = Duration.fromMillis(500); + const added = MediaTime.add(t1, d); + expect(MediaTime.asMillis(added)).toBe(1500); + + const subbed = MediaTime.sub(t2, d); + expect(MediaTime.asMillis(subbed)).toBe(2000); + }); + + it("handles min, max, cmp, round", () => { + const t1 = MediaTime.fromMillis(1000.4); + const t2 = MediaTime.fromMillis(2000.8); + + expect(MediaTime.asMillis(MediaTime.min(t1, t2))).toBe(1000.4); + expect(MediaTime.asMillis(MediaTime.max(t1, t2))).toBe(2000.8); + + expect(MediaTime.cmp(t1, t2)).toBeLessThan(0); + expect(MediaTime.cmp(t2, t1)).toBeGreaterThan(0); + expect(MediaTime.cmp(t1, t1)).toBe(0); + + expect(MediaTime.asMillis(MediaTime.round(t1))).toBe(1000); + expect(MediaTime.asMillis(MediaTime.round(t2))).toBe(2001); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 9e5fc68e32..47235d94b1 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src"] + "include": ["src", "test"] } diff --git a/packages/docs/src/components/ApiTester/trigger.css b/packages/docs/src/components/ApiTester/trigger.css index 7a0e9cec64..82ede39daa 100644 --- a/packages/docs/src/components/ApiTester/trigger.css +++ b/packages/docs/src/components/ApiTester/trigger.css @@ -121,11 +121,16 @@ .skeleton-pulse { animation: amll-skeleton-shimmer 1.4s ease-in-out infinite; - background: color-mix(in srgb, var(--sl-color-text-accent) 15%, var(--sl-color-bg-inline-code)); + background: color-mix( + in srgb, + var(--sl-color-text-accent) 15%, + var(--sl-color-bg-inline-code) + ); } @keyframes amll-skeleton-shimmer { - 0%, 100% { + 0%, + 100% { opacity: 0.35; } 50% {