Skip to content

Commit c6e059c

Browse files
committed
feat: animate cursor to playback range end and allow custom cursor handlers (#2536)
(cherry picked from commit 18f1253)
1 parent f02bb67 commit c6e059c

6 files changed

Lines changed: 601 additions & 119 deletions

File tree

packages/alphatab/src/AlphaTabApiBase.ts

Lines changed: 170 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import { AlphaTexImporter } from '@coderline/alphatab/importer/AlphaTexImporter';
1111
import { Logger } from '@coderline/alphatab/Logger';
1212
import { AlphaSynthMidiFileHandler } from '@coderline/alphatab/midi/AlphaSynthMidiFileHandler';
13-
import type { BeatTickLookupItem, IBeatVisibilityChecker } from '@coderline/alphatab/midi/BeatTickLookup';
13+
import type { IBeatVisibilityChecker } from '@coderline/alphatab/midi/BeatTickLookup';
1414
import type {
1515
MetaDataEvent,
1616
MetaEvent,
@@ -42,16 +42,20 @@ import {
4242
MidiTickLookupFindBeatResultCursorMode
4343
} from '@coderline/alphatab/midi/MidiTickLookup';
4444

45+
import {
46+
type ICursorHandler,
47+
NonAnimatingCursorHandler,
48+
ToNextBeatAnimatingCursorHandler
49+
} from '@coderline/alphatab/CursorHandler';
4550
import type { Beat } from '@coderline/alphatab/model/Beat';
4651
import { ModelUtils } from '@coderline/alphatab/model/ModelUtils';
4752
import type { Note } from '@coderline/alphatab/model/Note';
4853
import type { Score } from '@coderline/alphatab/model/Score';
4954
import type { Track } from '@coderline/alphatab/model/Track';
50-
import { PlayerMode, ScrollMode } from '@coderline/alphatab/PlayerSettings';
5155
import type { IContainer } from '@coderline/alphatab/platform/IContainer';
5256
import type { IMouseEventArgs } from '@coderline/alphatab/platform/IMouseEventArgs';
5357
import type { IUiFacade } from '@coderline/alphatab/platform/IUiFacade';
54-
import { ResizeEventArgs } from '@coderline/alphatab/ResizeEventArgs';
58+
import { PlayerMode, ScrollMode } from '@coderline/alphatab/PlayerSettings';
5559
import { BeatContainerGlyph } from '@coderline/alphatab/rendering/glyphs/BeatContainerGlyph';
5660
import type { IScoreRenderer, RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer';
5761
import type { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs';
@@ -62,6 +66,7 @@ import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds';
6266
import type { BoundsLookup } from '@coderline/alphatab/rendering/utils/BoundsLookup';
6367
import type { MasterBarBounds } from '@coderline/alphatab/rendering/utils/MasterBarBounds';
6468
import type { StaffSystemBounds } from '@coderline/alphatab/rendering/utils/StaffSystemBounds';
69+
import { ResizeEventArgs } from '@coderline/alphatab/ResizeEventArgs';
6570
import {
6671
HorizontalContinuousScrollHandler,
6772
HorizontalOffScreenScrollHandler,
@@ -88,6 +93,7 @@ import type { PlaybackRangeChangedEventArgs } from '@coderline/alphatab/synth/Pl
8893
import { PlayerState } from '@coderline/alphatab/synth/PlayerState';
8994
import type { PlayerStateChangedEventArgs } from '@coderline/alphatab/synth/PlayerStateChangedEventArgs';
9095
import type { PositionChangedEventArgs } from '@coderline/alphatab/synth/PositionChangedEventArgs';
96+
import { Cursors } from '@coderline/alphatab/platform/Cursors';
9197

9298
/**
9399
* @internal
@@ -166,6 +172,8 @@ export class AlphaTabApiBase<TSettings> {
166172
private _renderer: ScoreRendererWrapper;
167173

168174
private _defaultScrollHandler?: IScrollHandler;
175+
private _defaultCursorHandler?: ICursorHandler;
176+
private _customCursorHandler?: ICursorHandler;
169177

170178
/**
171179
* An indicator by how many midi-ticks the song contents are shifted.
@@ -988,6 +996,88 @@ export class AlphaTabApiBase<TSettings> {
988996
}
989997
}
990998

999+
/**
1000+
* A custom cursor handler which will be used to update the cursor positions during playback.
1001+
*
1002+
* @category Properties - Player
1003+
* @since 1.8.1
1004+
* @example
1005+
* JavaScript
1006+
* ```js
1007+
* const api = new alphaTab.AlphaTabApi(document.querySelector('#alphaTab'));
1008+
* api.customCursorHandler = {
1009+
* _customAdorner: undefined,
1010+
* onAttach(cursors) {
1011+
* this._customAdorner = document.createElement('div');
1012+
* this._customAdorner.classList.add('cursor-adorner');
1013+
* cursors.cursorWrapper.element.appendChild(this._customAdorner);
1014+
* },
1015+
* onDetach(cursors) { this._customAdorner.remove(); },
1016+
* placeBarCursor(barCursor, beatBounds) {
1017+
* const barBoundings = beatBounds.barBounds.masterBarBounds;
1018+
* const barBounds = barBoundings.visualBounds;
1019+
* barCursor.setBounds(barBounds.x, barBounds.y, barBounds.w, barBounds.h);
1020+
* },
1021+
* placeBeatCursor(beatCursor, beatBounds, startBeatX) {
1022+
* const barBoundings = beatBounds.barBounds.masterBarBounds;
1023+
* const barBounds = barBoundings.visualBounds;
1024+
* beatCursor.transitionToX(0, startBeatX);
1025+
* beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h);
1026+
* this._customAdorner.style.left = startBeatX + 'px';
1027+
* this._customAdorner.style.top = (barBounds.y - 10) + 'px';
1028+
* this._customAdorner.style.width = '1px';
1029+
* this._customAdorner.style.height = '10px';
1030+
* this._customAdorner.style.transition = 'left 0ms linear'; // stop animation
1031+
* },
1032+
* transitionBeatCursor(beatCursor, beatBounds, startBeatX, endBeatX, duration, cursorMode) {
1033+
* this._customAdorner.style.transition = `left ${duration}ms linear`; // start animation
1034+
* this._customAdorner.style.left = endBeatX + 'px';
1035+
* }
1036+
* }
1037+
* ```
1038+
*
1039+
* @example
1040+
* C#
1041+
* ```cs
1042+
* var api = new AlphaTabApi<MyControl>(...);
1043+
* api.CustomCursorHandler = new CustomCursorHandler();
1044+
* ```
1045+
*
1046+
* @example
1047+
* Android
1048+
* ```kotlin
1049+
* val api = AlphaTabApi<MyControl>(...)
1050+
* api.customCursorHandler = CustomCursorHandler();
1051+
* ```
1052+
*/
1053+
public get customCursorHandler(): ICursorHandler | undefined {
1054+
return this._customCursorHandler;
1055+
}
1056+
1057+
public set customCursorHandler(value: ICursorHandler | undefined) {
1058+
if (this._customCursorHandler === value) {
1059+
return;
1060+
}
1061+
const currentHandler = this._customCursorHandler ?? this._defaultCursorHandler;
1062+
1063+
this._customCursorHandler = value;
1064+
if (this._cursorWrapper) {
1065+
const cursors = new Cursors(
1066+
this._cursorWrapper,
1067+
this._barCursor!,
1068+
this._beatCursor!,
1069+
this._selectionWrapper!
1070+
);
1071+
1072+
currentHandler?.onDetach(cursors);
1073+
if (value) {
1074+
value?.onDetach(cursors);
1075+
} else if (this._defaultCursorHandler) {
1076+
this._defaultCursorHandler!.onAttach(cursors);
1077+
}
1078+
}
1079+
}
1080+
9911081
private _tickCache: MidiTickLookup | null = null;
9921082

9931083
/**
@@ -1487,6 +1577,9 @@ export class AlphaTabApiBase<TSettings> {
14871577

14881578
public set playbackRange(value: PlaybackRange | null) {
14891579
this._player.playbackRange = value;
1580+
if (this._tickCache) {
1581+
this._tickCache.playbackRange = value;
1582+
}
14901583
this._updateSelectionCursor(value);
14911584
}
14921585

@@ -1650,6 +1743,8 @@ export class AlphaTabApiBase<TSettings> {
16501743

16511744
generator.generate();
16521745
this._tickCache = generator.tickLookup;
1746+
this._tickCache.playbackRange = this.playbackRange;
1747+
16531748
this._onMidiLoad(midiFile);
16541749

16551750
const player = this._player;
@@ -2070,6 +2165,10 @@ export class AlphaTabApiBase<TSettings> {
20702165
if (!this._cursorWrapper) {
20712166
return;
20722167
}
2168+
const cursorHandler = this.customCursorHandler ?? this._defaultCursorHandler!;
2169+
cursorHandler?.onDetach(
2170+
new Cursors(this._cursorWrapper, this._barCursor!, this._beatCursor!, this._selectionWrapper!)
2171+
);
20732172
this.uiFacade.destroyCursors();
20742173
this._cursorWrapper = null;
20752174
this._barCursor = null;
@@ -2088,6 +2187,9 @@ export class AlphaTabApiBase<TSettings> {
20882187
this._barCursor = cursors.barCursor;
20892188
this._beatCursor = cursors.beatCursor;
20902189
this._selectionWrapper = cursors.selectionWrapper;
2190+
const cursorHandler = this.customCursorHandler ?? this._defaultCursorHandler!;
2191+
cursorHandler?.onAttach(cursors);
2192+
20912193
this._isInitialBeatCursorUpdate = true;
20922194
}
20932195
if (this._currentBeat !== null) {
@@ -2096,6 +2198,7 @@ export class AlphaTabApiBase<TSettings> {
20962198
}
20972199

20982200
private _updateCursors() {
2201+
this._updateCursorHandler();
20992202
this._updateScrollHandler();
21002203

21012204
const enable = this._hasCursor;
@@ -2106,6 +2209,23 @@ export class AlphaTabApiBase<TSettings> {
21062209
}
21072210
}
21082211

2212+
private _cursorHandlerMode = false;
2213+
private _updateCursorHandler() {
2214+
const currentHandler = this._defaultCursorHandler;
2215+
2216+
const cursorHandlerMode = this.settings.player.enableAnimatedBeatCursor;
2217+
// no change
2218+
if (currentHandler !== undefined && this._cursorHandlerMode === cursorHandlerMode) {
2219+
return;
2220+
}
2221+
2222+
if (cursorHandlerMode) {
2223+
this._defaultCursorHandler = new ToNextBeatAnimatingCursorHandler();
2224+
} else {
2225+
this._defaultCursorHandler = new NonAnimatingCursorHandler();
2226+
}
2227+
}
2228+
21092229
private _scrollHandlerMode = ScrollMode.Off;
21102230
private _scrollHandlerVertical = true;
21112231
private _updateScrollHandler() {
@@ -2199,10 +2319,6 @@ export class AlphaTabApiBase<TSettings> {
21992319
forceUpdate: boolean = false
22002320
): void {
22012321
const beat: Beat = lookupResult.beat;
2202-
const nextBeat: Beat | null = lookupResult.nextBeat?.beat ?? null;
2203-
const duration: number = lookupResult.duration;
2204-
const beatsToHighlight = lookupResult.beatLookup.highlightedBeats;
2205-
22062322
if (!beat) {
22072323
return;
22082324
}
@@ -2235,18 +2351,7 @@ export class AlphaTabApiBase<TSettings> {
22352351
this._previousStateForCursor = this._player.state;
22362352

22372353
this.uiFacade.beginInvoke(() => {
2238-
this._internalCursorUpdateBeat(
2239-
beat,
2240-
nextBeat,
2241-
duration,
2242-
stop,
2243-
beatsToHighlight,
2244-
cache!,
2245-
beatBoundings!,
2246-
shouldScroll,
2247-
lookupResult.cursorMode,
2248-
cursorSpeed
2249-
);
2354+
this._internalCursorUpdateBeat(lookupResult, stop, cache!, beatBoundings!, shouldScroll, cursorSpeed);
22502355
});
22512356
}
22522357

@@ -2266,19 +2371,22 @@ export class AlphaTabApiBase<TSettings> {
22662371
}
22672372

22682373
private _internalCursorUpdateBeat(
2269-
beat: Beat,
2270-
nextBeat: Beat | null,
2271-
duration: number,
2374+
lookupResult: MidiTickLookupFindBeatResult,
22722375
stop: boolean,
2273-
beatsToHighlight: BeatTickLookupItem[],
2274-
cache: BoundsLookup,
2376+
boundsLookup: BoundsLookup,
22752377
beatBoundings: BeatBounds,
22762378
shouldScroll: boolean,
2277-
cursorMode: MidiTickLookupFindBeatResultCursorMode,
22782379
cursorSpeed: number
22792380
) {
2280-
const barCursor = this._barCursor;
2381+
const beat = lookupResult.beat;
2382+
const nextBeat = lookupResult.nextBeat?.beat;
2383+
let duration = lookupResult.duration;
2384+
const beatsToHighlight = lookupResult.beatLookup.highlightedBeats;
2385+
const cursorMode = lookupResult.cursorMode;
2386+
const cursorHandler = this.customCursorHandler ?? this._defaultCursorHandler!;
2387+
22812388
const beatCursor = this._beatCursor;
2389+
const barCursor = this._barCursor;
22822390

22832391
const barBoundings: MasterBarBounds = beatBoundings.barBounds.masterBarBounds;
22842392
const barBounds: Bounds = barBoundings.visualBounds;
@@ -2287,18 +2395,18 @@ export class AlphaTabApiBase<TSettings> {
22872395
this._currentBeatBounds = beatBoundings;
22882396

22892397
if (barCursor) {
2290-
barCursor.setBounds(barBounds.x, barBounds.y, barBounds.w, barBounds.h);
2398+
cursorHandler.placeBarCursor(barCursor, beatBoundings);
22912399
}
22922400

22932401
const isPlayingUpdate = this._player.state === PlayerState.Playing && !stop;
22942402

2295-
let nextBeatX: number = barBoundings.visualBounds.x + barBoundings.visualBounds.w;
2403+
let nextBeatX: number = beatBoundings.realBounds.x + beatBoundings.realBounds.w;
22962404
let nextBeatBoundings: BeatBounds | null = null;
22972405
// get position of next beat on same system
22982406
if (nextBeat && cursorMode === MidiTickLookupFindBeatResultCursorMode.ToNextBext) {
22992407
// if we are moving within the same bar or to the next bar
23002408
// transition to the next beat, otherwise transition to the end of the bar.
2301-
nextBeatBoundings = cache.findBeat(nextBeat);
2409+
nextBeatBoundings = boundsLookup.findBeat(nextBeat);
23022410
if (
23032411
nextBeatBoundings &&
23042412
nextBeatBoundings.barBounds.masterBarBounds.staffSystemBounds === barBoundings.staffSystemBounds
@@ -2309,52 +2417,42 @@ export class AlphaTabApiBase<TSettings> {
23092417

23102418
let startBeatX = beatBoundings.onNotesX;
23112419
if (beatCursor) {
2312-
// relative positioning of the cursor
2313-
if (this.settings.player.enableAnimatedBeatCursor) {
2314-
const animationWidth = nextBeatX - beatBoundings.onNotesX;
2315-
const relativePosition = this._previousTick - this._currentBeat!.start;
2316-
const ratioPosition =
2317-
this._currentBeat!.tickDuration > 0 ? relativePosition / this._currentBeat!.tickDuration : 0;
2318-
startBeatX = beatBoundings.onNotesX + animationWidth * ratioPosition;
2319-
duration -= duration * ratioPosition;
2320-
2321-
if (isPlayingUpdate) {
2322-
// we do not "reset" the cursor if we are smoothly moving from left to right.
2323-
const jumpCursor =
2324-
!previousBeatBounds ||
2325-
this._isInitialBeatCursorUpdate ||
2326-
barBounds.y !== previousBeatBounds.barBounds.masterBarBounds.visualBounds.y ||
2327-
startBeatX < previousBeatBounds.onNotesX ||
2328-
barBoundings.index > previousBeatBounds.barBounds.masterBarBounds.index + 1;
2329-
2330-
if (jumpCursor) {
2331-
beatCursor.transitionToX(0, startBeatX);
2332-
beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h);
2333-
}
2334-
2335-
// it can happen that the cursor reaches the target position slightly too early (especially on backing tracks)
2336-
// to avoid the cursor stopping, causing a wierd look, we animate the cursor to the double position in double time.
2337-
// beatCursor!.transitionToX((duration / cursorSpeed), nextBeatX);
2338-
const factor = cursorMode === MidiTickLookupFindBeatResultCursorMode.ToNextBext ? 2 : 1;
2339-
nextBeatX = startBeatX + (nextBeatX - startBeatX) * factor;
2340-
duration = (duration / cursorSpeed) * factor;
2341-
2342-
// we need to put the transition to an own animation frame
2343-
// otherwise the stop animation above is not applied.
2344-
this.uiFacade.beginInvoke(() => {
2345-
beatCursor!.transitionToX(duration, nextBeatX);
2346-
});
2347-
} else {
2348-
duration = 0;
2349-
beatCursor.transitionToX(duration, nextBeatX);
2350-
beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h);
2420+
const animationWidth = nextBeatX - beatBoundings.onNotesX;
2421+
const relativePosition = this._previousTick - this._currentBeat!.start;
2422+
const ratioPosition =
2423+
this._currentBeat!.tickDuration > 0 ? relativePosition / this._currentBeat!.tickDuration : 0;
2424+
startBeatX = beatBoundings.onNotesX + animationWidth * ratioPosition;
2425+
duration -= duration * ratioPosition;
2426+
2427+
// respect speed
2428+
duration = duration / cursorSpeed;
2429+
2430+
if (isPlayingUpdate) {
2431+
// we do not "reset" the cursor if we are smoothly moving from left to right.
2432+
const jumpCursor =
2433+
!previousBeatBounds ||
2434+
this._isInitialBeatCursorUpdate ||
2435+
barBounds.y !== previousBeatBounds.barBounds.masterBarBounds.visualBounds.y ||
2436+
startBeatX < previousBeatBounds.onNotesX ||
2437+
barBoundings.index > previousBeatBounds.barBounds.masterBarBounds.index + 1;
2438+
2439+
if (jumpCursor) {
2440+
cursorHandler.placeBeatCursor(beatCursor, beatBoundings, startBeatX);
23512441
}
2442+
2443+
this.uiFacade.beginInvoke(() => {
2444+
cursorHandler.transitionBeatCursor(
2445+
beatCursor,
2446+
beatBoundings,
2447+
startBeatX,
2448+
nextBeatX,
2449+
duration,
2450+
cursorMode
2451+
);
2452+
});
23522453
} else {
2353-
// ticking cursor
23542454
duration = 0;
2355-
nextBeatX = startBeatX;
2356-
beatCursor.transitionToX(duration, nextBeatX);
2357-
beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h);
2455+
cursorHandler.placeBeatCursor(beatCursor, beatBoundings, startBeatX);
23582456
}
23592457

23602458
this._isInitialBeatCursorUpdate = false;

0 commit comments

Comments
 (0)