From 9c92388a99bb86ae377c0bc842c555910de708f1 Mon Sep 17 00:00:00 2001 From: danielku15 Date: Sun, 1 Feb 2026 16:00:39 +0100 Subject: [PATCH 1/2] feat: animate beat cursor to end of playback range --- packages/alphatab/src/AlphaTabApiBase.ts | 7 +- packages/alphatab/src/midi/MidiTickLookup.ts | 86 ++++--- .../test/audio/MidiTickLookup.test.ts | 231 +++++++++++++++++- 3 files changed, 279 insertions(+), 45 deletions(-) diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index 288d0271e..bab412ce5 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -1487,6 +1487,9 @@ export class AlphaTabApiBase { public set playbackRange(value: PlaybackRange | null) { this._player.playbackRange = value; + if (this._tickCache) { + this._tickCache.playbackRange = value; + } this._updateSelectionCursor(value); } @@ -1650,6 +1653,8 @@ export class AlphaTabApiBase { generator.generate(); this._tickCache = generator.tickLookup; + this._tickCache.playbackRange = this.playbackRange; + this._onMidiLoad(midiFile); const player = this._player; @@ -2292,7 +2297,7 @@ export class AlphaTabApiBase { const isPlayingUpdate = this._player.state === PlayerState.Playing && !stop; - let nextBeatX: number = barBoundings.visualBounds.x + barBoundings.visualBounds.w; + let nextBeatX: number = beatBoundings.realBounds.x + beatBoundings.realBounds.w; let nextBeatBoundings: BeatBounds | null = null; // get position of next beat on same system if (nextBeat && cursorMode === MidiTickLookupFindBeatResultCursorMode.ToNextBext) { diff --git a/packages/alphatab/src/midi/MidiTickLookup.ts b/packages/alphatab/src/midi/MidiTickLookup.ts index c1881d4b9..c8d7ba688 100644 --- a/packages/alphatab/src/midi/MidiTickLookup.ts +++ b/packages/alphatab/src/midi/MidiTickLookup.ts @@ -4,6 +4,7 @@ import { MasterBarTickLookup } from '@coderline/alphatab/midi/MasterBarTickLooku import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; import type { Beat } from '@coderline/alphatab/model/Beat'; import type { MasterBar } from '@coderline/alphatab/model/MasterBar'; +import type { PlaybackRange } from '@coderline/alphatab/synth/PlaybackRange'; /** * Describes how a cursor should be moving. @@ -21,9 +22,15 @@ export enum MidiTickLookupFindBeatResultCursorMode { ToNextBext = 1, /** - * The cursor should animate to the end of the bar (typically on repeats and jumps) + * @deprecated replaced by {@link ToEndOfBeat} */ - ToEndOfBar = 2 + ToEndOfBar = 2, + + /** + * The cursor should animate to the end of the **beat** (typically on repeats and jumps) + * (this is named end of bar historically) + */ + ToEndOfBeat = 3 } /** @@ -193,6 +200,12 @@ export class MidiTickLookup { */ public multiBarRestInfo: Map | null = null; + /** + * An optional playback range to consider when performing lookups. + * This will mainly influence the used {@link MidiTickLookupFindBeatResultCursorMode} + */ + public playbackRange: PlaybackRange | null = null; + /** * Finds the currently played beat given a list of tracks and the current time. * @param trackLookup The tracks indices in which to search the played beat for. @@ -228,6 +241,14 @@ export class MidiTickLookup { result = this._findBeatSlow(checker, currentBeatHint, tick, false); } + if (result) { + const playbackRange = this.playbackRange; + const isBeyondRangeEnd = playbackRange !== null && result!.start >= playbackRange.endTick; + if (isBeyondRangeEnd) { + return null; + } + } + return result; } @@ -257,10 +278,7 @@ export class MidiTickLookup { return null; } - private _fillNextBeatMultiBarRest( - current: MidiTickLookupFindBeatResult, - checker: IBeatVisibilityChecker - ) { + private _fillNextBeatMultiBarRest(current: MidiTickLookupFindBeatResult, checker: IBeatVisibilityChecker) { const group = this.multiBarRestInfo!.get(current.masterBar.masterBar.index)!; // this is a bit sensitive. we assume that the sequence of multi-rest bars and the @@ -288,22 +306,27 @@ export class MidiTickLookup { current.tickDuration = current.nextBeat.start - current.start; current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToNextBext; + // jump back if ( current.nextBeat.masterBar.masterBar.index !== endMasterBar.masterBar.index + 1 && (current.nextBeat.masterBar.masterBar.index !== endMasterBar.masterBar.index || current.nextBeat.beat.playbackStart <= current.beat.playbackStart) ) { - current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBar; + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; + } + // beyond end of playback range + else if (this.playbackRange !== null && this.playbackRange.endTick <= current.nextBeat.start) { + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; } } // no next beat, animate to the end of the bar (could be an incomplete bar) else { current.tickDuration = endMasterBar.nextMasterBar.end - current.start; - current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBar; + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; } } else { current.tickDuration = endMasterBar.end - current.start; - current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBar; + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; } } else { Logger.warning( @@ -313,16 +336,13 @@ export class MidiTickLookup { // this is wierd, we have a masterbar without known tick? // make a best guess with the number of bars current.tickDuration = (current.masterBar.end - current.masterBar.start) * (group.length + 1); - current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBar; + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; } current.calculateDuration(); } - private _fillNextBeat( - current: MidiTickLookupFindBeatResult, - checker: IBeatVisibilityChecker - ) { + private _fillNextBeat(current: MidiTickLookupFindBeatResult, checker: IBeatVisibilityChecker) { // on multibar rests take the duration until the end. if (this._isMultiBarRestResult(current)) { this._fillNextBeatMultiBarRest(current, checker); @@ -330,10 +350,7 @@ export class MidiTickLookup { this._fillNextBeatDefault(current, checker); } } - private _fillNextBeatDefault( - current: MidiTickLookupFindBeatResult, - checker: IBeatVisibilityChecker - ) { + private _fillNextBeatDefault(current: MidiTickLookupFindBeatResult, checker: IBeatVisibilityChecker) { current.nextBeat = this._findBeatInMasterBar( current.masterBar, current.beatLookup.nextBeat, @@ -355,19 +372,24 @@ export class MidiTickLookup { // no next beat, animate to the end of the bar (could be an incomplete bar) else { current.tickDuration = current.masterBar.end - current.start; - current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBar; + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; current.calculateDuration(); } - // if the next beat is not directly the next master bar (e.g. jumping back or forth) - // we report no next beat and animate to the end - if ( - current.nextBeat && - current.nextBeat.masterBar.masterBar.index !== current.masterBar.masterBar.index + 1 && - (current.nextBeat.masterBar.masterBar.index !== current.masterBar.masterBar.index || - current.nextBeat.beat.playbackStart <= current.beat.playbackStart) - ) { - current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBar; + if (current.nextBeat) { + // if the next beat is not directly the next master bar (e.g. jumping back or forth) + // we report no next beat and animate to the end + if ( + current.nextBeat.masterBar.masterBar.index !== current.masterBar.masterBar.index + 1 && + (current.nextBeat.masterBar.masterBar.index !== current.masterBar.masterBar.index || + current.nextBeat.beat.playbackStart <= current.beat.playbackStart) + ) { + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; + } + // the next beat might also be the beyond the selected range + else if (this.playbackRange !== null && this.playbackRange.endTick <= current.nextBeat.start) { + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; + } } } @@ -430,13 +452,7 @@ export class MidiTickLookup { // scan through beats and find first one which has a beat visible while (masterBar) { if (masterBar.firstBeat) { - const beat = this._findBeatInMasterBar( - masterBar, - masterBar.firstBeat, - tick, - checker, - isNextSearch - ); + const beat = this._findBeatInMasterBar(masterBar, masterBar.firstBeat, tick, checker, isNextSearch); if (beat) { return beat; diff --git a/packages/alphatab/test/audio/MidiTickLookup.test.ts b/packages/alphatab/test/audio/MidiTickLookup.test.ts index 71d6ffbc1..b89433c8d 100644 --- a/packages/alphatab/test/audio/MidiTickLookup.test.ts +++ b/packages/alphatab/test/audio/MidiTickLookup.test.ts @@ -21,6 +21,7 @@ import type { Score } from '@coderline/alphatab/model/Score'; import { Settings } from '@coderline/alphatab/Settings'; import { TestPlatform } from 'test/TestPlatform'; import { expect } from 'chai'; +import { PlaybackRange } from '@coderline/alphatab/synth/PlaybackRange'; describe('MidiTickLookupTest', () => { function buildLookup(score: Score, settings: Settings): MidiTickLookup { @@ -663,7 +664,8 @@ describe('MidiTickLookupTest', () => { currentBeatIds: number[], nextBeatIds: number[], expectedCursorModes: MidiTickLookupFindBeatResultCursorMode[] | null = null, - skipClean: boolean = false + skipClean: boolean = false, + prepareLookup: ((lookup: MidiTickLookup) => void) | null = null ) { const buffer = ByteBuffer.fromString(tex); const settings = new Settings(); @@ -686,6 +688,10 @@ describe('MidiTickLookupTest', () => { ); } + if (prepareLookup) { + prepareLookup(lookup); + } + let currentLookup: MidiTickLookupFindBeatResult | null = null; const actualIncrementalIds: number[] = []; @@ -1119,16 +1125,16 @@ describe('MidiTickLookupTest', () => { ], [ // 1st bar - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, // 2nd bar - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, // 3rd bar - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, // 1st bar (repated) MidiTickLookupFindBeatResultCursorMode.ToNextBext, @@ -1141,10 +1147,217 @@ describe('MidiTickLookupTest', () => { MidiTickLookupFindBeatResultCursorMode.ToNextBext, // 4th bar - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat ], true ); }); + + it('simple-repeat', () => { + it('not-applied', () => { + lookupTest( + ` + C4.2 * 2 | + C4.2 * 2 | + \\ro + \\rc 2 + C4.2 * 2 | + C4.2 * 2 | + C4.2 * 2 | + `, + [ + // 1st bar + 0, 960, 1920, 2880, + + // 2nd bar + 3840, 4800, 5760, 6720, + + // 3rd bar + 7680, 8640, 9600, 10560, + + // 3rd bar (repeated) + 11520, 12480, 13440, 14400, + + // 4th bar + 15360, 16320, 17280, 18240, + + // 5th bar + 19200, 20160, 21120, 22080 + ], + [0], + [ + // 1st bar + 1920, 1920, 1920, 1920, + // 2nd bar + 1920, 1920, 1920, 1920, + // 3rd bar + 1920, 1920, 1920, 1920, + // 3rd bar (repeated) + 1920, 1920, 1920, 1920, + // 4th bar + 1920, 1920, 1920, 1920, + // 5th bar + 1920, 1920, 1920, 1920 + ], + [ + // 1st bar + 0, 0, 1, 1, + // 2nd bar + 2, 2, 3, 3, + // 3rd bar + 4, 4, 5, 5, + // 3rd bar (repeated) + 4, 4, 5, 5, + // 4th bar + 6, 6, 7, 7, + // 5th bar + 8, 8, 9, 9 + ], + [ + // 1st bar + 1, 1, 2, 2, + // 2nd bar + 3, 3, 4, 4, + // 3rd bar + 5, 5, 4, 4, + // 3rd bar (repeated) + 5, 5, 6, 6, + // 4th bar + 7, 7, 8, 8, + // 5th bar + 9, 9, -1, -1 + ], + [ + // 1st bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 2nd bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 3rd bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + // 3rd bar (repeated) + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 4th bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 5th bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat + ], + true + ); + }); + }); + + describe('playback-range', () => { + it('full-bar', () => { + lookupTest( + ` + C4.2 * 2 | + C4.2 * 2 + `, + [ + // 1st bar + 0, 960, 1920, 2880 + ], + [0], + [ + // 1st bar + 1920, 1920, 1920, 1920 + ], + [ + // 1st bar + 0, 0, 1, 1 + ], + [ + // 1st bar + 1, 1, 2, 2 + ], + [ + // 1st bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat + ], + true, + lookup => { + // whole first bar + const range = new PlaybackRange(); + range.startTick = 0; + range.endTick = 3840; + lookup.playbackRange = range; + } + ); + }); + + it('mid-bar', () => { + lookupTest( + ` + C4.2 * 2 | + C4.2 * 2 + `, + [ + // 1st bar + 0, 960, 1920, 2880, + + // 2nd bar + 3840, 4800 + ], + [0], + [ + // 1st bar + 1920, 1920, 1920, 1920, + // 2nd bar + 1920, 1920 + ], + [ + // 1st bar + 0, 0, 1, 1, + // 2nd bar + 2, 2 + ], + [ + // 1st bar + 1, 1, 2, 2, + // 2nd bar + 3, 3 + ], + [ + // 1st bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 2nd bar + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat + ], + true, + lookup => { + // whole first bar + const range = new PlaybackRange(); + range.startTick = 0; + range.endTick = 5760; + lookup.playbackRange = range; + } + ); + }); + }); }); From ad6c9d9da5f297a45e4b38e678f8e46290d96c7b Mon Sep 17 00:00:00 2001 From: danielku15 Date: Sun, 1 Feb 2026 19:07:33 +0100 Subject: [PATCH 2/2] feat: allow custom cursor handler implementations --- packages/alphatab/src/AlphaTabApiBase.ts | 235 ++++++++++++------ packages/alphatab/src/CursorHandler.ts | 144 +++++++++++ packages/alphatab/src/platform/_barrel.ts | 5 +- .../javascript/HtmlElementContainer.ts | 14 +- .../test/audio/MidiTickLookup.test.ts | 188 +++++++------- 5 files changed, 417 insertions(+), 169 deletions(-) create mode 100644 packages/alphatab/src/CursorHandler.ts diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index bab412ce5..6efea4815 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -10,7 +10,7 @@ import { import { AlphaTexImporter } from '@coderline/alphatab/importer/AlphaTexImporter'; import { Logger } from '@coderline/alphatab/Logger'; import { AlphaSynthMidiFileHandler } from '@coderline/alphatab/midi/AlphaSynthMidiFileHandler'; -import type { BeatTickLookupItem, IBeatVisibilityChecker } from '@coderline/alphatab/midi/BeatTickLookup'; +import type { IBeatVisibilityChecker } from '@coderline/alphatab/midi/BeatTickLookup'; import type { MetaDataEvent, MetaEvent, @@ -42,16 +42,20 @@ import { MidiTickLookupFindBeatResultCursorMode } from '@coderline/alphatab/midi/MidiTickLookup'; +import { + type ICursorHandler, + NonAnimatingCursorHandler, + ToNextBeatAnimatingCursorHandler +} from '@coderline/alphatab/CursorHandler'; import type { Beat } from '@coderline/alphatab/model/Beat'; import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import type { Note } from '@coderline/alphatab/model/Note'; import type { Score } from '@coderline/alphatab/model/Score'; import type { Track } from '@coderline/alphatab/model/Track'; -import { PlayerMode, ScrollMode } from '@coderline/alphatab/PlayerSettings'; import type { IContainer } from '@coderline/alphatab/platform/IContainer'; import type { IMouseEventArgs } from '@coderline/alphatab/platform/IMouseEventArgs'; import type { IUiFacade } from '@coderline/alphatab/platform/IUiFacade'; -import { ResizeEventArgs } from '@coderline/alphatab/ResizeEventArgs'; +import { PlayerMode, ScrollMode } from '@coderline/alphatab/PlayerSettings'; import { BeatContainerGlyph } from '@coderline/alphatab/rendering/glyphs/BeatContainerGlyph'; import type { IScoreRenderer, RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer'; import type { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs'; @@ -62,6 +66,7 @@ import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; import type { BoundsLookup } from '@coderline/alphatab/rendering/utils/BoundsLookup'; import type { MasterBarBounds } from '@coderline/alphatab/rendering/utils/MasterBarBounds'; import type { StaffSystemBounds } from '@coderline/alphatab/rendering/utils/StaffSystemBounds'; +import { ResizeEventArgs } from '@coderline/alphatab/ResizeEventArgs'; import { HorizontalContinuousScrollHandler, HorizontalOffScreenScrollHandler, @@ -88,6 +93,7 @@ import type { PlaybackRangeChangedEventArgs } from '@coderline/alphatab/synth/Pl import { PlayerState } from '@coderline/alphatab/synth/PlayerState'; import type { PlayerStateChangedEventArgs } from '@coderline/alphatab/synth/PlayerStateChangedEventArgs'; import type { PositionChangedEventArgs } from '@coderline/alphatab/synth/PositionChangedEventArgs'; +import { Cursors } from '@coderline/alphatab/platform/Cursors'; /** * @internal @@ -166,6 +172,8 @@ export class AlphaTabApiBase { private _renderer: ScoreRendererWrapper; private _defaultScrollHandler?: IScrollHandler; + private _defaultCursorHandler?: ICursorHandler; + private _customCursorHandler?: ICursorHandler; /** * An indicator by how many midi-ticks the song contents are shifted. @@ -988,6 +996,88 @@ export class AlphaTabApiBase { } } + /** + * A custom cursor handler which will be used to update the cursor positions during playback. + * + * @category Properties - Player + * @since 1.8.1 + * @example + * JavaScript + * ```js + * const api = new alphaTab.AlphaTabApi(document.querySelector('#alphaTab')); + * api.customCursorHandler = { + * _customAdorner: undefined, + * onAttach(cursors) { + * this._customAdorner = document.createElement('div'); + * this._customAdorner.classList.add('cursor-adorner'); + * cursors.cursorWrapper.element.appendChild(this._customAdorner); + * }, + * onDetach(cursors) { this._customAdorner.remove(); }, + * placeBarCursor(barCursor, beatBounds) { + * const barBoundings = beatBounds.barBounds.masterBarBounds; + * const barBounds = barBoundings.visualBounds; + * barCursor.setBounds(barBounds.x, barBounds.y, barBounds.w, barBounds.h); + * }, + * placeBeatCursor(beatCursor, beatBounds, startBeatX) { + * const barBoundings = beatBounds.barBounds.masterBarBounds; + * const barBounds = barBoundings.visualBounds; + * beatCursor.transitionToX(0, startBeatX); + * beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); + * this._customAdorner.style.left = startBeatX + 'px'; + * this._customAdorner.style.top = (barBounds.y - 10) + 'px'; + * this._customAdorner.style.width = '1px'; + * this._customAdorner.style.height = '10px'; + * this._customAdorner.style.transition = 'left 0ms linear'; // stop animation + * }, + * transitionBeatCursor(beatCursor, beatBounds, startBeatX, endBeatX, duration, cursorMode) { + * this._customAdorner.style.transition = `left ${duration}ms linear`; // start animation + * this._customAdorner.style.left = endBeatX + 'px'; + * } + * } + * ``` + * + * @example + * C# + * ```cs + * var api = new AlphaTabApi(...); + * api.CustomCursorHandler = new CustomCursorHandler(); + * ``` + * + * @example + * Android + * ```kotlin + * val api = AlphaTabApi(...) + * api.customCursorHandler = CustomCursorHandler(); + * ``` + */ + public get customCursorHandler(): ICursorHandler | undefined { + return this._customCursorHandler; + } + + public set customCursorHandler(value: ICursorHandler | undefined) { + if (this._customCursorHandler === value) { + return; + } + const currentHandler = this._customCursorHandler ?? this._defaultCursorHandler; + + this._customCursorHandler = value; + if (this._cursorWrapper) { + const cursors = new Cursors( + this._cursorWrapper, + this._barCursor!, + this._beatCursor!, + this._selectionWrapper! + ); + + currentHandler?.onDetach(cursors); + if (value) { + value?.onDetach(cursors); + } else if (this._defaultCursorHandler) { + this._defaultCursorHandler!.onAttach(cursors); + } + } + } + private _tickCache: MidiTickLookup | null = null; /** @@ -2075,6 +2165,10 @@ export class AlphaTabApiBase { if (!this._cursorWrapper) { return; } + const cursorHandler = this.customCursorHandler ?? this._defaultCursorHandler!; + cursorHandler?.onDetach( + new Cursors(this._cursorWrapper, this._barCursor!, this._beatCursor!, this._selectionWrapper!) + ); this.uiFacade.destroyCursors(); this._cursorWrapper = null; this._barCursor = null; @@ -2093,6 +2187,9 @@ export class AlphaTabApiBase { this._barCursor = cursors.barCursor; this._beatCursor = cursors.beatCursor; this._selectionWrapper = cursors.selectionWrapper; + const cursorHandler = this.customCursorHandler ?? this._defaultCursorHandler!; + cursorHandler?.onAttach(cursors); + this._isInitialBeatCursorUpdate = true; } if (this._currentBeat !== null) { @@ -2101,6 +2198,7 @@ export class AlphaTabApiBase { } private _updateCursors() { + this._updateCursorHandler(); this._updateScrollHandler(); const enable = this._hasCursor; @@ -2111,6 +2209,23 @@ export class AlphaTabApiBase { } } + private _cursorHandlerMode = false; + private _updateCursorHandler() { + const currentHandler = this._defaultCursorHandler; + + const cursorHandlerMode = this.settings.player.enableAnimatedBeatCursor; + // no change + if (currentHandler !== undefined && this._cursorHandlerMode === cursorHandlerMode) { + return; + } + + if (cursorHandlerMode) { + this._defaultCursorHandler = new ToNextBeatAnimatingCursorHandler(); + } else { + this._defaultCursorHandler = new NonAnimatingCursorHandler(); + } + } + private _scrollHandlerMode = ScrollMode.Off; private _scrollHandlerVertical = true; private _updateScrollHandler() { @@ -2204,10 +2319,6 @@ export class AlphaTabApiBase { forceUpdate: boolean = false ): void { const beat: Beat = lookupResult.beat; - const nextBeat: Beat | null = lookupResult.nextBeat?.beat ?? null; - const duration: number = lookupResult.duration; - const beatsToHighlight = lookupResult.beatLookup.highlightedBeats; - if (!beat) { return; } @@ -2240,18 +2351,7 @@ export class AlphaTabApiBase { this._previousStateForCursor = this._player.state; this.uiFacade.beginInvoke(() => { - this._internalCursorUpdateBeat( - beat, - nextBeat, - duration, - stop, - beatsToHighlight, - cache!, - beatBoundings!, - shouldScroll, - lookupResult.cursorMode, - cursorSpeed - ); + this._internalCursorUpdateBeat(lookupResult, stop, cache!, beatBoundings!, shouldScroll, cursorSpeed); }); } @@ -2271,19 +2371,22 @@ export class AlphaTabApiBase { } private _internalCursorUpdateBeat( - beat: Beat, - nextBeat: Beat | null, - duration: number, + lookupResult: MidiTickLookupFindBeatResult, stop: boolean, - beatsToHighlight: BeatTickLookupItem[], - cache: BoundsLookup, + boundsLookup: BoundsLookup, beatBoundings: BeatBounds, shouldScroll: boolean, - cursorMode: MidiTickLookupFindBeatResultCursorMode, cursorSpeed: number ) { - const barCursor = this._barCursor; + const beat = lookupResult.beat; + const nextBeat = lookupResult.nextBeat?.beat; + let duration = lookupResult.duration; + const beatsToHighlight = lookupResult.beatLookup.highlightedBeats; + const cursorMode = lookupResult.cursorMode; + const cursorHandler = this.customCursorHandler ?? this._defaultCursorHandler!; + const beatCursor = this._beatCursor; + const barCursor = this._barCursor; const barBoundings: MasterBarBounds = beatBoundings.barBounds.masterBarBounds; const barBounds: Bounds = barBoundings.visualBounds; @@ -2292,7 +2395,7 @@ export class AlphaTabApiBase { this._currentBeatBounds = beatBoundings; if (barCursor) { - barCursor.setBounds(barBounds.x, barBounds.y, barBounds.w, barBounds.h); + cursorHandler.placeBarCursor(barCursor, beatBoundings); } const isPlayingUpdate = this._player.state === PlayerState.Playing && !stop; @@ -2303,7 +2406,7 @@ export class AlphaTabApiBase { if (nextBeat && cursorMode === MidiTickLookupFindBeatResultCursorMode.ToNextBext) { // if we are moving within the same bar or to the next bar // transition to the next beat, otherwise transition to the end of the bar. - nextBeatBoundings = cache.findBeat(nextBeat); + nextBeatBoundings = boundsLookup.findBeat(nextBeat); if ( nextBeatBoundings && nextBeatBoundings.barBounds.masterBarBounds.staffSystemBounds === barBoundings.staffSystemBounds @@ -2314,52 +2417,42 @@ export class AlphaTabApiBase { let startBeatX = beatBoundings.onNotesX; if (beatCursor) { - // relative positioning of the cursor - if (this.settings.player.enableAnimatedBeatCursor) { - const animationWidth = nextBeatX - beatBoundings.onNotesX; - const relativePosition = this._previousTick - this._currentBeat!.start; - const ratioPosition = - this._currentBeat!.tickDuration > 0 ? relativePosition / this._currentBeat!.tickDuration : 0; - startBeatX = beatBoundings.onNotesX + animationWidth * ratioPosition; - duration -= duration * ratioPosition; - - if (isPlayingUpdate) { - // we do not "reset" the cursor if we are smoothly moving from left to right. - const jumpCursor = - !previousBeatBounds || - this._isInitialBeatCursorUpdate || - barBounds.y !== previousBeatBounds.barBounds.masterBarBounds.visualBounds.y || - startBeatX < previousBeatBounds.onNotesX || - barBoundings.index > previousBeatBounds.barBounds.masterBarBounds.index + 1; - - if (jumpCursor) { - beatCursor.transitionToX(0, startBeatX); - beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); - } - - // it can happen that the cursor reaches the target position slightly too early (especially on backing tracks) - // to avoid the cursor stopping, causing a wierd look, we animate the cursor to the double position in double time. - // beatCursor!.transitionToX((duration / cursorSpeed), nextBeatX); - const factor = cursorMode === MidiTickLookupFindBeatResultCursorMode.ToNextBext ? 2 : 1; - nextBeatX = startBeatX + (nextBeatX - startBeatX) * factor; - duration = (duration / cursorSpeed) * factor; - - // we need to put the transition to an own animation frame - // otherwise the stop animation above is not applied. - this.uiFacade.beginInvoke(() => { - beatCursor!.transitionToX(duration, nextBeatX); - }); - } else { - duration = 0; - beatCursor.transitionToX(duration, nextBeatX); - beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); + const animationWidth = nextBeatX - beatBoundings.onNotesX; + const relativePosition = this._previousTick - this._currentBeat!.start; + const ratioPosition = + this._currentBeat!.tickDuration > 0 ? relativePosition / this._currentBeat!.tickDuration : 0; + startBeatX = beatBoundings.onNotesX + animationWidth * ratioPosition; + duration -= duration * ratioPosition; + + // respect speed + duration = duration / cursorSpeed; + + if (isPlayingUpdate) { + // we do not "reset" the cursor if we are smoothly moving from left to right. + const jumpCursor = + !previousBeatBounds || + this._isInitialBeatCursorUpdate || + barBounds.y !== previousBeatBounds.barBounds.masterBarBounds.visualBounds.y || + startBeatX < previousBeatBounds.onNotesX || + barBoundings.index > previousBeatBounds.barBounds.masterBarBounds.index + 1; + + if (jumpCursor) { + cursorHandler.placeBeatCursor(beatCursor, beatBoundings, startBeatX); } + + this.uiFacade.beginInvoke(() => { + cursorHandler.transitionBeatCursor( + beatCursor, + beatBoundings, + startBeatX, + nextBeatX, + duration, + cursorMode + ); + }); } else { - // ticking cursor duration = 0; - nextBeatX = startBeatX; - beatCursor.transitionToX(duration, nextBeatX); - beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); + cursorHandler.placeBeatCursor(beatCursor, beatBoundings, startBeatX); } this._isInitialBeatCursorUpdate = false; diff --git a/packages/alphatab/src/CursorHandler.ts b/packages/alphatab/src/CursorHandler.ts new file mode 100644 index 000000000..d2edad57e --- /dev/null +++ b/packages/alphatab/src/CursorHandler.ts @@ -0,0 +1,144 @@ +import { MidiTickLookupFindBeatResultCursorMode } from '@coderline/alphatab/midi/MidiTickLookup'; +import type { Cursors } from '@coderline/alphatab/platform/Cursors'; +import type { IContainer } from '@coderline/alphatab/platform/IContainer'; +import type { BeatBounds } from '@coderline/alphatab/rendering/_barrel'; + +/** + * Classes implementing this interface can handle the cursor placement logic + * as the playback in alphaTab progresses. + * + * @public + */ +export interface ICursorHandler { + /** + * Called when this handler activates. This can be on dynamic cursor creation + * or when setting a custom handler with cursors already created. + * @param cursors A container holding information about the cursor elements. + */ + onAttach(cursors: Cursors): void; + /** + * Called when this handler deactivates. This can be on dynamic cursor destroy + * or when setting a new custom handler. + * @param cursors A container holding information about the cursor elements. + */ + onDetach(cursors: Cursors): void; + + /** + * Instructs the handler to place the bar cursor for the given beat bounds instantly . + * @param barCursor The bar cursor. + * @param beatBounds The bounds of the currently active beat. + */ + placeBarCursor(barCursor: IContainer, beatBounds: BeatBounds): void; + + /** + * Instructs the handler to place the beat cursor for the given beat bounds instantly. + * @param barCursor The beat cursor. + * @param beatBounds The bounds of the currently active beat. + */ + placeBeatCursor(beatCursor: IContainer, beatBounds: BeatBounds, startBeatX: number): void; + + /** + * Instructs the handler to initiate a transition of the beat cursor (e.g. for dynamic animation). + * @param beatCursor The beat cursor + * @param beatBounds The bounds of the currently active beat. + * @param startBeatX The X-position where the transition of the beat cursor should start. + * @param nextBeatX The X-position where the transition of the beat cursor should end + * (typically the next beat or end of bar depending on the cursor mode and seeks) + * @param duration The duration in milliseconds on how long the transition should take. + * @param cursorMode The active cursor mode for the cursor placement. + */ + transitionBeatCursor( + beatCursor: IContainer, + beatBounds: BeatBounds, + startBeatX: number, + nextBeatX: number, + duration: number, + cursorMode: MidiTickLookupFindBeatResultCursorMode + ): void; +} + +/** + * A cursor handler which animates the beat cursor to the next beat or end of the beat bounds + * depending on the cursor mode. + * @internal + */ +export class ToNextBeatAnimatingCursorHandler implements ICursorHandler { + public onAttach(_cursors: Cursors): void { + // nothing to do + } + + public onDetach(_cursors: Cursors): void { + // nothing to do + } + + public placeBeatCursor(beatCursor: IContainer, beatBounds: BeatBounds, startBeatX: number): void { + const barBoundings = beatBounds.barBounds.masterBarBounds; + const barBounds = barBoundings.visualBounds; + beatCursor.transitionToX(0, startBeatX); + beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); + } + + public placeBarCursor(barCursor: IContainer, beatBounds: BeatBounds): void { + const barBoundings = beatBounds.barBounds.masterBarBounds; + const barBounds = barBoundings.visualBounds; + barCursor.setBounds(barBounds.x, barBounds.y, barBounds.w, barBounds.h); + } + + public transitionBeatCursor( + beatCursor: IContainer, + _beatBounds: BeatBounds, + startBeatX: number, + nextBeatX: number, + duration: number, + cursorMode: MidiTickLookupFindBeatResultCursorMode + ): void { + // it can happen that the cursor reaches the target position slightly too early (especially on backing tracks) + // to avoid the cursor stopping, causing a wierd look, we animate the cursor to the double position in double time. + // beatCursor!.transitionToX((duration / cursorSpeed), nextBeatX); + const factor = cursorMode === MidiTickLookupFindBeatResultCursorMode.ToNextBext ? 2 : 1; + nextBeatX = startBeatX + (nextBeatX - startBeatX) * factor; + duration = duration * factor; + + // we need to put the transition to an own animation frame + // otherwise the stop animation above is not applied. + beatCursor!.transitionToX(duration, nextBeatX); + } +} + +/** + * A cursor handler which just places the bar and beat cursor without any animations applied. + * @internal + */ +export class NonAnimatingCursorHandler implements ICursorHandler { + public onAttach(_cursors: Cursors): void { + // nothing to do + } + + public onDetach(_cursors: Cursors): void { + // nothing to do + } + + public placeBeatCursor(beatCursor: IContainer, beatBounds: BeatBounds, startBeatX: number): void { + const barBoundings = beatBounds.barBounds.masterBarBounds; + const barBounds = barBoundings.visualBounds; + beatCursor.transitionToX(0, startBeatX); + beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); + } + + public placeBarCursor(barCursor: IContainer, beatBounds: BeatBounds): void { + const barBoundings = beatBounds.barBounds.masterBarBounds; + const barBounds = barBoundings.visualBounds; + barCursor.setBounds(barBounds.x, barBounds.y, barBounds.w, barBounds.h); + } + + public transitionBeatCursor( + beatCursor: IContainer, + beatBounds: BeatBounds, + startBeatX: number, + _nextBeatX: number, + _duration: number, + _cursorMode: MidiTickLookupFindBeatResultCursorMode + ): void { + this.placeBeatCursor(beatCursor, beatBounds, startBeatX); + } +} diff --git a/packages/alphatab/src/platform/_barrel.ts b/packages/alphatab/src/platform/_barrel.ts index 30f252264..33eb21d92 100644 --- a/packages/alphatab/src/platform/_barrel.ts +++ b/packages/alphatab/src/platform/_barrel.ts @@ -1,8 +1,9 @@ export { Cursors } from '@coderline/alphatab/platform/Cursors'; -export { type ICanvas, TextAlign, TextBaseline, MeasuredText } from '@coderline/alphatab/platform/ICanvas'; +export { type ICanvas, MeasuredText, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; export type { IContainer } from '@coderline/alphatab/platform/IContainer'; export type { IMouseEventArgs } from '@coderline/alphatab/platform/IMouseEventArgs'; export type { IUiFacade } from '@coderline/alphatab/platform/IUiFacade'; +export type { IHtmlElementContainer } from '@coderline/alphatab/platform/javascript/HtmlElementContainer'; export { CssFontSvgCanvas } from '@coderline/alphatab/platform/svg/CssFontSvgCanvas'; -export { FontSizes, FontSizeDefinition } from '@coderline/alphatab/platform/svg/FontSizes'; +export { FontSizeDefinition, FontSizes } from '@coderline/alphatab/platform/svg/FontSizes'; export { SvgCanvas } from '@coderline/alphatab/platform/svg/SvgCanvas'; diff --git a/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts b/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts index 8c871cb84..a440ec5eb 100644 --- a/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts +++ b/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts @@ -5,11 +5,23 @@ import { BrowserMouseEventArgs } from '@coderline/alphatab/platform/javascript/B import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; import { Lazy } from '@coderline/alphatab/util/Lazy'; +/** + * A UI element implementation wrapping HTML elements. + * @target web + * @public + */ +export interface IHtmlElementContainer extends IContainer{ + /** + * The wrapped UI element. + */ + readonly element:HTMLElement; +} + /** * @target web * @internal */ -export class HtmlElementContainer implements IContainer { +export class HtmlElementContainer implements IHtmlElementContainer { private static _resizeObserver: Lazy = new Lazy( () => new ResizeObserver((entries: ResizeObserverEntry[]) => { diff --git a/packages/alphatab/test/audio/MidiTickLookup.test.ts b/packages/alphatab/test/audio/MidiTickLookup.test.ts index b89433c8d..8082468a0 100644 --- a/packages/alphatab/test/audio/MidiTickLookup.test.ts +++ b/packages/alphatab/test/audio/MidiTickLookup.test.ts @@ -1155,9 +1155,8 @@ describe('MidiTickLookupTest', () => { }); it('simple-repeat', () => { - it('not-applied', () => { - lookupTest( - ` + lookupTest( + ` C4.2 * 2 | C4.2 * 2 | \\ro @@ -1166,103 +1165,102 @@ describe('MidiTickLookupTest', () => { C4.2 * 2 | C4.2 * 2 | `, - [ - // 1st bar - 0, 960, 1920, 2880, + [ + // 1st bar + 0, 960, 1920, 2880, - // 2nd bar - 3840, 4800, 5760, 6720, + // 2nd bar + 3840, 4800, 5760, 6720, - // 3rd bar - 7680, 8640, 9600, 10560, + // 3rd bar + 7680, 8640, 9600, 10560, - // 3rd bar (repeated) - 11520, 12480, 13440, 14400, + // 3rd bar (repeated) + 11520, 12480, 13440, 14400, - // 4th bar - 15360, 16320, 17280, 18240, + // 4th bar + 15360, 16320, 17280, 18240, - // 5th bar - 19200, 20160, 21120, 22080 - ], - [0], - [ - // 1st bar - 1920, 1920, 1920, 1920, - // 2nd bar - 1920, 1920, 1920, 1920, - // 3rd bar - 1920, 1920, 1920, 1920, - // 3rd bar (repeated) - 1920, 1920, 1920, 1920, - // 4th bar - 1920, 1920, 1920, 1920, - // 5th bar - 1920, 1920, 1920, 1920 - ], - [ - // 1st bar - 0, 0, 1, 1, - // 2nd bar - 2, 2, 3, 3, - // 3rd bar - 4, 4, 5, 5, - // 3rd bar (repeated) - 4, 4, 5, 5, - // 4th bar - 6, 6, 7, 7, - // 5th bar - 8, 8, 9, 9 - ], - [ - // 1st bar - 1, 1, 2, 2, - // 2nd bar - 3, 3, 4, 4, - // 3rd bar - 5, 5, 4, 4, - // 3rd bar (repeated) - 5, 5, 6, 6, - // 4th bar - 7, 7, 8, 8, - // 5th bar - 9, 9, -1, -1 - ], - [ - // 1st bar - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - // 2nd bar - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - // 3rd bar - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, - MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, - // 3rd bar (repeated) - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - // 4th bar - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - // 5th bar - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToNextBext, - MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, - MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat - ], - true - ); - }); + // 5th bar + 19200, 20160, 21120, 22080 + ], + [0], + [ + // 1st bar + 1920, 1920, 1920, 1920, + // 2nd bar + 1920, 1920, 1920, 1920, + // 3rd bar + 1920, 1920, 1920, 1920, + // 3rd bar (repeated) + 1920, 1920, 1920, 1920, + // 4th bar + 1920, 1920, 1920, 1920, + // 5th bar + 1920, 1920, 1920, 1920 + ], + [ + // 1st bar + 0, 0, 1, 1, + // 2nd bar + 2, 2, 3, 3, + // 3rd bar + 4, 4, 5, 5, + // 3rd bar (repeated) + 4, 4, 5, 5, + // 4th bar + 6, 6, 7, 7, + // 5th bar + 8, 8, 9, 9 + ], + [ + // 1st bar + 1, 1, 2, 2, + // 2nd bar + 3, 3, 4, 4, + // 3rd bar + 5, 5, 4, 4, + // 3rd bar (repeated) + 5, 5, 6, 6, + // 4th bar + 7, 7, 8, 8, + // 5th bar + 9, 9, -1, -1 + ], + [ + // 1st bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 2nd bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 3rd bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + // 3rd bar (repeated) + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 4th bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 5th bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat + ], + true + ); }); describe('playback-range', () => {