diff --git a/src/elements/score.ts b/src/elements/score.ts index f2dbe6b48..199a3402f 100644 --- a/src/elements/score.ts +++ b/src/elements/score.ts @@ -29,6 +29,7 @@ export class Score { private systems: System[] ) {} + /** Creates a Score from the given rendering context and score render tree. */ static create( config: Config, log: Logger, @@ -64,6 +65,13 @@ export class Score { return this.root.getScrollContainer(); } + /** + * Creates and registers a playback cursor scoped to a single part. + * + * @param opts.partIndex The part the cursor advances through. Defaults to the first part. + * @param opts.span The vertical range of parts the cursor visually spans. Defaults to spanning all parts. + * @returns The newly created cursor. The cursor is owned by the Score and will be cleaned up on `destroy()`. + */ addCursor(opts?: { partIndex?: number; span?: playback.CursorVerticalSpan }): playback.Cursor { const partCount = this.getPartCount(); @@ -80,8 +88,7 @@ export class Score { const elementDescriber = playback.ElementDescriber.create(this, { partIndex }); const frames = playback.DefaultCursorFrame.create(this.log, this, timeline, span, elementDescriber); - const path = new playback.CursorPath(partIndex, frames); - const cursor = playback.Cursor.create(path, this.getScrollContainer(), elementDescriber); + const cursor = playback.Cursor.create(frames, this.getScrollContainer(), elementDescriber); this.cursors.push(cursor); @@ -375,7 +382,7 @@ export class Score { @util.memoize() private getTimestampLocator(): playback.TimestampLocator { - const paths = new Array(); + const partFrames = new Array(); const timelines = this.getTimelines(); for (let partIndex = 0; partIndex < this.getPartCount(); partIndex++) { @@ -384,10 +391,9 @@ export class Score { const span = { fromPartIndex: partIndex, toPartIndex: partIndex }; const elementDescriber = playback.ElementDescriber.create(this, { partIndex }); const frames = playback.DefaultCursorFrame.create(this.log, this, timeline, span, elementDescriber); - const path = new playback.CursorPath(partIndex, frames); - paths.push(path); + partFrames.push(frames); } - return playback.TimestampLocator.create(this, paths); + return playback.TimestampLocator.create(this, partFrames); } } diff --git a/src/playback/bsearchcursorframelocator.ts b/src/playback/bsearchcursorframelocator.ts index a1ae58629..d253e0457 100644 --- a/src/playback/bsearchcursorframelocator.ts +++ b/src/playback/bsearchcursorframelocator.ts @@ -1,16 +1,15 @@ import * as util from '@/util'; -import { CursorFrameLocator } from './types'; +import { CursorFrame, CursorFrameLocator } from './types'; import { Duration } from './duration'; -import { CursorPath } from './cursorpath'; /** * A CursorFrameLocator that uses binary search to locate the frame at a given time. */ export class BSearchCursorFrameLocator implements CursorFrameLocator { - constructor(private path: CursorPath) {} + constructor(private frames: CursorFrame[]) {} locate(time: Duration): number | null { - const frames = this.path.getFrames(); + const frames = this.frames; let left = 0; let right = frames.length - 1; diff --git a/src/playback/cursor.ts b/src/playback/cursor.ts index f29ed14f3..30a5d6d87 100644 --- a/src/playback/cursor.ts +++ b/src/playback/cursor.ts @@ -6,7 +6,6 @@ import { CursorFrame, CursorFrameLocator, CursorStateHintProvider } from './type import { FastCursorFrameLocator } from './fastcursorframelocator'; import { BSearchCursorFrameLocator } from './bsearchcursorframelocator'; import { Duration } from './duration'; -import { CursorPath } from './cursorpath'; import { LazyCursorStateHintProvider } from './lazycursorstatehintprovider'; import { EmptyCursorFrame } from './emptycursorframe'; import { ElementDescriber } from './elementdescriber'; @@ -38,28 +37,34 @@ export class Cursor { private previousFrame: CursorFrame = new EmptyCursorFrame(); private constructor( - private path: CursorPath, + private frames: CursorFrame[], private locator: CursorFrameLocator, private scroller: Scroller, private elementDescriber: ElementDescriber ) {} - static create(path: CursorPath, scrollContainer: HTMLElement, elementDescriber: ElementDescriber): Cursor { - const bSearchLocator = new BSearchCursorFrameLocator(path); - const fastLocator = new FastCursorFrameLocator(path, bSearchLocator); + /** Creates a Cursor over the given frames, scrolling within the provided container. */ + static create(frames: CursorFrame[], scrollContainer: HTMLElement, elementDescriber: ElementDescriber): Cursor { + const bSearchLocator = new BSearchCursorFrameLocator(frames); + const fastLocator = new FastCursorFrameLocator(frames, bSearchLocator); const scroller = new Scroller(scrollContainer); - return new Cursor(path, fastLocator, scroller, elementDescriber); + return new Cursor(frames, fastLocator, scroller, elementDescriber); } + /** + * Returns an iterable that walks every frame from the current position to the end. Iteration uses + * an internal clone, so this cursor's position is not modified. + */ iterable(): Iterable { // Clone the cursor to avoid modifying the index of this instance. - const cursor = new Cursor(this.path, this.locator, this.scroller, this.elementDescriber); + const cursor = new Cursor(this.frames, this.locator, this.scroller, this.elementDescriber); return new CursorIterator(cursor); } + /** Returns the cursor's current state. */ getCurrentState(): CursorState { const index = this.index; - const hasNext = index < this.path.getFrames().length - 1; + const hasNext = index < this.frames.length - 1; const hasPrevious = index > 0; const frame = this.getCurrentFrame(); const rect = this.getCursorRect(frame, this.alpha); @@ -76,18 +81,26 @@ export class Cursor { }; } + /** Returns the underlying frame array backing this cursor. */ + getCursorFrames(): CursorFrame[] { + return this.frames; + } + + /** Advances to the next frame, or completes the final frame (alpha = 1) if already at the end. */ next(): void { - if (this.index === this.path.getFrames().length - 1) { + if (this.index === this.frames.length - 1) { this.update(this.index, { alpha: 1 }); } else { this.update(this.index + 1, { alpha: 0 }); } } + /** Moves to the previous frame. Clamped at the first frame. */ previous(): void { this.update(this.index - 1, { alpha: 0 }); } + /** Jumps to the frame at the given frame index. Out-of-range indices are clamped. */ goTo(index: number): void { this.update(index, { alpha: 0 }); } @@ -105,7 +118,7 @@ export class Cursor { const time = this.normalize(timestampMs); const index = this.locator.locate(time); util.assertNotNull(index, 'Cursor frame locator failed to find a frame.'); - const entry = this.path.getFrames().at(index); + const entry = this.frames.at(index); util.assertDefined(entry); const left = entry.tRange.start; @@ -115,16 +128,24 @@ export class Cursor { this.update(index, { alpha }); } + /** Returns whether the cursor's current rect is fully within the scroll container's viewport. */ isFullyVisible(): boolean { const cursorRect = this.getCurrentState().rect; return this.scroller.isFullyVisible(cursorRect); } + /** Scrolls the container so the cursor is centered horizontally and aligned to the top. */ scrollIntoView(behavior: ScrollBehavior = 'auto'): void { const scrollPoint = this.getScrollPoint(); this.scroller.scrollTo(scrollPoint, behavior); } + /** + * Subscribes a listener to a cursor event. + * + * @param opts.emitBootstrapEvent When true, immediately invokes the listener with the current state. + * @returns A subscription id that can be passed to `removeEventListener` to detach. + */ addEventListener( name: N, listener: events.EventListener, @@ -137,18 +158,20 @@ export class Cursor { return id; } + /** Detaches one or more listeners by their subscription ids. */ removeEventListener(...ids: number[]): void { for (const id of ids) { this.topic.unsubscribe(id); } } + /** Detaches every listener registered on this cursor. */ removeAllEventListeners(): void { this.topic.unsubscribeAll(); } private getCurrentFrame(): CursorFrame { - return this.path.getFrames().at(this.index) ?? new EmptyCursorFrame(); + return this.frames.at(this.index) ?? new EmptyCursorFrame(); } private getScrollPoint(): Point { @@ -164,7 +187,7 @@ export class Cursor { } private getDuration(): Duration { - return this.path.getFrames().at(-1)?.tRange.end ?? Duration.zero(); + return this.frames.at(-1)?.tRange.end ?? Duration.zero(); } private getCursorRect(frame: CursorFrame, alpha: number): Rect { @@ -176,7 +199,7 @@ export class Cursor { } private update(index: number, { alpha }: { alpha: number }): void { - index = util.clamp(0, this.path.getFrames().length - 1, index); + index = util.clamp(0, this.frames.length - 1, index); alpha = util.clamp(0, 1, alpha); // Round to 3 decimal places to avoid overloading the event system with redundant updates. alpha = Math.round(alpha * 1000) / 1000; diff --git a/src/playback/cursorpath.ts b/src/playback/cursorpath.ts deleted file mode 100644 index 998fa6467..000000000 --- a/src/playback/cursorpath.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { CursorFrame } from './types'; - -/** A collection of cursor frames for a given part index.. */ -export class CursorPath { - constructor(private partIndex: number, private frames: CursorFrame[]) {} - - getPartIndex(): number { - return this.partIndex; - } - - getFrames(): CursorFrame[] { - return this.frames; - } -} diff --git a/src/playback/fastcursorframelocator.ts b/src/playback/fastcursorframelocator.ts index bd1e347e6..92e07ddab 100644 --- a/src/playback/fastcursorframelocator.ts +++ b/src/playback/fastcursorframelocator.ts @@ -1,6 +1,5 @@ -import { CursorPath } from './cursorpath'; import { Duration } from './duration'; -import { CursorFrameLocator } from './types'; +import { CursorFrame, CursorFrameLocator } from './types'; /** * A CursorFrameLocator that uses O(1) time complexity to locate the frame at a given time before falling back to a more @@ -9,10 +8,10 @@ import { CursorFrameLocator } from './types'; export class FastCursorFrameLocator implements CursorFrameLocator { private index = 0; - constructor(private path: CursorPath, private fallback: CursorFrameLocator) {} + constructor(private frames: CursorFrame[], private fallback: CursorFrameLocator) {} locate(time: Duration): number | null { - const frames = this.path.getFrames(); + const frames = this.frames; if (time.isLessThan(Duration.zero())) { return this.update(0); @@ -53,6 +52,6 @@ export class FastCursorFrameLocator implements CursorFrameLocator { } private getDuration(): Duration { - return this.path.getFrames().at(-1)?.tRange.end ?? Duration.zero(); + return this.frames.at(-1)?.tRange.end ?? Duration.zero(); } } diff --git a/src/playback/index.ts b/src/playback/index.ts index 71a62240a..36ed2d159 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -1,5 +1,4 @@ export * from './cursor'; -export * from './cursorpath'; export * from './duration'; export * from './durationrange'; export * from './timestamplocator'; diff --git a/src/playback/timestamplocator.ts b/src/playback/timestamplocator.ts index ee707e9e9..cc3713622 100644 --- a/src/playback/timestamplocator.ts +++ b/src/playback/timestamplocator.ts @@ -2,7 +2,6 @@ import * as spatial from '@/spatial'; import * as elements from '@/elements'; import * as util from '@/util'; import { Duration } from './duration'; -import { CursorPath } from './cursorpath'; import { CursorFrame } from './types'; type System = { @@ -13,18 +12,16 @@ type System = { export class TimestampLocator { private constructor(private systems: System[]) {} - static create(score: elements.Score, paths: CursorPath[]): TimestampLocator { + static create(score: elements.Score, partFrames: CursorFrame[][]): TimestampLocator { const systems = score.getSystems().map((system) => { const yRange = new util.NumberRange(system.rect().top(), system.rect().bottom()); const frames = new Array(); - for (const path of paths) { + for (const framesForPart of partFrames) { frames.push( - ...path - .getFrames() - .filter((frame) => - frame.getActiveElements().some((element) => element.getSystemIndex() === system.getIndex()) - ) + ...framesForPart.filter((frame) => + frame.getActiveElements().some((element) => element.getSystemIndex() === system.getIndex()) + ) ); }